├── .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 |
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 | 
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 |
5 | 模拟数据(性能测试)
6 | WebSerial
7 | WebSocket(暂未支持)
8 |
9 |
状态:空闲
10 |
11 |
12 | 最大缓冲点数:
13 |
21 |
22 |
缓冲使用情况:
23 |
26 |
27 | 缓冲: 0 / 120000 点
28 |
29 |
开始采集
30 |
31 |
32 | 下载 CSV
33 |
34 | 清空数据
35 |
36 |
37 |
38 |
39 |
40 |
解析设置
41 |
解析协议:
42 |
43 | Arduino (逗号/空格分隔)
44 | JustFloat (N*float + 帧尾)
45 | FireWater (文本 + 换行)
46 | AresPlot (监控任意全局变量)
47 | 自定义
48 |
49 |
50 |
51 |
自定义解析器 (JS 函数体):
52 |
57 |
58 | 更新解析器
59 |
60 |
状态:使用自定义解析器。
61 |
62 | 注意:执行自定义代码可能存在安全风险。
63 |
64 |
65 |
66 | 状态:使用内置解析器。
67 |
68 |
69 |
70 |
71 |
AresPlot 配置
72 |
73 |
77 |
78 |
79 |
80 | 点击或拖放打开ELF
83 |
84 |
85 |
86 |
87 | Status: Waiting for ELF file.
88 |
89 |
90 |
91 |
92 |
93 |
100 |
101 |
107 | +
108 |
109 |
110 |
111 |
112 |
113 |
监控列表:
114 |
117 | Slots for selected symbols.
118 |
119 |
120 |
121 |
122 |
158 |
159 |
160 |
WebSerial 连接设置
161 |
连接后,“开始采集”按钮将读取数据。
162 |
连接串口
163 |
164 |
波特率:
165 |
166 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
数据位:
192 |
193 | 8
194 | 7
195 |
196 |
停止位:
197 |
198 | 1
199 | 2
200 |
201 |
校验位:
202 |
203 | 无
204 | 偶校验
205 | 奇校验
206 |
207 |
流控制:
208 |
209 | 无
210 | 硬件
211 |
212 |
213 |
--------------------------------------------------------------------------------
/html_partials/plot_module.html:
--------------------------------------------------------------------------------
1 |
22 |
--------------------------------------------------------------------------------
/html_partials/quaternion_module.html:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
无效数据
17 |
18 |
选择四元数通道
19 |
请为 W, X, Y, Z 分配唯一的可用通道。
20 |
21 |
W:
22 |
X:
23 |
Y:
24 |
Z:
25 |
26 |
27 |
确认选择
28 |
29 |
30 |
--------------------------------------------------------------------------------
/html_partials/text_module.html:
--------------------------------------------------------------------------------
1 |
2 |
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 |
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 += `Ch ${i + 1} `;
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 |
--------------------------------------------------------------------------------