/* * http_handlers.cpp - HTTP 请求处理器实现 */ #include #include #include #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" #include "esp_http_server.h" #include "esp_wifi.h" #include "esp_netif.h" #include "esp_system.h" #include "esp_timer.h" #include "esp_ota_ops.h" #include "esp_task_wdt.h" #include "esp_heap_caps.h" #include "esp_rom_sys.h" #include "soc/soc.h" #include "app_config.h" #include "http_handlers.h" #include "http_util.h" #include "json_parse.h" #include "wifi_ota.h" #include "esp32_ota_core.h" #include "ota_coordinator.h" #include "ota_relay.h" #include "storage/gd32_flash_store.h" #include "health_monitor.h" #include "monitor/debug_log.h" #include "monitor/cpu_stats.h" #include "spi_master.h" #include "canfd_bridge.h" #include "monitor/can_feedback_task.h" #include "monitor/gd32_adc_feedback.h" #include "monitor/motor_state.h" #include "monitor/motor_version.h" #include "monitor/motor_ota_relay.h" #include "monitor/task_stack.h" #include "ble_ota.h" #include "system_boot.h" /** * @brief 统一 JSON 错误响应(避免前端 resp.json() 被 httpd 明文坑)。 * @param http_status 如 "400 Bad Request" / "403 Forbidden" / "500 Internal Server Error" * @param code 机器可读错误码(snake_case) * @param message 人类可读说明(勿含未转义双引号) */ static esp_err_t http_send_json_error(httpd_req_t *req, const char *http_status, const char *code, const char *message) { char body[320]; snprintf(body, sizeof(body), "{\"status\":\"error\",\"code\":\"%s\",\"message\":\"%s\"}", code ? code : "error", message ? message : "error"); httpd_resp_set_status(req, http_status ? http_status : "500 Internal Server Error"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, body, HTTPD_RESP_USE_STRLEN); return ESP_FAIL; } /** @brief OTA 收流:堆紧时让 LWIP 回收,再 httpd_req_recv。 */ static void otaUploadPaceIfHeapTight(void) { const uint32_t free_pre = esp_get_free_heap_size(); if (free_pre < 8192U) { (void)esp_task_wdt_reset(); vTaskDelay(pdMS_TO_TICKS(free_pre < 4096U ? 5U : 1U)); } } void http_set_cors_headers(httpd_req_t *req) { httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, OPTIONS"); httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); httpd_resp_set_hdr(req, "Access-Control-Max-Age", "600"); } /* 显式声明 keep-alive,让浏览器明确复用 TCP 连接。 * ESP-IDF httpd 默认 HTTP/1.1 持久连接,但不发此头时部分客户端/代理 * 按 HTTP/1.0 语义关闭。4Hz motor 轮询每次新建 TCP 握手 ~10~30ms * 纯开销;keep-alive 后复用同一 TCP,省全部握手时间。 * * 不放在 http_set_cors_headers 里:OTA 上传/relay handler 需要 Connection: close * (设备即将重启),它们仍调 cors_headers 但不应加 keep-alive,否则会与 * httpd_resp_set_hdr("Connection","close") 产生重复头。 */ void http_set_keepalive_hdr(httpd_req_t *req) { httpd_resp_set_hdr(req, "Connection", "keep-alive"); } esp_err_t http_options_handler(httpd_req_t *req) { http_set_cors_headers(req); httpd_resp_set_status(req, "204 No Content"); httpd_resp_send(req, NULL, 0); return ESP_OK; } esp_err_t esp32_ota_update_handler(httpd_req_t *req) { http_set_cors_headers(req); Esp32OtaCore &core = Esp32OtaCore::instance(); if (!core.isActive()) { /* 常见场景:finalize 已成功并重启 → 新固件无 OTA 会话 → 迟到的 /ota/update。 */ return http_send_json_error(req, "400 Bad Request", "ota_not_started", "OTA not started"); } char frontend_sha_hex[65] = {0}; bool has_frontend_sha = false; bool verify_only = false; if (req->content_len > 0 && req->content_len < 256) { char buf[256] = {0}; int ret = httpd_req_recv(req, buf, req->content_len); if (ret > 0) { buf[ret] = '\0'; JsonView root = jsonParse(buf); if (root.valid) { JsonView sha_item = jsonGet(&root, "sha256"); if (jsonIsString(&sha_item) && sha_item.str_len == 64) { jsonGetStringCopy(&sha_item, frontend_sha_hex, 65); has_frontend_sha = true; DEBUG_INFO("OTA", "Frontend SHA-256: %s", frontend_sha_hex); } JsonView verify_item = jsonGet(&root, "verify_only"); if (jsonIsBool(&verify_item)) { verify_only = jsonIsTrue(&verify_item); } } } } char device_sha_hex[65] = {0}; if (verify_only) { DEBUG_INFO("OTA", "ESP32 OTA verify-only mode, %u bytes", (unsigned)core.getReceived()); esp_err_t sha_err = core.computeDeviceSha(device_sha_hex); if (sha_err != ESP_OK) { DEBUG_ERR("OTA", "verify-only: SHA 计算失败: %s", esp_err_to_name(sha_err)); return http_send_json_error(req, "500 Internal Server Error", "sha_compute_failed", esp_err_to_name(sha_err)); } char ok_resp[256] = {0}; snprintf(ok_resp, sizeof(ok_resp), "{\"status\": \"ok\", \"message\": \"SHA-256 computed\", \"sha256\": \"%s\"}", device_sha_hex); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, ok_resp, strlen(ok_resp)); return ESP_OK; } DEBUG_INFO("OTA", "ESP32 OTA finalize, %u bytes...", (unsigned)core.getReceived()); Esp32FinalizeResult fr = OtaCoordinator::instance().finalizeForReboot( has_frontend_sha ? frontend_sha_hex : nullptr); if (fr.err == ESP_OK) { char resp[320] = {0}; snprintf(resp, sizeof(resp), "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", " "\"sha256\": \"%s\"}", fr.device_sha_hex); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); DEBUG_INFO("OTA", "响应已发送,等待 TCP 刷新..."); vTaskDelay(pdMS_TO_TICKS(100)); OtaCoordinator::instance().restartEsp32(); return ESP_OK; } if (fr.err == ESP_ERR_INVALID_VERSION) { char err_resp[256] = {0}; snprintf(err_resp, sizeof(err_resp), "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", \"frontend\": \"%s\", \"device\": \"%s\"}", frontend_sha_hex, fr.device_sha_hex); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, err_resp, strlen(err_resp)); } else { return http_send_json_error(req, "500 Internal Server Error", "finalize_failed", esp_err_to_name(fr.err)); } return ESP_FAIL; } esp_err_t restart_handler(httpd_req_t *req) { (void)req; http_set_cors_headers(req); DEBUG_INFO("OTA", "收到重启请求,重启..."); const char resp[] = "{\"status\": \"ok\", \"message\": \"rebooting\"}"; httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); OtaCoordinator::instance().restartEsp32(); return ESP_OK; } static int recv_json_header(httpd_req_t *req, char *buf, int buf_size) { int total = 0; int depth = 1; while (total < buf_size - 1) { int ret = httpd_req_recv(req, buf + total, 1); if (ret <= 0) return total; total += ret; buf[total] = '\0'; if (buf[total - 1] == '{') depth++; else if (buf[total - 1] == '}') { depth--; if (depth == 0) return total; } } return total; } /* OTA 上传静态收块:避免 OTA 中途再 malloc(2KB) 与 TCP 窗口抢堆。 */ static uint8_t s_ota_upload_recv[OTA_BLOCK_SIZE]; static uint8_t s_ota_json_scratch[256]; static uint8_t s_ota_preread[256]; esp_err_t ota_upload_handler(httpd_req_t *req) { http_set_cors_headers(req); /* chunked 请求(无 Content-Length 头)时 IDF 置 content_len=-1: * ESP32 分支无符号比较使收流循环失去长度上界,GD32 分支则一字节 * 不写却返回 progress:100 假成功——必须前置拒绝 */ if (req->content_len <= 0) { return http_send_json_error(req, "400 Bad Request", "bad_content_len", "missing or invalid Content-Length"); } uint8_t *recv_buf = s_ota_upload_recv; DEBUG_WARN("WIFI", "POST /ota/upload clen=%d free=%lu big=%lu", req->content_len, (unsigned long)esp_get_free_heap_size(), (unsigned long)heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); DEBUG_INFO("WIFI", "收到固件上传请求, 内容长度: %d 字节", req->content_len); esp_wifi_set_ps(WIFI_PS_NONE); // WiFi OTA 开始时恢复 BLE 共存状态(通过协调器) OtaCoordinator::instance().notifyBleCoexEnd(); char peek; int peek_ret = httpd_req_recv(req, &peek, 1); if (peek_ret <= 0) { return http_send_json_error(req, "500 Internal Server Error", "recv_failed", "receive failed"); } bool is_esp32_ota = false; uint32_t total_size = 0; int json_header_len = 0; /* 静态 scratch:OTA 期间少两次 256B heap 分配 */ uint8_t *pre_read_buf = s_ota_preread; memset(pre_read_buf, 0, 256); int pre_read_len = 1; pre_read_buf[0] = (uint8_t)peek; if (peek == '{') { char *json_buf = (char *)s_ota_json_scratch; memset(json_buf, 0, 256); json_buf[0] = peek; json_header_len = recv_json_header(req, json_buf + 1, 255); if (json_header_len > 0) { json_header_len++; json_buf[json_header_len] = '\0'; pre_read_len = json_header_len; if (pre_read_len > 256) pre_read_len = 256; memcpy(pre_read_buf, json_buf, pre_read_len); JsonView root = jsonParse(json_buf); if (root.valid) { JsonView type_item = jsonGet(&root, "type"); if (jsonStringEquals(&type_item, "esp32")) { is_esp32_ota = true; } JsonView size_item = jsonGet(&root, "total_size"); if (size_item.valid) { total_size = (uint32_t)jsonGetNumberValue(&size_item); } } } } if (is_esp32_ota) { if (total_size == 0) { return http_send_json_error(req, "400 Bad Request", "missing_total_size", "missing total_size"); } /* ESP32 路径不再需要 pre_read(JSON 已消费,固件从流继续读) */ pre_read_len = 0; esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size, OTA_CHANNEL_WIFI); if (err != ESP_OK) { if (err == ESP_ERR_INVALID_STATE) { return http_send_json_error(req, "403 Forbidden", "ota_occupied", "ESP32 OTA occupied by another channel"); } return http_send_json_error(req, "500 Internal Server Error", "ota_begin_failed", "OTA begin failed"); } Esp32OtaCore &core = Esp32OtaCore::instance(); uint8_t *fw_buf = recv_buf; // ★ 捕获当前 epoch,用于失败清理时检查会话是否仍属于本 handler // 若同类型重启已发生(新请求接管),旧 handler 的 abortCurrentIfEpoch 不会中止新会话 uint32_t my_epoch = OtaCoordinator::instance().getOtaEpoch(); size_t total_firmware_written = 0; size_t total_received = json_header_len; DEBUG_INFO("WIFI", "ESP32收流 clen=%d hdr=%d exp=%d", req->content_len, json_header_len, (req->content_len > json_header_len) ? (req->content_len - json_header_len) : 0); /* 浏览器 upload 进度到 100% 只表示 TCP 发完;设备仍在 recv+Flash。 * sequential 擦写期间仍需让 LWIP 有机会跑;堆紧时 pacing。 */ const int64_t t_recv0 = esp_timer_get_time(); uint32_t write_chunks = 0U; while (total_received < (size_t)req->content_len) { size_t to_read = (size_t)req->content_len - total_received; if (to_read > OTA_BLOCK_SIZE) { to_read = OTA_BLOCK_SIZE; } otaUploadPaceIfHeapTight(); int ret = httpd_req_recv(req, (char *)fw_buf, to_read); if (ret <= 0) { DEBUG_ERR("WIFI", "HTTP recv fail ret=%d OTA %u/%d free=%lu", ret, (unsigned)total_received, req->content_len, (unsigned long)esp_get_free_heap_size()); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); return http_send_json_error(req, "500 Internal Server Error", "http_recv_failed", "HTTP recv failed"); } err = core.write(fw_buf, (size_t)ret); if (err != ESP_OK) { DEBUG_ERR("WIFI", "OTA write failed: %s @%u free=%lu", esp_err_to_name(err), (unsigned)total_firmware_written, (unsigned long)esp_get_free_heap_size()); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); return http_send_json_error(req, "500 Internal Server Error", "ota_write_failed", "OTA write failed"); } total_firmware_written += (size_t)ret; total_received += (size_t)ret; ++write_chunks; (void)esp_task_wdt_reset(); } /* 回包前不打 INFO:堆紧时 DEBUG_INFO→UDP 会与 httpd_resp_send 抢 TX pbuf。 * 先让 LWIP 回收,成功后再记一条。 */ vTaskDelay(pdMS_TO_TICKS(10)); (void)esp_task_wdt_reset(); taskYIELD(); const char resp[] = "{\"status\": \"ok\", \"message\": \"stored, call POST /ota/update to finish\", " "\"target\": \"esp32\"}"; httpd_resp_set_type(req, "application/json"); httpd_resp_set_hdr(req, "Connection", "close"); esp_err_t send_err = httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN); if (send_err != ESP_OK) { DEBUG_ERR("WIFI", "ESP32 OTA回包失败: %s free=%lu", esp_err_to_name(send_err), (unsigned long)esp_get_free_heap_size()); /* 再试一次极简回包 */ send_err = httpd_resp_send(req, "{\"status\":\"ok\",\"target\":\"esp32\"}", HTTPD_RESP_USE_STRLEN); if (send_err != ESP_OK) { DEBUG_ERR("WIFI", "ESP32 OTA 简短响应仍失败: %s", esp_err_to_name(send_err)); } } else { DEBUG_INFO("WIFI", "ESP32存完 %uB %lldms free=%lu", (unsigned int)core.getReceived(), (long long)((esp_timer_get_time() - t_recv0) / 1000), (unsigned long)esp_get_free_heap_size()); } return (send_err == ESP_OK) ? ESP_OK : ESP_FAIL; } const uint32_t gd32_fw_size = (uint32_t)req->content_len; uint32_t gd32_valid_size; if (json_header_len > 0 && (uint32_t)json_header_len < gd32_fw_size) { gd32_valid_size = (total_size > 0 && total_size < gd32_fw_size) ? total_size : (gd32_fw_size - (uint32_t)json_header_len); } else { gd32_valid_size = gd32_fw_size; } if (OtaRelay::getState() != RELAY_IDLE) { DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::getState()); /* 上次失败可能残留本端状态;同时尽力 CANCEL 远端半会话 */ OtaRelay::instance().abortRemoteAndReset(true); } // 与 BLE 共用 prepareGd32Storage(内含 validateGd32Size + begin + erase + meta) esp_err_t prep_err = OtaCoordinator::instance().prepareGd32Storage(gd32_valid_size, OTA_CHANNEL_WIFI); if (prep_err == ESP_ERR_INVALID_SIZE) { DEBUG_ERR("WIFI", "firmware size invalid: valid=%u max=%u", (unsigned)gd32_valid_size, (unsigned)OTA_MAX_FIRMWARE_SIZE); return http_send_json_error(req, "500 Internal Server Error", "firmware_too_large", "firmware too large or alignment overflow"); } else if (prep_err == ESP_ERR_INVALID_STATE) { DEBUG_ERR("WIFI", "GD32 OTA 协调器拒绝存储(系统忙)"); return http_send_json_error(req, "403 Forbidden", "system_busy", "system busy with another OTA operation"); } else if (prep_err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 OTA 准备失败: %s", esp_err_to_name(prep_err)); return http_send_json_error(req, "500 Internal Server Error", "prepare_failed", esp_err_to_name(prep_err)); } // ★ 捕获当前 epoch,用于失败清理时检查会话是否仍属于本 handler uint32_t my_epoch = OtaCoordinator::instance().getOtaEpoch(); uint8_t *buf = recv_buf; int received = 0; // expected_fw_size_raw 仅用于后续日志;meta 已由 prepareGd32Storage 写入 uint32_t expected_fw_size_raw = gd32_valid_size; uint32_t gd32_offset = 0; DEBUG_INFO("WIFI", "GD32 raw upload start: %u bytes", expected_fw_size_raw); if (json_header_len == 0 && pre_read_len > 0) { int first_chunk_len = pre_read_len; if (first_chunk_len > req->content_len) first_chunk_len = req->content_len; if (first_chunk_len > 0) { gd32_offset = 0; esp_err_t err = OtaCoordinator::instance().writeGd32Chunk(gd32_offset, pre_read_buf, (size_t)first_chunk_len); if (err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 first chunk write failed: len=%d err=%s", first_chunk_len, esp_err_to_name(err)); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); return http_send_json_error(req, "500 Internal Server Error", "gd32_write_failed", "write gd32_ota failed"); } received = first_chunk_len; gd32_offset += (uint32_t)first_chunk_len; OtaCoordinator::instance().updateGd32Meta(gd32_offset, (uint32_t)(gd32_offset / OTA_BLOCK_SIZE)); } } else { received = pre_read_len; } while (received < req->content_len) { size_t to_read = req->content_len - received; if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; otaUploadPaceIfHeapTight(); int ret = httpd_req_recv(req, (char *)buf, to_read); if (ret <= 0) { DEBUG_ERR("WIFI", "GD32 upload interrupted: received=%d expected=%d", received, req->content_len); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); return http_send_json_error(req, "500 Internal Server Error", "upload_interrupted", "upload interrupted"); } esp_err_t err = OtaCoordinator::instance().writeGd32Chunk(gd32_offset, buf, (size_t)ret); if (err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 write failed: offset=%u len=%d err=%s", (unsigned)gd32_offset, ret, esp_err_to_name(err)); OtaCoordinator::instance().abortCurrentIfEpoch(my_epoch); return http_send_json_error(req, "500 Internal Server Error", "gd32_write_failed", "write gd32_ota failed"); } received += ret; gd32_offset += (uint32_t)ret; (void)esp_task_wdt_reset(); } OtaCoordinator::instance().updateGd32Meta(gd32_offset, (uint32_t)(gd32_offset / OTA_BLOCK_SIZE)); ota_meta_t final_meta; OtaCoordinator::instance().loadGd32Meta(&final_meta); if (final_meta.total_size > 0 && final_meta.stored_size >= final_meta.total_size) { /* 先让出再算 SHA;回包前不打 INFO,避免与 httpd_resp_send 抢堆 */ vTaskDelay(pdMS_TO_TICKS(10)); (void)esp_task_wdt_reset(); // 与 BLE 共用 finishGd32StorageAfterReceive:complete + SHA esp_err_t sha_err = OtaCoordinator::instance().finishGd32StorageAfterReceive(); if (sha_err != ESP_OK) { /* 存盘已完成,仍回 status=ok;relay 阶段会再比前端 SHA。 * 此处必须醒目告警,避免只当 INFO 被忽略。 */ DEBUG_WARN("WIFI", "GD32 SHA预算失败:%s (relay再校)", esp_err_to_name(sha_err)); } } const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\", " "\"message\": \"stored, call POST /ota/relay with sha256 to trigger\"}"; httpd_resp_set_type(req, "application/json"); httpd_resp_set_hdr(req, "Connection", "close"); esp_err_t send_err = httpd_resp_send(req, resp, strlen(resp)); if (send_err != ESP_OK) { DEBUG_ERR("WIFI", "GD32 OTA回包失败: %s free=%lu", esp_err_to_name(send_err), (unsigned long)esp_get_free_heap_size()); } else { DEBUG_INFO("WIFI", "GD32 OTA 存储+响应完成: %u / %u free=%lu", (unsigned)gd32_offset, expected_fw_size_raw, (unsigned long)esp_get_free_heap_size()); } return (send_err == ESP_OK) ? ESP_OK : ESP_FAIL; } esp_err_t ota_relay_trigger_handler(httpd_req_t *req) { http_set_cors_headers(req); DEBUG_INFO("WIFI", "=== HTTP: POST /ota/relay ==="); DEBUG_INFO("WIFI", "收到触发 OTA 中继请求"); ota_meta_t meta; memset(&meta, 0, sizeof(meta)); if (OtaCoordinator::instance().loadGd32Meta(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) { return http_send_json_error(req, "400 Bad Request", "firmware_not_stored", "firmware not fully stored"); } char frontend_sha_hex[65] = {0}; if (req->content_len > 0 && req->content_len < 512) { char buf[512] = {0}; int ret = httpd_req_recv(req, buf, req->content_len); if (ret > 0) { buf[ret] = '\0'; JsonView root = jsonParse(buf); if (root.valid) { JsonView sha_item = jsonGet(&root, "sha256"); if (jsonIsString(&sha_item) && sha_item.str_len == 64) { jsonGetStringCopy(&sha_item, frontend_sha_hex, 65); } } } } if (frontend_sha_hex[0] == '\0') { return http_send_json_error(req, "400 Bad Request", "missing_sha256", "missing sha256 in body (frontend SHA-256 required)"); } uint8_t frontend_sha[32] = {0}; http_hex_to_bytes(frontend_sha_hex, frontend_sha, 32); uint8_t device_sha[32] = {0}; char device_sha_hex[65] = {0}; esp_err_t verify_err = OtaCoordinator::instance().verifyGd32Firmware(frontend_sha, device_sha); http_bytes_to_hex_upper(device_sha, 32, device_sha_hex); if (verify_err == ESP_ERR_INVALID_CRC) { DEBUG_ERR("OTA", "GD32 SHA-256 不匹配"); DEBUG_ERR("OTA", "frontend=%s", frontend_sha_hex); DEBUG_ERR("OTA", "device=%s", device_sha_hex); /* 未进中继:须恢复 WiFi 上传期挂起的 BLE */ (void)BleOta::instance().resumeStack(); char err_resp[256]; snprintf(err_resp, sizeof(err_resp), "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", " "\"device\": \"%s\"}", device_sha_hex); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, err_resp, strlen(err_resp)); return ESP_FAIL; } else if (verify_err != ESP_OK) { (void)BleOta::instance().resumeStack(); return http_send_json_error(req, "500 Internal Server Error", "sha_compute_failed", "SHA compute failed"); } /* ★ 包级防呆:SHA 匹配只证明传输无误,不证明包内容正确。 * 上传 BOOT 包(或其他非 APP 包)时在此拒绝,GD32 不会被擦除 APP 区。 */ const Gd32ImageIdentity identity = Gd32FlashStore::instance().validateStoredImageIdentity(); if (identity != Gd32ImageIdentity::kApp) { (void)BleOta::instance().resumeStack(); const char *msg = (identity == Gd32ImageIdentity::kBootloader) ? "检测到 Bootloader 固件(复位向量在 0x08000000 区),已拒绝刷入 APP 区,请选择 APP 固件" : (identity == Gd32ImageIdentity::kInvalid) ? "固件向量表非法,疑似非 GD32 APP 固件,已拒绝刷写" : "固件存储读取失败,无法校验身份,请重新上传"; DEBUG_ERR("OTA", "GD32 固件身份校验失败 identity=%d,拒绝中继", (int)identity); return http_send_json_error(req, "400 Bad Request", "firmware_identity_mismatch", msg); } esp_err_t trigger_err = OtaCoordinator::instance().triggerGd32Relay(); if (trigger_err == ESP_ERR_INVALID_SIZE) { /* 兜底:triggerGd32Relay 内部身份校验拒绝(与上方同判据,正常不会走到) */ (void)BleOta::instance().resumeStack(); return http_send_json_error(req, "400 Bad Request", "firmware_identity_mismatch", "固件身份校验失败,已拒绝刷写"); } if (trigger_err != ESP_OK) { (void)BleOta::instance().resumeStack(); return http_send_json_error(req, "400 Bad Request", "relay_trigger_failed", "relay trigger failed - system busy or firmware not ready"); } char resp[256]; snprintf(resp, sizeof(resp), "{\"status\": \"ok\", \"message\": \"OTA relay triggered\", " "\"sha256\": \"%s\"}", device_sha_hex); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); return ESP_OK; } /* /logs 分块流式 sink:每个 chunk 经 httpd_resp_send_chunk 发出。 */ static bool logs_chunk_sink(void *ctx, const char *data, int len) { httpd_req_t *req = static_cast(ctx); return httpd_resp_send_chunk(req, data, len) == ESP_OK; } esp_err_t logs_handler(httpd_req_t *req) { http_set_cors_headers(req); http_set_keepalive_hdr(req); httpd_resp_set_type(req, "application/json"); /* 流式发送:消除原 ~4KB 一次性 malloc,改为 512B 栈缓冲 + 分块。 * lwip TX pbuf 峰值与应用堆峰值同时下降。 */ int r = DebugLog::instance().ringDrainJsonStream(logs_chunk_sink, req); if (r == 1) { /* 各数据 chunk 已发完,补发终止空 chunk 结束 chunked 响应。 */ return httpd_resp_send_chunk(req, NULL, 0); } if (r == 0) { /* 无日志:一次性小响应(不进 chunked 编码)。 */ return httpd_resp_send(req, "[]", 2); } /* r < 0:中途 send 失败,连接多半已断。 */ return ESP_FAIL; } // ============================================================ // HTTP JSON 共享工作区(.bss 单份 ~3KB) // 供 /system/status、/can/feedback、/system/heap_low_log 串行复用; // 互斥保护,禁止并发写同一缓冲(httpd 多 worker 时会排队)。 // ============================================================ static constexpr size_t HTTP_JSON_WORK_CAP = 3072; static char s_http_json_work[HTTP_JSON_WORK_CAP]; static SemaphoreHandle_t s_http_json_mux = nullptr; static portMUX_TYPE s_http_json_init_mux = portMUX_INITIALIZER_UNLOCKED; static SemaphoreHandle_t httpJsonWorkMutex(void) { if (s_http_json_mux == nullptr) { portENTER_CRITICAL(&s_http_json_init_mux); if (s_http_json_mux == nullptr) { s_http_json_mux = xSemaphoreCreateMutex(); } portEXIT_CRITICAL(&s_http_json_init_mux); } return s_http_json_mux; } // ============================================================ // GET /can/feedback — 三路 CAN 反馈快照(GD32 推送,ESP32 缓存) // 分块发送:复用 s_http_json_work,一次 HTTP 内按通道 send_chunk; // 前端仍一次 fetch 收完整 JSON。 // ============================================================ /** * 监控/业务共用:entry data hex 上限。 * 原 8B 只够 STATUS 预览,会截掉 GET_VERSION(0x00FF) 的 hard/boot(需 ≥22B)。 * 提到 64 与 CANFD_ID_SNAPSHOT_DATA_MAX 对齐,完整透出 FD 帧。 */ static constexpr uint8_t FEEDBACK_DATA_HEX_MAX = 64; /* can_feedback 分块追加:溢出时返回 -1 让调用方 break 截断 entries。 * 与 http_json_append 的区别仅在此处需要"溢出=break"而非"溢出=截断填满"。 */ static int feedback_append(char *buf, size_t cap, int off, const char *fmt, ...) { if (!buf || cap == 0 || off < 0 || (size_t)off >= cap) return -1; va_list ap; va_start(ap, fmt); int n = vsnprintf(buf + off, cap - (size_t)off, fmt, ap); va_end(ap); if (n < 0 || off + n >= (int)cap) return -1; return off + n; } esp_err_t can_feedback_handler(httpd_req_t *req) { http_set_cors_headers(req); http_set_keepalive_hdr(req); /* ?lite=1:轻量路径,跳过 entries 数组构造(~3KB → ~200B)。 * motor.html 的 onFeedback 只用 ch/enabled/valid/rx/tx/err/tec/rec/bus_state, * 不需要 entries(CAN 帧快照,index.html/can.html 才用)。 * 跳过 entries 省掉 N×snprintf+hex 编码 + chunked 发送,handler 从 ~10ms 降到 <1ms。 */ bool want_lite = http_query_bool(req, "lite"); /* OTA / send_batch 期间 SPI 被独占:跳过 0xC1 查询,全部通道返回 valid=false。 * 响应结构不变,前端轮询自然显示 "--",恢复后自动回来。 */ const bool spi_paused = canFeedbackPaused(); /* 2026-08 架构改造:不再读被动缓存 s_latest,改为按需向 GD32 查询 0xC1。 * lite 路径只需 page 0(统计在 page0 header);full 路径需 page 0+1(entries)。 * 单通道 result 在栈上用完即释放,不再常驻 s_latest[3]/s_work[3](省 ~7.8KB BSS)。 */ const bool feat_known = SpiMaster::instance().gd32FeatKnown(); const uint32_t feat = SpiMaster::instance().gd32FeatFlags(); const bool ch_en[CAN_CHANNEL_MAX] = { !feat_known || (feat & V2_FEAT_CAN_CH0) != 0U, !feat_known || (feat & V2_FEAT_CAN_CH1) != 0U, !feat_known || (feat & V2_FEAT_CAN_CH2) != 0U, }; const unsigned ch_mask = feat_known ? (unsigned)((feat & V2_FEAT_CAN_CH_MASK) >> V2_FEAT_CAN_CH_SHIFT) : 0x7U; /* lite 路径:一次性小响应,不走 chunked,不需要 httpJsonWorkMutex(用栈缓冲即可)。 * 体积上限:3 通道 × ~80B + header ~60B ≈ 300B,远小于 1024 栈预算。 * 每通道 1 次 SPI 查询(page 0),3 通道 ~6-12ms。 */ if (want_lite) { char resp[512]; int off = snprintf(resp, sizeof(resp), "{\"feat_known\":%s,\"can_ch_mask\":%u,\"can_brs\":%s,\"channels\":[", feat_known ? "true" : "false", ch_mask, (feat_known && (feat & V2_FEAT_CAN_BRS)) ? "true" : "false"); if (off < 0 || (size_t)off >= sizeof(resp)) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite hdr"); return ESP_FAIL; } for (uint8_t i = 0; i < CAN_CHANNEL_MAX; ++i) { can_feedback_result_t result = {}; bool valid = false; if (ch_en[i] && !spi_paused) { uint8_t entryIdx = 0; valid = (canFdQueryFeedback(i, 0, &result, &entryIdx) == 0); } const can_feedback_result_t &r = result; const char *stats_fmt; if (valid) { stats_fmt = ",\"mode\":%u,\"seq\":%lu,\"rx\":%lu,\"tx\":%lu," "\"drop\":%lu,\"err\":%lu,\"tec\":%u,\"rec\":%u," "\"bus_state\":%u,\"count\":%u"; } else { stats_fmt = ""; } /* entries 省略,count 仍报真实 total,前端可据此判断有无快照 */ int n = snprintf(resp + off, sizeof(resp) - (size_t)off, "%s{\"ch\":%u,\"enabled\":%s,\"valid\":%s", i == 0 ? "" : ",", (unsigned)i, ch_en[i] ? "true" : "false", valid ? "true" : "false"); if (n < 0 || off + n >= (int)sizeof(resp)) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite ch"); return ESP_FAIL; } off += n; if (valid) { n = snprintf(resp + off, sizeof(resp) - (size_t)off, stats_fmt, (unsigned)r.stats.mode, (unsigned long)r.stats.snapshot_seq, (unsigned long)r.stats.rx_count, (unsigned long)r.stats.tx_count, (unsigned long)r.stats.rx_drop_count, (unsigned long)r.stats.error_count, (unsigned)r.stats.tec, (unsigned)r.stats.rec, (unsigned)r.stats.bus_state, (unsigned)r.total_count); if (n < 0 || off + n >= (int)sizeof(resp)) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite stats"); return ESP_FAIL; } off += n; } /* 闭合 channel 对象 */ n = snprintf(resp + off, sizeof(resp) - (size_t)off, "}"); if (n < 0 || off + n >= (int)sizeof(resp)) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite close"); return ESP_FAIL; } off += n; } int n = snprintf(resp + off, sizeof(resp) - (size_t)off, "]}"); if (n < 0 || off + n >= (int)sizeof(resp)) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite end"); return ESP_FAIL; } off += n; httpd_resp_set_type(req, "application/json"); return httpd_resp_send(req, resp, off); } /* 完整路径(含 entries):保留给 index.html / can.html 的 CAN 监控页 * 每通道 2 次 SPI 查询(page 0+1),3 通道 6 次 ~12-24ms。 */ SemaphoreHandle_t mux = httpJsonWorkMutex(); if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb busy"); return ESP_FAIL; } char *work = s_http_json_work; httpd_resp_set_type(req, "application/json"); auto fail_unlock = [&](esp_err_t err) -> esp_err_t { if (mux) { xSemaphoreGive(mux); } return err; }; int n = snprintf(work, HTTP_JSON_WORK_CAP, "{\"feat_known\":%s,\"can_ch_mask\":%u,\"can_brs\":%s,\"channels\":[", feat_known ? "true" : "false", ch_mask, (feat_known && (feat & V2_FEAT_CAN_BRS)) ? "true" : "false"); if (n < 0 || (size_t)n >= HTTP_JSON_WORK_CAP) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb hdr"); return fail_unlock(ESP_FAIL); } if (httpd_resp_send_chunk(req, work, n) != ESP_OK) { return fail_unlock(ESP_FAIL); } for (uint8_t i = 0; i < CAN_CHANNEL_MAX; ++i) { /* 按需查询:page 0 重置 + 统计,page 1 追加 entries(OTA 暂停期间跳过) */ can_feedback_result_t result = {}; bool valid = false; if (ch_en[i] && !spi_paused) { uint8_t entryIdx = 0; int rc0 = canFdQueryFeedback(i, 0, &result, &entryIdx); if (rc0 == 0) { valid = true; /* page 1 追加剩余 entries(失败则仅用 page 0 的条目) */ (void)canFdQueryFeedback(i, 1, &result, &entryIdx); } } const can_feedback_result_t &r = result; int off = 0; off = feedback_append(work, HTTP_JSON_WORK_CAP, off, "%s{\"ch\":%u,\"enabled\":%s,\"valid\":%s", i == 0 ? "" : ",", (unsigned)i, ch_en[i] ? "true" : "false", valid ? "true" : "false"); if (off < 0) { break; } if (valid) { off = feedback_append(work, HTTP_JSON_WORK_CAP, off, ",\"mode\":%u,\"seq\":%lu,\"rx\":%lu,\"tx\":%lu," "\"drop\":%lu,\"err\":%lu,\"tec\":%u,\"rec\":%u," "\"bus_state\":%u,\"count\":%u,\"entries\":[", (unsigned)r.stats.mode, (unsigned long)r.stats.snapshot_seq, (unsigned long)r.stats.rx_count, (unsigned long)r.stats.tx_count, (unsigned long)r.stats.rx_drop_count, (unsigned long)r.stats.error_count, (unsigned)r.stats.tec, (unsigned)r.stats.rec, (unsigned)r.stats.bus_state, (unsigned)r.total_count); if (off < 0) { break; } uint8_t emitted = 0; for (uint8_t j = 0; j < r.total_count && j < CANFD_ID_SNAPSHOT_MAX; ++j) { const id_snapshot_entry_t &e = r.entries[j]; uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX) ? e.dlc : (uint8_t)CANFD_ID_SNAPSHOT_DATA_MAX; if (dataLen > FEEDBACK_DATA_HEX_MAX) { dataLen = FEEDBACK_DATA_HEX_MAX; } /* 先估长度:装不下则截断 entries(count 仍报真实 total) */ char entry_tmp[96 + FEEDBACK_DATA_HEX_MAX * 2]; int elen = snprintf(entry_tmp, sizeof(entry_tmp), "%s{\"id\":\"0x%08lX\",\"dlc\":%u,\"fd\":%u," "\"brs\":%u,\"esi\":%u,\"data\":\"", emitted ? "," : "", (unsigned long)e.id, (unsigned)e.dlc, (unsigned)e.fd_flag, (unsigned)e.brs, (unsigned)e.esi); if (elen < 0 || (size_t)elen >= sizeof(entry_tmp)) { break; } for (uint8_t d = 0; d < dataLen; ++d) { int hn = snprintf(entry_tmp + elen, sizeof(entry_tmp) - (size_t)elen, "%02X", e.data[d]); if (hn != 2) { break; } elen += hn; } if (elen + 2 >= (int)sizeof(entry_tmp)) { break; } entry_tmp[elen++] = '"'; entry_tmp[elen++] = '}'; entry_tmp[elen] = '\0'; if (off + elen + 2 >= (int)HTTP_JSON_WORK_CAP) { break; /* 本通道 entries 截断 */ } memcpy(work + off, entry_tmp, (size_t)elen); off += elen; emitted++; } off = feedback_append(work, HTTP_JSON_WORK_CAP, off, "]"); if (off < 0) { break; } } off = feedback_append(work, HTTP_JSON_WORK_CAP, off, "}"); if (off < 0) { break; } if (httpd_resp_send_chunk(req, work, off) != ESP_OK) { return fail_unlock(ESP_FAIL); } } if (httpd_resp_send_chunk(req, "]}", 2) != ESP_OK) { return fail_unlock(ESP_FAIL); } esp_err_t end_err = httpd_resp_send_chunk(req, NULL, 0); return fail_unlock(end_err == ESP_OK ? ESP_OK : ESP_FAIL); } // ============================================================ // GET/POST /can/mode — 查询或切换三路 CAN 接收模式 // ============================================================ esp_err_t can_mode_handler(httpd_req_t *req) { http_set_cors_headers(req); if (req->method == HTTP_GET) { http_set_keepalive_hdr(req); /* GET 轮询复用 TCP;POST 设置模式不复用 */ /* OTA / send_batch 期间不占 SPI:跳过 0xC1 查询与 0xC2 fallback */ if (canFeedbackPaused()) { char resp[64]; int len = snprintf(resp, sizeof(resp), "{\"mode\":0,\"source\":\"paused\",\"ok\":false}"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); return ESP_OK; } /* 2026-08 架构改造:按需向 GD32 查询 0xC1 page0 取 mode(不再读缓存 s_latest)。 * page0 header 含 mode 字段,1 次 SPI 查询 ~2-4ms。 */ can_feedback_result_t fb{}; uint8_t entryIdx = 0; if (canFdQueryFeedback(0, 0, &fb, &entryIdx) == 0) { char resp[64]; int len = snprintf(resp, sizeof(resp), "{\"mode\":%u}", (unsigned)fb.stats.mode); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); } else { /* 查询失败:回退实时 SPI 0xC2 查询实际模式 */ can_rx_mode_t actual = CAN_RX_MODE_ALL_PASS; int rc = canFdSetRxMode(CAN_RX_MODE_ALL_PASS, &actual, 2000); char resp[64]; int len = snprintf(resp, sizeof(resp), "{\"mode\":%u,\"source\":\"spi\",\"ok\":%s}", (unsigned)actual, rc == 0 ? "true" : "false"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); } return ESP_OK; } char buf[128] = {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'; JsonView root = jsonParse(buf); if (!root.valid) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败"); return ESP_FAIL; } JsonView mode_item = jsonGet(&root, "mode"); if (!jsonIsNumber(&mode_item)) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 mode 字段"); return ESP_FAIL; } int requested = (int)jsonGetNumberValue(&mode_item); can_rx_mode_t target = CAN_RX_MODE_ALL_PASS; if (requested == 1) { target = CAN_RX_MODE_FILTER_ID; } else if (requested == 2) { target = CAN_RX_MODE_BRIDGE_0_TO_12; } can_rx_mode_t actual = CAN_RX_MODE_ALL_PASS; int rc = canFdSetRxMode(target, &actual, 2000); char resp[96]; int len = snprintf(resp, sizeof(resp), "{\"ok\":%s,\"requested\":%d,\"actual\":%u}", rc == 0 ? "true" : "false", requested, (unsigned)actual); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); return ESP_OK; } /** * @brief POST /can/send_batch 流式 body 读取器。 * * 用 512B 滚动缓冲逐字符读 HTTP body,避免一次性 malloc(≤49KB)。 * 仅在 can_send_batch_handler 内使用,针对该端点的固定 JSON schema。 */ class BatchBodyReader { public: static constexpr size_t kBuf = 512; BatchBodyReader(httpd_req_t *req, int content_len) : m_req(req), m_remaining(content_len > 0 ? content_len : 0) {} /** 读下一字节,EOF 返回 -1 */ int nextChar() { if (m_pos >= m_len && !refill()) return -1; return (unsigned char)m_buf[m_pos++]; } /** 偷看下一字节(不推进),EOF 返回 -1 */ int peekChar() { if (m_pos >= m_len && !refill()) return -1; return (unsigned char)m_buf[m_pos]; } private: httpd_req_t *m_req; int m_remaining; char m_buf[kBuf]; size_t m_len = 0; size_t m_pos = 0; bool m_eof = false; bool refill() { if (m_eof) return false; if (m_pos > 0) { if (m_pos < m_len) { memmove(m_buf, m_buf + m_pos, m_len - m_pos); m_len -= m_pos; } else { m_len = 0; } m_pos = 0; } int to_read = (int)kBuf - (int)m_len; if (to_read > m_remaining) to_read = m_remaining; if (to_read <= 0) { m_eof = true; return false; } int ret = httpd_req_recv(m_req, m_buf + m_len, (size_t)to_read); if (ret <= 0) { m_eof = true; return false; } m_len += (size_t)ret; m_remaining -= ret; return m_pos < m_len; } }; /** * @brief POST /can/send_batch — CAN 发送唯一入口(单帧 = frames 长度 1)。 * * JSON: { "ch": 0, "frames": [ { "id": n, "dlc": n, "data": "HEX" }, ... ] } * 返回: { "status":"ok","results":[ ... ], 可选 skipped/abort_* } * * 流式改造(2026-08):原一次性 malloc(≤49KB) + JSON parse 双份堆占 + * 输出,峰值 ~100KB 会挤压 lwip pbuf 导致网络断连。 * 现用 512B 滚动缓冲逐字符读 body,单帧 jsonParse(零堆分配) 即用即弃, * 响应走 httpd_resp_send_chunk 小栈缓冲,峰值堆 <2KB。 * * 单帧 canFdSend 超时 1200ms(等应答);多帧 80ms/帧 + 帧间隔。 * 发送期间暂停 CAN 反馈任务。 */ esp_err_t can_send_batch_handler(httpd_req_t *req) { http_set_cors_headers(req); constexpr int kMaxFrames = 128; constexpr uint32_t kPerFrameTimeoutMs = 80U; constexpr uint32_t kSingleFrameTimeoutMs = 1200U; /* 正常 <20ms;httpd 单线程,此值决定其它端点最坏阻塞 */ constexpr uint32_t kInterFrameDelayMs = 1U; /* 上限保留 49152 以维持 API 兼容;流式读取不再一次性 malloc */ constexpr int kMaxBody = 49152; int content_len = req->content_len; if (content_len <= 0 || content_len > kMaxBody) { return http_send_json_error(req, "400 Bad Request", "bad_body", "body 长度无效或过大"); } BatchBodyReader reader(req, content_len); /* ── prefix 解析:定位 "ch" → ':' → 数字;再定位 "frames" → '[' ── */ auto findKey = [&](const char *key) -> bool { const size_t kl = strlen(key); size_t match = 0; int c; while ((c = reader.nextChar()) != -1) { if (c == key[match]) { if (++match == kl) return true; } else { match = (c == key[0]) ? 1 : 0; } } return false; }; auto skipWs = [&]() { int c; while ((c = reader.peekChar()) != -1) { if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { reader.nextChar(); } else { break; } } }; auto findChar = [&](char target) -> bool { int c; while ((c = reader.nextChar()) != -1) { if (c == target) return true; } return false; }; auto readInt = [&]() -> long { skipWs(); long sign = 1; int c = reader.peekChar(); if (c == '-') { reader.nextChar(); sign = -1; } long v = 0; while ((c = reader.peekChar()) != -1 && c >= '0' && c <= '9') { v = v * 10 + (c - '0'); reader.nextChar(); } return v * sign; }; if (!findKey("\"ch\"")) { return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 ch 字段"); } if (!findChar(':')) { return http_send_json_error(req, "400 Bad Request", "bad_args", "ch 字段格式错误"); } long ch_val = readInt(); if (ch_val < 0 || ch_val >= CAN_CHANNEL_MAX) { return http_send_json_error(req, "400 Bad Request", "bad_args", "ch 非法(0~2)"); } if (!findKey("\"frames\"")) { return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 frames 字段"); } if (!findChar('[')) { return http_send_json_error(req, "400 Bad Request", "bad_args", "frames 非数组"); } can_channel_t ch = static_cast(ch_val); /* ── 读取单个帧对象到大括号匹配,返回含外层 {} 的 JSON 子串 ── */ char frameJson[320]; auto readFrameObj = [&]() -> int { skipWs(); int c = reader.peekChar(); if (c != '{') return -1; reader.nextChar(); /* 消费 '{' */ frameJson[0] = '{'; size_t n = 1; int depth = 1; while ((c = reader.nextChar()) != -1) { if (c == '{') { depth++; } else if (c == '}') { depth--; if (depth == 0) { if (n + 1 >= sizeof(frameJson)) return -1; frameJson[n++] = '}'; frameJson[n] = '\0'; return (int)n; } } if (n + 1 >= sizeof(frameJson)) return -1; frameJson[n++] = (char)c; } return -1; }; /* abort 信息(processFrame 命中致命错误时填写) */ int abort_code = 0; int abort_gd32_err = 0; int emitted = 0; char chunk[192]; /* 单帧/多帧超时在读完首帧后确定;lambda 按引用捕获,后续赋值生效 */ uint32_t frame_timeout_ms = kPerFrameTimeoutMs; /* ── 处理一帧:解析 JSON → canFdSend → 发 chunk ── * 返回 true 表示致命错误需中止后续帧。 */ auto processFrame = [&]() -> bool { JsonView fr = jsonParse(frameJson); if (!fr.valid) { int n = snprintf(chunk, sizeof(chunk), "%s{\"status\":\"error\",\"code\":-1,\"gd32_err\":0}", (emitted == 0) ? "" : ","); httpd_resp_send_chunk(req, chunk, n); emitted++; return false; } JsonView id_item = jsonGet(&fr, "id"); JsonView data_item = jsonGet(&fr, "data"); JsonView dlc_item = jsonGet(&fr, "dlc"); if (!id_item.valid) { int n = snprintf(chunk, sizeof(chunk), "%s{\"status\":\"error\",\"code\":-1,\"gd32_err\":0}", (emitted == 0) ? "" : ","); httpd_resp_send_chunk(req, chunk, n); emitted++; return false; } uint32_t ext_id = static_cast(jsonGetNumberValue(&id_item)); uint8_t dlc = dlc_item.valid ? static_cast(jsonGetNumberValue(&dlc_item)) : 0U; if (dlc > CAN_MAX_DLC) dlc = CAN_MAX_DLC; uint8_t tx_data[CAN_MAX_DLC] = {0}; uint8_t tx_dlc = 0U; if (jsonIsString(&data_item)) { size_t hex_len = 0; const char *hex = jsonGetStringValue(&data_item, &hex_len); if (hex) { tx_dlc = static_cast(http_hex_to_bytes_n(hex, hex_len, tx_data, CAN_MAX_DLC)); } } if (tx_dlc == 0U && dlc > 0U) tx_dlc = dlc; uint8_t resp_data[CAN_MAX_DLC] = {0}; uint8_t gd32_err = 0U; int resp_len = canFdSend(ch, ext_id, tx_data, tx_dlc, resp_data, sizeof(resp_data), frame_timeout_ms, &gd32_err); if (resp_len >= 0) { char resp_hex[CAN_MAX_DLC * 2 + 1] = {0}; const size_t copy_n = (static_cast(resp_len) < sizeof(resp_data)) ? static_cast(resp_len) : sizeof(resp_data); http_bytes_to_hex_upper(resp_data, copy_n, resp_hex); int n = snprintf(chunk, sizeof(chunk), "%s{\"status\":\"ok\",\"sent\":%u,\"recv_len\":%u,\"recv\":\"%s\"}", (emitted == 0) ? "" : ",", (unsigned)tx_dlc, (unsigned)copy_n, resp_hex); httpd_resp_send_chunk(req, chunk, n); emitted++; return false; } /* 错误项 */ int n = snprintf(chunk, sizeof(chunk), "%s{\"status\":\"error\",\"code\":%d,\"gd32_err\":%u}", (emitted == 0) ? "" : ",", resp_len, (unsigned)gd32_err); httpd_resp_send_chunk(req, chunk, n); emitted++; /* SPI 失败 / BUS_OFF / TEC 过高:中止剩余帧,由 skipped 字段告知前端 */ const bool fatal_spi = (resp_len == -2); const bool fatal_bus = (resp_len == -3) && (gd32_err == 0xE2U || gd32_err == 0xE3U); if (fatal_spi || fatal_bus) { abort_code = resp_len; abort_gd32_err = (int)gd32_err; return true; } return false; }; /* ── 先读第一帧,判断单帧/多帧以决定超时 ── */ int flen = readFrameObj(); if (flen <= 0) { /* 空数组或首帧非法:直接返回空 results */ httpd_resp_set_type(req, "application/json"); static const char kEmptyResults[] = "{\"status\":\"ok\",\"results\":[]}"; httpd_resp_send_chunk(req, kEmptyResults, sizeof(kEmptyResults) - 1); httpd_resp_send_chunk(req, NULL, 0); return ESP_OK; } /* 偷看第一帧之后是否为 ']':是→单帧,否→多帧 */ skipWs(); const bool single_frame = (reader.peekChar() == ']'); frame_timeout_ms = single_frame ? kSingleFrameTimeoutMs : kPerFrameTimeoutMs; /* ── 开始 chunked 响应(prefix 已校验完毕,不会再回退为 error) ── */ httpd_resp_set_type(req, "application/json"); static const char kResultsPrefix[] = "{\"status\":\"ok\",\"results\":["; httpd_resp_send_chunk(req, kResultsPrefix, sizeof(kResultsPrefix) - 1); pauseCanFeedbackTask(true); bool aborted = processFrame(); int processed = 1; if (!aborted && !single_frame) { while (processed < kMaxFrames) { (void)esp_task_wdt_reset(); skipWs(); int c = reader.peekChar(); if (c == -1 || c == ']') break; if (c == ',') { reader.nextChar(); continue; } if (c != '{') { reader.nextChar(); continue; } /* 跳过意外字符 */ flen = readFrameObj(); if (flen <= 0) break; if (processFrame()) { aborted = true; break; } processed++; vTaskDelay(pdMS_TO_TICKS(kInterFrameDelayMs)); } } pauseCanFeedbackTask(false); /* 统计中止后未发送的剩余帧数(按 '{' 计数到 ']') */ int skipped = 0; if (aborted) { int c; while ((c = reader.nextChar()) != -1) { if (c == ']') break; if (c == '{') skipped++; } } char tail[96]; int tn; if (aborted && skipped > 0) { tn = snprintf(tail, sizeof(tail), "],\"skipped\":%d,\"abort_code\":%d,\"abort_gd32_err\":%u}", skipped, abort_code, (unsigned)abort_gd32_err); } else { tn = snprintf(tail, sizeof(tail), "]}"); } httpd_resp_send_chunk(req, tail, tn); httpd_resp_send_chunk(req, NULL, 0); return ESP_OK; } /** * @brief POST /can/send_burst — CAN 跨通道突发发送(一次 SPI 事务多帧近同时)。 * * JSON: { "suffix": 512, "dlc": 32, "frames": [ { "ch":0, "node":1, "data":"HEX" }, ... ] } * suffix = CAN ID 低 16 位(CMD=0x0200),id=(node<<16)|suffix;dlc 为本批统一数据长度。 * frames 可超过单次突发容量:内部按 canBurstCapacity(dlc) 分块,块与块之间背靠背 * 连续发起 SPI 事务(间隔 ~<1ms),实现「组内近同时、组间尽量紧」的多电机同步驱动。 * * 返回: { "status":"ok","sent":N,"results":[ {"status":"ok"} | {"status":"error","code":c,"gd32_err":g}, ... ] } * 单帧总线故障(0xE2/0xE3)/邮箱满(0xE4)等经 ACK 的每帧状态字节透传,前端据此处理冷却。 */ esp_err_t can_send_burst_handler(httpd_req_t *req) { http_set_cors_headers(req); constexpr int kMaxTotalFrames = 24; /* 单次请求帧数上限(一步同步电机数上限) */ constexpr uint32_t kBurstTimeoutMs = 200U; /* 单次突发 SPI 事务超时 */ constexpr int kMaxBody = 49152; int content_len = req->content_len; if (content_len <= 0 || content_len > kMaxBody) { return http_send_json_error(req, "400 Bad Request", "bad_body", "body 长度无效或过大"); } BatchBodyReader reader(req, content_len); auto findKey = [&](const char *key) -> bool { const size_t kl = strlen(key); size_t match = 0; int c; while ((c = reader.nextChar()) != -1) { if (c == key[match]) { if (++match == kl) return true; } else { match = (c == key[0]) ? 1 : 0; } } return false; }; auto skipWs = [&]() { int c; while ((c = reader.peekChar()) != -1) { if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { reader.nextChar(); } else { break; } } }; auto findChar = [&](char target) -> bool { int c; while ((c = reader.nextChar()) != -1) { if (c == target) return true; } return false; }; auto readInt = [&]() -> long { skipWs(); long sign = 1; int c = reader.peekChar(); if (c == '-') { reader.nextChar(); sign = -1; } long v = 0; while ((c = reader.peekChar()) != -1 && c >= '0' && c <= '9') { v = v * 10 + (c - '0'); reader.nextChar(); } return v * sign; }; char frameJson[320]; auto readFrameObj = [&]() -> int { skipWs(); int c = reader.peekChar(); if (c != '{') return -1; reader.nextChar(); frameJson[0] = '{'; size_t n = 1; int depth = 1; while ((c = reader.nextChar()) != -1) { if (c == '{') { depth++; } else if (c == '}') { depth--; if (depth == 0) { if (n + 1 >= sizeof(frameJson)) return -1; frameJson[n++] = '}'; frameJson[n] = '\0'; return (int)n; } } if (n + 1 >= sizeof(frameJson)) return -1; frameJson[n++] = (char)c; } return -1; }; /* ── prefix:suffix / dlc / frames ── */ if (!findKey("\"suffix\"") || !findChar(':')) { return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 suffix 字段"); } long suffix_val = readInt(); if (!findKey("\"dlc\"") || !findChar(':')) { return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 dlc 字段"); } long dlc_val = readInt(); if (dlc_val < 0 || dlc_val > CAN_MAX_DLC) { return http_send_json_error(req, "400 Bad Request", "bad_args", "dlc 超出范围"); } if (!findKey("\"frames\"") || !findChar('[')) { return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 frames 字段"); } const uint8_t dlc = static_cast(dlc_val); const uint16_t suffix = static_cast(suffix_val & 0xFFFF); const uint8_t cap = canBurstCapacity(dlc); if (cap == 0U) { return http_send_json_error(req, "400 Bad Request", "bad_args", "dlc 过大无法突发"); } /* 静态缓冲:httpd 单线程,避免大栈占用 */ static uint8_t s_chnode[kMaxTotalFrames]; static uint8_t s_data[kMaxTotalFrames][CAN_MAX_DLC]; static uint8_t s_status[kMaxTotalFrames]; int total = 0; while (total < kMaxTotalFrames) { skipWs(); int c = reader.peekChar(); if (c == -1 || c == ']') break; if (c == ',') { reader.nextChar(); continue; } if (c != '{') { reader.nextChar(); continue; } int flen = readFrameObj(); if (flen <= 0) break; JsonView fr = jsonParse(frameJson); if (!fr.valid) { return http_send_json_error(req, "400 Bad Request", "bad_frame", "帧 JSON 非法"); } JsonView ch_it = jsonGet(&fr, "ch"); JsonView node_it = jsonGet(&fr, "node"); JsonView data_it = jsonGet(&fr, "data"); if (!jsonIsNumber(&ch_it) || !jsonIsNumber(&node_it)) { return http_send_json_error(req, "400 Bad Request", "bad_frame", "帧缺少 ch/node"); } const int chv = static_cast(jsonGetNumberValue(&ch_it)); const int nodev = static_cast(jsonGetNumberValue(&node_it)); if (chv < 0 || chv >= CAN_CHANNEL_MAX) { return http_send_json_error(req, "400 Bad Request", "bad_frame", "ch 非法(0~2)"); } uint8_t data_buf[CAN_MAX_DLC]; uint8_t nbytes = 0U; if (jsonIsString(&data_it)) { size_t hex_len = 0U; const char *hex = jsonGetStringValue(&data_it, &hex_len); if (hex) { nbytes = static_cast( http_hex_to_bytes_n(hex, hex_len, data_buf, CAN_MAX_DLC)); } } if (nbytes != dlc) { return http_send_json_error(req, "400 Bad Request", "bad_frame", "帧 data 长度与 dlc 不一致"); } s_chnode[total] = static_cast(((chv & 0x0F) << 4) | (nodev & 0x0F)); if (dlc > 0U) { memcpy(s_data[total], data_buf, dlc); } total++; } if (total == 0) { return http_send_json_error(req, "400 Bad Request", "bad_args", "frames 为空"); } /* ── 分块背靠背突发发送 ── */ pauseCanFeedbackTask(true); bool aborted = false; for (int off = 0; off < total && !aborted; off += cap) { const int n = ((total - off) > cap) ? cap : (total - off); can_burst_entry_t entries[CAN_BURST_HARD_MAX]; for (int i = 0; i < n; ++i) { entries[i].chnode = s_chnode[off + i]; entries[i].data = s_data[off + i]; } uint8_t chunk_status[CAN_BURST_HARD_MAX]; uint8_t gerr = 0U; (void)esp_task_wdt_reset(); const int rc = canFdSendBurst(suffix, dlc, entries, static_cast(n), chunk_status, sizeof(chunk_status), kBurstTimeoutMs, &gerr); if (rc < 0) { /* SPI 失败/协议错误:本块及之后标记失败并中止 */ const uint8_t code = (rc == -3) ? gerr : 0xEEU; for (int i = 0; i < n; ++i) s_status[off + i] = code; for (int j = off + n; j < total; ++j) s_status[j] = 0xEEU; aborted = true; break; } for (int i = 0; i < n; ++i) s_status[off + i] = chunk_status[i]; } pauseCanFeedbackTask(false); /* ── 组装 JSON 响应 ── */ static char resp_buf[1600]; int off = snprintf(resp_buf, sizeof(resp_buf), "{\"status\":\"ok\",\"sent\":%d,\"results\":[", total); for (int i = 0; i < total; ++i) { const uint8_t st = s_status[i]; int w; if (st == 0x00U) { w = snprintf(resp_buf + off, sizeof(resp_buf) - off, "%s{\"status\":\"ok\"}", (i == 0) ? "" : ","); } else { w = snprintf(resp_buf + off, sizeof(resp_buf) - off, "%s{\"status\":\"error\",\"code\":-3,\"gd32_err\":%u}", (i == 0) ? "" : ",", (unsigned)st); } if (w < 0 || off + w >= (int)sizeof(resp_buf)) break; off += w; } if (off < (int)sizeof(resp_buf) - 2) { resp_buf[off++] = ']'; resp_buf[off++] = '}'; } httpd_resp_set_type(req, "application/json"); return httpd_resp_send(req, resp_buf, off); } esp_err_t spi_send_handler(httpd_req_t *req) { http_set_cors_headers(req); char buf[512] = {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'; JsonView root = jsonParse(buf); if (!root.valid) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败"); return ESP_FAIL; } JsonView type_item = jsonGet(&root, "type"); JsonView data_item = jsonGet(&root, "data"); JsonView timeout_item = jsonGet(&root, "timeout_ms"); if (!jsonIsString(&type_item)) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 type 字段"); return ESP_FAIL; } /* type_hex 是 hex 字符串如 "A1",拷贝到小缓冲供 strtol 使用 */ char type_hex_buf[8] = {}; jsonGetStringCopy(&type_item, type_hex_buf, sizeof(type_hex_buf)); uint8_t frame_type = (uint8_t)strtol(type_hex_buf, NULL, 16); uint32_t timeout_ms = timeout_item.valid ? (uint32_t)jsonGetNumberValue(&timeout_item) : 1000; uint8_t payload_data[SPI_OTA_FRAME_MAX] = {0}; size_t payload_len = 0; if (jsonIsString(&data_item)) { size_t hex_len = 0; const char *hex = jsonGetStringValue(&data_item, &hex_len); payload_len = http_hex_to_bytes_n(hex, hex_len, payload_data, SPI_OTA_FRAME_MAX); } DEBUG_INFO("SPI", "WEB SPI: type=0x%02X, payload=%zu 字节", frame_type, payload_len); char resp[1024]; uint8_t resp_type = 0; uint8_t resp_payload[SPI_OTA_FRAME_MAX] = {0}; size_t resp_len = 0; esp_err_t spi_err = SpiMaster::instance().transact(frame_type, payload_data, payload_len, &resp_type, resp_payload, sizeof(resp_payload), &resp_len, timeout_ms); if (spi_err == ESP_OK) { char resp_hex[SPI_OTA_FRAME_MAX * 2 + 1] = {0}; http_bytes_to_hex_upper(resp_payload, resp_len, resp_hex); snprintf(resp, sizeof(resp), "{\"status\": \"ok\", \"type\": \"%02X\", \"payload_len\": %zu, \"resp_type\": \"%02X\", \"resp_len\": %zu, \"recv\": \"%s\"}", frame_type, payload_len, resp_type, resp_len, resp_hex); /* 日志只记 type/len,完整 payload 仅在 HTTP JSON 返回给前端 */ DEBUG_INFO("SPI", "WEB SPI ok: type=0x%02X len=%zu", resp_type, resp_len); } else { snprintf(resp, sizeof(resp), "{\"status\": \"error\", \"code\": %d, \"message\": \"%s\"}", spi_err, esp_err_to_name(spi_err)); DEBUG_ERR("SPI", "WEB SPI 失败: %s", esp_err_to_name(spi_err)); } httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); return ESP_OK; } static constexpr uint32_t INTERNAL_HEAP_CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; /** 发送当前工作区并清空 offset(分块;失败返回 false) */ static bool status_flush_chunk(httpd_req_t *req, char *buf, size_t cap, int *len) { if (!req || !buf || !len || *len <= 0) { return true; } if (httpd_resp_send_chunk(req, buf, *len) != ESP_OK) { return false; } *len = 0; if (cap > 0) { buf[0] = '\0'; } return true; } /* CPU 快照静态缓冲:替代每秒 calloc(1.2KB),消除 IDLE 期持续的堆抖动。 * 与 s_http_json_work 同由 httpJsonWorkMutex 串行化。 */ static CpuUsageSnapshot s_cpu_snapshot; 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; } /** 将 CpuStats 1Hz 快照写入 JSON(不在 HTTP 线程采样)。最多 16 条任务以免撑爆 3KB 分块。 */ static int append_cpu_status_json(char* buf, size_t cap, int offset, const CpuUsageSnapshot& cpu) { static constexpr size_t kCpuTaskJsonMax = 16; offset = http_json_append(buf, cap, offset, "\"cpu\":{\"available\":%s,\"valid\":%s,\"usage_pct\":%.2f,\"idle_pct\":%.2f,\"sample_ms\":%lu,\"tasks\":[", cpu.available ? "true" : "false", cpu.valid ? "true" : "false", cpu.valid ? cpu.usage_pct : 0.0, cpu.valid ? cpu.idle_pct : 0.0, (unsigned long)(cpu.valid ? cpu.sample_ms : 0U)); const size_t ntask = (cpu.task_count > kCpuTaskJsonMax) ? kCpuTaskJsonMax : cpu.task_count; for (size_t i = 0; i < ntask; ++i) { const CpuTaskMetric& task = cpu.tasks[i]; offset = http_json_append(buf, cap, offset, "%s{\"name\":\"%s\",\"task_number\":%lu,\"runtime_delta\":%" PRIu64 ",\"usage_pct\":%.2f}", i == 0 ? "" : ",", task.name, (unsigned long)task.task_number, task.runtime_delta, task.usage_pct); } // 不带尾随逗号;由后续拼接块自行添加前导逗号,避免双重逗号 return http_json_append(buf, cap, offset, "]}"); } esp_err_t system_status_handler(httpd_req_t *req) { http_set_cors_headers(req); http_set_keepalive_hdr(req); /* ?lite=1:轻量路径,只返回 motor.html 高频轮询需要的 4 个字段 * (wifi.ip/mode/connected + gd32_features)。不调 heap_caps_get_info / * collectTaskStackMetrics / OtaCoordinator::getSnapshot / SystemBoot::appendStatusJson * 等重活,避免在 httpd 单线程里阻塞几十~上百 ms 拖慢 motor/feedback 请求。 * 完整 status 由前端 5s 拉一次。 */ bool want_lite = http_query_bool(req, "lite"); if (want_lite) { char ip_str[16] = "0.0.0.0"; const char *mode_str = "AP"; bool connected = false; if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) { 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)); } mode_str = "STA"; connected = true; } else if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_AP) { connected = true; } const bool feat_known = SpiMaster::instance().gd32FeatKnown(); const uint32_t fl = SpiMaster::instance().gd32FeatFlags(); char resp[256]; int n = snprintf(resp, sizeof(resp), "{\"wifi\":{\"mode\":\"%s\",\"ip\":\"%s\",\"connected\":%s}," "\"gd32_features\":{\"known\":%s,\"flags\":%lu," "\"can_ch0\":%s,\"can_ch1\":%s,\"can_ch2\":%s,\"can_ch_mask\":%u}}", mode_str, ip_str, connected ? "true" : "false", feat_known ? "true" : "false", (unsigned long)fl, (feat_known && (fl & V2_FEAT_CAN_CH0)) ? "true" : "false", (feat_known && (fl & V2_FEAT_CAN_CH1)) ? "true" : "false", (feat_known && (fl & V2_FEAT_CAN_CH2)) ? "true" : "false", feat_known ? (unsigned)((fl & V2_FEAT_CAN_CH_MASK) >> V2_FEAT_CAN_CH_SHIFT) : 7U); httpd_resp_set_type(req, "application/json"); return httpd_resp_send(req, resp, n > 0 ? n : 0); } /* layout=1:附带链接期静态布局(IRAM/.data/.bss/堆容量等),前端缓存后轮询不再要。 * 默认只下发 memory 动态字段,减 status JSON 体积。 */ bool want_mem_layout = http_query_bool(req, "layout"); /* 堆极紧时仍返回精简 JSON,禁止 500(前端轮询会刷红)。 */ const size_t largest_now = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); if (largest_now < 512U) { char tiny[192]; int n = snprintf(tiny, sizeof(tiny), "{\"version\":\"%s\",\"free_heap\":%lu,\"largest_free_block\":%lu," "\"wifi\":{\"connected\":%s},\"ota\":{\"sys_state\":\"degraded\"}," "\"cpu\":{\"available\":false,\"valid\":false}}", FW_VERSION, (unsigned long)esp_get_free_heap_size(), (unsigned long)largest_now, (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) ? "true" : "false"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, tiny, n > 0 ? n : 0); return ESP_OK; } SemaphoreHandle_t mux = httpJsonWorkMutex(); if (mux) { if (xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { /* 与 feedback/heap_low 争用工作区:仍回简包,避免 500 刷红 */ char busy[160]; snprintf(busy, sizeof(busy), "{\"version\":\"%s\",\"ota\":{\"sys_state\":\"busy\"}," "\"cpu\":{\"available\":false,\"valid\":false}}", FW_VERSION); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, busy, HTTPD_RESP_USE_STRLEN); return ESP_OK; } } char ip_str[16] = "0.0.0.0"; int8_t rssi = -127; bool connected = false; if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) { 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; } connected = true; } // 先自愈孤儿忙态(如 SHA 失败后卡在 ESP32_FINALIZING),再取快照给 UI OtaCoordinator::instance().healOrphanStates(); // 一次性快照,替代散读 OtaCoordinator / OtaRelay 多个 getter OtaSnapshot snap = OtaCoordinator::instance().getSnapshot(); 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); /* 内存全景:堆只是 SRAM 中可 malloc 的部分。 * 静态段用链接符号拆分(与 map 文件一致): * [IRAM code | .data | .bss | heap ...] (C3 上 IRAM/DRAM 同一物理 SRAM) * 手册 400KB = DRAM 窗口 + 约 16KB 指令 cache(cache 不在 SOC_DRAM_* 窗口内)。 */ multi_heap_info_t internal_info = {}; heap_caps_get_info(&internal_info, INTERNAL_HEAP_CAPS); extern char _iram_start[]; extern char _iram_end[]; extern char _data_start[]; extern char _data_end[]; extern char _bss_start[]; extern char _bss_end[]; extern char _heap_start[]; auto sym_span = [](const char *a, const char *b) -> size_t { const uintptr_t ua = reinterpret_cast(a); const uintptr_t ub = reinterpret_cast(b); return (ub >= ua) ? static_cast(ub - ua) : 0U; }; const size_t iram_bytes = sym_span(_iram_start, _iram_end); const size_t data_bytes = sym_span(_data_start, _data_end); const size_t bss_bytes = sym_span(_bss_start, _bss_end); /* C3 DIRAM:DRAM 基址起至 _heap_start 即为 IRAM+.data+.bss 占位 */ const uintptr_t heap_start_addr = reinterpret_cast(_heap_start); const size_t static_to_heap = (heap_start_addr >= static_cast(SOC_DRAM_LOW)) ? static_cast(heap_start_addr - static_cast(SOC_DRAM_LOW)) : (iram_bytes + data_bytes + bss_bytes); const size_t sram_soc = (SOC_DRAM_HIGH > SOC_DRAM_LOW) ? (size_t)(SOC_DRAM_HIGH - SOC_DRAM_LOW) : 0U; /* 应用实际接管:静态链接区 + 内部堆(比整窗更贴近“可用 DRAM”) */ const size_t sram_app = static_to_heap + internal_heap_total; const size_t sram_total = (sram_app > 0) ? sram_app : sram_soc; const size_t internal_heap_used = internal_info.total_allocated_bytes; const size_t static_reserved = static_to_heap; const size_t reserved_tail = (sram_soc > sram_app) ? (sram_soc - sram_app) : 0U; const size_t dma_total = heap_caps_get_total_size(MALLOC_CAP_DMA); const size_t dma_free = heap_caps_get_free_size(MALLOC_CAP_DMA); const size_t rtc_total = heap_caps_get_total_size(MALLOC_CAP_RTCRAM); const size_t rtc_free = heap_caps_get_free_size(MALLOC_CAP_RTCRAM); // 任务栈指标复用 health_monitor 共享的任务表(collectTaskStackMetrics), // 避免与巡检逻辑两地维护;http_server 在本 handler 上下文中,句柄即当前任务。 TaskStackMetric task_metrics[12]; size_t task_metrics_count = collectTaskStackMetrics(task_metrics, 12); size_t task_stack_total_bytes = 0; for (size_t i = 0; i < task_metrics_count; ++i) { task_stack_total_bytes += task_metrics[i].stack_size_bytes; } // http_server 行的句柄在 httpd 线程中取当前任务更准确 for (size_t i = 0; i < task_metrics_count; ++i) { if (strcmp(task_metrics[i].name, "http_server") == 0) { task_metrics[i].handle = xTaskGetCurrentTaskHandle(); } } /* CPU:只读 cpu_stats 任务的 1Hz 快照,禁止在 httpd 里 uxTaskGetSystemState */ CpuUsageSnapshot *cpu = &s_cpu_snapshot; // 用静态缓冲,不再每秒 calloc(1.2KB) CpuStats::instance().getSnapshot(*cpu); char *resp = s_http_json_work; memset(resp, 0, HTTP_JSON_WORK_CAP); httpd_resp_set_type(req, "application/json"); /* ── 分块 1:核心状态 + OTA/WiFi ── */ int len = snprintf(resp, HTTP_JSON_WORK_CAP, "{\"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, \"channel\": %d, \"channel_name\": \"%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\"}, ", FW_VERSION, fwBuildTime(), FW_GIT_HASH, HealthMonitor::instance().getCurrent(), (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", ip_str, rssi, connected ? "true" : "false", snap.sys_state_str, (int)snap.sys_state, (int)snap.channel, snap.channel == OTA_CHANNEL_BLE ? "BLE" : "WIFI", snap.relay.state, (unsigned long)snap.relay.seq, (unsigned long)snap.relay.total_packets, snap.relay.gd32_app_alive ? "true" : "false", snap.relay.version.app_major, snap.relay.version.app_minor, snap.relay.version.app_patch, snap.relay.version.app_build, snap.relay.version.boot_major, snap.relay.version.boot_minor, snap.relay.version.boot_patch, snap.relay.version.boot_build); if (len < 0 || (size_t)len >= HTTP_JSON_WORK_CAP) { if (mux) { xSemaphoreGive(mux); } httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "status hdr"); return ESP_FAIL; } if (!status_flush_chunk(req, resp, HTTP_JSON_WORK_CAP, &len)) { if (mux) { xSemaphoreGive(mux); } return ESP_FAIL; } /* ── 分块 2:CPU ── */ len = append_cpu_status_json(resp, HTTP_JSON_WORK_CAP, 0, *cpu); if (!status_flush_chunk(req, resp, HTTP_JSON_WORK_CAP, &len)) { if (mux) { xSemaphoreGive(mux); } return ESP_FAIL; } /* ── 分块 3:SPI / ADC / features / boot / memory ── */ { SpiMaster::SpiLinkStatus ls = SpiMaster::instance().linkStatus(); const char *role_str = "unknown"; switch (ls.gd32_role) { case 1: role_str = "app"; break; case 2: role_str = "boot"; break; default: break; } len = http_json_append(resp, HTTP_JSON_WORK_CAP, 0, ",\"spi_link\":{\"version\":%u,\"gd32_role\":\"%s\",\"session_id\":\"0x%08lX\"," "\"v2_active\":%s,\"local_credit\":%u,\"remote_credit\":%u," "\"crc_errors\":%lu,\"duplicate_requests\":%lu,\"seq_errors\":%lu," "\"retries\":%lu,\"ready_timeouts\":%lu,\"rx_overruns\":%lu," "\"resyncs\":%lu,\"max_resp_latency_us\":%lu}", (unsigned)ls.version, role_str, (unsigned long)ls.session_id, SpiMaster::instance().v2Active() ? "true" : "false", (unsigned)ls.local_credit, (unsigned)ls.remote_credit, (unsigned long)ls.stats.crc_errors, (unsigned long)ls.stats.duplicate_requests, (unsigned long)ls.stats.seq_errors, (unsigned long)ls.stats.retries, (unsigned long)ls.stats.ready_timeouts, (unsigned long)ls.stats.rx_overruns, (unsigned long)ls.stats.resyncs, (unsigned long)ls.stats.max_resp_latency_us); } /* ── GD32 APP 初始化状态(GD32 1.5.34+)── * state 来自身份块 [15](HELLO/PING 自动刷新,零成本); * 详情(模块位图/失败位图/复位源)惰性查询 0xA3:仅 APP 角色、缓存无效、 * 距上次尝试 ≥10s(失败退避,老固件不认识 0xA3 时每 10s 才多发一帧)。 * init 状态静态不变,成功一次即终身缓存(session 重建时 SpiMaster 自清)。 */ { const uint8_t init_state = SpiMaster::instance().gd32InitState(); const bool state_known = (init_state != GD32_INIT_STATE_UNKNOWN); gd32_init_status_t ist = {}; bool detail_known = SpiMaster::instance().gd32InitStatusCached(&ist); static uint32_t s_initStatusLastTryMs = 0; const uint32_t now_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); if (!detail_known && SpiMaster::instance().gd32Role() == V2_ROLE_APP && (now_ms - s_initStatusLastTryMs) >= 10000U) { s_initStatusLastTryMs = now_ms; if (SpiMaster::instance().appInitStatus(300U, nullptr) == ESP_OK) { detail_known = SpiMaster::instance().gd32InitStatusCached(&ist); } } if (detail_known) { len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, ",\"gd32_init\":{\"state\":%u,\"state_known\":%s,\"detail_known\":true," "\"module_mask\":%lu,\"critical_fail_mask\":%lu,\"reset_cause\":%u", (unsigned)ist.init_state, state_known ? "true" : "false", (unsigned long)ist.module_mask, (unsigned long)ist.critical_fail_mask, (unsigned)ist.reset_cause); /* GD32 1.5.36+:每模块 1B 故障码数组(前端按位序解码中文原因) */ if (len > 0 && ist.has_fault_codes) { len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, ",\"fault_codes\":["); for (uint8_t fi = 0; fi < GD32_INIT_MODULE_COUNT && len > 0; ++fi) { len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, "%s%u", fi == 0 ? "" : ",", (unsigned)ist.fault_codes[fi]); } if (len > 0) { len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, "]"); } } if (len > 0) { len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, "}"); } } else { len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, ",\"gd32_init\":{\"state\":%u,\"state_known\":%s,\"detail_known\":false}", (unsigned)(state_known ? init_state : 0U), state_known ? "true" : "false"); } } { gd32_adc_feedback_t ga = {}; const bool have = getLatestGd32AdcFeedback(&ga); const uint32_t age = gd32AdcFeedbackAgeMs(); const bool fresh = have && age != UINT32_MAX && age < 5000U; const bool feat_known = SpiMaster::instance().gd32FeatKnown(); const bool feat_adc = SpiMaster::instance().gd32FeatAdcFeedback(); const char *en = feat_known ? (feat_adc ? "true" : "false") : (have ? "true" : "null"); if (have) { len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, ",\"gd32_adc\":{\"enabled\":%s,\"feat_known\":%s,\"fresh\":%s,\"valid\":%s," "\"temperature\":%.2f,\"vdda\":%.3f,\"vin_adc\":%.3f,\"vcc\":%.3f," "\"temp_raw\":%u,\"vref_raw\":%u,\"seq\":%lu," "\"uptime_sec\":%lu,\"age_ms\":%lu}", en, feat_known ? "true" : "false", fresh ? "true" : "false", ga.valid ? "true" : "false", (double)ga.temperature_c, (double)ga.vdda_v, (double)ga.vin_adc_v, (double)ga.vcc_v, (unsigned)ga.temp_raw, (unsigned)ga.vref_raw, (unsigned long)ga.seq, (unsigned long)ga.uptime_sec, (unsigned long)(age == UINT32_MAX ? 0UL : (unsigned long)age)); } else { len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, ",\"gd32_adc\":{\"enabled\":%s,\"feat_known\":%s,\"fresh\":false,\"valid\":false}", en, feat_known ? "true" : "false"); } const uint32_t fl = SpiMaster::instance().gd32FeatFlags(); len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, ",\"gd32_features\":{\"known\":%s,\"flags\":%lu," "\"adc_feedback\":%s,\"uart_temp_print\":%s," "\"spi_app_log\":%s,\"can_brs\":%s," "\"can_ch0\":%s,\"can_ch1\":%s,\"can_ch2\":%s,\"can_ch_mask\":%u}", feat_known ? "true" : "false", (unsigned long)fl, (feat_known && (fl & V2_FEAT_ADC_FEEDBACK)) ? "true" : "false", (feat_known && (fl & V2_FEAT_UART_TEMP_PRINT)) ? "true" : "false", (feat_known && (fl & V2_FEAT_SPI_APP_LOG)) ? "true" : "false", (feat_known && (fl & V2_FEAT_CAN_BRS)) ? "true" : "false", (feat_known && (fl & V2_FEAT_CAN_CH0)) ? "true" : "false", (feat_known && (fl & V2_FEAT_CAN_CH1)) ? "true" : "false", (feat_known && (fl & V2_FEAT_CAN_CH2)) ? "true" : "false", feat_known ? (unsigned)((fl & V2_FEAT_CAN_CH_MASK) >> V2_FEAT_CAN_CH_SHIFT) : 7U); } len = SystemBoot::appendStatusJson(resp, HTTP_JSON_WORK_CAP, len); /* memory 动态字段:每次轮询;layout 仅 ?layout=1 时附带(链接期常量)。 */ if (want_mem_layout) { len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, ",\"memory\":{\"layout\":{\"chip\":\"esp32c3\",\"sram_total\":%lu,\"sram_soc\":%lu," "\"static_reserved\":%lu,\"reserved_tail\":%lu," "\"iram\":%lu,\"data\":%lu,\"bss\":%lu," "\"heap_total\":%lu,\"dma_total\":%lu,\"rtc_total\":%lu," "\"task_stack_total\":%lu,\"default_heap_total\":%lu}," "\"heap_used\":%lu,\"heap_free\":%lu," "\"heap_min_free\":%lu,\"heap_largest_free\":%lu," "\"alloc_blocks\":%lu,\"free_blocks\":%lu," "\"dma_free\":%lu,\"rtc_free\":%lu,\"default_heap_free\":%lu}", (unsigned long)sram_total, (unsigned long)sram_soc, (unsigned long)static_reserved, (unsigned long)reserved_tail, (unsigned long)iram_bytes, (unsigned long)data_bytes, (unsigned long)bss_bytes, (unsigned long)internal_heap_total, (unsigned long)dma_total, (unsigned long)rtc_total, (unsigned long)task_stack_total_bytes, (unsigned long)default_heap_total, (unsigned long)internal_heap_used, (unsigned long)internal_info.total_free_bytes, (unsigned long)internal_info.minimum_free_bytes, (unsigned long)internal_info.largest_free_block, (unsigned long)internal_info.allocated_blocks, (unsigned long)internal_info.free_blocks, (unsigned long)dma_free, (unsigned long)rtc_free, (unsigned long)free_heap); } else { len = http_json_append(resp, HTTP_JSON_WORK_CAP, len, ",\"memory\":{\"heap_used\":%lu,\"heap_free\":%lu," "\"heap_min_free\":%lu,\"heap_largest_free\":%lu," "\"alloc_blocks\":%lu,\"free_blocks\":%lu," "\"dma_free\":%lu,\"rtc_free\":%lu,\"default_heap_free\":%lu}", (unsigned long)internal_heap_used, (unsigned long)internal_info.total_free_bytes, (unsigned long)internal_info.minimum_free_bytes, (unsigned long)internal_info.largest_free_block, (unsigned long)internal_info.allocated_blocks, (unsigned long)internal_info.free_blocks, (unsigned long)dma_free, (unsigned long)rtc_free, (unsigned long)free_heap); } if (!status_flush_chunk(req, resp, HTTP_JSON_WORK_CAP, &len)) { if (mux) { xSemaphoreGive(mux); } return ESP_FAIL; } /* ── 分块 4:task_stack + 闭合 ── */ len = http_json_append(resp, HTTP_JSON_WORK_CAP, 0, ",\"task_stack\": {"); for (size_t i = 0; i < task_metrics_count; ++i) { len = http_json_append(resp, HTTP_JSON_WORK_CAP, 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 = http_json_append(resp, HTTP_JSON_WORK_CAP, len, "}}"); if (!status_flush_chunk(req, resp, HTTP_JSON_WORK_CAP, &len)) { if (mux) { xSemaphoreGive(mux); } return ESP_FAIL; } /* 结束 chunked body */ (void)httpd_resp_send_chunk(req, NULL, 0); if (mux) { xSemaphoreGive(mux); } return ESP_OK; } // ============================================================ // GET /system/probe — GD32 APP 单次 SPI 探测 // ============================================================ /** * @brief 显式触发一次 GD32 APP 存活探测。 * 成功时更新 OtaRelay 缓存的 alive/version 并返回版本信息。 */ esp_err_t system_probe_handler(httpd_req_t *req) { http_set_cors_headers(req); http_set_keepalive_hdr(req); gd32_version_t ver = {}; uint32_t uptime = 0; esp_err_t err = OtaCoordinator::instance().probeGd32AppOnce(&ver, &uptime); char resp[256]; int len = snprintf(resp, sizeof(resp), "{\"alive\": %s, \"uptime_sec\": %" PRIu32 ", " "\"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}", (err == ESP_OK) ? "true" : "false", uptime, ver.app_major, ver.app_minor, ver.app_patch, ver.app_build, ver.boot_major, ver.boot_minor, ver.boot_patch, ver.boot_build); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); return ESP_OK; } // ============================================================ // GET /system/heap_low_log — 设备唯一快照槽(0 或 1 条) // ============================================================ /** * @brief 返回设备侧最新一条"最低空闲创新低"上下文。 * 多条历史由网页 IndexedDB 累积;JSON 复用 s_http_json_work(无独立 .bss)。 */ esp_err_t system_heap_low_log_handler(httpd_req_t *req) { http_set_cors_headers(req); SemaphoreHandle_t mux = httpJsonWorkMutex(); if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, "{\"records\":[]}", HTTPD_RESP_USE_STRLEN); return ESP_OK; } char *resp = s_http_json_work; int len = HealthMonitor::instance().dumpHeapLowWaterJson(resp, HTTP_JSON_WORK_CAP, 0); if (len < 0) { len = 0; } if ((size_t)len >= HTTP_JSON_WORK_CAP) { len = (int)HTTP_JSON_WORK_CAP - 1; } resp[len] = '\0'; httpd_resp_set_type(req, "application/json"); esp_err_t err = httpd_resp_send(req, resp, len); if (mux) { xSemaphoreGive(mux); } return err; } /* ──────────────────────────────────────────────────────────── * GET /motor/state — 多电机状态表(来自 GD32 0xC5 推送) * ──────────────────────────────────────────────────────────── */ esp_err_t motor_state_handler(httpd_req_t *req) { http_set_cors_headers(req); http_set_keepalive_hdr(req); motor_state_table_t tbl = {}; const bool have = getLatestMotorState(&tbl); const uint32_t age = motorStateAgeMs(); /* 二进制模式:?bin=1(前端默认走此路径,体积比 JSON 省 66%)。 * 协议(LE,Header 12B + 每节点 Entry 92B,v0.74: STATUS DLC 48): * Header: magic u16 = 0x4D53 ('MS') * ver u8 = 2 (v0.74: entry 76→92 / data 32→48) * flags u8 = 0 * age_ms u32 (0xFFFFFFFF = 无数据) * active u8 (在线电机数) * count u8 (有效 entry 数) * rsvd u16 = 0 * Entry: node u8, ch u8, online u8, dlc u8, * rx_count u32, seq u32, data[48], * config_rx_count u32, config_seq u32, config_data[24] * 27 槽(9 node × 3 ch,node×channel 双键)最大 12 + 27*92 = 2508B。 * 无 have_data 时仍发 header(count=0),让前端区分「无数据」与「空总线」。 */ bool want_bin = http_query_bool(req, "bin"); if (want_bin) { httpd_resp_set_type(req, "application/octet-stream"); /* 静态缓冲:27 槽扩容后 1200B 不再上栈(httpd 栈 7KB 已紧); * httpd 单任务串行处理请求,静态缓冲无竞态。 */ static uint8_t buf[12 + MOTOR_MAX_SLOTS * MOTOR_ENTRY_SIZE]; size_t off = 0; /* magic 'MS' (LE: 0x53, 0x4D) */ buf[off++] = 0x53U; buf[off++] = 0x4DU; buf[off++] = 2U; /* version (v0.74: entry 92 / data 48) */ /* flags bit0 = boot_active(总线有电机在 BOOT,0x103 心跳) */ buf[off++] = (have && tbl.boot_active) ? 0x01U : 0x00U; /* age_ms LE u32 */ const uint32_t age_send = (age == UINT32_MAX) ? 0xFFFFFFFFU : age; buf[off++] = (uint8_t)(age_send & 0xFF); buf[off++] = (uint8_t)((age_send >> 8) & 0xFF); buf[off++] = (uint8_t)((age_send >> 16) & 0xFF); buf[off++] = (uint8_t)((age_send >> 24) & 0xFF); buf[off++] = have ? tbl.active_count : 0U; /* active */ const size_t count_pos = off; /* count,稍后回填 */ off += 3U; /* count u8 + rsvd u16 */ /* off 现在应该是 12 */ assert(off == 12); uint8_t count = 0; if (have) { for (uint8_t i = 0; i < MOTOR_MAX_SLOTS; ++i) { const motor_slot_t &s = tbl.slots[i]; if (s.node_id == 0) continue; buf[off++] = s.node_id; buf[off++] = s.channel; buf[off++] = s.online; buf[off++] = s.dlc; /* rx_count LE */ uint32_t r = s.rx_count; buf[off++] = (uint8_t)(r & 0xFF); buf[off++] = (uint8_t)((r >> 8) & 0xFF); buf[off++] = (uint8_t)((r >> 16) & 0xFF); buf[off++] = (uint8_t)((r >> 24) & 0xFF); /* seq LE */ uint32_t sq = s.seq; buf[off++] = (uint8_t)(sq & 0xFF); buf[off++] = (uint8_t)((sq >> 8) & 0xFF); buf[off++] = (uint8_t)((sq >> 16) & 0xFF); buf[off++] = (uint8_t)((sq >> 24) & 0xFF); memcpy(&buf[off], s.data, MOTOR_STATUS_DLC); off += MOTOR_STATUS_DLC; /* config_rx_count LE */ uint32_t cr = s.config_rx_count; buf[off++] = (uint8_t)(cr & 0xFF); buf[off++] = (uint8_t)((cr >> 8) & 0xFF); buf[off++] = (uint8_t)((cr >> 16) & 0xFF); buf[off++] = (uint8_t)((cr >> 24) & 0xFF); /* config_seq LE */ uint32_t csq = s.config_seq; buf[off++] = (uint8_t)(csq & 0xFF); buf[off++] = (uint8_t)((csq >> 8) & 0xFF); buf[off++] = (uint8_t)((csq >> 16) & 0xFF); buf[off++] = (uint8_t)((csq >> 24) & 0xFF); /* config_data[24] */ memcpy(&buf[off], s.config_data, MOTOR_CONFIG_DLC); off += MOTOR_CONFIG_DLC; count++; } } buf[count_pos] = count; return httpd_resp_send(req, (const char *)buf, off); } /* JSON 兼容路径(保留旧消费者,?bin 缺省时走此分支) */ httpd_resp_set_type(req, "application/json"); if (!have) { httpd_resp_sendstr(req, "{\"have_data\":false,\"active_count\":0,\"motors\":[]}"); return ESP_OK; } SemaphoreHandle_t mux = httpJsonWorkMutex(); if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "motor busy"); return ESP_FAIL; } char *w = s_http_json_work; /* http_json_append 截断时钳制在 cap-1;snprintf 截断返回期望长度, * off += snprintf 会越过 cap 导致 resp_send 越界读 */ int off = http_json_append(w, HTTP_JSON_WORK_CAP, 0, "{\"have_data\":true,\"active_count\":%u,\"boot_active\":%s,\"age_ms\":%lu,\"motors\":[", (unsigned)tbl.active_count, tbl.boot_active ? "true" : "false", (unsigned long)age); bool first = true; for (uint8_t i = 0; i < MOTOR_MAX_SLOTS && off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 128; ++i) { const motor_slot_t &s = tbl.slots[i]; if (s.node_id == 0) continue; /* data hex */ char hex[MOTOR_STATUS_DLC * 2 + 1]; http_bytes_to_hex_upper(s.data, MOTOR_STATUS_DLC, hex); char chex[MOTOR_CONFIG_DLC * 2 + 1]; http_bytes_to_hex_upper(s.config_data, MOTOR_CONFIG_DLC, chex); off = http_json_append(w, HTTP_JSON_WORK_CAP, off, "%s{\"node\":%u,\"ch\":%u,\"online\":%s,\"dlc\":%u," "\"rx_count\":%lu,\"seq\":%lu,\"data\":\"%s\"," "\"config_rx_count\":%lu,\"config_seq\":%lu,\"config_data\":\"%s\"}", first ? "" : ",", (unsigned)s.node_id, (unsigned)s.channel, s.online ? "true" : "false", (unsigned)s.dlc, (unsigned long)s.rx_count, (unsigned long)s.seq, hex, (unsigned long)s.config_rx_count, (unsigned long)s.config_seq, chex); first = false; } if (off > 0) { off = http_json_append(w, HTTP_JSON_WORK_CAP, off, "]}"); } esp_err_t ret = httpd_resp_send(req, w, off > 0 ? off : 0); if (mux) { xSemaphoreGive(mux); } return ret; } /** * @brief /motor/version — 返回 GD32 缓存的电机版本(0xC6 事件推送)。 * * 版本为按需查询的低频数据:前端发 0x00FF 请求触发电机应答,GD32 缓存后经 * 0xC6 推给 ESP32,本端点从缓存读取。前端按 seq 判断是否为本次查询的新应答。 * * JSON: {"have_data":bool,"nodes":[{"node":N,"seq":N,"age_ms":N,"version":"HEX(24B)"}]} */ esp_err_t motor_version_handler(httpd_req_t *req) { http_set_cors_headers(req); http_set_keepalive_hdr(req); motor_version_table_t tbl = {}; const bool have = getLatestMotorVersion(&tbl); httpd_resp_set_type(req, "application/json"); if (!have) { httpd_resp_sendstr(req, "{\"have_data\":false,\"nodes\":[]}"); return ESP_OK; } SemaphoreHandle_t mux = httpJsonWorkMutex(); if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "motor version busy"); return ESP_FAIL; } const int64_t now_ms = esp_timer_get_time() / 1000; char *w = s_http_json_work; int off = http_json_append(w, HTTP_JSON_WORK_CAP, 0, "{\"have_data\":true,\"nodes\":["); bool first = true; for (uint8_t i = 0; i < MOTORVER_MAX_NODES && off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 128; ++i) { const motor_version_slot_t &s = tbl.slots[i]; if (!s.valid || s.node_id == 0) continue; char hex[MOTORVER_DLC * 2 + 1]; http_bytes_to_hex_upper(s.version, MOTORVER_DLC, hex); const long age = (s.recv_ms > 0 && now_ms >= s.recv_ms) ? static_cast(now_ms - s.recv_ms) : 0L; off = http_json_append(w, HTTP_JSON_WORK_CAP, off, "%s{\"node\":%u,\"seq\":%lu,\"age_ms\":%ld,\"version\":\"%s\"}", first ? "" : ",", (unsigned)s.node_id, (unsigned long)s.seq, age, hex); first = false; } if (off > 0) { off = http_json_append(w, HTTP_JSON_WORK_CAP, off, "]}"); } esp_err_t ret = httpd_resp_send(req, w, off > 0 ? off : 0); if (mux) { xSemaphoreGive(mux); } return ret; } /* ═══════════════════ 电机 OTA relay ═══════════════════ */ esp_err_t motor_ota_upload_handler(httpd_req_t *req) { http_set_cors_headers(req); const uint32_t ch_q = http_query_u32(req, "ch", 0); if (ch_q >= CAN_CHANNEL_MAX) { httpd_resp_set_status(req, "400 Bad Request"); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, "{\"error\":\"bad channel (0~2)\"}"); return ESP_FAIL; } uint8_t channel = static_cast(ch_q); const int content_len = req->content_len; if (content_len <= 0 || content_len > (int)MOTOR_OTA_MAX_FW_SIZE) { httpd_resp_set_status(req, "400 Bad Request"); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, "{\"error\":\"firmware too large or empty\"}"); return ESP_FAIL; } /* ── 协调:电机固件与 GD32E508 固件共用 gd32_ota 分区,先互斥 + 安全清理 ── */ OtaCoordinator &coord = OtaCoordinator::instance(); /* 有活跃 OTA(GD32 STORING/RELAYING、ESP32_* 等)时拒绝 */ if (!coord.isIdleState(coord.getSystemState())) { httpd_resp_set_status(req, "409 Conflict"); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, "{\"error\":\"system busy with another OTA\"}"); return ESP_FAIL; } if (motorOtaIsActive()) { httpd_resp_set_status(req, "409 Conflict"); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, "{\"error\":\"motor OTA already in progress\"}"); return ESP_FAIL; } /* 即将覆写 gd32_ota 分区:先失效已暂存的 GD32 固件并复位协调器到 IDLE, 避免 /ota/relay 误把电机固件当成 GD32 固件中继刷进主控 */ coord.abortCurrent(); (void)coord.clearGd32Meta(); /* ── 流式收固件写 flash(无整固件堆缓冲;复用 OTA 静态收块)── * ★ full_erase=true:强制擦除整个 gd32_ota 分区(252KB ~200ms), * 杜绝上次固件残留 + 擦除位错误导致的单 bit 翻转(0xAC20 处 0x19→0x18 根因)。 */ Gd32FlashStore::instance().beginWriteSession(true); int received = 0; while (received < content_len) { size_t to_read = (size_t)content_len - received; if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; otaUploadPaceIfHeapTight(); int r = httpd_req_recv(req, reinterpret_cast(s_ota_upload_recv), to_read); if (r <= 0) { DEBUG_ERR("MOTOR_OTA", "upload interrupted: received=%d expected=%d", received, content_len); httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "recv failed"); return ESP_FAIL; } esp_err_t werr = Gd32FlashStore::instance().flashWriteChunk( (uint32_t)received, s_ota_upload_recv, (size_t)r); if (werr != ESP_OK) { DEBUG_ERR("MOTOR_OTA", "flash write fail @%d: %s", received, esp_err_to_name(werr)); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "flash write failed"); return ESP_FAIL; } received += r; (void)esp_task_wdt_reset(); } /* ── 收完固件写 meta(供 /motor/ota/relay 确认固件完整)── * 不在此处触发 SPI START + 喂数据:拆分两段式,与 BLE 刷 GD32 的 * "prepare+收流 → /ota/relay" 对齐。收固件阶段纯写 flash,不碰 SPI, * 避免与 GD32 按铃推送争抢单缓冲 MISO 产生的 "SPI start failed" 竞态。 */ ota_meta_t meta = {}; meta.total_size = static_cast(content_len); meta.stored_size = static_cast(received); meta.last_seq = 0; esp_err_t merr = Gd32FlashStore::instance().metaSave(&meta); if (merr != ESP_OK) { DEBUG_ERR("MOTOR_OTA", "meta save fail after upload: %s", esp_err_to_name(merr)); httpd_resp_set_status(req, "500 Internal Server Error"); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, "{\"error\":\"meta save failed\"}"); return ESP_FAIL; } /* ★ 上传收尾计算 flash SHA-256 并存入 meta.checksum。 * 电机 OTA 之前完全没有完整性校验:HTTP 流式写 flash → metaSave → relay 直接刷。 * 一旦 flash 写入错位/损坏,ESP32 会把"一致的垃圾"喂给 GD32(CAN CRC 全过但 APP 跑飞)。 * 这里在收流结束立即算 SHA-256,relay 触发时 metaVerifyChecksum 复核,从源头堵住坏数据。 */ esp_err_t sha_err = Gd32FlashStore::instance().metaFinalizeChecksum(); if (sha_err != ESP_OK) { DEBUG_ERR("MOTOR_OTA", "SHA-256 finalize fail after upload: %s", esp_err_to_name(sha_err)); httpd_resp_set_status(req, "500 Internal Server Error"); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, "{\"error\":\"sha256 compute failed\"}"); return ESP_FAIL; } /* ★ 把设备端 flash SHA 直接回给前端:前端在发进 BOOT 帧之前就能比对, * flash 写坏时电机还在 APP 正常运行即可中止,不用等到 relay 阶段 * (那时电机已被踢进 BOOT 白等)。relay 的三方比对仍保留作兜底。 */ ota_meta_t sha_meta = {}; char sha_hex[65] = {0}; if (Gd32FlashStore::instance().metaLoad(&sha_meta) == ESP_OK) { http_bytes_to_hex_lower(sha_meta.checksum, 32, sha_hex); } httpd_resp_set_type(req, "application/json"); char resp_buf[256]; snprintf(resp_buf, sizeof(resp_buf), "{\"status\":\"stored\",\"message\":\"call POST /motor/ota/relay?ch=%u to trigger\"," "\"channel\":%u,\"size\":%d,\"sha256\":\"%s\"}", (unsigned)channel, (unsigned)channel, received, sha_hex); httpd_resp_sendstr(req, resp_buf); return ESP_OK; } esp_err_t motor_ota_relay_handler(httpd_req_t *req) { http_set_cors_headers(req); http_set_keepalive_hdr(req); DEBUG_INFO("MOTOR_OTA", "=== HTTP: POST /motor/ota/relay ==="); const uint32_t ch_q = http_query_u32(req, "ch", 0); if (ch_q >= CAN_CHANNEL_MAX) { httpd_resp_set_status(req, "400 Bad Request"); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, "{\"error\":\"bad channel (0~2)\"}"); return ESP_FAIL; } uint8_t channel = static_cast(ch_q); /* 读 meta 拿 fw_size(motorOtaStart 参数的唯一来源)。 * total_size==0 = 无固件可刷(未上传过,或上次 relay 成功后 metaClear 已消费)。 * 数量完整性由上传流程保证:上传开头 clearGd32Meta,收满 content_len 才 metaSave, * 中断则 meta 保持空——因此这里无需再查 stored_size。 */ ota_meta_t meta = {}; if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK || meta.total_size == 0) { return http_send_json_error(req, "400 Bad Request", "firmware_not_stored", "no firmware stored (upload first)"); } /* relay 不做 SHA 校验(2026-07-27):完整性校验已在上传阶段闭环 *(ESP32 从 flash 读回算 SHA 随响应返回,前端比对 + 失败重传)。 * relay 只是启动扳机;body 一律忽略,仅排空以维持 keep-alive 解析。 */ if (req->content_len > 0) { char drain[128]; int remaining = req->content_len; while (remaining > 0) { const int chunk = remaining > (int)sizeof(drain) ? (int)sizeof(drain) : remaining; const int r = httpd_req_recv(req, drain, (size_t)chunk); if (r <= 0) break; remaining -= r; } } if (motorOtaIsActive()) { httpd_resp_set_status(req, "409 Conflict"); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, "{\"error\":\"motor OTA already in progress\"}"); return ESP_FAIL; } /* 触发 SPI START + 启动 feedTask(从 flash 分块读取喂 GD32)*/ const int result = motorOtaStart(meta.total_size, channel); httpd_resp_set_type(req, "application/json"); if (result == 0) { /* 成功触发后清 meta:固件已交给 feedTask,避免残留导致下次误判 */ (void)Gd32FlashStore::instance().metaClear(); httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"OTA started\"}"); } else if (result == -2) { httpd_resp_set_status(req, "409 Conflict"); httpd_resp_sendstr(req, "{\"error\":\"OTA already in progress\"}"); } else { httpd_resp_set_status(req, "500 Internal Server Error"); httpd_resp_sendstr(req, "{\"error\":\"SPI start failed\"}"); } return ESP_OK; } esp_err_t motor_ota_status_handler(httpd_req_t *req) { http_set_cors_headers(req); http_set_keepalive_hdr(req); const motor_ota_status_t st = motorOtaGetStatus(); static const char *state_names[] = { "idle", "handshake_req", "handshake_nonce", "handshake_confirm", "erase", "transfer", "finish_ota", "finished", "error", "pre_cancel", "wait_app_run" }; const int si = (st.state <= MOTOR_OTA_WAIT_APP_RUN) ? (int)st.state : 0; char buf[256]; snprintf(buf, sizeof(buf), "{\"state\":\"%s\",\"error\":%u,\"packets_acked\":%u,\"total_packets\":%u," "\"fw_size\":%lu,\"buf_used\":%u}", state_names[si], st.error_code, st.packets_acked, st.total_packets, (unsigned long)st.fw_size, st.buf_used); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, buf); return ESP_OK; } esp_err_t motor_ota_cancel_handler(httpd_req_t *req) { http_set_cors_headers(req); DEBUG_WARN("MOTOR_OTA", "cancel HTTP request received, calling motorOtaCancel()"); motorOtaCancel(); /* 两阶段都可取消:触发前未跑 feedTask 时 motorOtaCancel 是空操作, * 但仍清 GD32 侧状态 + 清 flash meta,避免残留固件占分区/被误判为已存。 * 触发后取消也清 meta 无害(feedTask 已停)。 */ (void)Gd32FlashStore::instance().metaClear(); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"cancelled\"}"); return ESP_OK; }