23 Commits

48 changed files with 2045 additions and 2338 deletions
+7 -36
View File
@@ -1,37 +1,8 @@
# CMake build output
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
dist/
build/
cmake-build-*/
# CMake generated files
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
CTestTestfile.cmake
Testing/
# Visual Studio / MSVC generated files
.vs/
*.sln
*.vcxproj
*.vcxproj.filters
*.vcxproj.user
*.obj
*.pdb
*.ilk
*.lib
*.exp
*.exe
*.recipe
*.tlog
*.lastbuildstate
# Ninja / Make generated files
*.ninja
.ninja_deps
.ninja_log
Makefile
# Logs and temporary files
*.log
*.tmp
*.temp
*.egg-info/
+1
View File
@@ -0,0 +1 @@
3.12
-61
View File
@@ -1,61 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(line_laser_modbus_cpp
VERSION 1.0.0
LANGUAGES CXX)
option(LINE_LASER_BUILD_TESTS "Build unit tests" ON)
option(LINE_LASER_BUILD_APPS "Build host/device demo applications" ON)
if(MSVC)
add_compile_options(/utf-8)
endif()
add_library(line_laser_modbus
src/protocol.cpp
src/host.cpp
src/device.cpp
src/motion_adapter.cpp)
target_include_directories(line_laser_modbus
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include)
target_compile_features(line_laser_modbus PUBLIC cxx_std_17)
if(MSVC)
target_compile_options(line_laser_modbus PRIVATE /W4 /permissive- /utf-8)
else()
target_compile_options(line_laser_modbus PRIVATE -Wall -Wextra -Wpedantic)
endif()
if(LINE_LASER_BUILD_APPS)
add_executable(host_demo apps/host_demo.cpp)
target_link_libraries(host_demo PRIVATE line_laser_modbus)
add_executable(device_demo apps/device_demo.cpp)
target_link_libraries(device_demo PRIVATE line_laser_modbus)
add_executable(adapter_demo apps/adapter_demo.cpp)
target_link_libraries(adapter_demo PRIVATE line_laser_modbus)
endif()
if(LINE_LASER_BUILD_TESTS)
enable_testing()
add_executable(protocol_tests tests/protocol_tests.cpp)
target_link_libraries(protocol_tests PRIVATE line_laser_modbus)
add_test(NAME protocol_tests COMMAND protocol_tests)
add_executable(host_tests tests/host_tests.cpp)
target_link_libraries(host_tests PRIVATE line_laser_modbus)
add_test(NAME host_tests COMMAND host_tests)
add_executable(device_tests tests/device_tests.cpp)
target_link_libraries(device_tests PRIVATE line_laser_modbus)
add_test(NAME device_tests COMMAND device_tests)
add_executable(adapter_tests tests/adapter_tests.cpp)
target_link_libraries(adapter_tests PRIVATE line_laser_modbus)
add_test(NAME adapter_tests COMMAND adapter_tests)
endif()
+36 -257
View File
@@ -1,281 +1,60 @@
# 线激光 Modbus RTU C++ 实现
# line-laser-modbus
本目录是 `../py/docs/proto.md` 协议的 C++17 实现,覆盖上位机主站、下位机从站、公共 Modbus RTU 协议层、示例程序和测试
线激光器上位机与运动控制器的 Modbus RTU 通信模块,覆盖模式命令、设备状态、6 轴位姿、目标示教位姿和 6 轴纠偏量的读写
实现目标:
协议文档见 [docs/proto.md](docs/proto.md)。
- 上位机负责构造读写请求、解析从站响应。
- 下位机负责维护保持寄存器表、校验请求、执行合法写入、返回响应。
- 公共协议层统一处理 CRC16、大小端、帧编解码和 TS+XYZABC 位姿寄存器转换。
- 不引入第三方库,减少资源占用,方便后续迁移到性能较弱的驱动板。
## 功能
## 目录结构
- 基于 `pymodbus` 的 Modbus RTU 串口客户端
- 按协议实现 uint16、uint32 时间戳、float32、XYZABC 位姿和 CRC16 编解码
- 提供无硬件内存模拟器用于测试
- 提供模式状态机校验和 50ms 轮询运行器
- 使用 TOML 作为运行配置入口
- `include/line_laser_modbus/protocol.hpp`:公共协议常量、CRC、帧构造/解析、TS+XYZABC 转换。
- `include/line_laser_modbus/host.hpp`:上位机/主站侧封装。
- `include/line_laser_modbus/device.hpp`:下位机/从站侧封装和内存保持寄存器表。
- `src/*.cpp`:协议、上位机、下位机实现。
- `apps/host_demo.cpp`:上位机构造 RTU 帧示例。
- `apps/device_demo.cpp`:下位机处理主站请求示例。
- `tests/*.cpp`:协议、上位机、下位机测试;每个测试文件顶部都写明测试内容。
## 安装与测试
## 整体模块图
```mermaid
flowchart LR
HostApp["fa:fa-desktop 上位机业务代码"] --> HostClient["HostClient<br/>主站封装"]
HostClient --> Protocol["protocol.hpp / protocol.cpp<br/>CRC16 / 帧编解码 / 位姿转换"]
Protocol --> Transport["fa:fa-exchange 传输层<br/>串口 / RS-485 / TCP 桥接"]
Transport --> DeviceServer["DeviceServer<br/>从站帧处理器"]
DeviceServer --> RegisterBank["RegisterBank<br/>保持寄存器表"]
RegisterBank --> Firmware["fa:fa-microchip 下位机固件<br/>运动控制逻辑"]
Tests["fa:fa-check 测试程序"] --> Protocol
Tests --> HostClient
Tests --> DeviceServer
```powershell
python -m uv sync --default-index https://mirrors.ustc.edu.cn/pypi/simple
python -m uv run pytest
python -m uv run ruff check
```
说明:
## 模拟运行
- `HostClient``DeviceServer` 都不绑定具体串口库,方便 PC、驱动板、仿真测试共用同一套协议代码。
- `protocol` 是唯一的帧格式和数据格式入口,避免上下位机分别实现导致字节序或 CRC 不一致。
- `RegisterBank` 使用紧凑数组保存 `0xD000 ~ 0xD06B`,比 `map` 更适合资源较弱的板卡。
## 主从通信时序
下面的时序图解释一次典型的“读取当前位姿”和“写入纠偏量”流程。
```mermaid
sequenceDiagram
participant App as 上位机业务
participant Host as HostClient
participant Bus as 串口_RS485
participant Dev as DeviceServer
participant Bank as RegisterBank
participant Ctrl as 运动控制逻辑
App->>Host: make_read_current_pose_request()
Host->>Host: 构造 0x03 请求帧并追加 CRC16
Host->>Bus: 发送 RTU 请求
Bus->>Dev: 收到完整帧
Dev->>Dev: 校验从站地址 / 功能码 / CRC
Dev->>Bank: read(0xD00A, 14)
Bank-->>Dev: TS + X/Y/Z/A/B/C 寄存器
Dev->>Dev: 构造 0x03 响应帧并追加 CRC16
Dev-->>Bus: 返回 RTU 响应
Bus-->>Host: 接收响应帧
Host->>Host: parse_current_pose_response()
Host-->>App: Pose6D
App->>Host: make_write_correction_request(Pose6D)
Host->>Host: TS+XYZABC 转 14 个寄存器并构造 0x10 请求帧
Host->>Bus: 发送纠偏量写入帧
Bus->>Dev: 收到完整帧
Dev->>Dev: 校验帧并检查地址范围
Dev->>Bank: write(0xD036, 14 registers)
Bank-->>Ctrl: 固件可读取最新纠偏量
Dev-->>Host: 0x10 写确认响应
Host-->>App: parse_write_ack() 成功
```powershell
python -m uv run line-laser-modbus --simulate read-status
python -m uv run line-laser-modbus --simulate write-mode 3
python -m uv run line-laser-modbus --simulate emergency-stop
python -m uv run line-laser-modbus --simulate poll-once --target 1 2 3 0 1 2
python -m uv run line-laser-modbus --simulate demo
```
## 下位机帧处理流程
## 示例代码
```mermaid
flowchart TD
Start([收到一帧 RTU 数据]) --> Size{长度是否足够?}
Size -- 否 --> Ignore1([忽略])
Size -- 是 --> Slave{从站地址是否匹配?}
Slave -- 否 --> Ignore2([忽略])
Slave -- 是 --> Crc{CRC 是否正确?}
Crc -- 否 --> Ignore3([忽略])
Crc -- 是 --> Func{功能码}
示例代码在 `examples/` 目录下,优先阅读模拟示例了解库的调用方式:
Func -- "0x03 读保持寄存器" --> ParseRead[解析 ReadRequest]
ParseRead --> AddrRead{地址范围有效?}
AddrRead -- 否 --> ExRead[返回异常响应 0x83]
AddrRead -- 是 --> ReadBank[从 RegisterBank 读取寄存器]
ReadBank --> ReadResp[构造读响应帧]
Func -- "0x10 写多个保持寄存器" --> ParseWrite[解析 WriteRequest]
ParseWrite --> AddrWrite{地址范围有效?}
AddrWrite -- 否 --> ExWriteAddr[返回异常响应 0x90]
AddrWrite -- 是 --> ModeCheck{是否写模式字?}
ModeCheck -- 否 --> WriteBank[写入 RegisterBank]
ModeCheck -- 是 --> ValidMode{模式值和切换是否合法?}
ValidMode -- 否 --> ExWriteValue[返回非法值异常]
ValidMode -- 是 --> WriteBank
WriteBank --> WriteResp[构造写确认帧]
Func -- 其他 --> ExFunc[返回非法功能码异常]
```powershell
python -m uv run python examples/simulated_basic.py
python -m uv run python examples/simulated_polling.py
```
## 类关系
连接真实串口前先修改 `config.toml`,再运行:
```mermaid
classDiagram
class HostClient {
-uint8_t slave_id_
+make_read_status_request()
+make_read_current_pose_request()
+make_write_mode_request(mode)
+make_write_target_pose_request(pose)
+make_write_correction_request(pose)
+parse_status_response(frame)
+parse_current_pose_response(frame)
+parse_write_ack(frame, address, quantity)
}
class DeviceServer {
-uint8_t slave_id_
-RegisterBank bank_
+process_request(request)
+bank()
}
class RegisterBank {
-array<uint16_t> registers_
+read(address, quantity)
+write(address, registers)
+set_mode(mode)
+set_state(state)
+set_current_pose(pose)
+target_pose()
+correction()
}
class Protocol {
+crc16(data)
+append_crc(frame)
+build_read_request(...)
+build_write_request(...)
+parse_read_response(...)
+parse_write_request(...)
+encode_pose_registers(pose)
+decode_pose_registers(registers)
}
HostClient ..> Protocol : 构造/解析帧
DeviceServer ..> Protocol : 校验/构造响应
DeviceServer *-- RegisterBank : 持有
RegisterBank ..> Protocol : 位姿寄存器转换
```powershell
python -m uv run python examples/real_serial_read_status.py
```
## 寄存器和位姿数据布局
## TOML 配置
三类 6 轴数据块都使用同一套布局:`uint32 timestamp_ms + 6 个 float`,共 14 个保持寄存器。
运行前按实际串口修改 `config.toml`。默认会读取当前目录下的 `config.toml`
```mermaid
flowchart LR
subgraph PoseBlock["TS + XYZABC 数据块,共 14 个寄存器"]
TS["0~1<br/>uint32 时间戳 ms"]
X["2~3<br/>float X mm"]
Y["4~5<br/>float Y mm"]
Z["6~7<br/>float Z mm"]
A["8~9<br/>float A deg"]
B["10~11<br/>float B deg"]
C["12~13<br/>float C deg"]
end
Current["当前位姿<br/>0xD00A ~ 0xD017"] --> PoseBlock
Target["目标示教位姿<br/>0xD020 ~ 0xD02D"] --> PoseBlock
Correction["6 轴纠偏量<br/>0xD036 ~ 0xD043"] --> PoseBlock
```powershell
python -m uv run line-laser-modbus read-status
```
寄存器表核心区域:
配置文件包含 `[serial]``[polling]` 两段,其中 `[polling]` 用于配置 50ms 轮询周期和连续超时判定次数。
```mermaid
flowchart TD
R0["0xD000<br/>模式命令字"] --> R1["0xD001<br/>设备状态字"]
R1 --> R2["0xD002 ~ 0xD009<br/>预留"]
R2 --> R3["0xD00A ~ 0xD017<br/>当前 6 轴位姿"]
R3 --> R4["0xD018 ~ 0xD01F<br/>预留"]
R4 --> R5["0xD020 ~ 0xD02D<br/>目标示教位姿"]
R5 --> R6["0xD02E ~ 0xD035<br/>预留"]
R6 --> R7["0xD036 ~ 0xD043<br/>6 轴纠偏量"]
R7 --> R8["0xD044 ~ 0xD04B<br/>预留"]
R8 --> R9["0xD04C ~ 0xD06B<br/>标定参数预留"]
```
## 类型标记
## 模式状态机
```mermaid
stateDiagram-v2
[*] --> Standby
Standby: 0 待机复位
Calibration: 1 系统标定
Teaching: 2 焊前扫描示教
Tracking: 3 在线全轴跟踪
Replay: 4 轨迹批量复现
Emergency: 5 紧急停止
Standby --> Calibration
Standby --> Teaching
Standby --> Tracking
Standby --> Replay
Calibration --> Standby: 标定完成
Teaching --> Standby
Teaching --> Tracking
Teaching --> Replay
Tracking --> Replay
Replay --> Tracking
Standby --> Emergency
Calibration --> Emergency
Teaching --> Emergency
Tracking --> Emergency
Replay --> Emergency
Emergency --> Standby: 只允许退回待机
```
`DeviceServer` 写模式命令字时会检查:
- 模式值必须在 `0 ~ 5`
- 任意模式都可以切到 `0 待机``5 急停`
- 急停后只能切回 `0 待机`,不能直接进入运行模式。
## 测试覆盖图
```mermaid
flowchart LR
ProtocolTests["protocol_tests.cpp<br/>CRC / 文档示例帧 / 位姿转换 / 解析器"] --> Protocol["协议层"]
HostTests["host_tests.cpp<br/>上位机请求构造 / 响应解析 / ACK 校验"] --> Host["HostClient"]
DeviceTests["device_tests.cpp<br/>寄存器读写 / 模式副作用 / 异常响应"] --> Device["DeviceServer + RegisterBank"]
Protocol --> Shared["公共字节序和 CRC 规则"]
Host --> Shared
Device --> Shared
```
## 为什么这样实现
```mermaid
flowchart TB
Requirement["需求<br/>高性能 / 低耗用 / 可迁移到驱动板"] --> NoThirdParty["不引入第三方库"]
Requirement --> ExplicitData["显式 uint8_t / uint16_t / uint32_t 类型"]
Requirement --> CompactBank["紧凑寄存器数组"]
Requirement --> NoTransportBinding["协议层不绑定串口库"]
NoThirdParty --> Result["减少依赖和移植成本"]
ExplicitData --> Result
CompactBank --> Result
NoTransportBinding --> Result
```
核心取舍:
- 公共协议代码只处理字节和寄存器,不负责串口收发,便于在 PC 和固件之间复用。
- 使用 `std::array` 存下位机寄存器表,避免动态映射结构的额外开销。
- 使用 `std::optional` 返回解析结果,避免异常依赖,适合固件侧错误处理。
- CRC、大小端、float 位模式转换都集中在 `protocol.cpp`,降低协议错误风险。
## 构建和测试
```bash
cmake -S cpp -B cpp/build
cmake --build cpp/build
ctest --test-dir cpp/build --output-on-failure
```
Windows/MSVC 下,`CMakeLists.txt` 已启用 `/utf-8`,用于正确编译中文注释。
`src/line_laser_modbus/py.typed` 是 PEP 561 类型标记文件。它可以为空,文件存在本身就表示这个包发布后应被类型检查器按 typed package 处理。
+12
View File
@@ -0,0 +1,12 @@
# TODO
- [ ] 补充 CLI 常用命令:读取当前位姿、写目标示教位姿、写纠偏量、持续轮询。
- [ ] 明确标定参数协议:地址、数量、顺序、单位、数据类型和写入时机。
- [ ] 实现标定参数写入接口,前提是协议参数定义已确认。
- [ ] 增加 CLI 模拟模式测试,覆盖常用命令输出和参数校验。
- [ ] 增加轮询异常测试,覆盖连续超时、恢复计数和不可恢复错误。
- [ ] 增加真实串口硬件联调记录,确认状态字、当前位姿、模式写入、目标位姿和纠偏量读写。
- [ ] 验证 50ms 轮询稳定性,记录实际通信耗时、超时次数和重试表现。
- [ ] 完善发布元数据,包括 license、authors、classifiers 和 project URLs。
- [ ] 评估是否加入类型检查和覆盖率统计。
- [ ] 根据现场设备参数更新或补充配置示例。
-251
View File
@@ -1,251 +0,0 @@
# 板卡运动控制适配说明
本文档说明如何把当前 C++ Modbus RTU 协议库迁移到真实驱动板,并对接板卡已有的运动控制模块。
## 迁移结论
你不需要重写 Modbus 协议层。板卡侧主要实现 `MotionControlAdapter` 接口,然后在串口收齐一帧 RTU 数据后调用 `MotionControlBridge::process_request()`
```mermaid
flowchart LR
UartRx["UART/RS-485 收完整 RTU 帧"] --> Bridge["MotionControlBridge"]
Bridge --> Server["DeviceServer"]
Server --> Bank["RegisterBank"]
Bridge --> Adapter["MotionControlAdapter 接口"]
Adapter --> Motion["板卡已有运动控制模块"]
Motion --> Adapter
Bridge --> UartTx["UART/RS-485 发响应帧"]
```
## 需要实现的接口
接口定义在 `include/line_laser_modbus/motion_adapter.hpp`
```cpp
class MotionControlAdapter {
public:
virtual ~MotionControlAdapter() = default;
virtual void on_mode_changed(WorkMode mode) = 0;
virtual void on_target_pose(const Pose6D& pose) = 0;
virtual void on_correction(const Pose6D& correction) = 0;
[[nodiscard]] virtual Pose6D current_pose() const = 0;
[[nodiscard]] virtual DeviceState current_state() const = 0;
};
```
每个函数的含义:
- `on_mode_changed()`:上位机写入 `0xD000` 模式命令字,且模式值和切换规则合法后调用。
- `on_target_pose()`:上位机写入 `0xD020 ~ 0xD02D` 目标示教位姿后调用。
- `on_correction()`:上位机写入 `0xD036 ~ 0xD043` 六轴纠偏量后调用。
- `current_pose()`:返回板卡当前实际位姿,桥接层会写入 `0xD00A ~ 0xD017`
- `current_state()`:返回板卡当前状态字,桥接层会写入 `0xD001`
## 推荐固件主循环
```mermaid
sequenceDiagram
participant UART as UART_RS485
participant Main as 固件主循环
participant Bridge as MotionControlBridge
participant Adapter as 你的板卡适配器
participant Motion as 运动控制模块
Motion-->>Adapter: 更新当前位姿和状态
UART-->>Main: 收到完整 RTU 请求帧
Main->>Bridge: process_request(request)
Bridge->>Adapter: current_pose()
Bridge->>Adapter: current_state()
Bridge->>Bridge: 写入当前位姿和状态寄存器
Bridge->>Bridge: DeviceServer 处理读写请求
Bridge->>Adapter: 写请求成功后回调模式/目标/纠偏
Adapter->>Motion: 转发给板卡运动控制逻辑
Bridge-->>Main: response
Main-->>UART: response 非空则发送
```
伪代码:
```cpp
#include "line_laser_modbus/motion_adapter.hpp"
class BoardMotionAdapter final : public line_laser_modbus::MotionControlAdapter {
public:
void on_mode_changed(line_laser_modbus::WorkMode mode) override {
// 调用你的板卡模式切换函数,例如 motion_set_mode(...)
}
void on_target_pose(const line_laser_modbus::Pose6D& pose) override {
// 把目标示教位姿送入轨迹/插补/记录模块
}
void on_correction(const line_laser_modbus::Pose6D& correction) override {
// 把实时纠偏量送入跟踪控制模块
}
line_laser_modbus::Pose6D current_pose() const override {
// 从板卡轴控反馈读取当前实际 XYZABC
return {};
}
line_laser_modbus::DeviceState current_state() const override {
// 从板卡报警、运行、急停、标定等状态生成协议状态字
return line_laser_modbus::DeviceState::StandbyReady;
}
};
line_laser_modbus::DeviceServer server;
BoardMotionAdapter adapter;
line_laser_modbus::MotionControlBridge bridge(server, adapter);
void on_complete_rtu_frame(const line_laser_modbus::ByteVector& request) {
const auto response = bridge.process_request(request);
if (!response.empty()) {
uart_send(response.data(), response.size());
}
}
```
## 数据流
```mermaid
flowchart TD
HostWriteMode["上位机写模式 0xD000"] --> ServerCheck["DeviceServer 校验模式合法性"]
ServerCheck --> AdapterMode["on_mode_changed(mode)"]
AdapterMode --> BoardMode["板卡切换运动模式"]
HostWriteTarget["上位机写目标位姿 0xD020"] --> BankTarget["RegisterBank 保存目标位姿"]
BankTarget --> AdapterTarget["on_target_pose(pose)"]
AdapterTarget --> BoardTarget["板卡轨迹/示教模块"]
HostWriteCorrection["上位机写纠偏量 0xD036"] --> BankCorrection["RegisterBank 保存纠偏量"]
BankCorrection --> AdapterCorrection["on_correction(correction)"]
AdapterCorrection --> BoardCorrection["板卡实时跟踪模块"]
BoardFeedback["板卡轴控反馈"] --> AdapterFeedback["current_pose/current_state"]
AdapterFeedback --> BridgePublish["MotionControlBridge 发布到寄存器"]
BridgePublish --> HostRead["上位机读取当前位姿/状态"]
```
## 模式和状态映射建议
```mermaid
flowchart LR
subgraph WorkMode["WorkMode 模式命令"]
M0["0 待机复位"]
M1["1 系统标定"]
M2["2 焊前扫描示教"]
M3["3 在线全轴跟踪"]
M4["4 轨迹批量复现"]
M5["5 紧急停止"]
end
subgraph BoardAction["板卡动作"]
A0["清空轨迹/纠偏/停机待命"]
A1["执行标定流程"]
A2["接收并记录目标轨迹点"]
A3["启用实时纠偏叠加"]
A4["调用已保存标准轨迹"]
A5["断使能/急停制动"]
end
M0 --> A0
M1 --> A1
M2 --> A2
M3 --> A3
M4 --> A4
M5 --> A5
```
`current_state()` 建议从板卡真实状态生成:
- 无故障待命:`DeviceState::StandbyReady`
- 运动中:`DeviceState::MotionRunning`
- 示教完成:`DeviceState::TeachingComplete`
- 在线跟踪正常:`DeviceState::OnlineTrackingNormal`
- 任意报警:`DeviceState::Alarm`
- 标定完成:`DeviceState::CalibrationComplete`
- 急停触发:`DeviceState::EmergencyTriggered`
## 线程和中断注意事项
如果板卡上通信任务和运动控制任务不是同一个线程,需要处理共享数据同步。
```mermaid
flowchart TB
UartTask["通信任务<br/>调用 MotionControlBridge"] --> Shared["共享运动数据快照"]
MotionTask["运动控制任务<br/>更新轴位姿和状态"] --> Shared
Shared --> Lock["互斥锁 / 临界区 / 双缓冲"]
```
建议:
- 不要在串口中断里直接执行复杂运动控制逻辑;中断只收字节,完整帧交给通信任务处理。
- `current_pose()``current_state()` 应尽量只读取快照,避免阻塞。
- `on_correction()` 如果运行在通信任务中,应把纠偏量写入线程安全队列或双缓冲,再由运动控制周期消费。
- 如果驱动板不支持 C++ 异常,当前实现仍可使用,因为协议和适配层没有依赖异常流程。
## 与现有代码的关系
```mermaid
classDiagram
class MotionControlAdapter {
<<interface>>
+on_mode_changed(mode)
+on_target_pose(pose)
+on_correction(correction)
+current_pose() Pose6D
+current_state() DeviceState
}
class MotionControlBridge {
-DeviceServer server_
-MotionControlAdapter adapter_
+process_request(request)
+publish_feedback()
}
class DeviceServer {
+process_request(request)
+bank()
}
class RegisterBank {
+read(address, quantity)
+write(address, registers)
+set_current_pose(pose)
+set_state(state)
}
MotionControlBridge --> MotionControlAdapter
MotionControlBridge --> DeviceServer
DeviceServer --> RegisterBank
```
## Demo
`apps/adapter_demo.cpp` 提供了一个模拟板卡适配器:
- `DemoBoardMotion` 实现 `MotionControlAdapter`
- 上位机 demo 写入在线跟踪模式、目标位姿和纠偏量。
- 桥接层把这些写请求回调给 `DemoBoardMotion`
- 读取当前位姿时,桥接层先从 `DemoBoardMotion::current_pose()` 发布反馈,再返回 Modbus 响应。
构建后可运行:
```bash
cmake --build cpp/build --config Release
cpp/build/Release/adapter_demo.exe
```
## 最小迁移清单
- 实现一个继承 `MotionControlAdapter` 的板卡适配类。
- 在 UART/RS-485 层实现完整 RTU 帧接收和响应发送。
-`MotionControlBridge::process_request()` 连接协议处理。
-`on_mode_changed()` 中接入板卡模式切换。
-`on_target_pose()` 中接入轨迹示教/记录模块。
-`on_correction()` 中接入实时纠偏模块。
-`current_pose()``current_state()` 中返回板卡真实反馈。
-91
View File
@@ -1,91 +0,0 @@
#include "line_laser_modbus/host.hpp"
#include "line_laser_modbus/motion_adapter.hpp"
#include <iomanip>
#include <iostream>
namespace {
void print_frame(const char* title, const line_laser_modbus::ByteVector& frame) {
std::cout << title;
for (const std::uint8_t byte : frame) {
std::cout << std::hex << std::uppercase << std::setw(2) << std::setfill('0')
<< static_cast<int>(byte) << ' ';
}
std::cout << std::dec << '\n';
}
class DemoBoardMotion final : public line_laser_modbus::MotionControlAdapter {
public:
void on_mode_changed(const line_laser_modbus::WorkMode mode) override {
mode_ = mode;
std::cout << "adapter: mode changed to "
<< static_cast<std::uint16_t>(mode_) << '\n';
}
void on_target_pose(const line_laser_modbus::Pose6D& pose) override {
target_pose_ = pose;
std::cout << "adapter: target pose timestamp "
<< target_pose_.timestamp_ms << '\n';
}
void on_correction(const line_laser_modbus::Pose6D& correction) override {
correction_ = correction;
current_pose_.x += correction_.x;
current_pose_.y += correction_.y;
current_pose_.z += correction_.z;
std::cout << "adapter: correction applied dx=" << correction_.x
<< ", dy=" << correction_.y << ", dz=" << correction_.z << '\n';
}
[[nodiscard]] line_laser_modbus::Pose6D current_pose() const override {
return current_pose_;
}
[[nodiscard]] line_laser_modbus::DeviceState current_state() const override {
if (mode_ == line_laser_modbus::WorkMode::OnlineTracking) {
return line_laser_modbus::DeviceState::OnlineTrackingNormal;
}
return line_laser_modbus::DeviceState::StandbyReady;
}
private:
line_laser_modbus::WorkMode mode_ =
line_laser_modbus::WorkMode::StandbyReset;
line_laser_modbus::Pose6D current_pose_{100U, 10.0F, 20.0F, 30.0F,
1.0F, 2.0F, 3.0F};
line_laser_modbus::Pose6D target_pose_{};
line_laser_modbus::Pose6D correction_{};
};
} // 匿名命名空间
int main() {
line_laser_modbus::HostClient host;
line_laser_modbus::DeviceServer server;
DemoBoardMotion board;
line_laser_modbus::MotionControlBridge bridge(server, board);
const auto mode_request =
host.make_write_mode_request(line_laser_modbus::WorkMode::OnlineTracking);
const auto mode_response = bridge.process_request(mode_request);
print_frame("write mode response: ", mode_response);
const line_laser_modbus::Pose6D target{200U, 100.0F, 200.0F, 300.0F,
10.0F, 20.0F, 30.0F};
const auto target_response =
bridge.process_request(host.make_write_target_pose_request(target));
print_frame("write target response: ", target_response);
const line_laser_modbus::Pose6D correction{250U, 0.5F, -0.25F, 1.0F,
0.0F, 0.0F, 0.0F};
const auto correction_response =
bridge.process_request(host.make_write_correction_request(correction));
print_frame("write correction response: ", correction_response);
const auto pose_response =
bridge.process_request(host.make_read_current_pose_request());
print_frame("read pose response: ", pose_response);
return 0;
}
-35
View File
@@ -1,35 +0,0 @@
#include "line_laser_modbus/device.hpp"
#include "line_laser_modbus/host.hpp"
#include <iomanip>
#include <iostream>
namespace {
void print_frame(const line_laser_modbus::ByteVector& frame) {
for (const std::uint8_t byte : frame) {
std::cout << std::hex << std::uppercase << std::setw(2) << std::setfill('0')
<< static_cast<int>(byte) << ' ';
}
std::cout << std::dec << '\n';
}
} // 匿名命名空间
int main() {
// 下位机示例:使用内存寄存器表处理一个主站请求。固件在 UART 收齐一个
// 完整 RTU 帧后,可以调用 process_request() 生成响应。
line_laser_modbus::DeviceServer device;
line_laser_modbus::HostClient host;
device.bank().set_current_pose(
line_laser_modbus::Pose6D{1234U, 10.0F, 20.0F, 30.0F, 1.0F, 2.0F, 3.0F});
const auto request = host.make_read_current_pose_request();
const auto response = device.process_request(request);
std::cout << "Read current pose response: ";
print_frame(response);
return 0;
}
-34
View File
@@ -1,34 +0,0 @@
#include "line_laser_modbus/host.hpp"
#include <iomanip>
#include <iostream>
namespace {
void print_frame(const line_laser_modbus::ByteVector& frame) {
for (const std::uint8_t byte : frame) {
std::cout << std::hex << std::uppercase << std::setw(2) << std::setfill('0')
<< static_cast<int>(byte) << ' ';
}
std::cout << std::dec << '\n';
}
} // 匿名命名空间
int main() {
// 上位机示例:这里只构造协议帧。实际部署时,应通过串口/RS-485 驱动发送
// 返回的字节,并把收到的响应交给 HostClient 的解析方法。
const line_laser_modbus::HostClient host;
const auto status_request = host.make_read_status_request();
std::cout << "Read mode/state request: ";
print_frame(status_request);
const line_laser_modbus::Pose6D correction{
1000U, 1.0F, 2.0F, 3.0F, 0.0F, 1.0F, 2.0F};
const auto correction_request = host.make_write_correction_request(correction);
std::cout << "Write correction request: ";
print_frame(correction_request);
return 0;
}
+13
View File
@@ -0,0 +1,13 @@
[serial]
port = "COM1"
slave_id = 8
baudrate = 115200
bytesize = 8
parity = "N"
stopbits = 1
timeout = 0.15
retries = 3
[polling]
interval_seconds = 0.05
max_timeouts = 3
+81
View File
@@ -0,0 +1,81 @@
# 协议更新记录
## V1.4 更新(2026-06-22
### 寄存器映射
- 新增 `0xD002` 可用缓存数量寄存器,数量为 `1`,数据类型为 `ushort`,通信方向为“控制器→视觉”。
- 可用缓存数量用于反馈控制器当前可用缓存数量;当数量为 `0` 时,控制器不再接收目标位姿。
- 写入 `0xD020` 目标示教位姿前,必须确保 `0xD002` 可用缓存数量大于 `0`
- 原预留扩展寄存器区间由 `0xD002 ~ 0xD009`、数量 `8` 调整为 `0xD003 ~ 0xD009`、数量 `7`
## V1.3 更新(2026-06-18
### 模式命令字
- 第 4.1 节模式命令字取值范围由 `0~5` 调整为 `0~4`
- 模式 `0` 由“待机复位模式”调整为“手动示教模式”。
- 模式 `0` 的主辅角色由“空闲态”调整为“控制器完全主导”。
- 模式 `0` 的核心功能调整为“视觉仅监控状态,不下发任何控制指令”,适用场景调整为“上电初始”。
- 在线全轴跟踪模式表中的轮询周期由 `20ms` 调整为 `50ms`
- 模式定义表删除模式 `5`“紧急停止模式”;但 V1.3 原文地址映射表仍描述模式命令字取值范围 `0~5`,常用指令帧示例也仍保留 `0xD000=0`“进入待机复位模式”和 `0xD000=5`“紧急停止”,`docs/proto.md` 已按原文保留这些内容。
### 设备状态字
- 状态 `0` 由“待机就绪 / 0(待机复位)”调整为“空闲 / 任意模式”。
- 状态 `6` 的对应模式由 `5(急停)` 调整为“任意模式”。
- 状态 `6` 的说明删除“仅可切换至待机模式”,保留为“紧急停止指令已执行,控制器锁定所有运动”。
### 各模式通信行为
- 第 8.1 节由“待机复位模式(0)”调整为“手动示教模式(0)”。
- 模式 `0` 下视觉设备行为由“只读取状态字与当前位姿,不下发目标位姿、不下发纠偏量”调整为“只读取工作模式和状态字段,坐标无需读取”。
- 删除独立的第 8.6 节“紧急停止模式(5)”,改为在第 8.5 节后以“注意”形式描述急停触发后的通信行为。
### 状态机规则
- 删除“任意模式可随时切换至 0 待机、5 急停”。
- 删除“急停 5 只能退回 0 待机,不能直接进入运行模式”。
- “标定完成自动回待机”调整为“标定完成自动回空闲状态”。
- “示教完成可切待机、在线跟踪、批量复现”调整为“示教完成可切空闲、在线跟踪、批量复现”。
- 状态机规则序号由 6 条调整为 4 条。
### 代码适配要点
- 普通模式切换按第 4.1 节限定为 `0~4`,急停值 `5` 仅作为专用急停写入命令保留。
- 手动示教模式下轮询只读取模式命令字和设备状态字,不读取当前位姿寄存器。
- 在线全轴跟踪模式和轨迹批量复现模式均允许由上层纠偏策略写入 6 轴纠偏量。
## V1.2 更新(2026-05-25
### 寄存器映射
- 当前6轴位姿由 `0xD00A ~ 0xD015` 调整为 `0xD00A ~ 0xD017`,寄存器数量由 12 增加到 14。
- 目标示教位姿由 `0xD016 ~ 0xD021` 调整为 `0xD020 ~ 0xD02D`,寄存器数量由 12 增加到 14。
- 6轴全量纠偏量由 `0xD022 ~ 0xD02D` 调整为 `0xD036 ~ 0xD043`,寄存器数量由 12 增加到 14。
- 复合数据块的数据类型按 V1.2 原文标记为 `uint32``float`
- 新增预留扩展区:`0xD018 ~ 0xD01F``0xD02E ~ 0xD035``0xD044 ~ 0xD04B`
- 标定参数预留区由 `0xD02E ~ 0xD04D` 调整为 `0xD04C ~ 0xD06B`
### 数据格式
- 当前位姿、目标示教位姿、6轴纠偏量的数据格式由 6 个 `float` 调整为 `uint32` 时间戳 + 6 个 `float`
- 三类6轴数据的排布顺序由 `X→Y→Z→A→B→C` 调整为 `TS→X→Y→Z→A→B→C`
- 时间戳单位为 `ms`,占用 2 个连续保持寄存器。
- 第 5 节总述删除旧版末尾补充说明“解析规则一致。”。
### 指令示例
- 读当前6轴位姿示例由读取 12 个寄存器调整为读取 14 个寄存器:`08 03 D0 0A 00 0E DC 55`
- 写6轴全量纠偏量示例调整为从 `0xD036` 写入 14 个寄存器,并增加 `ts=1000` 示例数据。
- V1.2 原文中的写6轴全量纠偏量示例帧使用从站地址 `01`,而协议核心规则仍声明从站地址固定为 `0x08``docs/proto.md` 已按 V1.2 原文保留该示例。
### 通信时序
- 在线全轴跟踪模式通信行为规范中的固定轮询周期由 `20ms` 调整为 `50ms`V1.2 原文模式表仍保留 `20ms` 描述,`docs/proto.md` 按 V1.2 原文分别保留。
- 视觉设备独立线程轮询周期由 `20ms` 调整为 `50ms`
- 单帧通信超时由 `50ms` 调整为 `150ms`
### 状态机规则
- 模式状态机第 3 条删除旧版补充说明“控制器内部处理”,调整为 V1.2 原文“标定完成自动回待机”。
+250
View File
@@ -0,0 +1,250 @@
# 线激光器上位机与运动控制器 Modbus RTU 通信协议
## 1. 协议概述
本协议用于焊缝识别设备(简称视觉设备)(主站)与运动控制器(从站)之间的双向通信,实现6轴位置/姿态数据传输、工作模式控制、设备状态反馈,覆盖系统标定、焊前示教、在线跟踪、轨迹批量复现全工作阶段。协议采用Modbus RTU标准,支持后期切换至Modbus TCP(寄存器地址、数据格式、功能码完全不变)。
## 2. 基础通信参数
### 2.1 串口参数
| 参数名称 | 参数值 | 说明 |
| -------- | -------------- | ------------------------------------- |
| 波特率 | 115200 bps | |
| 数据位 | 8位 | |
| 停止位 | 1位 | |
| 校验位 | 无校验(8N1) | 依赖CRC16校验保证数据完整性,简化配置 |
| 帧间隔 | ≥3.5个字符时间 | Modbus RTU标准要求,避免帧粘连 |
### 2.2 协议核心规则
| 规则名称 | 具体要求 | 说明 |
| ------------------ | -------------------------------------------------- | -------------------------------------------------- |
| 主从角色 | 主站:视觉设备<br>从站:运动控制器 | 主站主动发起读写请求,从站被动响应、执行指令 |
| 从站地址(Slave ID) | 0x08(固定) | 后期切换Modbus TCP时,Unit ID保持一致 |
| 寄存器类型 | 仅使用保持寄存器(Holding Register) | 支持读写操作,适配位置、姿态、命令、状态等数据传输 |
| 核心功能码 | 0x03(读保持寄存器), 0x10(写多个保持寄存器) | 不使用其他功能码 |
| 字节序 | 大端模式(Big-Endian) | 高寄存器高字节在前,解析时需按此规则拼接数据 |
| 编址方式 | 寄存器从0xD000开始绝对编址 | 后期可切换到Modbus TCP |
| CRC16校验 | 多项式0xA001,初始值0xFFFF,低字节在前、高字节在后 | 每帧数据末尾添加2字节CRC校验,确保数据传输无误 |
## 3. 保持寄存器地址映射
| 起始地址(十六进制) | 寄存器数量 | 数据类型 | 通信方向 | 寄存器名称 | 详细说明 |
| ------------------ | ---------- | -------- | ----------- | -------------- | ----------------------------------------------------------------- |
| 0xD000 | 1 | ushort | 视觉→控制器 | 模式命令字 | 控制控制器切换工作模式,取值范围0~5(详见4.1) |
| 0xD001 | 1 | ushort | 控制器→视觉 | 设备状态字 | 反馈控制器当前运行状态,取值范围0~6(详见4.2) |
| 0xD002 | 1 | ushort | 控制器→视觉 | 可用缓存数量 | 反馈控制器当前可用缓存数量,数量为0时,控制器不再接收目标位姿,0xD020写入目标位姿前务必确保可用缓存数量大于0 |
| 0xD003 ~ 0xD009 | 7 | 保留 | — | 预留扩展寄存器 | 暂不使用,用于后期功能扩展(如新增参数配置) |
| 0xD00A | 14 | uint32<br>float | 控制器→视觉 | 4字节无符号整型时间戳+当前6轴位姿 | 反馈控制器当前实际位置(X/Y/Z)和姿态(A/B/C),具体排布详见5.1 |
| 0xD018 ~ 0xD01F | 8 | 保留 | — | 预留扩展寄存器 | 暂不使用,用于后期功能扩展 |
| 0xD020 | 14 | uint32<br>float | 视觉→控制器 | 4字节无符号整型时间戳+目标示教位姿 | 激光扫描后下发的标准轨迹位姿(X/Y/Z/A/B/C),具体排布详见5.2 |
| 0xD02E ~ 0xD035 | 8 | 保留 | — | 预留扩展寄存器 | 暂不使用,用于后期功能扩展 |
| 0xD036 | 14 | uint32<br>float | 视觉→控制器 | 4字节无符号整型时间戳+6轴全量纠偏量 | 实时下发的位置偏差(ΔX/ΔY/ΔZ)和姿态偏差(ΔA/ΔB/ΔC),具体排布详见5.3 |
| 0xD044 ~ 0xD04B | 8 | 保留 | — | 预留扩展寄存器 | 暂不使用,用于后期功能扩展 |
| 0xD04C ~ 0xD06B | 32 | float | — | 标定参数预留 | 用于存储手眼标定相关参数,后期可扩展使用 |
## 4. 模式命令字与设备状态字详细定义
### 4.1 模式命令字(地址:0xD000,视觉→控制器)
用于切换工作模式,**由运动控制上位机下发到运动控制器,视觉设备轮询运动控制器读取设置值并执行对应逻辑**,取值范围0~4。
| 模式值 | 模式名称 | 主辅角色 | 核心功能 | 适用场景 |
| ------ | ---------------- | ---------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| 0 | 手动示教模式 | 控制器完全主导 | 视觉仅监控状态,不下发任何控制指令 | 上电初始 |
| 1 | 系统标定模式 | 控制器主、视觉辅 | 控制器按标定流程自动走位到标定点;视觉读取控制器当前位姿,完成手眼标定计算,下发标定参数 | 设备首次调试、视觉与控制器坐标系校准 |
| 2 | 焊前扫描示教模式 | 视觉主、控制器辅 | 视觉扫描工件轮廓,拟合空间轨迹,循环下发目标示教位姿;控制器跟随走位,记录轨迹点并内部保存 | 批量生产前,生成标准工艺轨迹 |
| 3 | 在线全轴跟踪模式 | 视觉主、控制器辅 | 视觉50ms周期读取控制器当前位姿,计算6轴全量偏差并下发;控制器按“基准轨迹+实时纠偏”连续运动 | 工件偏差较大、需实时跟随的生产场景(如焊接、坡口切割) |
| 4 | 轨迹批量复现模式 | 控制器主、视觉辅 | 控制器调用内部保存的标准轨迹,自动循环批量运行;视觉仅监控位姿,小幅下发纠偏量,超差报警 | 工件一致性好、批量量产的场景 |
### 4.2 设备状态字(地址:0xD001,控制器→视觉)
用于反馈运动控制器当前的运行状态,由控制器实时更新,视觉设备读取后判断设备工况,取值范围0~6。
| 状态值 | 状态名称 | 对应模式 | 详细说明 |
| ------ | ------------ | ------------------------- | ------------------------------------------------------------------------ |
| 0 | 空闲 | 任意模式 | 控制器停机、无故障、无轨迹,等待模式指令下发 |
| 1 | 运动运行中 | 2(示教)、3(跟踪)、4(复现) | 控制器正在执行运动指令(跟随示教、实时跟踪、批量复现) |
| 2 | 示教完成 | 2(示教) | 视觉扫描示教完成,控制器已保存完整标准轨迹,可切换至跟踪或复现模式 |
| 3 | 在线跟踪正常 | 3(跟踪) | 实时跟踪正常,纠偏量在合理范围,无超差、无故障 |
| 4 | 设备报警 | 任意模式 | 出现异常(轨迹超差、限位触发、通信故障、电机故障等),控制器停机,等待排查 |
| 5 | 标定完成 | 1(标定) | 系统标定流程完成,坐标系校准成功,可切换至其他运行模式 |
| 6 | 急停已触发 | 任意模式 | 紧急停止指令已执行,控制器锁定所有运动 |
## 5. 6轴数据(位置/姿态/纠偏)详细格式
当前位姿、目标示教位姿、6轴纠偏量数据均由1个32位uint(时间戳)与6个32位float构成,排布顺序统一(TS→X→Y→Z→A→B→C),每个uint或float占用2个连续寄存器,大端模式存储。
### 5.1 当前6轴位姿(地址:0xD00A ~ 0xD017,控制器→视觉)
反馈控制器当前实际的位置和姿态,视觉设备读取后用于标定计算、跟踪偏差分析、状态监控。
| 寄存器地址范围 | 数据类型 | 对应物理量 | 单位 | 说明 |
| --------------- | -------- | ---------- | ----- | ----------------------------------- |
| 0xD00A ~ 0xD00B | uint32 | 时间戳 | ms | 指示当前数据时间 |
| 0xD00C ~ 0xD00D | float | X 位置 | mm | 空间X轴坐标,根据实际设备坐标系定义 |
| 0xD00E ~ 0xD00F | float | Y 位置 | mm | 空间Y轴坐标 |
| 0xD010 ~ 0xD011 | float | Z 位置 | mm | 空间Z轴坐标(垂直方向) |
| 0xD012 ~ 0xD013 | float | A 姿态角 | °(度) | 绕X轴翻滚角 |
| 0xD014 ~ 0xD015 | float | B 姿态角 | °(度) | 绕Y轴俯仰角 |
| 0xD016 ~ 0xD017 | float | C 姿态角 | °(度) | 绕Z轴偏航角 |
### 5.2 目标示教位姿(地址:0xD020 ~ 0xD02D,视觉→控制器)
激光扫描工件后,拟合生成的标准轨迹位姿,下发给控制器后,控制器跟随走位并保存轨迹,排布与当前位姿完全一致。
| 寄存器地址范围 | 数据类型 | 对应物理量 | 单位 | 说明 |
| --------------- | -------- | ------------ | ----- | --------------------- |
| 0xD020 ~ 0xD021 | uint32 | 时间戳 | ms | 指示当前数据时间 |
| 0xD022 ~ 0xD023 | float | X 目标位置 | mm | 示教轨迹的X轴坐标 |
| 0xD024 ~ 0xD025 | float | Y 目标位置 | mm | 示教轨迹的Y轴坐标 |
| 0xD026 ~ 0xD027 | float | Z 目标位置 | mm | 示教轨迹的Z轴坐标 |
| 0xD028 ~ 0xD029 | float | A 目标姿态角 | °(度) | 示教轨迹的绕X轴翻滚角 |
| 0xD02A ~ 0xD02B | float | B 目标姿态角 | °(度) | 示教轨迹的绕Y轴俯仰角 |
| 0xD02C ~ 0xD02D | float | C 目标姿态角 | °(度) | 示教轨迹的绕Z轴偏航角 |
### 5.3 6轴全量纠偏量(地址:0xD036 ~ 0xD043,视觉→控制器)
视觉设备根据当前位姿与目标轨迹的偏差,实时计算并下发纠偏数据,控制器接收后叠加到基准轨迹,实现高精度跟踪。
| 寄存器地址范围 | 数据类型 | 对应偏差量 | 单位 | 说明 |
| --------------- | -------- | ---------- | ----- | ----------------------------------------- |
| 0xD036 ~ 0xD037 | uint32 | 时间戳 | ms | 指示当前数据时间 |
| 0xD038 ~ 0xD039 | float | ΔX 偏差 | mm | X轴方向偏差,正值=正向纠偏,负值=反向纠偏 |
| 0xD03A ~ 0xD03B | float | ΔY 偏差 | mm | Y轴方向偏差 |
| 0xD03C ~ 0xD03D | float | ΔZ 偏差 | mm | Z轴方向偏差 |
| 0xD03E ~ 0xD03F | float | ΔA 偏差 | °(度) | 绕X轴姿态偏差 |
| 0xD040 ~ 0xD041 | float | ΔB 偏差 | °(度) | 绕Y轴姿态偏差 |
| 0xD042 ~ 0xD043 | float | ΔC 偏差 | °(度) | 绕Z轴姿态偏差 |
## 6. Modbus RTU 帧格式标准
所有通信帧均遵循以下格式,CRC16校验码位于帧末尾,用于验证数据完整性;仅使用0x03(读)和0x10(写)两个功能码。
### 6.1 读保持寄存器(功能码:0x03)
用于视觉设备读取控制器的寄存器数据(如当前位姿、设备状态字),控制器被动响应并返回数据。
#### 6.1.1 请求帧格式(主站→从站)
| 字节位置 | 字节长度 | 内容 | 说明 |
| -------- | -------- | -------------- | -------------------------------------------------- |
| 1 | 1B | 从站地址 | 固定为0x08 |
| 2 | 1B | 功能码 | 0x03(读保持寄存器) |
| 3~4 | 2B | 起始寄存器地址 | 大端模式,如读取模式+状态,起始地址为0xD000 |
| 5~6 | 2B | 读取寄存器数量 | 大端模式,如读取2个寄存器(模式+状态),数量为0x0002 |
| 7~8 | 2B | CRC16校验码 | 低字节在前、高字节在后 |
#### 6.1.2 响应帧格式(从站→主站)
| 字节位置 | 字节长度 | 内容 | 说明 |
| -------- | -------- | ------------ | ------------------------------------------------- |
| 1 | 1B | 从站地址 | 固定为0x08,与请求帧一致 |
| 2 | 1B | 功能码 | 0x03,与请求帧一致 |
| 3 | 1B | 数据字节长度 | 后续数据的总字节数(1个16位ushort=2B1个float=4B) |
| 4~N | NB | 寄存器数据 | 按大端模式排列,连续存储 |
| N+1~N+2 | 2B | CRC16校验码 | 低字节在前、高字节在后 |
### 6.2 写多个保持寄存器(功能码:0x10)
用于视觉设备向控制器下发数据(如模式命令、目标位姿、纠偏量),控制器接收后执行并返回确认帧。
#### 6.2.1 请求帧格式(主站→从站)
| 字节位置 | 字节长度 | 内容 | 说明 |
| -------- | -------- | -------------- | ------------------------------------------------- |
| 1 | 1B | 从站地址 | 固定为0x08 |
| 2 | 1B | 功能码 | 0x10(写多个保持寄存器) |
| 3~4 | 2B | 起始寄存器地址 | 大端模式,如写模式命令,起始地址为0xD000 |
| 5~6 | 2B | 写入寄存器数量 | 大端模式,如写1个寄存器(模式命令),数量为0x0001 |
| 7 | 1B | 后续字节数 | 写入数据的总字节数(1个16位ushort=2B1个float=4B) |
| 8~N | NB | 寄存器数据 | 按大端模式排列,连续存储 |
| N+1~N+2 | 2B | CRC16校验码 | 低字节在前、高字节在后 |
#### 6.2.2 响应帧格式(从站→主站)
控制器接收并执行写指令后,返回确认帧,仅反馈写指令的关键信息,不返回写入的数据。
| 字节位置 | 字节长度 | 内容 | 说明 |
| -------- | -------- | -------------- | ------------------------ |
| 1 | 1B | 从站地址 | 固定为0x08,与请求帧一致 |
| 2 | 1B | 功能码 | 0x10,与请求帧一致 |
| 3~4 | 2B | 起始寄存器地址 | 与请求帧一致,大端模式 |
| 5~6 | 2B | 写入寄存器数量 | 与请求帧一致,大端模式 |
| 7~8 | 2B | CRC16校验码 | 低字节在前、高字节在后 |
## 7. 常用指令帧示例
以下指令帧均为十六进制格式,CRC16校验码已计算(可根据实际数据重新校验),适配所有工作阶段,直接下发即可执行。
| 指令用途 | 指令帧(十六进制) | 说明 |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| 读模式命令字+设备状态字(一次读2个寄存器) | 08 03 D0 00 00 02 FC 52 | 从站0x08,读起始地址0xD000,读2个寄存器,CRC= FC 52 |
| 写模式:进入待机复位模式(0xD000=0) | 08 10 D0 00 00 01 02 00 00 1D CD | 从站0x08,写起始地址0xD000,写1个寄存器,数据=0x0000CRC=1D CD |
| 写模式:进入系统标定模式(0xD000=1) | 08 10 D0 00 00 01 02 00 01 DC 0D | 数据=0x0001CRC= DC 0D |
| 写模式:进入焊前扫描示教模式(0xD000=2) | 08 10 D0 00 00 01 02 00 02 9C 0C | 数据=0x0002CRC=9C 0C |
| 写模式:进入在线全轴跟踪模式(0xD000=3) | 08 10 D0 00 00 01 02 00 03 5D CC | 数据=0x0003CRC=5D CC |
| 写模式:进入轨迹批量复现模式(0xD000=4) | 08 10 D0 00 00 01 02 00 04 1C 0E | 数据=0x0004CRC=1C 0E |
| 写模式:紧急停止(0xD000=5) | 08 10 D0 00 00 01 02 00 05 DD CE | 数据=0x0005CRC= DD CE |
| 读当前6轴位姿(起始0xD00A,读14个寄存器) | 08 03 D0 0A 00 0E DC 55 | 读X/Y/Z/A/B/C当前位姿,CRC=DC55 |
| 写6轴全量纠偏量(起始0xD036,写14个寄存器) | 01 10 D0 36 00 0E 1C 00 00 03 E8 3F 80 00 00 40 00 00 00 40 40 00 00 00 00 00 00 3F 80 00 00 40 00 00 00 D0 72 | 示例数据:ts=1000<br>ΔX=1.0、ΔY=2.0、ΔZ=3.0、ΔA=0.0、ΔB=1.0、ΔC=2.0CRC=D072 |
| 读设备状态字(单独读,起始0xD001,读1个寄存器) | 08 03 D0 01 00 01 ED 93 | 单独读取当前设备状态,CRC= ED 93 |
## 8. 各模式下通信行为规范
明确各工作模式下,主站(视觉)与从站(控制器)的通信职责,确保主辅关系清晰、通信高效,避免数据冲突。
### 8.1 手动示教模式(0)
控制器:轴停机、轨迹清空、纠偏清零、就绪待命。
视觉设备:只读取工作模式和状态字段,坐标无需读取。
### 8.2 系统标定模式(1)
控制器:按标定流程自动走标定点位。
视觉设备:周期读取 6 轴当前位姿,进行手眼标定计算,不启用示教与实时纠偏。
### 8.3 焊前扫描示教模式(2)
控制器:跟随激光下发的目标 6 轴位姿运动,自动记录并保存完整轨迹。
视觉设备:扫描工件轮廓,连续生成并下发 XYZABC 目标示教位姿。
### 8.4 在线全轴跟踪模式(3)
视觉设备以 50ms 固定周期轮询:
读取控制器当前 6 轴位姿;
对比理论轨迹,计算 ΔX ΔY ΔZ ΔA ΔB ΔC 全量偏差;
写入 6 轴纠偏量寄存器。
控制器:基准轨迹叠加实时全轴纠偏,连续插补运动。
### 8.5 轨迹批量复现模式(4)
控制器主导:调用内部已保存标准轨迹,自动循环批量运行。
视觉辅助:仅周期读取位姿做轨迹监控;仅小装夹偏差做微量 6 轴纠偏;偏差超阈值直接报警停机,不重新规划轨迹。
注意:急停触发时,控制器立刻停止所有轴运动、锁定使能;视觉设备停止轨迹下发、停止纠偏输出,仅保持通信监听。
## 9 模式状态机切换规则
- (1)标定完成自动回空闲状态;
- (2)示教完成可切空闲、在线跟踪、批量复现;
- (3)在线跟踪与轨迹批量复现可互相切换;
- (4)非法模式值控制器直接拒收不执行。
## 10 通信时序与稳定性要求
- (1)视觉设备独立线程轮询周期:50ms;
- (2)单帧通信超时:150ms,超时重发,多次超时判定通信断开;
+22
View File
@@ -0,0 +1,22 @@
# 使用示例
这些示例用于说明 `line_laser_modbus` 作为库时的常见用法
运行前先同步依赖
```powershell
python -m uv sync --default-index https://mirrors.ustc.edu.cn/pypi/simple
```
无硬件环境优先运行模拟示例
```powershell
python -m uv run python examples/simulated_basic.py
python -m uv run python examples/simulated_polling.py
```
连接真实串口前先修改根目录 `config.toml`
```powershell
python -m uv run python examples/real_serial_read_status.py
```
+27
View File
@@ -0,0 +1,27 @@
"""真实串口读取状态示例"""
from pathlib import Path
from line_laser_modbus import AppConfig, LineLaserClient
def main() -> None:
"""从 config.toml 读取串口配置并读取设备状态"""
# 运行这个示例前先修改项目根目录的 config.toml
# port 需要改成现场电脑实际看到的串口名
# Windows 常见格式是 COM1 或 COM3
config = AppConfig.from_toml(Path("config.toml"))
# 真实串口路径不注入 backend
# LineLaserClient 会自动创建 pymodbus 串口客户端
with LineLaserClient(config.serial) as client:
status = client.read_status()
pose = client.read_current_pose()
print("设备状态", status.name)
print("当前位姿", pose)
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
"""模拟环境中的基础读写示例"""
from line_laser_modbus import LineLaserClient, ModeCommand, Pose6D, SerialConfig
from line_laser_modbus.simulator import SimulatedModbusBackend
def main() -> None:
"""演示不连接硬件时如何使用客户端"""
# 模拟后端实现了客户端需要的最小 Modbus 接口
# 这让开发和测试可以在没有串口和控制器的机器上运行
backend = SimulatedModbusBackend()
# 端口名在模拟模式下不会真正打开
# 这里保留 SIM 只是让配置含义更清晰
config = SerialConfig(port="SIM")
# 客户端支持上下文管理器
# 进入 with 时连接后端退出 with 时关闭后端
with LineLaserClient(config, backend=backend) as client:
print("初始状态", client.read_status().name)
# write_mode 会直接写寄存器不做状态机校验
# 如果业务需要遵守协议切换规则可以使用 switch_mode
client.write_mode(ModeCommand.ONLINE_TRACKING)
print("当前模式", client.read_mode().name)
# 目标示教位姿和纠偏量都使用 Pose6D 表达
# 字段顺序固定为 X Y Z A B C
target_pose = Pose6D(100.0, 20.0, 30.0, 0.0, 15.0, 90.0)
correction = Pose6D(1.0, 0.0, -0.5, 0.0, 0.0, 2.0)
client.write_target_pose(target_pose)
client.write_correction(correction)
# 模拟后端提供便捷读取方法
# 真实设备运行时通常由控制器消费这些寄存器
print("模拟目标位姿", backend.target_pose())
print("模拟纠偏量", backend.correction())
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
"""模拟环境中的单周期轮询示例"""
from line_laser_modbus import DeviceStatus, LineLaserClient, ModeCommand, Pose6D, SerialConfig
from line_laser_modbus.runner import PollingRunner, pose_delta
from line_laser_modbus.simulator import SimulatedModbusBackend
def main() -> None:
"""演示轮询运行器如何读取快照并写入纠偏量"""
# 先让模拟控制器处于在线跟踪模式
# 只有 ONLINE_TRACKING 模式下 PollingRunner 才会写入纠偏寄存器
backend = SimulatedModbusBackend(
mode=ModeCommand.ONLINE_TRACKING,
status=DeviceStatus.TRACKING_OK,
current_pose=Pose6D(10.0, 20.0, 30.0, 0.0, 1.0, 2.0),
)
# 这里假设目标轨迹点是下面这个位姿
# pose_delta 会生成一个函数用于计算 目标位姿减当前位姿
target_pose = Pose6D(11.0, 22.0, 33.0, 0.0, 1.5, 1.0)
correction_provider = pose_delta(target_pose)
with LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client:
runner = PollingRunner(client, correction_provider=correction_provider)
# run_once 只执行一个周期
# 测试和无硬件演示建议用它避免进入无限循环
snapshot = runner.run_once()
print("读取模式", snapshot.mode.name)
print("读取状态", snapshot.status.name)
print("读取当前位姿", snapshot.pose)
print("写入纠偏量", backend.correction())
if __name__ == "__main__":
main()
-71
View File
@@ -1,71 +0,0 @@
#pragma once
#include "line_laser_modbus/protocol.hpp"
#include <array>
namespace line_laser_modbus {
constexpr std::size_t kRegisterBankSize =
static_cast<std::size_t>(kLastMappedAddress - kModeCommandAddress + 1U);
class RegisterBank {
public:
RegisterBank();
// 地址检查集中在这里处理。V1.2 寄存器表从 0xD000 到 0xD06B 是连续区域,
// 包含预留寄存器,因此紧凑数组比 map 更快、更省内存,同时保留后续启用
// 预留寄存器的空间。
[[nodiscard]] bool contains(std::uint16_t start_address,
std::uint16_t quantity) const noexcept;
[[nodiscard]] std::optional<RegisterVector> read(
std::uint16_t start_address,
std::uint16_t quantity) const;
[[nodiscard]] bool write(std::uint16_t start_address,
const RegisterVector& registers);
[[nodiscard]] WorkMode mode() const noexcept;
[[nodiscard]] DeviceState state() const noexcept;
bool set_mode(WorkMode mode) noexcept;
void set_state(DeviceState state) noexcept;
void set_current_pose(const Pose6D& pose);
[[nodiscard]] std::optional<Pose6D> current_pose() const;
[[nodiscard]] std::optional<Pose6D> target_pose() const;
[[nodiscard]] std::optional<Pose6D> correction() const;
private:
[[nodiscard]] static std::size_t index_of(std::uint16_t address) noexcept;
void apply_mode_side_effects(WorkMode mode) noexcept;
std::array<std::uint16_t, kRegisterBankSize> registers_{};
};
// DeviceServer 是下位机/从站侧帧处理器。
//
// 固件应在 UART 收到一个完整 RTU 帧后再调用 process_request()。该类会校验
// CRC、功能码、地址范围和模式切换规则,然后返回可直接发送的完整响应帧。
// 如果返回空字节数组,表示该帧应被忽略;在共享 RS-485 总线上,错误从站
// 地址或 CRC 损坏的流量都属于这种情况。
class DeviceServer {
public:
explicit DeviceServer(std::uint8_t slave_id = kDefaultSlaveId);
[[nodiscard]] std::uint8_t slave_id() const noexcept;
[[nodiscard]] RegisterBank& bank() noexcept;
[[nodiscard]] const RegisterBank& bank() const noexcept;
[[nodiscard]] ByteVector process_request(const ByteVector& request);
private:
[[nodiscard]] ByteVector process_read_request(const ReadRequest& request) const;
[[nodiscard]] ByteVector process_write_request(const WriteRequest& request);
[[nodiscard]] bool mode_write_is_valid(const WriteRequest& request) const noexcept;
std::uint8_t slave_id_;
RegisterBank bank_;
};
} // 命名空间 line_laser_modbus
-44
View File
@@ -1,44 +0,0 @@
#pragma once
#include "line_laser_modbus/protocol.hpp"
namespace line_laser_modbus {
struct StatusSnapshot {
WorkMode mode = WorkMode::StandbyReset;
DeviceState state = DeviceState::StandbyReady;
};
// HostClient 是上位机/主站侧协议封装。
//
// 设计说明:
// - 它不持有串口对象。生产代码可以把返回的字节发送到 UART、RS-485、
// TCP 隧道或测试桩,不需要改动协议逻辑。
// - 每个 make_* 方法都会返回包含 CRC 的完整 Modbus RTU 帧。
// - 每个 parse_* 方法都会校验从站地址和期望的载荷形状,避免调用方误收
// 其他请求对应的响应。
class HostClient {
public:
explicit HostClient(std::uint8_t slave_id = kDefaultSlaveId) noexcept;
[[nodiscard]] std::uint8_t slave_id() const noexcept;
[[nodiscard]] ByteVector make_read_status_request() const;
[[nodiscard]] ByteVector make_read_current_pose_request() const;
[[nodiscard]] ByteVector make_write_mode_request(WorkMode mode) const;
[[nodiscard]] ByteVector make_write_target_pose_request(const Pose6D& pose) const;
[[nodiscard]] ByteVector make_write_correction_request(const Pose6D& correction) const;
[[nodiscard]] std::optional<StatusSnapshot> parse_status_response(
const ByteVector& frame) const;
[[nodiscard]] std::optional<Pose6D> parse_current_pose_response(
const ByteVector& frame) const;
[[nodiscard]] bool parse_write_ack(const ByteVector& frame,
std::uint16_t expected_start_address,
std::uint16_t expected_quantity) const;
private:
std::uint8_t slave_id_;
};
} // 命名空间 line_laser_modbus
@@ -1,56 +0,0 @@
#pragma once
#include "line_laser_modbus/device.hpp"
namespace line_laser_modbus {
// 板卡运动控制适配接口。
//
// 迁移到真实驱动板时,协议库不需要知道具体轴控、插补、报警和坐标系实现。
// 板卡侧只要实现这个接口,MotionControlBridge 就能把 Modbus 寄存器协议和
// 真实运动控制模块连接起来。
class MotionControlAdapter {
public:
virtual ~MotionControlAdapter() = default;
// 上位机写入 0xD000 模式命令字,并且 DeviceServer 已确认该模式合法后调用。
virtual void on_mode_changed(WorkMode mode) = 0;
// 上位机写入 0xD020 ~ 0xD02D 目标示教位姿后调用。
virtual void on_target_pose(const Pose6D& pose) = 0;
// 上位机写入 0xD036 ~ 0xD043 六轴纠偏量后调用。
virtual void on_correction(const Pose6D& correction) = 0;
// 返回板卡当前实际位姿。桥接层会把它写入 0xD00A ~ 0xD017。
[[nodiscard]] virtual Pose6D current_pose() const = 0;
// 返回板卡当前设备状态。桥接层会把它写入 0xD001。
[[nodiscard]] virtual DeviceState current_state() const = 0;
};
// 协议服务器与板卡运动控制之间的桥接器。
//
// 固件主循环可以直接调用 process_request()
// 1. 先把板卡实时反馈发布到 RegisterBank
// 2. 调用 DeviceServer 处理 RTU 请求;
// 3. 如果本帧是合法写请求,则把写入结果回调给 MotionControlAdapter。
class MotionControlBridge {
public:
MotionControlBridge(DeviceServer& server, MotionControlAdapter& adapter) noexcept;
[[nodiscard]] ByteVector process_request(const ByteVector& request);
// 没有上位机请求时,也可以周期调用该函数刷新当前位姿和状态寄存器。
void publish_feedback();
private:
[[nodiscard]] bool write_ack_matches(const ByteVector& response,
const WriteRequest& request) const;
void notify_adapter_for_write(const WriteRequest& request);
DeviceServer& server_;
MotionControlAdapter& adapter_;
};
} // 命名空间 line_laser_modbus
-155
View File
@@ -1,155 +0,0 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace line_laser_modbus {
// 以下协议常量来自 py/docs/proto.md V1.2。
//
// 协议有意只保留很小的 Modbus 接口面:功能码 0x03 用于读取保持寄存器,
// 功能码 0x10 用于写多个保持寄存器。把公共常量集中在这里,可以避免
// 上位机和下位机在寄存器表变更时出现不一致。
constexpr std::uint8_t kDefaultSlaveId = 0x08;
constexpr std::uint8_t kReadHoldingRegisters = 0x03;
constexpr std::uint8_t kWriteMultipleRegisters = 0x10;
constexpr std::uint16_t kModeCommandAddress = 0xD000;
constexpr std::uint16_t kDeviceStateAddress = 0xD001;
constexpr std::uint16_t kCurrentPoseAddress = 0xD00A;
constexpr std::uint16_t kTargetPoseAddress = 0xD020;
constexpr std::uint16_t kCorrectionAddress = 0xD036;
constexpr std::uint16_t kCalibrationReserveAddress = 0xD04C;
constexpr std::uint16_t kLastMappedAddress = 0xD06B;
constexpr std::uint16_t kPoseRegisterCount = 14;
constexpr std::size_t kAxisCount = 6;
using ByteVector = std::vector<std::uint8_t>;
using RegisterVector = std::vector<std::uint16_t>;
using PoseRegisters = std::array<std::uint16_t, kPoseRegisterCount>;
enum class WorkMode : std::uint16_t {
StandbyReset = 0,
Calibration = 1,
PreWeldTeaching = 2,
OnlineTracking = 3,
BatchReplay = 4,
EmergencyStop = 5,
};
enum class DeviceState : std::uint16_t {
StandbyReady = 0,
MotionRunning = 1,
TeachingComplete = 2,
OnlineTrackingNormal = 3,
Alarm = 4,
CalibrationComplete = 5,
EmergencyTriggered = 6,
};
struct Pose6D {
// 所有位姿类数据块都按下面的顺序存储:
// uint32 timestamp_ms, float X, float Y, float Z, float A, float B, float C。
//
// 每个 32 位值拆成两个 16 位保持寄存器,并按大端字序保存。最终 RTU
// 帧的 CRC 字节顺序仍然遵循 Modbus 标准:低字节在前,高字节在后。
std::uint32_t timestamp_ms = 0;
float x = 0.0F;
float y = 0.0F;
float z = 0.0F;
float a = 0.0F;
float b = 0.0F;
float c = 0.0F;
};
struct ReadRequest {
std::uint8_t slave_id = 0;
std::uint16_t start_address = 0;
std::uint16_t quantity = 0;
};
struct ReadResponse {
std::uint8_t slave_id = 0;
RegisterVector registers;
};
struct WriteRequest {
std::uint8_t slave_id = 0;
std::uint16_t start_address = 0;
RegisterVector registers;
};
struct WriteResponse {
std::uint8_t slave_id = 0;
std::uint16_t start_address = 0;
std::uint16_t quantity = 0;
};
struct ParseError {
std::string message;
};
template <typename T>
struct ParseResult {
// 这里使用 std::optional 而不是异常,让固件版本的错误处理保持显式且低开销。
// 调用方可以自行决定无效帧是忽略、记录、重试,还是转换为 Modbus 异常响应。
std::optional<T> value;
std::optional<ParseError> error;
[[nodiscard]] bool ok() const noexcept { return value.has_value(); }
};
[[nodiscard]] bool is_valid_work_mode(std::uint16_t raw) noexcept;
[[nodiscard]] bool is_valid_device_state(std::uint16_t raw) noexcept;
[[nodiscard]] bool is_mode_transition_allowed(WorkMode current,
WorkMode next) noexcept;
// CRC16 使用 Modbus RTU 参数:多项式 0xA001,初始值 0xFFFF。
// append_crc() 会按 Modbus RTU 要求先追加低字节,再追加高字节。
[[nodiscard]] std::uint16_t crc16(const std::uint8_t* data,
std::size_t size) noexcept;
[[nodiscard]] std::uint16_t crc16(const ByteVector& frame) noexcept;
[[nodiscard]] bool has_valid_crc(const ByteVector& frame) noexcept;
void append_crc(ByteVector& frame);
[[nodiscard]] std::uint16_t read_u16_be(const std::uint8_t* data) noexcept;
void append_u16_be(ByteVector& frame, std::uint16_t value);
// 在业务层位姿结构和协议文档规定的保持寄存器布局之间转换。
// 上位机和下位机共用这组函数,避免两边各自实现 float 序列化导致差异。
[[nodiscard]] PoseRegisters encode_pose_registers(const Pose6D& pose) noexcept;
[[nodiscard]] Pose6D decode_pose_registers(const PoseRegisters& registers) noexcept;
// 帧构造函数始终返回包含 CRC 的完整 RTU 帧。调用方应通过实际传输层原样发送。
[[nodiscard]] ByteVector build_read_request(std::uint8_t slave_id,
std::uint16_t start_address,
std::uint16_t quantity);
[[nodiscard]] ByteVector build_read_response(std::uint8_t slave_id,
const RegisterVector& registers);
[[nodiscard]] ByteVector build_write_request(std::uint8_t slave_id,
std::uint16_t start_address,
const RegisterVector& registers);
[[nodiscard]] ByteVector build_write_response(std::uint8_t slave_id,
std::uint16_t start_address,
std::uint16_t quantity);
[[nodiscard]] ByteVector build_exception_response(std::uint8_t slave_id,
std::uint8_t function_code,
std::uint8_t exception_code);
// 解析函数会先校验帧长度、功能码、字节数和 CRC,再返回结构化数据。
// 应用层地址范围不在这里检查,而是由 DeviceServer/RegisterBank 负责。
[[nodiscard]] ParseResult<ReadRequest> parse_read_request(const ByteVector& frame);
[[nodiscard]] ParseResult<ReadResponse> parse_read_response(const ByteVector& frame);
[[nodiscard]] ParseResult<WriteRequest> parse_write_request(const ByteVector& frame);
[[nodiscard]] ParseResult<WriteResponse> parse_write_response(const ByteVector& frame);
[[nodiscard]] RegisterVector pose_to_register_vector(const Pose6D& pose);
[[nodiscard]] std::optional<Pose6D> pose_from_register_vector(
const RegisterVector& registers);
} // 命名空间 line_laser_modbus
+33
View File
@@ -0,0 +1,33 @@
[project]
name = "line-laser-modbus"
version = "0.1.0"
description = "Modbus RTU protocol tools for line laser vision and motion controller communication"
readme = "README.md"
requires-python = ">=3.12,<3.13"
dependencies = [
"pymodbus[serial]>=3.11.3",
]
[project.scripts]
line-laser-modbus = "line_laser_modbus.cli:main"
[dependency-groups]
dev = [
"pytest>=9.0.1",
"ruff>=0.14.6",
]
[build-system]
requires = ["uv_build>=0.11.14,<0.12.0"]
build-backend = "uv_build"
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]
-235
View File
@@ -1,235 +0,0 @@
#include "line_laser_modbus/device.hpp"
namespace line_laser_modbus {
namespace {
constexpr std::uint8_t kIllegalFunction = 0x01;
constexpr std::uint8_t kIllegalDataAddress = 0x02;
constexpr std::uint8_t kIllegalDataValue = 0x03;
[[nodiscard]] bool range_has_address(const std::uint16_t start_address,
const std::size_t quantity,
const std::uint16_t address) noexcept {
return address >= start_address &&
static_cast<std::uint32_t>(address) <
static_cast<std::uint32_t>(start_address) + quantity;
}
} // 匿名命名空间
RegisterBank::RegisterBank() {
registers_.fill(0U);
set_mode(WorkMode::StandbyReset);
set_state(DeviceState::StandbyReady);
}
bool RegisterBank::contains(const std::uint16_t start_address,
const std::uint16_t quantity) const noexcept {
if (quantity == 0U || start_address < kModeCommandAddress) {
return false;
}
const std::uint32_t end_address =
static_cast<std::uint32_t>(start_address) + quantity - 1U;
return end_address <= kLastMappedAddress;
}
std::optional<RegisterVector> RegisterBank::read(
const std::uint16_t start_address,
const std::uint16_t quantity) const {
if (!contains(start_address, quantity)) {
return std::nullopt;
}
RegisterVector result;
result.reserve(quantity);
const std::size_t first = index_of(start_address);
for (std::size_t i = 0; i < quantity; ++i) {
result.push_back(registers_[first + i]);
}
return result;
}
bool RegisterBank::write(const std::uint16_t start_address,
const RegisterVector& registers) {
if (!contains(start_address, static_cast<std::uint16_t>(registers.size()))) {
return false;
}
const std::size_t first = index_of(start_address);
for (std::size_t i = 0; i < registers.size(); ++i) {
registers_[first + i] = registers[i];
}
if (range_has_address(start_address, registers.size(), kModeCommandAddress)) {
apply_mode_side_effects(mode());
}
return true;
}
WorkMode RegisterBank::mode() const noexcept {
return static_cast<WorkMode>(registers_[index_of(kModeCommandAddress)]);
}
DeviceState RegisterBank::state() const noexcept {
return static_cast<DeviceState>(registers_[index_of(kDeviceStateAddress)]);
}
bool RegisterBank::set_mode(const WorkMode next_mode) noexcept {
const WorkMode current = mode();
if (!is_mode_transition_allowed(current, next_mode)) {
return false;
}
registers_[index_of(kModeCommandAddress)] =
static_cast<std::uint16_t>(next_mode);
apply_mode_side_effects(next_mode);
return true;
}
void RegisterBank::set_state(const DeviceState state) noexcept {
registers_[index_of(kDeviceStateAddress)] =
static_cast<std::uint16_t>(state);
}
void RegisterBank::set_current_pose(const Pose6D& pose) {
const bool written = write(kCurrentPoseAddress, pose_to_register_vector(pose));
(void)written;
}
std::optional<Pose6D> RegisterBank::current_pose() const {
const auto registers = read(kCurrentPoseAddress, kPoseRegisterCount);
return registers.has_value() ? pose_from_register_vector(*registers)
: std::nullopt;
}
std::optional<Pose6D> RegisterBank::target_pose() const {
const auto registers = read(kTargetPoseAddress, kPoseRegisterCount);
return registers.has_value() ? pose_from_register_vector(*registers)
: std::nullopt;
}
std::optional<Pose6D> RegisterBank::correction() const {
const auto registers = read(kCorrectionAddress, kPoseRegisterCount);
return registers.has_value() ? pose_from_register_vector(*registers)
: std::nullopt;
}
std::size_t RegisterBank::index_of(const std::uint16_t address) noexcept {
return static_cast<std::size_t>(address - kModeCommandAddress);
}
void RegisterBank::apply_mode_side_effects(const WorkMode mode) noexcept {
// 状态更新保持确定且低开销。接入真实轴控制后,固件可以在该寄存器表之上
// 叠加更细的运动状态。
switch (mode) {
case WorkMode::StandbyReset:
set_state(DeviceState::StandbyReady);
break;
case WorkMode::EmergencyStop:
set_state(DeviceState::EmergencyTriggered);
break;
case WorkMode::OnlineTracking:
set_state(DeviceState::OnlineTrackingNormal);
break;
case WorkMode::Calibration:
case WorkMode::PreWeldTeaching:
case WorkMode::BatchReplay:
set_state(DeviceState::MotionRunning);
break;
}
}
DeviceServer::DeviceServer(const std::uint8_t slave_id)
: slave_id_(slave_id), bank_() {}
std::uint8_t DeviceServer::slave_id() const noexcept {
return slave_id_;
}
RegisterBank& DeviceServer::bank() noexcept {
return bank_;
}
const RegisterBank& DeviceServer::bank() const noexcept {
return bank_;
}
ByteVector DeviceServer::process_request(const ByteVector& request) {
if (request.size() < 4U) {
return {};
}
if (request[0] != slave_id_) {
return {};
}
if (!has_valid_crc(request)) {
return {};
}
if (request[1] == kReadHoldingRegisters) {
const auto parsed = parse_read_request(request);
return parsed.ok() ? process_read_request(*parsed.value)
: build_exception_response(slave_id_, request[1],
kIllegalDataValue);
}
if (request[1] == kWriteMultipleRegisters) {
const auto parsed = parse_write_request(request);
return parsed.ok() ? process_write_request(*parsed.value)
: build_exception_response(slave_id_, request[1],
kIllegalDataValue);
}
return build_exception_response(slave_id_, request[1], kIllegalFunction);
}
ByteVector DeviceServer::process_read_request(const ReadRequest& request) const {
const auto registers = bank_.read(request.start_address, request.quantity);
if (!registers.has_value()) {
return build_exception_response(slave_id_, kReadHoldingRegisters,
kIllegalDataAddress);
}
return build_read_response(slave_id_, *registers);
}
ByteVector DeviceServer::process_write_request(const WriteRequest& request) {
if (!bank_.contains(request.start_address,
static_cast<std::uint16_t>(request.registers.size()))) {
return build_exception_response(slave_id_, kWriteMultipleRegisters,
kIllegalDataAddress);
}
if (!mode_write_is_valid(request)) {
return build_exception_response(slave_id_, kWriteMultipleRegisters,
kIllegalDataValue);
}
if (!bank_.write(request.start_address, request.registers)) {
return build_exception_response(slave_id_, kWriteMultipleRegisters,
kIllegalDataAddress);
}
return build_write_response(slave_id_, request.start_address,
static_cast<std::uint16_t>(
request.registers.size()));
}
bool DeviceServer::mode_write_is_valid(const WriteRequest& request) const noexcept {
if (!range_has_address(request.start_address, request.registers.size(),
kModeCommandAddress)) {
return true;
}
const std::size_t mode_offset =
static_cast<std::size_t>(kModeCommandAddress - request.start_address);
const std::uint16_t raw_next = request.registers[mode_offset];
if (!is_valid_work_mode(raw_next)) {
return false;
}
return is_mode_transition_allowed(bank_.mode(),
static_cast<WorkMode>(raw_next));
}
} // 命名空间 line_laser_modbus
-72
View File
@@ -1,72 +0,0 @@
#include "line_laser_modbus/host.hpp"
namespace line_laser_modbus {
HostClient::HostClient(const std::uint8_t slave_id) noexcept
: slave_id_(slave_id) {}
std::uint8_t HostClient::slave_id() const noexcept {
return slave_id_;
}
ByteVector HostClient::make_read_status_request() const {
return build_read_request(slave_id_, kModeCommandAddress, 2U);
}
ByteVector HostClient::make_read_current_pose_request() const {
return build_read_request(slave_id_, kCurrentPoseAddress, kPoseRegisterCount);
}
ByteVector HostClient::make_write_mode_request(const WorkMode mode) const {
return build_write_request(
slave_id_, kModeCommandAddress,
RegisterVector{static_cast<std::uint16_t>(mode)});
}
ByteVector HostClient::make_write_target_pose_request(const Pose6D& pose) const {
return build_write_request(slave_id_, kTargetPoseAddress,
pose_to_register_vector(pose));
}
ByteVector HostClient::make_write_correction_request(const Pose6D& correction) const {
return build_write_request(slave_id_, kCorrectionAddress,
pose_to_register_vector(correction));
}
std::optional<StatusSnapshot> HostClient::parse_status_response(
const ByteVector& frame) const {
const auto parsed = parse_read_response(frame);
if (!parsed.ok() || parsed.value->slave_id != slave_id_ ||
parsed.value->registers.size() != 2U) {
return std::nullopt;
}
const std::uint16_t raw_mode = parsed.value->registers[0];
const std::uint16_t raw_state = parsed.value->registers[1];
if (!is_valid_work_mode(raw_mode) || !is_valid_device_state(raw_state)) {
return std::nullopt;
}
return StatusSnapshot{static_cast<WorkMode>(raw_mode),
static_cast<DeviceState>(raw_state)};
}
std::optional<Pose6D> HostClient::parse_current_pose_response(
const ByteVector& frame) const {
const auto parsed = parse_read_response(frame);
if (!parsed.ok() || parsed.value->slave_id != slave_id_) {
return std::nullopt;
}
return pose_from_register_vector(parsed.value->registers);
}
bool HostClient::parse_write_ack(const ByteVector& frame,
const std::uint16_t expected_start_address,
const std::uint16_t expected_quantity) const {
const auto parsed = parse_write_response(frame);
return parsed.ok() && parsed.value->slave_id == slave_id_ &&
parsed.value->start_address == expected_start_address &&
parsed.value->quantity == expected_quantity;
}
} // 命名空间 line_laser_modbus
+20
View File
@@ -0,0 +1,20 @@
"""线激光 Modbus 协议包"""
from line_laser_modbus.client import LineLaserClient
from line_laser_modbus.config import AppConfig, PollingConfig, SerialConfig
from line_laser_modbus.models import DeviceSnapshot, DeviceStatus, ModeCommand, Pose6D, TimedPose6D
from line_laser_modbus.runner import PollingRunner
# 公开库调用时最常用的类型和入口。
__all__ = [
"AppConfig",
"DeviceSnapshot",
"DeviceStatus",
"LineLaserClient",
"ModeCommand",
"PollingConfig",
"PollingRunner",
"Pose6D",
"SerialConfig",
"TimedPose6D",
]
+67
View File
@@ -0,0 +1,67 @@
"""命令行入口"""
from __future__ import annotations
import argparse
from pathlib import Path
from line_laser_modbus.client import LineLaserClient
from line_laser_modbus.config import AppConfig
from line_laser_modbus.models import NORMAL_MODE_COMMANDS, ModeCommand, Pose6D
from line_laser_modbus.runner import PollingRunner, pose_delta
from line_laser_modbus.simulator import SimulatedModbusBackend
def main() -> None:
"""解析命令行参数并执行对应协议动作"""
parser = argparse.ArgumentParser(prog="line-laser-modbus")
parser.add_argument("--config", default="config.toml", help="TOML config path")
parser.add_argument("--simulate", action="store_true", help="Use in-memory simulator")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("read-status")
write_mode = subparsers.add_parser("write-mode")
write_mode.add_argument(
"mode",
type=int,
choices=sorted(mode.value for mode in NORMAL_MODE_COMMANDS),
)
subparsers.add_parser("emergency-stop")
poll = subparsers.add_parser("poll-once")
poll.add_argument("--target", nargs=6, type=float, metavar=("X", "Y", "Z", "A", "B", "C"))
subparsers.add_parser("demo")
args = parser.parse_args()
app_config = AppConfig.from_toml(Path(args.config))
backend = SimulatedModbusBackend() if args.simulate else None
with LineLaserClient(app_config.serial, backend=backend) as client:
if args.command == "read-status":
print(client.read_status().name)
elif args.command == "write-mode":
client.write_mode(args.mode)
print(client.read_mode().name)
elif args.command == "emergency-stop":
client.trigger_emergency_stop()
print("EMERGENCY_STOP")
elif args.command == "poll-once":
target = Pose6D.from_iterable(args.target) if args.target else Pose6D.zeros()
snapshot = PollingRunner(
client,
correction_provider=pose_delta(target),
config=app_config.polling,
).run_once()
print(f"{snapshot.mode.name} {snapshot.status.name}")
elif args.command == "demo":
client.write_mode(ModeCommand.ONLINE_TRACKING)
PollingRunner(
client,
correction_provider=pose_delta(Pose6D(1.0, 2.0, 3.0, 0.0, 1.0, 2.0)),
config=app_config.polling,
).run_once()
print(client.read_mode().name)
if __name__ == "__main__":
main()
+235
View File
@@ -0,0 +1,235 @@
"""线激光 Modbus 协议客户端"""
from __future__ import annotations
from types import TracebackType
from typing import Protocol, Self
from pymodbus import ModbusException
from pymodbus.client import ModbusSerialClient
from line_laser_modbus.codec import (
decode_timed_pose,
decode_u16,
encode_timed_pose,
encode_u16,
)
from line_laser_modbus.config import SerialConfig
from line_laser_modbus.constants import (
ADDR_AVAILABLE_CACHE_COUNT,
ADDR_CORRECTION,
ADDR_CURRENT_POSE,
ADDR_DEVICE_STATUS,
ADDR_MODE_COMMAND,
ADDR_TARGET_POSE,
REGISTER_COUNT_POSE,
)
from line_laser_modbus.models import (
NORMAL_MODE_COMMANDS,
DeviceSnapshot,
DeviceStatus,
ModeCommand,
Pose6D,
TimedPose6D,
ensure_mode,
ensure_status,
validate_mode_switch,
)
class ModbusBackend(Protocol):
"""协议客户端需要的最小后端接口"""
def connect(self) -> bool:
"""打开后端连接"""
...
def close(self) -> None:
"""关闭后端连接"""
...
def read_holding_registers(self, address: int, *, count: int, device_id: int):
"""从设备读取保持寄存器"""
...
def write_registers(self, address: int, values: list[int], *, device_id: int):
"""向设备写入保持寄存器"""
...
class LineLaserClient:
"""面向协议语义的类型化读写客户端"""
def __init__(self, config: SerialConfig, backend: ModbusBackend | None = None) -> None:
"""创建真实串口客户端或注入模拟后端"""
self.config = config
self._backend = backend or ModbusSerialClient(
port=config.port,
baudrate=config.baudrate,
bytesize=config.bytesize,
parity=config.parity,
stopbits=config.stopbits,
timeout=config.timeout,
retries=config.retries,
)
def __enter__(self) -> Self:
"""进入上下文管理器时连接后端"""
self.connect()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
"""退出上下文管理器时关闭后端"""
self.close()
def connect(self) -> None:
"""连接配置中的 Modbus 设备"""
if not self._backend.connect():
msg = f"Unable to connect Modbus device on {self.config.port}"
raise ConnectionError(msg)
def close(self) -> None:
"""关闭当前 Modbus 连接"""
self._backend.close()
def read_mode(self) -> ModeCommand:
"""读取 0xD000 模式命令字"""
return ensure_mode(self._read_word(ADDR_MODE_COMMAND))
def write_mode(self, mode: int | ModeCommand) -> None:
"""不做状态机校验直接写入模式命令字"""
mode_command = ensure_mode(mode)
if mode_command not in NORMAL_MODE_COMMANDS:
msg = "Mode command 5 is reserved for emergency stop; use trigger_emergency_stop()"
raise ValueError(msg)
self._write_registers(ADDR_MODE_COMMAND, [encode_u16(mode_command.value)])
def trigger_emergency_stop(self) -> None:
"""按 V1.3 示例帧向模式命令字写入急停值 5"""
self._write_registers(ADDR_MODE_COMMAND, [encode_u16(ModeCommand.EMERGENCY_STOP.value)])
def switch_mode(self, mode: int | ModeCommand) -> None:
"""按协议状态机规则校验后切换模式"""
current = self.read_mode()
status = self.read_status()
target = validate_mode_switch(current, mode, status)
self.write_mode(target)
def read_status(self) -> DeviceStatus:
"""读取 0xD001 设备状态字"""
return ensure_status(self._read_word(ADDR_DEVICE_STATUS))
def read_available_cache_count(self) -> int:
"""读取 0xD002 目标位姿可用缓存数量"""
return self._read_word(ADDR_AVAILABLE_CACHE_COUNT)
def read_current_pose(self) -> Pose6D:
"""读取控制器当前 XYZABC 位姿"""
return self.read_current_timed_pose().pose
def read_current_timed_pose(self) -> TimedPose6D:
"""读取控制器当前时间戳和 XYZABC 位姿"""
registers = self._read_registers(ADDR_CURRENT_POSE, REGISTER_COUNT_POSE)
return decode_timed_pose(registers)
def read_snapshot(self) -> DeviceSnapshot:
"""读取模式状态和当前位姿组成一次逻辑快照"""
mode = self.read_mode()
status = self.read_status()
if mode is ModeCommand.MANUAL_TEACHING or status is DeviceStatus.EMERGENCY_TRIGGERED:
return DeviceSnapshot(mode, status, Pose6D.zeros(), 0)
timed_pose = self.read_current_timed_pose()
return DeviceSnapshot(
mode,
status,
timed_pose.pose,
timed_pose.timestamp,
)
def write_target_pose(self, pose: Pose6D, *, timestamp: int = 0) -> None:
"""写入示教目标 XYZABC 位姿"""
self.write_target_timed_pose(TimedPose6D.from_pose(pose, timestamp))
def write_target_timed_pose(self, data: TimedPose6D) -> None:
"""写入示教目标时间戳和 XYZABC 位姿"""
if self.read_available_cache_count() <= 0:
msg = "Target pose cache is full; available cache count at 0xD002 is 0"
raise RuntimeError(msg)
self._write_registers(ADDR_TARGET_POSE, encode_timed_pose(data))
def write_correction(self, pose: Pose6D, *, timestamp: int = 0) -> None:
"""写入实时 XYZABC 纠偏量"""
self.write_timed_correction(TimedPose6D.from_pose(pose, timestamp))
def write_timed_correction(self, data: TimedPose6D) -> None:
"""写入实时时间戳和 XYZABC 纠偏量"""
self._write_registers(ADDR_CORRECTION, encode_timed_pose(data))
def _read_word(self, address: int) -> int:
"""从绝对地址读取单个 uint16 寄存器"""
return decode_u16(self._read_registers(address, 1)[0])
def _read_registers(self, address: int, count: int) -> list[int]:
"""读取并校验连续保持寄存器"""
try:
response = self._backend.read_holding_registers(
address,
count=count,
device_id=self.config.slave_id,
)
except ModbusException as exc:
msg = f"Modbus read failed at 0x{address:04X}"
raise RuntimeError(msg) from exc
self._raise_on_error(response, f"Modbus read error at 0x{address:04X}")
return [decode_u16(register) for register in response.registers]
def _write_registers(self, address: int, registers: list[int]) -> None:
"""校验并写入连续保持寄存器"""
safe_registers = [encode_u16(register) for register in registers]
try:
response = self._backend.write_registers(
address,
safe_registers,
device_id=self.config.slave_id,
)
except ModbusException as exc:
msg = f"Modbus write failed at 0x{address:04X}"
raise RuntimeError(msg) from exc
self._raise_on_error(response, f"Modbus write error at 0x{address:04X}")
@staticmethod
def _raise_on_error(response, message: str) -> None:
"""在 pymodbus 返回异常响应时抛出运行时错误"""
# pymodbus 的异常响应不是 Python 异常需要显式判断
if response is None or response.isError():
raise RuntimeError(message)
+163
View File
@@ -0,0 +1,163 @@
"""寄存器和 RTU 帧编解码工具"""
from __future__ import annotations
import struct
from line_laser_modbus.constants import (
AXIS_NAMES,
FUNC_READ_HOLDING_REGISTERS,
FUNC_WRITE_MULTIPLE_REGISTERS,
REGISTER_COUNT_AXES,
REGISTER_COUNT_POSE,
REGISTER_COUNT_TIMESTAMP,
SLAVE_ID,
)
from line_laser_modbus.models import Pose6D, TimedPose6D
def encode_u16(value: int) -> int:
"""校验并返回一个可放入单个 Modbus 寄存器的值"""
if not 0 <= value <= 0xFFFF:
msg = f"Value out of uint16 range: {value}"
raise ValueError(msg)
return value
def decode_u16(register: int) -> int:
"""校验并解析单个无符号 16 位寄存器"""
return encode_u16(register)
def encode_u32(value: int) -> list[int]:
"""将一个 uint32 编码为两个大端寄存器"""
if not 0 <= value <= 0xFFFFFFFF:
msg = f"Value out of uint32 range: {value}"
raise ValueError(msg)
high, low = struct.unpack(">HH", struct.pack(">I", value))
return [high, low]
def decode_u32(registers: list[int] | tuple[int, int]) -> int:
"""将两个大端寄存器解析为一个 uint32"""
if len(registers) != REGISTER_COUNT_TIMESTAMP:
msg = f"uint32 requires 2 registers, got {len(registers)}"
raise ValueError(msg)
raw = struct.pack(">HH", encode_u16(registers[0]), encode_u16(registers[1]))
return struct.unpack(">I", raw)[0]
def encode_f32(value: float) -> list[int]:
"""将一个 float32 编码为两个大端寄存器"""
# 按协议要求使用大端字节序拆成两个保持寄存器
raw = struct.pack(">f", float(value))
high, low = struct.unpack(">HH", raw)
return [high, low]
def decode_f32(registers: list[int] | tuple[int, int]) -> float:
"""将两个大端寄存器解析为一个 float32"""
if len(registers) != 2:
msg = f"float32 requires 2 registers, got {len(registers)}"
raise ValueError(msg)
raw = struct.pack(">HH", encode_u16(registers[0]), encode_u16(registers[1]))
return struct.unpack(">f", raw)[0]
def encode_pose(pose: Pose6D) -> list[int]:
"""将 XYZABC 数据编码为时间戳 0 加十二个轴寄存器"""
return encode_timed_pose(TimedPose6D.from_pose(pose))
def encode_axes(pose: Pose6D) -> list[int]:
"""将 XYZABC 六轴数据编码为十二个保持寄存器"""
registers: list[int] = []
for value in pose.as_tuple():
registers.extend(encode_f32(value))
return registers
def decode_axes(registers: list[int] | tuple[int, ...]) -> Pose6D:
"""将十二个轴寄存器解析为 XYZABC 数据"""
if len(registers) != REGISTER_COUNT_AXES:
msg = f"Pose axes require {REGISTER_COUNT_AXES} registers, got {len(registers)}"
raise ValueError(msg)
values = [decode_f32(registers[index : index + 2]) for index in range(0, len(registers), 2)]
return Pose6D(**dict(zip(AXIS_NAMES, values, strict=True)))
def encode_timed_pose(data: TimedPose6D) -> list[int]:
"""将时间戳和 XYZABC 编码为十四个保持寄存器"""
return [*encode_u32(data.timestamp), *encode_axes(data.pose)]
def decode_timed_pose(registers: list[int] | tuple[int, ...]) -> TimedPose6D:
"""将十四个保持寄存器解析为时间戳和 XYZABC 数据"""
if len(registers) != REGISTER_COUNT_POSE:
msg = f"Timed pose requires {REGISTER_COUNT_POSE} registers, got {len(registers)}"
raise ValueError(msg)
timestamp = decode_u32(registers[:REGISTER_COUNT_TIMESTAMP])
pose = decode_axes(registers[REGISTER_COUNT_TIMESTAMP:])
return TimedPose6D(timestamp, pose)
def decode_pose(registers: list[int] | tuple[int, ...]) -> Pose6D:
"""将时间戳加六轴数据块解析为 XYZABC 数据"""
return decode_timed_pose(registers).pose
def crc16(data: bytes) -> int:
"""计算原始帧字节的 Modbus RTU CRC16"""
# Modbus RTU CRC16 低字节先发但整数内部按正常高低位保存
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x0001:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc & 0xFFFF
def append_crc(data: bytes) -> bytes:
"""给帧载荷追加 Modbus RTU CRC16 字节"""
crc = crc16(data)
return data + bytes((crc & 0xFF, crc >> 8))
def build_read_frame(address: int, count: int, slave_id: int = SLAVE_ID) -> bytes:
"""构造功能码 0x03 的原始读保持寄存器请求"""
payload = struct.pack(">BBHH", slave_id, FUNC_READ_HOLDING_REGISTERS, address, count)
return append_crc(payload)
def build_write_frame(address: int, registers: list[int], slave_id: int = SLAVE_ID) -> bytes:
"""构造功能码 0x10 的原始写多个寄存器请求"""
count = len(registers)
body = b"".join(struct.pack(">H", encode_u16(register)) for register in registers)
payload = struct.pack(
">BBHHB",
slave_id,
FUNC_WRITE_MULTIPLE_REGISTERS,
address,
count,
len(body),
)
return append_crc(payload + body)
+69
View File
@@ -0,0 +1,69 @@
"""运行配置模型"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
from line_laser_modbus.constants import (
DEFAULT_BAUDRATE,
DEFAULT_BYTESIZE,
DEFAULT_PARITY,
DEFAULT_POLLING_INTERVAL_SECONDS,
DEFAULT_RETRIES,
DEFAULT_STOPBITS,
DEFAULT_TIMEOUT_SECONDS,
SLAVE_ID,
)
@dataclass(frozen=True, slots=True)
class SerialConfig:
"""串口通信配置"""
port: str = "COM1"
slave_id: int = SLAVE_ID
baudrate: int = DEFAULT_BAUDRATE
bytesize: int = DEFAULT_BYTESIZE
parity: str = DEFAULT_PARITY
stopbits: int = DEFAULT_STOPBITS
timeout: float = DEFAULT_TIMEOUT_SECONDS
retries: int = DEFAULT_RETRIES
@classmethod
def from_toml(cls, path: str | Path) -> SerialConfig:
"""从 TOML 文件读取串口配置"""
with Path(path).open("rb") as file:
data = tomllib.load(file)
serial = data.get("serial", {})
return cls(**serial)
@dataclass(frozen=True, slots=True)
class PollingConfig:
"""轮询运行配置"""
interval_seconds: float = DEFAULT_POLLING_INTERVAL_SECONDS
max_timeouts: int = 3
@dataclass(frozen=True, slots=True)
class AppConfig:
"""应用运行配置"""
serial: SerialConfig = SerialConfig()
polling: PollingConfig = PollingConfig()
@classmethod
def from_toml(cls, path: str | Path) -> AppConfig:
"""从 TOML 文件读取完整应用配置"""
with Path(path).open("rb") as file:
data = tomllib.load(file)
# 配置文件只暴露运行需要的最小入口避免协议常量被外部误改
return cls(
serial=SerialConfig(**data.get("serial", {})),
polling=PollingConfig(**data.get("polling", {})),
)
+44
View File
@@ -0,0 +1,44 @@
"""来自 docs/proto.md 的协议常量"""
# 固定从站地址和功能码。
SLAVE_ID = 0x08
FUNC_READ_HOLDING_REGISTERS = 0x03
FUNC_WRITE_MULTIPLE_REGISTERS = 0x10
# 保持寄存器绝对地址。
ADDR_MODE_COMMAND = 0xD000
ADDR_DEVICE_STATUS = 0xD001
ADDR_AVAILABLE_CACHE_COUNT = 0xD002
ADDR_EXTENSION_RESERVED_1_START = 0xD003
ADDR_EXTENSION_RESERVED_1_END = 0xD009
ADDR_CURRENT_POSE = 0xD00A
ADDR_EXTENSION_RESERVED_2_START = 0xD018
ADDR_EXTENSION_RESERVED_2_END = 0xD01F
ADDR_TARGET_POSE = 0xD020
ADDR_EXTENSION_RESERVED_3_START = 0xD02E
ADDR_EXTENSION_RESERVED_3_END = 0xD035
ADDR_CORRECTION = 0xD036
ADDR_EXTENSION_RESERVED_4_START = 0xD044
ADDR_EXTENSION_RESERVED_4_END = 0xD04B
ADDR_CALIBRATION_RESERVED_START = 0xD04C
ADDR_CALIBRATION_RESERVED_END = 0xD06B
# 协议数据宽度和位姿字段顺序。
REGISTER_COUNT_WORD = 1
REGISTER_COUNT_TIMESTAMP = 2
REGISTER_COUNT_AXES = 12
REGISTER_COUNT_POSE = 14
REGISTER_COUNT_EXTENSION_RESERVED_1 = 7
REGISTER_COUNT_EXTENSION_RESERVED = 8
REGISTER_COUNT_CALIBRATION_RESERVED = 32
AXIS_NAMES = ("x", "y", "z", "a", "b", "c")
# 默认串口通信参数。
DEFAULT_BAUDRATE = 115200
DEFAULT_BYTESIZE = 8
DEFAULT_PARITY = "N"
DEFAULT_STOPBITS = 1
DEFAULT_TIMEOUT_SECONDS = 0.15
DEFAULT_RETRIES = 3
DEFAULT_POLLING_INTERVAL_SECONDS = 0.05
+159
View File
@@ -0,0 +1,159 @@
"""协议类型模型"""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
from typing import Self
class ModeCommand(IntEnum):
"""0xD000 模式命令字"""
MANUAL_TEACHING = 0
STANDBY_RESET = 0
CALIBRATION = 1
PRE_WELD_TEACHING = 2
ONLINE_TRACKING = 3
TRAJECTORY_REPLAY = 4
EMERGENCY_STOP = 5
class DeviceStatus(IntEnum):
"""0xD001 设备状态字"""
IDLE = 0
STANDBY_READY = 0
RUNNING = 1
TEACHING_DONE = 2
TRACKING_OK = 3
ALARM = 4
CALIBRATION_DONE = 5
EMERGENCY_TRIGGERED = 6
NORMAL_MODE_COMMANDS = frozenset(
{
ModeCommand.MANUAL_TEACHING,
ModeCommand.CALIBRATION,
ModeCommand.PRE_WELD_TEACHING,
ModeCommand.ONLINE_TRACKING,
ModeCommand.TRAJECTORY_REPLAY,
}
)
@dataclass(frozen=True, slots=True)
class DeviceSnapshot:
"""单次逻辑周期读取到的控制器状态"""
mode: ModeCommand
status: DeviceStatus
pose: Pose6D
timestamp: int = 0
@dataclass(frozen=True, slots=True)
class Pose6D:
"""XYZABC 位姿或纠偏向量"""
x: float
y: float
z: float
a: float
b: float
c: float
@classmethod
def zeros(cls) -> Self:
"""创建全零位姿"""
return cls(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
@classmethod
def from_iterable(cls, values: list[float] | tuple[float, ...]) -> Self:
"""从六个数值创建 XYZABC 位姿"""
if len(values) != 6:
msg = f"Pose6D requires 6 values, got {len(values)}"
raise ValueError(msg)
return cls(*(float(value) for value in values))
def as_tuple(self) -> tuple[float, float, float, float, float, float]:
"""按协议顺序返回六轴元组"""
return (self.x, self.y, self.z, self.a, self.b, self.c)
@dataclass(frozen=True, slots=True)
class TimedPose6D:
"""协议中的时间戳加 XYZABC 数据块"""
timestamp: int
pose: Pose6D
@classmethod
def from_pose(cls, pose: Pose6D, timestamp: int = 0) -> Self:
"""从普通六轴数据创建带时间戳数据块"""
return cls(timestamp, pose)
def ensure_mode(value: int | ModeCommand) -> ModeCommand:
"""校验并转换模式命令字"""
try:
return ModeCommand(value)
except ValueError as exc:
msg = f"Invalid mode command: {value}"
raise ValueError(msg) from exc
def ensure_status(value: int | DeviceStatus) -> DeviceStatus:
"""校验并转换设备状态字"""
try:
return DeviceStatus(value)
except ValueError as exc:
msg = f"Invalid device status: {value}"
raise ValueError(msg) from exc
def can_switch_mode(
current: int | ModeCommand,
target: int | ModeCommand,
status: int | DeviceStatus | None = None,
) -> bool:
"""判断模式切换是否符合协议状态机规则"""
current_mode = ensure_mode(current)
target_mode = ensure_mode(target)
current_status = ensure_status(status) if status is not None else None
if target_mode not in NORMAL_MODE_COMMANDS:
return False
if current_status is DeviceStatus.CALIBRATION_DONE:
return target_mode is ModeCommand.MANUAL_TEACHING
if current_status is DeviceStatus.TEACHING_DONE:
return target_mode in {
ModeCommand.MANUAL_TEACHING,
ModeCommand.ONLINE_TRACKING,
ModeCommand.TRAJECTORY_REPLAY,
}
if current_mode in {ModeCommand.ONLINE_TRACKING, ModeCommand.TRAJECTORY_REPLAY}:
return target_mode in {ModeCommand.ONLINE_TRACKING, ModeCommand.TRAJECTORY_REPLAY}
return True
def validate_mode_switch(
current: int | ModeCommand,
target: int | ModeCommand,
status: int | DeviceStatus | None = None,
) -> ModeCommand:
"""校验模式切换并返回目标模式"""
target_mode = ensure_mode(target)
if not can_switch_mode(current, target_mode, status):
msg = f"Illegal mode switch: {ensure_mode(current).name} -> {target_mode.name}"
raise ValueError(msg)
return target_mode
+1
View File
@@ -0,0 +1 @@
+90
View File
@@ -0,0 +1,90 @@
"""生产和模拟环境共用的轮询运行器"""
from __future__ import annotations
import time
from collections.abc import Callable
from line_laser_modbus.client import LineLaserClient
from line_laser_modbus.config import PollingConfig
from line_laser_modbus.constants import DEFAULT_POLLING_INTERVAL_SECONDS, DEFAULT_TIMEOUT_SECONDS
from line_laser_modbus.models import DeviceSnapshot, ModeCommand, Pose6D
CorrectionProvider = Callable[[DeviceSnapshot], Pose6D]
SnapshotHandler = Callable[[DeviceSnapshot], None]
CORRECTION_MODES = frozenset({ModeCommand.ONLINE_TRACKING, ModeCommand.TRAJECTORY_REPLAY})
def pose_delta(target: Pose6D) -> CorrectionProvider:
"""创建按目标位姿减当前位姿计算纠偏量的函数"""
def calculate(snapshot: DeviceSnapshot) -> Pose6D:
"""根据状态快照计算纠偏量"""
# 纠偏量按目标位姿减当前位姿计算实际项目可替换为轨迹规划结果
return Pose6D.from_iterable(
[
target_value - current
for target_value, current in zip(
target.as_tuple(),
snapshot.pose.as_tuple(),
strict=True,
)
]
)
return calculate
class PollingRunner:
"""按固定周期读取状态并在跟踪模式下写入纠偏量"""
def __init__(
self,
client: LineLaserClient,
correction_provider: CorrectionProvider | None = None,
snapshot_handler: SnapshotHandler | None = None,
config: PollingConfig | None = None,
) -> None:
"""创建轮询运行器"""
self.client = client
self.correction_provider = correction_provider
self.snapshot_handler = snapshot_handler
self.config = config or PollingConfig()
self.timeout_count = 0
def run_once(self) -> DeviceSnapshot:
"""执行一次读取和可选纠偏写入"""
snapshot = self.client.read_snapshot()
self.timeout_count = 0
if self.snapshot_handler:
self.snapshot_handler(snapshot)
if snapshot.mode in CORRECTION_MODES and self.correction_provider:
self.client.write_correction(self.correction_provider(snapshot))
return snapshot
def run_forever(self) -> None:
"""按配置周期持续运行直到出现不可恢复超时"""
while True:
started = time.monotonic()
try:
self.run_once()
except TimeoutError:
self.timeout_count += 1
if self.timeout_count >= self.config.max_timeouts:
raise
elapsed = time.monotonic() - started
time.sleep(max(0.0, self.config.interval_seconds - elapsed))
def default_polling_config() -> PollingConfig:
"""按协议默认超时时间生成轮询配置"""
max_timeouts = max(1, round(DEFAULT_TIMEOUT_SECONDS / DEFAULT_POLLING_INTERVAL_SECONDS))
return PollingConfig(
interval_seconds=DEFAULT_POLLING_INTERVAL_SECONDS,
max_timeouts=max_timeouts,
)
+126
View File
@@ -0,0 +1,126 @@
"""测试和演示使用的内存 Modbus 模拟器"""
from __future__ import annotations
from dataclasses import dataclass
from line_laser_modbus.codec import decode_pose, encode_pose, encode_u16
from line_laser_modbus.constants import (
ADDR_AVAILABLE_CACHE_COUNT,
ADDR_CORRECTION,
ADDR_CURRENT_POSE,
ADDR_DEVICE_STATUS,
ADDR_MODE_COMMAND,
ADDR_TARGET_POSE,
SLAVE_ID,
)
from line_laser_modbus.models import DeviceStatus, ModeCommand, Pose6D
@dataclass(slots=True)
class _ReadResponse:
"""模拟 pymodbus 读响应"""
registers: list[int]
def isError(self) -> bool:
"""返回响应是否为异常响应"""
return False
@dataclass(slots=True)
class _WriteResponse:
"""模拟 pymodbus 写响应"""
address: int
count: int
def isError(self) -> bool:
"""返回响应是否为异常响应"""
return False
class SimulatedModbusBackend:
"""不依赖硬件的 pymodbus 兼容后端"""
def __init__(
self,
*,
slave_id: int = SLAVE_ID,
mode: ModeCommand = ModeCommand.MANUAL_TEACHING,
status: DeviceStatus = DeviceStatus.IDLE,
available_cache_count: int = 1,
current_pose: Pose6D | None = None,
) -> None:
"""创建模拟后端并写入初始寄存器值"""
self.slave_id = slave_id
self.connected = False
self.registers: dict[int, int] = {}
self._seed(mode, status, available_cache_count, current_pose or Pose6D.zeros())
def connect(self) -> bool:
"""标记模拟后端为已连接"""
self.connected = True
return True
def close(self) -> None:
"""标记模拟后端为已关闭"""
self.connected = False
def read_holding_registers(self, address: int, *, count: int, device_id: int) -> _ReadResponse:
"""从模拟寄存器表读取连续保持寄存器"""
self._ensure_ready(device_id)
return _ReadResponse([self.registers.get(address + offset, 0) for offset in range(count)])
def write_registers(self, address: int, values: list[int], *, device_id: int) -> _WriteResponse:
"""向模拟寄存器表写入连续保持寄存器"""
self._ensure_ready(device_id)
for offset, value in enumerate(values):
self.registers[address + offset] = value
return _WriteResponse(address, len(values))
def target_pose(self) -> Pose6D:
"""读取模拟器中保存的目标示教位姿"""
return self._read_pose(ADDR_TARGET_POSE)
def correction(self) -> Pose6D:
"""读取模拟器中保存的实时纠偏量"""
return self._read_pose(ADDR_CORRECTION)
def _seed(
self,
mode: ModeCommand,
status: DeviceStatus,
available_cache_count: int,
pose: Pose6D,
) -> None:
"""写入模拟器初始模式状态和当前位姿"""
self.registers[ADDR_MODE_COMMAND] = mode.value
self.registers[ADDR_DEVICE_STATUS] = status.value
self.registers[ADDR_AVAILABLE_CACHE_COUNT] = encode_u16(available_cache_count)
for offset, value in enumerate(encode_pose(pose)):
self.registers[ADDR_CURRENT_POSE + offset] = value
def _read_pose(self, address: int) -> Pose6D:
"""从指定地址读取一组模拟位姿寄存器"""
return decode_pose([self.registers.get(address + offset, 0) for offset in range(14)])
def _ensure_ready(self, device_id: int) -> None:
"""检查连接状态和从站地址"""
# 模拟器也校验从站地址避免测试漏掉 Unit ID 配置
if not self.connected:
raise ConnectionError("Simulated backend is not connected")
if device_id != self.slave_id:
raise ConnectionError(f"Unexpected slave id: {device_id}")
-87
View File
@@ -1,87 +0,0 @@
#include "line_laser_modbus/motion_adapter.hpp"
namespace line_laser_modbus {
namespace {
[[nodiscard]] bool range_contains(const std::uint16_t start_address,
const std::size_t quantity,
const std::uint16_t address,
const std::uint16_t address_quantity) noexcept {
const std::uint32_t quantity32 = static_cast<std::uint32_t>(quantity);
const std::uint32_t start = start_address;
const std::uint32_t end = start + quantity32;
const std::uint32_t wanted_start = address;
const std::uint32_t wanted_end = wanted_start + address_quantity;
return wanted_start >= start && wanted_end <= end;
}
[[nodiscard]] std::optional<Pose6D> pose_from_write_request(
const WriteRequest& request,
const std::uint16_t pose_address) {
if (!range_contains(request.start_address, request.registers.size(),
pose_address, kPoseRegisterCount)) {
return std::nullopt;
}
const std::size_t first =
static_cast<std::size_t>(pose_address - request.start_address);
RegisterVector pose_registers;
pose_registers.reserve(kPoseRegisterCount);
for (std::size_t i = 0; i < kPoseRegisterCount; ++i) {
pose_registers.push_back(request.registers[first + i]);
}
return pose_from_register_vector(pose_registers);
}
} // 匿名命名空间
MotionControlBridge::MotionControlBridge(DeviceServer& server,
MotionControlAdapter& adapter) noexcept
: server_(server), adapter_(adapter) {}
ByteVector MotionControlBridge::process_request(const ByteVector& request) {
publish_feedback();
const auto parsed_write = parse_write_request(request);
const ByteVector response = server_.process_request(request);
if (parsed_write.ok() && write_ack_matches(response, *parsed_write.value)) {
notify_adapter_for_write(*parsed_write.value);
}
return response;
}
void MotionControlBridge::publish_feedback() {
server_.bank().set_current_pose(adapter_.current_pose());
server_.bank().set_state(adapter_.current_state());
}
bool MotionControlBridge::write_ack_matches(const ByteVector& response,
const WriteRequest& request) const {
const auto ack = parse_write_response(response);
return ack.ok() && ack.value->slave_id == server_.slave_id() &&
ack.value->start_address == request.start_address &&
ack.value->quantity == request.registers.size();
}
void MotionControlBridge::notify_adapter_for_write(const WriteRequest& request) {
if (range_contains(request.start_address, request.registers.size(),
kModeCommandAddress, 1U)) {
const std::size_t offset =
static_cast<std::size_t>(kModeCommandAddress - request.start_address);
adapter_.on_mode_changed(static_cast<WorkMode>(request.registers[offset]));
}
const auto target_pose = pose_from_write_request(request, kTargetPoseAddress);
if (target_pose.has_value()) {
adapter_.on_target_pose(*target_pose);
}
const auto correction = pose_from_write_request(request, kCorrectionAddress);
if (correction.has_value()) {
adapter_.on_correction(*correction);
}
}
} // 命名空间 line_laser_modbus
-349
View File
@@ -1,349 +0,0 @@
#include "line_laser_modbus/protocol.hpp"
#include <cstring>
namespace line_laser_modbus {
namespace {
// 这些常量保持为私有,因为调用方不应调整 CRC 算法。协议文档已经固定了
// 参数,测试也会对照文档里的示例帧。
constexpr std::uint16_t kCrcInitial = 0xFFFF;
constexpr std::uint16_t kCrcPolynomial = 0xA001;
[[nodiscard]] ParseError make_error(const char* message) {
return ParseError{message};
}
template <typename T>
[[nodiscard]] ParseResult<T> fail(const char* message) {
return ParseResult<T>{std::nullopt, make_error(message)};
}
[[nodiscard]] std::uint32_t read_u32_from_registers(
const std::uint16_t high_word,
const std::uint16_t low_word) noexcept {
return (static_cast<std::uint32_t>(high_word) << 16U) |
static_cast<std::uint32_t>(low_word);
}
void write_u32_to_registers(const std::uint32_t value,
std::uint16_t& high_word,
std::uint16_t& low_word) noexcept {
high_word = static_cast<std::uint16_t>((value >> 16U) & 0xFFFFU);
low_word = static_cast<std::uint16_t>(value & 0xFFFFU);
}
[[nodiscard]] std::uint32_t float_to_u32(const float value) noexcept {
// 使用 memcpy 可以避开严格别名问题,并保留完整的 IEEE-754 位模式。
std::uint32_t raw = 0;
std::memcpy(&raw, &value, sizeof(raw));
return raw;
}
[[nodiscard]] float u32_to_float(const std::uint32_t raw) noexcept {
float value = 0.0F;
std::memcpy(&value, &raw, sizeof(value));
return value;
}
[[nodiscard]] bool check_common_response_prefix(const ByteVector& frame,
const std::uint8_t function_code,
const std::size_t min_size,
const char* type_name,
std::string& error) {
if (frame.size() < min_size) {
error = std::string(type_name) + " frame is too short";
return false;
}
if (!has_valid_crc(frame)) {
error = std::string(type_name) + " frame CRC is invalid";
return false;
}
if (frame[1] != function_code) {
error = std::string(type_name) + " frame has unexpected function code";
return false;
}
return true;
}
} // 匿名命名空间
bool is_valid_work_mode(const std::uint16_t raw) noexcept {
return raw <= static_cast<std::uint16_t>(WorkMode::EmergencyStop);
}
bool is_valid_device_state(const std::uint16_t raw) noexcept {
return raw <= static_cast<std::uint16_t>(DeviceState::EmergencyTriggered);
}
bool is_mode_transition_allowed(const WorkMode current,
const WorkMode next) noexcept {
// 协议规则:任意模式都可以切换到待机或急停。
if (next == WorkMode::StandbyReset || next == WorkMode::EmergencyStop) {
return true;
}
// 协议规则:急停模式只能退回待机,不能直接进入运行模式。
if (current == WorkMode::EmergencyStop) {
return false;
}
// 其余文档内模式之间视为合法运行切换。
return true;
}
std::uint16_t crc16(const std::uint8_t* data, const std::size_t size) noexcept {
std::uint16_t crc = kCrcInitial;
for (std::size_t i = 0; i < size; ++i) {
crc ^= data[i];
for (int bit = 0; bit < 8; ++bit) {
// Modbus CRC 按最低位优先右移;当移出的位为 1 时,应用反向多项式 0xA001。
if ((crc & 0x0001U) != 0U) {
crc = static_cast<std::uint16_t>((crc >> 1U) ^ kCrcPolynomial);
} else {
crc = static_cast<std::uint16_t>(crc >> 1U);
}
}
}
return crc;
}
std::uint16_t crc16(const ByteVector& frame) noexcept {
return crc16(frame.data(), frame.size());
}
bool has_valid_crc(const ByteVector& frame) noexcept {
if (frame.size() < 4U) {
return false;
}
const std::size_t payload_size = frame.size() - 2U;
const std::uint16_t expected = crc16(frame.data(), payload_size);
const std::uint16_t actual =
static_cast<std::uint16_t>(frame[payload_size]) |
(static_cast<std::uint16_t>(frame[payload_size + 1U]) << 8U);
return expected == actual;
}
void append_crc(ByteVector& frame) {
const std::uint16_t crc = crc16(frame.data(), frame.size());
frame.push_back(static_cast<std::uint8_t>(crc & 0x00FFU));
frame.push_back(static_cast<std::uint8_t>((crc >> 8U) & 0x00FFU));
}
std::uint16_t read_u16_be(const std::uint8_t* data) noexcept {
return static_cast<std::uint16_t>((static_cast<std::uint16_t>(data[0]) << 8U) |
static_cast<std::uint16_t>(data[1]));
}
void append_u16_be(ByteVector& frame, const std::uint16_t value) {
frame.push_back(static_cast<std::uint8_t>((value >> 8U) & 0x00FFU));
frame.push_back(static_cast<std::uint8_t>(value & 0x00FFU));
}
PoseRegisters encode_pose_registers(const Pose6D& pose) noexcept {
PoseRegisters registers{};
write_u32_to_registers(pose.timestamp_ms, registers[0], registers[1]);
// 轴字段按原始 IEEE-754 字节处理,保留负值、NaN/Inf 位模式,以及与控制器
// 完全一致的寄存器数据。
const std::array<float, kAxisCount> axes{pose.x, pose.y, pose.z,
pose.a, pose.b, pose.c};
for (std::size_t axis = 0; axis < axes.size(); ++axis) {
const std::uint32_t raw = float_to_u32(axes[axis]);
write_u32_to_registers(raw, registers[2U + axis * 2U],
registers[3U + axis * 2U]);
}
return registers;
}
Pose6D decode_pose_registers(const PoseRegisters& registers) noexcept {
Pose6D pose{};
pose.timestamp_ms = read_u32_from_registers(registers[0], registers[1]);
std::array<float*, kAxisCount> axes{&pose.x, &pose.y, &pose.z,
&pose.a, &pose.b, &pose.c};
for (std::size_t axis = 0; axis < axes.size(); ++axis) {
const std::uint32_t raw =
read_u32_from_registers(registers[2U + axis * 2U],
registers[3U + axis * 2U]);
*axes[axis] = u32_to_float(raw);
}
return pose;
}
ByteVector build_read_request(const std::uint8_t slave_id,
const std::uint16_t start_address,
const std::uint16_t quantity) {
ByteVector frame;
frame.reserve(8U);
frame.push_back(slave_id);
frame.push_back(kReadHoldingRegisters);
append_u16_be(frame, start_address);
append_u16_be(frame, quantity);
append_crc(frame);
return frame;
}
ByteVector build_read_response(const std::uint8_t slave_id,
const RegisterVector& registers) {
ByteVector frame;
frame.reserve(5U + registers.size() * 2U);
frame.push_back(slave_id);
frame.push_back(kReadHoldingRegisters);
frame.push_back(static_cast<std::uint8_t>(registers.size() * 2U));
for (const std::uint16_t reg : registers) {
append_u16_be(frame, reg);
}
append_crc(frame);
return frame;
}
ByteVector build_write_request(const std::uint8_t slave_id,
const std::uint16_t start_address,
const RegisterVector& registers) {
ByteVector frame;
frame.reserve(9U + registers.size() * 2U);
frame.push_back(slave_id);
frame.push_back(kWriteMultipleRegisters);
append_u16_be(frame, start_address);
append_u16_be(frame, static_cast<std::uint16_t>(registers.size()));
frame.push_back(static_cast<std::uint8_t>(registers.size() * 2U));
for (const std::uint16_t reg : registers) {
append_u16_be(frame, reg);
}
append_crc(frame);
return frame;
}
ByteVector build_write_response(const std::uint8_t slave_id,
const std::uint16_t start_address,
const std::uint16_t quantity) {
ByteVector frame;
frame.reserve(8U);
frame.push_back(slave_id);
frame.push_back(kWriteMultipleRegisters);
append_u16_be(frame, start_address);
append_u16_be(frame, quantity);
append_crc(frame);
return frame;
}
ByteVector build_exception_response(const std::uint8_t slave_id,
const std::uint8_t function_code,
const std::uint8_t exception_code) {
ByteVector frame;
frame.reserve(5U);
frame.push_back(slave_id);
frame.push_back(static_cast<std::uint8_t>(function_code | 0x80U));
frame.push_back(exception_code);
append_crc(frame);
return frame;
}
ParseResult<ReadRequest> parse_read_request(const ByteVector& frame) {
if (frame.size() != 8U) {
return fail<ReadRequest>("read request frame size must be 8 bytes");
}
if (!has_valid_crc(frame)) {
return fail<ReadRequest>("read request CRC is invalid");
}
if (frame[1] != kReadHoldingRegisters) {
return fail<ReadRequest>("read request function code is not 0x03");
}
return ParseResult<ReadRequest>{
ReadRequest{frame[0], read_u16_be(&frame[2]), read_u16_be(&frame[4])},
std::nullopt};
}
ParseResult<ReadResponse> parse_read_response(const ByteVector& frame) {
std::string error;
if (!check_common_response_prefix(frame, kReadHoldingRegisters, 5U,
"read response", error)) {
return fail<ReadResponse>(error.c_str());
}
const std::uint8_t byte_count = frame[2];
if ((byte_count % 2U) != 0U) {
return fail<ReadResponse>("read response byte count must be even");
}
if (frame.size() != static_cast<std::size_t>(byte_count) + 5U) {
return fail<ReadResponse>("read response size does not match byte count");
}
RegisterVector registers;
registers.reserve(byte_count / 2U);
for (std::size_t i = 0; i < byte_count; i += 2U) {
registers.push_back(read_u16_be(&frame[3U + i]));
}
return ParseResult<ReadResponse>{ReadResponse{frame[0], registers},
std::nullopt};
}
ParseResult<WriteRequest> parse_write_request(const ByteVector& frame) {
if (frame.size() < 9U) {
return fail<WriteRequest>("write request frame is too short");
}
if (!has_valid_crc(frame)) {
return fail<WriteRequest>("write request CRC is invalid");
}
if (frame[1] != kWriteMultipleRegisters) {
return fail<WriteRequest>("write request function code is not 0x10");
}
const std::uint16_t quantity = read_u16_be(&frame[4]);
const std::uint8_t byte_count = frame[6];
if (byte_count != quantity * 2U) {
return fail<WriteRequest>("write request byte count does not match quantity");
}
if (frame.size() != static_cast<std::size_t>(byte_count) + 9U) {
return fail<WriteRequest>("write request size does not match byte count");
}
RegisterVector registers;
registers.reserve(quantity);
for (std::size_t i = 0; i < byte_count; i += 2U) {
registers.push_back(read_u16_be(&frame[7U + i]));
}
return ParseResult<WriteRequest>{
WriteRequest{frame[0], read_u16_be(&frame[2]), registers}, std::nullopt};
}
ParseResult<WriteResponse> parse_write_response(const ByteVector& frame) {
std::string error;
if (!check_common_response_prefix(frame, kWriteMultipleRegisters, 8U,
"write response", error)) {
return fail<WriteResponse>(error.c_str());
}
if (frame.size() != 8U) {
return fail<WriteResponse>("write response frame size must be 8 bytes");
}
return ParseResult<WriteResponse>{
WriteResponse{frame[0], read_u16_be(&frame[2]), read_u16_be(&frame[4])},
std::nullopt};
}
RegisterVector pose_to_register_vector(const Pose6D& pose) {
const PoseRegisters fixed = encode_pose_registers(pose);
return RegisterVector(fixed.begin(), fixed.end());
}
std::optional<Pose6D> pose_from_register_vector(const RegisterVector& registers) {
if (registers.size() != kPoseRegisterCount) {
return std::nullopt;
}
PoseRegisters fixed{};
for (std::size_t i = 0; i < fixed.size(); ++i) {
fixed[i] = registers[i];
}
return decode_pose_registers(fixed);
}
} // 命名空间 line_laser_modbus
+1
View File
@@ -0,0 +1 @@
-147
View File
@@ -1,147 +0,0 @@
// 本文件测试板卡运动控制适配层。验证 MotionControlBridge 是否会在处理请求前
// 发布当前位姿/状态,并在合法写模式、目标位姿和纠偏量后调用板卡适配接口。
#include "line_laser_modbus/host.hpp"
#include "line_laser_modbus/motion_adapter.hpp"
#include "test_support.hpp"
#include <iostream>
using namespace line_laser_modbus;
namespace {
class FakeMotion final : public MotionControlAdapter {
public:
void on_mode_changed(const WorkMode mode) override {
last_mode = mode;
++mode_calls;
}
void on_target_pose(const Pose6D& pose) override {
last_target = pose;
++target_calls;
}
void on_correction(const Pose6D& correction) override {
last_correction = correction;
++correction_calls;
}
[[nodiscard]] Pose6D current_pose() const override {
return pose;
}
[[nodiscard]] DeviceState current_state() const override {
return state;
}
Pose6D pose{123U, 1.0F, 2.0F, 3.0F, 4.0F, 5.0F, 6.0F};
DeviceState state = DeviceState::OnlineTrackingNormal;
WorkMode last_mode = WorkMode::StandbyReset;
Pose6D last_target{};
Pose6D last_correction{};
int mode_calls = 0;
int target_calls = 0;
int correction_calls = 0;
};
void test_bridge_publishes_feedback_before_read() {
HostClient host;
DeviceServer server;
FakeMotion motion;
MotionControlBridge bridge(server, motion);
const auto response = bridge.process_request(host.make_read_current_pose_request());
const auto parsed_pose = host.parse_current_pose_response(response);
test_support::require_true(parsed_pose.has_value(),
"bridge must return current pose response");
test_support::require_equal(parsed_pose->timestamp_ms, 123U,
"bridge must publish adapter timestamp");
test_support::require_float_close(parsed_pose->x, 1.0F,
"bridge must publish adapter pose x");
}
void test_bridge_notifies_mode_after_valid_write() {
HostClient host;
DeviceServer server;
FakeMotion motion;
MotionControlBridge bridge(server, motion);
const auto response =
bridge.process_request(host.make_write_mode_request(WorkMode::OnlineTracking));
test_support::require_true(host.parse_write_ack(response, kModeCommandAddress, 1U),
"valid mode write must be acknowledged");
test_support::require_equal(motion.mode_calls, 1,
"bridge must notify mode write once");
test_support::require_true(motion.last_mode == WorkMode::OnlineTracking,
"bridge must pass written mode to adapter");
}
void test_bridge_notifies_pose_writes() {
HostClient host;
DeviceServer server;
FakeMotion motion;
MotionControlBridge bridge(server, motion);
const Pose6D target{200U, 10.0F, 20.0F, 30.0F, 1.0F, 2.0F, 3.0F};
const auto target_response =
bridge.process_request(host.make_write_target_pose_request(target));
test_support::require_true(
host.parse_write_ack(target_response, kTargetPoseAddress, kPoseRegisterCount),
"target pose write must be acknowledged");
test_support::require_equal(motion.target_calls, 1,
"bridge must notify target pose write");
test_support::require_equal(motion.last_target.timestamp_ms, 200U,
"bridge must pass target timestamp");
test_support::require_float_close(motion.last_target.z, 30.0F,
"bridge must pass target z");
const Pose6D correction{201U, 0.5F, -0.25F, 1.5F, 0.0F, 0.0F, 0.0F};
const auto correction_response =
bridge.process_request(host.make_write_correction_request(correction));
test_support::require_true(
host.parse_write_ack(correction_response, kCorrectionAddress,
kPoseRegisterCount),
"correction write must be acknowledged");
test_support::require_equal(motion.correction_calls, 1,
"bridge must notify correction write");
test_support::require_float_close(motion.last_correction.y, -0.25F,
"bridge must pass correction y");
}
void test_bridge_does_not_notify_rejected_mode_write() {
HostClient host;
DeviceServer server;
FakeMotion motion;
MotionControlBridge bridge(server, motion);
const auto emergency_response =
bridge.process_request(host.make_write_mode_request(WorkMode::EmergencyStop));
test_support::require_true(
host.parse_write_ack(emergency_response, kModeCommandAddress, 1U),
"emergency mode write must be acknowledged");
const auto rejected =
bridge.process_request(host.make_write_mode_request(WorkMode::OnlineTracking));
test_support::require_true(!host.parse_write_ack(rejected, kModeCommandAddress, 1U),
"illegal mode transition must not be acknowledged");
test_support::require_equal(motion.mode_calls, 1,
"bridge must not notify rejected mode write");
test_support::require_true(motion.last_mode == WorkMode::EmergencyStop,
"last notified mode must remain emergency");
}
} // 匿名命名空间
int main() {
test_bridge_publishes_feedback_before_read();
test_bridge_notifies_mode_after_valid_write();
test_bridge_notifies_pose_writes();
test_bridge_does_not_notify_rejected_mode_write();
std::cout << "adapter_tests passed\n";
return 0;
}
-119
View File
@@ -1,119 +0,0 @@
// 本文件测试下位机/从站侧服务。验证寄存器表读写、完整请求到响应处理、
// 模式切换带来的状态副作用,以及急停后非法模式切换会被拒绝。
#include "line_laser_modbus/device.hpp"
#include "line_laser_modbus/host.hpp"
#include "test_support.hpp"
#include <iostream>
using namespace line_laser_modbus;
namespace {
void test_read_status_request_round_trip() {
DeviceServer device;
HostClient host;
const ByteVector response =
device.process_request(host.make_read_status_request());
const auto status = host.parse_status_response(response);
test_support::require_true(status.has_value(), "device must answer status read");
test_support::require_true(status->mode == WorkMode::StandbyReset,
"initial device mode must be standby");
test_support::require_true(status->state == DeviceState::StandbyReady,
"initial device state must be ready");
}
void test_write_mode_changes_state() {
DeviceServer device;
HostClient host;
const ByteVector ack =
device.process_request(host.make_write_mode_request(WorkMode::OnlineTracking));
test_support::require_true(
host.parse_write_ack(ack, kModeCommandAddress, 1U),
"device must ack valid online tracking mode write");
test_support::require_true(device.bank().mode() == WorkMode::OnlineTracking,
"device mode register must update");
test_support::require_true(device.bank().state() == DeviceState::OnlineTrackingNormal,
"device state must reflect online tracking");
}
void test_emergency_allows_only_standby_exit() {
DeviceServer device;
HostClient host;
const ByteVector emergency_ack =
device.process_request(host.make_write_mode_request(WorkMode::EmergencyStop));
test_support::require_true(host.parse_write_ack(emergency_ack, kModeCommandAddress, 1U),
"emergency mode write must be accepted");
const ByteVector rejected =
device.process_request(host.make_write_mode_request(WorkMode::OnlineTracking));
test_support::require_true(rejected.size() == 5U,
"illegal transition must return exception frame");
test_support::require_equal(rejected[1], static_cast<std::uint8_t>(0x90),
"write exception function code must be 0x90");
test_support::require_true(device.bank().mode() == WorkMode::EmergencyStop,
"illegal transition must not modify mode");
const ByteVector accepted =
device.process_request(host.make_write_mode_request(WorkMode::StandbyReset));
test_support::require_true(host.parse_write_ack(accepted, kModeCommandAddress, 1U),
"standby exit from emergency must be accepted");
}
void test_target_pose_write_and_current_pose_read() {
DeviceServer device;
HostClient host;
const Pose6D target{2000U, 10.0F, 20.0F, 30.0F, 1.0F, 2.0F, 3.0F};
const ByteVector write_ack =
device.process_request(host.make_write_target_pose_request(target));
test_support::require_true(
host.parse_write_ack(write_ack, kTargetPoseAddress, kPoseRegisterCount),
"device must ack target pose write");
const auto stored_target = device.bank().target_pose();
test_support::require_true(stored_target.has_value(),
"device must store target pose registers");
test_support::require_equal(stored_target->timestamp_ms, 2000U,
"target timestamp must store");
test_support::require_float_close(stored_target->z, 30.0F,
"target z must store");
device.bank().set_current_pose(target);
const auto read_response =
device.process_request(host.make_read_current_pose_request());
const auto current = host.parse_current_pose_response(read_response);
test_support::require_true(current.has_value(), "host must parse device pose read");
test_support::require_float_close(current->x, 10.0F, "current x must read");
}
void test_out_of_range_read_returns_exception() {
DeviceServer device;
const ByteVector request =
build_read_request(kDefaultSlaveId, static_cast<std::uint16_t>(0xD06C), 1U);
const ByteVector response = device.process_request(request);
test_support::require_true(response.size() == 5U,
"out-of-range read must return exception frame");
test_support::require_equal(response[1], static_cast<std::uint8_t>(0x83),
"read exception function code must be 0x83");
}
} // 匿名命名空间
int main() {
test_read_status_request_round_trip();
test_write_mode_changes_state();
test_emergency_allows_only_standby_exit();
test_target_pose_write_and_current_pose_read();
test_out_of_range_read_returns_exception();
std::cout << "device_tests passed\n";
return 0;
}
-78
View File
@@ -1,78 +0,0 @@
// 本文件测试上位机/主站侧封装。验证 HostClient 是否能构造文档要求的请求,
// 并且只接受从站地址、寄存器数量、地址和 Modbus CRC 都符合预期的响应。
#include "line_laser_modbus/host.hpp"
#include "test_support.hpp"
#include <iostream>
using namespace line_laser_modbus;
namespace {
void test_status_response_parse() {
const HostClient host;
const ByteVector response =
build_read_response(kDefaultSlaveId,
RegisterVector{static_cast<std::uint16_t>(
WorkMode::OnlineTracking),
static_cast<std::uint16_t>(
DeviceState::OnlineTrackingNormal)});
const auto status = host.parse_status_response(response);
test_support::require_true(status.has_value(),
"host must parse valid mode/state response");
test_support::require_true(status->mode == WorkMode::OnlineTracking,
"host must parse work mode");
test_support::require_true(status->state == DeviceState::OnlineTrackingNormal,
"host must parse device state");
}
void test_status_response_rejects_invalid_state() {
const HostClient host;
const ByteVector response = build_read_response(kDefaultSlaveId,
RegisterVector{0U, 99U});
test_support::require_true(!host.parse_status_response(response).has_value(),
"host must reject invalid state value");
}
void test_pose_response_parse() {
const HostClient host;
const Pose6D source{55U, -1.5F, 2.25F, 3.5F, 4.0F, 5.0F, 6.0F};
const ByteVector response =
build_read_response(kDefaultSlaveId, pose_to_register_vector(source));
const auto pose = host.parse_current_pose_response(response);
test_support::require_true(pose.has_value(), "host must parse current pose");
test_support::require_equal(pose->timestamp_ms, 55U,
"host must parse pose timestamp");
test_support::require_float_close(pose->x, -1.5F, "host must parse pose x");
test_support::require_float_close(pose->y, 2.25F, "host must parse pose y");
test_support::require_float_close(pose->z, 3.5F, "host must parse pose z");
}
void test_write_ack_parse() {
const HostClient host;
const ByteVector ack =
build_write_response(kDefaultSlaveId, kTargetPoseAddress, kPoseRegisterCount);
test_support::require_true(
host.parse_write_ack(ack, kTargetPoseAddress, kPoseRegisterCount),
"host must accept matching write ack");
test_support::require_true(
!host.parse_write_ack(ack, kCorrectionAddress, kPoseRegisterCount),
"host must reject ack for unexpected address");
}
} // 匿名命名空间
int main() {
test_status_response_parse();
test_status_response_rejects_invalid_state();
test_pose_response_parse();
test_write_ack_parse();
std::cout << "host_tests passed\n";
return 0;
}
-109
View File
@@ -1,109 +0,0 @@
// 本文件测试底层 Modbus RTU 协议实现:
// CRC16 是否匹配文档示例帧、大端寄存器打包是否正确、TS+XYZABC 位姿转换
// 是否可往返,以及请求/响应解析器是否严格校验。
#include "line_laser_modbus/protocol.hpp"
#include "test_support.hpp"
#include <array>
#include <iostream>
using namespace line_laser_modbus;
namespace {
void test_documented_read_frames() {
test_support::require_bytes_equal(
build_read_request(kDefaultSlaveId, kModeCommandAddress, 2U),
ByteVector{0x08, 0x03, 0xD0, 0x00, 0x00, 0x02, 0xFC, 0x52},
"read mode/state request must match protocol document");
test_support::require_bytes_equal(
build_read_request(kDefaultSlaveId, kCurrentPoseAddress, kPoseRegisterCount),
ByteVector{0x08, 0x03, 0xD0, 0x0A, 0x00, 0x0E, 0xDC, 0x55},
"read current pose request must match protocol document");
}
void test_documented_mode_write_frames() {
const std::array<ByteVector, 6U> expected_frames{{
{0x08, 0x10, 0xD0, 0x00, 0x00, 0x01, 0x02, 0x00, 0x00, 0x1D, 0xCD},
{0x08, 0x10, 0xD0, 0x00, 0x00, 0x01, 0x02, 0x00, 0x01, 0xDC, 0x0D},
{0x08, 0x10, 0xD0, 0x00, 0x00, 0x01, 0x02, 0x00, 0x02, 0x9C, 0x0C},
{0x08, 0x10, 0xD0, 0x00, 0x00, 0x01, 0x02, 0x00, 0x03, 0x5D, 0xCC},
{0x08, 0x10, 0xD0, 0x00, 0x00, 0x01, 0x02, 0x00, 0x04, 0x1C, 0x0E},
{0x08, 0x10, 0xD0, 0x00, 0x00, 0x01, 0x02, 0x00, 0x05, 0xDD, 0xCE},
}};
for (std::size_t mode = 0; mode < expected_frames.size(); ++mode) {
test_support::require_bytes_equal(
build_write_request(kDefaultSlaveId, kModeCommandAddress,
RegisterVector{static_cast<std::uint16_t>(mode)}),
expected_frames[mode],
"mode write request must match protocol document");
}
}
void test_pose_register_round_trip() {
const Pose6D source{1000U, 1.0F, 2.0F, 3.0F, 0.0F, 1.0F, 2.0F};
const RegisterVector registers = pose_to_register_vector(source);
test_support::require_equal(registers.size(), static_cast<std::size_t>(14U),
"pose must occupy 14 registers");
test_support::require_equal(registers[0], static_cast<std::uint16_t>(0x0000),
"timestamp high word must be big-endian");
test_support::require_equal(registers[1], static_cast<std::uint16_t>(0x03E8),
"timestamp low word must be big-endian");
test_support::require_equal(registers[2], static_cast<std::uint16_t>(0x3F80),
"float 1.0 high word must be big-endian");
test_support::require_equal(registers[3], static_cast<std::uint16_t>(0x0000),
"float 1.0 low word must be big-endian");
const auto decoded = pose_from_register_vector(registers);
test_support::require_true(decoded.has_value(), "pose decode must succeed");
test_support::require_equal(decoded->timestamp_ms, 1000U,
"timestamp must round-trip");
test_support::require_float_close(decoded->x, 1.0F, "x must round-trip");
test_support::require_float_close(decoded->y, 2.0F, "y must round-trip");
test_support::require_float_close(decoded->z, 3.0F, "z must round-trip");
test_support::require_float_close(decoded->a, 0.0F, "a must round-trip");
test_support::require_float_close(decoded->b, 1.0F, "b must round-trip");
test_support::require_float_close(decoded->c, 2.0F, "c must round-trip");
}
void test_parser_rejects_bad_crc() {
ByteVector frame = build_read_request(kDefaultSlaveId, kDeviceStateAddress, 1U);
frame.back() ^= 0x01U;
const auto parsed = parse_read_request(frame);
test_support::require_true(!parsed.ok(), "read request parser must reject bad CRC");
}
void test_write_request_parser() {
const ByteVector frame =
build_write_request(kDefaultSlaveId, kCorrectionAddress,
pose_to_register_vector(
Pose6D{1000U, 1.0F, 2.0F, 3.0F, 0.0F, 1.0F, 2.0F}));
const auto parsed = parse_write_request(frame);
test_support::require_true(parsed.ok(), "write request parser must accept valid frame");
test_support::require_equal(parsed.value->slave_id, kDefaultSlaveId,
"write request slave id must parse");
test_support::require_equal(parsed.value->start_address, kCorrectionAddress,
"write request start address must parse");
test_support::require_equal(parsed.value->registers.size(),
static_cast<std::size_t>(kPoseRegisterCount),
"write request register count must parse");
}
} // 匿名命名空间
int main() {
test_documented_read_frames();
test_documented_mode_write_frames();
test_pose_register_round_trip();
test_parser_rejects_bad_crc();
test_write_request_parser();
std::cout << "protocol_tests passed\n";
return 0;
}
+130
View File
@@ -0,0 +1,130 @@
import pytest
from line_laser_modbus.client import LineLaserClient
from line_laser_modbus.config import SerialConfig
from line_laser_modbus.constants import (
ADDR_AVAILABLE_CACHE_COUNT,
ADDR_CORRECTION,
ADDR_CURRENT_POSE,
ADDR_MODE_COMMAND,
ADDR_TARGET_POSE,
)
from line_laser_modbus.models import DeviceStatus, ModeCommand, Pose6D, TimedPose6D
from line_laser_modbus.simulator import SimulatedModbusBackend
class CountingBackend(SimulatedModbusBackend):
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.read_addresses: list[int] = []
def read_holding_registers(self, address: int, *, count: int, device_id: int):
self.read_addresses.append(address)
return super().read_holding_registers(address, count=count, device_id=device_id)
def test_client_reads_seeded_status_and_pose_from_simulator() -> None:
pose = Pose6D(10.0, 20.0, 30.0, 1.0, 2.0, 3.0)
backend = SimulatedModbusBackend(
status=DeviceStatus.TRACKING_OK,
current_pose=pose,
)
with LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client:
assert client.read_status() is DeviceStatus.TRACKING_OK
assert client.read_available_cache_count() == 1
assert client.read_current_pose() == pose
timed_pose = client.read_current_timed_pose()
assert timed_pose.timestamp == 0
assert timed_pose.pose == pose
def test_client_writes_mode_and_correction_to_simulator() -> None:
backend = SimulatedModbusBackend()
correction = Pose6D(1.0, 2.0, 3.0, 0.0, 1.0, 2.0)
with LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client:
client.write_mode(ModeCommand.ONLINE_TRACKING)
client.write_correction(correction)
assert client.read_mode() is ModeCommand.ONLINE_TRACKING
assert backend.correction() == correction
def test_client_rejects_emergency_stop_as_normal_mode_write() -> None:
backend = SimulatedModbusBackend()
with (
pytest.raises(ValueError, match="reserved for emergency stop"),
LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client,
):
client.write_mode(ModeCommand.EMERGENCY_STOP)
def test_client_triggers_emergency_stop_as_special_command() -> None:
backend = SimulatedModbusBackend()
with LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client:
client.trigger_emergency_stop()
assert backend.registers[ADDR_MODE_COMMAND] == ModeCommand.EMERGENCY_STOP.value
def test_snapshot_skips_pose_read_in_manual_teaching_mode() -> None:
backend = CountingBackend(mode=ModeCommand.MANUAL_TEACHING, status=DeviceStatus.IDLE)
with LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client:
snapshot = client.read_snapshot()
assert snapshot.mode is ModeCommand.MANUAL_TEACHING
assert snapshot.status is DeviceStatus.IDLE
assert snapshot.pose == Pose6D.zeros()
assert ADDR_CURRENT_POSE not in backend.read_addresses
def test_client_writes_timed_target_pose_to_simulator() -> None:
backend = SimulatedModbusBackend()
target = TimedPose6D(1234, Pose6D(1.0, 2.0, 3.0, 4.0, 5.0, 6.0))
with LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client:
client.write_target_timed_pose(target)
assert backend.target_pose() == target.pose
assert backend.registers[ADDR_TARGET_POSE] == 0x0000
assert backend.registers[ADDR_TARGET_POSE + 1] == 0x04D2
def test_client_rejects_target_pose_when_cache_is_full() -> None:
backend = SimulatedModbusBackend(available_cache_count=0)
target = TimedPose6D(1234, Pose6D(1.0, 2.0, 3.0, 4.0, 5.0, 6.0))
with (
pytest.raises(RuntimeError, match="Target pose cache is full"),
LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client,
):
client.write_target_timed_pose(target)
assert ADDR_TARGET_POSE not in backend.registers
assert backend.registers[ADDR_AVAILABLE_CACHE_COUNT] == 0
def test_client_writes_timed_correction_to_simulator() -> None:
backend = SimulatedModbusBackend()
correction = TimedPose6D(1000, Pose6D(1.0, 2.0, 3.0, 0.0, 1.0, 2.0))
with LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client:
client.write_timed_correction(correction)
assert backend.correction() == correction.pose
assert backend.registers[ADDR_CORRECTION] == 0x0000
assert backend.registers[ADDR_CORRECTION + 1] == 0x03E8
def test_simulator_rejects_wrong_slave_id() -> None:
backend = SimulatedModbusBackend()
with (
pytest.raises(ConnectionError),
LineLaserClient(SerialConfig(port="SIM", slave_id=0x09), backend=backend) as client,
):
client.read_status()
+48
View File
@@ -0,0 +1,48 @@
from line_laser_modbus.codec import (
build_read_frame,
build_write_frame,
decode_pose,
decode_timed_pose,
encode_pose,
encode_timed_pose,
)
from line_laser_modbus.models import Pose6D, TimedPose6D
def test_read_frame_matches_readme_example() -> None:
frame = build_read_frame(0xD000, 2)
assert frame.hex(" ").upper() == "08 03 D0 00 00 02 FC 52"
def test_mode_write_frame_matches_readme_example() -> None:
frame = build_write_frame(0xD000, [0x0003])
assert frame.hex(" ").upper() == "08 10 D0 00 00 01 02 00 03 5D CC"
def test_pose_float_register_roundtrip() -> None:
pose = Pose6D(1.25, -2.5, 3.0, 0.0, 45.5, -90.0)
assert decode_pose(encode_pose(pose)) == pose
def test_timed_pose_register_roundtrip() -> None:
data = TimedPose6D(1000, Pose6D(1.25, -2.5, 3.0, 0.0, 45.5, -90.0))
registers = encode_timed_pose(data)
assert len(registers) == 14
assert decode_timed_pose(registers) == data
def test_current_pose_read_frame_matches_protocol_example() -> None:
frame = build_read_frame(0xD00A, 14)
assert frame.hex(" ").upper() == "08 03 D0 0A 00 0E DC 55"
def test_correction_write_frame_matches_protocol_docx_example() -> None:
data = TimedPose6D(1000, Pose6D(1.0, 2.0, 3.0, 0.0, 1.0, 2.0))
frame = build_write_frame(0xD036, encode_timed_pose(data), slave_id=0x01)
assert (
frame.hex(" ").upper()
== "01 10 D0 36 00 0E 1C 00 00 03 E8 3F 80 00 00 40 00 00 00 "
"40 40 00 00 00 00 00 00 3F 80 00 00 40 00 00 00 D0 72"
)
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from line_laser_modbus.config import AppConfig
def test_app_config_reads_serial_and_polling_sections(tmp_path: Path) -> None:
config_file = tmp_path / "config.toml"
config_file.write_text(
"""
[serial]
port = "COM9"
slave_id = 8
[polling]
interval_seconds = 0.01
max_timeouts = 5
""".strip(),
encoding="utf-8",
)
config = AppConfig.from_toml(config_file)
assert config.serial.port == "COM9"
assert config.polling.interval_seconds == 0.01
assert config.polling.max_timeouts == 5
def test_default_config_matches_protocol_timing() -> None:
config = AppConfig()
assert config.serial.timeout == 0.15
assert config.polling.interval_seconds == 0.05
+51
View File
@@ -0,0 +1,51 @@
from line_laser_modbus.constants import (
ADDR_AVAILABLE_CACHE_COUNT,
ADDR_CALIBRATION_RESERVED_END,
ADDR_CALIBRATION_RESERVED_START,
ADDR_CORRECTION,
ADDR_CURRENT_POSE,
ADDR_EXTENSION_RESERVED_1_END,
ADDR_EXTENSION_RESERVED_1_START,
ADDR_EXTENSION_RESERVED_2_END,
ADDR_EXTENSION_RESERVED_2_START,
ADDR_EXTENSION_RESERVED_3_END,
ADDR_EXTENSION_RESERVED_3_START,
ADDR_EXTENSION_RESERVED_4_END,
ADDR_EXTENSION_RESERVED_4_START,
ADDR_TARGET_POSE,
REGISTER_COUNT_CALIBRATION_RESERVED,
REGISTER_COUNT_EXTENSION_RESERVED,
REGISTER_COUNT_EXTENSION_RESERVED_1,
REGISTER_COUNT_POSE,
)
def test_calibration_reserved_range_matches_protocol() -> None:
assert ADDR_CALIBRATION_RESERVED_START == 0xD04C
assert ADDR_CALIBRATION_RESERVED_END == 0xD06B
assert ADDR_CALIBRATION_RESERVED_START + REGISTER_COUNT_CALIBRATION_RESERVED - 1 == 0xD06B
def test_pose_ranges_match_protocol() -> None:
assert ADDR_AVAILABLE_CACHE_COUNT == 0xD002
assert ADDR_CURRENT_POSE == 0xD00A
assert ADDR_TARGET_POSE == 0xD020
assert ADDR_CORRECTION == 0xD036
assert REGISTER_COUNT_POSE == 14
def test_extension_reserved_ranges_match_protocol() -> None:
ranges = [
(ADDR_EXTENSION_RESERVED_2_START, ADDR_EXTENSION_RESERVED_2_END, 0xD018, 0xD01F),
(ADDR_EXTENSION_RESERVED_3_START, ADDR_EXTENSION_RESERVED_3_END, 0xD02E, 0xD035),
(ADDR_EXTENSION_RESERVED_4_START, ADDR_EXTENSION_RESERVED_4_END, 0xD044, 0xD04B),
]
assert ADDR_EXTENSION_RESERVED_1_START == 0xD003
assert ADDR_EXTENSION_RESERVED_1_END == 0xD009
assert ADDR_EXTENSION_RESERVED_1_END - ADDR_EXTENSION_RESERVED_1_START + 1 == (
REGISTER_COUNT_EXTENSION_RESERVED_1
)
for start, end, expected_start, expected_end in ranges:
assert start == expected_start
assert end == expected_end
assert end - start + 1 == REGISTER_COUNT_EXTENSION_RESERVED
+48
View File
@@ -0,0 +1,48 @@
from line_laser_modbus.client import LineLaserClient
from line_laser_modbus.config import SerialConfig
from line_laser_modbus.models import DeviceStatus, ModeCommand, Pose6D
from line_laser_modbus.runner import PollingRunner, pose_delta
from line_laser_modbus.simulator import SimulatedModbusBackend
def test_polling_runner_writes_tracking_correction() -> None:
backend = SimulatedModbusBackend(
mode=ModeCommand.ONLINE_TRACKING,
status=DeviceStatus.TRACKING_OK,
current_pose=Pose6D(1.0, 2.0, 3.0, 0.0, 1.0, 2.0),
)
target = Pose6D(2.0, 4.0, 6.0, 0.0, 0.0, 5.0)
with LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client:
snapshot = PollingRunner(client, correction_provider=pose_delta(target)).run_once()
assert snapshot.status is DeviceStatus.TRACKING_OK
assert snapshot.timestamp == 0
assert backend.correction() == Pose6D(1.0, 2.0, 3.0, 0.0, -1.0, 3.0)
def test_polling_runner_writes_replay_correction() -> None:
backend = SimulatedModbusBackend(
mode=ModeCommand.TRAJECTORY_REPLAY,
status=DeviceStatus.RUNNING,
current_pose=Pose6D(1.0, 2.0, 3.0, 0.0, 1.0, 2.0),
)
target = Pose6D(2.0, 4.0, 6.0, 0.0, 0.0, 5.0)
with LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client:
PollingRunner(client, correction_provider=pose_delta(target)).run_once()
assert backend.correction() == Pose6D(1.0, 2.0, 3.0, 0.0, -1.0, 3.0)
def test_polling_runner_does_not_write_correction_outside_correction_modes() -> None:
backend = SimulatedModbusBackend(current_pose=Pose6D(1.0, 2.0, 3.0, 0.0, 1.0, 2.0))
with LineLaserClient(SerialConfig(port="SIM"), backend=backend) as client:
runner = PollingRunner(
client,
correction_provider=pose_delta(Pose6D(2.0, 2.0, 2.0, 2.0, 2.0, 2.0)),
)
runner.run_once()
assert backend.correction() == Pose6D.zeros()
+64
View File
@@ -0,0 +1,64 @@
import pytest
from line_laser_modbus.models import (
NORMAL_MODE_COMMANDS,
DeviceStatus,
ModeCommand,
can_switch_mode,
validate_mode_switch,
)
def test_normal_mode_commands_match_v13_range() -> None:
assert {mode.value for mode in NORMAL_MODE_COMMANDS} == {0, 1, 2, 3, 4}
def test_emergency_stop_is_not_a_normal_mode_switch_target() -> None:
assert not can_switch_mode(ModeCommand.MANUAL_TEACHING, ModeCommand.EMERGENCY_STOP)
def test_teaching_done_can_enter_tracking_or_replay() -> None:
assert can_switch_mode(
ModeCommand.PRE_WELD_TEACHING,
ModeCommand.MANUAL_TEACHING,
DeviceStatus.TEACHING_DONE,
)
assert can_switch_mode(
ModeCommand.PRE_WELD_TEACHING,
ModeCommand.ONLINE_TRACKING,
DeviceStatus.TEACHING_DONE,
)
assert can_switch_mode(
ModeCommand.PRE_WELD_TEACHING,
ModeCommand.TRAJECTORY_REPLAY,
DeviceStatus.TEACHING_DONE,
)
def test_calibration_done_returns_to_idle_command() -> None:
assert can_switch_mode(
ModeCommand.CALIBRATION,
ModeCommand.MANUAL_TEACHING,
DeviceStatus.CALIBRATION_DONE,
)
assert not can_switch_mode(
ModeCommand.CALIBRATION,
ModeCommand.ONLINE_TRACKING,
DeviceStatus.CALIBRATION_DONE,
)
assert not can_switch_mode(
ModeCommand.CALIBRATION,
ModeCommand.TRAJECTORY_REPLAY,
DeviceStatus.CALIBRATION_DONE,
)
def test_tracking_and_replay_only_switch_to_each_other() -> None:
assert can_switch_mode(ModeCommand.ONLINE_TRACKING, ModeCommand.TRAJECTORY_REPLAY)
assert can_switch_mode(ModeCommand.TRAJECTORY_REPLAY, ModeCommand.ONLINE_TRACKING)
assert not can_switch_mode(ModeCommand.ONLINE_TRACKING, ModeCommand.MANUAL_TEACHING)
def test_invalid_transition_raises() -> None:
with pytest.raises(ValueError, match="Illegal mode switch"):
validate_mode_switch(ModeCommand.MANUAL_TEACHING, ModeCommand.EMERGENCY_STOP)
-51
View File
@@ -1,51 +0,0 @@
#pragma once
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
namespace test_support {
inline void require_true(const bool condition, const std::string& message) {
if (!condition) {
std::cerr << "FAILED: " << message << '\n';
std::exit(1);
}
}
template <typename T, typename U>
void require_equal(const T& actual, const U& expected, const std::string& message) {
if (!(actual == expected)) {
std::cerr << "FAILED: " << message << '\n'
<< " actual: " << actual << '\n'
<< " expected: " << expected << '\n';
std::exit(1);
}
}
inline void require_float_close(const float actual,
const float expected,
const std::string& message) {
if (std::fabs(actual - expected) > 0.0001F) {
std::cerr << "FAILED: " << message << '\n'
<< " actual: " << actual << '\n'
<< " expected: " << expected << '\n';
std::exit(1);
}
}
inline void require_bytes_equal(const std::vector<std::uint8_t>& actual,
const std::vector<std::uint8_t>& expected,
const std::string& message) {
if (actual != expected) {
std::cerr << "FAILED: " << message << '\n'
<< " actual size: " << actual.size() << '\n'
<< " expected size: " << expected.size() << '\n';
std::exit(1);
}
}
} // 命名空间 test_support
Generated
+135
View File
@@ -0,0 +1,135 @@
version = 1
revision = 3
requires-python = "==3.12.*"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "line-laser-modbus"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "pymodbus", extra = ["serial"] },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "pymodbus", extras = ["serial"], specifier = ">=3.11.3" }]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.1" },
{ name = "ruff", specifier = ">=0.14.6" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pymodbus"
version = "3.13.0"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/3d/55/eee0782c2ca9fa8e7c3218caca22fe30e850cd6c0c0d475c10b8c19e5a36/pymodbus-3.13.0.tar.gz", hash = "sha256:da4c87afe772787620594c564cd8aa8a4c58ff9786382aba9550fe0ce8879f32", size = 165750, upload-time = "2026-04-12T07:43:23.067Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ed/9e/6046aa966c04f72417e7f3ee25cec975f770c934ccf83aa6b0c77400ff12/pymodbus-3.13.0-py3-none-any.whl", hash = "sha256:6ce838690b59ef3da00893699d04dc56e60abfb5b569363b7d6526470a3a44f5", size = 166398, upload-time = "2026-04-12T07:43:21.522Z" },
]
[package.optional-dependencies]
serial = [
{ name = "pyserial" },
]
[[package]]
name = "pyserial"
version = "3.5"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "ruff"
version = "0.15.12"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
]