diff --git a/main/include/version.h b/main/include/version.h index f513cc6..2b3054e 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 32 -#define FW_VERSION_STRING "0.3.32" +#define FW_VERSION_PATCH 33 +#define FW_VERSION_STRING "0.3.33" // 编译时间 -#define BUILD_TIME "2026-06-23 00:04:33" +#define BUILD_TIME "2026-06-25 01:39:58" // Git 信息 -#define GIT_HASH "1491e9a" +#define GIT_HASH "7a41eac" diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index 3031c92..adb5ec0 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_http_server.h" @@ -667,6 +668,8 @@ esp_err_t spi_send_handler(httpd_req_t *req) } static constexpr uint32_t INTERNAL_HEAP_CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; +static constexpr size_t SYSTEM_STATUS_JSON_CAP = 8192; +static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 24; struct TaskStackMetric { const char* name; @@ -674,6 +677,146 @@ struct TaskStackMetric { size_t stack_size_bytes; }; +struct CpuTaskSample { + UBaseType_t task_number; + configRUN_TIME_COUNTER_TYPE runtime; +}; + +struct CpuTaskMetric { + const char* name; + UBaseType_t task_number; + uint64_t runtime_delta; + double usage_pct; +}; + +struct CpuUsageSnapshot { + bool available; + bool valid; + uint32_t sample_ms; + double usage_pct; + double idle_pct; + CpuTaskMetric tasks[CPU_TASK_SNAPSHOT_MAX]; + size_t task_count; +}; + +static portMUX_TYPE s_cpu_stats_mux = portMUX_INITIALIZER_UNLOCKED; +static CpuTaskSample s_cpu_last_samples[CPU_TASK_SNAPSHOT_MAX]; +static size_t s_cpu_last_count = 0; +static configRUN_TIME_COUNTER_TYPE s_cpu_last_total = 0; +static int64_t s_cpu_last_time_us = 0; +static bool s_cpu_has_last = false; + +static uint64_t runtime_delta(configRUN_TIME_COUNTER_TYPE current, configRUN_TIME_COUNTER_TYPE previous) +{ + return current >= previous ? (uint64_t)(current - previous) : 0U; +} + +static bool task_name_is_idle(const char* name) +{ + return name && strncmp(name, "IDLE", 4) == 0; +} + +static void reset_cpu_usage_baseline_locked(const TaskStatus_t* tasks, size_t task_count, + configRUN_TIME_COUNTER_TYPE total_runtime, + int64_t now_us) +{ + size_t copy_count = std::min(task_count, CPU_TASK_SNAPSHOT_MAX); + for (size_t i = 0; i < copy_count; ++i) { + s_cpu_last_samples[i].task_number = tasks[i].xTaskNumber; + s_cpu_last_samples[i].runtime = tasks[i].ulRunTimeCounter; + } + s_cpu_last_count = copy_count; + s_cpu_last_total = total_runtime; + s_cpu_last_time_us = now_us; + s_cpu_has_last = true; +} + +static CpuUsageSnapshot sample_cpu_usage() +{ + CpuUsageSnapshot result = {}; +#if defined(CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS) + result.available = true; + + UBaseType_t task_count = uxTaskGetNumberOfTasks(); + if (task_count == 0) { + return result; + } + task_count = std::min(task_count + 2U, CPU_TASK_SNAPSHOT_MAX); + + TaskStatus_t* tasks = (TaskStatus_t*)calloc(task_count, sizeof(TaskStatus_t)); + if (!tasks) { + return result; + } + + configRUN_TIME_COUNTER_TYPE total_runtime = 0; + UBaseType_t captured_count = uxTaskGetSystemState(tasks, task_count, &total_runtime); + int64_t now_us = esp_timer_get_time(); + if (captured_count == 0 || captured_count > task_count) { + free(tasks); + return result; + } + + CpuTaskSample previous_samples[CPU_TASK_SNAPSHOT_MAX] = {}; + size_t previous_count = 0; + configRUN_TIME_COUNTER_TYPE previous_total = 0; + int64_t previous_time_us = 0; + bool had_previous = false; + + portENTER_CRITICAL(&s_cpu_stats_mux); + previous_count = s_cpu_last_count; + previous_total = s_cpu_last_total; + previous_time_us = s_cpu_last_time_us; + had_previous = s_cpu_has_last; + for (size_t i = 0; i < previous_count; ++i) { + previous_samples[i] = s_cpu_last_samples[i]; + } + reset_cpu_usage_baseline_locked(tasks, captured_count, total_runtime, now_us); + portEXIT_CRITICAL(&s_cpu_stats_mux); + + bool can_compute = had_previous && total_runtime >= previous_total && now_us > previous_time_us; + uint64_t total_delta = can_compute ? runtime_delta(total_runtime, previous_total) : 0U; + uint64_t idle_delta = 0U; + if (can_compute && total_delta > 0U) { + for (UBaseType_t i = 0; i < captured_count; ++i) { + configRUN_TIME_COUNTER_TYPE last_runtime = 0; + bool found_previous = false; + for (size_t j = 0; j < previous_count; ++j) { + if (previous_samples[j].task_number == tasks[i].xTaskNumber) { + last_runtime = previous_samples[j].runtime; + found_previous = true; + break; + } + } + if (!found_previous) { + continue; + } + uint64_t delta = runtime_delta(tasks[i].ulRunTimeCounter, last_runtime); + double pct = (double)delta * 100.0 / (double)total_delta; + if (task_name_is_idle(tasks[i].pcTaskName)) { + idle_delta += delta; + } + if (result.task_count < CPU_TASK_SNAPSHOT_MAX) { + CpuTaskMetric& metric = result.tasks[result.task_count++]; + metric.name = tasks[i].pcTaskName ? tasks[i].pcTaskName : ""; + metric.task_number = tasks[i].xTaskNumber; + metric.runtime_delta = delta; + metric.usage_pct = pct; + } + } + + result.valid = true; + result.sample_ms = (uint32_t)((now_us - previous_time_us) / 1000); + result.idle_pct = std::min(100.0, (double)idle_delta * 100.0 / (double)total_delta); + result.usage_pct = std::max(0.0, 100.0 - result.idle_pct); + } + + free(tasks); +#else + result.available = false; +#endif + return result; +} + static size_t get_task_stack_high_watermark_bytes(TaskHandle_t handle) { if (handle == NULL) return 0; @@ -703,6 +846,30 @@ static int append_jsonf(char* buf, size_t cap, int offset, const char* fmt, ...) return offset + (int)written; } +static int append_cpu_status_json(char* buf, size_t cap, int offset, const CpuUsageSnapshot& cpu) +{ + offset = append_jsonf(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)); + + for (size_t i = 0; i < cpu.task_count; ++i) { + const CpuTaskMetric& task = cpu.tasks[i]; + offset = append_jsonf(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 append_jsonf(buf, cap, offset, "]},"); +} + esp_err_t system_status_handler(httpd_req_t *req) { http_set_cors_headers(req); @@ -744,9 +911,15 @@ esp_err_t system_status_handler(httpd_req_t *req) { "ble_ota", BleOta::instance().getTaskHandle(), 4096 }, { "ble_ota_worker", BleOta::instance().getWorkerTaskHandle(), 4096 } }; + CpuUsageSnapshot cpu = sample_cpu_usage(); - char resp[4096]; - int len = snprintf(resp, sizeof(resp), + char* resp = (char*)calloc(1, SYSTEM_STATUS_JSON_CAP); + if (!resp) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "no memory"); + return ESP_FAIL; + } + + int len = snprintf(resp, SYSTEM_STATUS_JSON_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, " @@ -755,8 +928,7 @@ esp_err_t system_status_handler(httpd_req_t *req) "\"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\": {", + "\"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}, ", FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH, HealthMonitor::instance().getCurrent(), (unsigned long)free_heap, (unsigned long)min_free_heap, @@ -786,8 +958,10 @@ esp_err_t system_status_handler(httpd_req_t *req) snap.relay.version.boot_major, snap.relay.version.boot_minor, snap.relay.version.boot_patch, snap.relay.version.boot_build); + len = append_cpu_status_json(resp, SYSTEM_STATUS_JSON_CAP, len, cpu); + len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, "\"task_stack\": {"); for (size_t i = 0; i < sizeof(task_metrics) / sizeof(task_metrics[0]); ++i) { - len = append_jsonf(resp, sizeof(resp), len, + len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, "%s\"%s\":{\"stack_size_bytes\":%lu,\"high_watermark_bytes\":%lu,\"peak_used_bytes\":%lu}", i == 0 ? "" : ",", task_metrics[i].name, @@ -795,10 +969,11 @@ esp_err_t system_status_handler(httpd_req_t *req) (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, "}}"); + len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, "}}"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); + free(resp); return ESP_OK; } diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index ff0e1e0..7353817 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -5,10 +5,56 @@ #include "canfd_bridge.h" #include "spi_master.h" +namespace { + +constexpr uint8_t kSpiCanFrameType = 0xC0U; + +static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, + const uint8_t *data, uint8_t dlc, + uint8_t *recv_buf, uint8_t recv_max, + uint32_t timeout_ms) +{ + uint8_t tx_buf[72]; + size_t tx_len = 0; + + tx_buf[tx_len++] = static_cast(channel & CAN_CHANNEL_MASK); + tx_buf[tx_len++] = static_cast((ext_id >> 24) & 0xFFU); + tx_buf[tx_len++] = static_cast((ext_id >> 16) & 0xFFU); + tx_buf[tx_len++] = static_cast((ext_id >> 8) & 0xFFU); + tx_buf[tx_len++] = static_cast(ext_id & 0xFFU); + if (dlc > 0U) { + memcpy(&tx_buf[tx_len], data, dlc); + tx_len += dlc; + } + + uint8_t resp_type = 0U; + size_t resp_len = 0U; + esp_err_t ret = SpiMaster::instance().otaTransfer( + kSpiCanFrameType, + tx_buf, + tx_len, + &resp_type, + recv_buf, + recv_max, + &resp_len, + timeout_ms); + if (ret != ESP_OK || resp_type != SPI_OTA_RSP_ACK) { + return -1; + } + + if (recv_buf == nullptr || recv_max == 0U) { + return static_cast(resp_len); + } + + const size_t copied_len = (resp_len < recv_max) ? resp_len : recv_max; + return static_cast(copied_len); +} + +} // namespace + /** * @brief 发送一帧 CANFD 数据,可选等待并读取响应。 - * 通过 SpiMaster 的公共 syncTransfer() 完成 SPI 传输。 - * 注意: syncTransfer 内部会加 SPI 互斥锁,FreeRTOS mutex 为递归锁故安全。 + * 通过 SPI 协议帧发送给 GD32 APP,并读取 ACK/ERROR 响应。 */ int canFdSend(can_channel_t channel, uint32_t ext_id, const uint8_t *data, uint8_t dlc, @@ -18,29 +64,12 @@ int canFdSend(can_channel_t channel, uint32_t ext_id, if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) { return -1; } - - uint8_t tx_buf[72]; - size_t tx_len = 0; - - tx_buf[tx_len++] = (uint8_t)(channel & CAN_CHANNEL_MASK); - tx_buf[tx_len++] = (uint8_t)((ext_id >> 24) & 0xFF); - tx_buf[tx_len++] = (uint8_t)((ext_id >> 16) & 0xFF); - tx_buf[tx_len++] = (uint8_t)((ext_id >> 8) & 0xFF); - tx_buf[tx_len++] = (uint8_t)(ext_id & 0xFF); - memcpy(&tx_buf[tx_len], data, dlc); - tx_len += dlc; - - esp_err_t ret = SpiMaster::instance().syncTransfer( - 0, tx_buf, tx_len, recv_buf, recv_max, timeout_ms); - if (ret != ESP_OK) { - return -1; - } - - return (int)recv_max; + return sendCanFrameOverSpi(channel, ext_id, data, dlc, recv_buf, recv_max, timeout_ms); } /** - * @brief 发送一帧无需响应的 CANFD 数据。 + * @brief 发送一帧无需上层消费响应的 CANFD 数据。 + * GD32 APP 仍会返回 ACK/ERROR,这里主动读走响应,避免残留到下一笔 SPI 事务。 */ int canFdSendNoReply(can_channel_t channel, uint32_t ext_id, const uint8_t *data, uint8_t dlc) @@ -49,21 +78,5 @@ int canFdSendNoReply(can_channel_t channel, uint32_t ext_id, return -1; } - uint8_t tx_buf[72]; - size_t tx_len = 0; - - tx_buf[tx_len++] = (uint8_t)(channel & CAN_CHANNEL_MASK); - tx_buf[tx_len++] = (uint8_t)((ext_id >> 24) & 0xFF); - tx_buf[tx_len++] = (uint8_t)((ext_id >> 16) & 0xFF); - tx_buf[tx_len++] = (uint8_t)((ext_id >> 8) & 0xFF); - tx_buf[tx_len++] = (uint8_t)(ext_id & 0xFF); - memcpy(&tx_buf[tx_len], data, dlc); - tx_len += dlc; - - esp_err_t ret = SpiMaster::instance().send(0, tx_buf, tx_len); - if (ret != ESP_OK) { - return -1; - } - - return 0; + return (sendCanFrameOverSpi(channel, ext_id, data, dlc, nullptr, 0U, 1000U) >= 0) ? 0 : -1; } diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 7fa4c39..8e852b3 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -7,6 +7,13 @@ CONFIG_LWIP_MAX_SOCKETS=7 CONFIG_LWIP_TCP_WND_DEFAULT=11480 CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520 +# FreeRTOS runtime stats -- /system/status CPU usage +CONFIG_FREERTOS_USE_TRACE_FACILITY=y +CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y +CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y +CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U32=y +CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y + # ── NimBLE: 禁用未使用的标准 GATT 服务(只保留自定义 OTA service)── # 每项 service 含 characteristic + descriptor + UUID 注册,合计约占 8-15 KB CONFIG_BT_NIMBLE_PROX_SERVICE=n diff --git a/tools/app.js b/tools/app.js index 527cc41..a02cf53 100644 --- a/tools/app.js +++ b/tools/app.js @@ -360,8 +360,12 @@ function setHelper(id, text, type) { el.innerHTML = text; } function updateCanHint() { - var dlc = parseInt(document.getElementById('canDlc').value) || 0; - var dataHex = document.getElementById('canData').value.trim().replace(/\s/g, ''); + var dlcEl = document.getElementById('canDlc'); + var dataEl = document.getElementById('canData'); + var hintEl = document.getElementById('canHint'); + if (!dlcEl || !dataEl || !hintEl) return; + var dlc = parseInt(dlcEl.value) || 0; + var dataHex = dataEl.value.trim().replace(/\s/g, ''); if (!dataHex) { setHelper('canHint', '输入十六进制数据后会自动检查字节数是否与 DLC 一致。', ''); return; @@ -490,6 +494,78 @@ function updateHeapAndTaskStatusUi(data) { setFill(fillId, pct); setText(pctId, pct.toFixed(1) + '%'); } + function setCpuFill(pct) { + var el = document.getElementById('cpuUsageFill'); + if (!el) return; + var safePct = Math.max(0, Math.min(100, Number(pct) || 0)); + el.style.width = safePct + '%'; + el.className = 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : ''); + } + function updateCpuUsageUi(cpu) { + cpu = cpu || {}; + var taskList = document.getElementById('cpuTaskList'); + if (!cpu.available) { + setText('cpuUsageText', '未启用 FreeRTOS 统计'); + setText('cpuUsagePct', '--'); + setText('cpuIdlePct', '--'); + setText('cpuSampleMs', '--'); + setText('cpuTaskCount', '--'); + setCpuFill(0); + if (taskList) taskList.innerHTML = '
固件未启用 CPU runtime stats
'; + return; + } + if (!cpu.valid) { + setText('cpuUsageText', '等待下一次采样...'); + setText('cpuUsagePct', '--'); + setText('cpuIdlePct', '--'); + setText('cpuSampleMs', '--'); + setText('cpuTaskCount', '--'); + setCpuFill(0); + if (taskList) taskList.innerHTML = '
首次刷新只建立基线,下一次刷新后显示 CPU 占用
'; + return; + } + var usagePct = Number(cpu.usage_pct) || 0; + var idlePct = Number(cpu.idle_pct) || 0; + var sampleMs = Number(cpu.sample_ms) || 0; + var tasks = Array.isArray(cpu.tasks) ? cpu.tasks.slice() : []; + tasks.sort(function (a, b) { + return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0); + }); + + setText('cpuUsageText', 'CPU 已用 ' + formatPercent(usagePct)); + setText('cpuUsagePct', formatPercent(usagePct)); + setText('cpuIdlePct', formatPercent(idlePct)); + setText('cpuSampleMs', sampleMs > 0 ? sampleMs + ' ms' : '--'); + setText('cpuTaskCount', String(tasks.length)); + setCpuFill(usagePct); + + if (taskList) { + var visibleTasks = tasks.slice(0, 8); + if (!visibleTasks.length) { + taskList.innerHTML = '
当前采样没有任务增量
'; + } else { + taskList.innerHTML = visibleTasks.map(function (task) { + var pct = Number(task.usage_pct) || 0; + var delta = Number(task.runtime_delta) || 0; + var fillClass = pct >= 85 ? 'err' : pct >= 70 ? 'warn' : ''; + return '' + + '
' + + '
' + + '
' + escapeHtml(task.name || 'task') + '
' + + '
' + escapeHtml(formatPercent(pct)) + '
' + + '
' + + '
' + + '
' + + '
任务号' + escapeHtml(task.task_number) + '
' + + '
增量' + escapeHtml(delta) + '
' + + '
占比' + escapeHtml(formatPercent(pct)) + '
' + + '
' + + '
'; + }).join(''); + } + } + } + updateCpuUsageUi(data.cpu); 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) : '--'); @@ -819,10 +895,18 @@ async function refreshSystemStatusNow(event) { } // ==================== CAN 帧发送 ==================== async function sendCanFrame() { - var ch = parseInt(document.getElementById('canChannel').value); - var idStr = document.getElementById('canId').value.trim(); - var dlc = parseInt(document.getElementById('canDlc').value) || 8; - var dataStr = document.getElementById('canData').value.trim(); + var chEl = document.getElementById('canChannel'); + var idEl = document.getElementById('canId'); + var dlcEl = document.getElementById('canDlc'); + var dataEl = document.getElementById('canData'); + if (!chEl || !idEl || !dlcEl || !dataEl) { + showToast('请在独立 CAN 页面中发送帧', 'warning'); + return; + } + var ch = parseInt(chEl.value); + var idStr = idEl.value.trim(); + var dlc = parseInt(dlcEl.value) || 8; + var dataStr = dataEl.value.trim(); var extId = 0; if (idStr.toLowerCase().indexOf('0x') === 0) extId = parseInt(idStr, 16); else { extId = parseInt(idStr, 10); if (isNaN(extId)) extId = parseInt(idStr, 16); } @@ -854,10 +938,15 @@ async function sendCanFrame() { } } function clearCanFields() { - document.getElementById('canId').value = '0x18DA00F1'; - document.getElementById('canDlc').value = '8'; - document.getElementById('canData').value = '0102030405060708'; - document.getElementById('canResp').style.display = 'none'; + var idEl = document.getElementById('canId'); + var dlcEl = document.getElementById('canDlc'); + var dataEl = document.getElementById('canData'); + var respEl = document.getElementById('canResp'); + if (!idEl || !dlcEl || !dataEl) return; + idEl.value = '0x18DA00F1'; + dlcEl.value = '8'; + dataEl.value = '0102030405060708'; + if (respEl) respEl.style.display = 'none'; updateCanHint(); } // ==================== SPI 发送 ==================== @@ -1001,10 +1090,14 @@ function setupDropZone(zoneId, chip) { } setupDropZone('uploadZoneEsp32', 'esp32'); setupDropZone('uploadZoneGd32', 'gd32'); -document.getElementById('canData').addEventListener('input', updateCanHint); -document.getElementById('canDlc').addEventListener('input', updateCanHint); -document.getElementById('spiType').addEventListener('input', updateSpiHint); -document.getElementById('spiData').addEventListener('input', updateSpiHint); +var canDataEl = document.getElementById('canData'); +var canDlcEl = document.getElementById('canDlc'); +if (canDataEl) canDataEl.addEventListener('input', updateCanHint); +if (canDlcEl) canDlcEl.addEventListener('input', updateCanHint); +var spiTypeEl = document.getElementById('spiType'); +var spiDataEl = document.getElementById('spiData'); +if (spiTypeEl) spiTypeEl.addEventListener('input', updateSpiHint); +if (spiDataEl) spiDataEl.addEventListener('input', updateSpiHint); // ==================== Intel HEX → BIN 转换 ==================== /** * 将 Intel HEX 字符串解析为原始 BIN 字节数组。 diff --git a/tools/ble_ota.js b/tools/ble_ota.js index 1f82d4a..c651441 100644 --- a/tools/ble_ota.js +++ b/tools/ble_ota.js @@ -93,8 +93,6 @@ abortBtn: $('abortBtn'), uploadZone: $('uploadZone'), firmwareFile: $('firmwareFile'), - payloadSize: $('payloadSize'), - ctrlHex: $('ctrlHex'), connStatus: $('connStatus'), // 共享连接指示器 connStatusTxt: $('connStatusTxt'), // 共享连接指示器文本 connState: $('connState'), @@ -120,6 +118,11 @@ return TARGETS[window.flashTarget] || TARGETS.esp32; } + function currentPayloadSize() { + const cfg = currentTarget(); + return Math.max(32, Math.min(498, Number(cfg.recommendedPayload) || 480)); + } + function flowTarget() { return TARGETS[state.uploadTargetKey] || currentTarget(); } @@ -324,7 +327,7 @@ if (!Number.isFinite(totalSize) || totalSize <= 0) return; const safeBytes = Math.max(0, Math.min(totalSize, ackBytes >>> 0)); const percent = (safeBytes / totalSize) * 100; - const payloadSize = Math.max(1, Number(els.payloadSize.value) || 480); + const payloadSize = Math.max(1, currentPayloadSize()); const ackedPackets = Math.min(packetTotal, Math.ceil(safeBytes / payloadSize)); const cfg = currentTarget(); setProgress(percent, `[${cfg.label}] 已确认 ${ackedPackets}/${packetTotal} 包`); @@ -363,8 +366,6 @@ els.connectBtn.disabled = false; } els.disconnectBtn.disabled = !connected; - els.payloadSize.disabled = state.uploading; - els.ctrlHex.disabled = state.uploading; els.autoFlashBtn.disabled = !idle; els.abortBtn.disabled = !state.uploading; @@ -373,7 +374,7 @@ } else if (!state.notificationsEnabled) { setReadyHint('设备已连接,正在自动准备刷写通道。若长时间无响应,请断开后重连。'); } else if (!state.selectedFile) { - setReadyHint('设备已连接。下一步请选择固件文件,并确认目标芯片与 Payload。'); + setReadyHint('设备已连接。下一步请选择目标芯片与固件文件。'); } else { setReadyHint('设备与文件都已就绪,可以直接开始刷写或执行一键刷写。'); } @@ -406,8 +407,6 @@ function refreshTargetUi() { const cfg = currentTarget(); updateBleStageLabels(cfg); - els.ctrlHex.value = cfg.ctrlHex; - els.payloadSize.value = String(cfg.recommendedPayload); if (state.selectedFile) updateFileInfo(state.selectedFile); setConnectionState(!!state.server); // 同步目标胶囊标签 @@ -552,7 +551,7 @@ // 不再用 ackMinSeenSeq 作下界 —— 因为本 run 第一个窗口的 seq 本身就是 0..31, // 下界过严会把正常 ACK 也误判为越界(导致 ackBytes 永远 0)。 if (state.uploading && state.activeTotalSize > 0) { - const payloadSize = Math.max(1, Number(els.payloadSize.value) || 480); + const payloadSize = Math.max(1, currentPayloadSize()); const maxExpectedSeq = Math.ceil(state.activeTotalSize / payloadSize) - 1; if (ackSeq > maxExpectedSeq + BLE_OTA_ACK_WINDOW) { bleLog('ACK', `忽略越界 ACK: seq=${ackSeq} (run 范围 0..${maxExpectedSeq})`, 'W'); @@ -954,7 +953,7 @@ els.fileName.textContent = file ? file.name : '-'; els.fileType.textContent = file ? (file.name.toLowerCase().includes('.hex') ? 'Intel HEX' : 'Binary') : '-'; els.fileSize.textContent = file ? formatBytes(file.size) : '0 B'; - const payloadSize = Number(els.payloadSize.value) || 480; + const payloadSize = currentPayloadSize(); const packets = file ? Math.ceil(file.size / payloadSize) : 0; els.packetCount.textContent = String(packets); els.sentCount.textContent = '0'; @@ -1141,7 +1140,7 @@ const cfg = currentTarget(); state.uploadTargetKey = window.flashTarget || 'esp32'; - const payloadSize = Math.max(32, Math.min(498, Number(els.payloadSize.value) || 480)); + const payloadSize = Math.max(32, Math.min(498, currentPayloadSize())); const file = state.selectedFile; state.abortRequested = false; @@ -1401,7 +1400,7 @@ setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...'); showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待 GD32 缓存校验' }); await sleep(30); - const cmdHex = els.ctrlHex.value || cfg.ctrlHex; + const cmdHex = cfg.ctrlHex; const cmdBase = hexToBytes(cmdHex); const cmd = new Uint8Array(cmdBase.length + 32 + 2); cmd.set(cmdBase, 0); @@ -1554,7 +1553,6 @@ els.firmwareFile.addEventListener('change', syncSelectedFileFromInput); els.firmwareFile.addEventListener('input', syncSelectedFileFromInput); window.addEventListener('focus', () => setTimeout(syncSelectedFileFromInput, 0)); - els.payloadSize.addEventListener('change', () => updateFileInfo(state.selectedFile)); } // 暴露到 window,方便外部模式切换/集成时调用(默认 API:init / disconnect) diff --git a/tools/can.html b/tools/can.html new file mode 100644 index 0000000..48c5962 --- /dev/null +++ b/tools/can.html @@ -0,0 +1,209 @@ + + + + + +ESP32 CAN 通信控制台 + + + +
+ +
+
+ + ESP32 CAN 通信控制台 +
+
+ 未连接 + v-- + CH0 + 未导入 DBC + 0 报文 +
+ +
+ +
+
🧰 工作台设置
+
+
+
+ +
+ + +
+
+ +
+ + +
+ +
+ +
+
📥
+

导入 DBC

+

点击或拖拽 .dbc 文件

+
+ +
+ + +
+ +
+
+ 📄 - + +
+
+
报文0
+
信号0
+
节点0
+
大小0 B
+
+
+ +
+
+ + 等待连接测试 +
+
+ 当前页面会直接调用 /system/status 和 /can/send。 +
+
+ 当前解析器会自动识别常见 DBC 的 BO_、SG_、VAL_、 + CM_ 和 SIG_VALTYPE_。 +
+
+
+
+ +
+
📚 报文列表
+
+
+
等待导入 DBC...
+
+
+
+ +
+
+
🎚️ 信号编辑
+
+
+
📡
+

先导入 DBC,再选择一条 CAN 报文

+

上方按表格选报文,下方按信号名直接编辑,右侧查看编码结果并发送。

+
+ + +
+
+ + +
+
+ +
+ ✓ + 操作成功 +
+ + + + diff --git a/tools/can.js b/tools/can.js new file mode 100644 index 0000000..777a96a --- /dev/null +++ b/tools/can.js @@ -0,0 +1,1111 @@ +(function () { + 'use strict'; + + var state = { + dbcFile: null, + dbcText: '', + dbc: null, + filteredMessages: [], + selectedMessageKey: '', + signalValues: {}, + generatedData: new Uint8Array(0), + history: [], + latestStatus: null + }; + + var el = {}; + + function cacheElements() { + [ + 'toolIp', 'defaultChannel', 'manualChannel', 'manualId', 'manualDlc', 'manualData', + 'testConnBtn', 'connIndicator', 'connIndicatorTxt', 'connectionNote', + 'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcFileName', 'dbcMessageCount', + 'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcSearch', 'dbcHint', 'clearDbcBtn', + 'messageList', 'emptyMessageState', 'messageWorkspace', 'selectedMessageTitle', + 'selectedMessageSubtitle', 'selectedMessageMeta', 'selectedMessageComment', + 'sendSelectedBtn', 'signalGrid', 'generatedHex', 'generatedBytes', 'encodeHint', + 'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn', + 'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco', + 'toastMsg', 'canConnPill', 'canConnTxt', 'canVersionPill', 'canChannelPill', + 'dbcSummaryPill', 'messageSummaryPill' + ].forEach(function (id) { + el[id] = document.getElementById(id); + }); + } + + function showToast(message, type) { + type = type || 'success'; + if (!el.toast) return; + el.toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show'; + el.toastIco.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0'; + el.toastMsg.textContent = message; + clearTimeout(showToast._timer); + showToast._timer = setTimeout(function () { + el.toast.classList.remove('show'); + }, 3000); + } + + function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) return '--'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + function formatTime(date) { + var d = date instanceof Date ? date : new Date(); + var hh = String(d.getHours()).padStart(2, '0'); + var mm = String(d.getMinutes()).padStart(2, '0'); + var ss = String(d.getSeconds()).padStart(2, '0'); + return hh + ':' + mm + ':' + ss; + } + + function sanitizeHtml(text) { + return String(text == null ? '' : text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function getBaseUrl() { + var ip = (el.toolIp && el.toolIp.value ? el.toolIp.value.trim() : '') || '192.168.31.127'; + return 'http://' + ip; + } + + function parseNumericId(input) { + var raw = String(input == null ? '' : input).trim(); + if (!raw) return NaN; + if (/^0x/i.test(raw)) return parseInt(raw, 16); + if (/^[0-9]+$/.test(raw)) return parseInt(raw, 10); + if (/^[0-9a-fA-F]+h$/i.test(raw)) return parseInt(raw.slice(0, -1), 16); + if (/^[0-9a-fA-F]+$/.test(raw)) return parseInt(raw, 16); + return NaN; + } + + function padHex(value, width) { + return value.toString(16).toUpperCase().padStart(width, '0'); + } + + function bytesToHex(bytes) { + if (!bytes || !bytes.length) return ''; + var parts = []; + for (var i = 0; i < bytes.length; i++) { + parts.push(padHex(bytes[i], 2)); + } + return parts.join(''); + } + + function setConnectionState(connected, text, buildVersion, buildTime) { + if (el.canConnPill) { + el.canConnPill.className = 'pill ' + (connected ? 'online' : 'offline'); + } + if (el.canConnTxt) el.canConnTxt.textContent = text; + if (el.connIndicator) { + el.connIndicator.className = 'conn-indicator ' + (connected ? 'ok' : 'err'); + } + if (el.connIndicatorTxt) el.connIndicatorTxt.textContent = text; + if (el.canVersionPill) { + el.canVersionPill.textContent = buildVersion ? ('v' + buildVersion) : 'v--'; + el.canVersionPill.dataset.tooltip = buildTime ? ('编译: ' + buildTime) : ''; + } + } + + async function testConnection() { + var baseUrl = getBaseUrl(); + if (el.connectionNote) { + el.connectionNote.className = 'helper-note'; + el.connectionNote.innerHTML = '正在请求 /system/status 检查设备状态...'; + } + try { + var resp = await fetch(baseUrl + '/system/status', { signal: AbortSignal.timeout(3000) }); + if (!resp.ok) { + setConnectionState(false, 'HTTP ' + resp.status, '', ''); + if (el.connectionNote) { + el.connectionNote.className = 'helper-note err'; + el.connectionNote.innerHTML = '状态接口返回 HTTP ' + resp.status + ',请确认设备地址和 HTTP 服务。'; + } + showToast('设备响应异常: HTTP ' + resp.status, 'error'); + return; + } + var data = await resp.json(); + state.latestStatus = data; + var wifi = data.wifi || {}; + var connected = !!wifi.connected; + var mode = wifi.mode || 'STA'; + var text = connected ? (mode + ' 已连接') : (mode + ' 未连接'); + setConnectionState(connected, text, data.version, data.build_time); + if (el.connectionNote) { + el.connectionNote.className = 'helper-note ' + (connected ? 'ok' : 'warn'); + el.connectionNote.innerHTML = + 'IP: ' + sanitizeHtml(wifi.ip || '--') + ',RSSI: ' + + sanitizeHtml(wifi.rssi !== undefined && wifi.rssi !== -127 ? (wifi.rssi + ' dBm') : '--') + + ',运行: ' + sanitizeHtml(formatUptime(data.uptime_sec)) + ''; + } + showToast(connected ? 'ESP32 当前在线' : '设备已响应,但 WiFi 未连接', connected ? 'success' : 'warning'); + } catch (err) { + setConnectionState(false, '连接失败', '', ''); + if (el.connectionNote) { + el.connectionNote.className = 'helper-note err'; + el.connectionNote.innerHTML = '无法访问 /system/status:' + sanitizeHtml(err.message); + } + showToast('连接失败: ' + err.message, 'error'); + } + } + + function formatUptime(sec) { + if (!Number.isFinite(sec) || sec < 0) return '--'; + var s = Math.floor(sec); + var days = Math.floor(s / 86400); + s %= 86400; + var hours = Math.floor(s / 3600); + s %= 3600; + var mins = Math.floor(s / 60); + s %= 60; + if (days > 0) return days + 'd ' + hours + 'h'; + if (hours > 0) return hours + 'h ' + mins + 'm'; + if (mins > 0) return mins + 'm ' + s + 's'; + return s + 's'; + } + + function normalizeWhitespace(text) { + return String(text || '').replace(/\r/g, '').trim(); + } + + function parseDbc(text) { + var lines = String(text || '').replace(/\r/g, '').split('\n'); + var messages = []; + var messageMap = {}; + var comments = { messages: {}, signals: {} }; + var valueTables = {}; + var nodeNames = []; + var current = null; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line) continue; + + var nodeMatch = line.match(/^BU_\s*:\s*(.*)$/); + if (nodeMatch) { + nodeNames = nodeMatch[1].trim() ? nodeMatch[1].trim().split(/\s+/) : []; + continue; + } + + var messageMatch = line.match(/^BO_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s+(.+)$/); + if (messageMatch) { + current = { + idRaw: parseInt(messageMatch[1], 10), + name: messageMatch[2], + dlc: parseInt(messageMatch[3], 10), + transmitter: messageMatch[4].trim(), + signals: [], + comment: '' + }; + current.id = current.idRaw & 0x1FFFFFFF; + current.isExtended = current.idRaw > 0x7FF; + current.key = String(current.idRaw) + ':' + current.name; + messages.push(current); + messageMap[current.idRaw] = current; + continue; + } + + var signalMatch = line.match(/^SG_\s+([A-Za-z0-9_]+)(?:\s+((?:M|m-?\d+)))?\s*:\s*(\d+)\|(\d+)@([01])([+-])\s+\(([^\),]+),([^\)]+)\)\s+\[([^\|]*)\|([^\]]*)\]\s+"([^"]*)"\s+(.+)$/); + if (signalMatch && current) { + var muxInfo = parseMultiplexToken(signalMatch[2]); + current.signals.push({ + name: signalMatch[1], + startBit: parseInt(signalMatch[3], 10), + length: parseInt(signalMatch[4], 10), + byteOrder: signalMatch[5] === '1' ? 'little' : 'big', + isSigned: signalMatch[6] === '-', + factor: parseFloat(signalMatch[7]), + offset: parseFloat(signalMatch[8]), + min: signalMatch[9].trim() === '' ? null : parseFloat(signalMatch[9]), + max: signalMatch[10].trim() === '' ? null : parseFloat(signalMatch[10]), + unit: signalMatch[11], + receivers: signalMatch[12].split(/\s*,\s*/), + comment: '', + valueType: 'int', + enumMap: {}, + multiplexRole: muxInfo.role, + multiplexValue: muxInfo.value + }); + continue; + } + + var messageCommentMatch = line.match(/^CM_\s+BO_\s+(\d+)\s+"([\s\S]*)";$/); + if (messageCommentMatch) { + comments.messages[messageCommentMatch[1]] = messageCommentMatch[2]; + continue; + } + + var signalCommentMatch = line.match(/^CM_\s+SG_\s+(\d+)\s+([A-Za-z0-9_]+)\s+"([\s\S]*)";$/); + if (signalCommentMatch) { + comments.signals[signalCommentMatch[1] + ':' + signalCommentMatch[2]] = signalCommentMatch[3]; + continue; + } + + var valueMatch = line.match(/^VAL_\s+(\d+)\s+([A-Za-z0-9_]+)\s+(.+);$/); + if (valueMatch) { + var table = {}; + var pairRe = /(-?\d+)\s+"([^"]*)"/g; + var m; + while ((m = pairRe.exec(valueMatch[3])) !== null) { + table[m[1]] = m[2]; + } + valueTables[valueMatch[1] + ':' + valueMatch[2]] = table; + continue; + } + + var valTypeMatch = line.match(/^SIG_VALTYPE_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s*;$/); + if (valTypeMatch) { + var msg = messageMap[valTypeMatch[1]]; + if (!msg) continue; + var signal = findSignal(msg, valTypeMatch[2]); + if (signal) { + signal.valueType = valTypeMatch[3] === '1' ? 'float' : valTypeMatch[3] === '2' ? 'double' : 'int'; + } + } + } + + messages.forEach(function (msg) { + msg.comment = comments.messages[String(msg.idRaw)] || ''; + msg.signals.forEach(function (signal) { + signal.comment = comments.signals[String(msg.idRaw) + ':' + signal.name] || ''; + signal.enumMap = valueTables[String(msg.idRaw) + ':' + signal.name] || {}; + }); + }); + + return { + messages: messages, + nodeNames: nodeNames, + messageMap: messageMap + }; + } + + function findSignal(message, signalName) { + for (var i = 0; i < message.signals.length; i++) { + if (message.signals[i].name === signalName) return message.signals[i]; + } + return null; + } + + function parseMultiplexToken(token) { + if (!token) return { role: null, value: null }; + if (token === 'M') return { role: 'mux', value: null }; + var match = token.match(/^m(-?\d+)$/); + if (match) { + return { role: 'value', value: parseInt(match[1], 10) }; + } + return { role: null, value: null }; + } + + function buildSummary() { + if (!state.dbc) { + if (el.dbcSummaryPill) el.dbcSummaryPill.textContent = '未导入 DBC'; + if (el.messageSummaryPill) el.messageSummaryPill.textContent = '0 报文'; + return; + } + if (el.dbcSummaryPill) { + el.dbcSummaryPill.textContent = (state.dbc.nodeNames.length || 0) + ' 节点'; + } + if (el.messageSummaryPill) { + el.messageSummaryPill.textContent = state.dbc.messages.length + ' 报文'; + } + } + + function updateDbcFileInfo(file, dbc) { + if (!el.dbcFileInfo) return; + if (!file || !dbc) { + el.dbcFileInfo.classList.remove('show'); + el.dbcFileName.textContent = '-'; + el.dbcMessageCount.textContent = '0'; + el.dbcSignalCount.textContent = '0'; + el.dbcNodeCount.textContent = '0'; + el.dbcFileSize.textContent = '0 B'; + return; + } + var signalCount = dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0); + el.dbcFileInfo.classList.add('show'); + el.dbcFileName.textContent = file.name || '-'; + el.dbcMessageCount.textContent = String(dbc.messages.length); + el.dbcSignalCount.textContent = String(signalCount); + el.dbcNodeCount.textContent = String(dbc.nodeNames.length); + el.dbcFileSize.textContent = formatBytes(file.size || 0); + } + + function filterMessages() { + var keyword = normalizeWhitespace(el.dbcSearch ? el.dbcSearch.value : '').toLowerCase(); + var messages = state.dbc ? state.dbc.messages.slice() : []; + if (keyword) { + messages = messages.filter(function (msg) { + var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); + if (msg.name.toLowerCase().indexOf(keyword) >= 0) return true; + if (idHex.toLowerCase().indexOf(keyword) >= 0) return true; + if (String(msg.idRaw).indexOf(keyword) >= 0) return true; + if ((msg.transmitter || '').toLowerCase().indexOf(keyword) >= 0) return true; + for (var i = 0; i < msg.signals.length; i++) { + if (msg.signals[i].name.toLowerCase().indexOf(keyword) >= 0) return true; + } + return false; + }); + } + state.filteredMessages = messages; + renderMessageList(); + } + + function renderMessageList() { + if (!el.messageList) return; + if (!state.dbc) { + el.messageList.innerHTML = '
等待导入 DBC...
'; + return; + } + if (!state.filteredMessages.length) { + el.messageList.innerHTML = '
没有匹配的报文。
'; + return; + } + var rows = state.filteredMessages.map(function (msg, index) { + var active = msg.key === state.selectedMessageKey ? ' active' : ''; + var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); + var comment = msg.comment || ''; + if (comment.length > 36) comment = comment.slice(0, 36) + '...'; + return '' + + ''; + }).join(''); + el.messageList.innerHTML = + '
' + + '
' + + '#' + + '报文名称' + + '标识符' + + '通道' + + '类型' + + 'DLC' + + '信号' + + '发送节点' + + '注释' + + '
' + + '
' + rows + '
' + + '
'; + var buttons = el.messageList.querySelectorAll('.message-row'); + for (var i = 0; i < buttons.length; i++) { + buttons[i].addEventListener('click', function () { + selectMessage(this.getAttribute('data-key')); + }); + } + } + + function selectMessage(key) { + if (!state.dbc) return; + var msg = null; + for (var i = 0; i < state.dbc.messages.length; i++) { + if (state.dbc.messages[i].key === key) { + msg = state.dbc.messages[i]; + break; + } + } + if (!msg) return; + state.selectedMessageKey = key; + state.signalValues = {}; + msg.signals.forEach(function (signal) { + state.signalValues[signal.name] = getDefaultSignalValue(signal); + }); + renderMessageList(); + renderSelectedMessage(); + updateEncodedPreview(); + } + + function getDefaultSignalValue(signal) { + var enumKeys = Object.keys(signal.enumMap || {}); + if (enumKeys.length) return enumKeys[0]; + if (Number.isFinite(signal.min)) return String(signal.min); + if (signal.factor === 0) return '0'; + return '0'; + } + + function getSelectedMessage() { + if (!state.dbc || !state.selectedMessageKey) return null; + for (var i = 0; i < state.dbc.messages.length; i++) { + if (state.dbc.messages[i].key === state.selectedMessageKey) return state.dbc.messages[i]; + } + return null; + } + + function getMultiplexerSignal(message) { + if (!message) return null; + for (var i = 0; i < message.signals.length; i++) { + if (message.signals[i].multiplexRole === 'mux') return message.signals[i]; + } + return null; + } + + function getActiveSignals(message) { + if (!message) return []; + var muxSignal = getMultiplexerSignal(message); + if (!muxSignal) return message.signals.slice(); + var currentValue = Number(state.signalValues[muxSignal.name]); + return message.signals.filter(function (signal) { + if (!signal.multiplexRole || signal.multiplexRole === 'mux') return true; + return signal.multiplexRole === 'value' && Number.isFinite(currentValue) && signal.multiplexValue === currentValue; + }); + } + + function renderSelectedMessage() { + var msg = getSelectedMessage(); + var hasMessage = !!msg; + if (el.emptyMessageState) el.emptyMessageState.style.display = hasMessage ? 'none' : ''; + if (el.messageWorkspace) el.messageWorkspace.style.display = hasMessage ? '' : 'none'; + if (!msg) return; + + var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); + el.selectedMessageTitle.textContent = msg.name; + el.selectedMessageSubtitle.textContent = idHex + ' · ' + (msg.isExtended ? '扩展帧' : '标准帧'); + el.selectedMessageComment.textContent = msg.comment || '暂无报文注释。'; + var activeSignals = getActiveSignals(msg); + var muxSignal = getMultiplexerSignal(msg); + el.selectedMessageMeta.innerHTML = '' + + metaCard('发送节点', msg.transmitter || 'Vector__XXX') + + metaCard('DLC', String(msg.dlc)) + + metaCard('信号数', activeSignals.length + ' / ' + msg.signals.length) + + metaCard('通道', 'CH' + (el.defaultChannel ? el.defaultChannel.value : '0')); + if (muxSignal && el.selectedMessageComment) { + var muxValue = state.signalValues[muxSignal.name]; + el.selectedMessageComment.textContent = + (msg.comment ? msg.comment + ' · ' : '') + + 'Mux ' + muxSignal.name + '=' + muxValue + ',当前展示并编码匹配该值的信号。'; + } + renderSignalGrid(msg); + } + + function metaCard(label, value) { + return '
' + sanitizeHtml(label) + '' + sanitizeHtml(value) + '
'; + } + + function renderSignalGrid(message) { + if (!el.signalGrid) return; + var activeSignals = getActiveSignals(message); + if (!activeSignals.length) { + el.signalGrid.innerHTML = '
当前报文没有信号定义。
'; + return; + } + var rows = activeSignals.map(function (signal, index) { + return renderSignalRow(signal, index); + }).join(''); + el.signalGrid.innerHTML = + '
' + + '
' + + '#' + + '信号名称' + + '输入值' + + '范围 / 枚举' + + '位定义' + + '注释' + + '
' + + '
' + rows + '
' + + '
'; + bindSignalInputs(message); + } + + function renderSignalRow(signal, index) { + var enumKeys = Object.keys(signal.enumMap || {}); + var bitRange = signal.startBit + '|' + signal.length + ' @' + (signal.byteOrder === 'little' ? 'Intel' : 'Motorola'); + var rangeText = ''; + if (Number.isFinite(signal.min) || Number.isFinite(signal.max)) { + rangeText = (Number.isFinite(signal.min) ? signal.min : '-') + ' ~ ' + (Number.isFinite(signal.max) ? signal.max : '-'); + } else { + rangeText = '未声明'; + } + var typeText = (signal.valueType || 'int').toUpperCase() + ' / ' + (signal.isSigned ? 'signed' : 'unsigned'); + var factorText = 'factor ' + signal.factor + ' / offset ' + signal.offset + (signal.unit ? (' / ' + signal.unit) : ''); + var comment = signal.comment || '-'; + if (comment.length > 48) comment = comment.slice(0, 48) + '...'; + var inputHtml = ''; + if (enumKeys.length) { + inputHtml = ''; + } else { + inputHtml = ''; + } + return '' + + '
' + + '' + (index + 1) + '' + + '' + + '' + sanitizeHtml(signal.name) + '' + + '' + sanitizeHtml((signal.receivers || []).join(', ') || '-') + '' + + '' + + '' + inputHtml + '' + + '' + + '' + sanitizeHtml(rangeText) + '' + + (enumKeys.length ? '' + sanitizeHtml(enumKeys.slice(0, 3).map(function (key) { return key + ':' + signal.enumMap[key]; }).join(' / ')) + (enumKeys.length > 3 ? ' ...' : '') + '' : '' + sanitizeHtml(factorText) + '') + + '' + + '' + + '' + sanitizeHtml(bitRange) + '' + + '' + sanitizeHtml(typeText) + '' + + '' + + '' + sanitizeHtml(comment) + '' + + '
'; + } + + function bindSignalInputs(message) { + var inputs = el.signalGrid.querySelectorAll('.signal-input'); + for (var i = 0; i < inputs.length; i++) { + inputs[i].addEventListener('input', function () { + var name = this.getAttribute('data-signal'); + state.signalValues[name] = this.value; + var signal = findSignal(message, name); + if (signal && signal.multiplexRole === 'mux') { + renderSelectedMessage(); + } + updateEncodedPreview(); + }); + inputs[i].addEventListener('change', function () { + var name = this.getAttribute('data-signal'); + state.signalValues[name] = this.value; + var signal = findSignal(message, name); + if (signal && signal.multiplexRole === 'mux') { + renderSelectedMessage(); + } + updateEncodedPreview(); + }); + } + if (message && message.signals.length) updateEncodedPreview(); + } + + function updateEncodedPreview() { + var message = getSelectedMessage(); + if (!message) { + state.generatedData = new Uint8Array(0); + if (el.generatedHex) el.generatedHex.textContent = '--'; + if (el.generatedBytes) el.generatedBytes.innerHTML = ''; + return; + } + try { + state.generatedData = encodeMessageData(message, state.signalValues); + var hex = bytesToHex(state.generatedData); + el.generatedHex.textContent = hex || '--'; + el.generatedBytes.innerHTML = renderByteStrip(state.generatedData); + var hint = 'DLC ' + message.dlc + ',当前编码 ' + state.generatedData.length + + ' 字节,发送通道 CH' + (el.defaultChannel ? el.defaultChannel.value : '0') + '。'; + if (el.encodeHint) { + el.encodeHint.className = 'helper-note ok'; + el.encodeHint.innerHTML = hint; + } + } catch (err) { + state.generatedData = new Uint8Array(0); + el.generatedHex.textContent = '--'; + el.generatedBytes.innerHTML = ''; + if (el.encodeHint) { + el.encodeHint.className = 'helper-note err'; + el.encodeHint.innerHTML = '编码失败:' + sanitizeHtml(err.message); + } + } + } + + function renderByteStrip(bytes) { + if (!bytes || !bytes.length) return '
无数据
'; + var html = []; + for (var i = 0; i < bytes.length; i++) { + html.push('B' + i + '' + padHex(bytes[i], 2) + ''); + } + return html.join(''); + } + + function encodeMessageData(message, signalValues) { + var bytes = new Uint8Array(message.dlc); + var activeSignals = getActiveSignals(message); + for (var i = 0; i < activeSignals.length; i++) { + var signal = activeSignals[i]; + var parsed = parseSignalInput(signal, signalValues[signal.name]); + if (signal.valueType === 'float' || signal.valueType === 'double') { + writeFloatSignal(bytes, signal, parsed.numeric); + } else { + writeIntegerSignal(bytes, signal, parsed); + } + } + return bytes; + } + + function parseSignalInput(signal, inputValue) { + var value = inputValue; + if (value === '' || value == null) value = getDefaultSignalValue(signal); + var num = Number(value); + if (!Number.isFinite(num)) throw new Error(signal.name + ' 不是合法数字'); + if (Number.isFinite(signal.min) && num < signal.min) { + throw new Error(signal.name + ' 低于最小值 ' + signal.min); + } + if (Number.isFinite(signal.max) && num > signal.max) { + throw new Error(signal.name + ' 高于最大值 ' + signal.max); + } + return { + numeric: num, + text: String(value).trim() + }; + } + + function isIntegerLike(value) { + return /^-?\d+$/.test(String(value == null ? '' : value).trim()); + } + + function physicalToRawBigInt(signal, parsedValue) { + if ((signal.factor === 1 || signal.factor === 1.0) && + (signal.offset === 0 || signal.offset === 0.0) && + isIntegerLike(parsedValue.text)) { + return BigInt(parsedValue.text); + } + var rawNumber = signal.factor === 0 + ? Math.round(signal.offset ? (parsedValue.numeric - signal.offset) : parsedValue.numeric) + : Math.round((parsedValue.numeric - signal.offset) / signal.factor); + if (!Number.isFinite(rawNumber)) { + throw new Error(signal.name + ' 原始值计算失败'); + } + return BigInt(rawNumber); + } + + function bigPow2(bits) { + return 1n << BigInt(bits); + } + + function maxUnsignedValue(bits) { + return bigPow2(bits) - 1n; + } + + function minSignedValue(bits) { + return -(1n << BigInt(bits - 1)); + } + + function maxSignedValue(bits) { + return (1n << BigInt(bits - 1)) - 1n; + } + + function writeIntegerSignal(bytes, signal, parsedValue) { + var raw = physicalToRawBigInt(signal, parsedValue); + if (signal.length <= 0 || signal.length > 64) { + throw new Error(signal.name + ' 位宽超出当前页面支持范围'); + } + if (signal.isSigned) { + var min = minSignedValue(signal.length); + var max = maxSignedValue(signal.length); + if (raw < min || raw > max) { + throw new Error(signal.name + ' 原始值超出带符号范围'); + } + } else { + var maxUnsigned = maxUnsignedValue(signal.length); + if (raw < 0 || raw > maxUnsigned) { + throw new Error(signal.name + ' 原始值超出无符号范围'); + } + } + var unsignedRaw = raw < 0 ? bigPow2(signal.length) + raw : raw; + + if (signal.byteOrder === 'little') { + for (var i = 0; i < signal.length; i++) { + var bitVal = Number((unsignedRaw >> BigInt(i)) & 1n); + var bitIndex = signal.startBit + i; + setBitByIndex(bytes, bitIndex, bitVal); + } + return; + } + + var positions = buildMotorolaBitPositions(signal.startBit, signal.length); + for (var j = 0; j < positions.length; j++) { + var shift = signal.length - 1 - j; + var motorolaBit = Number((unsignedRaw >> BigInt(shift)) & 1n); + setBitByIndex(bytes, positions[j], motorolaBit); + } + } + + function writeFloatSignal(bytes, signal, physicalValue) { + if (signal.length !== 32 && signal.length !== 64) { + throw new Error(signal.name + ' 浮点信号长度需为 32 或 64 位'); + } + if (signal.byteOrder !== 'little') { + throw new Error(signal.name + ' 浮点信号当前仅支持 Intel 小端'); + } + if (signal.startBit % 8 !== 0) { + throw new Error(signal.name + ' 浮点信号起始位必须按字节对齐'); + } + var buffer = new ArrayBuffer(signal.length / 8); + var view = new DataView(buffer); + if (signal.length === 32) view.setFloat32(0, physicalValue, true); + else view.setFloat64(0, physicalValue, true); + var rawBytes = new Uint8Array(buffer); + for (var i = 0; i < rawBytes.length; i++) { + bytes[(signal.startBit / 8) + i] = rawBytes[i]; + } + } + + function buildMotorolaBitPositions(startBit, length) { + var positions = []; + var bit = startBit; + for (var i = 0; i < length; i++) { + positions.push(bit); + if (bit % 8 === 0) bit += 15; + else bit -= 1; + } + return positions; + } + + function setBitByIndex(bytes, bitIndex, bitValue) { + var byteIndex = Math.floor(bitIndex / 8); + var bitInByte = bitIndex % 8; + if (byteIndex < 0 || byteIndex >= bytes.length) { + throw new Error('信号写入越界,bit=' + bitIndex); + } + if (bitValue) bytes[byteIndex] |= (1 << bitInByte); + else bytes[byteIndex] &= ~(1 << bitInByte); + } + + async function sendCanRequest(payload) { + var resp = await fetch(getBaseUrl() + '/can/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(5000) + }); + var text = await resp.text(); + var json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch (err) { + throw new Error('响应不是合法 JSON: ' + text); + } + if (!resp.ok) { + throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '请求失败')); + } + return json; + } + + function buildSendResponseText(result) { + if (result.status === 'ok') { + return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据'; + } + return '发送失败: code=' + result.code; + } + + function updateResponseBox(box, textEl, result, mode) { + if (!box || !textEl) return; + box.style.display = 'block'; + box.className = 'can-response' + (result.status === 'ok' ? ' ok' : ' err'); + textEl.textContent = buildSendResponseText(result); + if (mode === 'manual') { + el.manualHint.className = 'helper-note ' + (result.status === 'ok' ? 'ok' : 'err'); + el.manualHint.innerHTML = result.status === 'ok' + ? '原始帧已发送到设备,若有响应则显示在下方。' + : '设备返回错误码 ' + sanitizeHtml(result.code) + '。'; + } + } + + function addHistoryEntry(entry) { + state.history.unshift(entry); + if (state.history.length > 20) state.history.length = 20; + renderHistory(); + } + + function renderHistory() { + if (!el.historyList) return; + if (!state.history.length) { + el.historyList.innerHTML = '
尚未发送任何 CAN 帧。
'; + return; + } + el.historyList.innerHTML = state.history.map(function (item) { + return '' + + '
' + + '
' + + '' + sanitizeHtml(item.mode) + '' + + '' + sanitizeHtml(item.time) + '' + + '
' + + '
' + + 'CH' + sanitizeHtml(item.channel) + ' · ' + + sanitizeHtml(item.idHex) + ' · DLC ' + sanitizeHtml(item.dlc) + + '
' + + '
' + sanitizeHtml(item.dataHex || '--') + '
' + + '
' + sanitizeHtml(item.resultText) + '
' + + '
'; + }).join(''); + } + + async function sendSelectedMessage() { + var msg = getSelectedMessage(); + if (!msg) { + showToast('请先选择一条报文', 'warning'); + return; + } + updateEncodedPreview(); + if (!state.generatedData.length && msg.dlc > 0) { + showToast('当前报文还没有可发送的数据', 'error'); + return; + } + var channel = parseInt(el.defaultChannel.value, 10) || 0; + var payload = { + ch: channel, + id: msg.id, + dlc: msg.dlc, + data: bytesToHex(state.generatedData) + }; + try { + var result = await sendCanRequest(payload); + updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc'); + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'DBC', + ok: result.status === 'ok', + channel: channel, + idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), + dlc: msg.dlc, + dataHex: bytesToHex(state.generatedData), + resultText: buildSendResponseText(result) + }); + showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); + } catch (err) { + if (el.dbcResp) { + el.dbcResp.style.display = 'block'; + el.dbcResp.className = 'can-response err'; + el.dbcRespText.textContent = err.message; + } + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'DBC', + ok: false, + channel: channel, + idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), + dlc: msg.dlc, + dataHex: bytesToHex(state.generatedData), + resultText: err.message + }); + showToast('发送失败: ' + err.message, 'error'); + } + } + + function validateManualData() { + var dataHex = (el.manualData.value || '').trim().replace(/\s+/g, ''); + var dlc = parseInt(el.manualDlc.value, 10); + if (!Number.isFinite(dlc) || dlc < 0 || dlc > 64) { + throw new Error('DLC 需在 0~64 之间'); + } + if (dataHex && !/^[0-9a-fA-F]+$/.test(dataHex)) { + throw new Error('HEX 数据包含非法字符'); + } + if (dataHex.length % 2 !== 0) { + throw new Error('HEX 数据长度必须为偶数'); + } + var byteLen = dataHex.length / 2; + if (byteLen !== dlc) { + throw new Error('当前 HEX 为 ' + byteLen + ' 字节,但 DLC=' + dlc); + } + return dataHex.toUpperCase(); + } + + function refreshManualHint() { + try { + var dataHex = validateManualData(); + el.manualHint.className = 'helper-note ok'; + el.manualHint.innerHTML = '原始 HEX 合法,当前长度 ' + (dataHex.length / 2) + ' 字节。'; + } catch (err) { + el.manualHint.className = 'helper-note err'; + el.manualHint.innerHTML = sanitizeHtml(err.message); + } + } + + async function sendManualFrame() { + var id = parseNumericId(el.manualId.value); + if (!Number.isFinite(id)) { + showToast('CAN ID 格式错误', 'error'); + return; + } + var channel = parseInt(el.manualChannel.value, 10) || 0; + var dlc = parseInt(el.manualDlc.value, 10) || 0; + var dataHex = ''; + try { + dataHex = validateManualData(); + } catch (err) { + showToast(err.message, 'error'); + refreshManualHint(); + return; + } + + try { + var result = await sendCanRequest({ + ch: channel, + id: id, + dlc: dlc, + data: dataHex + }); + updateResponseBox(el.manualResp, el.manualRespText, result, 'manual'); + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'RAW', + ok: result.status === 'ok', + channel: channel, + idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3), + dlc: dlc, + dataHex: dataHex, + resultText: buildSendResponseText(result) + }); + showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); + } catch (err) { + if (el.manualResp) { + el.manualResp.style.display = 'block'; + el.manualResp.className = 'can-response err'; + el.manualRespText.textContent = err.message; + } + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'RAW', + ok: false, + channel: channel, + idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3), + dlc: dlc, + dataHex: dataHex, + resultText: err.message + }); + showToast('发送失败: ' + err.message, 'error'); + } + } + + function resetManualFields() { + el.manualChannel.value = el.defaultChannel.value; + el.manualId.value = '0x18DA00F1'; + el.manualDlc.value = '8'; + el.manualData.value = '0102030405060708'; + if (el.manualResp) el.manualResp.style.display = 'none'; + refreshManualHint(); + } + + function loadPreviewToManual() { + var msg = getSelectedMessage(); + if (!msg || !state.generatedData.length) { + showToast('当前没有可载入的预览帧', 'warning'); + return; + } + el.manualChannel.value = el.defaultChannel.value; + el.manualId.value = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); + el.manualDlc.value = String(msg.dlc); + el.manualData.value = bytesToHex(state.generatedData); + refreshManualHint(); + showToast('已载入到原始发送区', 'success'); + } + + async function handleDbcFile(file) { + if (!file) return; + var text = await file.text(); + var dbc = parseDbc(text); + state.dbcFile = file; + state.dbcText = text; + state.dbc = dbc; + state.selectedMessageKey = ''; + state.signalValues = {}; + updateDbcFileInfo(file, dbc); + buildSummary(); + filterMessages(); + if (dbc.messages.length) selectMessage(dbc.messages[0].key); + else renderSelectedMessage(); + if (el.dbcHint) { + el.dbcHint.className = 'helper-note ok'; + el.dbcHint.innerHTML = 'DBC 已解析:' + dbc.messages.length + ' 条报文,' + + dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0) + + ' 个信号。'; + } + showToast('DBC 导入成功', 'success'); + } + + function clearDbc() { + state.dbcFile = null; + state.dbcText = ''; + state.dbc = null; + state.filteredMessages = []; + state.selectedMessageKey = ''; + state.signalValues = {}; + state.generatedData = new Uint8Array(0); + if (el.dbcFileInput) el.dbcFileInput.value = ''; + if (el.dbcSearch) el.dbcSearch.value = ''; + updateDbcFileInfo(null, null); + buildSummary(); + renderMessageList(); + renderSelectedMessage(); + if (el.generatedHex) el.generatedHex.textContent = '--'; + if (el.generatedBytes) el.generatedBytes.innerHTML = ''; + if (el.dbcHint) { + el.dbcHint.className = 'helper-note'; + el.dbcHint.innerHTML = '当前解析器会自动识别常见 DBC 的 BO_、SG_、VAL_、CM_ 和 SIG_VALTYPE_。'; + } + } + + function bindEvents() { + el.testConnBtn.addEventListener('click', testConnection); + el.defaultChannel.addEventListener('change', function () { + el.manualChannel.value = el.defaultChannel.value; + el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; + renderMessageList(); + renderSelectedMessage(); + updateEncodedPreview(); + }); + el.dbcDropZone.addEventListener('click', function () { el.dbcFileInput.click(); }); + el.dbcDropZone.addEventListener('dragover', function (event) { + event.preventDefault(); + el.dbcDropZone.classList.add('dragover'); + }); + el.dbcDropZone.addEventListener('dragleave', function () { + el.dbcDropZone.classList.remove('dragover'); + }); + el.dbcDropZone.addEventListener('drop', function (event) { + event.preventDefault(); + el.dbcDropZone.classList.remove('dragover'); + var file = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null; + if (file) handleDbcFile(file).catch(handleDbcError); + }); + el.dbcFileInput.addEventListener('change', function (event) { + var file = event.target && event.target.files ? event.target.files[0] : null; + if (file) handleDbcFile(file).catch(handleDbcError); + }); + el.clearDbcBtn.addEventListener('click', clearDbc); + el.dbcSearch.addEventListener('input', filterMessages); + el.sendSelectedBtn.addEventListener('click', sendSelectedMessage); + el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual); + el.sendManualBtn.addEventListener('click', sendManualFrame); + el.resetManualBtn.addEventListener('click', resetManualFields); + el.manualData.addEventListener('input', refreshManualHint); + el.manualDlc.addEventListener('input', refreshManualHint); + el.manualId.addEventListener('input', refreshManualHint); + el.toolIp.addEventListener('change', testConnection); + } + + function handleDbcError(err) { + if (el.dbcHint) { + el.dbcHint.className = 'helper-note err'; + el.dbcHint.innerHTML = 'DBC 解析失败:' + sanitizeHtml(err.message); + } + showToast('DBC 解析失败: ' + err.message, 'error'); + } + + function init() { + cacheElements(); + bindEvents(); + setConnectionState(false, '未连接', '', ''); + if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; + if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value; + buildSummary(); + renderMessageList(); + renderSelectedMessage(); + refreshManualHint(); + renderHistory(); + setTimeout(testConnection, 150); + } + + init(); +})(); diff --git a/tools/index.html b/tools/index.html index f9ea2f6..11fec31 100644 --- a/tools/index.html +++ b/tools/index.html @@ -1,4 +1,4 @@ - +