├── .vscode └── settings.json ├── LICENSE ├── README.md ├── aresplot.md ├── aresplot_client.c ├── css └── styles.css ├── html_partials ├── control_panel.html ├── plot_module.html ├── quaternion_module.html └── text_module.html ├── icons └── icon-512x512.png ├── index.html ├── js ├── config.js ├── event_bus.js ├── main.js ├── modules │ ├── aresplot_protocol.js │ ├── data_processing.js │ ├── elf_analyzer_service.js │ ├── plot_module.js │ ├── quat_module.js │ ├── serial.js │ ├── terminal_module.js │ ├── ui.js │ └── worker_service.js ├── utils.js └── worker │ └── data_worker.js ├── manifest.json ├── pictures └── screenshot.png ├── plotter.html └── sw.js /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveServer.settings.port": 5526 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 CaptainKAZ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📊 Web Serial Plotter - 高性能网页串口绘图器 2 | 3 |

4 | 5 | Live Demo 6 | 7 |

8 | 9 | 这是一个基于 **Web Serial API** 的高性能网页串口数据绘图和可视化工具,旨在提供一个**无需安装、跨平台**的串口数据监控解决方案。它利用 **Web Worker** 处理数据以保持主线程流畅,使用 **TimeChart** 库进行高效的 WebGL 加速绘图,并集成了 **Three.js** 实现四元数姿态的 3D 可视化,以及 **xterm.js** 来显示原始数据流。 10 | 11 | **➡️ [点此在线体验 (GitHub Pages)](https://captainkaz.github.io/web-serial-plotter/)** 12 | 13 | ## ✨ 功能特点 14 | 15 | * 🔌 **Web Serial 连接:** 通过浏览器直接连接和读取串口设备数据。 16 | * 🧪 **模拟数据源:** 内置模拟数据生成器,方便无设备时进行功能测试和性能演示。 17 | * 🧩 **多种解析协议:** 18 | * 支持多种内置串口数据解析协议(默认逗号/空格分隔、JustFloat、FireWater)。 19 | * 支持用户**自定义 JavaScript 解析函数**,提供最大的灵活性。 20 | * 📈 **高性能实时绘图:** 21 | * 使用 [TimeChart](https://github.com/huww98/TimeChart) 库进行 WebGL 加速的多通道数据曲线绘制。 22 | * 交互式图表:支持缩放(鼠标滚轮)、平移(鼠标拖拽)。 23 | * 自动跟随最新数据或手动浏览历史数据(可切换)。 24 | * 图表通道数可根据接收到的数据动态增加。 25 | * 🔢 **数据显示:** 26 | * 实时显示最新解析出的各通道数值。 27 | * 使用 [xterm.js](https://xtermjs.org/) 终端模拟器显示原始串口数据流,支持文本(STR)和十六进制(HEX)两种格式切换。 28 | * **四元数 3D 可视化:** 29 | * 使用 [Three.js](https://threejs.org/) 实时展示基于四元数数据的 3D 物体姿态。 30 | * 需要用户从可用数据通道中选择代表 W, X, Y, Z 的四个通道。 31 | * 💾 **数据缓冲与导出:** 32 | * 可配置数据点的内存缓冲大小。 33 | * 实时显示缓冲区使用百分比、已缓冲点数和预计剩余/总缓冲时间。 34 | * 可以将缓冲区内的数据点(时间戳和数值)导出为 CSV 文件。 35 | * 🚀 **性能优化:** 36 | * 使用 Web Worker 进行数据处理和解析,避免阻塞浏览器主线程,确保 UI 流畅。 37 | * 优化数据批处理和 UI 更新逻辑。 38 | * 📱 **现代化 Web 技术:** 39 | * 采用 ES Modules 进行模块化开发。 40 | * 支持 PWA (Progressive Web App),可添加到主屏幕并具有一定的离线缓存能力 (通过 Service Worker)。 41 | * 使用 [Split.js](https://split.js.org/) 实现可拖拽调整大小的界面布局。 42 | * 使用 [Lucide Icons](https://lucide.dev/) 提供清晰的图标。 43 | 44 | ## 🚀 性能亮点 (Performance Highlight) 45 | 46 | **我们致力于保持 Web Serial Plotter 的高性能特性。** Web Worker 的使用、WebGL 加速的 TimeChart 以及优化的数据处理流程都是为了确保应用的流畅运行,即使在处理高频数据时也是如此。 47 | 48 | > **📊 基准参考:** 在我们的测试中(硬件:**NVIDIA RTX 3050, AMD Ryzen 7 6800HS**),使用模拟数据源以 **1000Hz** 频率处理 **40 个通道** 时,应用能够稳定、流畅地以 **~60 FPS** 运行。 49 | 50 | 我们欢迎任何能够进一步提升性能的贡献。 51 | 52 | ## 📸 截图 (Screenshots) 53 | 54 | ![Web Serial Plotter 应用截图](pictures/screenshot.png) 55 | 56 | ## 🛠️ 技术栈 57 | 58 | * **核心 API:** Web Serial API, Web Workers API 59 | * **绘图:** [TimeChart](https://github.com/huww98/TimeChart) (WebGL-based) 60 | * **3D 可视化:** [Three.js](https://threejs.org/) 61 | * **终端显示:** [xterm.js](https://xtermjs.org/) 62 | * **布局:** [Split.js](https://split.js.org/), CSS (可能包含 Tailwind CSS 类) 63 | * **图标:** [Lucide Icons](https://lucide.dev/) 64 | * **数据处理/交互:** D3.js (部分库, 通过 CDN), Vanilla JavaScript (ES Modules) 65 | * **PWA:** Service Worker, Manifest 66 | 67 | ## 🔧 如何使用 68 | 69 | 1. **在线体验:** 70 | * 直接访问: **[https://captainkaz.github.io/web-serial-plotter/](https://captainkaz.github.io/web-serial-plotter/)** 71 | 72 | 2. **本地运行 (完整功能):** 73 | * **环境要求:** 74 | * 需要支持 **Web Serial API** 的现代浏览器(例如:最新版本的 Google Chrome, Microsoft Edge)。 75 | * Web Serial API 通常需要**安全的上下文** (`https://`) 或在 `localhost` 上运行。 76 | * **步骤:** 77 | * 克隆或下载本仓库代码。 78 | * 使用一个简单的本地 Web 服务器 在项目根目录启动服务 (例如: `npx http-server .`)。 79 | * 通过浏览器访问 `http://localhost:PORT` (PORT 通常是 8080)。 80 | * > **⚠️ 重要提示:** 直接通过 `file://` 协议打开本地 `index.html` 文件 **无法** 使用 Web Serial API 功能。 81 | 82 | 3. **操作流程:** 83 | * **选择数据源:** 在左侧控制面板的“数据源”下拉菜单中选择 "WebSerial"(或 "模拟数据" 进行测试)。 84 | * **(WebSerial) 配置参数:** 85 | * 根据您的设备设置正确的波特率、数据位、停止位、校验位和流控制。 86 | * 选择合适的“解析协议”。如果选择“自定义”,请在文本框中输入 JavaScript 解析函数代码,然后点击“更新解析器”按钮将代码发送到 Worker。 87 | * **(WebSerial) 连接设备:** 88 | * 点击“连接串口”按钮。 89 | * 浏览器会弹出设备选择窗口,选择您要连接的串口设备并点击“连接”。 90 | * **开始采集:** 点击“开始采集”按钮。应用将开始接收(或生成)数据并显示。 91 | * **查看姿态 (可选):** 如果数据包含四元数,点击“四元数姿态显示”模块右上角的设置图标,选择对应的 W, X, Y, Z 通道并点击“确认选择”。 92 | * **交互:** 93 | * 在曲线图区域,使用鼠标滚轮缩放时间轴,使用Shift+鼠标滚轮缩放数据轴,按住鼠标左键拖拽平移。双击图表可快速启用“跟随”模式并打开数据轴自动范围。 94 | * 切换“跟随”复选框来控制图表是否自动滚动到最新数据。 95 | * 在原始数据显示区域,点击 "STR" 或 "HEX" 按钮切换显示格式。 96 | * 拖动模块之间的分隔条调整布局大小。 97 | * **停止采集:** 点击“结束采集”按钮。 98 | * **数据导出/清除:** 99 | * 点击“下载 CSV”将当前缓冲区内的数据保存为 CSV 文件。 100 | * 点击“清除图表和缓冲”按钮会清空所有图表、缓冲区和统计数据。 101 | 102 | ## 📑 界面说明 103 | 104 | * **控制面板 (左侧):** 105 | * **数据源:** 选择数据来源 (WebSerial/模拟),控制开始/停止采集,显示连接状态。 106 | * **模拟设置:** (仅在数据源为模拟时可见) 配置模拟数据的通道数、频率和幅值。 107 | * **WebSerial 设置:** (仅在数据源为 WebSerial 时可见) 连接/断开串口,配置串口参数(波特率等),选择或自定义数据解析逻辑。 108 | * **数据采集与导出:** 设置缓冲区大小,查看缓冲区使用情况,提供下载 CSV 和清除数据的功能。 109 | * **显示区域 (右侧,可调整布局):** 110 | * **曲线显示 (WebGL):** 使用 TimeChart 实时绘制数据曲线,可交互操作。 111 | * **原始数据显示:** 上方显示最新解析出的数值,下方使用 xterm.js 终端显示带时间戳的原始数据流 (STR/HEX)。 112 | * **四元数姿态显示:** 使用 Three.js 显示 3D 模型姿态,需要先配置通道。 113 | 114 | ## 📜 解析器说明 115 | 116 | 数据解析在 Web Worker 中进行,以避免阻塞主线程。 117 | 118 | * **内置协议:** 119 | * **默认:** 解析以换行符 (`\n` 或 `\r\n`) 结束,由逗号或空格分隔的数值行。 120 | * [**JustFloat:**](https://www.vofa.plus/docs/learning/dataengines/justfloat/) 解析 `N` 个 4 字节小端浮点数后跟特定帧尾 `[0x00, 0x00, 0x80, 0x7f]` 的二进制帧。 121 | * [**FireWater:**](https://www.vofa.plus/docs/learning/dataengines/firewater) 解析形如 `<任意前缀>:<数值1>,<数值2>,...,<数值N>\n` 的文本行。 122 | * **自定义解析器函数 (当协议选择 "自定义" 时):** 123 | * 您需要提供一个 JavaScript 函数体。该函数接收一个 `Uint8Array` 类型的参数 `uint8ArrayData`,代表从串口接收到的原始字节数据块。 124 | * 函数必须返回一个对象,格式为:`{ values: number[] | null, frameByteLength: number }`。 125 | * `values`: 解析成功时,包含一帧数据的数值数组 `number[]`;如果传入的 `uint8ArrayData` 中没有找到一个完整的帧,则返回 `null`。 126 | * `frameByteLength`: 如果解析成功 (`values` 非 `null`),表示这一帧数据在输入的 `uint8ArrayData` 中消耗了多少字节;如果解析未成功 (`values` 为 `null`),则应返回 `0`。 127 | * Worker 会持续调用此函数,直到输入的 `uint8ArrayData` 被完全消耗或函数返回 `frameByteLength: 0`。 128 | 129 | ## 🤝 贡献 (Contributing) 130 | 131 | 欢迎各种形式的贡献!如果您想参与改进 Web Serial Plotter,请直接提pr,如果有愿望单,也可以直接提issue。 132 | 133 | ## 📄 许可证 (License) 134 | 135 | 本项目采用 MIT 许可证。 -------------------------------------------------------------------------------- /aresplot.md: -------------------------------------------------------------------------------- 1 | # AresPlot Protocol Specification v1.0 2 | 3 | ## 1. 概述 (Overview) 4 | 5 | AresPlot 是一种基于串行或类似数据流的通信协议,主要用于嵌入式微控制器 (MCU) 系统内部变量的高频监控(类似示波器)和实时参数调整。上位机 (PC) 通过解析 MCU 的 ELF 文件获取全局或静态变量的地址和类型信息,用户选择需监控的变量后,通过本协议与下位机 (MCU) 通信,实现数据的高速传输和参数的动态修改。 6 | 7 | **主要特性:** 8 | 9 | * **高频数据流:** 支持以较高频率从 MCU 发送变量数据。 10 | * **动态变量选择与控制:** 上位机可指定监控任意数量和组合的变量。`CMD_START_MONITOR` 指令可在监控过程中反复发送以更改监控变量集。通过发送 `NumVariables` 为 0 的 `CMD_START_MONITOR` 指令来停止监控数据流。 11 | * **统一数据类型:** MCU 将所有监控的变量值转换为 FP32 (单精度浮点数) 发送,简化上位机处理。 12 | * **实时调参:** 上位机可向 MCU 发送指令,修改指定内存地址的变量值。 13 | * **采样率可配置(可选):** 上位机可以请求 MCU 调整数据发送的频率。MCU 在上电后会使用一个默认的采样率。 14 | * **ELF 辅助:** 上位机通过 ELF 文件自动发现变量,协议本身专注于通信。 15 | 16 | ## 2. 定义 (Definitions) 17 | 18 | * **字节序 (Endianness):** 本协议所有多字节字段均采用 **小端序 (Little Endian)**。 19 | * **浮点数 (Floating Point):** 本协议中所有浮点数值均采用 **FP32 (IEEE 754 单精度浮点数)** 格式,并按小端序传输。 20 | * **PC:** 上位机,通常是个人计算机。 21 | * **MCU:** 下位机,嵌入式微控制器。 22 | 23 | ## 3. 连接拓扑 (Connection Topology) 24 | 25 | MCU 与 PC 通过串行接口(如 UART, USB CDC)或任何支持可靠有序字节流的通道连接。 26 | 27 | ```mermaid 28 | graph LR 29 | PC[上位机 PC] -- "串行/数据流 (Serial/Stream)" --> MCU[下位机 MCU] 30 | ``` 31 | 32 | ## 4. 通用帧结构 (General Frame Structure) 33 | 34 | 所有 AresPlot 通信都遵循一个固定的序列:帧起始符 (SOP),命令ID (CMD),负载长度 (LEN),实际的有效负载 (PAYLOAD),校验和 (CHECKSUM),以及帧结束符 (EOP)。 35 | 36 | **帧序列:** 37 | `SOP` (1 Byte) -> `CMD` (1 Byte) -> `LEN` (2 Bytes) -> `PAYLOAD` (LEN Bytes) -> `CHECKSUM` (1 Byte) -> `EOP` (1 Byte) 38 | 39 | **通用帧图示 (概念性):** 40 | ```mermaid 41 | --- 42 | title: General AresPlot Frame (Conceptual Layout) 43 | --- 44 | packet-beta 45 | 0-7: "SOP (0xA5)" 46 | 8-15: "CMD ID" 47 | 16-31: "Payload LEN (uint16_t)" 48 | 32-63: "PAYLOAD (Variable: LEN bytes)" 49 | 64-71: "CHECKSUM (1 byte)" 50 | 72-79: "EOP (0x5A) (1 byte)" 51 | ``` 52 | 53 | **通用帧字段说明:** 54 | 55 | | 字段名 | 偏移 (字节, 从SOP) | 大小 (字节) | 数据类型 | 描述 | 56 | |----------------|--------------------|-------------|----------|----------------------------------------------------------------------| 57 | | SOP | 0 | 1 | uint8_t | 帧起始符 (`0xA5`) | 58 | | CMD | 1 | 1 | uint8_t | 命令标识符 | 59 | | LEN | 2 | 2 | uint16_t | `PAYLOAD` 字段的字节长度 (小端序) | 60 | | PAYLOAD | 4 | `LEN` 值 | uint8_t[]| 实际数据负载,其结构取决于 `CMD` | 61 | | CHECKSUM | 4 + `LEN` 值 | 1 | uint8_t | 从 `CMD` 到 `PAYLOAD` (包含这两者) 所有字节的简单异或和 (XOR Sum) | 62 | | EOP | 5 + `LEN` 值 | 1 | uint8_t | 帧结束符 (`0x5A`) | 63 | 64 | ## 5. 命令定义 (Command Definitions) 65 | 66 | ### 5.1. `AresOriginalType_t` 枚举 (Original Data Type Enumeration) 67 | 68 | 此枚举用于在 `CMD_START_MONITOR` 和 `CMD_SET_VARIABLE` 命令中指定变量的原始数据类型。MCU 根据此类型从内存读取数据或向内存写入数据。 69 | 70 | | 值 | 名称 | 描述 | 71 | | :--- | :------------------- | :------------------------------------- | 72 | | 0x00 | `ARES_TYPE_INT8` | 有符号 8 位整数 | 73 | | 0x01 | `ARES_TYPE_UINT8` | 无符号 8 位整数 | 74 | | 0x02 | `ARES_TYPE_INT16` | 有符号 16 位整数 (小端序) | 75 | | 0x03 | `ARES_TYPE_UINT16` | 无符号 16 位整数 (小端序) | 76 | | 0x04 | `ARES_TYPE_INT32` | 有符号 32 位整数 (小端序) | 77 | | 0x05 | `ARES_TYPE_UINT32` | 无符号 32 位整数 (小端序) | 78 | | 0x06 | `ARES_TYPE_FLOAT32` | 32 位单精度浮点数 (IEEE 754, 小端序) | 79 | | 0x07 | `ARES_TYPE_FLOAT64` | 64 位双精度浮点数 (IEEE 754, 小端序) * | 80 | | 0x08 | `ARES_TYPE_BOOL` | 布尔型 (通常 1 字节) | 81 | 82 | *注意: `ARES_TYPE_FLOAT64` 仅在 MCU 支持且带宽允许时使用。所有类型在传输时都会被转换为 FP32 (用于 `CMD_MONITOR_DATA`) 或从 FP32 转换而来 (用于 `CMD_SET_VARIABLE`)。* 83 | 84 | ### 5.2. PC -> MCU 命令 85 | 86 | #### 5.2.1. `CMD_START_MONITOR (0x01)`: 请求开始/更新/停止监控变量 87 | 88 | * **用途:** 上位机指定要监控的变量列表(0 到 255 个,实际数量受带宽限制)。此命令可在监控过程中反复发送以更改监控变量集。若 `NumVariables` 为 0,则 MCU 停止发送 `CMD_MONITOR_DATA`。 89 | * **帧结构 (示例 N=1 变量, 总计 12 字节 / 96 比特):** 90 | ```mermaid 91 | --- 92 | title: CMD_START_MONITOR (0x01) Frame (Example for N=1 variable) 93 | --- 94 | packet-beta 95 | 0-7: "SOP (0xA5)" 96 | 8-15: "CMD (0x01)" 97 | 16-31: "LEN (N=1: 0x0006)" 98 | 32-39: "NumVariables (N)" 99 | 40-71: "Var1_Address" 100 | 72-79: "Var1_OriginalType" 101 | 80-87: "CHECKSUM" 102 | 88-95: "EOP (0x5A)" 103 | ``` 104 | * **帧结构 (示例 N=0, 停止监控, 总计 7 字节 / 56 比特):** 105 | ```mermaid 106 | --- 107 | title: CMD_START_MONITOR (0x01) Frame (Example for N=0, Stop Monitoring) 108 | --- 109 | packet-beta 110 | 0-7: "SOP (0xA5)" 111 | 8-15: "CMD (0x01)" 112 | 16-31: "LEN (N=0: 0x0001)" 113 | 32-39: "NumVariables (0x00)" 114 | 40-47: "CHECKSUM" 115 | 48-55: "EOP (0x5A)" 116 | ``` 117 | * **字段说明:** 118 | | 字段名 | 偏移 (字节) | 大小 (字节) | 数据类型 | 描述 | 119 | |--------------------|-------------------------|-------------|--------------------|----------------------------------------------------------------------| 120 | | SOP | 0 | 1 | uint8_t | 帧起始符 (`0xA5`) | 121 | | CMD | 1 | 1 | uint8_t | 命令 ID (`0x01`) | 122 | | LEN | 2 | 2 | uint16_t | Payload 长度 (`1 + N*5`字节, N 为变量数 0-255, 小端序) | 123 | | **Payload:** | | | | (开始于字节偏移 4) | 124 | | NumVariables | 4 | 1 | uint8_t | 要监控的变量数量 (N), 范围 0-255。若为 0,则停止数据流。 | 125 | | Var1_Address | 5 (若 N>0) | 4 | uint32_t | (若N>0) 第1个变量的内存地址 (小端序) | 126 | | Var1_OriginalType | 9 (若 N>0) | 1 | AresOriginalType_t | (若N>0) 第1个变量的原始数据类型 | 127 | | ... | ... | ... | ... | 若N>1, 后续变量信息 (Var_i_Address, Var_i_OriginalType) 重复 N-1 次 | 128 | | CHECKSUM | 4 + `LEN` | 1 | uint8_t | 校验和 | 129 | | EOP | 5 + `LEN` | 1 | uint8_t | 帧结束符 (`0x5A`) | 130 | 131 | *注: 若 N=0, `LEN` 为 1 (`0x0100` 小端序), Payload 仅含 `NumVariables`。若 N=1, `LEN` 为 6 (`0x0600`)。* 132 | 133 | #### 5.2.2. `CMD_SET_VARIABLE (0x02)`: 请求设置变量值 134 | 135 | * **用途:** 上位机请求 MCU 修改指定内存地址的变量值。 136 | * **帧结构 (总计 14 字节 / 112 比特):** 137 | ```mermaid 138 | --- 139 | title: CMD_SET_VARIABLE (0x02) Frame 140 | --- 141 | packet-beta 142 | 0-7: "SOP (0xA5)" 143 | 8-15: "CMD (0x02)" 144 | 16-31: "LEN (0x0009)" 145 | 32-63: "Address" 146 | 64-71: "OriginalType" 147 | 72-103: "Value (FP32)" 148 | 104-111: "CHECKSUM" 149 | 112-119: "EOP (0x5A)" 150 | ``` 151 | * **字段说明:** 152 | | 字段名 | 偏移 (字节) | 大小 (字节) | 数据类型 | 描述 | 153 | |----------------|-------------|-------------|--------------------|----------------------------------------------------------------------| 154 | | SOP | 0 | 1 | uint8_t | 帧起始符 (`0xA5`) | 155 | | CMD | 1 | 1 | uint8_t | 命令 ID (`0x02`) | 156 | | LEN | 2 | 2 | uint16_t | Payload 长度 (固定为 9, 小端序: `0x0900`) | 157 | | **Payload:** | | | | (开始于字节偏移 4) | 158 | | Address | 4 | 4 | uint32_t | 要设置的变量的内存地址 (小端序) | 159 | | OriginalType | 8 | 1 | AresOriginalType_t | 变量的原始数据类型 | 160 | | Value | 9 | 4 | FP32 | 要设置的新值 (单精度浮点数, 小端序) | 161 | | CHECKSUM | 13 | 1 | uint8_t | 校验和 | 162 | | EOP | 14 | 1 | uint8_t | 帧结束符 (`0x5A`) | 163 | 164 | #### 5.2.3. `CMD_SET_SAMPLE_RATE (0x03)`: 设置采样率 (可选) 165 | 166 | * **用途:** (可选) 上位机请求 MCU 设置数据监控的采样频率。MCU 会有一个默认采样率。 167 | * **帧结构 (总计 10 字节 / 80 比特):** 168 | ```mermaid 169 | --- 170 | title: CMD_SET_SAMPLE_RATE (0x03) Frame 171 | --- 172 | packet-beta 173 | 0-7: "SOP (0xA5)" 174 | 8-15: "CMD (0x03)" 175 | 16-31: "LEN (0x0004)" 176 | 32-63: "SampleRateHz" 177 | 64-71: "CHECKSUM" 178 | 72-79: "EOP (0x5A)" 179 | ``` 180 | * **字段说明:** 181 | | 字段名 | 偏移 (字节) | 大小 (字节) | 数据类型 | 描述 | 182 | |----------------|-------------|-------------|----------|----------------------------------------------------------------------| 183 | | SOP | 0 | 1 | uint8_t | 帧起始符 (`0xA5`) | 184 | | CMD | 1 | 1 | uint8_t | 命令 ID (`0x03`) | 185 | | LEN | 2 | 2 | uint16_t | Payload 长度 (固定为 4, 小端序: `0x0400`) | 186 | | SampleRateHz | 4 | 4 | uint32_t | (Payload) 期望的采样频率,单位 Hz (小端序)。值为 0 可能表示“尽可能快”。 | 187 | | CHECKSUM | 8 | 1 | uint8_t | 校验和 | 188 | | EOP | 9 | 1 | uint8_t | 帧结束符 (`0x5A`) | 189 | 190 | ### 5.3. MCU -> PC 命令 191 | 192 | #### 5.3.1. `CMD_MONITOR_DATA (0x81)`: 发送监控数据 193 | 194 | * **用途:** MCU 定期向上位机发送当前监控变量的值。仅当 `CMD_START_MONITOR` 请求的 `NumVariables` > 0 时发送。 195 | * **帧结构 (示例 N=1 变量, 总计 13 字节 / 104 比特):** 196 | ```mermaid 197 | --- 198 | title: CMD_MONITOR_DATA (0x81) Frame (Example for N=1 variable) 199 | --- 200 | packet-beta 201 | 0-7: "SOP (0xA5)" 202 | 8-15: "CMD (0x81)" 203 | 16-31: "LEN (N=1: 0x0008)" 204 | 32-63: "Timestamp" 205 | 64-95: "Value_1 (FP32)" 206 | 96-103: "CHECKSUM" 207 | 104-111: "EOP (0x5A)" 208 | ``` 209 | * **字段说明:** 210 | | 字段名 | 偏移 (字节) | 大小 (字节) | 数据类型 | 描述 | 211 | |----------------|-------------------------|-------------|----------|----------------------------------------------------------------------| 212 | | SOP | 0 | 1 | uint8_t | 帧起始符 (`0xA5`) | 213 | | CMD | 1 | 1 | uint8_t | 命令 ID (`0x81`) | 214 | | LEN | 2 | 2 | uint16_t | Payload 长度 (`4 + N*4`字节, N 为变量数, 小端序) | 215 | | **Payload:** | | | | (开始于字节偏移 4) | 216 | | Timestamp | 4 | 4 | uint32_t | MCU 侧的时间戳 (例如毫秒, 小端序) | 217 | | Value_1 | 8 | 4 | FP32 | 第1个变量的值 (单精度浮点数, 小端序) | 218 | | ... | ... | ... | ... | 若N>1, 后续变量值 (Value_i) 重复 N-1 次 | 219 | | CHECKSUM | 4 + `LEN` | 1 | uint8_t | 校验和 | 220 | | EOP | 5 + `LEN` | 1 | uint8_t | 帧结束符 (`0x5A`) | 221 | 222 | *注: 数据值的顺序必须与 `CMD_START_MONITOR` 请求中的变量顺序严格一致。若 N=1, `LEN` 为 8 (`0x0800`)。* 223 | 224 | #### 5.3.2. `CMD_ACK (0x82)`: 命令确认/应答 225 | 226 | * **用途:** MCU 回复上位机发来的命令,告知执行状态。 227 | * **帧结构 (总计 7 字节 / 56 比特):** 228 | ```mermaid 229 | --- 230 | title: CMD_ACK (0x82) Frame 231 | --- 232 | packet-beta 233 | 0-7: "SOP (0xA5)" 234 | 8-15: "CMD (0x82)" 235 | 16-31: "LEN (0x0002)" 236 | 32-39: "AckCmdID" 237 | 40-47: "Status" 238 | 48-55: "CHECKSUM" 239 | 56-63: "EOP (0x5A)" 240 | ``` 241 | * **字段说明:** 242 | | 字段名 | 偏移 (字节) | 大小 (字节) | 数据类型 | 描述 | 243 | |----------------|-------------|-------------|----------|----------------------------------------------------------------------| 244 | | SOP | 0 | 1 | uint8_t | 帧起始符 (`0xA5`) | 245 | | CMD | 1 | 1 | uint8_t | 命令 ID (`0x82`) | 246 | | LEN | 2 | 2 | uint16_t | Payload 长度 (固定为 2, 小端序: `0x0200`) | 247 | | **Payload:** | | | | (开始于字节偏移 4) | 248 | | AckCmdID | 4 | 1 | uint8_t | 被确认的来自 PC 的命令 ID (`0x01`, `0x02`, `0x03`) | 249 | | Status | 5 | 1 | uint8_t | 执行状态 (见下表) | 250 | | CHECKSUM | 6 | 1 | uint8_t | 校验和 | 251 | | EOP | 7 | 1 | uint8_t | 帧结束符 (`0x5A`) | 252 | 253 | * **Status 字段值:** 254 | | 值 | 名称 | 描述 | 255 | | :--- | :------------------------------- | :------------------------------------------------------- | 256 | | 0x00 | `STATUS_OK` | 成功 | 257 | | 0x01 | `STATUS_ERROR_CHECKSUM` | 接收到的帧校验和错误 | 258 | | 0x02 | `STATUS_ERROR_UNKNOWN_CMD` | 未知命令 | 259 | | 0x03 | `STATUS_ERROR_INVALID_PAYLOAD` | Payload 格式或长度错误 | 260 | | 0x04 | `STATUS_ERROR_ADDR_INVALID` | 无效地址 (如对齐、范围) | 261 | | 0x05 | `STATUS_ERROR_TYPE_UNSUPPORTED` | 不支持的数据类型 | 262 | | 0x06 | `STATUS_ERROR_RATE_UNACHIEVABLE` | (若`CMD_SET_SAMPLE_RATE`被调用) 无法达到请求的采样率 | 263 | | 0x07 | `STATUS_ERROR_MCU_BUSY_OR_LIMIT` | MCU 繁忙或内部资源限制 (如变量数量超出MCU处理能力) | 264 | | 0xFF | `STATUS_ERROR_GENERAL_FAIL` | 通用失败 | 265 | 266 | #### 5.3.3. `CMD_ERROR_REPORT (0x8F)`: MCU 主动错误报告 (可选) 267 | 268 | * **用途:** MCU 主动向上位机报告一些异步发生的错误或严重问题。 269 | * **帧结构 (示例 M=3 字节消息, 总计 10 字节 / 80 比特):** 270 | ```mermaid 271 | --- 272 | title: CMD_ERROR_REPORT (0x8F) Frame (Example for M=3 byte message) 273 | --- 274 | packet-beta 275 | 0-7: "SOP (0xA5)" 276 | 8-15: "CMD (0x8F)" 277 | 16-31: "LEN (M=3: 0x0004)" 278 | 32-39: "ErrorCode" 279 | 40-63: "Optional_Message (M=3 bytes)" 280 | 64-71: "CHECKSUM" 281 | 72-79: "EOP (0x5A)" 282 | ``` 283 | * **字段说明:** 284 | | 字段名 | 偏移 (字节) | 大小 (字节) | 数据类型 | 描述 | 285 | |--------------------|-------------------------|-------------|----------|----------------------------------------------------------------------| 286 | | SOP | 0 | 1 | uint8_t | 帧起始符 (`0xA5`) | 287 | | CMD | 1 | 1 | uint8_t | 命令 ID (`0x8F`) | 288 | | LEN | 2 | 2 | uint16_t | Payload 长度 (`1 + M`字节, M为消息长度, 小端序) | 289 | | **Payload:** | | | | (开始于字节偏移 4) | 290 | | ErrorCode | 4 | 1 | uint8_t | 错误代码 (可自定义一套 MCU 内部错误码) | 291 | | Optional_Message | 5 | M | uint8_t[]| 可选的错误描述信息 (ASCII/UTF-8 字符串或自定义数据) | 292 | | CHECKSUM | 4 + `LEN` | 1 | uint8_t | 校验和 | 293 | | EOP | 5 + `LEN` | 1 | uint8_t | 帧结束符 (`0x5A`) | 294 | *注: `LEN` 示例值为 `0x0400` (小端序) 当 M=3。图示仅为M=3的情况。* 295 | 296 | ## 6. 带宽与变量监控数量建议 297 | 298 | 下表提供了在不同 UART 波特率和期望采样频率下,理论上可以同时监控的最大 FP32 变量数量 (N) 的建议。这些计算基于 `CMD_MONITOR_DATA` 帧的结构 (`11 + N*4` 字节) 和标准 (1位起始位、8位数据位、1位校验位、1位停止位) 的 UART 传输(11位/字节)。 299 | 300 | **计算公式:** `N <= ((波特率 / 采样频率) - 110) / 44` 301 | 302 | | 波特率 (bps) | 期望采样频率 (Hz) | 理论最大变量数 (N) | CMD_MONITOR_DATA 帧大小 (字节, N个变量) | 带宽利用率 (大致) | 303 | | --- | --- | --- | --- | --- | 304 | | 9600 | 10 | 19 | 86 | 0.98 | 305 | | 19200 | 10 | 41 | 174 | 0.99 | 306 | | | 100 | 1 | 14 | 0.8 | 307 | | 38400 | 10 | 84 | 346 | 0.99 | 308 | | | 100 | 6 | 34 | 0.97 | 309 | | 57600 | 10 | 128 | 522 | 0.99 | 310 | | | 100 | 10 | 50 | 0.95 | 311 | | 115200 | 10 | 255 | 1030 | 0.98 | 312 | | | 100 | 23 | 102 | 0.97 | 313 | | 230400 | 100 | 49 | 206 | 0.98 | 314 | | | 1000 | 2 | 18 | 0.85 | 315 | | 460800 | 100 | 102 | 418 | 0.99 | 316 | | | 1000 | 7 | 38 | 0.9 | 317 | | 921600 | 100 | 206 | 834 | 0.99 | 318 | | | 1000 | 18 | 82 | 0.97 | 319 | | 1000000 | 100 | 224 | 906 | 0.99 | 320 | | | 1000 | 20 | 90 | 0.99 | 321 | | 2000000 | 100 | 255 | 1030 | 0.56 | 322 | | | 1000 | 42 | 178 | 0.97 | 323 | 324 | **说明:** 325 | * "理论最大变量数" 是向下取整的结果。 326 | * "带宽利用率" 是指在监控最大数量变量时,数据传输占用的带宽百分比。接近100%意味着链路饱和。 327 | * 实际可监控的变量数量还可能受到 MCU 处理能力、内存、以及串口驱动效率等因素的影响。建议在接近理论上限时进行测试。 328 | * 对于高采样频率 (如 1kHz),低波特率下可能无法监控任何变量。 329 | * `NumVariables` 字段本身支持最多 255 个变量,但实际受限于上述带宽和 MCU 能力。 330 | 331 | ## 7. 交互时序 (Interaction Sequences) 332 | 333 | ### 7.1. 成功启动监控与数据流 334 | ```mermaid 335 | sequenceDiagram 336 | participant PC 337 | participant MCU 338 | 339 | Note over PC, MCU: MCU uses default sample rate initially. 340 | 341 | opt Set Custom Sample Rate (Optional) 342 | PC->>MCU: CMD_SET_SAMPLE_RATE (Rate: 100Hz) 343 | MCU->>PC: CMD_ACK (AckCmdID: 0x03, Status: OK) 344 | end 345 | 346 | PC->>MCU: CMD_START_MONITOR (NumVariables: 2, Vars: [Addr1,Type1], [Addr2,Type2]) 347 | MCU->>PC: CMD_ACK (AckCmdID: 0x01, Status: OK) 348 | 349 | loop Periodic Data (at default or set rate) 350 | MCU->>PC: CMD_MONITOR_DATA (Timestamp, Val1_FP32, Val2_FP32) 351 | end 352 | ``` 353 | 354 | ### 7.2. 更改监控变量 355 | ```mermaid 356 | sequenceDiagram 357 | participant PC 358 | participant MCU 359 | 360 | Note over PC, MCU: Initially monitoring VarA, VarB. 361 | PC->>MCU: CMD_START_MONITOR (NumVariables: 2, Vars: [AddrA,TypeA], [AddrB,TypeB]) 362 | MCU->>PC: CMD_ACK (AckCmdID: 0x01, Status: OK) 363 | loop Data for Vars A,B 364 | MCU->>PC: CMD_MONITOR_DATA (ValA, ValB) 365 | end 366 | 367 | Note over PC, MCU: PC requests to monitor only VarC. 368 | PC->>MCU: CMD_START_MONITOR (NumVariables: 1, Vars: [AddrC,TypeC]) 369 | MCU->>PC: CMD_ACK (AckCmdID: 0x01, Status: OK) 370 | Note over MCU: Stops sending A,B, Starts sending C. 371 | loop Data for Var C 372 | MCU->>PC: CMD_MONITOR_DATA (ValC) 373 | end 374 | ``` 375 | 376 | ### 7.3. 停止监控 377 | ```mermaid 378 | sequenceDiagram 379 | participant PC 380 | participant MCU 381 | 382 | Note over PC, MCU: Currently monitoring some variables. 383 | loop Periodic Data 384 | MCU->>PC: CMD_MONITOR_DATA (...) 385 | end 386 | 387 | PC->>MCU: CMD_START_MONITOR (NumVariables: 0) 388 | MCU->>PC: CMD_ACK (AckCmdID: 0x01, Status: OK) 389 | Note over MCU: Stops sending CMD_MONITOR_DATA. 390 | ``` 391 | 392 | ### 7.4. 设置变量值 393 | ```mermaid 394 | sequenceDiagram 395 | participant PC 396 | participant MCU 397 | 398 | Note over MCU: Currently sending CMD_MONITOR_DATA for VarX. 399 | MCU->>PC: CMD_MONITOR_DATA (Timestamp, OldValX_FP32, ...) 400 | 401 | PC->>MCU: CMD_SET_VARIABLE (AddrX, TypeX, NewValX_FP32) 402 | MCU->>PC: CMD_ACK (AckCmdID: 0x02, Status: OK) 403 | 404 | Note over MCU: Variable at AddrX is now NewValX. 405 | MCU->>PC: CMD_MONITOR_DATA (Timestamp, NewValX_FP32, ...) 406 | ``` 407 | 408 | ## 8. 校验和计算 (Checksum Calculation) 409 | 410 | 如通用帧结构中所述,校验和是 `CMD` 字段开始到 `PAYLOAD` 字段结束的所有字节的简单异或和。 411 | 412 | **示例 (伪代码):** 413 | 414 | ```c 415 | uint8_t calculate_checksum(uint8_t cmd, uint16_t len_val, uint8_t* payload_ptr) { 416 | uint8_t checksum = 0; 417 | checksum ^= cmd; 418 | checksum ^= (uint8_t)(len_val & 0xFF); // Low byte of len_val 419 | checksum ^= (uint8_t)((len_val >> 8) & 0xFF); // High byte of len_val 420 | 421 | for (uint16_t i = 0; i < len_val; i++) { 422 | checksum ^= payload_ptr[i]; 423 | } 424 | return checksum; 425 | } 426 | ``` 427 | 发送方计算并附加校验和。接收方对接收到的 `CMD`, `LEN` (两字节), `PAYLOAD` 计算校验和,并与接收到的 `CHECKSUM` 字段比较。 428 | 429 | ## 9. 注意事项 (Considerations) 430 | 431 | * **MCU 性能:** MCU 需要有足够的处理能力来以请求的频率读取内存、执行类型转换 (尤其是整型到浮点型)、组包并通过串行接口发送数据。ISR (中断服务程序) 中的处理应尽可能高效。 432 | * **带宽:** 监控的变量数量和采样频率直接影响带宽需求。上位机应根据选定的波特率和期望的采样频率,合理选择监控的变量数量,参考第6节的建议。 433 | * **错误处理:** 除了校验和,还应考虑超时机制。对于高频数据流,有时丢失少量数据包是可以接受的,重传机制可能会增加复杂性。 434 | * **原子性:** 在读取或写入 MCU 变量时,如果这些变量可能被中断或其他并发任务修改,需要确保操作的原子性(例如,通过关中断或使用互斥量)。 435 | * **可扩展性:** 未来可考虑加入更多命令,如查询 MCU 能力等。 -------------------------------------------------------------------------------- /aresplot_client.c: -------------------------------------------------------------------------------- 1 | // aresplot_mcu.h 2 | 3 | #ifndef ARESPLOT_MCU_H 4 | #define ARESPLOT_MCU_H 5 | 6 | #include // For standard integer types like uint8_t, uint32_t 7 | #include // For size_t 8 | 9 | // --- 用户配置区 User Configuration Section --- 10 | 11 | // 定义MCU能支持同时监控的最大变量数量 12 | // Define the maximum number of variables the MCU can monitor simultaneously 13 | #define ARESPLOT_MAX_VARS_TO_MONITOR (10) // 可根据MCU资源调整 Can be adjusted based on MCU resources 14 | 15 | // 定义接收和发送缓冲区的最大大小 (应大于最大可能的帧大小) 16 | // Define max size for receive and transmit buffers (should be larger than max possible frame size) 17 | // 最大帧 (CMD_START_MONITOR): 1(SOP)+1(CMD)+2(LEN)+1(NumVars)+ARESPLOT_MAX_VARS_TO_MONITOR*5(Vars)+1(CS)+1(EOP) 18 | // = 7 + 10*5 = 57 bytes for 10 vars. 19 | // 选择一个安全的大小, e.g., 128. This buffer is used for RX payload and TX frame assembly. 20 | #define ARESPLOT_SHARED_BUFFER_SIZE (128) 21 | 22 | // 是否启用可选的 CMD_ERROR_REPORT 功能 (1: 启用, 0: 禁用) 23 | // Enable optional CMD_ERROR_REPORT feature (1: enable, 0: disable) 24 | #define ARESPLOT_ENABLE_ERROR_REPORT (0) 25 | 26 | // 默认采样周期 (毫秒) - 如果 CMD_SET_SAMPLE_RATE 未被调用 27 | // Default sampling period (milliseconds) - if CMD_SET_SAMPLE_RATE is not called 28 | #define ARESPLOT_DEFAULT_SAMPLE_PERIOD_MS (10) // e.g., 10ms for 100Hz 29 | 30 | // --- 协议常量 Protocol Constants (与 aresplot.md 一致) --- 31 | #define ARESPLOT_SOP (0xA5) // 帧起始符 Start of Packet 32 | #define ARESPLOT_EOP (0x5A) // 帧结束符 End of Packet 33 | 34 | // PC -> MCU 命令ID (根据最新协议文档 v5) 35 | #define ARESPLOT_CMD_START_MONITOR (0x01) // 请求开始/更新/停止监控变量 36 | #define ARESPLOT_CMD_SET_VARIABLE (0x02) // 请求设置变量值 37 | #define ARESPLOT_CMD_SET_SAMPLE_RATE (0x03) // (可选) 设置采样率 38 | 39 | // MCU -> PC 命令ID (根据最新协议文档 v5) 40 | #define ARESPLOT_CMD_MONITOR_DATA (0x81) // 发送监控数据 41 | #define ARESPLOT_CMD_ACK (0x82) // 命令确认/应答 42 | #if ARESPLOT_ENABLE_ERROR_REPORT 43 | #define ARESPLOT_CMD_ERROR_REPORT (0x8F) // (可选) MCU主动错误报告 44 | #endif 45 | 46 | // AresOriginalType_t 枚举 (变量原始数据类型) 47 | typedef enum { 48 | ARES_TYPE_INT8 = 0x00, 49 | ARES_TYPE_UINT8 = 0x01, 50 | ARES_TYPE_INT16 = 0x02, 51 | ARES_TYPE_UINT16 = 0x03, 52 | ARES_TYPE_INT32 = 0x04, 53 | ARES_TYPE_UINT32 = 0x05, 54 | ARES_TYPE_FLOAT32 = 0x06, 55 | ARES_TYPE_FLOAT64 = 0x07, // 注意: MCU端通常转换为float32发送 56 | ARES_TYPE_BOOL = 0x08 57 | } aresplot_original_type_t; 58 | 59 | // ACK状态码 Status codes for CMD_ACK (根据最新协议文档 v5) 60 | typedef enum { 61 | ARES_STATUS_OK = 0x00, // 成功 62 | ARES_STATUS_ERROR_CHECKSUM = 0x01, // 接收到的帧校验和错误 63 | ARES_STATUS_ERROR_UNKNOWN_CMD = 0x02, // 未知命令 64 | ARES_STATUS_ERROR_INVALID_PAYLOAD = 0x03, // Payload 格式或长度错误 65 | ARES_STATUS_ERROR_ADDR_INVALID = 0x04, // 无效地址 (如对齐、范围) 66 | ARES_STATUS_ERROR_TYPE_UNSUPPORTED = 0x05, // 不支持的数据类型 67 | ARES_STATUS_ERROR_RATE_UNACHIEVABLE = 0x06, // (若CMD_SET_SAMPLE_RATE被调用) 无法达到请求的采样率 68 | ARES_STATUS_ERROR_MCU_BUSY_OR_LIMIT = 0x07, // MCU 繁忙或内部资源限制 (如变量数量超出MCU处理能力) 69 | ARES_STATUS_ERROR_GENERAL_FAIL = 0xFF // 通用失败 70 | } aresplot_ack_status_t; 71 | 72 | // 存储单个监控变量信息的结构体 73 | typedef struct { 74 | void* ptr; // 指向变量内存地址的指针 Pointer to the variable's memory address 75 | aresplot_original_type_t type; // 变量的原始数据类型 Original data type of the variable 76 | } aresplot_var_info_t; 77 | 78 | 79 | // --- 用户需要实现的硬件/系统相关回调函数 --- 80 | // --- User-implemented hardware/system-specific callback functions --- 81 | 82 | /** 83 | * @brief 发送一个数据包 (通常是一个完整的Aresplot帧) 到通信接口 (例如 UART DMA, USB packet) 84 | * Sends a data packet (usually a complete Aresplot frame) to the communication interface (e.g., UART DMA, USB packet). 85 | * @param data 指向要发送数据的指针 Pointer to the data to send. 86 | * @param length 要发送数据的长度 Length of the data to send. 87 | * @note 用户需要确保此函数是非阻塞的,或者在RTOS环境中适当地处理阻塞。 88 | * The user needs to ensure this function is non-blocking or handles blocking appropriately in an RTOS environment. 89 | * 如果发送操作是异步的 (例如DMA),此函数启动传输后即可返回。 90 | * If the send operation is asynchronous (e.g., DMA), this function can return after initiating the transfer. 91 | */ 92 | void aresplot_user_send_packet(const uint8_t* data, uint16_t length); 93 | 94 | /** 95 | * @brief 获取当前系统时间戳 (例如,毫秒) 96 | * Gets the current system timestamp (e.g., in milliseconds). 97 | * @return 当前时间戳 Current timestamp. 98 | */ 99 | uint32_t aresplot_user_get_tick_ms(void); 100 | 101 | /** 102 | * @brief (可选, 用于RTOS或需要保护共享资源的关键操作) 进入临界区 103 | * (Optional, for RTOS or critical operations needing shared resource protection) Enters a critical section. 104 | */ 105 | void aresplot_user_critical_enter(void); 106 | 107 | /** 108 | * @brief (可选, 用于RTOS或需要保护共享资源的关键操作) 退出临界区 109 | * (Optional, for RTOS or critical operations needing shared resource protection) Exits a critical section. 110 | */ 111 | void aresplot_user_critical_exit(void); 112 | 113 | // --- Aresplot 服务函数 API --- 114 | // --- Aresplot Service Function API --- 115 | 116 | /** 117 | * @brief 初始化 Aresplot 服务 118 | * Initializes the Aresplot service. 119 | * 应在系统启动时调用一次。 120 | * Should be called once at system startup. 121 | */ 122 | void aresplot_init(void); 123 | 124 | /** 125 | * @brief 向 Aresplot 服务喂入一个从通信接口接收到的字节 (用于基于字节流的接收) 126 | * Feeds a byte received from the communication interface to the Aresplot service (for byte-stream based reception). 127 | * 应在每次接收到一个字节时调用 (例如,在 UART RX 中断处理程序中,如果未使用DMA/packet-based interface)。 128 | * Should be called every time a byte is received (e.g., in UART RX interrupt handler if not using DMA/packet-based interface). 129 | * @param byte 接收到的字节 The received byte. 130 | */ 131 | void aresplot_rx_feed_byte(uint8_t byte); 132 | 133 | /** 134 | * @brief 向 Aresplot 服务喂入一个从通信接口接收到的数据包 (用于基于包的接收, 如DMA, USB) 135 | * Feeds a data packet received from the communication interface to the Aresplot service (for packet-based reception, e.g., DMA, USB). 136 | * @param data 指向接收到的数据包的指针 Pointer to the received data packet. 137 | * @param length 数据包的长度 Length of the data packet. 138 | * @note 此函数会迭代调用 aresplot_rx_feed_byte 来处理包内数据。 139 | * This function iterates through the packet and calls aresplot_rx_feed_byte for each byte. 140 | */ 141 | void aresplot_rx_feed_packet(const uint8_t* data, uint16_t length); 142 | 143 | 144 | /** 145 | * @brief Aresplot 服务的主处理函数/周期性任务 146 | * Main processing function / periodic task for the Aresplot service. 147 | * 此函数负责发送挂起的ACK帧和定期的监控数据帧。 148 | * This function is responsible for sending pending ACK frames and periodic monitor data frames. 149 | * - 对于裸机系统: 应从主循环中定期调用,或者通过定时器中断标志触发调用。 150 | * 其调用频率应快于或等于数据发送频率。 151 | * - 对于RTOS系统: 可以作为一个低优先级任务的主体。 152 | * 153 | * - For bare-metal systems: Should be called periodically from the main loop, 154 | * or triggered by a timer interrupt flag. Its calling frequency 155 | * should be faster than or equal to the data sending frequency. 156 | * - For RTOS systems: Can be the body of a low-priority task. 157 | */ 158 | void aresplot_service_tick(void); 159 | 160 | 161 | #if ARESPLOT_ENABLE_ERROR_REPORT 162 | /** 163 | * @brief (可选) 发送一个错误报告给上位机 164 | * (Optional) Sends an error report to the host PC. 165 | * @param error_code 错误码 Error code. 166 | * @param message 可选的错误消息 (可以为 NULL) Optional error message (can be NULL). 167 | * @param msg_len 错误消息的长度 (如果 message 为 NULL, 则为 0) Length of the error message (0 if message is NULL). 168 | * @return 如果成功将错误报告加入发送队列则返回1, 否则返回0 (例如队列满) 169 | * Returns 1 if the error report was successfully queued for sending, 0 otherwise (e.g., queue full). 170 | */ 171 | int aresplot_report_error(uint8_t error_code, const char* message, uint8_t msg_len); 172 | #endif 173 | 174 | #endif // ARESPLOT_MCU_H 175 | 176 | // aresplot_mcu.c 177 | 178 | #include "aresplot_mcu.h" 179 | #include // For memcpy (如果不想用,可以手动实现 If not desired, can be implemented manually) 180 | 181 | // --- 内部状态变量 Internal State Variables --- 182 | 183 | // 接收状态机 184 | typedef enum { 185 | ARES_RX_STATE_WAIT_SOP, 186 | ARES_RX_STATE_WAIT_CMD, 187 | ARES_RX_STATE_WAIT_LEN1, 188 | ARES_RX_STATE_WAIT_LEN2, 189 | ARES_RX_STATE_WAIT_PAYLOAD, 190 | ARES_RX_STATE_WAIT_CHECKSUM, 191 | ARES_RX_STATE_WAIT_EOP 192 | } aresplot_rx_state_t; 193 | 194 | static volatile aresplot_rx_state_t g_rx_state; // 当前接收状态 Current receive state 195 | static uint8_t g_rx_payload_buffer[ARESPLOT_SHARED_BUFFER_SIZE]; // 接收缓冲区仅用于Payload Receive buffer for payload only 196 | static uint16_t g_rx_payload_len; // 当前帧的Payload长度 Payload length of the current frame 197 | static uint16_t g_rx_payload_idx; // 当前接收的Payload字节计数 Payload byte counter 198 | static uint8_t g_rx_cmd; // 当前帧的命令ID Command ID of the current frame 199 | static uint8_t g_rx_checksum_calculated; // 计算出的校验和 Calculated checksum 200 | 201 | // 监控变量列表 202 | static aresplot_var_info_t g_monitor_vars[ARESPLOT_MAX_VARS_TO_MONITOR]; // 存储被监控变量信息的数组 Array to store monitored variable info 203 | static uint8_t g_num_monitor_vars; // 当前正在监控的变量数量 Number of currently monitored variables 204 | static volatile uint8_t g_monitoring_active; // 监控是否激活标志 Flag indicating if monitoring is active 205 | 206 | // 数据发送定时 207 | static uint32_t g_sample_period_ms; // 采样周期 (毫秒) Sampling period (ms) 208 | static uint32_t g_last_sample_time_ms; // 上次采样时间戳 Last sample timestamp 209 | 210 | // 发送组装缓冲区 (用于 ACK, Monitor Data, Error Report) 211 | // Transmit assembly buffer (for ACK, Monitor Data, Error Report) 212 | static uint8_t g_tx_assembly_buffer[ARESPLOT_SHARED_BUFFER_SIZE]; 213 | 214 | // ACK 发送相关 215 | // ACK transmit related 216 | static volatile uint8_t g_ack_pending; // 是否有ACK等待发送 Flag indicating if an ACK is pending 217 | static uint8_t g_ack_cmd_to_ack; // 要ACK的命令ID 218 | static aresplot_ack_status_t g_ack_status_to_send; // 要发送的ACK状态 219 | 220 | #if ARESPLOT_ENABLE_ERROR_REPORT 221 | // 错误报告发送相关 222 | // Error report transmit related 223 | static volatile uint8_t g_error_report_pending; // 是否有错误报告等待发送 224 | static uint8_t g_error_report_code_to_send; // 要发送的错误码 225 | static char g_error_report_msg_to_send[ARESPLOT_SHARED_BUFFER_SIZE - 7]; // 错误消息缓冲区 (最大可能长度) 226 | static uint8_t g_error_report_msg_len_to_send; // 错误消息长度 227 | #endif 228 | 229 | 230 | // --- 内部辅助函数 Internal Helper Functions --- 231 | 232 | /** 233 | * @brief 计算帧校验和 (从CMD到PAYLOAD尾) 234 | * Calculates the frame checksum (from CMD to end of PAYLOAD). 235 | * @param cmd 命令ID Command ID. 236 | * @param len Payload长度 Payload length. 237 | * @param payload 指向Payload数据的指针 Pointer to payload data. 238 | * @return 计算得到的校验和 Calculated checksum. 239 | */ 240 | static uint8_t calculate_checksum(uint8_t cmd, uint16_t len, const uint8_t* payload) { 241 | uint8_t checksum = 0; 242 | checksum ^= cmd; 243 | checksum ^= (uint8_t)(len & 0xFF); 244 | checksum ^= (uint8_t)((len >> 8) & 0xFF); 245 | for (uint16_t i = 0; i < len; ++i) { 246 | checksum ^= payload[i]; 247 | } 248 | return checksum; 249 | } 250 | 251 | /** 252 | * @brief 组装并发送一个完整的帧 (通过 aresplot_user_send_packet) 253 | * Assembles and sends a complete frame (via aresplot_user_send_packet). 254 | * @param cmd 命令ID Command ID. 255 | * @param payload 指向Payload数据的指针 (可以为NULL如果len为0) Pointer to payload data (can be NULL if len is 0). 256 | * @param len Payload长度 Payload length. 257 | * @note 此函数内部使用全局的 g_tx_assembly_buffer 进行帧组装。 258 | * This function uses the global g_tx_assembly_buffer for frame assembly internally. 259 | * 调用此函数前应确保 g_tx_assembly_buffer 未被其他发送操作占用 (通过 pending 标志等机制)。 260 | * Ensure g_tx_assembly_buffer is not in use by other send operations before calling (e.g. via pending flags). 261 | */ 262 | static void assemble_and_send_frame_internal(uint8_t cmd, const uint8_t* payload, uint16_t len) { 263 | uint16_t frame_idx = 0; 264 | uint16_t total_frame_len = 1 + 1 + 2 + len + 1 + 1; // SOP+CMD+LEN+Payload+CS+EOP 265 | 266 | if (total_frame_len > ARESPLOT_SHARED_BUFFER_SIZE) { 267 | // 帧太大无法组装,这通常不应发生如果 ARESPLOT_SHARED_BUFFER_SIZE 设置正确 268 | // Frame too large to assemble, should not happen if ARESPLOT_SHARED_BUFFER_SIZE is set correctly 269 | return; 270 | } 271 | 272 | // 1. SOP 273 | g_tx_assembly_buffer[frame_idx++] = ARESPLOT_SOP; 274 | // 2. CMD 275 | g_tx_assembly_buffer[frame_idx++] = cmd; 276 | // 3. LEN (Little Endian) 277 | g_tx_assembly_buffer[frame_idx++] = (uint8_t)(len & 0xFF); 278 | g_tx_assembly_buffer[frame_idx++] = (uint8_t)((len >> 8) & 0xFF); 279 | // 4. PAYLOAD 280 | if (payload && len > 0) { 281 | memcpy(&g_tx_assembly_buffer[frame_idx], payload, len); 282 | frame_idx += len; 283 | } 284 | // 5. CHECKSUM 285 | g_tx_assembly_buffer[frame_idx++] = calculate_checksum(cmd, len, payload); 286 | // 6. EOP 287 | g_tx_assembly_buffer[frame_idx++] = ARESPLOT_EOP; 288 | 289 | aresplot_user_send_packet(g_tx_assembly_buffer, frame_idx); 290 | } 291 | 292 | 293 | /** 294 | * @brief 标记一个ACK响应等待发送 (实际发送在 aresplot_service_tick 中完成) 295 | * Flags an ACK response to be sent (actual sending happens in aresplot_service_tick). 296 | * @param ack_cmd_id 被ACK的命令ID The command ID being ACKed. 297 | * @param status ACK状态码 ACK status code. 298 | */ 299 | static void queue_ack_response(uint8_t ack_cmd_id, aresplot_ack_status_t status) { 300 | aresplot_user_critical_enter(); 301 | // 如果有其他发送操作正在等待 (例如错误报告),ACK可以优先,或者简单覆盖/排队 302 | // For simplicity, if another ACK is pending, this new one overwrites it. 303 | g_ack_cmd_to_ack = ack_cmd_id; 304 | g_ack_status_to_send = status; 305 | g_ack_pending = 1; 306 | aresplot_user_critical_exit(); 307 | } 308 | 309 | 310 | /** 311 | * @brief 处理接收到的 CMD_START_MONITOR 命令 312 | * Processes a received CMD_START_MONITOR command. 313 | */ 314 | static void handle_cmd_start_monitor(void) { 315 | uint8_t num_vars_requested = g_rx_payload_buffer[0]; // Payload的第一个字节是NumVariables 316 | aresplot_ack_status_t status = ARES_STATUS_OK; 317 | 318 | aresplot_user_critical_enter(); 319 | g_monitoring_active = 0; 320 | g_num_monitor_vars = 0; 321 | 322 | if (num_vars_requested == 0) { 323 | status = ARES_STATUS_OK; 324 | } else if (num_vars_requested > ARESPLOT_MAX_VARS_TO_MONITOR) { 325 | status = ARES_STATUS_ERROR_MCU_BUSY_OR_LIMIT; 326 | } else { 327 | if (g_rx_payload_len != (1 + num_vars_requested * 5)) { 328 | status = ARES_STATUS_ERROR_INVALID_PAYLOAD; 329 | } else { 330 | g_num_monitor_vars = num_vars_requested; 331 | for (uint8_t i = 0; i < g_num_monitor_vars; ++i) { 332 | uint8_t* p_var_info_payload = &g_rx_payload_buffer[1 + i * 5]; 333 | 334 | uint32_t temp_addr = (uint32_t)p_var_info_payload[0] | 335 | ((uint32_t)p_var_info_payload[1] << 8) | 336 | ((uint32_t)p_var_info_payload[2] << 16) | 337 | ((uint32_t)p_var_info_payload[3] << 24); 338 | g_monitor_vars[i].ptr = (void*)temp_addr; 339 | g_monitor_vars[i].type = (aresplot_original_type_t)p_var_info_payload[4]; 340 | } 341 | g_monitoring_active = 1; 342 | g_last_sample_time_ms = aresplot_user_get_tick_ms(); 343 | status = ARES_STATUS_OK; 344 | } 345 | } 346 | aresplot_user_critical_exit(); 347 | queue_ack_response(ARESPLOT_CMD_START_MONITOR, status); 348 | } 349 | 350 | /** 351 | * @brief 处理接收到的 CMD_SET_VARIABLE 命令 352 | * Processes a received CMD_SET_VARIABLE command. 353 | */ 354 | static void handle_cmd_set_variable(void) { 355 | if (g_rx_payload_len != 9) { 356 | queue_ack_response(ARESPLOT_CMD_SET_VARIABLE, ARES_STATUS_ERROR_INVALID_PAYLOAD); 357 | return; 358 | } 359 | 360 | void* addr; 361 | aresplot_original_type_t original_type; 362 | float float_val; 363 | uint8_t* p_payload = g_rx_payload_buffer; 364 | 365 | uint32_t temp_addr = (uint32_t)p_payload[0] | 366 | ((uint32_t)p_payload[1] << 8) | 367 | ((uint32_t)p_payload[2] << 16) | 368 | ((uint32_t)p_payload[3] << 24); 369 | addr = (void*)temp_addr; 370 | 371 | original_type = (aresplot_original_type_t)p_payload[4]; 372 | 373 | uint8_t temp_float_bytes[4]; 374 | temp_float_bytes[0] = p_payload[5]; 375 | temp_float_bytes[1] = p_payload[6]; 376 | temp_float_bytes[2] = p_payload[7]; 377 | temp_float_bytes[3] = p_payload[8]; 378 | memcpy(&float_val, temp_float_bytes, sizeof(float)); 379 | 380 | aresplot_user_critical_enter(); 381 | switch (original_type) { 382 | case ARES_TYPE_INT8: *(volatile int8_t*)addr = (int8_t)float_val; break; 383 | case ARES_TYPE_UINT8: *(volatile uint8_t*)addr = (uint8_t)float_val; break; 384 | case ARES_TYPE_INT16: *(volatile int16_t*)addr = (int16_t)float_val; break; 385 | case ARES_TYPE_UINT16: *(volatile uint16_t*)addr = (uint16_t)float_val; break; 386 | case ARES_TYPE_INT32: *(volatile int32_t*)addr = (int32_t)float_val; break; 387 | case ARES_TYPE_UINT32: *(volatile uint32_t*)addr = (uint32_t)float_val; break; 388 | case ARES_TYPE_FLOAT32: *(volatile float*)addr = float_val; break; 389 | case ARES_TYPE_BOOL: *(volatile uint8_t*)addr = (float_val != 0.0f) ? 1 : 0; break; 390 | default: 391 | aresplot_user_critical_exit(); 392 | queue_ack_response(ARESPLOT_CMD_SET_VARIABLE, ARES_STATUS_ERROR_TYPE_UNSUPPORTED); 393 | return; 394 | } 395 | aresplot_user_critical_exit(); 396 | queue_ack_response(ARESPLOT_CMD_SET_VARIABLE, ARES_STATUS_OK); 397 | } 398 | 399 | /** 400 | * @brief 处理接收到的 CMD_SET_SAMPLE_RATE 命令 401 | * Processes a received CMD_SET_SAMPLE_RATE command. 402 | */ 403 | static void handle_cmd_set_sample_rate(void) { 404 | if (g_rx_payload_len != 4) { 405 | queue_ack_response(ARESPLOT_CMD_SET_SAMPLE_RATE, ARES_STATUS_ERROR_INVALID_PAYLOAD); 406 | return; 407 | } 408 | 409 | uint32_t rate_hz; 410 | uint8_t* p_payload = g_rx_payload_buffer; 411 | 412 | rate_hz = (uint32_t)p_payload[0] | 413 | ((uint32_t)p_payload[1] << 8) | 414 | ((uint32_t)p_payload[2] << 16) | 415 | ((uint32_t)p_payload[3] << 24); 416 | 417 | aresplot_user_critical_enter(); 418 | if (rate_hz == 0) { 419 | g_sample_period_ms = ARESPLOT_DEFAULT_SAMPLE_PERIOD_MS; 420 | } else { 421 | uint32_t period_ms = 1000 / rate_hz; 422 | if (period_ms == 0 && rate_hz !=0 ) period_ms = 1; 423 | g_sample_period_ms = period_ms; 424 | } 425 | g_last_sample_time_ms = aresplot_user_get_tick_ms(); 426 | aresplot_user_critical_exit(); 427 | 428 | queue_ack_response(ARESPLOT_CMD_SET_SAMPLE_RATE, ARES_STATUS_OK); 429 | } 430 | 431 | 432 | /** 433 | * @brief 处理一个完整的、校验通过的帧 434 | * Processes a complete, checksum-verified frame. 435 | */ 436 | static void process_received_frame(void) { 437 | switch (g_rx_cmd) { 438 | case ARESPLOT_CMD_START_MONITOR: 439 | handle_cmd_start_monitor(); 440 | break; 441 | case ARESPLOT_CMD_SET_VARIABLE: 442 | handle_cmd_set_variable(); 443 | break; 444 | case ARESPLOT_CMD_SET_SAMPLE_RATE: 445 | handle_cmd_set_sample_rate(); 446 | break; 447 | default: 448 | queue_ack_response(g_rx_cmd, ARES_STATUS_ERROR_UNKNOWN_CMD); 449 | break; 450 | } 451 | } 452 | 453 | // --- 公共 API 函数实现 Public API Function Implementations --- 454 | 455 | void aresplot_init(void) { 456 | g_rx_state = ARES_RX_STATE_WAIT_SOP; 457 | g_num_monitor_vars = 0; 458 | g_monitoring_active = 0; 459 | g_sample_period_ms = ARESPLOT_DEFAULT_SAMPLE_PERIOD_MS; 460 | g_last_sample_time_ms = 0; 461 | g_ack_pending = 0; 462 | #if ARESPLOT_ENABLE_ERROR_REPORT 463 | g_error_report_pending = 0; 464 | #endif 465 | } 466 | 467 | void aresplot_rx_feed_byte(uint8_t byte) { 468 | switch (g_rx_state) { 469 | case ARES_RX_STATE_WAIT_SOP: 470 | if (byte == ARESPLOT_SOP) { 471 | g_rx_state = ARES_RX_STATE_WAIT_CMD; 472 | g_rx_checksum_calculated = 0; 473 | } 474 | break; 475 | 476 | case ARES_RX_STATE_WAIT_CMD: 477 | g_rx_cmd = byte; 478 | g_rx_checksum_calculated ^= byte; 479 | g_rx_state = ARES_RX_STATE_WAIT_LEN1; 480 | break; 481 | 482 | case ARES_RX_STATE_WAIT_LEN1: 483 | g_rx_payload_len = byte; // LSB 484 | g_rx_checksum_calculated ^= byte; 485 | g_rx_state = ARES_RX_STATE_WAIT_LEN2; 486 | break; 487 | 488 | case ARES_RX_STATE_WAIT_LEN2: 489 | g_rx_payload_len |= ((uint16_t)byte << 8); // MSB 490 | g_rx_checksum_calculated ^= byte; 491 | if (g_rx_payload_len > sizeof(g_rx_payload_buffer)) { 492 | g_rx_state = ARES_RX_STATE_WAIT_SOP; 493 | } else if (g_rx_payload_len == 0) { 494 | g_rx_state = ARES_RX_STATE_WAIT_CHECKSUM; 495 | } else { 496 | g_rx_payload_idx = 0; 497 | g_rx_state = ARES_RX_STATE_WAIT_PAYLOAD; 498 | } 499 | break; 500 | 501 | case ARES_RX_STATE_WAIT_PAYLOAD: 502 | g_rx_payload_buffer[g_rx_payload_idx++] = byte; 503 | g_rx_checksum_calculated ^= byte; 504 | if (g_rx_payload_idx >= g_rx_payload_len) { 505 | g_rx_state = ARES_RX_STATE_WAIT_CHECKSUM; 506 | } 507 | break; 508 | 509 | case ARES_RX_STATE_WAIT_CHECKSUM: 510 | if (byte == g_rx_checksum_calculated) { 511 | g_rx_state = ARES_RX_STATE_WAIT_EOP; 512 | } else { 513 | queue_ack_response(g_rx_cmd, ARES_STATUS_ERROR_CHECKSUM); 514 | g_rx_state = ARES_RX_STATE_WAIT_SOP; 515 | } 516 | break; 517 | 518 | case ARES_RX_STATE_WAIT_EOP: 519 | if (byte == ARESPLOT_EOP) { 520 | process_received_frame(); 521 | } 522 | g_rx_state = ARES_RX_STATE_WAIT_SOP; 523 | break; 524 | 525 | default: 526 | g_rx_state = ARES_RX_STATE_WAIT_SOP; 527 | break; 528 | } 529 | } 530 | 531 | void aresplot_rx_feed_packet(const uint8_t* data, uint16_t length) { 532 | for (uint16_t i = 0; i < length; ++i) { 533 | aresplot_rx_feed_byte(data[i]); 534 | } 535 | } 536 | 537 | 538 | static void send_monitor_data_payload_assembly(uint8_t* out_payload_buffer, uint16_t* out_payload_len) { 539 | uint32_t timestamp; 540 | uint16_t payload_idx = 0; 541 | float temp_float_val; 542 | uint8_t current_num_vars; 543 | aresplot_var_info_t local_monitor_vars[ARESPLOT_MAX_VARS_TO_MONITOR]; 544 | 545 | aresplot_user_critical_enter(); 546 | if (!g_monitoring_active || g_num_monitor_vars == 0) { 547 | aresplot_user_critical_exit(); 548 | *out_payload_len = 0; 549 | return; 550 | } 551 | current_num_vars = g_num_monitor_vars; 552 | for(uint8_t i=0; i < current_num_vars; ++i) { 553 | local_monitor_vars[i] = g_monitor_vars[i]; 554 | } 555 | aresplot_user_critical_exit(); 556 | 557 | timestamp = aresplot_user_get_tick_ms(); 558 | 559 | out_payload_buffer[payload_idx++] = (uint8_t)(timestamp & 0xFF); 560 | out_payload_buffer[payload_idx++] = (uint8_t)((timestamp >> 8) & 0xFF); 561 | out_payload_buffer[payload_idx++] = (uint8_t)((timestamp >> 16) & 0xFF); 562 | out_payload_buffer[payload_idx++] = (uint8_t)((timestamp >> 24) & 0xFF); 563 | 564 | for (uint8_t i = 0; i < current_num_vars; ++i) { 565 | if (local_monitor_vars[i].ptr == NULL) { 566 | temp_float_val = 0.0f; 567 | } else { 568 | switch (local_monitor_vars[i].type) { 569 | case ARES_TYPE_INT8: temp_float_val = (float)(*(volatile int8_t*)local_monitor_vars[i].ptr); break; 570 | case ARES_TYPE_UINT8: temp_float_val = (float)(*(volatile uint8_t*)local_monitor_vars[i].ptr); break; 571 | case ARES_TYPE_INT16: temp_float_val = (float)(*(volatile int16_t*)local_monitor_vars[i].ptr); break; 572 | case ARES_TYPE_UINT16: temp_float_val = (float)(*(volatile uint16_t*)local_monitor_vars[i].ptr); break; 573 | case ARES_TYPE_INT32: temp_float_val = (float)(*(volatile int32_t*)local_monitor_vars[i].ptr); break; 574 | case ARES_TYPE_UINT32: temp_float_val = (float)(*(volatile uint32_t*)local_monitor_vars[i].ptr); break; 575 | case ARES_TYPE_FLOAT32: temp_float_val = (*(volatile float*)local_monitor_vars[i].ptr); break; 576 | case ARES_TYPE_BOOL: temp_float_val = (*(volatile uint8_t*)local_monitor_vars[i].ptr) ? 1.0f : 0.0f; break; 577 | default: temp_float_val = 0.0f; break; 578 | } 579 | } 580 | 581 | uint8_t temp_float_bytes[4]; // Temporary buffer for float bytes 582 | memcpy(temp_float_bytes, &temp_float_val, sizeof(float)); 583 | out_payload_buffer[payload_idx++] = temp_float_bytes[0]; 584 | out_payload_buffer[payload_idx++] = temp_float_bytes[1]; 585 | out_payload_buffer[payload_idx++] = temp_float_bytes[2]; 586 | out_payload_buffer[payload_idx++] = temp_float_bytes[3]; 587 | } 588 | *out_payload_len = 4 + current_num_vars * 4; 589 | } 590 | 591 | 592 | void aresplot_service_tick(void) { 593 | uint32_t current_time_ms; 594 | uint8_t ack_cmd_to_process = 0; 595 | aresplot_ack_status_t status_to_process = ARES_STATUS_OK; 596 | uint8_t process_ack = 0; 597 | 598 | #if ARESPLOT_ENABLE_ERROR_REPORT 599 | uint8_t error_code_to_process = 0; 600 | char error_msg_to_process[ARESPLOT_SHARED_BUFFER_SIZE - 7]; 601 | uint8_t error_msg_len_to_process = 0; 602 | uint8_t process_error_report = 0; 603 | #endif 604 | 605 | // 1. 检查是否有挂起的ACK,并准备发送 (在临界区外组装,减少临界区时间) 606 | // Check for pending ACK and prepare for sending (assemble outside critical section to reduce time in cs) 607 | aresplot_user_critical_enter(); 608 | if (g_ack_pending) { 609 | ack_cmd_to_process = g_ack_cmd_to_ack; 610 | status_to_process = g_ack_status_to_send; 611 | process_ack = 1; 612 | g_ack_pending = 0; // 清除挂起标志 Clear pending flag 613 | } 614 | aresplot_user_critical_exit(); 615 | 616 | if (process_ack) { 617 | uint8_t ack_payload[2]; 618 | ack_payload[0] = ack_cmd_to_process; 619 | ack_payload[1] = (uint8_t)status_to_process; 620 | assemble_and_send_frame_internal(ARESPLOT_CMD_ACK, ack_payload, 2); 621 | } 622 | 623 | 624 | #if ARESPLOT_ENABLE_ERROR_REPORT 625 | // 2. 检查是否有挂起的错误报告 (如果启用) 626 | // Check for pending error report (if enabled) 627 | aresplot_user_critical_enter(); 628 | if (g_error_report_pending) { 629 | error_code_to_process = g_error_report_code_to_send; 630 | memcpy(error_msg_to_process, g_error_report_msg_to_send, g_error_report_msg_len_to_send); 631 | error_msg_len_to_process = g_error_report_msg_len_to_send; 632 | process_error_report = 1; 633 | g_error_report_pending = 0; // 清除挂起标志 634 | } 635 | aresplot_user_critical_exit(); 636 | 637 | if (process_error_report) { 638 | uint8_t error_payload[1 + ARESPLOT_SHARED_BUFFER_SIZE - 7]; // Max possible payload 639 | uint16_t error_payload_len = 1 + error_msg_len_to_process; 640 | error_payload[0] = error_code_to_process; 641 | if (error_msg_len_to_process > 0) { 642 | memcpy(&error_payload[1], error_msg_to_process, error_msg_len_to_process); 643 | } 644 | assemble_and_send_frame_internal(ARESPLOT_CMD_ERROR_REPORT, error_payload, error_payload_len); 645 | } 646 | #endif 647 | 648 | // 3. 检查是否需要发送监控数据 649 | if (g_monitoring_active && g_num_monitor_vars > 0) { // 再次检查,因为状态可能已改变 650 | current_time_ms = aresplot_user_get_tick_ms(); 651 | uint32_t time_diff; 652 | 653 | aresplot_user_critical_enter(); 654 | uint32_t last_sample = g_last_sample_time_ms; 655 | uint32_t sample_period = g_sample_period_ms; 656 | aresplot_user_critical_exit(); 657 | 658 | if (current_time_ms >= last_sample) { 659 | time_diff = current_time_ms - last_sample; 660 | } else { 661 | time_diff = (0xFFFFFFFFU - last_sample) + current_time_ms + 1; 662 | } 663 | 664 | if (time_diff >= sample_period) { 665 | uint8_t monitor_data_payload[4 + ARESPLOT_MAX_VARS_TO_MONITOR * 4]; 666 | uint16_t monitor_data_payload_len = 0; 667 | 668 | send_monitor_data_payload_assembly(monitor_data_payload, &monitor_data_payload_len); 669 | 670 | if (monitor_data_payload_len > 0) { 671 | assemble_and_send_frame_internal(ARESPLOT_CMD_MONITOR_DATA, monitor_data_payload, monitor_data_payload_len); 672 | } 673 | 674 | aresplot_user_critical_enter(); 675 | g_last_sample_time_ms = current_time_ms; 676 | aresplot_user_critical_exit(); 677 | } 678 | } 679 | } 680 | 681 | 682 | #if ARESPLOT_ENABLE_ERROR_REPORT 683 | int aresplot_report_error(uint8_t error_code, const char* message, uint8_t msg_len) { 684 | aresplot_user_critical_enter(); 685 | if (g_error_report_pending) { 686 | aresplot_user_critical_exit(); 687 | return 0; 688 | } 689 | 690 | // 确保消息不会导致payload溢出 g_error_report_msg_to_send 缓冲区 691 | // Ensure message doesn't overflow g_error_report_msg_to_send buffer 692 | if (msg_len >= sizeof(g_error_report_msg_to_send)) { 693 | msg_len = sizeof(g_error_report_msg_to_send) -1; // Leave space for null terminator if it were a C string 694 | } 695 | 696 | g_error_report_code_to_send = error_code; 697 | if (message && msg_len > 0) { 698 | memcpy(g_error_report_msg_to_send, message, msg_len); 699 | g_error_report_msg_len_to_send = msg_len; 700 | } else { 701 | g_error_report_msg_len_to_send = 0; 702 | } 703 | 704 | g_error_report_pending = 1; 705 | aresplot_user_critical_exit(); 706 | return 1; 707 | } 708 | #endif 709 | 710 | -------------------------------------------------------------------------------- /css/styles.css: -------------------------------------------------------------------------------- 1 | /* css/styles.css - Extracted and refined from plotter.html */ 2 | 3 | /* --- Global Reset & Base --- */ 4 | * { 5 | box-sizing: border-box; 6 | } 7 | 8 | html, 9 | body { 10 | height: 100%; 11 | margin: 0; 12 | overflow: hidden; 13 | /* Prevent body scrollbars */ 14 | font-family: 'Inter', sans-serif; 15 | /* Base font */ 16 | background-color: #f3f4f6; 17 | /* Default background */ 18 | overflow-x: hidden; 19 | } 20 | 21 | body { 22 | display: flex; 23 | /* Use flex for overall layout if needed */ 24 | position: relative; 25 | } 26 | 27 | /* --- Main Layout Containers --- */ 28 | .main-container { 29 | display: flex; 30 | flex: 1; 31 | height: 100%; 32 | padding: 1rem; 33 | gap: 1rem; 34 | overflow: hidden; 35 | /* 确保内容不会溢出 main-container */ 36 | width: 100%; 37 | /* 明确宽度 */ 38 | } 39 | 40 | .control-panel { 41 | width: 350px; 42 | /* Fixed width for control panel */ 43 | min-width: 250px; 44 | flex-shrink: 0; 45 | /* Prevent shrinking */ 46 | display: flex; 47 | flex-direction: column; 48 | gap: 1rem; 49 | padding: 1rem; 50 | background-color: white; 51 | border-radius: 0.5rem; 52 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); 53 | overflow-y: auto; 54 | /* Allow scrolling if content exceeds height */ 55 | } 56 | 57 | .display-area-container { 58 | display: flex; 59 | flex-direction: column; 60 | flex: 1; 61 | /* Allow display area to take remaining width */ 62 | min-width: 300px; 63 | overflow: hidden; 64 | } 65 | 66 | .display-area { 67 | display: flex; 68 | flex-direction: column; 69 | flex: 1; 70 | /* Allow inner display area to fill its container */ 71 | overflow: hidden; 72 | width: 100%; 73 | height: 100%; 74 | position: relative; 75 | /* Add positioning context */ 76 | } 77 | 78 | /* --- Placeholders & Split Targets Layout --- */ 79 | /* These styles help ensure placeholders have dimensions for Split.js */ 80 | #plot-module-placeholder, 81 | #bottomRow { 82 | display: flex; 83 | flex-direction: row; 84 | flex: 1 1 35%; 85 | /* Default size basis */ 86 | min-height: 150px; 87 | overflow: hidden; 88 | position: relative; 89 | /* Add positioning context */ 90 | } 91 | 92 | #text-module-placeholder, 93 | #quaternion-module-placeholder { 94 | display: flex; 95 | /* Use flex */ 96 | position: relative; 97 | overflow: hidden; 98 | } 99 | 100 | 101 | /* Vertical Split Targets Sizing */ 102 | #plot-module-placeholder { 103 | flex: 1 1 65%; 104 | /* Default size basis, allow shrink/grow */ 105 | min-height: 150px; 106 | /* Minimum height */ 107 | flex-direction: column; 108 | /* Assume content inside flows top-down */ 109 | } 110 | 111 | #bottomRow { 112 | flex: 1 1 35%; 113 | /* Default size basis, allow shrink/grow */ 114 | min-height: 150px; 115 | /* Minimum height */ 116 | flex-direction: row; 117 | /* Children (text/quat placeholders) side-by-side */ 118 | } 119 | 120 | /* Horizontal Split Targets Sizing */ 121 | #text-module-placeholder, 122 | #quaternion-module-placeholder { 123 | flex: 1 1 50%; 124 | /* Default size basis, allow shrink/grow */ 125 | min-width: 150px; 126 | /* Minimum width */ 127 | flex-direction: column; 128 | /* Assume content inside flows top-down */ 129 | } 130 | 131 | 132 | /* --- Split.js Gutters --- */ 133 | .gutter { 134 | background-color: #e5e7eb; 135 | background-repeat: no-repeat; 136 | background-position: 50%; 137 | z-index: 20; 138 | /* Ensure gutter is clickable */ 139 | } 140 | 141 | .gutter.gutter-horizontal { 142 | /* Between text/quat */ 143 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeAAPRUQBx75IwAAAAAElFTkSuQmCC'); 144 | cursor: col-resize; 145 | height: 100%; 146 | } 147 | 148 | .gutter.gutter-vertical { 149 | /* Between plot/bottomRow */ 150 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII='); 151 | cursor: row-resize; 152 | width: 100%; 153 | } 154 | 155 | /* --- Module Styling (Container, Header, Controls) --- */ 156 | /* Style applies to the #plotModule, #textModule, #quatModule divs loaded inside placeholders */ 157 | .module-container { 158 | width: 100%; 159 | /* Fill placeholder width */ 160 | height: 100%; 161 | /* Fill placeholder height */ 162 | display: flex; 163 | flex-direction: column; 164 | background-color: white; 165 | border-radius: 0.5rem; 166 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); 167 | padding: 0.5rem; 168 | overflow: hidden; 169 | /* Contains content */ 170 | position: relative; 171 | /* For absolutely positioned children like FPS */ 172 | } 173 | 174 | .module-header { 175 | display: flex; 176 | justify-content: space-between; 177 | align-items: center; 178 | min-height: 40px; 179 | margin-bottom: 0.15rem; 180 | padding-left: 0.2rem; 181 | padding-bottom: 0.1rem; 182 | border-bottom: 1px solid #e5e7eb; 183 | flex-shrink: 0; 184 | /* Prevent header from shrinking */ 185 | flex-wrap: wrap; 186 | gap: 0.75rem; 187 | } 188 | 189 | .module-header h3 { 190 | margin: 0 0 0 0; 191 | /* Reset margin */ 192 | margin-right: auto; 193 | /* Push controls right */ 194 | padding-bottom: 0; 195 | font-size: 1.125rem; 196 | font-weight: 600; 197 | color: #1f2937; 198 | flex-shrink: 0; 199 | } 200 | 201 | .module-controls { 202 | display: flex; 203 | align-items: center; 204 | gap: 0.75rem; 205 | flex-wrap: wrap; 206 | } 207 | 208 | .data-format-controls { 209 | /* STR/HEX buttons */ 210 | display: flex; 211 | gap: 0.25rem; 212 | } 213 | 214 | .data-format-controls button { 215 | padding: 0.125rem 0.5rem; 216 | font-size: 0.75rem; 217 | border: 1px solid transparent; 218 | background-color: #e5e7eb; 219 | color: #374151; 220 | border-radius: 0.25rem; 221 | cursor: pointer; 222 | } 223 | 224 | .data-format-controls button.active { 225 | background-color: #dbeafe; 226 | border-color: #93c5fd; 227 | color: #1e40af; 228 | font-weight: 500; 229 | } 230 | 231 | /* --- Specific Module Content Styling --- */ 232 | #dataRateDisplay { 233 | position: absolute; 234 | /* 改为绝对定位 */ 235 | left: 50%; 236 | /* 左边缘移到中心 */ 237 | transform: translateX(-50%); 238 | /* 向左移动自身宽度的一半,实现水平居中 */ 239 | /* align-items: center (父元素) 会处理垂直居中, 无需 top/translateY */ 240 | white-space: nowrap; 241 | /* 防止文本换行 */ 242 | /* 保留基础样式 */ 243 | font-size: 0.875rem; 244 | color: #4b5563; 245 | } 246 | 247 | /* Plot Module */ 248 | .plot-container .plot-content { 249 | /* The div holding the #lineChart */ 250 | flex-grow: 1; 251 | /* Allow content div to fill space */ 252 | min-height: 0; 253 | /* Crucial for flex-grow in column */ 254 | width: 100%; 255 | height: 100%; 256 | } 257 | 258 | .plot-container #lineChart { 259 | /* TimeChart target */ 260 | width: 100%; 261 | height: 100%; 262 | } 263 | 264 | /* Text Module */ 265 | #parsedDataDisplay { 266 | display: flex; 267 | flex-wrap: wrap; 268 | gap: 0.3rem; 269 | font-family: monospace; 270 | font-size: 0.875rem; 271 | padding: 0.25rem; 272 | border: 1px solid #e5e7eb; 273 | border-radius: 0.375rem; 274 | background-color: #f9fafb; 275 | margin-bottom: 0.5rem; 276 | min-height: 2.5rem; 277 | align-items: center; 278 | flex-shrink: 0; 279 | overflow-y: auto; 280 | max-height: 100px; 281 | } 282 | 283 | #parsedDataDisplay .channel-value { 284 | background-color: #e5e7eb; 285 | padding: .25rem; 286 | border-radius: 0.25rem; 287 | white-space: nowrap; 288 | } 289 | 290 | #parsedDataDisplay .channel-label { 291 | font-size: 0.75rem; 292 | color: #6b7280; 293 | margin-right: 0.125rem; 294 | } 295 | 296 | /* Style for the xterm container div */ 297 | .terminal-container { 298 | flex-grow: 1; 299 | width: 100%; 300 | height: 100%; 301 | min-height: 50px; 302 | padding: .25rem; 303 | border-radius: 0.375rem; 304 | overflow: hidden; 305 | /* Keep this */ 306 | } 307 | 308 | .terminal-container .xterm .xterm-viewport { 309 | width: 100% !important; 310 | height: 100% !important; 311 | } 312 | 313 | /* Quaternion Module */ 314 | /* --- Quaternion Module Specific --- */ 315 | .quaternion-container { 316 | position: relative; 317 | /* Needed for absolute positioning of overlays */ 318 | overflow: hidden; 319 | /* Ensure overlays don't spill out */ 320 | display: flex; 321 | /* Use flex for overall structure */ 322 | flex-direction: column; 323 | } 324 | 325 | .quat-view-area { 326 | flex-grow: 1; 327 | /* Allow 3D view to take available space */ 328 | min-height: 0; 329 | /* Crucial for flex-grow in column layout */ 330 | background-color: #e5e7eb; 331 | /* Default background */ 332 | border-radius: 0.375rem; 333 | position: relative; 334 | /* For error overlay positioning */ 335 | width: 100%; 336 | /* height: 100%; */ 337 | /* Let flexbox handle height */ 338 | overflow: hidden; 339 | cursor: grab; 340 | } 341 | 342 | .quaternion-container .quat-view-area:active { 343 | cursor: grabbing; 344 | } 345 | 346 | 347 | /* Overlay for Data Processing Errors (e.g., NaN) */ 348 | .quat-error-overlay { 349 | position: absolute; 350 | top: 0; 351 | left: 0; 352 | right: 0; 353 | bottom: 0; 354 | display: flex; 355 | /* Hidden by default via inline style */ 356 | justify-content: center; 357 | align-items: center; 358 | background-color: rgba(229, 231, 235, 0.6); 359 | /* Semi-transparent background */ 360 | color: #dc2626; 361 | /* Red text for error */ 362 | font-weight: 500; 363 | text-align: center; 364 | padding: 1rem; 365 | border-radius: 0.375rem; 366 | /* Match parent */ 367 | z-index: 5; 368 | pointer-events: none; 369 | /* Allow interaction with underlying view if needed */ 370 | overflow-y: auto; 371 | } 372 | 373 | /* Overlay for Initial Channel Selection */ 374 | .quat-selector-overlay { 375 | position: absolute; 376 | top: 0; 377 | left: 0; 378 | right: 0; 379 | bottom: 0; 380 | background-color: rgba(243, 244, 246, 0.95); 381 | /* Slightly opaque background */ 382 | display: flex; 383 | flex-direction: column; 384 | justify-content: center; 385 | align-items: center; 386 | padding: 2rem; 387 | z-index: 10; 388 | /* Above the 3D view */ 389 | border-radius: 0.5rem; 390 | /* Match module container */ 391 | text-align: center; 392 | } 393 | 394 | .quat-selector-overlay h4 { 395 | font-size: 1.0rem; 396 | font-weight: 600; 397 | color: #1f2937; 398 | margin-bottom: 0.5rem; 399 | } 400 | 401 | .quat-selector-overlay p.text-xs { 402 | /* 更精确地选中描述文字 */ 403 | font-size: 0.75rem; 404 | /* 可选:稍微减小字号 */ 405 | color: #4b5563; 406 | /* 默认灰色 */ 407 | margin-bottom: 0.5rem; 408 | /* 减小描述文字下边距 */ 409 | line-height: 1.2; 410 | /* 可选:调整行高 */ 411 | } 412 | 413 | .quat-selector-grid { 414 | display: grid; 415 | grid-template-columns: repeat(2, minmax(0, 1fr)); 416 | /* 2 columns */ 417 | gap: 0.5rem; 418 | margin-bottom: .75rem; 419 | width: 100%; 420 | max-width: 300px; 421 | /* Limit width of selectors */ 422 | } 423 | 424 | .quat-selector-grid div { 425 | text-align: left; 426 | } 427 | 428 | .quat-selector-grid label { 429 | display: block; 430 | margin-bottom: 0.25rem; 431 | font-size: 0.75rem; 432 | font-weight: 500; 433 | color: #374151; 434 | } 435 | 436 | .quat-selector-grid select { 437 | /* Inherit base select styles or add specific ones */ 438 | width: 100%; 439 | padding: 0.3rem 0.5rem; 440 | /* 减小选择框内边距 */ 441 | border: 1px solid #d1d5db; 442 | border-radius: 0.375rem; 443 | font-size: .875rem; 444 | /* Reset font size */ 445 | margin-bottom: 0; 446 | /* Override general margin */ 447 | } 448 | 449 | .quat-selector-error-message { 450 | color: #dc2626; 451 | /* Red */ 452 | font-size: 0.875rem; 453 | margin-top: 0.5rem; 454 | margin-bottom: 1rem; 455 | min-height: 1.25rem; 456 | /* Reserve space */ 457 | } 458 | 459 | .quat-confirm-button { 460 | /* Use base button styles or define specific */ 461 | padding: 0.4rem 1rem; 462 | background-color: #2563eb; 463 | color: white; 464 | border-radius: 0.375rem; 465 | font-weight: 500; 466 | cursor: pointer; 467 | transition: background-color 0.2s; 468 | } 469 | 470 | .quat-confirm-button:hover:not(:disabled) { 471 | background-color: #1d4ed8; 472 | } 473 | 474 | .quat-confirm-button:disabled { 475 | background-color: #9ca3af; 476 | cursor: not-allowed; 477 | opacity: 0.7; 478 | } 479 | 480 | /* Button in module header */ 481 | .quat-control-button { 482 | background: none; 483 | /* border: 1px solid #d1d5db; */ 484 | padding: 0.25rem; 485 | color: #4b5563; 486 | cursor: pointer; 487 | line-height: 0; 488 | /* Align icon */ 489 | border-radius: 0.25rem; 490 | display: flex; 491 | align-items: center; 492 | } 493 | 494 | .quat-control-button:hover { 495 | background-color: #f3f4f6; 496 | border-color: #9ca3af; 497 | color: #1f2937; 498 | } 499 | 500 | .quat-control-button svg { 501 | width: 1em; /* 控制图标大小 */ 502 | height: 1em; 503 | } 504 | 505 | /* --- General Control Styles (Buttons, Inputs, Labels, etc.) --- */ 506 | #control-panel button { 507 | padding: 0.5rem 1rem; 508 | border-radius: 0.375rem; 509 | background-color: #3b82f6; 510 | color: white; 511 | font-weight: 500; 512 | transition: background-color 0.2s; 513 | border: none; 514 | cursor: pointer; 515 | margin-bottom: 0.25rem; 516 | } 517 | 518 | #control-panel button:hover { 519 | background-color: #2563eb; 520 | } 521 | 522 | #control-panel button#clearDataButton:hover:not(:disabled) { 523 | background-color: #ef4444; 524 | color: white 525 | } 526 | 527 | #control-panel button:disabled { 528 | background-color: #9ca3af; 529 | cursor: not-allowed; 530 | opacity: 0.7; 531 | } 532 | 533 | #control-panel input[type="number"], 534 | #control-panel select, 535 | #control-panel textarea { 536 | border: 1px solid #d1d5db; 537 | border-radius: 0.375rem; 538 | padding: 0.5rem; 539 | width: 100%; 540 | font-size: 1rem; 541 | margin-bottom: 0.25rem; 542 | padding-left: 0.75rem; 543 | } 544 | 545 | #control-panel select { 546 | padding-right: 2.5rem; 547 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 548 | background-position: right 0.5rem center; 549 | background-repeat: no-repeat; 550 | background-size: 1.5em 1.5em; 551 | -webkit-appearance: none; 552 | -moz-appearance: none; 553 | appearance: none; 554 | } 555 | 556 | #control-panel label { 557 | display: block; 558 | margin-bottom: 0.25rem; 559 | font-size: 0.875rem; 560 | font-weight: 500; 561 | color: #374151; 562 | } 563 | 564 | #control-panel .control-section { 565 | /* Sections within Control Panel */ 566 | border: 1px solid #e5e7eb; 567 | border-radius: 0.375rem; 568 | padding: 0.75rem; 569 | } 570 | 571 | .control-section:last-child { 572 | margin-bottom: 0; 573 | } 574 | 575 | .parser-status { 576 | font-size: 0.75rem; 577 | margin-top: 0.25rem; 578 | color: #6b7280; 579 | font-style: italic; 580 | } 581 | 582 | /* Define color classes directly */ 583 | .parser-status.text-green-600 { 584 | color: #059669 !important; 585 | } 586 | 587 | .parser-status.text-red-600 { 588 | color: #dc2626 !important; 589 | } 590 | 591 | /* Control Panel Headings */ 592 | .control-panel h3 { 593 | font-size: 1.25rem; 594 | font-weight: 600; 595 | margin: 0 0 0.25rem 0; 596 | color: #111827; 597 | border-bottom: 1px solid #e5e7eb; 598 | padding-bottom: 0.5rem; 599 | } 600 | 601 | .control-panel h4 { 602 | font-size: 1rem; 603 | font-weight: 600; 604 | margin: 0 0 0.5rem 0; 605 | color: #374151; 606 | } 607 | 608 | #aresplotControlsSection h3 { 609 | /* Inherits existing h3 styles, fine for now */ 610 | margin-bottom: 0.75rem; /* Ensure consistent bottom margin */ 611 | } 612 | 613 | #symbolSlotsContainer { 614 | /* Styles mostly handled by Tailwind in HTML, but ensure min-height */ 615 | min-height: 1rem; /* ~128px, adjust as needed */ 616 | /* Tailwind classes bg-gray-50, border, p-2 are already applied */ 617 | } 618 | 619 | /* Placeholder style for slots (real style applied when slots are rendered) */ 620 | #symbolSlotsContainer > span.text-gray-400 { 621 | display: block; 622 | padding: 0.5rem; 623 | text-align: center; 624 | } 625 | 626 | #symbolSearchArea button { 627 | padding: .25rem .75rem; 628 | border-radius: .25rem; 629 | } 630 | 631 | #symbolSearchArea input { 632 | border: 1px solid #d1d5db; 633 | border-radius: 0.25rem; 634 | padding: 0.25rem; 635 | width: 100%; 636 | font-size: 1rem; 637 | margin-bottom: 0.25rem; 638 | padding-left: 0.5rem; 639 | } 640 | 641 | #symbolSlotsContainer button { 642 | padding: .25rem; 643 | width: 100%; 644 | font-size: 1rem; 645 | margin-bottom: 0rem; 646 | color: #9ca3af; 647 | background-color: transparent; 648 | } 649 | 650 | #symbolSlotsContainer button:hover { 651 | background-color: red; 652 | color: #dbeafe; 653 | } 654 | /* Style for action buttons inside slots (will be applied later) */ 655 | .slot-action-btn { 656 | padding: 0.1rem 0.3rem; 657 | font-size: 0.75rem; /* Smaller font */ 658 | line-height: 1; 659 | border: 1px solid #d1d5db; 660 | background-color: #f9fafb; 661 | border-radius: 0.25rem; 662 | cursor: pointer; 663 | margin-left: 0.25rem; 664 | } 665 | .slot-action-btn:hover:not(:disabled) { 666 | background-color: #e5e7eb; 667 | } 668 | .slot-action-btn:disabled { 669 | opacity: 0.5; 670 | cursor: not-allowed; 671 | } 672 | 673 | /* Styles for Drag and Drop Zone */ 674 | #elfDropZone { 675 | transition: border-color 0.2s ease-in-out, background-color 0.2s ease-in-out; 676 | } 677 | 678 | /* Style applied when a file is dragged over the drop zone */ 679 | #elfDropZone.drag-over { 680 | border-color: #2563eb; /* Tailwind blue-600 */ 681 | background-color: #eff6ff; /* Tailwind blue-50 */ 682 | } 683 | 684 | #elfDropZone p { 685 | pointer-events: none; /* Prevent text/icon from interfering with drop */ 686 | } 687 | 688 | .drag-handle { 689 | cursor: grab; /* Indicates draggable */ 690 | } 691 | .drag-handle:active { 692 | cursor: grabbing; 693 | } 694 | 695 | /* SortableJS ghost/chosen classes for visual feedback */ 696 | .sortable-ghost { 697 | opacity: 0.4; 698 | background: #c8ebfb; /* Light blue placeholder */ 699 | } 700 | .sortable-chosen { 701 | /* Styles for the item being actively dragged, if needed */ 702 | /* e.g., box-shadow: 0 0 5px rgba(0,0,0,0.2); */ 703 | } 704 | 705 | /* Ensure slot items are block or flex to allow SortableJS to work correctly */ 706 | #symbolSlotsContainer > div { 707 | /* display: block; or display: flex; (already flex from Tailwind) */ 708 | /* Ensure no strange margins collapse issues with SortableJS */ 709 | } 710 | 711 | /* Progress Bar */ 712 | .progress-bar-container { 713 | width: 100%; 714 | background-color: #e5e7eb; 715 | border-radius: 0.375rem; 716 | overflow: hidden; 717 | height: 0.5rem; 718 | } 719 | 720 | .progress-bar { 721 | background-color: #3b82f6; 722 | height: 100%; 723 | width: 0%; 724 | transition: width 0.2s ease-out; 725 | } 726 | 727 | /* Toggle Switch */ 728 | .toggle-switch { 729 | display: inline-flex; 730 | align-items: center; 731 | cursor: pointer; 732 | vertical-align: middle; 733 | } 734 | 735 | .toggle-switch input { 736 | display: none; 737 | } 738 | 739 | .toggle-switch .slider { 740 | width: 34px; 741 | height: 20px; 742 | background-color: #ccc; 743 | border-radius: 10px; 744 | position: relative; 745 | transition: background-color 0.2s; 746 | } 747 | 748 | .toggle-switch .slider::before { 749 | content: ""; 750 | position: absolute; 751 | width: 16px; 752 | height: 16px; 753 | border-radius: 50%; 754 | background-color: white; 755 | left: 2px; 756 | top: 2px; 757 | transition: transform 0.2s; 758 | } 759 | 760 | .toggle-switch input:checked+.slider { 761 | background-color: #3b82f6; 762 | } 763 | 764 | .toggle-switch input:checked+.slider::before { 765 | transform: translateX(14px); 766 | } 767 | 768 | .toggle-switch-label { 769 | margin-left: 0.5rem; 770 | font-size: 0.875rem; 771 | color: #374151; 772 | user-select: none; 773 | } 774 | 775 | /* Worker Status Display */ 776 | #workerStatusDisplay { 777 | font-size: 0.75rem; 778 | color: #6b7280; 779 | margin-top: 0.5rem; 780 | padding: 0.25rem 0.5rem; 781 | background-color: #f9fafb; 782 | border: 1px solid #e5e7eb; 783 | border-radius: 0.25rem; 784 | display: none; 785 | /* Hidden by default */ 786 | } 787 | 788 | /* --- Fullscreen Module Styles --- */ 789 | 790 | /* 当 #displayArea 处于全屏模式时的样式 */ 791 | .display-area-fullscreen-active { 792 | /* 可以根据需要添加特定样式,例如移除内边距 */ 793 | /* padding: 0 !important; */ 794 | } 795 | 796 | /* 用于隐藏非全屏元素的类 */ 797 | .hidden-by-fullscreen { 798 | display: none !important; 799 | visibility: hidden !important; /* 双重保险 */ 800 | } 801 | 802 | /* 全屏模块本身的样式 */ 803 | .module-fullscreen { 804 | position: absolute !important; /* 使用 absolute 相对于 #displayArea 定位 */ 805 | top: 0 !important; 806 | left: 0 !important; 807 | right: 0 !important; 808 | bottom: 0 !important; 809 | width: 100% !important; 810 | height: 100% !important; 811 | z-index: 50 !important; /* 确保在最上层 */ 812 | margin: 0 !important; /* 清除外边距 */ 813 | border-radius: 0 !important; /* 移除圆角(可选) */ 814 | /* 确保 flex 布局仍然有效 */ 815 | display: flex !important; 816 | flex-direction: column !important; 817 | overflow: hidden !important; /* 防止内容溢出 */ 818 | } 819 | 820 | /* 当任何模块全屏时,隐藏 Split.js 的 Gutter */ 821 | .display-area-fullscreen-active .gutter { 822 | display: none !important; 823 | } 824 | 825 | /* 全屏按钮的基础样式 */ 826 | .module-fullscreen-button { 827 | background: none; 828 | border: none; 829 | padding: 0.25rem; /* 轻微内边距 */ 830 | color: #4b5563; /* gray-600 */ 831 | cursor: pointer; 832 | line-height: 0; /* 图标垂直居中 */ 833 | border-radius: 0.25rem; 834 | display: inline-flex; /* 使 SVG 居中 */ 835 | align-items: center; 836 | justify-content: center; 837 | } 838 | .module-fullscreen-button:hover { 839 | background-color: #f3f4f6; /* gray-100 */ 840 | color: #1f2937; /* gray-800 */ 841 | } 842 | .module-fullscreen-button svg { 843 | width: 1em; /* 控制图标大小 */ 844 | height: 1em; 845 | } -------------------------------------------------------------------------------- /html_partials/control_panel.html: -------------------------------------------------------------------------------- 1 |
2 |

数据采集与导出

3 | 4 | 9 |

状态:空闲

10 |

11 |
12 | 13 | 21 |
22 | 23 |
24 |
25 |
26 |

27 | 缓冲: 0 / 120000 点 28 |

29 | 30 | 31 |
32 | 33 | 36 |
37 |
38 | 39 | 69 | 70 | 121 | 122 | 158 | 159 | 213 | -------------------------------------------------------------------------------- /html_partials/plot_module.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

曲线显示 (WebGL)

4 | 5 | 速率: 0 Hz 6 | 7 |
8 | 13 | 16 |
17 |
18 |
19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /html_partials/quaternion_module.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

四元数姿态显示

4 |
5 | 8 | 9 | 12 |
13 |
14 | 15 |
16 | 17 |
18 |

选择四元数通道

19 |

请为 W, X, Y, Z 分配唯一的可用通道。

20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 | 28 |
29 |
30 |
-------------------------------------------------------------------------------- /html_partials/text_module.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

原始数据显示

4 |
5 |
6 | 7 | 8 |
9 |
10 | 11 | 12 | 32 |
33 | 36 |
37 |
38 |
39 | 等待数据... 40 |
41 |
42 |
43 | -------------------------------------------------------------------------------- /icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainKAZ/web-serial-plotter/c6060ed8b6ffcfabc80e4e676cd4a5b1dbdaa42c/icons/icon-512x512.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 高性能WebSerial串口绘图工具 7 | 13 | 14 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 |
49 | 50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /js/config.js: -------------------------------------------------------------------------------- 1 | // js/config.js 2 | 3 | // --- Core Behavior Constants --- 4 | export const ESTIMATION_UPDATE_INTERVAL_MS = 1000; // How often buffer estimation runs (ms) 5 | // --- Buffer & Logging Constants --- 6 | export const MAX_RAW_TEXT_LINES_DISPLAY = 1000; // Max lines shown in the raw text area 7 | export const MAX_RAW_LOG_BUFFER_LINES = 10000; // Max lines kept in the raw log memory buffer 8 | export const DEFAULT_MAX_BUFFER_POINTS = 120000; // Default max points for TimeChart/CSV buffer (matches HTML default) 9 | export const MIN_BUFFER_POINTS = 1000; // Minimum allowed buffer points 10 | 11 | // --- Charting Constants --- 12 | export const seriesColors = [ 13 | // Default colors for TimeChart series 14 | "#1f77b4", 15 | "#ff7f0e", 16 | "#2ca02c", 17 | "#d62728", 18 | "#9467bd", 19 | "#8c564b", 20 | "#e377c2", 21 | "#7f7f7f", 22 | "#bcbd22", 23 | "#17becf", 24 | "#aec7e8", 25 | "#ffbb78", 26 | "#98df8a", 27 | "#ff9896", 28 | "#c5b0d5", 29 | "#c49c94", 30 | ]; 31 | export const ZOOM_FACTOR = 1.1; // Factor for wheel zoom in TimeChart custom interaction 32 | 33 | // --- Simulation Defaults --- 34 | export const DEFAULT_SIM_CHANNELS = 1; 35 | export const DEFAULT_SIM_FREQUENCY = 1000; 36 | export const DEFAULT_SIM_AMPLITUDE = 1; 37 | 38 | // --- Serial Defaults --- 39 | export const DEFAULT_BAUD_RATE = 115200; 40 | // Add other serial defaults if needed 41 | 42 | // --- Terminal View --- 43 | export const TERMINAL_UPDATE_INTERVAL_MS = 40; 44 | 45 | // Base time calculation (consider if needed globally or just in timechart init) 46 | // Using performance.now() relative timestamps might be simpler if baseTime is handled well by TimeChart 47 | // export const performanceTimeToDateEpochOffset = Date.now() - performance.now(); 48 | // export const baseTimeForChart = 0; // Or performanceTimeToDateEpochOffset, or null 49 | 50 | console.log("config.js loaded"); // For debugging load order 51 | -------------------------------------------------------------------------------- /js/event_bus.js: -------------------------------------------------------------------------------- 1 | // js/event_bus.js 2 | /** 3 | * 简单的全局事件总线 (基于 EventTarget) 4 | * 用于模块间解耦通信。 5 | * 6 | * 使用方法: 7 | * import { eventBus } from './event_bus.js'; 8 | * 9 | * // 触发事件 10 | * eventBus.emit('some-event', { detail: 'some data' }); 11 | * 12 | * // 监听事件 13 | * eventBus.on('some-event', (event) => { 14 | * console.log('Event received:', event.detail); 15 | * }); 16 | * 17 | * // 移除监听 18 | * // eventBus.off('some-event', listenerFunction); 19 | */ 20 | class EventBus extends EventTarget { 21 | on(eventName, listener) { 22 | this.addEventListener(eventName, listener); 23 | } 24 | 25 | off(eventName, listener) { 26 | this.removeEventListener(eventName, listener); 27 | } 28 | 29 | emit(eventName, detail) { 30 | this.dispatchEvent(new CustomEvent(eventName, { detail })); 31 | } 32 | } 33 | 34 | export const eventBus = new EventBus(); 35 | console.log("EventBus initialized."); 36 | -------------------------------------------------------------------------------- /js/modules/aresplot_protocol.js: -------------------------------------------------------------------------------- 1 | // js/modules/aresplot_protocol.js 2 | 3 | // --- Protocol Constants --- 4 | export const SOP = 0xA5; // Start of Packet 5 | export const EOP = 0x5A; // End of Packet 6 | 7 | export const CMD_ID = { 8 | START_MONITOR: 0x01, // PC -> MCU: Request to start/update/stop monitoring variables 9 | SET_VARIABLE: 0x02, // PC -> MCU: Request to set a variable's value 10 | SET_SAMPLE_RATE: 0x03, // PC -> MCU: Request to set sample rate (optional) 11 | MONITOR_DATA: 0x81, // MCU -> PC: Transmitting monitored variable data 12 | ACK: 0x82, // MCU -> PC: Command Acknowledgment/Response 13 | ERROR_REPORT: 0x8F // MCU -> PC: MCU asynchronous error report (optional) 14 | }; 15 | 16 | // AresOriginalType_t Enum (mirrors the spec) 17 | // Values that PC sends to MCU in CMD_START_MONITOR and CMD_SET_VARIABLE 18 | export const AresOriginalType = { 19 | INT8: 0x00, 20 | UINT8: 0x01, 21 | INT16: 0x02, 22 | UINT16: 0x03, 23 | INT32: 0x04, 24 | UINT32: 0x05, 25 | FLOAT32: 0x06, 26 | FLOAT64: 0x07, // Note: MCU must support this if used 27 | BOOL: 0x08 28 | }; 29 | 30 | // ACK Statuses (mirrors the spec) 31 | export const AckStatus = { 32 | OK: 0x00, 33 | ERROR_CHECKSUM: 0x01, 34 | ERROR_UNKNOWN_CMD: 0x02, 35 | ERROR_INVALID_PAYLOAD: 0x03, 36 | ERROR_ADDR_INVALID: 0x04, 37 | ERROR_TYPE_UNSUPPORTED: 0x05, 38 | ERROR_RATE_UNACHIEVABLE: 0x06, 39 | ERROR_MCU_BUSY_OR_LIMIT: 0x07, 40 | ERROR_GENERAL_FAIL: 0xFF 41 | }; 42 | 43 | const HEADER_SIZE = 1 + 1 + 2; // SOP + CMD + LEN 44 | const CHECKSUM_EOP_SIZE = 1 + 1; // CHECKSUM + EOP 45 | 46 | /** 47 | * Calculates the AresPlot checksum. 48 | * Checksum is calculated from CMD ID through the end of the PAYLOAD. 49 | * @param {number} cmdId - The command ID (uint8_t). 50 | * @param {number} payloadLenUint16 - The length of the payload (uint16_t). 51 | * @param {Uint8Array} payloadUint8Array - The payload data. 52 | * @returns {number} The calculated checksum (uint8_t). 53 | */ 54 | function calculateAresplotChecksum(cmdId, payloadLenUint16, payloadUint8Array) { 55 | let checksum = 0; 56 | checksum ^= cmdId; 57 | checksum ^= (payloadLenUint16 & 0xFF); // Low byte of len 58 | checksum ^= ((payloadLenUint16 >> 8) & 0xFF); // High byte of len 59 | 60 | for (let i = 0; i < payloadUint8Array.length; i++) { 61 | checksum ^= payloadUint8Array[i]; 62 | } 63 | return checksum; 64 | } 65 | 66 | /** 67 | * Builds a CMD_START_MONITOR (0x01) frame. 68 | * @param {Array<{address: number, originalType: number}>} symbols - Array of symbol objects. 69 | * Each symbol object must have 'address' (uint32_t) and 'originalType' (AresOriginalType_t value). 70 | * @returns {Uint8Array} The complete frame as a Uint8Array, ready to be sent. 71 | */ 72 | export function buildStartMonitorFrame(symbols) { 73 | if (!Array.isArray(symbols)) { 74 | throw new Error("buildStartMonitorFrame: symbols argument must be an array."); 75 | } 76 | 77 | const numVariables = symbols.length; 78 | if (numVariables > 255) { 79 | throw new Error("buildStartMonitorFrame: Number of variables cannot exceed 255."); 80 | } 81 | 82 | // Calculate payload length: 1 byte for NumVariables + N * 5 bytes for (address + type) 83 | const payloadLength = 1 + numVariables * 5; 84 | const frameSize = HEADER_SIZE + payloadLength + CHECKSUM_EOP_SIZE; 85 | const frame = new Uint8Array(frameSize); 86 | const payloadView = new DataView(frame.buffer, frame.byteOffset + HEADER_SIZE, payloadLength); // View for payload 87 | 88 | // --- Build Payload --- 89 | payloadView.setUint8(0, numVariables); // NumVariables 90 | let currentPayloadOffset = 1; 91 | for (const symbol of symbols) { 92 | if (typeof symbol.address !== 'number' || typeof symbol.originalType !== 'number') { 93 | throw new Error("buildStartMonitorFrame: Each symbol must have 'address' (number) and 'originalType' (AresOriginalType_t number)."); 94 | } 95 | payloadView.setUint32(currentPayloadOffset, symbol.address, true); // address (little-endian) 96 | currentPayloadOffset += 4; 97 | payloadView.setUint8(currentPayloadOffset, symbol.originalType); // originalType 98 | currentPayloadOffset += 1; 99 | } 100 | const payloadActual = new Uint8Array(frame.buffer, frame.byteOffset + HEADER_SIZE, payloadLength); 101 | 102 | // --- Build Full Frame --- 103 | let frameOffset = 0; 104 | frame[frameOffset++] = SOP; 105 | frame[frameOffset++] = CMD_ID.START_MONITOR; 106 | // LEN (payloadLength) - Little Endian 107 | frame[frameOffset++] = payloadLength & 0xFF; 108 | frame[frameOffset++] = (payloadLength >> 8) & 0xFF; 109 | 110 | // Copy payload (already set via payloadView) 111 | frameOffset += payloadLength; // Advance offset past payload 112 | 113 | // CHECKSUM 114 | const checksum = calculateAresplotChecksum(CMD_ID.START_MONITOR, payloadLength, payloadActual); 115 | frame[frameOffset++] = checksum; 116 | 117 | // EOP 118 | frame[frameOffset++] = EOP; 119 | 120 | if (frameOffset !== frameSize) { 121 | console.error("buildStartMonitorFrame: Frame size mismatch!", { frameOffset, frameSize }); 122 | // This should not happen if logic is correct 123 | } 124 | // console.log("Built CMD_START_MONITOR frame:", frame); 125 | return frame; 126 | } 127 | 128 | 129 | // --- AresplotFrameParser Class --- 130 | export class AresplotFrameParser { 131 | constructor() { 132 | this.internalBuffer = new Uint8Array(0); // Parser manages its own buffer 133 | // console.log("AresplotFrameParser instance created for direct parsing."); 134 | } 135 | 136 | /** 137 | * Appends new data to the internal buffer. 138 | * @param {Uint8Array} newData - The new chunk of data received. 139 | */ 140 | pushData(newData) { 141 | if (!(newData instanceof Uint8Array) || newData.length === 0) return; 142 | const combined = new Uint8Array(this.internalBuffer.length + newData.length); 143 | combined.set(this.internalBuffer); 144 | combined.set(newData, this.internalBuffer.length); 145 | this.internalBuffer = combined; 146 | } 147 | 148 | /** 149 | * Attempts to parse the next available segment (frame or unidentified data) from the internal buffer. 150 | * If a segment is processed, it's removed from the internal buffer. 151 | * @returns {object|null} An object describing the parsed segment, or null if no complete segment can be processed yet. 152 | * Possible return object structures: 153 | * - Valid MONITOR_DATA: { type: 'data', mcuTimestampMs, values, rawFrame, consumedBytes } 154 | * - Valid ACK: { type: 'ack', ackCmdId, status, rawFrame, consumedBytes } 155 | * - Valid ERROR_REPORT: { type: 'error_report', errorCode, messageBytes, rawFrame, consumedBytes } 156 | * - Unidentified Data: { type: 'unidentified', rawData, consumedBytes } (e.g. bytes before SOP, or a corrupted frame) 157 | * - Needs More Data: null (if buffer doesn't contain a full potential segment yet) 158 | */ 159 | parseNext() { 160 | if (this.internalBuffer.length === 0) { 161 | return null; // Nothing to parse 162 | } 163 | 164 | let sopIndex = this.internalBuffer.indexOf(SOP); 165 | 166 | if (sopIndex === -1) { // No SOP found 167 | // If buffer is "large enough" and no SOP, assume it's all unidentified data 168 | // and consume it to prevent infinite buffering of garbage. 169 | if (this.internalBuffer.length >= 256) { // Configurable threshold 170 | const unidentifiedData = this.internalBuffer.slice(0); // Copy 171 | this.internalBuffer = new Uint8Array(0); 172 | // console.warn("AresplotParser: Flushed large buffer segment due to no SOP.", unidentifiedData.length); 173 | return { type: 'unidentified', rawData: unidentifiedData, consumedBytes: unidentifiedData.length }; 174 | } 175 | return null; // Wait for more data, SOP might still arrive 176 | } 177 | 178 | // SOP found 179 | if (sopIndex > 0) { 180 | // Data before SOP is unidentified 181 | const unidentifiedData = this.internalBuffer.slice(0, sopIndex); 182 | this.internalBuffer = this.internalBuffer.slice(sopIndex); 183 | // console.debug("AresplotParser: Consumed unidentified data before SOP.", unidentifiedData.length); 184 | return { type: 'unidentified', rawData: unidentifiedData, consumedBytes: unidentifiedData.length }; 185 | } 186 | 187 | // Buffer now starts with SOP. Check for header. 188 | if (this.internalBuffer.length < HEADER_SIZE) { 189 | return null; // Not enough for header yet 190 | } 191 | 192 | const cmdId = this.internalBuffer[1]; 193 | const payloadLen = this.internalBuffer[2] | (this.internalBuffer[3] << 8); // Little-endian 194 | 195 | // Sanity check for payloadLen 196 | if (payloadLen > 2048) { // Max reasonable payload 197 | // console.warn(`AresplotParser: Invalid payload length: ${payloadLen}. Discarding SOP and header.`); 198 | const badHeaderSegment = this.internalBuffer.slice(0, HEADER_SIZE); 199 | this.internalBuffer = this.internalBuffer.slice(HEADER_SIZE); // Consume the bad header 200 | return { type: 'unidentified', rawData: badHeaderSegment, consumedBytes: HEADER_SIZE }; 201 | } 202 | 203 | const expectedFrameSize = HEADER_SIZE + payloadLen + CHECKSUM_EOP_SIZE; 204 | 205 | if (this.internalBuffer.length < expectedFrameSize) { 206 | return null; // Not enough data for the complete frame 207 | } 208 | 209 | // We have a potential full frame 210 | const frameBytes = this.internalBuffer.slice(0, expectedFrameSize); 211 | const payload = frameBytes.slice(HEADER_SIZE, HEADER_SIZE + payloadLen); 212 | const receivedChecksum = frameBytes[HEADER_SIZE + payloadLen]; 213 | const eop = frameBytes[expectedFrameSize - 1]; 214 | 215 | const calculatedChecksum = calculateAresplotChecksum(cmdId, payloadLen, payload); 216 | 217 | if (calculatedChecksum !== receivedChecksum || eop !== EOP) { 218 | let warning = ""; 219 | if (calculatedChecksum !== receivedChecksum) warning += `Checksum error (Cmd:0x${cmdId.toString(16)} Exp:${calculatedChecksum} Got:${receivedChecksum}). `; 220 | if (eop !== EOP) warning += `EOP error (Cmd:0x${cmdId.toString(16)} Exp:${EOP} Got:${eop}).`; 221 | // console.warn("AresplotParser: Invalid frame. " + warning); 222 | 223 | // Treat the entire expected frame as unidentified/corrupted 224 | this.internalBuffer = this.internalBuffer.slice(expectedFrameSize); 225 | return { type: 'unidentified', rawData: frameBytes, consumedBytes: expectedFrameSize, warning: warning.trim() }; 226 | } 227 | 228 | // Frame is valid, consume it from buffer 229 | this.internalBuffer = this.internalBuffer.slice(expectedFrameSize); 230 | 231 | // Process payload based on CMD ID 232 | const payloadView = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); 233 | switch (cmdId) { 234 | case CMD_ID.MONITOR_DATA: 235 | if (payload.length < 4 || (payload.length - 4) % 4 !== 0) { 236 | return { type: 'unidentified', rawData: frameBytes, consumedBytes: expectedFrameSize, warning: "Invalid MONITOR_DATA payload size." }; 237 | } 238 | const mcuTimestampMs = payloadView.getUint32(0, true); 239 | const numValues = (payload.length - 4) / 4; 240 | const values = []; 241 | for (let i = 0; i < numValues; i++) { 242 | values.push(payloadView.getFloat32(4 + i * 4, true)); 243 | } 244 | return { type: 'data', mcuTimestampMs, values, rawFrame: frameBytes, consumedBytes: expectedFrameSize }; 245 | case CMD_ID.ACK: 246 | if (payload.length < 2) { 247 | return { type: 'unidentified', rawData: frameBytes, consumedBytes: expectedFrameSize, warning: "Invalid ACK payload size." }; 248 | } 249 | const ackCmdId = payloadView.getUint8(0); 250 | const status = payloadView.getUint8(1); 251 | return { type: 'ack', ackCmdId, status, rawFrame: frameBytes, consumedBytes: expectedFrameSize }; 252 | case CMD_ID.ERROR_REPORT: // Assuming structure: ErrorCode (1 byte) + Optional_Message (M bytes) 253 | if (payload.length < 1) { 254 | return { type: 'unidentified', rawData: frameBytes, consumedBytes: expectedFrameSize, warning: "Invalid ERROR_REPORT payload size." }; 255 | } 256 | const errorCode = payloadView.getUint8(0); 257 | const messageBytes = payload.slice(1); 258 | return { type: 'error_report', errorCode, messageBytes, rawFrame: frameBytes, consumedBytes: expectedFrameSize }; 259 | default: 260 | return { type: 'unidentified', rawData: frameBytes, consumedBytes: expectedFrameSize, warning: `Unknown CMD ID: 0x${cmdId.toString(16)}` }; 261 | } 262 | } 263 | } 264 | 265 | console.log("aresplot_protocol.js loaded"); -------------------------------------------------------------------------------- /js/modules/data_processing.js: -------------------------------------------------------------------------------- 1 | // js/modules/data_processing.js 2 | // Manages the data buffer for CSV export and calculates rate/estimates. 3 | 4 | import { formatSecondsToHMS } from "../utils.js"; 5 | 6 | // --- Module State --- 7 | let dataBuffer = []; // Buffer specifically for CSV export/download 8 | let currentDataRateHz = 0; 9 | let estimatedBufferTimeRemainingSec = null; 10 | let estimatedBufferTimeSec = null; 11 | let dataPointCounter = 0; 12 | let lastRateCheckTime = 0; 13 | 14 | // --- Buffer Management --- 15 | 16 | /** 17 | * Adds data points from a batch to the internal CSV buffer. 18 | * @param {Array} batch - Array of data items { timestamp, values, ... }. 19 | */ 20 | export function addToBuffer(batch) { 21 | for (const item of batch) { 22 | // Ensure basic structure is present 23 | if ( 24 | !item || 25 | typeof item.timestamp !== "number" || 26 | !Array.isArray(item.values) 27 | ) 28 | continue; 29 | // Store only timestamp and validated values for CSV 30 | dataBuffer.push({ 31 | timestamp: item.timestamp, 32 | values: item.values.map((v) => 33 | typeof v === "number" && isFinite(v) ? v : NaN 34 | ), // Sanitize values 35 | }); 36 | } 37 | } 38 | 39 | /** 40 | * Trims the internal CSV data buffer to the specified maximum number of points. 41 | * @param {number} maxPoints - The maximum number of points to keep. 42 | */ 43 | export function trimDataBuffer(maxPoints) { 44 | const pointsToRemove = dataBuffer.length - maxPoints; 45 | if (pointsToRemove > 0) { 46 | dataBuffer.splice(0, pointsToRemove); 47 | } 48 | } 49 | 50 | /** 51 | * Returns the current number of points in the CSV buffer. 52 | * @returns {number} 53 | */ 54 | export function getBufferLength() { 55 | return dataBuffer.length; 56 | } 57 | 58 | /** 59 | * Clears the internal CSV data buffer. 60 | */ 61 | export function clearBuffer() { 62 | dataBuffer = []; 63 | } 64 | 65 | // --- Data Rate Calculation --- 66 | 67 | /** 68 | * Updates the internal data rate calculation based on processed points and time. 69 | * @param {number} pointsInBatch - Number of data points/timestamps processed. 70 | * @param {number} currentTimestamp - The timestamp of the latest data point (performance.now() based). 71 | */ 72 | export function updateDataRate(pointsInBatch, currentTimestamp) { 73 | if (pointsInBatch > 0) { 74 | dataPointCounter += pointsInBatch; 75 | } 76 | const now = currentTimestamp; // Use timestamp from data processing loop 77 | const rateDelta = now - lastRateCheckTime; 78 | 79 | if (rateDelta >= 1000) { 80 | // Update rate calculation roughly every second 81 | currentDataRateHz = (dataPointCounter * 1000) / rateDelta; 82 | dataPointCounter = 0; 83 | lastRateCheckTime = now; 84 | } else if ( 85 | performance.now() - lastRateCheckTime > 2000 && 86 | pointsInBatch === 0 87 | ) { 88 | // Decay rate to 0 if no data received for a while 89 | currentDataRateHz = 0; 90 | dataPointCounter = 0; // Reset counter 91 | lastRateCheckTime = performance.now(); // Update check time to avoid rapid resets 92 | } 93 | } 94 | 95 | /** 96 | * Gets the currently calculated data rate. Handles decay if inactive. 97 | * @returns {number} Data rate in Hz. 98 | */ 99 | export function getCurrentDataRate() { 100 | // Check for decay if called long after last update 101 | if (performance.now() - lastRateCheckTime > 2000 && dataPointCounter === 0) { 102 | currentDataRateHz = 0; 103 | } 104 | return currentDataRateHz; 105 | } 106 | 107 | // --- Buffer Time Estimation --- 108 | 109 | /** 110 | * Calculates the estimated total buffer time and remaining time based on current state. 111 | * @param {number} rate - Current data rate in Hz. 112 | * @param {number} currentPoints - Current number of points in the buffer. 113 | * @param {number} maxPoints - Maximum points the buffer can hold. 114 | * @param {boolean} isCollecting - Whether data collection is active. 115 | */ 116 | export function calculateBufferEstimate( 117 | rate, 118 | currentPoints, 119 | maxPoints, 120 | isCollecting 121 | ) { 122 | const remainingPoints = maxPoints - currentPoints; 123 | if (isCollecting && rate > 0 && maxPoints > 0) { 124 | estimatedBufferTimeSec = maxPoints / rate; 125 | estimatedBufferTimeRemainingSec = 126 | remainingPoints <= 0 ? 0 : remainingPoints / rate; 127 | } else { 128 | estimatedBufferTimeRemainingSec = null; 129 | estimatedBufferTimeSec = null; 130 | } 131 | } 132 | 133 | /** 134 | * Gets the estimated remaining buffer time in seconds. 135 | * @returns {number | null} 136 | */ 137 | export function getEstimateRemaining() { 138 | return estimatedBufferTimeRemainingSec; 139 | } 140 | 141 | /** 142 | * Gets the estimated total buffer time in seconds. 143 | * @returns {number | null} 144 | */ 145 | export function getEstimateTotal() { 146 | return estimatedBufferTimeSec; 147 | } 148 | 149 | /** 150 | * Resets the rate and estimation calculation states. 151 | */ 152 | export function resetEstimatesAndRate() { 153 | currentDataRateHz = 0; 154 | estimatedBufferTimeRemainingSec = null; 155 | estimatedBufferTimeSec = null; 156 | dataPointCounter = 0; 157 | lastRateCheckTime = performance.now(); 158 | } 159 | 160 | // --- Data Export --- 161 | 162 | /** 163 | * Generates and triggers the download of the internal dataBuffer as a CSV file. 164 | * @param {Array<{name: string}> | null} chartSeriesRef - Optional array of series objects (like [{name: 'Ch 1'}, ...]) for header names. 165 | */ 166 | export function downloadCSV(chartSeriesRef = null) { 167 | if (!dataBuffer || dataBuffer.length === 0) { 168 | alert("没有数据可以下载。"); 169 | return; 170 | } 171 | console.log("Generating CSV from dataProcessor buffer..."); 172 | 173 | const numPoints = dataBuffer.length; 174 | const numChannels = dataBuffer[0]?.values?.length || 0; 175 | if (numChannels === 0) { 176 | alert("缓冲区中未找到通道数据。"); 177 | return; 178 | } 179 | 180 | // Build Header Row 181 | let header = "Timestamp (s)"; 182 | for (let i = 0; i < numChannels; i++) { 183 | // Use names from chart series if provided, otherwise default 184 | const seriesName = chartSeriesRef?.[i]?.name || `通道 ${i + 1}`; 185 | // Sanitize name for CSV (remove commas, quotes) 186 | const sanitizedName = seriesName.replace(/["',]/g, ""); 187 | header += `,${sanitizedName}`; 188 | } 189 | header += "\n"; 190 | 191 | // Process data rows (using Promise for potentially large data) 192 | new Promise((resolve, reject) => { 193 | try { 194 | const rows = [header]; 195 | // Use map for potentially better performance? Or keep simple loop. 196 | for (let i = 0; i < numPoints; i++) { 197 | const entry = dataBuffer[i]; 198 | // Skip invalid entries just in case 199 | if ( 200 | !entry || 201 | typeof entry.timestamp !== "number" || 202 | !Array.isArray(entry.values) 203 | ) 204 | continue; 205 | 206 | // Format timestamp (seconds with high precision) 207 | let rowValues = [(entry.timestamp / 1000.0).toFixed(6)]; 208 | 209 | // Format channel values (numbers with high precision, empty for NaN/null/undefined) 210 | for (let ch = 0; ch < numChannels; ch++) { 211 | const value = entry.values[ch]; 212 | rowValues.push( 213 | typeof value === "number" && isFinite(value) ? value.toFixed(6) : "" 214 | ); 215 | } 216 | rows.push(rowValues.join(",")); 217 | } 218 | resolve(rows.join("\n")); 219 | } catch (error) { 220 | reject(error); 221 | } 222 | }) 223 | .then((csvContent) => { 224 | // Create Blob and trigger download 225 | const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); 226 | const link = document.createElement("a"); 227 | const url = URL.createObjectURL(blob); 228 | link.setAttribute("href", url); 229 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); 230 | link.setAttribute("download", `web_plotter_data_${timestamp}.csv`); 231 | link.style.visibility = "hidden"; 232 | document.body.appendChild(link); 233 | link.click(); 234 | document.body.removeChild(link); 235 | URL.revokeObjectURL(url); // Clean up object URL 236 | console.log("CSV download initiated."); 237 | }) 238 | .catch((error) => { 239 | console.error("生成 CSV 时出错:", error); 240 | alert("导出 CSV 时出错: " + error.message); 241 | }); 242 | } 243 | 244 | // Note: updateParsedDataDisplay and updateDataRateDisplayUI are removed 245 | // as those responsibilities are now handled by main.js loop and plot_module respectively. 246 | 247 | console.log("data_processing.js (Refactored - Data Buffer/Stats only) loaded."); 248 | -------------------------------------------------------------------------------- /js/modules/elf_analyzer_service.js: -------------------------------------------------------------------------------- 1 | // js/modules/elf_analyzer_service.js 2 | 3 | // --- Static Import --- 4 | // Import Wasm binding functions statically at the top. 5 | // Ensure the path is correct relative to where this module will be bundled/served from, 6 | // OR use the full URL if importing directly in the browser without bundling. 7 | // Using the full URL as specified in the documentation: 8 | import { default as wasmInit, analyze_elf_recursively } from 'https://captainkaz.github.io/elf_analyzer_wasm/pkg/elf_analyzer_wasm.js'; 9 | // --- End Static Import --- 10 | 11 | // Module state 12 | let isWasmInitialized = false; 13 | let isWasmInitializing = false; 14 | let allParsedSymbols = []; 15 | let wasmInitializationPromise = null; // Stores the promise during initialization 16 | 17 | /** 18 | * Initializes the Wasm module. Should be called once during app startup. 19 | * Uses the statically imported init function. 20 | * @returns {Promise} A promise that resolves when initialization is complete or rejects on error. 21 | */ 22 | export async function initWasmModule() { 23 | // Prevent concurrent or repeated initialization 24 | if (isWasmInitialized || isWasmInitializing) { 25 | // If already initialized, return resolved promise 26 | // If initializing, return the existing promise 27 | return wasmInitializationPromise || Promise.resolve(); 28 | } 29 | 30 | isWasmInitializing = true; 31 | console.log("Starting Wasm module initialization..."); 32 | 33 | wasmInitializationPromise = (async () => { 34 | try { 35 | if (typeof wasmInit !== 'function') { 36 | throw new Error("Wasm module's default export (init function) not found or not a function."); 37 | } 38 | if (typeof analyze_elf_recursively !== 'function') { // Check the named export too 39 | throw new Error("Wasm module did not export 'analyze_elf_recursively' function."); 40 | } 41 | 42 | await wasmInit(); // Call the statically imported init function 43 | 44 | isWasmInitialized = true; 45 | isWasmInitializing = false; 46 | console.log("ELF Analyzer Wasm Module initialized successfully (proactively)."); 47 | } catch (error) { 48 | isWasmInitialized = false; 49 | isWasmInitializing = false; 50 | wasmInitializationPromise = null; // Clear promise on error 51 | console.error("Failed to initialize ELF Analyzer Wasm Module:", error); 52 | // Re-throw so the initial caller in main.js can potentially handle it (e.g., disable aresplot option) 53 | throw error; 54 | } 55 | })(); 56 | 57 | return wasmInitializationPromise; 58 | } 59 | 60 | 61 | /** 62 | * Ensures the Wasm module has completed its initialization. 63 | * Relies on initWasmModule being called beforehand during app startup. 64 | * @returns {Promise} A promise that resolves when initialization is complete, or rejects if it failed. 65 | */ 66 | export async function ensureInitialized() { 67 | if (isWasmInitialized) { 68 | return Promise.resolve(); 69 | } 70 | // If initialization hasn't been kicked off somehow, or failed, this will reject. 71 | // If it's in progress, this will wait for it to finish. 72 | if (!wasmInitializationPromise) { 73 | // This case ideally shouldn't happen if initWasmModule is called on startup. 74 | console.warn("ensureInitialized called before initWasmModule was attempted. Trying to init now."); 75 | return initWasmModule(); // Attempt to initialize now 76 | } 77 | return wasmInitializationPromise; 78 | } 79 | 80 | /** 81 | * Analyzes an ELF file using the loaded Wasm module. 82 | * @param {Uint8Array} elfFileBytes - The ELF file content as a Uint8Array. 83 | * @returns {Promise>} A promise that resolves with an array of symbol objects, 84 | * or rejects if analysis fails or Wasm module is not ready. 85 | */ 86 | export async function analyzeElf(elfFileBytes) { 87 | await ensureInitialized(); // Wait for initialization if it's still in progress 88 | 89 | if (!isWasmInitialized || typeof analyze_elf_recursively !== 'function') { 90 | throw new Error("Wasm module not ready or analyze function unavailable."); 91 | } 92 | 93 | console.log(`Analyzing ELF data (${elfFileBytes.byteLength} bytes)...`); 94 | try { 95 | // Call the statically imported Wasm function 96 | const results = analyze_elf_recursively(elfFileBytes); 97 | allParsedSymbols = results || []; 98 | console.log(`ELF analysis complete. Found ${allParsedSymbols.length} symbols.`); 99 | return allParsedSymbols; 100 | } catch (error) { 101 | console.error("Error during Wasm analyze_elf_recursively call:", error); 102 | allParsedSymbols = []; 103 | throw new Error(`ELF Analysis Failed: ${error.message || error}`); 104 | } 105 | } 106 | 107 | // --- Functions below remain the same --- 108 | 109 | /** 110 | * Checks if the Wasm module has been initialized and an ELF file loaded. 111 | * @returns {boolean} True if symbols are available, false otherwise. 112 | */ 113 | export function isElfLoadedAndAnalyzed() { 114 | // Ensure wasm is initialized *and* symbols have been loaded 115 | return isWasmInitialized && allParsedSymbols.length > 0; 116 | } 117 | 118 | /** 119 | * Searches the currently parsed symbols by name (case-insensitive). 120 | * Detects duplicates within the results and adds a flag for UI disambiguation. 121 | * @param {string} searchTerm - The term to search for in symbol names. 122 | * @param {number} [limit=50] - The maximum number of results to return. 123 | * @returns {Array} An array of matching symbol objects, limited by the limit. 124 | * Each object might have an added 'needsDisambiguation' boolean flag. 125 | */ 126 | export function searchSymbols(searchTerm, limit = 50) { 127 | if (!isElfLoadedAndAnalyzed()) { 128 | return []; 129 | } 130 | const trimmedSearchTerm = searchTerm.trim(); 131 | if (trimmedSearchTerm === '') { 132 | return []; 133 | } 134 | 135 | const lowerSearchTerm = trimmedSearchTerm.toLowerCase(); 136 | const matched = []; 137 | 138 | // First pass: find all matches up to the limit 139 | for (const symbol of allParsedSymbols) { 140 | if (symbol && symbol.name && symbol.name.toLowerCase().includes(lowerSearchTerm)) { 141 | // Add a copy to avoid modifying the original allParsedSymbols 142 | matched.push({ ...symbol, needsDisambiguation: false }); 143 | if (matched.length >= limit) { 144 | break; 145 | } 146 | } 147 | } 148 | 149 | // Second pass: detect names duplicated *within the matched results* 150 | if (matched.length > 1) { 151 | const nameCounts = {}; 152 | matched.forEach(symbol => { 153 | nameCounts[symbol.name] = (nameCounts[symbol.name] || 0) + 1; 154 | }); 155 | 156 | matched.forEach(symbol => { 157 | if (nameCounts[symbol.name] > 1) { 158 | // Only mark if file and line info is available to actually disambiguate 159 | if (symbol.file_name && symbol.line_number) { 160 | symbol.needsDisambiguation = true; 161 | } 162 | // If file/line is missing for a duplicate, it cannot be uniquely identified by this method. 163 | // Consider how to handle this - maybe exclude them or show a generic duplicate marker? 164 | // For now, we only set the flag if disambiguation info exists. 165 | } 166 | }); 167 | } 168 | 169 | return matched; 170 | } 171 | 172 | /** 173 | * Gets all parsed symbols. Use cautiously due to potentially large size. 174 | * @returns {Array} A copy of the parsed symbols array. 175 | */ 176 | export function getAllParsedSymbols() { 177 | return [...allParsedSymbols]; 178 | } 179 | 180 | /** 181 | * Clears the stored symbols. Called when data is cleared or protocol changes. 182 | */ 183 | export function clearParsedSymbols() { 184 | allParsedSymbols = []; 185 | console.log("Cleared stored ELF symbols."); 186 | } 187 | 188 | /** 189 | * Gets the initialization status of the Wasm module. 190 | * @returns {boolean} 191 | */ 192 | export function isWasmReady() { 193 | return isWasmInitialized; 194 | } -------------------------------------------------------------------------------- /js/modules/quat_module.js: -------------------------------------------------------------------------------- 1 | // js/modules/quat_module.js 2 | // Handles dynamic channel updates on select click and initial channel check. 3 | 4 | // --- Module State --- 5 | export let threeRenderer = null; 6 | export let threeCamera = null; 7 | let threeScene, threeObject, threeAxesHelper, threeOrbitControls; 8 | let lastValidQuaternion = null; 9 | let quatAnimationRequest = null; 10 | let isInitialized = false; 11 | let isChannelSelectionConfirmed = false; 12 | 13 | // DOM Elements 14 | let containerElement = null; 15 | let quatViewDivElement = null; 16 | let quatDataErrorOverlayElement = null; 17 | let selectorOverlayElement = null; 18 | let wSelectElement = null; 19 | let xSelectElement = null; 20 | let ySelectElement = null; 21 | let zSelectElement = null; 22 | let confirmBtnElement = null; 23 | let reSelectBtnElement = null; 24 | let selectorErrorElement = null; 25 | let selectorInfoElement = null; // Optional: For messages like "Need 4 channels" 26 | 27 | let internalConfig = { 28 | selectedChannels: { w: null, x: null, y: null, z: null }, 29 | numChannels: 1, 30 | }; 31 | 32 | // --- Internal Helpers --- 33 | function updateQuaternionViewInternal(w, x, y, z) { 34 | if (!threeObject || !lastValidQuaternion) return; 35 | lastValidQuaternion.set(x, y, z, w).normalize(); 36 | threeObject.setRotationFromQuaternion(lastValidQuaternion); 37 | } 38 | 39 | function animateQuaternion() { 40 | if (!isInitialized) return; 41 | quatAnimationRequest = requestAnimationFrame(animateQuaternion); 42 | if (!threeRenderer || !threeScene || !threeCamera) return; 43 | threeOrbitControls?.update(); 44 | threeRenderer.render(threeScene, threeCamera); 45 | } 46 | 47 | function setupScene() { 48 | threeScene = new THREE.Scene(); 49 | threeScene.background = new THREE.Color(0xe5e7eb); 50 | const geometry = new THREE.BoxGeometry(0.8, 1.2, 0.4); 51 | const materials = [ 52 | /* ... 6 materials ... */ 53 | new THREE.MeshStandardMaterial({ color: 0xff0000, name: "X+" }), 54 | new THREE.MeshStandardMaterial({ color: 0xffa500, name: "X-" }), 55 | new THREE.MeshStandardMaterial({ color: 0x00ff00, name: "Y+" }), 56 | new THREE.MeshStandardMaterial({ color: 0x0000ff, name: "Y-" }), 57 | new THREE.MeshStandardMaterial({ color: 0xffffff, name: "Z+" }), 58 | new THREE.MeshStandardMaterial({ color: 0x808080, name: "Z-" }), 59 | ]; 60 | threeObject = new THREE.Mesh(geometry, materials); 61 | threeScene.add(threeObject); 62 | threeAxesHelper = new THREE.AxesHelper(1.5); 63 | threeScene.add(threeAxesHelper); 64 | const ambientLight = new THREE.AmbientLight(0xffffff, 0.7); 65 | threeScene.add(ambientLight); 66 | const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9); 67 | directionalLight.position.set(1, 2, 1.5).normalize(); 68 | threeScene.add(directionalLight); 69 | } 70 | 71 | // --- Internal Helpers (Modified/New) --- 72 | 73 | function populateSelectors() { 74 | const numChannels = internalConfig.numChannels; 75 | const selects = [ 76 | wSelectElement, 77 | xSelectElement, 78 | ySelectElement, 79 | zSelectElement, 80 | ]; 81 | if (selects.some((el) => !el)) return; 82 | 83 | // Preserve *currently selected* values before clearing 84 | const currentValues = { 85 | w: wSelectElement.value, 86 | x: xSelectElement.value, 87 | y: ySelectElement.value, 88 | z: zSelectElement.value, 89 | }; 90 | 91 | const defaultOption = ``; 92 | let options = defaultOption; 93 | for (let i = 0; i < numChannels; i++) { 94 | options += ``; 95 | } 96 | 97 | selects.forEach((sel, index) => { 98 | const key = ["w", "x", "y", "z"][index]; 99 | // Store scroll position before changing innerHTML (might help reduce flicker) 100 | // const currentScrollTop = sel.scrollTop; 101 | sel.innerHTML = options; 102 | // Restore selection if it's still a valid channel index 103 | if ( 104 | currentValues[key] !== "" && 105 | parseInt(currentValues[key]) < numChannels 106 | ) { 107 | sel.value = currentValues[key]; 108 | } else { 109 | sel.value = ""; // Reset if invalid 110 | } 111 | // Restore scroll position (experimental, may not be needed) 112 | // sel.scrollTop = currentScrollTop; 113 | }); 114 | 115 | // Update button state after repopulating 116 | handleInternalSelectionChange(false); // Pass false to avoid clearing error message here 117 | } 118 | 119 | function handleInternalSelectionChange(clearError = true) { 120 | const allSelected = 121 | wSelectElement?.value !== "" && 122 | xSelectElement?.value !== "" && 123 | ySelectElement?.value !== "" && 124 | zSelectElement?.value !== ""; 125 | 126 | const minChannelsMet = internalConfig.numChannels >= 4; 127 | 128 | if (confirmBtnElement) { 129 | // Enable confirm only if >= 4 channels available AND all are selected 130 | confirmBtnElement.disabled = !allSelected || !minChannelsMet; 131 | } 132 | if (selectorErrorElement && clearError) { 133 | selectorErrorElement.style.display = "none"; 134 | selectorErrorElement.textContent = ""; 135 | } 136 | // Update info message 137 | if (selectorInfoElement) { 138 | selectorInfoElement.textContent = minChannelsMet 139 | ? "请为 W, X, Y, Z 分配唯一的可用通道。" 140 | : "需要至少 4 个可用通道才能选择。"; 141 | selectorInfoElement.style.color = minChannelsMet ? "#4b5563" : "#dc2626"; // Grey or Red 142 | } 143 | } 144 | 145 | function handleConfirmSelection() { 146 | if ( 147 | !wSelectElement || 148 | !xSelectElement || 149 | !ySelectElement || 150 | !zSelectElement || 151 | !selectorErrorElement 152 | ) 153 | return; 154 | 155 | // Ensure at least 4 channels are available before confirming 156 | if (internalConfig.numChannels < 4) { 157 | selectorErrorElement.textContent = "可用通道不足 4 个,无法确认。"; 158 | selectorErrorElement.style.display = "block"; 159 | confirmBtnElement.disabled = true; 160 | return; 161 | } 162 | 163 | const w = wSelectElement.value === "" ? null : parseInt(wSelectElement.value); 164 | const x = xSelectElement.value === "" ? null : parseInt(xSelectElement.value); 165 | const y = ySelectElement.value === "" ? null : parseInt(ySelectElement.value); 166 | const z = zSelectElement.value === "" ? null : parseInt(zSelectElement.value); 167 | 168 | const hasAllIndices = w !== null && x !== null && y !== null && z !== null; 169 | if (!hasAllIndices) { 170 | selectorErrorElement.textContent = "所有 W, X, Y, Z 通道都必须选择。"; 171 | selectorErrorElement.style.display = "block"; 172 | return; 173 | } 174 | 175 | const selectionSet = new Set([w, x, y, z]); 176 | if (selectionSet.size !== 4) { 177 | selectorErrorElement.textContent = "W, X, Y, Z 必须选择不同的通道。"; 178 | selectorErrorElement.style.display = "block"; 179 | return; 180 | } 181 | 182 | // Selection is valid 183 | internalConfig.selectedChannels = { w, x, y, z }; 184 | isChannelSelectionConfirmed = true; 185 | selectorErrorElement.style.display = "none"; 186 | if (selectorOverlayElement) selectorOverlayElement.style.display = "none"; 187 | if (reSelectBtnElement) reSelectBtnElement.style.display = "inline-flex"; 188 | if (quatDataErrorOverlayElement) 189 | quatDataErrorOverlayElement.style.display = "none"; 190 | 191 | console.log( 192 | "Quaternion channels confirmed:", 193 | internalConfig.selectedChannels 194 | ); 195 | } 196 | 197 | function handleShowSelectorOverlay() { 198 | if (selectorOverlayElement) { 199 | populateSelectors(); // Refresh options when button is clicked 200 | selectorOverlayElement.style.display = "flex"; 201 | } 202 | if (reSelectBtnElement) reSelectBtnElement.style.display = "none"; 203 | isChannelSelectionConfirmed = false; 204 | handleInternalSelectionChange(); // Update button state based on current selections 205 | } 206 | 207 | // Define the handler that refreshes selectors on click/focus 208 | function handleDropdownInteraction() { 209 | console.log("Dropdown interaction, repopulating selectors..."); 210 | populateSelectors(); 211 | } 212 | 213 | // --- Display Module Interface Implementation --- 214 | 215 | export function create(elementId, initialState = {}) { 216 | if (isInitialized) return true; 217 | containerElement = document.getElementById(elementId); 218 | if (!containerElement) { 219 | console.error(`Quat Module: Container #${elementId} not found.`); 220 | return false; 221 | } 222 | 223 | // Find all elements 224 | quatViewDivElement = containerElement.querySelector("#quaternionView"); 225 | quatDataErrorOverlayElement = containerElement.querySelector( 226 | "#quatDataErrorOverlay" 227 | ); 228 | selectorOverlayElement = containerElement.querySelector( 229 | "#quatChannelSelectorOverlay" 230 | ); 231 | wSelectElement = containerElement.querySelector("#quatWChannelInternal"); 232 | xSelectElement = containerElement.querySelector("#quatXChannelInternal"); 233 | ySelectElement = containerElement.querySelector("#quatYChannelInternal"); 234 | zSelectElement = containerElement.querySelector("#quatZChannelInternal"); 235 | confirmBtnElement = containerElement.querySelector( 236 | "#quatConfirmSelectionBtn" 237 | ); 238 | reSelectBtnElement = containerElement.querySelector("#quatReSelectBtn"); 239 | selectorErrorElement = containerElement.querySelector("#quatSelectorError"); 240 | selectorInfoElement = selectorOverlayElement?.querySelector("p.text-xs"); // Get the info paragraph 241 | 242 | if ( 243 | !quatViewDivElement || 244 | !quatDataErrorOverlayElement || 245 | !selectorOverlayElement || 246 | !wSelectElement || 247 | !xSelectElement || 248 | !ySelectElement || 249 | !zSelectElement || 250 | !confirmBtnElement || 251 | !reSelectBtnElement || 252 | !selectorErrorElement || 253 | !selectorInfoElement 254 | ) { 255 | console.error("Quat Module: Could not find all internal elements.",quatViewDivElement,quatDataErrorOverlayElement,selectorOverlayElement,wSelectElement,xSelectElement,ySelectElement,zSelectElement,confirmBtnElement,reSelectBtnElement,selectorErrorElement,selectorInfoElement); 256 | console.log(containerElement); 257 | return false; 258 | } 259 | if (typeof THREE === "undefined") { 260 | console.error("THREE library not loaded."); 261 | return false; 262 | } 263 | 264 | internalConfig = { ...internalConfig, ...initialState }; 265 | lastValidQuaternion = new THREE.Quaternion(); 266 | 267 | try { 268 | // Basic Three.js setup 269 | const width = quatViewDivElement.clientWidth; 270 | const height = quatViewDivElement.clientHeight; 271 | threeCamera = new THREE.PerspectiveCamera( 272 | 75, 273 | width > 0 && height > 0 ? width / height : 1, 274 | 0.1, 275 | 1000 276 | ); 277 | threeCamera.position.set(0, 1.5, 3); 278 | threeRenderer = new THREE.WebGLRenderer({ antialias: true }); 279 | if (width > 0 && height > 0) threeRenderer.setSize(width, height); 280 | while ( 281 | quatViewDivElement.firstChild && 282 | quatViewDivElement.firstChild !== quatDataErrorOverlayElement 283 | ) { 284 | quatViewDivElement.removeChild(quatViewDivElement.firstChild); 285 | } 286 | quatViewDivElement.insertBefore( 287 | threeRenderer.domElement, 288 | quatDataErrorOverlayElement 289 | ); 290 | setupScene(); 291 | if (typeof THREE.OrbitControls === "function") { 292 | /* ... setup OrbitControls ... */ 293 | threeOrbitControls = new THREE.OrbitControls( 294 | threeCamera, 295 | threeRenderer.domElement 296 | ); 297 | threeOrbitControls.enableDamping = true; 298 | threeOrbitControls.dampingFactor = 0.1; 299 | threeOrbitControls.screenSpacePanning = false; 300 | threeOrbitControls.minDistance = 1; 301 | threeOrbitControls.maxDistance = 10; 302 | } 303 | 304 | // Initial UI State & Listeners 305 | populateSelectors(); // Populate based on initial numChannels 306 | confirmBtnElement.addEventListener("click", handleConfirmSelection); 307 | reSelectBtnElement.addEventListener("click", handleShowSelectorOverlay); 308 | // Add listeners for selection change AND interaction (mousedown) 309 | [wSelectElement, xSelectElement, ySelectElement, zSelectElement].forEach( 310 | (sel) => { 311 | sel.addEventListener("change", handleInternalSelectionChange); 312 | sel.addEventListener("mousedown", handleDropdownInteraction); // Refresh on click 313 | // sel.addEventListener('focus', handleDropdownInteraction); // Alternative: refresh on focus 314 | } 315 | ); 316 | 317 | // Initial visibility based on channel count 318 | if (internalConfig.numChannels < 4) { 319 | selectorOverlayElement.style.display = "flex"; 320 | reSelectBtnElement.style.display = "none"; 321 | confirmBtnElement.disabled = true; 322 | isChannelSelectionConfirmed = false; 323 | selectorInfoElement.textContent = "需要至少 4 个可用通道才能选择。"; 324 | selectorInfoElement.style.color = "#dc2626"; // Red 325 | } else { 326 | selectorOverlayElement.style.display = "flex"; // Start visible anyway 327 | reSelectBtnElement.style.display = "none"; 328 | isChannelSelectionConfirmed = false; 329 | handleInternalSelectionChange(); // Set initial button state 330 | } 331 | quatDataErrorOverlayElement.style.display = "none"; 332 | 333 | isInitialized = true; 334 | if (!quatAnimationRequest) animateQuaternion(); 335 | console.log("Quaternion Module Created."); 336 | return true; 337 | } catch (error) { 338 | console.error("Error initializing Quat Module:", error); 339 | destroy(); 340 | return false; 341 | } 342 | } 343 | 344 | export function processDataBatch(batch) { 345 | if (!isInitialized || batch.length === 0) { 346 | return; 347 | } 348 | 349 | // Detect maximum channels in the current batch 350 | let maxChannelsInBatch = 0; 351 | for (const item of batch) { 352 | if (item && Array.isArray(item.values)) { 353 | maxChannelsInBatch = Math.max(maxChannelsInBatch, item.values.length); 354 | } 355 | } 356 | 357 | // Update available channels and UI if the count has changed 358 | if (maxChannelsInBatch > 0 && maxChannelsInBatch !== internalConfig.numChannels) { 359 | internalConfig.numChannels = maxChannelsInBatch; 360 | populateSelectors(); // Refresh dropdowns 361 | 362 | const selections = [ 363 | internalConfig.selectedChannels.w, internalConfig.selectedChannels.x, 364 | internalConfig.selectedChannels.y, internalConfig.selectedChannels.z 365 | ]; 366 | const currentSelectionStillValid = selections.every(idx => idx === null || idx < internalConfig.numChannels); 367 | const minChannelsMet = internalConfig.numChannels >= 4; 368 | 369 | // Force re-selection if current selection is invalid or insufficient channels 370 | if (isChannelSelectionConfirmed && (!currentSelectionStillValid || !minChannelsMet)) { 371 | handleShowSelectorOverlay(); // Shows overlay, resets isChannelSelectionConfirmed 372 | } else { 373 | handleInternalSelectionChange(); // Update overlay UI state if visible 374 | } 375 | } 376 | 377 | // Only proceed with visualization if channels are confirmed 378 | if (!isChannelSelectionConfirmed || !threeObject) { 379 | if (quatDataErrorOverlayElement && !isChannelSelectionConfirmed) { 380 | quatDataErrorOverlayElement.style.display = 'none'; // Hide error if waiting for selection 381 | } 382 | return; 383 | } 384 | 385 | // Process the last data point for visualization 386 | const lastItem = batch[batch.length - 1]; 387 | if (!lastItem || !Array.isArray(lastItem.values)) { 388 | return; 389 | } 390 | 391 | const { values } = lastItem; 392 | const { w, x, y, z } = internalConfig.selectedChannels; 393 | 394 | // Validate selected indices against the current data frame's length 395 | if (w === null || x === null || y === null || z === null || 396 | w >= values.length || x >= values.length || y >= values.length || z >= values.length) 397 | { 398 | if (quatDataErrorOverlayElement) { 399 | quatDataErrorOverlayElement.textContent = "Selected channels invalid for current data frame."; // Keep necessary user message 400 | quatDataErrorOverlayElement.style.display = 'flex'; 401 | } 402 | return; 403 | } 404 | 405 | const wVal = values[w]; 406 | const xVal = values[x]; 407 | const yVal = values[y]; 408 | const zVal = values[z]; 409 | 410 | // Update 3D view if data is valid 411 | if (typeof wVal === 'number' && !isNaN(wVal) && 412 | typeof xVal === 'number' && !isNaN(xVal) && 413 | typeof yVal === 'number' && !isNaN(yVal) && 414 | typeof zVal === 'number' && !isNaN(zVal)) 415 | { 416 | updateQuaternionViewInternal(wVal, xVal, yVal, zVal); 417 | if (quatDataErrorOverlayElement) quatDataErrorOverlayElement.style.display = 'none'; 418 | } else { 419 | if (quatDataErrorOverlayElement) { 420 | quatDataErrorOverlayElement.textContent = "Received invalid (NaN) quaternion data."; // Keep necessary user message 421 | quatDataErrorOverlayElement.style.display = 'flex'; 422 | } 423 | } 424 | } 425 | export function resize() { 426 | if (!isInitialized || !threeRenderer || !threeCamera || !quatViewDivElement) 427 | return; 428 | try { 429 | const width = quatViewDivElement.clientWidth; 430 | const height = quatViewDivElement.clientHeight; 431 | if (width > 0 && height > 0) { 432 | threeCamera.aspect = width / height; 433 | threeCamera.updateProjectionMatrix(); 434 | threeRenderer.setSize(width, height); 435 | } 436 | } catch (e) { 437 | console.warn("Error resizing Quat view:", e); 438 | } 439 | } 440 | 441 | export function updateConfig(newConfig) { 442 | if (!isInitialized) return; 443 | let needsRepopulate = false; 444 | let previouslyConfirmed = isChannelSelectionConfirmed; // Store state before update 445 | 446 | if ( 447 | newConfig.numChannels !== undefined && 448 | newConfig.numChannels !== internalConfig.numChannels 449 | ) { 450 | internalConfig.numChannels = newConfig.numChannels; 451 | needsRepopulate = true; 452 | } 453 | 454 | if (needsRepopulate) { 455 | populateSelectors(); // Repopulate with new channel count 456 | 457 | // Check validity of *current* selections after repopulate 458 | const selections = [ 459 | internalConfig.selectedChannels.w, 460 | internalConfig.selectedChannels.x, 461 | internalConfig.selectedChannels.y, 462 | internalConfig.selectedChannels.z, 463 | ]; 464 | const currentSelectionStillValid = selections.every( 465 | (idx) => idx !== null && idx < internalConfig.numChannels 466 | ); 467 | const minChannelsMet = internalConfig.numChannels >= 4; 468 | 469 | // If previously confirmed, but now invalid OR not enough channels, force re-selection. 470 | if ( 471 | previouslyConfirmed && 472 | (!currentSelectionStillValid || !minChannelsMet) 473 | ) { 474 | console.warn("Available channels changed, forcing re-selection."); 475 | handleShowSelectorOverlay(); // This also sets isChannelSelectionConfirmed = false 476 | } else { 477 | // Just update button state based on new channel count / selections 478 | handleInternalSelectionChange(); 479 | } 480 | } 481 | } 482 | 483 | export function clear() { 484 | if (!isInitialized) return; 485 | if (threeObject && lastValidQuaternion) { 486 | lastValidQuaternion.identity(); 487 | threeObject.setRotationFromQuaternion(lastValidQuaternion); 488 | } 489 | threeOrbitControls?.reset(); 490 | if (quatDataErrorOverlayElement) 491 | quatDataErrorOverlayElement.style.display = "none"; 492 | internalConfig.selectedChannels = { w: null, x: null, y: null, z: null }; 493 | handleShowSelectorOverlay(); 494 | } 495 | 496 | export function destroy() { 497 | if (!isInitialized) return; 498 | isInitialized = false; 499 | if (quatAnimationRequest) cancelAnimationFrame(quatAnimationRequest); 500 | quatAnimationRequest = null; 501 | 502 | // Remove listeners 503 | confirmBtnElement?.removeEventListener("click", handleConfirmSelection); 504 | reSelectBtnElement?.removeEventListener("click", handleShowSelectorOverlay); 505 | [wSelectElement, xSelectElement, ySelectElement, zSelectElement].forEach( 506 | (sel) => { 507 | if (sel) { 508 | sel.removeEventListener("change", handleInternalSelectionChange); 509 | sel.removeEventListener("mousedown", handleDropdownInteraction); 510 | // sel.removeEventListener('focus', handleDropdownInteraction); 511 | } 512 | } 513 | ); 514 | 515 | // Dispose Three.js resources 516 | threeOrbitControls?.dispose(); 517 | if (threeObject) { 518 | /* ... dispose geometry/material ... */ 519 | if (threeObject.geometry) threeObject.geometry.dispose(); 520 | if (threeObject.material) { 521 | if (Array.isArray(threeObject.material)) 522 | threeObject.material.forEach((m) => m?.dispose()); 523 | else threeObject.material?.dispose(); 524 | } 525 | threeScene?.remove(threeObject); 526 | } 527 | if (threeAxesHelper) threeScene?.remove(threeAxesHelper); 528 | threeRenderer?.dispose(); 529 | 530 | // Clear DOM/State refs 531 | containerElement = null; 532 | quatViewDivElement = null; 533 | quatDataErrorOverlayElement = null; 534 | selectorOverlayElement = null; 535 | wSelectElement = null; 536 | xSelectElement = null; 537 | ySelectElement = null; 538 | zSelectElement = null; 539 | confirmBtnElement = null; 540 | reSelectBtnElement = null; 541 | selectorErrorElement = null; 542 | selectorInfoElement = null; 543 | threeRenderer = null; 544 | threeCamera = null; 545 | threeScene = null; 546 | threeObject = null; 547 | threeAxesHelper = null; 548 | threeOrbitControls = null; 549 | lastValidQuaternion = null; 550 | internalConfig = { 551 | selectedChannels: { w: null, x: null, y: null, z: null }, 552 | numChannels: 0, 553 | }; 554 | isChannelSelectionConfirmed = false; 555 | 556 | console.log("Quaternion Module Destroyed."); 557 | } 558 | 559 | console.log("quat_module.js (with internal UI and dynamic refresh) loaded."); 560 | -------------------------------------------------------------------------------- /js/modules/serial.js: -------------------------------------------------------------------------------- 1 | // js/modules/serial.js 2 | import { eventBus } from "../event_bus.js"; 3 | 4 | let serialPort = null; 5 | let isConnectedState = false; 6 | let currentDisconnectHandler = null; 7 | let writableStreamDefaultWriter = null; 8 | 9 | /** 10 | * Attempts to connect to a user-selected serial port. 11 | * @param {object} options - Connection options (baudRate, dataBits, etc.) 12 | * @returns {Promise} True if connection was successful. 13 | */ 14 | async function connect(options) { 15 | if (!("serial" in navigator)) { 16 | eventBus.emit("serial:error", new Error("浏览器不支持 Web Serial API。")); 17 | return false; 18 | } 19 | if (serialPort) { 20 | console.warn( 21 | "SerialService: Connect called while already connected. Disconnecting first." 22 | ); 23 | await disconnect(); 24 | } 25 | 26 | if (!options || !options.baudRate || options.baudRate <= 0) { 27 | eventBus.emit("serial:error", new Error("连接失败:未提供有效的波特率。")); 28 | return false; 29 | } 30 | 31 | try { 32 | eventBus.emit("serial:status", "请求串口权限..."); 33 | const requestedPort = await navigator.serial.requestPort(); 34 | eventBus.emit("serial:status", "正在打开串口..."); 35 | 36 | await requestedPort.open(options); 37 | 38 | serialPort = requestedPort; 39 | isConnectedState = true; 40 | 41 | // --- NEW: Get and store the writer --- 42 | if (serialPort.writable) { 43 | writableStreamDefaultWriter = serialPort.writable.getWriter(); 44 | console.log("SerialService: Writable stream writer obtained."); 45 | } else { 46 | console.warn("SerialService: Port is not writable."); 47 | // This might not be an error if the use case is read-only, 48 | // but for Aresplot, we'll need it. 49 | } 50 | // --- END NEW --- 51 | 52 | console.log("SerialService: Port opened successfully.", options); 53 | eventBus.emit("serial:connected", { portInfo: requestedPort.getInfo() }); 54 | 55 | removeExternalDisconnectListener(); 56 | currentDisconnectHandler = (event) => handleExternalDisconnect(event); 57 | navigator.serial.addEventListener("disconnect", currentDisconnectHandler); 58 | 59 | return true; 60 | } catch (error) { 61 | console.error( 62 | "SerialService: Connection failed:", 63 | error.name, 64 | error.message 65 | ); 66 | let userMessage = `串口连接失败: ${error.message}`; 67 | if (error.name === "NotFoundError") userMessage = "未选择串口。"; 68 | else if (error.name === "InvalidStateError") 69 | userMessage = `串口打开失败 (已被占用?): ${error.message}`; 70 | eventBus.emit("serial:error", new Error(userMessage)); 71 | await cleanupConnectionState(requestedPort); // Pass port to cleanup 72 | return false; 73 | } 74 | } 75 | 76 | /** 77 | * Disconnects the currently connected serial port. 78 | * @returns {Promise} True if disconnection was successful or already disconnected. 79 | */ 80 | async function disconnect() { 81 | const portToClose = serialPort; // Capture current port reference 82 | await cleanupConnectionState(portToClose); // Pass port for specific cleanup 83 | 84 | if (portToClose) { 85 | eventBus.emit("serial:status", "正在关闭串口..."); 86 | try { 87 | // Note: readable might have been transferred, so only close the port itself 88 | await portToClose.close(); 89 | console.log("SerialService: Port closed successfully."); 90 | eventBus.emit("serial:disconnected"); 91 | return true; 92 | } catch (error) { 93 | console.warn(`SerialService: Error closing port: ${error.message}`); 94 | eventBus.emit( 95 | "serial:error", 96 | new Error(`关闭串口时出错: ${error.message}`) 97 | ); 98 | return false; 99 | } 100 | } 101 | return true; // Already disconnected 102 | } 103 | 104 | /** 105 | * Writes data to the connected serial port. 106 | * @param {Uint8Array|ArrayBuffer} data - The data to write. 107 | * @returns {Promise} 108 | * @throws {Error} if not connected, port not writable, or write fails. 109 | */ 110 | async function write(data) { 111 | if (!isConnectedState || !serialPort || !serialPort.writable) { 112 | throw new Error("SerialService: Not connected or port not writable."); 113 | } 114 | if (!writableStreamDefaultWriter) { 115 | // Attempt to get writer again if it wasn't obtained during connect or was released 116 | try { 117 | writableStreamDefaultWriter = serialPort.writable.getWriter(); 118 | console.log("SerialService: Re-acquired writable stream writer."); 119 | } catch (e) { 120 | throw new Error(`SerialService: Failed to get writer: ${e.message}`); 121 | } 122 | } 123 | 124 | try { 125 | await writableStreamDefaultWriter.write(data); 126 | // console.debug("SerialService: Data written successfully:", data); 127 | } catch (error) { 128 | console.error("SerialService: Error writing data:", error); 129 | // Attempt to gracefully handle writer errors, potentially by releasing and trying to reacquire next time 130 | try { 131 | writableStreamDefaultWriter.releaseLock(); 132 | writableStreamDefaultWriter = null; 133 | console.warn( 134 | "SerialService: Writer released due to write error. Will attempt to reacquire on next write." 135 | ); 136 | } catch (releaseError) { 137 | console.error( 138 | "SerialService: Error releasing writer after write error:", 139 | releaseError 140 | ); 141 | } 142 | throw new Error(`SerialService: Write failed: ${error.message}`); 143 | } 144 | } 145 | 146 | /** 147 | * Checks if currently connected to a serial port. 148 | * @returns {boolean} 149 | */ 150 | function isConnected() { 151 | return isConnectedState && serialPort !== null; 152 | } 153 | 154 | /** 155 | * Gets the readable stream of the current port. 156 | * @returns {ReadableStream | null} 157 | */ 158 | function getReadableStream() { 159 | if (serialPort && serialPort.readable && !serialPort.readable.locked) { 160 | // Check if not locked 161 | return serialPort.readable; 162 | } 163 | if (serialPort && serialPort.readable && serialPort.readable.locked) { 164 | console.warn("SerialService: ReadableStream is locked. Cannot transfer."); 165 | } 166 | return null; 167 | } 168 | 169 | /** 170 | * (Internal) Gets the raw SerialPort object. Use with caution. 171 | * Needed by main.js to potentially re-acquire writer if it gets into a bad state, 172 | * or for advanced operations. 173 | * @returns {SerialPort | null} 174 | */ 175 | function getInternalPortReference() { 176 | return serialPort; 177 | } 178 | 179 | // --- Internal helper functions --- 180 | 181 | function handleExternalDisconnect(event) { 182 | if (serialPort && event.target === serialPort) { 183 | console.warn( 184 | "SerialService: External disconnect event for the connected port." 185 | ); 186 | cleanupConnectionState(serialPort); // Pass the port that disconnected 187 | eventBus.emit("serial:disconnected", { external: true }); 188 | } 189 | } 190 | 191 | function removeExternalDisconnectListener() { 192 | if (currentDisconnectHandler) { 193 | navigator.serial.removeEventListener( 194 | "disconnect", 195 | currentDisconnectHandler 196 | ); 197 | currentDisconnectHandler = null; 198 | } 199 | } 200 | 201 | /** 202 | * Cleans up internal connection state and releases resources. 203 | * @param {SerialPort | null} portInstance - The specific port instance to clean up resources for. 204 | */ 205 | async function cleanupConnectionState(portInstance) { 206 | removeExternalDisconnectListener(); // General listener removal 207 | 208 | // Release writer specifically for the portInstance if it matches the active one 209 | if ( 210 | writableStreamDefaultWriter && 211 | portInstance && 212 | serialPort === portInstance 213 | ) { 214 | try { 215 | // Check if port is still open before trying to abort/release 216 | if (portInstance.writable) { 217 | // Check if writable exists (might be null if port closed abruptly) 218 | await writableStreamDefaultWriter 219 | .abort() 220 | .catch((e) => 221 | console.warn( 222 | "SerialService: Error aborting writer during cleanup:", 223 | e 224 | ) 225 | ); 226 | } 227 | } catch (e) { 228 | console.warn( 229 | "SerialService: Exception during writer abort in cleanup (port might be already closed):", 230 | e 231 | ); 232 | } finally { 233 | try { 234 | // The lock might be released by abort, or if not, try to release it. 235 | // This can error if already released or if the stream is broken. 236 | if ( 237 | writableStreamDefaultWriter && 238 | typeof writableStreamDefaultWriter.releaseLock === "function" 239 | ) { 240 | writableStreamDefaultWriter.releaseLock(); 241 | } 242 | } catch (e) { 243 | console.warn( 244 | "SerialService: Exception during writer releaseLock in cleanup:", 245 | e 246 | ); 247 | } 248 | writableStreamDefaultWriter = null; 249 | console.log("SerialService: Writable stream writer released and nulled."); 250 | } 251 | } else if (writableStreamDefaultWriter && !portInstance) { 252 | // If called without a specific port (e.g., general disconnect), clear the global writer 253 | writableStreamDefaultWriter = null; 254 | } 255 | 256 | // If cleaning up the active port, reset global state 257 | if (portInstance && serialPort === portInstance) { 258 | serialPort = null; 259 | isConnectedState = false; 260 | } else if (!portInstance) { 261 | // General cleanup if no specific port given 262 | serialPort = null; 263 | isConnectedState = false; 264 | } 265 | // console.log("SerialService: Connection state potentially cleaned up."); 266 | } 267 | 268 | // Export public interface 269 | export { 270 | connect, 271 | disconnect, 272 | write, 273 | isConnected, 274 | getReadableStream, 275 | getInternalPortReference, 276 | }; 277 | -------------------------------------------------------------------------------- /js/modules/terminal_module.js: -------------------------------------------------------------------------------- 1 | // js/modules/terminal_module.js 2 | // Manages the xterm.js terminal, parsed data display, and raw output buffering. 3 | 4 | // Target update interval for the raw output terminal in milliseconds (e.g., 100ms = ~10 FPS) 5 | import { TERMINAL_UPDATE_INTERVAL_MS } from "../config.js"; 6 | 7 | // Module state 8 | let terminalInstance = null; 9 | let fitAddonInstance = null; 10 | let rawStrBtnElement = null; 11 | let rawHexBtnElement = null; 12 | let terminalEncodingSelectElement = null; // NEW: Reference to the encoding select dropdown 13 | let parsedDataDisplayElement = null; 14 | let isInitialized = false; 15 | let textDecoder = null; // For decoding raw bytes 16 | let currentEncoding = "utf-8"; // Default encoding, will be updated by recommendation 17 | 18 | // Configuration and state for buffering/updates 19 | let internalConfig = { 20 | rawDisplayMode: "str", // Initial display mode 21 | }; 22 | let rawOutputBuffer = ""; // Buffer for raw terminal lines 23 | let lastTerminalWriteTime = 0; // Timestamp of last terminal write 24 | 25 | // --- Internal Helper Functions --- 26 | 27 | function updateParsedDataDisplayInternal(latestValues) { 28 | if (!parsedDataDisplayElement) return; 29 | if (!latestValues || latestValues.length === 0) { 30 | parsedDataDisplayElement.innerHTML = `Waiting for data...`; 31 | return; 32 | } 33 | let htmlContent = ""; 34 | latestValues.forEach((value, index) => { 35 | let displayValue = "N/A"; 36 | if (typeof value === "number" && isFinite(value)) { 37 | displayValue = value.toFixed(3); 38 | } else if (isNaN(value)) { 39 | displayValue = "NaN"; 40 | } 41 | htmlContent += `
ch${ 42 | index + 1 43 | }:${displayValue}
`; 44 | }); 45 | parsedDataDisplayElement.innerHTML = htmlContent; 46 | } 47 | 48 | function handleInternalFormatChange(newMode) { 49 | if (internalConfig.rawDisplayMode !== newMode) { 50 | flushRawOutputBuffer(); // Flush buffer before changing mode 51 | internalConfig.rawDisplayMode = newMode; 52 | rawStrBtnElement?.classList.toggle("active", newMode === "str"); 53 | rawHexBtnElement?.classList.toggle("active", newMode === "hex"); 54 | 55 | 56 | if (terminalInstance) 57 | terminalInstance.write( 58 | `\r\n--- Display Mode Changed to ${internalConfig.rawDisplayMode.toUpperCase()} ---\r\n` 59 | ); 60 | } 61 | } 62 | 63 | // --- Encoding Handling --- 64 | 65 | // Tries to recommend an encoding based on browser language 66 | function getRecommendedEncoding() { 67 | const lang = (navigator.language || navigator.userLanguage || "").toLowerCase(); 68 | if (lang.startsWith("zh")) return "gbk"; // Simplified Chinese 69 | if (lang.startsWith("ja")) return "shift_jis"; // Japanese 70 | if (lang.startsWith("ko")) return "euc-kr"; // Korean 71 | // Default to UTF-8 for others 72 | return "utf-8"; 73 | } 74 | 75 | // Handles changing the active text encoding based on dropdown selection 76 | function handleEncodingChange() { 77 | if (!terminalEncodingSelectElement) return; 78 | const newEncoding = terminalEncodingSelectElement.value; 79 | console.log(`Attempting to change encoding to: ${newEncoding}`); 80 | 81 | try { 82 | // Test if the encoding is valid by creating a temporary decoder 83 | new TextDecoder(newEncoding); 84 | 85 | // Update the main TextDecoder instance 86 | textDecoder = new TextDecoder(newEncoding, { fatal: false }); // Use fatal: false to avoid throwing on errors 87 | currentEncoding = newEncoding; // Update state only if valid 88 | console.log(`TextDecoder updated to ${currentEncoding}`); 89 | 90 | // Ensure display mode is 'str' when changing encoding 91 | if (internalConfig.rawDisplayMode !== "str") { 92 | handleInternalFormatChange("str"); // Switch to text view (will also log the mode change) 93 | } else { 94 | // If already in 'str' mode, just log the encoding change 95 | if (terminalInstance) { 96 | terminalInstance.write( 97 | `\r\n--- Text Encoding Changed to ${currentEncoding.toUpperCase()} ---\r\n` 98 | ); 99 | } 100 | } 101 | 102 | } catch (error) { 103 | console.error(`Failed to set encoding ${newEncoding}:`, error); 104 | // Revert the dropdown selection back to the previous valid encoding 105 | terminalEncodingSelectElement.value = currentEncoding; 106 | if (terminalInstance) { 107 | terminalInstance.write( 108 | `\r\n--- Error: Invalid encoding '${newEncoding}'. Reverted to ${currentEncoding.toUpperCase()}. ---\r\n` 109 | ); 110 | } 111 | } 112 | } 113 | 114 | 115 | // Bound event handlers for STR/HEX mode buttons 116 | let boundStrModeHandler = () => handleInternalFormatChange("str"); // STR button changes display mode to text 117 | let boundHexModeHandler = () => handleInternalFormatChange("hex"); // HEX button changes display mode to hex 118 | 119 | // Helper to flush the raw output buffer immediately 120 | function flushRawOutputBuffer() { 121 | if (terminalInstance && rawOutputBuffer.length > 0) { 122 | try { 123 | // Clean up consecutive newlines before writing 124 | const cleanedBuffer = rawOutputBuffer.replace( 125 | /(?:\r?\n\s*){2,}/g, 126 | "\r\n" 127 | ); 128 | terminalInstance.write(cleanedBuffer); 129 | lastTerminalWriteTime = performance.now(); // Update time as we just wrote 130 | } catch (e) { 131 | console.warn("Error flushing terminal buffer:", e); 132 | } 133 | } 134 | rawOutputBuffer = ""; // Always clear buffer after attempting flush 135 | } 136 | 137 | // --- Display Module Interface Implementation --- 138 | 139 | export function create(elementId, initialState = {}) { 140 | if (isInitialized) return true; 141 | const containerElement = document.getElementById(elementId); 142 | if (!containerElement) { 143 | console.error(`Terminal Module: Container #${elementId} not found.`); 144 | return false; 145 | } 146 | 147 | // Find internal elements 148 | const targetDiv = containerElement.querySelector("#terminal"); 149 | parsedDataDisplayElement = 150 | containerElement.querySelector("#parsedDataDisplay"); 151 | rawStrBtnElement = containerElement.querySelector("#rawStrBtn"); 152 | rawHexBtnElement = containerElement.querySelector("#rawHexBtn"); 153 | terminalEncodingSelectElement = containerElement.querySelector("#terminalEncodingSelect"); // Get the select element 154 | 155 | if ( 156 | !targetDiv || 157 | !parsedDataDisplayElement || 158 | !rawStrBtnElement || 159 | !rawHexBtnElement || 160 | !terminalEncodingSelectElement // Check for the select element 161 | ) { 162 | console.error("Terminal Module: Could not find all required internal elements."); 163 | return false; 164 | } 165 | const Terminal = window.Terminal; 166 | const FitAddon = window.FitAddon?.FitAddon; 167 | if (!Terminal || !FitAddon) { 168 | console.error("xterm.js or FitAddon library not loaded."); 169 | return false; 170 | } 171 | 172 | // Merge initial state 173 | internalConfig = { ...internalConfig, ...initialState }; 174 | 175 | // Initialize state variables 176 | rawOutputBuffer = ""; 177 | lastTerminalWriteTime = 0; 178 | // Set initial encoding based on recommendation 179 | const recommendedEncoding = getRecommendedEncoding(); 180 | currentEncoding = recommendedEncoding; 181 | terminalEncodingSelectElement.value = currentEncoding; // Set dropdown to recommended value 182 | 183 | try { 184 | textDecoder = new TextDecoder(currentEncoding, { fatal: false }); // Initialize with recommended encoding 185 | } catch (e) { 186 | console.error(`Failed to initialize TextDecoder with recommended encoding ${currentEncoding}:`, e); 187 | currentEncoding = "utf-8"; // Fallback to UTF-8 188 | terminalEncodingSelectElement.value = currentEncoding; 189 | textDecoder = new TextDecoder(currentEncoding, { fatal: false }); 190 | } 191 | 192 | try { 193 | terminalInstance = new Terminal({ 194 | fontFamily: 'Consolas, "Liberation Mono", Menlo, Courier, monospace', 195 | fontSize: 13, 196 | theme: { 197 | background: "#FFFFFF", 198 | foreground: "#000000", 199 | cursor: "#000000", 200 | selectionBackground: "#A9A9A9", 201 | }, 202 | cursorBlink: false, 203 | convertEol: true, 204 | scrollback: 5000, 205 | disableStdin: true, 206 | windowsMode: false, 207 | }); 208 | fitAddonInstance = new FitAddon(); 209 | terminalInstance.loadAddon(fitAddonInstance); 210 | terminalInstance.open(targetDiv); 211 | fitAddonInstance.fit(); 212 | terminalInstance.write(`Terminal Initialized.\r\n`); 213 | updateParsedDataDisplayInternal([]); 214 | // Initial UI state: STR active, HEX inactive, encoding select visible 215 | rawStrBtnElement.classList.add("active"); 216 | rawHexBtnElement.classList.remove("active"); 217 | terminalEncodingSelectElement.parentElement.style.display = ''; // Ensure encoding select is visible initially 218 | 219 | // Set up buttons and select dropdown 220 | rawStrBtnElement.addEventListener("click", boundStrModeHandler); // Use new handler 221 | rawHexBtnElement.addEventListener("click", boundHexModeHandler); // Use new handler 222 | terminalEncodingSelectElement.addEventListener("change", handleEncodingChange); // Listen for dropdown changes 223 | 224 | isInitialized = true; 225 | console.log( 226 | `Terminal Module Created (Time-based Update: ~${( 227 | 1000 / TERMINAL_UPDATE_INTERVAL_MS 228 | ).toFixed(0)} FPS).` 229 | ); 230 | return true; 231 | } catch (error) { 232 | console.error("Error initializing xterm.js:", error); 233 | isInitialized = false; 234 | terminalInstance = null; 235 | fitAddonInstance = null; 236 | return false; 237 | } 238 | } 239 | 240 | export function processDataBatch(batch) { 241 | if (!isInitialized || batch.length === 0) return; 242 | const now = performance.now(); 243 | 244 | // --- Update parsed data display (always) --- 245 | let latestValuesForDisplay = null; 246 | for (let i = batch.length - 1; i >= 0; i--) { 247 | if (batch[i] && Array.isArray(batch[i].values)) { 248 | latestValuesForDisplay = batch[i].values; 249 | break; 250 | } 251 | } 252 | if (latestValuesForDisplay !== null) { 253 | updateParsedDataDisplayInternal(latestValuesForDisplay); 254 | } 255 | 256 | // --- Format and buffer raw output (always) --- 257 | for (const item of batch) { 258 | if (!item || typeof item.timestamp !== "number") continue; 259 | const { timestamp, values, rawLineBytes } = item; 260 | const displayTimeStr = (timestamp / 1000.0).toFixed(3); 261 | let displayLine = ""; 262 | 263 | if (rawLineBytes instanceof Uint8Array && rawLineBytes.byteLength > 0) { 264 | // --- Display based on mode --- 265 | if (internalConfig.rawDisplayMode === "hex") { 266 | // Show HEX button now that we have raw bytes (if not already visible) 267 | if (rawHexBtnElement?.classList.contains("hidden")) { 268 | rawHexBtnElement.classList.remove("hidden"); 269 | } 270 | displayLine = Array.prototype.map 271 | .call(rawLineBytes, (b) => 272 | b.toString(16).toUpperCase().padStart(2, "0") 273 | ) 274 | .join(" "); 275 | } else { 276 | // STR mode (uses currentEncoding via textDecoder) 277 | try { 278 | // Ensure textDecoder is initialized and using the current encoding 279 | if (!textDecoder) { 280 | try { 281 | textDecoder = new TextDecoder(currentEncoding, { fatal: false }); 282 | } catch (err) { 283 | console.error(`Failed to initialize TextDecoder with ${currentEncoding}, falling back to utf-8`, err); 284 | currentEncoding = 'utf-8'; 285 | terminalEncodingSelectElement.value = currentEncoding; // Update dropdown if fallback occurs 286 | textDecoder = new TextDecoder(currentEncoding, { fatal: false }); 287 | } 288 | } 289 | let decodedString = textDecoder 290 | .decode(rawLineBytes, { stream: false }) // Use stream: false for complete lines 291 | .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "."); // Replace non-printable chars 292 | displayLine = decodedString.trimEnd(); // Remove trailing whitespace/newlines from source data 293 | } catch (e) { 294 | console.warn(`Decoding error with ${currentEncoding}:`, e); 295 | displayLine = "[Decode Error]"; 296 | // No automatic fallback here, user must select a working encoding 297 | } 298 | } 299 | } else if (Array.isArray(values) && values.length > 0) { 300 | displayLine = values 301 | .map((v) => 302 | v === null || v === undefined 303 | ? "N/A" 304 | : isNaN(v) 305 | ? "NaN" 306 | : typeof v === "number" 307 | ? v.toFixed(3) 308 | : String(v) 309 | ) 310 | .join(", "); 311 | } else { 312 | displayLine = "[No Data]"; 313 | } 314 | // Append formatted line with timestamp and module-added newline to buffer 315 | rawOutputBuffer += `${displayTimeStr}: ${displayLine}\r\n`; 316 | } 317 | 318 | // --- Write buffer to terminal based on time interval --- 319 | if ( 320 | terminalInstance && 321 | rawOutputBuffer.length > 0 && 322 | now - lastTerminalWriteTime >= TERMINAL_UPDATE_INTERVAL_MS 323 | ) { 324 | // Clean up consecutive newlines before writing 325 | const cleanedBuffer = rawOutputBuffer.replace(/(?:\r?\n\s*){2,}/g, "\r\n"); 326 | terminalInstance.write(cleanedBuffer); 327 | rawOutputBuffer = ""; // Clear buffer 328 | lastTerminalWriteTime = now; // Update last write time 329 | // terminalInstance.scrollToBottom(); // Optional scroll 330 | } 331 | } 332 | 333 | export function resize() { 334 | if (!fitAddonInstance || !isInitialized) return; 335 | try { 336 | fitAddonInstance.fit(); 337 | } catch (e) { 338 | console.warn("Terminal fit error:", e); 339 | } 340 | } 341 | 342 | export function updateConfig(newConfig) { 343 | if (!isInitialized) return; 344 | // Handle only rawDisplayMode changes now 345 | if ( 346 | newConfig.rawDisplayMode !== undefined && 347 | newConfig.rawDisplayMode !== internalConfig.rawDisplayMode 348 | ) { 349 | handleInternalFormatChange(newConfig.rawDisplayMode); // Use the handler which includes flushing 350 | } 351 | // updateDivider logic removed 352 | } 353 | 354 | export function clear() { 355 | if (!isInitialized) return; 356 | if (terminalInstance) { 357 | terminalInstance.clear(); 358 | terminalInstance.write("Terminal cleared.\r\n"); 359 | } 360 | updateParsedDataDisplayInternal([]); 361 | // batchCounter removed 362 | } 363 | 364 | export function destroy() { 365 | if (!isInitialized) return; 366 | flushRawOutputBuffer(); // Flush pending output first 367 | 368 | isInitialized = false; 369 | 370 | // Remove event listeners 371 | if (rawStrBtnElement) { 372 | rawStrBtnElement.removeEventListener("click", boundStrModeHandler); 373 | rawStrBtnElement = null; 374 | } 375 | if (rawHexBtnElement) { 376 | rawHexBtnElement.removeEventListener("click", boundHexModeHandler); 377 | rawHexBtnElement = null; 378 | } 379 | if (terminalEncodingSelectElement) { 380 | terminalEncodingSelectElement.removeEventListener("change", handleEncodingChange); 381 | terminalEncodingSelectElement = null; 382 | } 383 | 384 | // Dispose terminal 385 | if (terminalInstance) { 386 | terminalInstance.dispose(); 387 | } 388 | 389 | // Reset state variables 390 | terminalInstance = null; 391 | fitAddonInstance = null; 392 | parsedDataDisplayElement = null; 393 | textDecoder = null; 394 | rawOutputBuffer = ""; 395 | lastTerminalWriteTime = 0; 396 | currentEncoding = "utf-8"; // Reset to default 397 | internalConfig = { rawDisplayMode: "str" }; // Reset config 398 | 399 | console.log("Terminal Module Destroyed."); 400 | } 401 | 402 | console.log("terminal_module.js (Time-based Update, Buffering) loaded."); 403 | -------------------------------------------------------------------------------- /js/modules/worker_service.js: -------------------------------------------------------------------------------- 1 | // js/modules/worker_service.js 2 | import { eventBus } from "../event_bus.js"; 3 | 4 | let worker = null; 5 | let workerUrl = null; // 用于释放 Object URL 6 | 7 | /** 8 | * Initializes the Worker Service, creating the Web Worker as an ES module. 9 | * @returns {Promise} True if initialization was successful. 10 | */ 11 | async function init() { 12 | if (worker) { 13 | console.warn("WorkerService already initialized."); 14 | return true; 15 | } 16 | try { 17 | const workerScriptPath = 'js/worker/data_worker.js'; 18 | console.log(`WorkerService: Attempting to load worker from: ${workerScriptPath}`); 19 | worker = new Worker(workerScriptPath, { type: "module" }); 20 | worker.onmessage = (event) => { 21 | const { type, payload } = event.data; 22 | // ... (rest of existing onmessage handler) 23 | switch (type) { 24 | case "dataBatch": 25 | eventBus.emit("worker:dataBatch", payload); 26 | break; 27 | case "status": // General status messages from worker 28 | eventBus.emit("worker:status", payload); 29 | break; 30 | case "info": // For less critical info messages, e.g., timestamp sync 31 | eventBus.emit("worker:info", payload); // You might need to add listener in main.js 32 | break; 33 | case "warn": // For warnings 34 | eventBus.emit("worker:warn", payload); 35 | break; 36 | case "error": 37 | console.error("Worker reported error:", payload); 38 | eventBus.emit("worker:error", new Error(typeof payload === 'string' ? payload : payload.message || "Unknown worker error")); 39 | break; 40 | default: 41 | console.log("WorkerService received unhandled message type from worker:", type, payload); 42 | } 43 | }; 44 | 45 | worker.onerror = (error) => { 46 | console.error("Unhandled Worker Error Event in WorkerService:", error); 47 | eventBus.emit("worker:error", error); 48 | }; 49 | 50 | console.log("WorkerService initialized, Worker (as module) created."); 51 | return true; 52 | } catch (error) { 53 | console.error("WorkerService initialization failed:", error); 54 | eventBus.emit("worker:error", new Error(`Worker initialization failed: ${error.message}`)); 55 | if (workerUrl) { 56 | URL.revokeObjectURL(workerUrl); // Clean up blob URL if worker creation failed 57 | workerUrl = null; 58 | } 59 | worker = null; 60 | return false; 61 | } 62 | } 63 | 64 | /** 65 | * 向 Worker 发送启动指令。 66 | * @param {object} config - 启动配置 (包含 source, config, protocol, parserCode, readableStream 等) 67 | */ 68 | function startWorker(config) { 69 | if (!worker) { 70 | console.error("WorkerService: Cannot start, worker not initialized."); 71 | return; 72 | } 73 | console.log("WorkerService: Sending start command to worker:", config); 74 | try { 75 | if (config.source === "webserial" && config.readableStream) { 76 | // 传输 ReadableStream 77 | worker.postMessage({ type: "startSerialStream", payload: config }, [ 78 | config.readableStream, 79 | ]); 80 | } else if (config.source === "simulated") { 81 | // 模拟数据不需要传输 82 | worker.postMessage({ type: "start", payload: config }); 83 | } else { 84 | console.error("WorkerService: Invalid start config", config); 85 | } 86 | } catch (error) { 87 | console.error("WorkerService: Error sending start message:", error); 88 | eventBus.emit( 89 | "worker:error", 90 | new Error(`Failed to send start message: ${error.message}`) 91 | ); 92 | } 93 | } 94 | 95 | /** 96 | * 向 Worker 发送停止指令。 97 | */ 98 | async function stopWorker() { 99 | if (!worker) { 100 | console.warn( 101 | "WorkerService: Cannot stop, worker not initialized or already terminated." 102 | ); 103 | return; 104 | } 105 | console.log("WorkerService: Sending stop command to worker."); 106 | await worker.postMessage({ type: "stop" }); 107 | } 108 | 109 | /** 110 | * 向 Worker 发送更新解析器的指令。 111 | * @param {string} protocol - 新协议名称 112 | * @param {string} [code] - 自定义协议的代码 (可选) 113 | */ 114 | function updateParser(protocol, code = "") { 115 | if (!worker) { 116 | console.error( 117 | "WorkerService: Cannot update parser, worker not initialized." 118 | ); 119 | return; 120 | } 121 | console.log( 122 | `WorkerService: Sending parser update to worker - Protocol: ${protocol}` 123 | ); 124 | const payload = { protocol: protocol }; 125 | if (protocol === "custom") { 126 | payload.parserCode = code; 127 | } 128 | worker.postMessage({ type: "updateActiveParser", payload: payload }); 129 | } 130 | 131 | /** 132 | * 终止 Worker 并清理资源。 133 | */ 134 | function terminate() { 135 | if (worker) { 136 | console.log("WorkerService: Terminating worker."); 137 | worker.terminate(); 138 | worker = null; 139 | } 140 | if (workerUrl) { 141 | URL.revokeObjectURL(workerUrl); 142 | workerUrl = null; 143 | console.log("Worker Object URL revoked."); 144 | } 145 | } 146 | 147 | /** 148 | * 向 Worker 发送更新模拟数据配置的指令。 149 | * @param {object} config - 新的模拟配置对象 { numChannels, frequency, amplitude } 150 | */ 151 | function updateSimConfig(config) { 152 | if (!worker) { 153 | console.error("WorkerService: 无法更新模拟配置,Worker 未初始化。"); 154 | return; 155 | } 156 | console.log("WorkerService: 向 Worker 发送 updateSimConfig 命令:", config); 157 | // Worker 内部已经有处理 'updateSimConfig' 的逻辑 158 | worker.postMessage({ type: 'updateSimConfig', payload: config }); 159 | } 160 | 161 | // 导出公共接口 162 | export { init, startWorker, stopWorker, updateParser, terminate, updateSimConfig}; 163 | -------------------------------------------------------------------------------- /js/utils.js: -------------------------------------------------------------------------------- 1 | // js/utils.js 2 | 3 | /** 4 | * Debounce function to limit the rate at which a function can fire. 5 | * @param {Function} func - The function to debounce. 6 | * @param {number} wait - The debounce interval in milliseconds. 7 | * @returns {Function} The debounced function. 8 | */ 9 | export function debounce(func, wait) { 10 | let timeout; 11 | return function executedFunction(...args) { 12 | const later = () => { 13 | clearTimeout(timeout); 14 | func(...args); 15 | }; 16 | clearTimeout(timeout); 17 | timeout = setTimeout(later, wait); 18 | }; 19 | } 20 | 21 | /** 22 | * Formats a total number of seconds into a human-readable string (H:M:S, M:S, or S.s). 23 | * @param {number|null} totalSeconds - The total seconds to format. 24 | * @returns {string} The formatted time string or "-". 25 | */ 26 | export function formatSecondsToHMS(totalSeconds) { 27 | if (totalSeconds === null || totalSeconds < 0 || !isFinite(totalSeconds)) { 28 | return "-"; 29 | } 30 | if (totalSeconds === Infinity) { 31 | return "∞"; 32 | } 33 | 34 | const seconds = Math.floor(totalSeconds % 60); 35 | const minutes = Math.floor((totalSeconds / 60) % 60); 36 | const hours = Math.floor(totalSeconds / 3600); 37 | 38 | const sStr = String(seconds).padStart(2, "0"); 39 | const mStr = String(minutes).padStart(2, "0"); 40 | 41 | if (hours > 0) { 42 | return `${hours}h ${mStr}m ${sStr}s`; 43 | } else if (minutes > 0) { 44 | return `${minutes}m ${sStr}s`; 45 | } else { 46 | // Show one decimal for small values for better precision indication 47 | if (totalSeconds < 10) { 48 | return `${totalSeconds.toFixed(1)}s`; 49 | } 50 | return `${seconds}s`; 51 | } 52 | } 53 | 54 | console.log("utils.js loaded"); // For debugging load order 55 | -------------------------------------------------------------------------------- /js/worker/data_worker.js: -------------------------------------------------------------------------------- 1 | // js/worker/data_worker.js 2 | 3 | // --- Worker Globals --- 4 | const WORKER_BATCH_INTERVAL_MS = 10; // Simulation batching interval 5 | const SERIAL_BATCH_TIME_MS = 10; // Serial data batching interval for main thread updates 6 | const MAX_RAW_BUFFER_LENGTH_FOR_DISPLAY_BREAK = 80; // For non-Aresplot text line breaking 7 | 8 | // --- Imports --- 9 | // Assuming data_worker.js is in js/worker/ and aresplot_protocol.js is in js/modules/ 10 | console.warn("Worker Top-Level: data_worker.js script started execution."); 11 | import { 12 | AresplotFrameParser, // The class itself 13 | CMD_ID as ARESPLOT_CMD_ID, 14 | AckStatus as ARESPLOT_ACK_STATUS 15 | // SOP, EOP are used internally by AresplotFrameParser, not directly needed here 16 | } from '../modules/aresplot_protocol.js'; // Adjust path if necessary 17 | 18 | 19 | // --- Simulation State (Copied from your original plotter.html worker script) --- 20 | let simWorkerBatchInterval = null; 21 | let currentDataSource = 'simulated'; // Default source 22 | let simConfig = { numChannels: 4, frequency: 1000, amplitude: 1 }; 23 | let simCurrentRunStartTime = 0; 24 | let simLastBatchSendTime = 0; 25 | 26 | function generateAndSendSimBatch() { 27 | const now = performance.now(); 28 | const timeSinceLastBatch = Math.max(1, now - simLastBatchSendTime); 29 | const pointsInBatch = Math.max(1, Math.round((simConfig.frequency * timeSinceLastBatch) / 1000)); 30 | const batch = []; 31 | for (let p = 0; p < pointsInBatch; p++) { 32 | const pointTimestamp = simLastBatchSendTime + (timeSinceLastBatch * (p + 1)) / pointsInBatch; 33 | const pointElapsedMs = pointTimestamp - simCurrentRunStartTime; // Relative to this worker's sim start 34 | const values = []; 35 | for (let i = 0; i < simConfig.numChannels; i++) { 36 | const phase = (i * Math.PI) / 4; 37 | const freqMultiplier = 1 + i * 0.5; 38 | const timeSec = pointElapsedMs / 1000.0; 39 | let value = simConfig.amplitude * Math.sin(2 * Math.PI * freqMultiplier * timeSec + phase) + (Math.random() - 0.5) * 0.1 * simConfig.amplitude; 40 | values.push(typeof value === 'number' && isFinite(value) ? value : 0); 41 | } 42 | batch.push({ timestamp: pointTimestamp, values: values }); // No rawLineBytes for sim 43 | } 44 | if (batch.length > 0) { 45 | self.postMessage({ type: 'dataBatch', payload: batch }); 46 | } 47 | simLastBatchSendTime = now; 48 | } 49 | function startSimulation() { 50 | stopSimulation(); 51 | simCurrentRunStartTime = performance.now(); 52 | simLastBatchSendTime = simCurrentRunStartTime; 53 | console.log(`Worker: Starting simulation (Freq: ${simConfig.frequency}Hz, Ch: ${simConfig.numChannels})`); 54 | simWorkerBatchInterval = setInterval(generateAndSendSimBatch, WORKER_BATCH_INTERVAL_MS); 55 | } 56 | function stopSimulation() { 57 | if (simWorkerBatchInterval) { 58 | clearInterval(simWorkerBatchInterval); 59 | simWorkerBatchInterval = null; 60 | console.warn("Worker: Simulation interval STOPPED."); 61 | simCurrentRunStartTime = 0; 62 | } 63 | } 64 | // --- End Simulation State --- 65 | 66 | 67 | // --- Serial Data Handling State (Worker) --- 68 | let keepReadingSerial = false; 69 | let internalWorkerBuffer = new Uint8Array(0); // Buffer for non-Aresplot text/custom parsers 70 | let currentReader = null; 71 | 72 | let currentParserType = "default"; // Active parser type: 'default', 'justfloat', 'firewater', 'aresplot', 'custom' 73 | let customSerialParserFunction = null; // For 'custom' protocol 74 | let selectedBuiltInParser = parseDefault; // Holds the function for 'default', 'justfloat', 'firewater' 75 | 76 | // --- Aresplot Specific State --- 77 | let aresplotParserInstanceForWorker = null; // Use distinct name 78 | let initialTimestampBias = null; 79 | let lastBiasCheckPcTime = 0; 80 | const TIMESTAMP_DRIFT_THRESHOLD_MS = 500; 81 | const TIMESTAMP_DRIFT_CHECK_INTERVAL_MS = 5000; 82 | 83 | 84 | // --- Utility Functions --- 85 | function concatUint8Arrays(a, b) { 86 | if (!a || a.byteLength === 0) return b; 87 | if (!b || b.byteLength === 0) return a; 88 | const result = new Uint8Array(a.byteLength + b.byteLength); 89 | result.set(a, 0); 90 | result.set(b, a.byteLength); 91 | return result; 92 | } 93 | 94 | // --- Built-in Non-Aresplot Parsers (From your original plotter.html worker and previous discussions) --- 95 | // These parsers return: { values: number[]|null, frameByteLength: number, rawLineBytes?: Uint8Array } 96 | function parseDefault(uint8ArrayData) { 97 | const newlineIndex = uint8ArrayData.indexOf(0x0A); // LF 98 | if (newlineIndex !== -1) { 99 | const frameByteLength = newlineIndex + 1; 100 | const rawLine = uint8ArrayData.slice(0, frameByteLength); 101 | // Handle CR LF before LF 102 | const lineDataEnd = (newlineIndex > 0 && uint8ArrayData[newlineIndex - 1] === 0x0D) ? newlineIndex - 1 : newlineIndex; 103 | const lineBytes = uint8ArrayData.slice(0, lineDataEnd); 104 | 105 | const textDecoder = new TextDecoder('utf-8', { fatal: false, ignoreBOM: true }); 106 | const lineString = textDecoder.decode(lineBytes).trim(); 107 | 108 | if (lineString === "") return { values: [], frameByteLength: frameByteLength, rawLineBytes: rawLine }; 109 | const values = lineString.split(/[\s,]+/).map(s => parseFloat(s.trim())).filter(n => !isNaN(n)); 110 | return { values: values, frameByteLength: frameByteLength, rawLineBytes: rawLine }; 111 | } 112 | return { values: null, frameByteLength: 0 }; 113 | } 114 | 115 | function parseJustFloat(uint8ArrayData) { 116 | const tail = [0x00, 0x00, 0x80, 0x7f]; 117 | const tailLength = tail.length; 118 | const floatSize = 4; 119 | if (uint8ArrayData.length < floatSize + tailLength) return { values: null, frameByteLength: 0 }; 120 | 121 | for (let i = 0; i <= uint8ArrayData.length - (floatSize + tailLength); i++) { 122 | let tailMatch = true; 123 | for (let k = 0; k < tailLength; k++) { 124 | if (uint8ArrayData[i + floatSize * (Math.floor(i/floatSize)) + k] !== tail[k]) { // This check for tail seems problematic 125 | // Correct tail check: the tail starts *after* the float data 126 | if(uint8ArrayData[i + k] !== tail[k] ){ 127 | tailMatch = false; 128 | break; 129 | } 130 | } 131 | } 132 | // Simpler tail check: Check from index i as potential start of N floats 133 | // The tail would be at i + N*floatSize 134 | // Let's find the tail first 135 | let tailStartIndex = -1; 136 | for (let j = 0; j <= uint8ArrayData.length - tailLength; j++) { 137 | let found = true; 138 | for (let k=0; k < tailLength; k++) { 139 | if (uint8ArrayData[j+k] !== tail[k]) { 140 | found = false; break; 141 | } 142 | } 143 | if (found) { 144 | tailStartIndex = j; 145 | break; 146 | } 147 | } 148 | 149 | if (tailStartIndex !== -1) { 150 | const dataBeforeTailLength = tailStartIndex; 151 | if (dataBeforeTailLength > 0 && dataBeforeTailLength % floatSize === 0) { 152 | const dataPart = uint8ArrayData.slice(0, dataBeforeTailLength); 153 | const values = []; 154 | const view = new DataView(dataPart.buffer, dataPart.byteOffset); 155 | for (let offset = 0; offset < dataBeforeTailLength; offset += floatSize) { 156 | values.push(view.getFloat32(offset, true)); // true for little-endian 157 | } 158 | const frameByteLength = dataBeforeTailLength + tailLength; 159 | const rawLine = uint8ArrayData.slice(0, frameByteLength); 160 | return { values: values, frameByteLength: frameByteLength, rawLineBytes: rawLine }; 161 | } else { 162 | // Invalid data length before tail, consume up to end of found tail to avoid re-parsing bad segment 163 | const frameByteLength = tailStartIndex + tailLength; 164 | const rawLine = uint8ArrayData.slice(0, frameByteLength); // Log what was consumed 165 | console.warn("JustFloat: Invalid data length before tail. Consuming segment."); 166 | return { values: [], frameByteLength: frameByteLength, rawLineBytes: rawLine }; // Return empty values 167 | } 168 | } 169 | } 170 | return { values: null, frameByteLength: 0 }; // No complete frame found 171 | } 172 | 173 | function parseFirewater(uint8ArrayData) { 174 | const newlineIndex = uint8ArrayData.indexOf(0x0A); // LF 175 | if (newlineIndex !== -1) { 176 | const frameByteLength = newlineIndex + 1; 177 | const rawLine = uint8ArrayData.slice(0, frameByteLength); 178 | const lineDataEnd = (newlineIndex > 0 && uint8ArrayData[newlineIndex - 1] === 0x0D) ? newlineIndex - 1 : newlineIndex; 179 | const lineBytes = uint8ArrayData.slice(0, lineDataEnd); 180 | 181 | const textDecoder = new TextDecoder('utf-8', { fatal: false, ignoreBOM: true }); 182 | let lineString = textDecoder.decode(lineBytes); 183 | const colonIndex = lineString.lastIndexOf(":"); 184 | if (colonIndex !== -1) { 185 | lineString = lineString.substring(colonIndex + 1); 186 | } 187 | const values = lineString.trim().split(',').map(s => parseFloat(s.trim())).filter(n => !isNaN(n)); 188 | return { values: values, frameByteLength: frameByteLength, rawLineBytes: rawLine }; 189 | } 190 | return { values: null, frameByteLength: 0 }; 191 | } 192 | // --- End Built-in Parsers --- 193 | 194 | // --- Aresplot Time Sync Logic (copied from previous, unchanged) --- 195 | function handleAresplotMonitorData(mcuTimestampMs, fp32ValuesArray, rawFrameBytes) { 196 | const pcNow = performance.now(); 197 | let calibratedPcTimestamp; 198 | if (initialTimestampBias === null) { 199 | initialTimestampBias = pcNow - mcuTimestampMs; 200 | lastBiasCheckPcTime = pcNow; 201 | self.postMessage({ type: 'info', payload: { source: 'aresplot_timestamp', message: `Timestamp bias initialized: ${initialTimestampBias.toFixed(0)}ms.` }}); 202 | } 203 | const currentFrameBias = pcNow - mcuTimestampMs; 204 | const drift = Math.abs(currentFrameBias - initialTimestampBias); 205 | if (drift > TIMESTAMP_DRIFT_THRESHOLD_MS) { 206 | self.postMessage({ type: 'warn', payload: { source: 'aresplot_timestamp', message: `Timestamp drift >${TIMESTAMP_DRIFT_THRESHOLD_MS}ms detected (${drift.toFixed(0)}ms). Re-synchronizing. Plot may jump.` } }); 207 | initialTimestampBias = currentFrameBias; 208 | lastBiasCheckPcTime = pcNow; 209 | } else if (pcNow - lastBiasCheckPcTime > TIMESTAMP_DRIFT_CHECK_INTERVAL_MS) { 210 | lastBiasCheckPcTime = pcNow; 211 | } 212 | calibratedPcTimestamp = mcuTimestampMs + initialTimestampBias; 213 | return { timestamp: calibratedPcTimestamp, values: fp32ValuesArray, rawLineBytes: rawFrameBytes }; 214 | } 215 | // --- End Aresplot Time Sync --- 216 | 217 | 218 | // --- Function to set the active serial parser (copied from previous, unchanged) --- 219 | function setSerialParser(protocol, parserCode) { 220 | let parserStatusMsg = `Worker: Parser set to '${protocol}'.`; 221 | let success = true; 222 | currentParserType = protocol; 223 | customSerialParserFunction = null; 224 | selectedBuiltInParser = null; 225 | if (protocol !== 'aresplot') { initialTimestampBias = null; } 226 | 227 | switch (protocol) { 228 | case "custom": 229 | if (parserCode) { 230 | try { 231 | customSerialParserFunction = new Function("uint8ArrayData", parserCode); 232 | customSerialParserFunction(new Uint8Array([49, 44, 50, 10])); 233 | parserStatusMsg = `Worker: Custom JS parser updated and applied.`; 234 | } catch (error) { 235 | parserStatusMsg = `Worker: Invalid custom parser: ${error.message}. Defaulting.`; success = false; currentParserType = "default"; selectedBuiltInParser = parseDefault; self.postMessage({ type: "error", payload: parserStatusMsg }); 236 | } 237 | } else { parserStatusMsg = "Worker: Custom parser no code. Defaulting."; success = false; currentParserType = "default"; selectedBuiltInParser = parseDefault; } 238 | break; 239 | case "aresplot": parserStatusMsg = `Worker: Aresplot protocol selected.`; break; 240 | case "default": selectedBuiltInParser = parseDefault; break; 241 | case "justfloat": selectedBuiltInParser = parseJustFloat; break; 242 | case "firewater": selectedBuiltInParser = parseFirewater; break; 243 | default: parserStatusMsg = `Worker: Unknown protocol '${protocol}'. Defaulting.`; currentParserType = "default"; selectedBuiltInParser = parseDefault; success = false; 244 | } 245 | if (success) console.log(parserStatusMsg); 246 | return { success, parserStatusMsg }; 247 | } 248 | 249 | 250 | // --- Core Serial Stream Reading Function (Revised for unified parsing loop) --- 251 | async function startReadingSerialFromStream(stream) { 252 | console.log(`Worker: startReadingSerialFromStream. Active parser type: ${currentParserType}`); 253 | keepReadingSerial = true; 254 | const dataPointsBatch = []; 255 | let lastSerialSendTime = performance.now(); 256 | 257 | // Reset/Initialize parser state for the stream 258 | if (currentParserType === "aresplot") { 259 | aresplotParserInstanceForWorker = new AresplotFrameParser(); // No callbacks, direct return handling 260 | initialTimestampBias = null; // Reset bias for new Aresplot session 261 | lastBiasCheckPcTime = 0; 262 | console.log("Worker: AresplotFrameParser instance created for stream."); 263 | } else { 264 | aresplotParserInstanceForWorker = null; 265 | internalWorkerBuffer = new Uint8Array(0); // Buffer for other parsers 266 | } 267 | 268 | try { 269 | currentReader = stream.getReader(); 270 | self.postMessage({ type: "status", payload: "Worker: Starting serial read loop." }); 271 | 272 | while (keepReadingSerial) { 273 | const { value, done } = await currentReader.read().catch(err => { 274 | if (err.name !== 'AbortError' && keepReadingSerial) { console.error("Worker: Read error:", err); self.postMessage({ type: "error", payload: `Read error: ${err.message}` }); } 275 | return { value: undefined, done: true }; 276 | }); 277 | 278 | const pcTimeForFrameProcessing = performance.now(); 279 | 280 | if (done) { console.log("Worker: Stream done."); keepReadingSerial = false; break; } 281 | if (!keepReadingSerial) { console.log("Worker: Commanded to stop reading."); if(currentReader && !done) await currentReader.cancel().catch(()=>{}); break; } 282 | 283 | if (value && value.byteLength > 0) { 284 | if (currentParserType === "aresplot" && aresplotParserInstanceForWorker) { 285 | aresplotParserInstanceForWorker.pushData(value); // Push new data to Aresplot parser 286 | let aresplotSegment; 287 | // Loop to consume all processable segments from Aresplot parser's internal buffer 288 | while (keepReadingSerial && (aresplotSegment = aresplotParserInstanceForWorker.parseNext())) { 289 | if (aresplotSegment.type === 'data') { 290 | const dataPoint = handleAresplotMonitorData(aresplotSegment.mcuTimestampMs, aresplotSegment.values, aresplotSegment.rawFrame); 291 | if (dataPoint) dataPointsBatch.push(dataPoint); 292 | } else if (aresplotSegment.type === 'ack') { 293 | if (aresplotSegment.status !== ARESPLOT_ACK_STATUS.OK) { 294 | self.postMessage({ type: 'warn', payload: { source: 'aresplot_ack_error', commandId: aresplotSegment.ackCmdId, statusCode: aresplotSegment.status, message: `MCU NACK for CMD 0x${aresplotSegment.ackCmdId.toString(16)} - Status 0x${aresplotSegment.status.toString(16)}` }}); 295 | } 296 | } else if (aresplotSegment.type === 'error_report') { // Assuming ERROR_REPORT exists in CMD_ID 297 | self.postMessage({ type: 'warn', payload: { source: 'aresplot_mcu_error', errorCode: aresplotSegment.errorCode, messageBytes: aresplotSegment.messageBytes, rawFrame: aresplotSegment.rawFrame }}); 298 | } else if (aresplotSegment.type === 'unidentified') { 299 | dataPointsBatch.push({ timestamp: pcTimeForFrameProcessing, values: [], rawLineBytes: aresplotSegment.rawData, isUnidentifiedAresplotData: true }); 300 | if (aresplotSegment.warning) { 301 | self.postMessage({ type: 'warn', payload: { source: 'aresplot_parser_internal', message: aresplotSegment.warning }}); 302 | } 303 | } 304 | // consumedBytes is handled internally by parseNext() removing from its buffer 305 | } 306 | } else { 307 | // Logic for other parsers (custom, default, justfloat, firewater) 308 | internalWorkerBuffer = concatUint8Arrays(internalWorkerBuffer, value); 309 | let processedInLoop; 310 | do { 311 | processedInLoop = 0; 312 | let parseResult = null; 313 | try { 314 | if (currentParserType === "custom" && customSerialParserFunction) { 315 | parseResult = customSerialParserFunction(internalWorkerBuffer); 316 | } else if (selectedBuiltInParser) { 317 | parseResult = selectedBuiltInParser(internalWorkerBuffer); 318 | } else { 319 | console.error("Worker: No valid parser function selected for type:", currentParserType); 320 | // To prevent infinite loop, consume something or break 321 | if (internalWorkerBuffer.length > 0) { 322 | dataPointsBatch.push({ timestamp: pcTimeForFrameProcessing, values: [], rawLineBytes: internalWorkerBuffer.slice(0,1), isUnidentifiedData: true }); 323 | internalWorkerBuffer = internalWorkerBuffer.slice(1); 324 | processedInLoop = 1; 325 | } 326 | break; // Break inner loop if no parser 327 | } 328 | 329 | if (parseResult && parseResult.values !== null && parseResult.frameByteLength > 0) { 330 | const rawBytes = parseResult.rawLineBytes || internalWorkerBuffer.slice(0, parseResult.frameByteLength); 331 | dataPointsBatch.push({ timestamp: pcTimeForFrameProcessing, values: parseResult.values, rawLineBytes: rawBytes }); 332 | internalWorkerBuffer = internalWorkerBuffer.slice(parseResult.frameByteLength); 333 | processedInLoop = parseResult.frameByteLength; 334 | } 335 | } catch (e) { 336 | self.postMessage({ type: "error", payload: `Parser error (${currentParserType}): ${e.message}` }); 337 | if (internalWorkerBuffer.length > 0) { // Consume a byte to try to recover 338 | internalWorkerBuffer = internalWorkerBuffer.slice(1); 339 | processedInLoop = 1; 340 | } 341 | } 342 | } while (processedInLoop > 0 && internalWorkerBuffer.length > 0 && keepReadingSerial); 343 | 344 | // MAX_RAW_BUFFER_LENGTH_FOR_DISPLAY_BREAK logic (as in your original) 345 | if (currentParserType !== "justfloat" && currentParserType !== "aresplot" && // Typically for text-based 346 | internalWorkerBuffer.length > MAX_RAW_BUFFER_LENGTH_FOR_DISPLAY_BREAK && 347 | internalWorkerBuffer.indexOf(0x0A) === -1 && 348 | internalWorkerBuffer.indexOf(0x0D) === -1) 349 | { 350 | const rawSegmentBytes = internalWorkerBuffer.slice(0, MAX_RAW_BUFFER_LENGTH_FOR_DISPLAY_BREAK); 351 | dataPointsBatch.push({ timestamp: pcTimeForFrameProcessing, values: [], rawLineBytes: rawSegmentBytes, isUnidentifiedData: true }); 352 | internalWorkerBuffer = internalWorkerBuffer.slice(MAX_RAW_BUFFER_LENGTH_FOR_DISPLAY_BREAK); 353 | self.postMessage({ type: 'warn', payload: { source: 'parser_line_break', message: `Forced line break in '${currentParserType}' due to no newline.` }}); 354 | } 355 | } 356 | } 357 | 358 | // Batch send data to main thread periodically 359 | if (dataPointsBatch.length > 0 && (pcTimeForFrameProcessing - lastSerialSendTime >= SERIAL_BATCH_TIME_MS)) { 360 | try { 361 | self.postMessage({ type: "dataBatch", payload: [...dataPointsBatch] }); 362 | } catch (postError) { console.error("Worker: Error posting dataBatch:", postError); } 363 | dataPointsBatch.length = 0; 364 | lastSerialSendTime = pcTimeForFrameProcessing; 365 | } 366 | } // end while 367 | } catch (error) { 368 | if (error.name !== 'AbortError' && keepReadingSerial) { console.error("Worker: Outer read loop error:", error); self.postMessage({ type: "error", payload: `Outer read loop error: ${error.message}` });} 369 | } finally { 370 | console.log("Worker: Serial read loop 'finally' block executing."); 371 | keepReadingSerial = false; // Ensure flag is false 372 | if (currentReader) { 373 | try { 374 | if (!currentReader.closed) { // Check if not already closed 375 | await currentReader.cancel().catch(()=>{}); // Attempt to cancel pending reads 376 | } 377 | } catch (e) { console.warn("Worker: Error during reader final cleanup:", e); } 378 | } 379 | currentReader = null; 380 | aresplotParserInstanceForWorker = null; // Clean up Aresplot instance 381 | initialTimestampBias = null; 382 | internalWorkerBuffer = new Uint8Array(0); 383 | if (dataPointsBatch.length > 0) { // Send any remaining data 384 | try { self.postMessage({ type: "dataBatch", payload: [...dataPointsBatch] }); } 385 | catch (e) { console.error("Worker: Error posting final dataBatch from finally:", e); } 386 | dataPointsBatch.length = 0; 387 | } 388 | self.postMessage({ type: "status", payload: "Worker: Serial read loop finished/terminated." }); 389 | } 390 | } 391 | 392 | // --- Worker Message Handler (onmessage) --- 393 | // Ensure onmessage calls setSerialParser correctly and handles start/stop 394 | self.onmessage = async (event) => { 395 | const { type, payload } = event.data; 396 | switch (type) { 397 | case 'start': 398 | console.log("Worker: 'start' (sim) command:", payload); 399 | if (payload.source === "simulated") { 400 | if (keepReadingSerial && currentReader) { keepReadingSerial = false; await currentReader.cancel().catch(() => {}); } 401 | currentDataSource = "simulated"; 402 | setSerialParser("default"); // Reset protocol 403 | simConfig = payload.config; 404 | startSimulation(); 405 | } else { self.postMessage({ type: "warn", payload: "Worker: 'start' for non-sim ignored." }); } 406 | break; 407 | case "startSerialStream": 408 | console.log("Worker: 'startSerialStream' command for protocol:", payload.protocol); 409 | if (payload.source === "webserial" && payload.readableStream) { 410 | stopSimulation(); 411 | currentDataSource = "webserial"; 412 | const initialParserResult = setSerialParser(payload.protocol, payload.parserCode); 413 | self.postMessage({ type: "status", payload: initialParserResult.parserStatusMsg }); 414 | if (initialParserResult.success) { 415 | if (keepReadingSerial) { // Ensure previous stream fully stopped 416 | keepReadingSerial = false; if (currentReader) await currentReader.cancel().catch(() => {}); 417 | await new Promise(resolve => setTimeout(resolve, 50)); // Allow time for cleanup 418 | } 419 | startReadingSerialFromStream(payload.readableStream); 420 | } else { self.postMessage({ type: "error", payload: "Parser setup fail." }); } 421 | } else { self.postMessage({ type: "error", payload: "Invalid 'startSerialStream'." }); } 422 | break; 423 | case 'stop': 424 | console.log("Worker: 'stop' command."); 425 | stopSimulation(); 426 | if (keepReadingSerial) { keepReadingSerial = false; if (currentReader) await currentReader.cancel().catch(() => {}); } 427 | break; 428 | case 'updateSimConfig': 429 | if (currentDataSource === "simulated") { simConfig = payload; if (simWorkerBatchInterval) startSimulation(); } 430 | break; 431 | case "updateActiveParser": 432 | console.log("Worker: 'updateActiveParser' for protocol:", payload.protocol); 433 | if (currentDataSource === "webserial") { 434 | const oldParserType = currentParserType; 435 | const updateResult = setSerialParser(payload.protocol, payload.parserCode); 436 | self.postMessage({ type: "status", payload: updateResult.parserStatusMsg }); 437 | if (keepReadingSerial && oldParserType !== currentParserType) { 438 | console.warn(`Worker: Parser changed mid-stream from ${oldParserType} to ${currentParserType}.`); 439 | // For Aresplot, new instance is made in startReadingSerialFromStream. 440 | // For others, the change is effective immediately for next data chunk. 441 | } 442 | } else { self.postMessage({ type: "warn", payload: "Parser update ignored (not webserial)." }); } 443 | break; 444 | default: 445 | console.warn("Worker received unknown message type:", type, payload); 446 | } 447 | }; 448 | 449 | // --- Worker Ready --- 450 | self.postMessage({ type: "status", payload: "Worker ready (ES Module, Integrated Parsers v3)." }); 451 | console.log("Worker script (ES Module, Integrated Parsers v3) loaded."); -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Web Serial Plotter", 3 | "short_name": "SerialPlot", 4 | "description": "A high-performance web-based plotter using Web Serial and TimeChart.", 5 | "start_url": "index.html", 6 | "display": "standalone", 7 | "background_color": "#f3f4f6", 8 | "theme_color": "#3b82f6", 9 | "orientation": "any", 10 | "icons": [ 11 | { 12 | "src": "icons/icon-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "any maskable" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /pictures/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainKAZ/web-serial-plotter/c6060ed8b6ffcfabc80e4e676cd4a5b1dbdaa42c/pictures/screenshot.png -------------------------------------------------------------------------------- /sw.js: -------------------------------------------------------------------------------- 1 | // sw.js 2 | 3 | // 定义缓存名称,通常包含版本号以便更新 4 | const CACHE_NAME = "web-serial-plotter-cache-v3.1+"; // Increment version number 5 | // 定义需要缓存的核心文件(应用外壳)和依赖项 6 | const urlsToCache = [ 7 | "/", // 根路径通常也需要缓存 8 | "index.html", 9 | "manifest.json", // Added manifest file 10 | "css/styles.css", 11 | // Core JS Modules 12 | "js/main.js", 13 | "js/config.js", 14 | "js/utils.js", 15 | "js/event_bus.js", 16 | "js/modules/ui.js", 17 | "js/modules/plot_module.js", 18 | "js/modules/terminal_module.js", 19 | "js/modules/quat_module.js", 20 | "js/modules/data_processing.js", 21 | "js/modules/serial.js", 22 | "js/modules/worker_service.js", 23 | "js/worker/data_worker.js", // Worker 脚本也需要缓存 24 | "js/modules/elf_analyzer_service.js", 25 | "js/modules/aresplot_protocol.js", 26 | // HTML Partials 27 | "html_partials/control_panel.html", 28 | "html_partials/plot_module.html", 29 | "html_partials/text_module.html", 30 | "html_partials/quaternion_module.html", 31 | // 外部库的 CDN URL 32 | // IMPORTANT: Caching root domains or '@latest' might be unreliable. 33 | // It's best to use specific file URLs if possible. These are based on index.html. 34 | "https://cdn.tailwindcss.com", // Tailwind CSS (might need specific file URL) 35 | "https://cdn.jsdelivr.net/npm/d3-array@3", 36 | "https://cdn.jsdelivr.net/npm/d3-color@3", 37 | "https://cdn.jsdelivr.net/npm/d3-format@3", 38 | "https://cdn.jsdelivr.net/npm/d3-interpolate@3", 39 | "https://cdn.jsdelivr.net/npm/d3-time@3", 40 | "https://cdn.jsdelivr.net/npm/d3-time-format@4", 41 | "https://cdn.jsdelivr.net/npm/d3-scale@4", 42 | "https://cdn.jsdelivr.net/npm/d3-selection@3", 43 | "https://cdn.jsdelivr.net/npm/d3-axis@3", 44 | "https://huww98.github.io/TimeChart/dist/timechart.min.js", 45 | "https://captainkaz.github.io/elf_analyzer_wasm/pkg/elf_analyzer_wasm.js", 46 | "https://captainkaz.github.io/elf_analyzer_wasm/pkg/elf_analyzer_wasm_bg.wasm", // Standard name for the wasm file 47 | "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js", 48 | "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js", 49 | "https://unpkg.com/split.js/dist/split.min.js", 50 | "https://unpkg.com/lucide@latest/dist/umd/lucide.min.js", // More specific Lucide URL if available, assuming UMD here 51 | "https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css", 52 | "https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js", 53 | "https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js", 54 | 55 | // Icons and Assets 56 | "icons/icon-512x512.png", 57 | // Add any other static assets used by your CSS or JS if necessary 58 | ]; 59 | 60 | // 安装 Service Worker 时触发 61 | self.addEventListener("install", (event) => { 62 | console.log("[Service Worker] Install event for cache:", CACHE_NAME); 63 | event.waitUntil( 64 | caches 65 | .open(CACHE_NAME) 66 | .then((cache) => { 67 | console.log("[Service Worker] Opened cache:", CACHE_NAME); 68 | const cachePromises = urlsToCache.map((urlToCache) => { 69 | // Use request object for more control, especially for wasm 70 | const request = new Request(urlToCache, { mode: "cors" }); // Try 'cors' first for external URLs 71 | return fetch(request) 72 | .then((response) => { 73 | if (!response.ok) { 74 | // If CORS fails for external, try no-cors as fallback (opaque response) 75 | if (new URL(urlToCache).origin !== self.location.origin) { 76 | console.warn( 77 | `[Service Worker] CORS fetch failed for ${urlToCache}. Trying no-cors.` 78 | ); 79 | const noCorsRequest = new Request(urlToCache, { 80 | mode: "no-cors", 81 | }); 82 | return fetch(noCorsRequest).then((noCorsResponse) => { 83 | if (noCorsResponse.type === "opaque") { 84 | return cache.put(urlToCache, noCorsResponse); // Cache opaque response 85 | } else { 86 | console.error( 87 | `[Service Worker] no-cors fetch for ${urlToCache} did not result in opaque response.` 88 | ); 89 | return Promise.resolve(); // Don't block install 90 | } 91 | }); 92 | } else { 93 | // If same-origin fetch failed, log error 94 | console.error( 95 | `[Service Worker] Failed to fetch same-origin ${urlToCache}. Status: ${response.status}` 96 | ); 97 | return Promise.resolve(); // Don't block install 98 | } 99 | } 100 | // Cache successful CORS response 101 | return cache.put(urlToCache, response); 102 | }) 103 | .catch((error) => { 104 | console.error( 105 | `[Service Worker] Error fetching/caching ${urlToCache}:`, 106 | error 107 | ); 108 | return Promise.resolve(); // Don't block install on individual file error 109 | }); 110 | }); 111 | return Promise.all(cachePromises); 112 | }) 113 | .then(() => { 114 | console.log( 115 | "[Service Worker] Finished attempting to cache all specified URLs." 116 | ); 117 | console.log( 118 | "[Service Worker] Installation finished, attempting to activate..." 119 | ); 120 | return self.skipWaiting(); // Force activation 121 | }) 122 | .catch((error) => { 123 | console.error( 124 | "[Service Worker] Cache opening or skipWaiting failed during install:", 125 | error 126 | ); 127 | }) 128 | ); 129 | }); 130 | 131 | // 激活 Service Worker 时触发 132 | self.addEventListener("activate", (event) => { 133 | console.log("[Service Worker] Activate event for cache:", CACHE_NAME); 134 | // 清理旧缓存 135 | event.waitUntil( 136 | caches 137 | .keys() 138 | .then((cacheNames) => { 139 | return Promise.all( 140 | cacheNames.map((cacheName) => { 141 | // 如果缓存名称不是当前的缓存名称,则删除它 142 | if (cacheName !== CACHE_NAME) { 143 | console.log("[Service Worker] Deleting old cache:", cacheName); 144 | return caches.delete(cacheName); 145 | } 146 | }) 147 | ); 148 | }) 149 | .then(() => { 150 | console.log("[Service Worker] Claiming clients"); 151 | // 让 Service Worker 立即控制当前打开的页面 (clients) 152 | // 这对于确保 PWA 立即使用新缓存至关重要 153 | return self.clients.claim(); 154 | }) 155 | .catch((error) => { 156 | console.error( 157 | "[Service Worker] Cache cleanup or claiming clients failed during activate:", 158 | error 159 | ); 160 | }) 161 | ); 162 | }); 163 | 164 | // 拦截网络请求 165 | self.addEventListener("fetch", (event) => { 166 | const requestUrl = new URL(event.request.url); 167 | 168 | // 仅处理 GET 请求,且协议为 http 或 https 169 | if ( 170 | event.request.method !== "GET" || 171 | !requestUrl.protocol.startsWith("http") 172 | ) { 173 | // 对于非 GET 或非 HTTP(S) 请求,直接由浏览器处理 174 | // console.log(`[Service Worker] Ignoring non-GET/non-HTTP(S) request: ${event.request.method} ${event.request.url}`); 175 | return; 176 | } 177 | 178 | // 采用 Cache First 策略 179 | event.respondWith( 180 | caches 181 | .match(event.request, { ignoreVary: true }) // ignoreVary can help match opaque responses 182 | .then((cachedResponse) => { 183 | // 如果在缓存中找到匹配的响应 184 | if (cachedResponse) { 185 | // console.log(`[Service Worker] Serving from cache: ${event.request.url}`); 186 | return cachedResponse; // 直接返回缓存的响应 187 | } 188 | 189 | // 如果缓存中没有找到,则尝试从网络获取 190 | // console.log(`[Service Worker] Fetching from network: ${event.request.url}`); 191 | return fetch(event.request) 192 | .then((networkResponse) => { 193 | // 检查是否收到了有效的响应 (ok or opaque) 194 | if ( 195 | networkResponse && 196 | (networkResponse.ok || networkResponse.type === "opaque") 197 | ) { 198 | // 克隆响应,因为响应体只能被读取一次 199 | const responseToCache = networkResponse.clone(); 200 | 201 | // 尝试将网络响应添加到缓存中 (异步操作,不阻塞返回网络响应) 202 | caches.open(CACHE_NAME).then((cache) => { 203 | // console.log(`[Service Worker] Caching new response: ${event.request.url}`); 204 | cache 205 | .put(event.request, responseToCache) 206 | .catch((cachePutError) => { 207 | console.warn( 208 | `[Service Worker] Failed to cache response for ${event.request.url}:`, 209 | cachePutError 210 | ); 211 | // Especially handle QuotaExceededError if storage is full 212 | if (cachePutError.name === "QuotaExceededError") { 213 | console.error( 214 | "[Service Worker] Cache storage quota exceeded. Cannot cache new items." 215 | ); 216 | // Optionally, implement cache cleanup logic here 217 | } 218 | }); 219 | }); 220 | } else { 221 | console.warn( 222 | `[Service Worker] Network fetch failed or received non-OK response for: ${event.request.url}, Status: ${networkResponse?.status}, Type: ${networkResponse?.type}` 223 | ); 224 | } 225 | 226 | // 返回从网络获取的原始响应 (即使缓存失败) 227 | return networkResponse; 228 | }) 229 | .catch((error) => { 230 | console.error( 231 | `[Service Worker] Fetch failed entirely for: ${event.request.url}`, 232 | error 233 | ); 234 | // 如果网络请求失败,可以尝试返回一个离线占位符页面或资源 235 | // 例如: return caches.match('/offline.html'); 236 | // 对于 JS/CSS 等关键资源,失败可能导致应用无法工作,所以可能返回错误更合适 237 | // 对于 API 请求,可能返回一个表示离线的 JSON 238 | // 对于此应用,若核心资源加载失败,直接失败可能更清晰 239 | // 返回一个基本的错误响应 240 | return new Response(`Network error: ${error.message}`, { 241 | status: 408, // Request Timeout 242 | headers: { "Content-Type": "text/plain" }, 243 | }); 244 | }); 245 | }) 246 | ); 247 | }); 248 | --------------------------------------------------------------------------------