feat(http): 添加CPU使用率监控功能

添加了详细的CPU使用率统计功能,包括:
- 实现FreeRTOS运行时统计采样机制
- 添加CPU任务占用率计算和快照功能
- 在/system/status接口中增加CPU状态JSON数据
- 支持实时监控各任务的运行时间和占用百分比

更新了前端界面以显示CPU使用情况:
- 添加CPU使用率进度条和百分比显示
- 显示空闲时间、采样窗口和任务数量
- 展示前8个最耗时任务的详细统计信息

同时优化了CAN FD桥接实现,重构为SPI协议帧传输方式,
并改进了前端CAN页面布局,将CAN表单迁移到独立页面。
This commit is contained in:
小龙虾
2026-06-25 01:42:31 +08:00
parent 7a41eac526
commit 6ef97307c3
10 changed files with 2354 additions and 114 deletions
+4 -4
View File
@@ -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"
+181 -6
View File
@@ -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;
}
+52 -39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+46 -38
View File
@@ -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
View File
@@ -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;
}
}