feat(http): 添加CPU使用率监控功能
添加了详细的CPU使用率统计功能,包括: - 实现FreeRTOS运行时统计采样机制 - 添加CPU任务占用率计算和快照功能 - 在/system/status接口中增加CPU状态JSON数据 - 支持实时监控各任务的运行时间和占用百分比 更新了前端界面以显示CPU使用情况: - 添加CPU使用率进度条和百分比显示 - 显示空闲时间、采样窗口和任务数量 - 展示前8个最耗时任务的详细统计信息 同时优化了CAN FD桥接实现,重构为SPI协议帧传输方式, 并改进了前端CAN页面布局,将CAN表单迁移到独立页面。
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <string.h>
|
||||
#include <stdarg.h>
|
||||
#include <inttypes.h>
|
||||
#include <algorithm>
|
||||
#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<UBaseType_t>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<uint8_t>(channel & CAN_CHANNEL_MASK);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>((ext_id >> 24) & 0xFFU);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>((ext_id >> 16) & 0xFFU);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>((ext_id >> 8) & 0xFFU);
|
||||
tx_buf[tx_len++] = static_cast<uint8_t>(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<int>(resp_len);
|
||||
}
|
||||
|
||||
const size_t copied_len = (resp_len < recv_max) ? resp_len : recv_max;
|
||||
return static_cast<int>(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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+107
-14
@@ -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 = '<div class="status-empty">固件未启用 CPU runtime stats</div>';
|
||||
return;
|
||||
}
|
||||
if (!cpu.valid) {
|
||||
setText('cpuUsageText', '等待下一次采样...');
|
||||
setText('cpuUsagePct', '--');
|
||||
setText('cpuIdlePct', '--');
|
||||
setText('cpuSampleMs', '--');
|
||||
setText('cpuTaskCount', '--');
|
||||
setCpuFill(0);
|
||||
if (taskList) taskList.innerHTML = '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>';
|
||||
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 = '<div class="status-empty">当前采样没有任务增量</div>';
|
||||
} 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 '' +
|
||||
'<div class="status-task cpu-task">' +
|
||||
'<div class="status-task-head">' +
|
||||
'<div class="status-task-name">' + escapeHtml(task.name || 'task') + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(formatPercent(pct)) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, pct)) + '%"></div></div>' +
|
||||
'<div class="status-task-meta">' +
|
||||
'<div><span>任务号</span><b>' + escapeHtml(task.task_number) + '</b></div>' +
|
||||
'<div><span>增量</span><b>' + escapeHtml(delta) + '</b></div>' +
|
||||
'<div><span>占比</span><b>' + escapeHtml(formatPercent(pct)) + '</b></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).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 字节数组。
|
||||
|
||||
+11
-13
@@ -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)
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 CAN 通信控制台</title>
|
||||
<link rel="stylesheet" href="style.css?v=20260624b">
|
||||
</head>
|
||||
<body class="can-page">
|
||||
<div class="container can-container">
|
||||
|
||||
<header class="header-bar can-header">
|
||||
<div class="header-brand">
|
||||
<span class="dot-mark"></span>
|
||||
<strong>ESP32 CAN 通信控制台</strong>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<span class="pill offline" id="canConnPill"><span class="indicator"></span><span id="canConnTxt">未连接</span></span>
|
||||
<span class="pill" id="canVersionPill">v--</span>
|
||||
<span class="pill" id="canChannelPill">CH0</span>
|
||||
<span class="pill" id="dbcSummaryPill">未导入 DBC</span>
|
||||
<span class="pill" id="messageSummaryPill">0 报文</span>
|
||||
</div>
|
||||
<div class="header-sub can-header-actions">
|
||||
<a class="pill can-link-pill" href="index.html">返回 OTA 控制台</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card can-toolbar-card">
|
||||
<div class="card-head"><span class="ico">🧰</span> 工作台设置</div>
|
||||
<div class="card-body">
|
||||
<div class="can-toolbar-grid">
|
||||
<div class="can-toolbar-block">
|
||||
<label class="form-label" for="toolIp">ESP32 IP</label>
|
||||
<div class="input-group">
|
||||
<input class="input" type="text" id="toolIp" value="192.168.31.127" placeholder="输入 IP 地址">
|
||||
<button class="btn btn-outline" id="testConnBtn" type="button">测试</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-channel">
|
||||
<label class="form-label" for="defaultChannel">默认通道</label>
|
||||
<select class="input" id="defaultChannel">
|
||||
<option value="0">CH0</option>
|
||||
<option value="1">CH1</option>
|
||||
<option value="2">CH2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-upload">
|
||||
<label class="form-label">DBC 文件</label>
|
||||
<div class="upload-zone upload-zone-sm dbc-dropzone" id="dbcDropZone">
|
||||
<div class="upload-ico">📥</div>
|
||||
<h4>导入 DBC</h4>
|
||||
<p>点击或拖拽 .dbc 文件</p>
|
||||
</div>
|
||||
<input type="file" id="dbcFileInput" accept=".dbc,text/plain" style="display:none;">
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-search">
|
||||
<label class="form-label" for="dbcSearch">报文搜索</label>
|
||||
<input class="input" type="text" id="dbcSearch" placeholder="按报文名、ID、节点或信号名过滤">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-info can-file-info-inline" id="dbcFileInfo">
|
||||
<div class="file-info-row">
|
||||
<span class="can-file-name">📄 <span id="dbcFileName">-</span></span>
|
||||
<button class="btn btn-ghost btn-sm" id="clearDbcBtn" type="button">清除</button>
|
||||
</div>
|
||||
<div class="file-info-grid">
|
||||
<div class="file-meta-item"><span>报文</span><b id="dbcMessageCount">0</b></div>
|
||||
<div class="file-meta-item"><span>信号</span><b id="dbcSignalCount">0</b></div>
|
||||
<div class="file-meta-item"><span>节点</span><b id="dbcNodeCount">0</b></div>
|
||||
<div class="file-meta-item"><span>大小</span><b id="dbcFileSize">0 B</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-foot">
|
||||
<div class="conn-indicator err" id="connIndicator">
|
||||
<span class="light"></span>
|
||||
<span id="connIndicatorTxt">等待连接测试</span>
|
||||
</div>
|
||||
<div class="helper-note" id="connectionNote">
|
||||
当前页面会直接调用 <code>/system/status</code> 和 <code>/can/send</code>。
|
||||
</div>
|
||||
<div class="helper-note" id="dbcHint">
|
||||
当前解析器会自动识别常见 DBC 的 <code>BO_</code>、<code>SG_</code>、<code>VAL_</code>、
|
||||
<code>CM_</code> 和 <code>SIG_VALTYPE_</code>。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card can-table-card">
|
||||
<div class="card-head"><span class="ico">📚</span> 报文列表</div>
|
||||
<div class="card-body can-table-card-body">
|
||||
<div class="message-list" id="messageList">
|
||||
<div class="status-empty">等待导入 DBC...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="can-editor-layout">
|
||||
<section class="card can-signal-card">
|
||||
<div class="card-head"><span class="ico">🎚️</span> 信号编辑</div>
|
||||
<div class="card-body">
|
||||
<div class="can-empty-state" id="emptyMessageState">
|
||||
<div class="can-empty-icon">📡</div>
|
||||
<h3>先导入 DBC,再选择一条 CAN 报文</h3>
|
||||
<p>上方按表格选报文,下方按信号名直接编辑,右侧查看编码结果并发送。</p>
|
||||
</div>
|
||||
|
||||
<div id="messageWorkspace" style="display:none;">
|
||||
<div class="can-selection-bar">
|
||||
<div class="can-selection-copy">
|
||||
<div class="can-title" id="selectedMessageTitle">-</div>
|
||||
<div class="can-subtitle" id="selectedMessageSubtitle">-</div>
|
||||
</div>
|
||||
<div class="can-selection-actions">
|
||||
<button class="btn btn-outline" id="loadPreviewToRawBtn" type="button">载入原始帧</button>
|
||||
<button class="btn btn-primary" id="sendSelectedBtn" type="button">发送当前报文</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="can-meta-grid" id="selectedMessageMeta"></div>
|
||||
<div class="helper-note can-comment-note" id="selectedMessageComment">暂无报文注释。</div>
|
||||
|
||||
<div class="signal-grid signal-table-host" id="signalGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="can-sidepanel">
|
||||
<section class="card">
|
||||
<div class="card-head"><span class="ico">🛰️</span> 帧预览</div>
|
||||
<div class="card-body">
|
||||
<div class="can-frame-panel can-frame-panel-compact">
|
||||
<div class="can-panel-kicker">编码结果</div>
|
||||
<div class="can-frame-hex" id="generatedHex">--</div>
|
||||
<div class="can-byte-strip" id="generatedBytes"></div>
|
||||
<div class="helper-note" id="encodeHint">选择报文后,信号值会实时编码为 CAN 数据。</div>
|
||||
<div class="can-response" id="dbcResp" style="display:none;">
|
||||
<div class="can-response-label">设备响应</div>
|
||||
<div id="dbcRespText"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><span class="ico">✍️</span> 原始帧发送</div>
|
||||
<div class="card-body">
|
||||
<div class="row-2 can-raw-grid">
|
||||
<div class="form-row" style="margin:0;">
|
||||
<label class="form-label" for="manualChannel">通道</label>
|
||||
<select class="input" id="manualChannel">
|
||||
<option value="0">CH0</option>
|
||||
<option value="1">CH1</option>
|
||||
<option value="2">CH2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="margin:0;">
|
||||
<label class="form-label" for="manualId">CAN ID</label>
|
||||
<input class="input" type="text" id="manualId" value="0x18DA00F1" placeholder="支持 0x 或十进制">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top:10px;">
|
||||
<label class="form-label" for="manualDlc">DLC</label>
|
||||
<input class="input" type="number" id="manualDlc" value="8" min="0" max="64">
|
||||
</div>
|
||||
<div class="form-row" style="margin-top:10px;">
|
||||
<label class="form-label" for="manualData">HEX 数据</label>
|
||||
<input class="input" type="text" id="manualData" value="0102030405060708" placeholder="01020304...">
|
||||
</div>
|
||||
<div class="helper-note" id="manualHint">
|
||||
原始帧模式适合绕过 DBC 直接发 HEX,或发送当前预览帧后二次微调。
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary" id="sendManualBtn" type="button">发送原始帧</button>
|
||||
<button class="btn btn-outline" id="resetManualBtn" type="button">重置</button>
|
||||
</div>
|
||||
<div class="can-response" id="manualResp" style="display:none;">
|
||||
<div class="can-response-label">设备响应</div>
|
||||
<div id="manualRespText"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><span class="ico">🕘</span> 最近发送</div>
|
||||
<div class="card-body">
|
||||
<div class="history-list history-list-compact" id="historyList">
|
||||
<div class="status-empty">尚未发送任何 CAN 帧。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast">
|
||||
<span class="toast-ico" id="toastIco">✓</span>
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="can.js?v=20260624b"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1111
File diff suppressed because it is too large
Load Diff
+46
-38
@@ -1,4 +1,4 @@
|
||||
<!--
|
||||
<!--
|
||||
index.html - ESP32 OTA 中继控制台主页面
|
||||
支持 WiFi OTA(ESP32 本机 + GD32 中继)和 BLE OTA 双通道固件升级。
|
||||
配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA), style.css (UI 样式)
|
||||
@@ -61,18 +61,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bleModeCards" style="display:none;">
|
||||
<div class="row-2" id="bleParamRow" style="margin-top:0;">
|
||||
<div class="form-row" style="margin:0;">
|
||||
<label class="form-label" for="payloadSize">每包 Payload</label>
|
||||
<input id="payloadSize" class="input" type="number" min="32" max="498" value="480">
|
||||
</div>
|
||||
<div class="form-row" style="margin:0;">
|
||||
<label class="form-label" for="ctrlHex">触发命令(HEX)</label>
|
||||
<input id="ctrlHex" class="input" type="text" value="F7" maxlength="8">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conn-indicator err" id="connStatus" style="margin-top:12px;">
|
||||
<span class="light"></span>
|
||||
<span id="connStatusTxt">等待连接</span>
|
||||
@@ -157,7 +145,7 @@
|
||||
<b id="fileSha256" style="font-size:10px;word-break:break-all;font-family:var(--font-mono);">-</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-note">建议使用 Chrome / Edge。若首包失败,可将 Payload 从 480 降为 240 再试。</div>
|
||||
<div class="footer-note">建议使用 Chrome / Edge。每包 Payload 与触发命令由系统按目标自动设置。</div>
|
||||
</div>
|
||||
<div style="height:12px"></div>
|
||||
<div id="wifiEsp32ActionPane">
|
||||
@@ -302,7 +290,25 @@
|
||||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body">
|
||||
<div class="status-section-title">CPU 占用</div>
|
||||
<div class="heap-group cpu-group">
|
||||
<div class="heap-group-title">ESP32 Runtime <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(FreeRTOS)</span></div>
|
||||
<div class="heap-bar-row">
|
||||
<span class="heap-bar-text" id="cpuUsageText">等待采样...</span>
|
||||
<span class="heap-bar-track"><span class="heap-bar-fill cpu-fill" id="cpuUsageFill"></span></span>
|
||||
<span class="heap-bar-pct" id="cpuUsagePct">--</span>
|
||||
</div>
|
||||
<div class="heap-detail-row">
|
||||
<span>空闲: <b id="cpuIdlePct">--</b></span>
|
||||
<span>窗口: <b id="cpuSampleMs">--</b></span>
|
||||
<span>任务数: <b id="cpuTaskCount">--</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-task-list cpu-task-list" id="cpuTaskList">
|
||||
<div class="status-empty">等待下一次状态刷新...</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section-title">堆概况</div>
|
||||
<div class="heap-group">
|
||||
<div class="heap-group-title">内部 DRAM <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(数据堆)</span></div>
|
||||
@@ -327,35 +333,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAN panel -->
|
||||
<!-- CAN workspace entry -->
|
||||
<div class="card collapsed">
|
||||
<div class="card-head" onclick="toggleCollapse(this)">
|
||||
<span class="ico">📨</span> CAN 帧发送
|
||||
<span class="ico">📨</span> CAN 通信页
|
||||
<span class="collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="can-grid">
|
||||
<div class="form-row" style="margin:0"><label class="form-label">通道</label>
|
||||
<select class="input" id="canChannel"><option value="0">CH0</option><option value="1">CH1</option><option value="2">CH2</option></select>
|
||||
<div class="can-launch-card">
|
||||
<div class="can-launch-copy">
|
||||
<div class="can-launch-top">
|
||||
<div>
|
||||
<div class="can-launch-kicker">CAN Workspace</div>
|
||||
<h3>独立 CAN 通信工作台</h3>
|
||||
</div>
|
||||
<span class="can-launch-badge">DBC / HEX</span>
|
||||
</div>
|
||||
<p class="can-launch-desc">导入 DBC 后自动读取报文和信号,按物理值实时组帧,并复用现有 <code>/can/send</code> 接口直接发送。</p>
|
||||
<div class="can-launch-points">
|
||||
<span class="can-launch-point">自动解析 DBC</span>
|
||||
<span class="can-launch-point">信号实时组帧</span>
|
||||
<span class="can-launch-point">保留原始 HEX 发送</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin:0"><label class="form-label">扩展帧 ID</label>
|
||||
<input class="input" type="text" id="canId" value="0x18DA00F1">
|
||||
<div class="can-launch-actions">
|
||||
<a class="btn btn-primary can-launch-primary" href="can.html">进入 CAN 页面</a>
|
||||
<button class="btn btn-outline can-launch-secondary" type="button" onclick="window.open('can.html','_blank','noopener')">新窗口打开</button>
|
||||
</div>
|
||||
<div class="form-row" style="margin:0"><label class="form-label">DLC</label>
|
||||
<input class="input" type="number" id="canDlc" value="8" min="1" max="64">
|
||||
<div class="can-launch-foot">
|
||||
<span class="can-launch-foot-label">说明</span>
|
||||
<span>手工 CAN 表单已迁到独立页面,避免和 OTA 控制流混在一起;SPI 调试仍保留在当前页。</span>
|
||||
</div>
|
||||
<div class="form-row" style="margin:0"><label class="form-label">数据 (HEX)</label>
|
||||
<input class="input" type="text" id="canData" value="0102030405060708" placeholder="01020304...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="helper-note" id="canHint">输入十六进制数据后会自动检查字节数是否与 DLC 一致。</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary" onclick="sendCanFrame()">发送</button>
|
||||
<button class="btn btn-outline" onclick="clearCanFields()">清空</button>
|
||||
</div>
|
||||
<div id="canResp" style="display:none;margin-top:12px;padding:12px;background:var(--bg-elevated);border-radius:var(--radius-sm);font-family:var(--font-mono);font-size:12px;">
|
||||
<div style="color:var(--text-label);margin-bottom:4px;">响应</div>
|
||||
<div id="canRespText" style="color:var(--accent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -418,7 +426,7 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260619e"></script>
|
||||
<script src="app.js?v=20260623a"></script>
|
||||
<script>
|
||||
window.connectionMode = window.connectionMode || 'wifi';
|
||||
window.flashTarget = window.flashTarget || 'esp32';
|
||||
@@ -670,6 +678,6 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
applySharedModeUi();
|
||||
})();
|
||||
</script>
|
||||
<script src="ble_ota.js?v=20260617b"></script>
|
||||
<script src="ble_ota.js?v=20260625a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+626
@@ -370,6 +370,16 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.heap-group:last-child { margin-bottom: 0; }
|
||||
.cpu-group { margin-bottom: 8px; }
|
||||
.cpu-task-list {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.cpu-task-list .status-task {
|
||||
background: rgba(255,255,255,0.025);
|
||||
}
|
||||
.cpu-fill {
|
||||
background: linear-gradient(90deg, #38bdf8, #22c55e);
|
||||
}
|
||||
|
||||
.heap-group-title {
|
||||
font-size: 11px;
|
||||
@@ -769,10 +779,595 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
.can-grid {
|
||||
display: grid; grid-template-columns: 70px 1fr 60px 1fr; gap: 10px;
|
||||
}
|
||||
.can-launch-card {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(34,211,238,.18), transparent 28%),
|
||||
linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%);
|
||||
border: 1px solid rgba(34,211,238,.14);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255,255,255,.02),
|
||||
0 18px 40px rgba(0,0,0,.18);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.can-launch-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, rgba(34,211,238,.9), rgba(34,211,238,.18));
|
||||
}
|
||||
.can-launch-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.can-launch-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.can-launch-copy h3 {
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.can-launch-desc {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
max-width: 700px;
|
||||
}
|
||||
.can-launch-kicker {
|
||||
color: #88ecff;
|
||||
font-size: 10px;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
opacity: .92;
|
||||
}
|
||||
.can-launch-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(34,211,238,.08);
|
||||
border: 1px solid rgba(34,211,238,.18);
|
||||
color: #a9f4ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.can-launch-points {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.can-launch-point {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.03);
|
||||
border: 1px solid rgba(255,255,255,.06);
|
||||
color: #c6d7e8;
|
||||
font-size: 12px;
|
||||
}
|
||||
.can-launch-point::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(34,211,238,.35);
|
||||
}
|
||||
.can-launch-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(180px, .85fr);
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.can-launch-primary,
|
||||
.can-launch-secondary {
|
||||
min-height: 42px;
|
||||
}
|
||||
.can-launch-primary {
|
||||
box-shadow: 0 10px 24px rgba(34,211,238,.18);
|
||||
}
|
||||
.can-launch-secondary {
|
||||
color: #d5e4f4;
|
||||
border-color: rgba(255,255,255,.08);
|
||||
background: rgba(255,255,255,.015);
|
||||
}
|
||||
.can-launch-secondary:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,.05);
|
||||
border-color: rgba(34,211,238,.18);
|
||||
}
|
||||
.can-launch-foot {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(255,255,255,.06);
|
||||
color: #91a6bc;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.can-launch-foot-label {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.04);
|
||||
color: #d8e6f4;
|
||||
font-size: 10px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.spi-grid {
|
||||
display: grid; grid-template-columns: 80px 1fr 80px 80px; gap: 10px;
|
||||
}
|
||||
|
||||
/* === CAN WORKSPACE PAGE === */
|
||||
.can-page::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 10% 18%, rgba(34,211,238,.08), transparent 26%),
|
||||
radial-gradient(circle at 85% 14%, rgba(245,158,11,.05), transparent 24%);
|
||||
}
|
||||
.can-container {
|
||||
max-width: 1700px;
|
||||
}
|
||||
.can-header {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
z-index: 5;
|
||||
backdrop-filter: blur(14px);
|
||||
background: rgba(15,22,31,.86);
|
||||
}
|
||||
.can-header-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.can-link-pill {
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.can-toolbar-card,
|
||||
.can-table-card,
|
||||
.can-editor-layout {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.can-toolbar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1.1fr) 180px minmax(220px, .9fr) minmax(260px, 1fr);
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.can-toolbar-block {
|
||||
min-width: 0;
|
||||
}
|
||||
.can-toolbar-upload .upload-zone {
|
||||
height: 100%;
|
||||
min-height: 88px;
|
||||
padding: 18px 14px;
|
||||
}
|
||||
.can-file-info-inline {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.can-file-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
.can-toolbar-foot {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
.can-table-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
.can-editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.can-sidepanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.dbc-dropzone {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dbc-dropzone::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -40% auto auto -10%;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: radial-gradient(circle, rgba(34,211,238,.18), transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.message-list {
|
||||
max-height: 390px;
|
||||
overflow: auto;
|
||||
}
|
||||
.message-table {
|
||||
min-width: 1200px;
|
||||
}
|
||||
.message-table-head,
|
||||
.message-row {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(180px, 1.6fr) 150px 88px 88px 76px 76px 150px minmax(220px, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
.message-table-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #182330;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.message-row {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(255,255,255,.04);
|
||||
transition: background .12s ease, border-color .12s ease;
|
||||
}
|
||||
.message-row:hover {
|
||||
background: rgba(255,255,255,.03);
|
||||
}
|
||||
.message-row.active {
|
||||
background: linear-gradient(90deg, rgba(34,211,238,.2), rgba(34,211,238,.07));
|
||||
box-shadow: inset 3px 0 0 0 var(--accent);
|
||||
}
|
||||
.message-col {
|
||||
padding: 11px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
border-right: 1px solid rgba(255,255,255,.03);
|
||||
}
|
||||
.message-table-head .message-col {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
color: #c8d6e7;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.message-col:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.message-col-index,
|
||||
.message-col-dlc,
|
||||
.message-col-signal,
|
||||
.message-col-channel,
|
||||
.message-col-type {
|
||||
text-align: center;
|
||||
}
|
||||
.message-col-id,
|
||||
.history-item-hex,
|
||||
.can-frame-hex,
|
||||
.can-title,
|
||||
.can-subtitle,
|
||||
.byte-pill,
|
||||
.history-item-main,
|
||||
.history-item-foot,
|
||||
.signal-table,
|
||||
.signal-cell-meta,
|
||||
.signal-cell-range {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.message-col-id {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
.can-empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 300px;
|
||||
text-align: center;
|
||||
border: 1px dashed rgba(34,211,238,.2);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(180deg, rgba(26,36,50,.7), rgba(20,29,40,.96));
|
||||
padding: 28px;
|
||||
}
|
||||
.can-empty-icon {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 14px;
|
||||
background: rgba(34,211,238,.12);
|
||||
font-size: 32px;
|
||||
box-shadow: inset 0 0 0 1px rgba(34,211,238,.16);
|
||||
}
|
||||
.can-empty-state h3 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.can-empty-state p {
|
||||
max-width: 560px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.75;
|
||||
}
|
||||
.can-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.can-subtitle {
|
||||
margin-top: 6px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
.can-selection-bar,
|
||||
.history-item-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.can-selection-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.can-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.can-meta-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.can-meta-card span {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.can-meta-card b {
|
||||
font-size: 14px;
|
||||
}
|
||||
.can-comment-note {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.can-frame-panel {
|
||||
background: linear-gradient(180deg, rgba(26,36,50,.95), rgba(13,19,27,.98));
|
||||
border: 1px solid rgba(34,211,238,.12);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.02);
|
||||
}
|
||||
.can-frame-panel-compact {
|
||||
padding: 16px;
|
||||
}
|
||||
.can-panel-kicker {
|
||||
color: var(--text-label);
|
||||
font-size: 10px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.can-panel-title {
|
||||
margin-top: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.can-frame-hex {
|
||||
margin-top: 14px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
background: #081019;
|
||||
color: #7ef3ff;
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
border: 1px solid rgba(34,211,238,.16);
|
||||
}
|
||||
.can-byte-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.byte-pill {
|
||||
min-width: 62px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(34,211,238,.08);
|
||||
border: 1px solid rgba(34,211,238,.16);
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.byte-pill b {
|
||||
display: block;
|
||||
color: var(--text-label);
|
||||
font-size: 10px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.signal-table-host {
|
||||
display: block;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.signal-table {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.signal-table-head,
|
||||
.signal-row {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(220px, 1.2fr) minmax(220px, 1fr) minmax(220px, 1fr) minmax(190px, .9fr) minmax(220px, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
.signal-table-head {
|
||||
background: #182330;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.signal-table-body {
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
}
|
||||
.signal-row {
|
||||
border-bottom: 1px solid rgba(255,255,255,.04);
|
||||
background: rgba(255,255,255,.01);
|
||||
}
|
||||
.signal-row:nth-child(even) {
|
||||
background: rgba(255,255,255,.025);
|
||||
}
|
||||
.signal-cell {
|
||||
padding: 11px 12px;
|
||||
border-right: 1px solid rgba(255,255,255,.03);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
min-width: 0;
|
||||
}
|
||||
.signal-cell:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.signal-table-head .signal-cell {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
color: #c8d6e7;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.signal-cell-index {
|
||||
text-align: center;
|
||||
}
|
||||
.signal-cell-name b,
|
||||
.signal-cell-range b,
|
||||
.signal-cell-meta b {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.signal-cell-name small,
|
||||
.signal-cell-range small,
|
||||
.signal-cell-meta small,
|
||||
.signal-cell-comment {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.signal-input-compact {
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.can-response {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.can-response.ok {
|
||||
border-color: rgba(34,197,94,.25);
|
||||
color: #c7f9d5;
|
||||
}
|
||||
.can-response.err {
|
||||
border-color: rgba(239,68,68,.25);
|
||||
color: #fecaca;
|
||||
}
|
||||
.can-response-label {
|
||||
color: var(--text-label);
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 620px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.history-list-compact {
|
||||
max-height: 360px;
|
||||
}
|
||||
.history-item {
|
||||
padding: 12px 13px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.history-item-time {
|
||||
color: var(--text-label);
|
||||
font-size: 11px;
|
||||
}
|
||||
.history-item-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
}
|
||||
.history-item-tag.ok {
|
||||
background: rgba(34,197,94,.12);
|
||||
color: #b8f5cb;
|
||||
}
|
||||
.history-item-tag.err {
|
||||
background: rgba(239,68,68,.12);
|
||||
color: #fecaca;
|
||||
}
|
||||
.history-item-main {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.history-item-hex {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #7ef3ff;
|
||||
word-break: break-all;
|
||||
}
|
||||
.history-item-foot {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* === TOAST === */
|
||||
.toast {
|
||||
position: fixed; top: 45%; left: 50%; transform: translateX(-50%) translateY(30px);
|
||||
@@ -868,6 +1463,13 @@ code { color: #d3f9ff; font-family: var(--font-mono); }
|
||||
@media (max-width: 1100px) {
|
||||
.main-layout { grid-template-columns: 1fr; }
|
||||
.badge-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.can-toolbar-grid,
|
||||
.can-toolbar-foot,
|
||||
.can-editor-layout { grid-template-columns: 1fr; }
|
||||
.message-list {
|
||||
max-height: 420px;
|
||||
}
|
||||
.can-meta-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.container { padding: 16px; }
|
||||
@@ -876,5 +1478,29 @@ code { color: #d3f9ff; font-family: var(--font-mono); }
|
||||
.status-kv-grid, .status-task-meta { grid-template-columns: 1fr; }
|
||||
.can-grid { grid-template-columns: 1fr 1fr; }
|
||||
.btn-row { flex-direction: column; }
|
||||
.can-meta-grid { grid-template-columns: 1fr; }
|
||||
.can-launch-top,
|
||||
.can-launch-actions,
|
||||
.can-launch-foot {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.can-selection-bar,
|
||||
.can-selection-actions,
|
||||
.history-item-top {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.can-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
.can-frame-hex {
|
||||
font-size: 15px;
|
||||
}
|
||||
.message-table,
|
||||
.signal-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user