diff --git a/Document/PLSR_document/PLSR_Bug报告_2026-08-13_短轮廓与段间延迟.md b/Document/PLSR_document/PLSR_Bug报告_2026-08-13_短轮廓与段间延迟.md deleted file mode 100644 index a1894fd..0000000 --- a/Document/PLSR_document/PLSR_Bug报告_2026-08-13_短轮廓与段间延迟.md +++ /dev/null @@ -1,119 +0,0 @@ -# PLSR Bug 报告(2026-08-13,逻辑分析仪实测) - -> 发送对象:Codex 开发 -> 全部现象来自实板逻辑分析仪波形(6.25MS/s),运动计数均正确(1000/1000 脉冲),问题集中在**短轮廓(脉冲数不足以完成完整加减速)路径**和**段间延迟**。 - ---- - -## Bug 1:短轮廓减速提前切换,尾部以停止速度慢爬(bug1.bin) - -**配置**:段数 1|基准 100000Hz|启动 100Hz|停止 100Hz|加速 100ms|减速 100ms|段频率 100000Hz|脉冲数 **1000** - -**现象**:总时长 **461ms**(理论 ~64ms),尾部 ~38 个脉冲以 **100Hz** 输出(每个 10ms,共 400ms)。 - -波形结构: -``` -100Hz →(450脉冲)→ 33kHz 峰值 →(485脉冲 减速到~8kHz)→ 跳变 100Hz×38个 → 停 -``` - -**实测 vs 理论**: -| 项 | 理论 | 实测 | -|---|---|---| -| 峰值 | ~31,623Hz((f²−10⁴)/1e6=1000 → f=√1e9) | 33,069Hz(+4.5%) | -| 加速段 | ~500 脉冲 | ~450 脉冲 | -| 减速段 | 平滑减到 100Hz,正好用完 | **减到 ~8kHz 直接跳 100Hz** | -| 总时长 | ~64ms | **461ms** | - -**根因**:剩余脉冲不足以完成完整减速 ramp 时,固件直接 `ApplyFrequency(停止速度)` 把剩余脉冲发完,而不是按剩余脉冲**裁剪/重算减速斜坡**(截断三角)。 - -**修复方向**:减速进入判定 `剩余脉冲 ≤ 减速所需` 时,按剩余脉冲重算减速斜率(保持三角形面积守恒),不要跳变到停止速度。 - ---- - -## Bug 2:加速 ramp 消失 + 减速提前切换(bug2.bin) - -**配置**:段数 1|基准 100000Hz|启动 100Hz|停止 100Hz|加速 **10ms**|减速 **10ms**|段频率 100000Hz|脉冲数 **1000** - -**现象**:总时长 **524ms**(理论 ~20ms)。加速段整个丢失(**3 个脉冲**就从 100Hz 冲到 100kHz),然后 100kHz 恒速 945 个,尾部 **51 个 100Hz**(510ms)。 - -波形结构: -``` -100Hz →(1脉冲 10ms)→ 200Hz →(66kHz)→ 100kHz × 945个 → 100Hz × 51个 → 停 -``` - -**根因 A(加速消失)——时序错位**: -``` -启动速度 100Hz → 第 1 个脉冲要等 1/100Hz = 10ms 才发出 -加速 ramp → (100000−100)×10ms/100000 = 9.99ms 就走完了 - -→ ramp 在第一个脉冲之前完成,加速段物理不可见 -→ 首脉冲出来时频率已是 100kHz -``` - -**根因 B(减速提前切换)**:同 Bug 1。 - -**修复方向**:ramp 计时起点改为**第一个脉冲发出时刻**(或段启动先应用启动速度、等首脉冲后再启动 ramp),而不是从段启动瞬间计时。 - ---- - -## Bug 3:整段以启动/停止速度输出(启动速度极低场景,无波形,配置可复现) - -**配置**:段数 1|基准 100000Hz|启动 **10Hz**|停止 **10Hz**|加速 **10ms**|减速 **100ms**|段频率 100000Hz|脉冲数 **1000** - -**现象**:**输出 10Hz 脉冲 1000 个**(约 100 秒)。 - -**数学推演**: -``` -启动速度 10Hz → 第 1 个脉冲要等 1/10Hz = 100ms -完整梯形需要 → 加速500 + 减速5000 = 5500 脉冲(1000 不够 → 短轮廓) -短轮廓总时长 → 10Hz↗42,640Hz↘10Hz ≈ 47ms - -47ms 曲线 < 100ms 首脉冲等待 → ramp 全程跑在第一个脉冲之前,消耗脉冲 = 0 -固件曲线走完后的处理:频率回到停止速度 10Hz,剩余 1000 脉冲全部 10Hz 输出 -``` - -**根因**:Bug 2 根因 A 的极端版(ramp 时长 < 首脉冲等待时间),叠加"曲线走完但 0 脉冲消耗"的判定缺陷。 - -**修复方向**:同 Bug 2 根因 A;并检查短轮廓"脉冲消耗=0"时的处理(应按首脉冲后的剩余脉冲重新规划)。 - -**验证方法**:把启动速度改成 1000Hz(首脉冲 1ms < ramp 47ms),同一配置应恢复短轮廓三角——可快速验证根因。 - ---- - -## Bug 4:段间 2.5ms 低电平间隙(TC-PD-011,有限路径 10 段连续执行) - -**配置**:段数 10|完成模式|相对位置|恒速 1000Hz(启动=停止=段频率)|段脉冲 100~109|跳转 0 - -**现象**:每个段之间固定 ~2.5ms 低电平(段 100ms 内占 2.5%)。 - -**根因**:恒速小段走**有限脉冲路径**,段完成与下一段启动被拆成**两个主循环周期**(每周期 ~1ms): -``` -段 N 最后脉冲 → CC1 中断置 FiniteCompletionPending(IRQ 内,零延迟) -周期 N:PlsrPoll1ms → PlsrServiceFiniteCompletion() 消费完成事件 - (诊断结算 + PlsrBoundaryPending=1)→ return ← plsr.c:4502 提前返回 -周期 N+1:PlsrPoll1ms → PlsrHandleBoundary() → 启动段 N+1 -延迟 = 段结束→周期N随机等待(0~1ms) + 2 周期 ≈ 2~3ms,平均 2.5ms -``` - -**对比**:非有限路径(有加减速)段完成在 IRQ 内直接置 `PlsrBoundaryPending`,段间仅 0~1ms——无此问题。 - -**修复方向**:`PlsrServiceFiniteCompletion` 消费完成后**不 return**,同周期继续处理 `PlsrBoundaryPending`(去掉 plsr.c:4502 的提前返回),段间延迟降到 ~1ms。 - ---- - -## 汇总与优先级 - -| # | 问题 | 严重度 | 涉及路径 | 实测文件 | -|---|---|---|---|---| -| 1 | 减速预算不足时跳停止速度发完剩余 | 高 | 短轮廓/减速切换(`PlsrMaybePlanBoundaryRamp`、短轮廓边界规划) | bug1.bin、bug2.bin | -| 2 | ramp 计时从段启动起算,ramp 时长 < 首脉冲等待时加速丢失 | 高 | 段启动/ramp(`PlsrBeginSegmentOutput`→`PlsrRampStart`) | bug2.bin、Bug 3 | -| 3 | 短轮廓"0 脉冲消耗"判定 + 峰值规划偏差(31623 vs 实测 33069,加速 500 vs 450) | 中 | 短轮廓峰值/预算计算(`PlsrShortProfilePeak` 等) | bug1.bin | -| 4 | 有限路径段间 2 周期延迟(~2.5ms) | 低 | `PlsrServiceFiniteCompletion` → `PlsrPoll1ms` | TC-PD-011 | - -**建议修复顺序**:Bug 1/2 根因(同一短轮廓路径)→ Bug 3(同根因极端版,改完 1/2 后复测确认)→ Bug 4(一行 return 改动)→ 峰值规划偏差(精度优化)。 - -**回归验证**: -- bug1 配置预期:三角平滑到 100Hz 恰好 1000 脉冲,总时长 ~64ms,无 100Hz 尾巴 -- bug2 配置预期:10Hz→100kHz→10Hz(或短轮廓峰值),总时长 ~20ms -- Bug 3 配置预期:首脉冲等待 100ms 后短轮廓三角 ~47ms,总时长 ~147ms -- TC-PD-011 预期:段间间隙 <1ms diff --git a/Document/PLSR_document/PLSR_PULSE_DIR测试用例与验收标准_2026-08-13.md b/Document/PLSR_document/PLSR_PULSE_DIR测试用例与验收标准_2026-08-13.md deleted file mode 100644 index d78ea4f..0000000 --- a/Document/PLSR_document/PLSR_PULSE_DIR测试用例与验收标准_2026-08-13.md +++ /dev/null @@ -1,706 +0,0 @@ -# PLSR PULSE/DIR 测试用例与验收标准 - -> 文档日期:2026-08-13 -> 适用分支:`codex/plsr-2026-minimal` -> 当前范围:单相 PULSE/DIR 基础功能、内部硬件计数、输出诊断 -> 暂不验收:AB 正交输出。AB 仍需单独完成硬件计数架构重构,不得用本文件的结论替代 AB 验收。 - - - - - -**下述1就是“是”,0就是“否”** - - - -## 1. 测试目标和判定口径 - -本文件同时检查三条链路: - -1. 上位机运行状态:位置、频率、段号、状态码和错误码。 -2. MCU 内部诊断:TIM9/TIM12 通过内部定时器连接得到的硬件周期计数,以及实际生效的 PSC/ARR/CCR 和曲线更新顺序。 -3. 端子波形:逻辑分析仪直接检查 Y0~Y3 的实际输出脉冲和 Y12~Y15 的方向电平。 - -内部诊断不是外部端到端检测。它能发现定时器周期数、频率配置和曲线更新错误,但不能发现输出驱动、电气端子或外部接线故障。因此 P0 波形用例必须同时满足内部诊断和逻辑分析仪验收值。 - -优先级定义: - -- `P0`:交付前必须通过,任一失败即不允许验收。 -- `P1`:完整功能回归必须通过。 -- `P2`:工程质量、异常和长时间稳定性测试。 - -## 2. 测试环境 - -### 2.1 软件和通信 - -- 固件:本分支最新 IAR 构建并烧录的 `EWARM/Modbus/Exe/Modbus.hex`。 -- 上位机:`HostComputer/plsr_control_panel.py`。 -- 串口:`COM5`,`9600 baud`,`8E1`,Modbus RTU 从站地址 `1`。 -- 逻辑分析仪:采样率不低于被测最高频率的 20 倍;100 kHz 测试推荐 `10 MHz` 或更高。 - -启动上位机: - -```powershell -py -B HostComputer\plsr_control_panel.py -``` - -### 2.2 接线 - -- 逻辑分析仪地接 PLC 输出公共参考端。 -- CH0 接本次选择的脉冲端子 Y0、Y1、Y2 或 Y3。 -- CH1 接本次选择的方向端子 Y12、Y13、Y14 或 Y15。 -- 涉及 WAIT/EXT 的用例,再用可控 PLC 输出分别驱动 X4、X5;不执行输入用例时无需保留该接线。 -- 测试前确认输出负载和公共端连接符合板卡电气要求。 - -### 2.3 逻辑分析仪统一设置 - -- 数字阈值按实际输出电平设置。 -- 普通脉冲测试使用 CH0 上升沿触发。 -- 方向切换测试采集窗口必须覆盖方向变化、方向延时和第一个脉冲。 -- 曲线和 100,000 脉冲测试应使用足够长的采集深度,禁止因缓冲区不足丢掉首尾波形。 - -### 2.4 每个运动用例的统一操作 - -除用例另有说明外,均按以下顺序执行: - -1. 在“公共参数”页选择 `PULSE/DIR (0)` 并填写公共参数。 -2. 点击“写入公共参数”,等待写入并回读完成。 -3. 在“10 段参数”页填写有效段;未使用段的脉冲数设为 `0`。 -4. 点击“写入 10 段参数”,等待写入并回读完成。 -5. 点击“清零”,再到“输出诊断”页点击“清除诊断”。 -6. 逻辑分析仪进入等待触发状态。 -7. 点击“启动”,等待状态进入“完成 (7)”“停止 (8)”或“错误 (9)”。 -8. 保存上位机最终状态、诊断页数据和逻辑分析仪统计/截图。 - -## 3. 通用验收指标 - -除用例另有规定外,运动正常完成时必须同时满足: - -| 项目 | 验收指标 | -|---|---| -| 最终状态 | `完成 (7)` | -| 错误码 | `0` | -| 位置 | 等于各段有符号位移之和;绝对模式按目标位置判定 | -| 内部期望脉冲 | 等于当前诊断段的脉冲绝对值 | -| 内部实测脉冲 | 与内部期望脉冲完全相等 | -| 内部计数误差 | `0` | -| 诊断 | 计数、频率、曲线均显示“通过”;首个故障为“无故障 (0)” | -| 外部脉冲个数 | 逻辑分析仪上升沿个数与设定脉冲数完全相等,不允许 ±1 | -| 稳态频率 | 相对“期望定时器频率”误差不超过 `±0.1%` 或 `±1 Hz`,取较大者 | -| 稳态占空比 | 排除首尾过渡周期后,每周期高、低电平各占 `35%~65%` | -| 窄脉冲 | 不允许出现小于稳态周期 `20%` 的异常高/低脉冲 | -| 方向信号 | 正反向电平符合逻辑设置;发波期间不得抖动 | -| 非选中输出 | Y0~Y3 中未选中的脉冲输出不得出现脉冲 | - -说明:上位机“请求频率”可能因定时器整数分频产生量化误差。外部频率应与诊断页“期望定时器频率”比较,而不是强制与请求值逐 Hz 相等。 - -## 4. P0 必测用例 - -### TC-PD-001 通信、参数写入和回读 - -- 优先级:`P0` -- 方法:上位机 -- 配置:连接 COM5 后读取全部参数;将输出模式改为 PULSE/DIR,脉冲输出 Y0,方向输出 Y12,段数 1,段 1 为 1,000 Hz、100 脉冲,然后分别写入公共参数和 10 段参数,再次读取全部参数。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| COM5 连接 | `9600 8E1`、从站 1,连接成功并持续稳定 | 是 | | | -| 公共参数回读 | 与写入值逐项一致 | 是 | | | -| 10 段参数回读 | 与写入值逐项一致 | 是 | | | -| 输出模式回读 | `PULSE/DIR (0)` | 是 | | | -| 保留地址 | 保持规定值,未被界面误写 | 是 | | | -| 通信质量 | 无超时、CRC 错误或非预期 Modbus 异常 | 是 | | | - -### TC-PD-002 Y0 正向 100 kHz 定频输出 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 公共参数:Y0、Y12、相对模式、完成模式、正逻辑、直线曲线、默认/起始/停止速度均 100,000 Hz、加减速均 0 ms、段数 1。 -- 段 1:100,000 Hz,`+100000` 脉冲,跳转 0。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 最终状态/错误码 | 完成 `7` / 错误码 `0` | 1 | | | -| 最终位置 | `100000` | 1 | | | -| 内部诊断计数 | 期望 `100000`,实测 `100000`,误差 `0` | 1 | | | -| 内部频率/曲线诊断 | 均已检查且通过,首个故障 `0` | 1 | | | -| 外部上升沿 | Y0 恰好 `100000` 个 | 1 | | | -| 稳态频率 | 相对期望定时器频率误差不超过 `±0.1%` 或 `±1 Hz` | 1 | | | -| 周期/占空比 | 周期约 `10 us`;高、低各占 `35%~65%` | 1 | | | -| 波形完整性 | 无窄脉冲、额外边沿和异常停顿 | 1 | | | -| 方向/非选中输出 | Y12 为正向电平且稳定;Y1~Y3 无脉冲 | 1 | | | - -备注:**稳定出现上述两个问题,脉冲输出口改成Y1Y2Y3一样会有这两种问题,只是窄脉冲有0.0几us区别** - -### TC-PD-003 Y0 反向 100 kHz 定频输出 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 配置:与 TC-PD-002 相同,仅段 1 脉冲数改为 `-100000`。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 最终状态/错误码 | 完成 `7` / 错误码 `0` | 1 | | | -| 最终位置 | `-100000` | 1 | | | -| 内部诊断计数 | 期望 `100000`,实测 `100000`,误差 `0` | 1 | | | -| 外部上升沿 | Y0 恰好 `100000` 个 | 1 | | | -| 稳态频率/占空比 | 频率满足通用误差;高、低各占 `35%~65%` | 1 | | | -| 方向电平 | Y12 与 TC-PD-002 相反且发波期间稳定 | 1 | | | -| 波形完整性 | 无窄脉冲、额外边沿和异常停顿 | 1 | | | - -备注:同TC-PD-002 - -### TC-PD-004 四路脉冲输出选择 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 配置:依次选择 Y0、Y1、Y2、Y3;对应方向端子可统一选择 Y12;每次均为 100,000 Hz、10,000 脉冲、零加减速。 - -验收记录(每行分别执行一次): - -| 输出 | 状态 `7`/错误 `0` | 位置 `10000` | 内部计数 `10000`/误差 `0` | 外部上升沿 `10000` | 频率/波形通过 | 非选中路无脉冲 | 是 | 否 | 备注 | -|---|---|---|---|---|---|---|:---:|:---:|---| -| Y0 | | | | | | | | | | -| Y1 | | | | | | | | | | -| Y2 | | | | | | | | | | -| Y3 | | | | | | | | | | - -备注:全部通过 - -### TC-PD-005 四路方向端子和方向极性 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 配置:脉冲端固定 Y0、1,000 Hz、100 脉冲、方向延时 20 ms;方向端依次选择 Y12~Y15。每一路分别执行正/负脉冲,并分别选择正逻辑/负逻辑,共 16 个组合。 - -验收记录(每个组合均要求:状态 7、错误 0、内外计数 100、误差 0、方向延时 `20 ms ± 2 ms`、发波期间方向稳定): - -| 方向端 | 脉冲符号 | 逻辑 | 方向电平正确 | 延时正确 | 内外计数正确 | 位置正确 | 是 | 否 | 备注/实测延时 | -|---|---:|---|:---:|:---:|:---:|:---:|:---:|:---:|---| -| Y12 | + | 正逻辑 | | | | | | | | -| Y12 | - | 正逻辑 | | | | | | | | -| Y12 | + | 负逻辑 | | | | | | | | -| Y12 | - | 负逻辑 | | | | | | | | -| Y13 | + | 正逻辑 | | | | | | | | -| Y13 | - | 正逻辑 | | | | | | | | -| Y13 | + | 负逻辑 | | | | | | | | -| Y13 | - | 负逻辑 | | | | | | | | -| Y14 | + | 正逻辑 | | | | | | | | -| Y14 | - | 正逻辑 | | | | | | | | -| Y14 | + | 负逻辑 | | | | | | | | -| Y14 | - | 负逻辑 | | | | | | | | -| Y15 | + | 正逻辑 | | | | | | | | -| Y15 | - | 正逻辑 | | | | | | | | -| Y15 | + | 负逻辑 | | | | | | | | -| Y15 | - | 负逻辑 | | | | | | | | - -备注:暂时未测 - -### TC-PD-006 计数边界 - -- 优先级:`P0` -- 方法:上位机;100,000 脉冲子项加逻辑分析仪 -- 配置:100,000 Hz、零加减速,分别运行 `1`、`2`、`65535`、`65536`、`65537`、`100000` 脉冲。 - -验收记录: - -| 设定脉冲 | 状态 `7`/错误 `0` | 位置等于设定 | 内部期望=实测=设定 | 误差 `0` | 外部计数(100000 项) | 是 | 否 | 备注 | -|---:|:---:|:---:|:---:|:---:|---|:---:|:---:|---| -| 1 | 1 | 1 | 1 | 1 | 1 | | | | -| 2 | 1 | 1 | 1 | 1 | 1 | | | | -| 65535 | 1 | 1 | 1 | 1 | 1 | | | | -| 65536 | | | | | 1 | | | | -| 65537 | | | | | | | | | -| 100000 | | | | | 必须为 100000 | | | | - -备注:全部通过 - - - -### TC-PD-007 三种长加减速曲线 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 每种曲线分别执行一次:直线、S 曲线、正弦曲线。 -- 公共参数:Y0/Y12,相对模式,起始 10,000 Hz,默认 50,000 Hz,停止 10,000 Hz,加速 100 ms,减速 100 ms。 -- 段 1:50,000 Hz、20,000 脉冲。 - -验收记录(每种曲线分别填写): - -| 曲线 | 状态 `7`/错误 `0` | 位置/内外计数 `20000` | 10k→50k 单调加速 | 50k→10k 单调减速 | 无窄脉冲/空洞 | 样本>0且不匹配=0 | 最大误差=0/首错=FFFFFFFF | 是 | 否 | 备注 | -|---|---|---|---|---|---|---|---|:---:|:---:|---| -| 直线 | | | | | | | | | | | -| S 曲线 | | | | | | | | | | | -| 正弦曲线 | | | | | | | | | | | - -备注:验收全部通过 - -### TC-PD-008 短段曲线和单脉冲 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 配置:默认 100,000 Hz、起始 100,000 Hz、停止 100 Hz、减速 1,000 ms;直线/S/正弦三种模式分别运行 1、2、10 脉冲。 - -验收记录(每个组合均要求:状态 7、错误 0、位置及内外计数等于设定、内部误差 0、尾周期完整): - -| 曲线 | 设定脉冲 | 状态/错误正确 | 位置正确 | 内部计数/误差正确 | 外部计数正确 | 无截断/窄脉冲 | 是 | 否 | 备注/实测值 | -|---|---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|---| -| 直线 | 1 | 1 | 1 | 1 | 1 | 1 | | | | -| 直线 | 2 | 1 | 1 | 1 | 1 | 1 | | | | -| 直线 | 10 | 1 | 1 | 1 | 1 | 1 | | | | -| S 曲线 | 1 | 1 | 1 | 1 | 1 | 1 | | | | -| S 曲线 | 2 | 1 | 1 | 1 | 1 | 1 | | | | -| S 曲线 | 10 | 1 | 1 | 1 | 1 | 1 | | | | -| 正弦曲线 | 1 | 1 | 1 | 1 | 1 | 1 | | | | -| 正弦曲线 | 2 | 1 | 1 | 1 | 1 | 1 | | | | -| 正弦曲线 | 10 | 1 | 1 | 1 | 1 | 1 | | | | - - - -### TC-PD-009 运行中修改当前段频率 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 配置:Y0/Y12,初始 1,000 Hz,段 1 为 10,000 脉冲;运行中把段 1 频率依次改为 5,000 Hz、20,000 Hz、1,000 Hz,每次点击“写入 10 段参数”。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 初始频率 | 稳定在可实现的 1,000 Hz | 1 | | | -| 第一次更新 | 后续合法边界稳定到可实现的 5,000 Hz | 1 | | | -| 第二次更新 | 后续合法边界稳定到可实现的 20,000 Hz | 1 | | | -| 第三次更新 | 后续合法边界稳定到可实现的 1,000 Hz | 1 | | | -| 更新波形 | 无窄脉冲、额外边沿或异常长间隔 | 1 | | | -| 最终状态/位置 | 状态 7、错误 0、位置 10,000 | 1 | | | -| 内部/外部计数 | 均为 10,000,内部误差 0 | 1 | | | - -### TC-PD-010 STOP 减速停止和重复 STOP - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 配置:起始/默认速度 100,000 Hz,停止速度 10,000 Hz,减速 300 ms,目标 1,000,000 脉冲。 -- 步骤:位置大于 10,000 后连续点击两次“停止”。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| STOP 接受 | 两次 STOP 均不导致通信或系统异常 | | | | -| 最终状态/错误码 | 停止 `8` / 错误码 `0` | | | | -| 提前停止 | `10000 < abs(位置) < 1000000` | | | | -| 减速过程 | 频率总体下降并到达停止边界 | | | | -| 尾脉冲 | 最后高、低电平完整,无截断窄脉冲 | | | | -| 停止后输出 | 不再出现脉冲,不自动重启 | | | | -| 内部计数一致性 | 内部累计增量等于最终位置绝对增量 | | | | - -### TC-PD-011 10 段连续执行 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 公共参数:段数 10、起始段 1、完成模式、相对位置、速度固定 1,000 Hz。 -- 段参数:第 1~10 段脉冲数依次为 100~109,频率均 1,000 Hz,跳转均为 0。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 段顺序 | 依次经过 1、2、3、4、5、6、7、8、9、10 | 1 | | | -| 段完整性 | 无漏段、重复段或提前完成 | 1 | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | 1 | | | -| 最终位置 | `1045` | 1 | | | -| 外部总上升沿 | `1045` | 1 | | | -| 末段内部诊断 | 期望 109、实测 109、误差 0 | 1 | | | - -### TC-PD-012 起始段为第 10 段 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 配置:段数 10、起始段 10;第 1~9 段各设非零脉冲,第 10 段设 200 脉冲。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 首个活动段 | 直接为第 10 段 | 1 | | | -| 第 1~9 段 | 均未执行 | 1 | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | 1 | | | -| 位置/外部计数 | 均为 200 | 1 | | | -| 内部诊断 | 期望 200、实测 200、误差 0 | 1 | | | - -### TC-PD-013 完成模式与后续模式 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 配置:两段均为 10,000 Hz、各 2,000 脉冲,先选择完成模式,再选择后续模式各执行一次。 - -验收记录: - -| 模式 | 段顺序 1→2 | 状态 7/错误 0 | 位置 4000 | 外部计数 4000 | 末段内部计数 2000/误差0 | 边界无窄脉冲/额外脉冲 | 是 | 否 | 备注 | -|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|---| -| 完成模式 | | | | | | | | | | -| 后续模式 | | | | | | | | | | - -### TC-PD-014 累计位置、反向累计和清零 - -- 优先级:`P0` -- 方法:上位机 -- 步骤:相对模式依次运行 `+1000`、`-300`、`+20`,中间不清零;最后点击“清零”。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| `+1000` 后位置 | 1,000 | | | | -| `-300` 后位置 | 700 | | | | -| `+20` 后位置 | 720 | | | | -| 各次诊断 | 内部实测等于当次绝对脉冲数、误差 0 | | | | -| 清零后状态 | 位置 0、空闲、段号 0、错误码 0 | | | | -| 清零输出 | 清零动作不产生脉冲 | | | | - -### TC-PD-015 绝对位置及相等不动作 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 步骤:先清零;绝对模式依次设置目标 `+1000`、`+300`、`+300`。 - -验收记录: - -| 子项 | 方向/输出脉冲 | 最终位置 | 状态 7/错误 0 | 内外计数正确 | 是 | 否 | 备注 | -|---|---|---:|:---:|:---:|:---:|:---:|---| -| 目标 +1000 | 正向 1,000 | 1000 | | | | | | -| 目标 +300 | 反向 700 | 300 | | | | | | -| 再次目标 +300 | 不输出,外部 0 脉冲 | 300 | | N/A | | | | - -## 5. P1 完整功能用例 - -### TC-PD-101 非顺序跳转 - -- 优先级:`P1` -- 方法:上位机 -- 配置:有效段数 4;段 1 跳转 3,段 3 跳转 4,段 4 跳转 0;各段脉冲数分别为 10、20、30、40。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 执行顺序 | 1 → 3 → 4 | | | | -| 第 2 段 | 未执行 | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | 80 | | | | -| 末段内部诊断 | 期望 40、实测 40、误差 0 | | | | - -### TC-PD-102 WAIT 时间 - -- 优先级:`P1` -- 方法:上位机 + 秒表/日志 -- 配置:段 1 为 1,000 Hz、100 脉冲,等待类型 WAIT 时间,WAIT=500 ms。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 进入等待 | 100 脉冲后状态为等待 5 | | | | -| 等待时长 | `500 ms ± 30 ms` | | | | -| 等待期间位置 | 始终为 100 | | | | -| 等待期间输出 | 无新增脉冲 | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 内部诊断 | 期望 100、实测 100、误差 0 | | | | - -### TC-PD-103 ACT 时间切断 - -- 优先级:`P1` -- 方法:上位机 + 逻辑分析仪 -- 配置:段 1 为 10,000 Hz、100,000 脉冲,等待类型 ACT 时间,ACT=100 ms。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| ACT 动作时间 | 启动后 `100 ms ± 30 ms` 触发切断 | | | | -| 切断有效 | `0 < 最终位置 < 100000` | | | | -| 最终状态/错误码 | 完成或进入规定下一段 / 错误 0 | | | | -| 停止后输出 | 无残余脉冲 | | | | -| 尾脉冲 | 最后一个周期完整,无窄脉冲 | | | | - -### TC-PD-104 WAIT 信号 X4/X5 - -- 优先级:`P1` -- 方法:上位机 + 可控输入接线 -- 步骤:X4 和 X5 分别测试。保持输入 OFF,运行 1,000 Hz、100 脉冲、WAIT 信号;进入等待并保持至少 100 ms 后将输入置 ON。 - -验收记录: - -| 输入 | OFF 时等待且位置 100 | OFF 100 ms 不误触发 | ON 后完成 | 内部计数 100/误差0 | 另一输入无串扰 | 是 | 否 | 备注 | -|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|---| -| X4 | | | | | | | | | -| X5 | | | | | | | | | - -### TC-PD-105 EXT 信号 X4/X5 - -- 优先级:`P1` -- 方法:上位机 + 可控输入接线 + 逻辑分析仪 -- 步骤:X4 和 X5 分别测试。运行 10,000 Hz、100,000 脉冲、EXT 信号;发波后将所选输入置 ON。 - -验收记录: - -| 输入 | ON 后切断 | `0 < 位置 < 100000` | 状态正确/错误0 | 停止后无脉冲 | 未选输入不触发 | 尾周期完整 | 是 | 否 | 备注 | -|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|---| -| X4 | | | | | | | | | | -| X5 | | | | | | | | | | - -### TC-PD-106 EXT 信号或发送完成 - -- 优先级:`P1` -- 方法:上位机 + 可控输入接线 -- 子项 A:输入始终 OFF,运行 100 脉冲。 -- 子项 B:运行 100,000 脉冲,过程中将输入置 ON。 - -验收记录: - -| 子项 | 最终状态/错误码 | 最终位置 | 内部诊断/切断 | 停止后无脉冲 | 是 | 否 | 备注 | -|---|---|---|---|:---:|:---:|:---:|---| -| A:输入 OFF | 完成 7 / 错误 0 | 100 | 期望=实测=100,误差0 | | | | | -| B:输入 ON | 按 EXT 分支完成 / 错误 0 | `0 < 位置 < 100000` | 输入触发提前切断 | | | | | - -### TC-PD-107 运行中修改未来段频率(完成模式) - -- 优先级:`P1` -- 方法:上位机 + 逻辑分析仪 -- 配置:两段各 2,000 脉冲;段 1 为 1,000 Hz,段 2 初始为 2,000 Hz。 -- 步骤:段 1 运行时把段 2 改为 5,000 Hz并写入段参数。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 段 1 频率 | 写未来段后仍为可实现的 1,000 Hz | | | | -| 段 2 频率 | 到达段 2 后为可实现的 5,000 Hz | | | | -| 段切换波形 | 无额外边沿、漏脉冲和窄脉冲 | | | | -| 最终状态/位置 | 完成 7、错误 0、位置 4,000 | | | | -| 段 2 计数 | 内外均为 2,000,内部误差 0 | | | | - -### TC-PD-108 运行中修改未来段频率(后续模式) - -- 优先级:`P1` -- 方法:上位机 + 逻辑分析仪 -- 配置和步骤:与 TC-PD-107 相同,但发送模式选择后续模式。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 段 1 频率 | 写未来段后仍为可实现的 1,000 Hz | | | | -| 未来预装重建 | 到达段 2 后使用可实现的 5,000 Hz | | | | -| 无缝边界 | 无多脉冲、漏脉冲、窄脉冲或异常空洞 | | | | -| 最终状态/位置 | 完成 7、错误 0、位置 4,000 | | | | -| 段 2 计数 | 内外均为 2,000,内部误差 0 | | | | - -### TC-PD-109 频率上下限 - -- 优先级:`P1` -- 方法:上位机 + 逻辑分析仪 -- 子项:分别运行 1 Hz、2 Hz、99,999 Hz、100,000 Hz,每次 10 脉冲。 - -验收记录: - -| 请求频率 | 合法值接受/非法值拒绝 | 状态7/错误0 | 内外计数10 | 外部频率满足量化误差 | 不发波(非法值) | 是 | 否 | 备注/实测Hz | -|---:|---|:---:|:---:|:---:|:---:|:---:|:---:|---| -| 0 Hz | 必须拒绝 | N/A | N/A | N/A | | | | | -| 1 Hz | 必须接受 | | | | N/A | | | | -| 2 Hz | 必须接受 | | | | N/A | | | | -| 99,999 Hz | 必须接受 | | | | N/A | | | | -| 100,000 Hz | 必须接受 | | | | N/A | | | | -| 100,001 Hz | 必须拒绝 | N/A | N/A | N/A | | | | | - -### TC-PD-110 忙状态写保护 - -- 优先级:`P1` -- 方法:上位机或产品测试脚本 -- 步骤:启动长运动后尝试修改脉冲输出、方向输出、输出模式、段数等结构参数,同时允许修改当前/未来段频率。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/异常码 | -|---|---|:---:|:---:|---| -| 修改脉冲输出 | 返回设备忙,活动配置不变 | | | | -| 修改方向输出 | 返回设备忙,活动配置不变 | | | | -| 修改输出模式 | 返回设备忙,活动配置不变 | | | | -| 修改段数 | 返回设备忙,活动配置不变 | | | | -| 修改合法当前段频率 | 接受并在合法边界生效 | | | | -| 修改合法未来段频率 | 接受并在进入该段时生效 | | | | -| 运动连续性 | 被拒绝写入不导致停波、错误或重启 | | | | - -### TC-PD-111 诊断清除和故障锁存 - -- 优先级:`P1` -- 方法:上位机 -- 步骤:完成一个正常用例,确认诊断通过;点击“清除诊断”;运动中再次尝试清除诊断。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 正常完成诊断 | 计数、频率、曲线均通过 | | | | -| 空闲清除 | 锁存原因和统计复位 | | | | -| 运行中清除 | 返回设备忙 | | | | -| 运行影响 | 清除请求不影响正在执行的波形 | | | | -| 再次完成诊断 | 重新生成完整通过结果 | | | | - -### TC-PD-112 配置与位置掉电保持 - -- 优先级:`P1` -- 方法:上位机 + 断电/复位 -- 步骤:写入一组容易识别的公共参数和段参数,运行到非零位置并等待保存完成;断电重上电后读取全部参数和位置。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 公共参数恢复 | 重启后与断电前逐项一致 | | | | -| 段参数恢复 | 重启后与断电前逐项一致 | | | | -| 位置恢复 | 恢复断电前已保存的非零位置 | | | | -| 上电安全 | 上电不自行发波 | | | | -| 通信恢复 | COM5 可重新连接并正常读写 | | | | -| 清零持久化 | 清零、保存、再重启后位置为 0 | | | | - -### TC-PD-113 运行中异常复位的位置有效性 - -- 优先级:`P1` -- 方法:上位机 + 硬复位 -- 步骤:启动长运动,在运行中执行硬复位;重新连接后读取状态,再尝试绝对位置启动。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 复位后输出 | 不自行恢复发波 | | | | -| 忙状态恢复识别 | 能识别上次运动未正常结束 | | | | -| 位置有效性 | 不把中断位置当作可信绝对位置 | | | | -| 绝对模式保护 | 清零前启动被拒绝或明确要求清零 | | | | -| 恢复操作 | 相对模式和清零仍可正常使用 | | | | - -## 6. P2 工程和异常用例 - -### TC-PD-201 Modbus 地址、功能码和异常码 - -- 优先级:`P2` -- 方法:自动产品测试脚本 -- 执行: - -```powershell -py -B HostComputer\plsr_modbus_product_test.py --run --phase smoke --port COM5 -``` - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/输出摘要 | -|---|---|:---:|:---:|---| -| 固定地址映射 | 参数、状态、诊断、控制地址与地址表一致 | | | | -| 跨区/越界访问 | 返回非法地址 | | | | -| 只读区写入 | 返回非法地址或规定异常 | | | | -| 非法参数值 | 返回非法数据值 | | | | -| 不完整 32 位访问 | 按协议拒绝 | | | | -| 脚本结果 | 全部用例 PASS、零失败 | | | | - -### TC-PD-202 Host 回归、上位机和脚本自检 - -- 优先级:`P2` -- 方法:PC 自动测试 -- 执行:使用仓库现有 Host 测试构建命令,并运行: - -```powershell -py -B HostComputer\plsr_modbus_product_test.py --self-test -py -B HostComputer\plsr_control_panel.py --self-test -``` - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/输出摘要 | -|---|---|:---:|:---:|---| -| C Host 回归 | 全部测试和断言通过、0 failures | | | | -| 产品脚本自检 | PASS | | | | -| 控制面板自检 | PASS | | | | -| 自检副作用 | 不开串口、不启动 GUI、不改板上配置 | | | | - -### TC-PD-203 IAR 构建和固件产物 - -- 优先级:`P2` -- 方法:IAR 命令行构建 -- 执行: - -```powershell -& 'F:\IAR Systems\Embedded Workbench 8.3\common\bin\IarBuild.exe' ` - 'EWARM\Modbus.ewp' -build Modbus -log errors -``` - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| IAR 构建错误 | `0 errors` | | | | -| IAR warning | 数量和原因已记录,无新增代码 warning | | | | -| HEX 生成 | 文件存在且非空 | | | | -| HEX SHA256 | 已计算并记录 | | | | -| 烧录校验 | Download verify 成功 | | | | - -### TC-PD-204 内部硬件计数性能 - -- 优先级:`P2` -- 方法:DWT CYCCNT + ST-LINK 读取调试统计 -- 配置 A:100 kHz、100,000 脉冲定频。 -- 配置 B:1 kHz -> 100 kHz -> 1 kHz、长加减速曲线。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/次数/平均/最大 cycles | -|---|---|:---:|:---:|---| -| 逐脉冲业务 ISR | 不存在,ISR 次数不随每个输出脉冲增加 | | | | -| 65,536 块中断 | 最大值 `< 1680 cycles` | | | | -| 最终停止中断 | 最大值 `< 1680 cycles` | | | | -| 短曲线生产项 | 平均值和最大值均 `< 1680 cycles` | | | | -| 长硬件计数曲线 | 逐脉冲生产活动计数为 `0` | | | | -| 性能统计完整性 | 各路径次数、平均值、最大值均已记录 | | | | - -### TC-PD-205 8 小时稳定性 - -- 优先级:`P2` -- 方法:上位机/脚本循环 + 定期诊断读取 -- 配置:四路输出轮换,正反向轮换,1 kHz/10 kHz/100 kHz 轮换,每轮 100,000 脉冲;至少每分钟读取一次状态和诊断。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|:---:|:---:|---| -| 连续运行时长 | 不少于 8 小时 | | | | -| 通信 | 无失联、连续超时或异常帧 | | | | -| 系统状态 | 无错误状态、看门狗复位、卡死 | | | | -| 每轮计数 | 位置增量=内部期望=内部实测=100,000 | | | | -| 累计位置 | 正反向累计无漂移 | | | | -| 四路/三频率覆盖 | Y0~Y3 和 1k/10k/100k 均实际执行 | | | | -| 结束恢复 | STOP、CLEAR、再次 START 均正常 | | | | - -## 7. 推荐执行顺序 - -1. TC-PD-001、002、003:先确认通信和一组正反向基本波形。 -2. TC-PD-004~010:确认四路、方向、边界、曲线、动态更新和停止。 -3. TC-PD-011~015:确认多段、发送模式、累计和绝对位置。 -4. TC-PD-101~113:完成跳转、输入、动态段参数、诊断和掉电保持。 -5. TC-PD-201~205:完成异常、构建、性能和稳定性验收。 - -任何 P0 用例失败时,应停止扩大测试范围,保留当前固件 SHA256、上位机参数、诊断寄存器、逻辑分析仪原始采集文件和复现步骤,修复后从该用例开始并重跑全部 P0。 - -## 8. 填写规则 - -- 每个用例直接在其下方验收表填写。“是”和“否”只能选择一项;未执行时两项均留空,并在备注写明原因。 -- 多子项用例必须逐行填写,禁止只给整个用例一个总结果。 -- 备注至少填写关键实测值;逻辑分析仪用例还应填写原始采集文件名。 -- 每次执行前在本文件顶部或测试报告中记录固件 SHA256、日期和测试人员。 -- 每次重烧固件后不得沿用上一版波形或诊断结果。 -- 任一必测指标选择“否”,该用例结论即为 FAIL,并在备注填写问题编号和复现条件。 diff --git a/Document/PLSR_document/PLSR信捷对标追踪矩阵.md b/Document/PLSR_document/PLSR信捷对标追踪矩阵.md deleted file mode 100644 index 51d5019..0000000 --- a/Document/PLSR_document/PLSR信捷对标追踪矩阵.md +++ /dev/null @@ -1,119 +0,0 @@ -# PLSR 信捷对标追踪矩阵 - -> 矩阵版本:V1.0 Rev.B -> 基准资料:[信捷XD/XL系列PLC定位控制手册](./信捷XD_XL系列PLC定位控制手册_PD02_20260510_V1.3.pdf) -> 对标原则:信捷PLSR已有功能全部纳入,本项目增强功能允许保留;Y0~Y3的PLSR固定软元件 -> 地址按[PLSR地址映射](./PLSR地址映射.md)一比一对应。 - -状态说明: - -- `已纳入`:已写入V1.0需求。 -- `等效实现`:功能和外部行为一致,但使用本项目数据结构或Modbus接口。 -- `待实机确认`:手册语义存在歧义,需要信捷实机或厂家资料关闭。 -- `增强`:信捷手册没有强制规定,本项目额外提供。 - -## 1. 指令和参数模型 - -| 信捷功能 | 手册位置(印刷页) | 本项目要求 | 状态 | -|---|---:|---|---| -| `PLSR S0 S1 S2 D` | 49 | 保留S0/S1/S2/D逻辑模型 | 已纳入 | -| 上升沿启动且启动条件断开后继续 | 49 | START接受后锁存运行 | 等效实现 | -| STOP中断运动 | 49 | 支持减速停止和立即停止 | 增强 | -| S0为可选择首地址的脉冲数据块 | 6~17 | 运行时指定未占用保持寄存器首地址,保持信捷固定相对布局 | 一比一实现 | -| S1为可选择首地址的用户参数块 | 17~18 | 在动态数据池中指定首地址,仅含相对/绝对和起始段 | 已纳入 | -| S2选择K0~K4 | 18~24、49 | 每轴提供0~4套参数 | 已纳入 | -| D选择脉冲输出端口 | 49 | D选择Y0~Y3;PULSE/DIR一一对应Q0~Q3,AB/CW/CCW仅允许Y0或Y2并占用固定点对 | 等效实现 | - -## 2. 脉冲和定位 - -| 信捷功能 | 手册位置(印刷页) | 本项目要求 | 状态 | -|---|---:|---|---| -| 1 Hz~100 kHz | 49 | V1.0保证1 Hz~100 kHz | 已纳入 | -| 100~200 kHz非保证输出说明 | 50 | 板测后开放非保证扩展区并告警 | 已纳入 | -| 有符号32位脉冲数 | 49 | 支持完整INT32范围 | 已纳入 | -| 负数表示反向 | 49 | 有符号脉冲数量决定方向 | 已纳入 | -| 频率增减与方向无关 | 49 | 速度规划与方向符号分离 | 已纳入 | -| 段频率0使用默认速度 | 56 | 使用当前S2默认速度 | 已纳入 | -| 段脉冲数0的行为 | 56 | 项目冻结为跳过当前段且不输出脉冲;最后一段则完成任务 | 已冻结差异 | -| 相对定位 | 17、69等 | 支持相对模式 | 已纳入 | -| 绝对定位 | 17、69等 | 支持绝对模式 | 已纳入 | -| 绝对目标等于累计位置时不输出 | 50 | 零脉冲直接完成 | 已纳入 | -| 当前累计脉冲掉电保持 | 48、50 | Backup SRAM检查点和独立有效标志 | 增强 | -| 运行中修改当前段频率立即生效 | 50、56 | 在S2配置的0.1/1 ms内生效 | 已纳入 | -| 其它段参数下次启动生效 | 50 | 影子参数提交,下次任务生效 | 增强 | - -## 3. S0多段、等待和跳转 - -| 信捷功能 | 手册位置(印刷页) | 本项目要求 | 状态 | -|---|---:|---|---| -| 1~100段 | 6、51 | 每轴最多100段 | 已纳入 | -| 每段频率和有符号脉冲数 | 6 | 等效字段 | 已纳入 | -| 脉冲完成后跳段 | 7~8 | H00等效条件 | 已纳入 | -| Wait时间 | 8~9 | 完成后计时,0按1 ms,最大INT32_MAX ms | 已纳入 | -| Wait信号 | 9~11 | 完成后判断,提前有效则立即通过 | 已纳入 | -| ACT时间 | 11~13 | 从段开始计时,可中途放弃剩余脉冲 | 已纳入 | -| EXT信号 | 13~15 | 上升沿立即跳段,优先外部中断输入 | 已纳入 | -| EXT或脉冲完成 | 15~16 | 先到条件决定跳段 | 已纳入 | -| 等待参数支持常数和多种变量 | 16 | 常数、输入、内部位、普通/保持变量 | 等效实现 | -| 跳转目标支持常数或变量 | 16~17 | 常数或运行时变量,范围0~100 | 等效实现 | -| 跳转0为顺序,最后一段0为结束 | 17 | 相同语义 | 已纳入 | -| 跳转自身为无限循环 | 17 | 相同语义并提供诊断 | 增强 | -| 指定起始段,0/1均从第一段开始 | 18 | 相同语义 | 已纳入 | -| 最后一段非完成条件保持等待 | 7 | WAIT状态保持至条件或STOP | 等效实现 | - -## 4. 速度曲线和段衔接 - -| 信捷功能 | 手册位置(印刷页) | 本项目要求 | 状态 | -|---|---:|---|---| -| 完成方式 | 29~30、55 | 段完成后才向下一速度变化 | 已纳入 | -| 后续方式 | 29~30、55 | 段边界前达到下一段速度 | 已纳入 | -| 直线加减速 | 42 | 支持 | 已纳入 | -| S形加减速 | 42 | 支持 | 已纳入 | -| 正弦曲线加减速 | 42 | 支持 | 已纳入 | -| 短段自动形成三角曲线 | 55 | 自动压缩短距离曲线 | 已纳入 | -| 默认速度与加减速时间定义斜率 | 40、50 | 使用相同斜率模型 | 已纳入 | -| 起始速度和终止速度 | 43 | S2参数 | 已纳入 | -| 最高速度限制 | 22~24、49 | 高于S2最高速度时按最高速度限幅运行,并增加SPEED_CLAMPED告警 | 已纳入 | -| 最后一段动态变频后重算曲线 | 56 | 动态修改后重算剩余曲线 | 已纳入 | - -## 5. 公共参数和输出方式 - -| 信捷功能 | 手册位置(印刷页) | 本项目要求 | 状态 | -|---|---:|---|---| -| 方向逻辑 | 25、60等 | 正/负逻辑可配置 | 已纳入 | -| 方向输出点 | 31、60等 | 从有效未占用Q点配置 | 等效实现 | -| 方向延时 | 31、41 | 默认10 ms,单位ms可配置 | 已纳入 | -| PULSE/DIR | 29、49 | 最多四轴 | 已纳入 | -| AB相输出 | 29、70等 | D取Y0或Y2,使用Q0/Q1或Q2/Q3固定点对;一个完整正交周期计1个指令脉冲 | 增强 | -| 脉冲单位 | 28~29 | 支持脉冲模式 | 已纳入 | -| 1 μm、0.01/0.1/1 mm当量 | 28 | 内核直接换算并保存余数 | 增强 | -| 每转脉冲数和每转移动量 | 28、60等 | 公共参数 | 已纳入 | -| 软限位和正负限位值 | 25~26 | 减速停止、禁越界方向、允许反向离开 | 已纳入 | -| 硬限位输入和常开/常闭 | 31、61等 | 输入点及极性可配置 | 已纳入 | -| 正反向齿隙补偿 | 31、41~43 | V1.0实现 | 已纳入 | -| 补偿加减速时间 | 41 | S2参数 | 已纳入 | -| FOLLOW性能参数 | 23~24、44 | 范围1~100 | 已纳入 | -| FOLLOW前馈补偿 | 23~24、44 | 范围0~100% | 已纳入 | -| 0.1 ms/1 ms刷新 | 22~24、44 | S2配置,独立硬件控制周期 | 已纳入 | - -## 6. 监控、事件和本项目增强 - -| 功能 | 本项目要求 | 状态 | -|---|---|---| -| 每段对应脉冲中断 | 提供带轴号、段号、原因的段事件 | 等效实现 | -| 正在发脉冲标志 | 每轴独立运行标志 | 等效实现 | -| 当前段脉冲数 | 每轴当前段已发/剩余计数 | 增强 | -| Modbus状态监控 | 原子状态快照 | 增强 | -| 参数原子提交 | 影子参数加COMMIT | 增强 | -| 命令防重 | 命令序号保证只执行一次 | 增强 | -| 四轴资源检查 | PULSE/DIR轴数 + 2×AB轴数 + 2×CW/CCW轴数不超过4 | 增强 | -| 自测和错误数据块号 | 参数、状态机、计数、跳转、定时器和冲突诊断 | 增强 | - -## 7. 关闭准则 - -严格功能对标完成必须同时满足: - -1. 本矩阵不存在未处理的信捷PLSR功能。 -2. `待实机确认`项目已通过信捷实机或厂家书面资料关闭。 -3. 所有`已纳入`和`等效实现`项目具有测试用例和结果。 -4. Y0~Y3的PLSR固定软元件地址不得作为差异项;差异仅限编程界面或明确记录的增强行为。 diff --git a/Document/PLSR_document/PLSR地址映射.md b/Document/PLSR_document/PLSR地址映射.md index c58c661..db7788f 100644 --- a/Document/PLSR_document/PLSR地址映射.md +++ b/Document/PLSR_document/PLSR地址映射.md @@ -104,7 +104,20 @@ AB 模式的频率单位是完整正交周期/秒,一个 `00` 起止的四状 诊断使用定时器分频值、完整周期计数和频率变化次序核对输出配置。它能发现内部计数、频率和曲线不一致,但不能代替逻辑分析仪对端子占空比、窄脉冲或电气波形的验收。 -## 5 控制区 +## 5 profile 队列诊断区 `0x2200..0x2208`(只读) + +| 地址 | 类型 | 名称 | 说明 | +|---|---|---|---| +| `0x2200` | U16 | 队列诊断标志 | bit0=到达低水位,bit1=发生欠载,bit2=规划曲线被裁剪 | +| `0x2201` | U16 | 最小队列深度 | 本次运行期间观测到的最小 profile 项数;尚无样本时为 `0xFFFF` | +| `0x2202` | U16 | 当前队列深度 | 读取时尚未消费的 profile 项数 | +| `0x2203..0x2204` | U32 | 低水位事件数 | 队列跨入低水位的累计次数 | +| `0x2205..0x2206` | U32 | 队列欠载数 | IRQ 需要新项但生产器未完成且队列为空的次数 | +| `0x2207..0x2208` | U32 | 规划裁剪数 | 已发布但无法达到请求峰值的规划次数 | + +`0x3100=1` 同时清除本区的锁存标志和计数。原有 `0x2100..0x2119` 诊断区的边界保持不变。 + +## 6 控制区 | 地址 | 类型 | 名称 | 写入值 | |---|---|---|---| @@ -114,8 +127,8 @@ AB 模式的频率单位是完整正交周期/秒,一个 `00` 起止的四状 控制寄存器读取恒为 0。除上述单一值外的位组合均返回非法数据值。 运行期间写 `0x3100=1` 返回设备忙,避免清除正在采集的诊断上下文。 -## 6 地址边界 +## 7 地址边界 - 原固定区 `0x1000..0x1197`、`0x2000..0x2006`、`0x3000` 保持不变。 -- 扩展区仅增加 `0x1200`、`0x2100..0x2119`、`0x3100`。 +- 扩展区增加 `0x1200`、`0x2100..0x2119`、`0x2200..0x2208`、`0x3100`。 - 完全落在 PLSR 地址之外的请求返回“不处理”,跨越 PLSR 地址与空洞的请求返回“非法地址”。 diff --git a/Document/PLSR_document/PLSR多段测试用例与验收标准_2026-08-14.md b/Document/PLSR_document/PLSR多段测试用例与验收标准_2026-08-14.md deleted file mode 100644 index 439af4a..0000000 --- a/Document/PLSR_document/PLSR多段测试用例与验收标准_2026-08-14.md +++ /dev/null @@ -1,1093 +0,0 @@ -# PLSR 多段测试用例与验收标准 - -> 文档日期:2026-08-14 -> 适用分支:`codex/plsr-2026-minimal` -> 测试方法:上位机 `plsr_control_panel.py`(串口 COM5 / 9600 8E1 / 从站 1),波形类指标需逻辑分析仪 -> 范围:多段路径(段数 ≥ 2)的功能与验收;单段用例见《PLSR_PULSE_DIR测试用例与验收标准_2026-08-13.md》 -> 判定口径:上位机“实时状态”区(位置/频率/状态/当前段/错误码)为通过依据;“输出诊断”区(0x2100)为内部一致性佐证;标注“逻辑分析仪”的指标在未接线时记“未测”,不视为失败 - -## 1. 通用操作步骤(每个用例均按此执行) - -1. 连接上位机:串口选择 `COM5`,从站地址 `1`,点击连接,确认右上角显示 `COM5 / 从站 1`。 -2. 在“公共参数 0x1000-0x1013”页按用例的**公共参数表**填写,点击 `写入公共参数`。 -3. 在“10 段参数 0x1100-0x1197”页按用例的**段参数表**填写各段,点击 `写入 10 段参数`。 -4. 点击 `读取全部参数`,确认回读值与写入值一致。 -5. 点击 `清零`,确认累计位置为 0、状态为空闲 (1)。 -6. 点击 `启动`,观察“实时状态”区:状态、当前段、当前频率、累计位置的变化过程。 -7. 等待状态进入 `完成 (7)` / `停止 (8)` / `错误 (9)`,记录最终位置、当前段、错误码。 -8. 到“输出诊断 0x2100-0x2119”页记录期望脉冲数、实测脉冲数、计数误差和诊断标志。 - -## 2. 通用验收指标(未单独列出的用例必须满足) - -| 项目 | 验收指标 | -|---|---| -| 最终状态 | `完成 (7)`(STOP 用例为 `停止 (8)`) | -| 错误码 | `0` | -| 累计位置 | 等于各段有符号位移之和;绝对模式按目标位置判定 | -| 内部诊断 | 末段期望脉冲 = 该段脉冲绝对值,实测 = 期望,计数误差 `0`,诊断计数“通过” | -| 通信质量 | 无超时、CRC 错误或非预期 Modbus 异常 | - -## 3. 公共参数填写说明(上位机界面字段 → 用例取值) - -下表为公共参数页的全部字段与取值含义,各用例的公共参数表按此顺序给出具体值: - -| # | 界面字段 | 取值含义 | -|---|---|---| -| 1 | 脉冲输出 (单逻辑 PLSR) | `0`=Y0、`1`=Y1、`2`=Y2、`3`=Y3 | -| 2 | 方向输出 | `0`=Y12、`1`=Y13、`2`=Y14、`3`=Y15 | -| 3 | 方向延时 ms | 方向建立延时,`0..`;AB 模式忽略 | -| 4 | WAIT 输入 | `0`=X4、`1`=X5 | -| 5 | EXT 输入 | `0`=X4、`1`=X5 | -| 6 | 发送模式 | `0`=脉冲发送完毕(完成模式)、`1`=后续发送(后续模式) | -| 7 | 方向逻辑 | `0`=正逻辑、`1`=负逻辑 | -| 8 | 曲线模式 | `0`=直线、`1`=平滑、`2`=正弦 | -| 9 | 位置模式 | `0`=相对位置、`1`=绝对位置 | -| 10 | 段数 | `1..10` | -| 11 | 起始段 | `1..10`,启动时不得大于段数 | -| 12 | 默认速度 Hz | `1..100000` | -| 13 | 启动速度 Hz | `0..100000` | -| 14 | 停止速度 Hz | `0..100000` | -| 15 | 加速时间 ms | `0..` | -| 16 | 减速时间 ms | `0..` | -| 17 | 输出模式 0x1200 | `0`=PULSE/DIR、`1`=AB 正交 | - ---- - -## 4. P0 必测用例 - -### TC-MS-001 完成模式 10 段阶梯频率连续执行 - -- 优先级:`P0` -- 方法:上位机(可选:逻辑分析仪测段间波形) - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 10 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数: - -| 段 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| -| 频率 Hz | 1000 | 2000 | 3000 | 4000 | 5000 | 6000 | 7000 | 8000 | 9000 | 10000 | -| 脉冲数 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | -| 等待类型 | 外部或完成 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 段顺序 | 依次经过 1、2、3、…、10 | 1 | | | -| 各段频率 | 执行中当前频率依次约为 1000→2000→…→10000 Hz(允许 ±1 Hz 量化差) | 1 | | | -| 无漏段/重复段 | 当前段单调递增,无跳变、回退 | 1 | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | 1 | | | -| 最终位置 | `1000` | 1 | | | -| 末段内部诊断 | 期望 100、实测 100、误差 0 | 1 | | | -| 段间间隙(逻辑分析仪) | 完成模式下段间无脉冲时间 ≤ 2 ms | 1 | | | -| 段间无额外脉冲 | 段边界处无窄脉冲、无额外边沿 | 1 | | | - -### TC-MS-002 后续模式 3 段无缝衔接 - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪(重点验证段间不停轴) - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | **后续发送 (1)** | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 3 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数: - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | 1000 | 4000 | 2000 | -| 脉冲数 | 1000 | 2000 | 3000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 段顺序 | 1 → 2 → 3 | | | | -| 衔接不停轴 | 段边界处输出无中断(后续模式特性) | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | `6000` | | | | -| 外部总上升沿(逻辑分析仪) | `6000`,无漏脉冲、无额外脉冲 | | | | -| 段边界波形(逻辑分析仪) | 频率从 1000 Hz 平滑过渡到 4000 Hz,无毛刺、无窄脉冲 | | | | -| 末段内部诊断 | 期望 3000、实测 3000、误差 0 | | | | - -### TC-MS-003 混合方向多段(正向/反向交替) - -- 优先级:`P0` -- 方法:上位机(可选:逻辑分析仪测方向电平) - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | **10** | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 3 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数: - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | 2000 | 2000 | 2000 | -| 脉冲数 | **+1000** | **−500** | **+200** | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 段顺序 | 1 → 2 → 3 | | | | -| 方向切换 | 段 2 期间方向输出与段 1 相反;段 3 恢复与段 1 相同 | | | | -| 方向延时 | 段 1→2、2→3 边界均有约 `10 ms ± 2 ms` 方向建立延时 | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | `1000 − 500 + 200 = 700` | | | | -| 内部诊断(末段) | 期望 200、实测 200、误差 0 | | | | -| 方向电平稳定(逻辑分析仪) | 发波期间方向电平无抖动、无毛刺 | | | | - -### TC-MS-004 非顺序跳转链(1→3→5→7→9) - -- 优先级:`P0` -- 方法:上位机 - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 9 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数(偶数段 2/4/6/8 保留脉冲数但**不应执行**): - -| 段 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| -| 频率 Hz | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | -| 脉冲数 | 100 | 999 | 300 | 999 | 500 | 999 | 700 | 999 | 900 | -| 等待类型 | 外部或完成 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | -| 跳转段 | **3** | 0 | **5** | 0 | **7** | 0 | **9** | 0 | **0** | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 执行顺序 | 1 → 3 → 5 → 7 → 9 | | | | -| 偶数段 | 第 2/4/6/8 段未执行(当前段从不出现这些值) | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | `100+300+500+700+900 = 2500` | | | | -| 末段内部诊断 | 期望 900、实测 900、误差 0 | | | | - -### TC-MS-005 段频率为 0 时回退默认速度(多段混合) - -- 优先级:`P0`(对应 2026-08-13 需求明确版“频率 0 用默认速度”) -- 方法:上位机 - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 3 | -| 起始段 | 1 | -| 默认速度 Hz | **5000** | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数(段 1、段 3 频率填 `0`,应回退到默认速度): - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | **0** | 2000 | **0** | -| 脉冲数 | 1000 | 1000 | 1000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 写入接受 | 段 1/3 频率 0 写入成功、回读为 0(不返回非法值) | | | | -| 段 1 频率 | 执行段 1 时当前频率约为 `5000 Hz`(默认速度,±1 Hz) | | | | -| 段 2 频率 | 执行段 2 时当前频率约为 `2000 Hz` | | | | -| 段 3 频率 | 执行段 3 时当前频率恢复约为 `5000 Hz` | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | `3000` | | | | -| 末段内部诊断 | 期望 1000、实测 1000、误差 0 | | | | - ---- - -## 5. P1 完整功能用例 - -### TC-MS-101 段间 WAIT 时间等待 - -- 优先级:`P1` -- 方法:上位机 - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 2 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数: - -| 段 | 1 | 2 | -|---|---:|---:| -| 频率 Hz | 1000 | 2000 | -| 脉冲数 | 100 | 200 | -| 等待类型 | **定时等待 0** | 外部或完成 4 | -| WAIT ms | **300** | 0 | -| ACT ms | 0 | 0 | -| 跳转段 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 进入等待 | 段 1 完成后状态变为 `等待 (5)`,当前段仍为 1 | | | | -| 等待时长 | `300 ms ± 30 ms` 后自动进入段 2 | | | | -| 等待期间位置 | 保持 `100` 不变 | | | | -| 等待期间输出 | 无新增脉冲(当前频率为 0) | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | `300` | | | | - -### TC-MS-102 段间 WAIT 信号(X4) - -- 优先级:`P1` -- 方法:上位机 + 可控输入(Y4→X4 回环或手动给 X4) - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 2 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数: - -| 段 | 1 | 2 | -|---|---:|---:| -| 频率 Hz | 1000 | 2000 | -| 脉冲数 | 100 | 200 | -| 等待类型 | **信号等待 1** | 外部或完成 4 | -| WAIT ms | 0 | 0 | -| ACT ms | 0 | 0 | -| 跳转段 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 进入等待 | 段 1 完成后状态为 `等待 (5)`,位置 100 | | | | -| X4=OFF 保持 | X4 低电平期间持续等待,不误启动段 2 | | | | -| X4=ON 后继续 | X4 置 ON 后 1 ms 内(下个轮询周期)进入段 2 | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | `300` | | | | - -### TC-MS-103 段间 ACT 时间切断并继续 - -- 优先级:`P1` -- 方法:上位机 - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 2 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数: - -| 段 | 1 | 2 | -|---|---:|---:| -| 频率 Hz | 10000 | 2000 | -| 脉冲数 | 100000 | 2000 | -| 等待类型 | **定时动作 2** | 外部或完成 4 | -| WAIT ms | 0 | 0 | -| ACT ms | **100** | 0 | -| 跳转段 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 段 1 切断 | 启动约 `100 ms ± 30 ms` 后段 1 被切断 | | | | -| 切断位置 | `0 < 段 1 实际脉冲 < 100000` | | | | -| 进入段 2 | 切断后进入段 2,段 2 完整执行 2000 脉冲 | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | `段1实际 + 2000`(记录实测值) | | | | -| 末段内部诊断 | 期望 2000、实测 2000、误差 0 | | | | - -### TC-MS-104 段间 EXT 信号切断并继续(X4) - -- 优先级:`P1` -- 方法:上位机 + 可控输入(Y4→X4 回环或手动给 X4) - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 2 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数: - -| 段 | 1 | 2 | -|---|---:|---:| -| 频率 Hz | 10000 | 2000 | -| 脉冲数 | 100000 | 2000 | -| 等待类型 | **外部信号 3** | 外部或完成 4 | -| WAIT ms | 0 | 0 | -| ACT ms | 0 | 0 | -| 跳转段 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 段 1 切断 | 发波过程中 X4 置 ON 后,段 1 在下一个脉冲边界被切断 | | | | -| 切断位置 | `0 < 段 1 实际脉冲 < 100000` | | | | -| 进入段 2 | 切断后进入段 2,段 2 完整执行 2000 脉冲 | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | `段1实际 + 2000`(记录实测值) | | | | - -### TC-MS-105 起始段为第 5 段 - -- 优先级:`P1` -- 方法:上位机 - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 10 | -| 起始段 | **5** | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数(第 1~4 段设 1000 脉冲但**不应执行**,第 5~10 段各 100 脉冲): - -| 段 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| -| 频率 Hz | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | -| 脉冲数 | 1000 | 1000 | 1000 | 1000 | 100 | 100 | 100 | 100 | 100 | 100 | -| 等待类型 | 外部或完成 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 首个活动段 | 启动后直接为第 5 段 | | | | -| 第 1~4 段 | 未执行(位置不包含其脉冲数) | | | | -| 段顺序 | 5 → 6 → 7 → 8 → 9 → 10 | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | `600` | | | | -| 末段内部诊断 | 期望 100、实测 100、误差 0 | | | | - -### TC-MS-106 运行中修改未来段频率(完成模式) - -- 优先级:`P1` -- 方法:上位机 - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 3 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数(段 2 频率**初始**为 2000 Hz,运行中改为 5000 Hz): - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | 1000 | 2000 → **5000** | 3000 | -| 脉冲数 | 1000 | 1000 | 1000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -步骤:段 1 执行期间(状态为运行/加速/减速),在“10 段参数”页把段 2 频率改为 `5000`,点击 `写入 10 段参数`。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 段 1 不受影响 | 写未来段期间段 1 仍按 1000 Hz 完成 1000 脉冲 | | | | -| 段 2 采用新频率 | 到达段 2 后当前频率约为 `5000 Hz`(±1 Hz) | | | | -| 段 3 正常 | 段 3 按 3000 Hz 完成 | | | | -| 最终状态/错误码 | 完成 7 / 错误 0 | | | | -| 最终位置 | `3000` | | | | -| 末段内部诊断 | 期望 1000、实测 1000、误差 0 | | | | - -### TC-MS-107 运行中修改未来段频率(后续模式) - -- 优先级:`P1` -- 方法:上位机 + 逻辑分析仪 -- 配置:与 TC-MS-106 相同,仅发送模式改为 **后续发送 (1)**。 - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | **后续发送 (1)** | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 3 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数与步骤同 TC-MS-106。 - -验收记录(TC-MS-106 全部指标 + 以下): - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 衔接不停轴 | 段 1→2 边界输出无中断,频率平滑过渡到 5000 Hz | | | | -| 段边界波形(逻辑分析仪) | 无窄脉冲、无额外边沿 | | | | - -### TC-MS-108 自跳转循环运行 + STOP - -- 优先级:`P1` -- 方法:上位机 - -公共参数: - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 1 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -段参数(段 1 自跳转循环): - -| 段 | 1 | -|---|---:| -| 频率 Hz | 1000 | -| 脉冲数 | 1000 | -| 等待类型 | 外部或完成 4 | -| WAIT ms | 0 | -| ACT ms | 0 | -| 跳转段 | **1(自跳转循环)** | - -步骤:启动后等待至少 3 轮循环(位置 ≥ 3000),点击 `停止`。 - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 循环执行 | 启动后位置持续增加,越过 3000 后仍在运行 | | | | -| 循环无错误 | 循环期间错误码恒为 0,状态在 运行/加速/减速/等待 间正常流转 | | | | -| STOP 生效 | 点击停止后进入 `停止 (8)`,输出不再发脉冲 | | | | -| STOP 位置 | `1000 ≤ 位置`(记录实测值) | | | | -| 重复 STOP | 再次点击停止无异常(幂等) | | | | -| 重新启动 | 再次点击启动可重新循环,位置继续累计 | | | | - ---- - -## 6. 极限参数专项用例(速度/加减速边界与组合) - -> 本节覆盖启动速度、停止速度、加速时间、减速时间、默认速度的边界值与危险组合。 -> 执行时重点观察:① 是否出现错误码或状态异常;② 每段是否按时完成(无异常长时间慢爬); -> ③ ramp 形态是否符合预期(平滑过渡,无跳变);④ 边界语义(如启动速度 > 段频率)下固件行为是否合理。 -> 公共参数表中"脉冲输出 Y0 / 方向输出 Y12 / WAIT X4 / EXT X4 / 方向逻辑正 / 曲线直线 / 位置相对 / 起始段 1 / 方向延时 0 / 输出模式 PULSE/DIR"为固定项,不再逐表重复,各表只列差异项。 - -### TC-MS-201 启动/停止速度为 0 + 非零加减速(零速 ramp 边界) - -- 优先级:`P0` -- 方法:上位机 + 逻辑分析仪 -- 关注点:速度上下限为 0,ramp 从 0 Hz 起步/收束到 0 Hz,验证定时器在零速边界的行为。 - -公共参数(差异项): - -| 字段 | 值 | -|---|---| -| 发送模式 | 脉冲发送完毕 (0) | -| 段数 | 3 | -| 默认速度 Hz | 20000 | -| 启动速度 Hz | **0** | -| 停止速度 Hz | **0** | -| 加速时间 ms | **100** | -| 减速时间 ms | **100** | - -段参数(每段均 2000 脉冲): - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | 20000 | 20000 | 20000 | -| 脉冲数 | 2000 | 2000 | 2000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 写入接受 | 启动/停止速度 0 写入成功 | | | | -| 正常执行 | 3 段全部完成,无 ERROR/TIMER | | | | -| 首脉冲 | 首脉冲以非零可实现频率发出(0 Hz 按硬件最小频率处理),无死等 | | | | -| 最终状态/位置 | 完成 7 / 错误 0,位置 `6000` | | | | -| 末段内部诊断 | 期望 2000、实测 2000、误差 0 | | | | -| 波形(逻辑分析仪) | 段尾频率平滑收束到 0 附近停止,无窄脉冲 | | | | - -### TC-MS-202 启动速度高于段频率(向下 ramp) - -- 优先级:`P1` -- 方法:上位机 -- 关注点:启动速度 50000 Hz > 段频率 1000 Hz,ramp 方向与常规相反,验证固件行为合理(平滑向下过渡或按段频率执行),无错误。 - -公共参数(差异项): - -| 字段 | 值 | -|---|---| -| 段数 | 3 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | **50000** | -| 停止速度 Hz | 100 | -| 加速时间 ms | **100** | -| 减速时间 ms | **100** | - -段参数(每段均 1000 脉冲): - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | **1000** | **1000** | **1000** | -| 脉冲数 | 1000 | 1000 | 1000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 无错误 | 3 段完成,错误码 0,无 ERROR/TIMER | | | | -| 频率行为 | 记录实际频率变化(预期从 50000 Hz 平滑向下过渡到 1000 Hz,或按段频率 1000 Hz 执行) | | | | -| 最终位置 | `3000` | | | | -| 末段内部诊断 | 期望 1000、实测 1000、误差 0 | | | | - -### TC-MS-203 停止速度高于段频率("向上"减速边界) - -- 优先级:`P1` -- 方法:上位机 -- 关注点:停止速度 50000 Hz > 段频率 1000 Hz,段末减速目标高于当前频率,验证不产生异常或长时间输出。 - -公共参数(差异项): - -| 字段 | 值 | -|---|---| -| 段数 | 3 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | **50000** | -| 加速时间 ms | **100** | -| 减速时间 ms | **100** | - -段参数(每段均 1000 脉冲): - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | **1000** | **1000** | **1000** | -| 脉冲数 | 1000 | 1000 | 1000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 无错误 | 3 段完成,错误码 0 | | | | -| 无异常输出 | 段尾不出现长时间高速输出或无法停止 | | | | -| 频率行为 | 记录实际频率变化形态 | | | | -| 最终位置 | `3000` | | | | -| 末段内部诊断 | 期望 1000、实测 1000、误差 0 | | | | - -### TC-MS-204 默认速度极低 + 高频段(ramp 折算超长) - -- 优先级:`P1` -- 方法:上位机 -- 关注点:加减速时长按 `duration = 频率差 × baseTime / 默认速度` 折算,默认速度 100 Hz、段频率 100000 Hz 时 ramp 折算到近 100 秒级,短段必然截断,验证短轮廓路径处理。 - -公共参数(差异项): - -| 字段 | 值 | -|---|---| -| 段数 | 3 | -| 默认速度 Hz | **100** | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | **100** | -| 减速时间 ms | **100** | - -段参数(每段均 1000 脉冲): - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | **100000** | **100000** | **100000** | -| 脉冲数 | 1000 | 1000 | 1000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 无错误 | 3 段完成,错误码 0 | | | | -| 按时完成 | 每段总时长记录实测值;应远小于 ramp 折算时长(截断三角),无秒级异常 | | | | -| 频率形态 | 记录峰值频率(预期远低于 100000 Hz)与波形形态 | | | | -| 最终位置 | `3000` | | | | -| 末段内部诊断 | 期望 1000、实测 1000、误差 0 | | | | - -### TC-MS-205 加减速时间极端不对称(加速 1 ms / 减速 10000 ms) - -- 优先级:`P1` -- 方法:上位机 + 逻辑分析仪 -- 关注点:加速 1 ms 接近瞬间,减速 10000 ms 折算约 10 秒级(远大于段执行时长),验证边界减速规划不误判。 - -公共参数(差异项): - -| 字段 | 值 | -|---|---| -| 段数 | 3 | -| 默认速度 Hz | 20000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | **1** | -| 减速时间 ms | **10000** | - -段参数(每段均 5000 脉冲): - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | 20000 | 20000 | 20000 | -| 脉冲数 | 5000 | 5000 | 5000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 加速形态 | 加速几乎瞬间完成(1 ms 级 ramp) | | | | -| 减速规划 | 段尾减速按剩余脉冲边界触发,不提前在段中段开始长达秒级的减速 | | | | -| 无错误 | 3 段完成,错误码 0 | | | | -| 最终位置 | `15000` | | | | -| 末段内部诊断 | 期望 5000、实测 5000、误差 0 | | | | - -### TC-MS-206 零加减速 + 起停速度与段频率不同(跳频边界) - -- 优先级:`P1` -- 方法:上位机 -- 关注点:加减速时间 0 但启动/停止速度与段频率不同,验证零时长 ramp 直接跳频的正确性。 - -公共参数(差异项): - -| 字段 | 值 | -|---|---| -| 段数 | 3 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | **500** | -| 停止速度 Hz | **500** | -| 加速时间 ms | 0 | -| 减速时间 ms | 0 | - -段参数(每段均 1000 脉冲): - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | **8000** | **8000** | **8000** | -| 脉冲数 | 1000 | 1000 | 1000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 跳频正确 | 每段直接以 8000 Hz 输出(无 500 Hz ramp 过程) | | | | -| 无错误 | 3 段完成,错误码 0 | | | | -| 最终位置 | `3000` | | | | -| 末段内部诊断 | 期望 1000、实测 1000、误差 0 | | | | -| 波形(逻辑分析仪) | 段起止频率跳变无窄脉冲、无额外边沿 | | | | - -### TC-MS-207 全部速度一致恒速(无 ramp 机会) - -- 优先级:`P1` -- 方法:上位机 -- 关注点:默认速度 = 启动速度 = 停止速度 = 段频率,频率差为 0,验证 ramp 差值为 0 时稳定恒速。 - -公共参数(差异项): - -| 字段 | 值 | -|---|---| -| 段数 | 5 | -| 默认速度 Hz | **20000** | -| 启动速度 Hz | **20000** | -| 停止速度 Hz | **20000** | -| 加速时间 ms | **100** | -| 减速时间 ms | **100** | - -段参数(每段均 2000 脉冲): - -| 段 | 1 | 2 | 3 | 4 | 5 | -|---|---:|---:|---:|---:|---:| -| 频率 Hz | 20000 | 20000 | 20000 | 20000 | 20000 | -| 脉冲数 | 2000 | 2000 | 2000 | 2000 | 2000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 恒定频率 | 每段当前频率约 `20000 Hz`,无 ramp 波动 | | | | -| 无错误 | 5 段完成,错误码 0 | | | | -| 最终位置 | `10000` | | | | -| 末段内部诊断 | 期望 2000、实测 2000、误差 0 | | | | - -### TC-MS-208 高频段与低频段混排 + 大加减速 - -- 优先级:`P1` -- 方法:上位机 + 逻辑分析仪 -- 关注点:100000 Hz 与 100 Hz 段交替,大加减速下验证高频→低频的减速与低频→高频的加速在短段内正确截断。 - -公共参数(差异项): - -| 字段 | 值 | -|---|---| -| 段数 | 3 | -| 默认速度 Hz | 100000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | **100** | -| 减速时间 ms | **100** | - -段参数: - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | **100000** | **100** | **100000** | -| 脉冲数 | 5000 | 50 | 5000 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 段 1/3 | 100000 Hz 段正常执行,无错误 | | | | -| 段 2 | 100 Hz 短段正常执行,无慢爬、无跳变 | | | | -| 段间过渡 | 高频→低频减速与低频→高频加速形态合理(记录实测) | | | | -| 最终状态/位置 | 完成 7 / 错误 0,位置 `10050` | | | | -| 末段内部诊断 | 期望 5000、实测 5000、误差 0 | | | | -| 波形(逻辑分析仪) | 无窄脉冲、无额外边沿 | | | | - -### TC-MS-209 超大加减速(10000 ms)+ 极短段(100 脉冲) - -- 优先级:`P1` -- 方法:上位机 + 逻辑分析仪 -- 关注点:加减速折算约 10 秒级而每段仅 100 脉冲,验证极短轮廓截断路径的峰值规划与时长。 - -公共参数(差异项): - -| 字段 | 值 | -|---|---| -| 段数 | 3 | -| 默认速度 Hz | 100000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | **10000** | -| 减速时间 ms | **10000** | - -段参数(每段均 100 脉冲): - -| 段 | 1 | 2 | 3 | -|---|---:|---:|---:| -| 频率 Hz | **100000** | **100000** | **100000** | -| 脉冲数 | 100 | 100 | 100 | -| 等待类型 | 外部或完成 4 | 4 | 4 | -| WAIT ms | 0 | 0 | 0 | -| ACT ms | 0 | 0 | 0 | -| 跳转段 | 0 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 短轮廓执行 | 每段按截断三角完成,峰值远低于 100000 Hz(记录实测) | | | | -| 按时完成 | 每段总时长毫秒级,无秒级慢爬 | | | | -| 无错误 | 3 段完成,错误码 0 | | | | -| 最终位置 | `300` | | | | -| 末段内部诊断 | 期望 100、实测 100、误差 0 | | | | - -### TC-MS-210 后续模式 + 段间频率差 1000→100000 Hz - -- 优先级:`P1` -- 方法:上位机 + 逻辑分析仪 -- 关注点:后续模式下段 1 运行中提前变频至 100000 Hz,段间频率差 100 倍,验证无缝衔接与大幅变频。 - -公共参数(差异项): - -| 字段 | 值 | -|---|---| -| 发送模式 | **后续发送 (1)** | -| 段数 | 2 | -| 默认速度 Hz | 100000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | **100** | -| 减速时间 ms | **100** | - -段参数: - -| 段 | 1 | 2 | -|---|---:|---:| -| 频率 Hz | **1000** | **100000** | -| 脉冲数 | 1000 | 5000 | -| 等待类型 | 外部或完成 4 | 4 | -| WAIT ms | 0 | 0 | -| ACT ms | 0 | 0 | -| 跳转段 | 0 | 0 | - -验收记录: - -| 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | -|---|---|---|---|---| -| 段 1 频率 | 段 1 以 1000 Hz 输出(后续模式下提前向 100000 Hz 变频) | | | | -| 衔接不停轴 | 段 1→2 边界输出无中断 | | | | -| 段 2 频率 | 段 2 达到约 `100000 Hz` | | | | -| 最终状态/位置 | 完成 7 / 错误 0,位置 `6000` | | | | -| 末段内部诊断 | 期望 5000、实测 5000、误差 0 | | | | -| 波形(逻辑分析仪) | 变频过程无窄脉冲、无额外边沿 | | | | - ---- - -## 7. 备注与已知限制 - -1. **TC-MS-001/002/003** 的段间波形指标需要逻辑分析仪;未接线时记录“未测”。已知 8-13 Bug 报告指出有限路径段间存在 ~2.5 ms 间隙(Bug 4),本次执行时重点观察该项,若复现请记录。 -2. **TC-MS-005**(频率 0 用默认速度)依赖固件 `PlsrResolvedSegmentFrequency` 语义(工作区未提交改动),若当前烧录固件不含该修复,段 1/3 写入 0 可能被拒绝(返回非法值)或按 0 Hz 处理,请记录实际行为。 -3. 所有用例执行前先 `清零`,执行后记录“输出诊断”页数据;异常退出时先 `停止` 再处理。 -4. 上位机轮询显示存在 1 ms 级延迟,频率/状态的瞬态值以逻辑分析仪为准,上位机显示值允许 ±1 Hz 量化误差。 -5. 多段测试中的方向延时(TC-MS-003)默认 10 ms,若修改公共参数中的方向延时,需同步调整验收范围(`延时 ± 2 ms`)。 diff --git a/Document/PLSR_document/PLSR方案设计书_V1.0.md b/Document/PLSR_document/PLSR方案设计书_V1.0.md deleted file mode 100644 index fabf7b3..0000000 --- a/Document/PLSR_document/PLSR方案设计书_V1.0.md +++ /dev/null @@ -1,1351 +0,0 @@ -# PLSR 方案设计书 - -> 文档版本:V1.0 Rev.C -> 需求基线:[PLSR需求规格统计_V1.3.md](./PLSR需求规格统计_V1.3.md) Rev.B -> 目标平台:XDM-60T4-E / STM32F407IG / uC/OS-II -> 编制日期:2026-08-05 -> 文档状态:设计基线;标记为“待上板验证”的项目在验证完成前不得关闭 - -## 目录 - -1. [概述](#1-概述) - 1. [项目背景](#11-项目背景) - 2. [定义](#12-定义) -2. [系统概述](#2-系统概述) - 1. [需求概述](#21-需求概述) - 2. [系统结构图](#22-系统结构图) - 3. [功能模块列表](#23-功能模块列表) -3. [功能模块设计](#3-功能模块设计) - 1. [PLSR运动控制核心模块设计](#31-plsr运动控制核心模块设计) - 2. [参数解析与任务提交模块设计](#32-参数解析与任务提交模块设计) - 3. [路径解释与段调度模块设计](#33-路径解释与段调度模块设计) - 4. [速度规划模块设计](#34-速度规划模块设计) - 5. [高速脉冲输出模块设计](#35-高速脉冲输出模块设计) - 6. [状态机与命令模块设计](#36-状态机与命令模块设计) - 7. [输出资源管理模块设计](#37-输出资源管理模块设计) - 8. [软元件与通信适配模块设计](#38-软元件与通信适配模块设计) - 9. [位置、当量与持久化模块设计](#39-位置当量与持久化模块设计) - 10. [诊断与自测模块设计](#310-诊断与自测模块设计) -4. [数据结构设计](#4-数据结构设计) - 1. [数据结构设计](#41-数据结构设计) - 2. [数据结构与程序的关系](#42-数据结构与程序的关系) -5. [错误处理设计](#5-错误处理设计) - 1. [错误处理设计描述](#51-错误处理设计描述) - 2. [错误输出信息](#52-错误输出信息) -6. [参考资料](#6-参考资料) - -## 1 概述 - -### 1.1 项目背景 - -本项目在现有 XDM-60T4-E 控制器工程中实现 PLSR 定位控制功能。控制器采用 -STM32F407IG、12 MHz 外部晶振、168 MHz 系统时钟和 uC/OS-II,现有工程已经具备 -USART1 Modbus RTU、USB Device、Backup SRAM 和基本任务调度能力,但尚未实现高速脉冲、 -路径解释、速度规划及轴状态管理。 - -PLSR V1.0 对标信捷 XD/XL 系列 PLC 的 PLSR 逻辑模型,保留 `PLSR S0 S1 S2 D`、 -S0 多段数据、S1 用户参数、S2 K0~K4 参数组、D 输出端口以及固定 HSD/SFD/SM/SD/I -软元件语义。在此基础上增加四轴独立运行、CW/CCW、64 位内部位置、参数原子提交、命令防重、 -诊断和 Backup SRAM 检查点等能力。 - -本文说明系统如何实现需求,重点覆盖: - -- 总体软硬件架构; -- OS 任务与硬件中断的职责边界; -- PULSE/DIR、AB 相和 CW/CCW 输出; -- S0/S1/S2/D 解析与运行快照; -- 多段路径、等待、跳转和段事件; -- 加减速、动态调频、位置及累计; -- 固定软元件、保存、错误和诊断。 - -V1.0 不实现多轴直线或圆弧插补、闭环位置修正、电子凸轮、龙门同步、完整机械回零和 -安全认证急停。 - -### 1.2 定义 - -| 名称 | 定义 | -| --- | --- | -| PLSR | 多段脉冲定位指令,逻辑调用形式为 `PLSR S0 S1 S2 D` | -| S0 | 动态首地址指定的路径数据块,保存总段数及各段频率、脉冲数、等待和跳转 | -| S1 | 动态首地址指定的用户参数块,保存相对/绝对模式和起始段 | -| S2 | K0~K4 运动参数套组,保存默认速度、速度限制、曲线、补偿、FOLLOW和刷新周期 | -| D | 输出资源操作数,兼容语义下选择 Y0~Y3,本项目对应 Q0~Q3 高速资源 | -| PULSE/DIR | 一个端口输出脉冲,另一个端口输出方向电平 | -| AB 相 | 两路相差四分之一周期的正交输出,通过相位先后表示方向 | -| CW/CCW | 正向和反向分别使用独立脉冲端口,两个端口不得同时输出 | -| 指令脉冲 | PULSE/DIR 的一个上升沿、CW/CCW 的一个完整脉冲或 AB 相的一个完整四状态周期 | -| 控制刷新周期 | 速度规划器更新目标频率的周期,只允许 0.1 ms 或 1 ms | -| 脉冲周期 | 由当前输出频率决定的物理波形周期,与 OS Tick 和控制刷新周期不同 | -| 运行快照 | START 接受后为轴生成的不可变 S0/S1/S2/D 执行副本 | -| BUSY | 轴存在已接受但尚未终止的任务,包括运动、WAIT和PAUSED | -| PULSE_ACTIVE | 当前物理脉冲正在输出 | -| DONE | 当前任务正常完成并锁存;STOP或ERROR不得置位 | -| 当量 | 通过每转脉冲数和每转移动量将工程单位换算为指令脉冲 | -| 固定软元件 | 信捷规定地址和含义的 HSD、SFD、SM、SD、I 数据 | -| 动态数据地址 | 调用者在允许数据区内为 S0、S1 选择的连续首地址 | - -## 2 系统概述 - -### 2.1 需求概述 - -#### 2.1.1 功能范围 - -系统应实现四个独立逻辑轴。PULSE/DIR 可同时运行四轴;AB 相和 CW/CCW 使用 Q0/Q1、 -Q2/Q3 固定点对,最多同时运行两轴。四轴之间不做插补,各轴独立启动、停止、调频、定位和查询。 - -主要功能如下: - -| 类别 | V1.0功能 | -| --- | --- | -| 输出方式 | PULSE/DIR、AB相、CW/CCW | -| 轴数 | 四轴独立;AB/CW/CCW最多两轴 | -| 频率 | 1~100 kHz保证范围;100001~200000 Hz经板测后作为非保证扩展 | -| 脉冲数量 | 完整有符号32位;0表示跳过当前段 | -| 定位 | 相对定位、绝对定位、设置/清零当前位置 | -| 路径 | 1~100段,支持等待、ACT、EXT和动态跳转 | -| 曲线 | 无加减速、直线、S形、正弦 | -| 段衔接 | 完成方式、后续方式 | -| 控制 | START、STOP、PAUSE、RESUME、保存、清零、自测 | -| 刷新周期 | S2配置0.1 ms或1 ms,OS Tick保持1 ms | -| 保存 | Flash固定配置、Backup SRAM动态检查点 | -| 对标 | 固定HSD/SFD/SM/SD/I地址及S0/S1动态首地址语义 | - -#### 2.1.2 性能和安全目标 - -- 正常完成时脉冲数量误差为 0; -- 输出目标占空比为 50%; -- 当前段目标频率修改应在配置的控制刷新周期内开始应用; -- 四轴 100 kHz 并发时不得丢脉冲、重复脉冲或串轴; -- Modbus和USB压力不得改变运动结果; -- 上电、复位、HardFault和看门狗复位期间输出保持安全无效; -- 运行参数使用快照或原子访问,禁止读取半更新数据; -- 异常复位后不得自动续跑,并清除绝对位置有效标志; -- 软件急停没有独立硬件切断回路,不得宣称安全认证急停。 - -#### 2.1.3 工程基线与约束 - -| 项目 | 现状/约束 | 方案处理 | -| --- | --- | --- | -| MCU | STM32F407IG,168 MHz | 保持现有时钟配置 | -| OS | uC/OS-II,Tick为1 ms | 不提高OS Tick | -| Modbus | USART1+DMA,任务轮询 | 作为调用与监控适配层 | -| USB | OTG FS中断当前优先级0 | 调低到运动中断之后,建议优先级6 | -| PLSR定时器 | 当前未初始化 | 新增TIM6控制基准、TIM9/TIM12硬件计数及TIM10/11/13/14脉冲输出 | -| Modbus历史 | 代码仍写入1000起始历史区 | 删除历史写入,释放给S0/S1动态数据池 | -| Backup SRAM | 已完成基础初始化 | 扩展为带版本和CRC的双检查点 | -| 急停 | 无外部硬件切断 | 仅实现软件立即关闭和状态锁存 | - -### 2.2 系统结构图 - -![PLSR系统结构图](./PLSR系统结构图.svg) - -**图 2-1 PLSR系统结构图** - -图中蓝色表示控制与数据流,橙色表示硬实时及物理输出路径,绿色表示固定软元件、持久化和诊断等 -数据服务。为方便纯文本环境阅读,结构关系同时保留如下文字版本。 - -```text -┌──────────────────────────────────────────────────────────┐ -│ PLC调用 / Modbus RTU / 内部测试接口 │ -└───────────────────────────┬──────────────────────────────┘ - │ -┌───────────────────────────▼──────────────────────────────┐ -│ 命令与软元件适配:调用上升沿、命令序号、状态快照 │ -└───────────────────────────┬──────────────────────────────┘ - │ -┌───────────────────────────▼──────────────────────────────┐ -│ 参数提交:读取S0/S1/S2/D、完整校验、资源申请、运行快照 │ -└───────────────────────────┬──────────────────────────────┘ - │ -┌───────────────────────────▼──────────────────────────────┐ -│ 四轴运动内核:状态机、路径解释、速度规划、位置与段事件 │ -└───────────────┬───────────────────────────┬──────────────┘ - │ │ -┌───────────────▼──────────────┐ ┌─────────▼──────────────┐ -│ OS任务控制面 │ │ 定时器/EXTI实时面 │ -│ 命令、段切换、等待、保存 │ │ 脉冲、调频、尾停、限位 │ -└───────────────┬──────────────┘ └─────────┬──────────────┘ - │ │ - └─────────────┬─────────────┘ - │ -┌─────────────────────────────▼────────────────────────────┐ -│ STM32F407板级层:TIM6、TIM9/12、TIM10/11/13/14、GPIO、EXTI│ -└─────────────────────────────┬────────────────────────────┘ - │ - Q0~Q3 / DIR - │ - 伺服驱动器 -``` - -系统划分为控制面和实时面: - -| 执行域 | 运行位置 | 负责内容 | 禁止内容 | -| --- | --- | --- | --- | -| 控制面 | `PlsrTask` | 命令、校验、快照、状态机慢路径、等待、段切换、保存和发布 | 逐脉冲翻转GPIO | -| 实时面 | TIM/EXTI中断 | 硬件累计、软件兜底计数、尾脉冲、速度刷新、限位和EXT锁存 | 阻塞、动态内存、Flash、通信解析、OS等待 | - -### 2.3 功能模块列表 - -| 编号 | 模块 | 建议源文件 | 主要职责 | -| --- | --- | --- | --- | -| M01 | PLSR运动控制核心 | `plsr_core.c` | 四轴对象、任务入口、事件分派和模块协调 | -| M02 | 参数解析与任务提交 | `plsr_parser.c` | S0/S1/S2/D读取、校验、快照和提交 | -| M03 | 路径解释与段调度 | `plsr_path.c` | 段进入、等待、跳转、零段和段事件 | -| M04 | 速度规划 | `plsr_profile.c` | 三种曲线、短距离、衔接和动态调频 | -| M05 | 高速脉冲输出 | `plsr_hal_f407.c` | PWM、TIM9/TIM12硬件累计、软件计数兜底、AB配对和安全关闭 | -| M06 | 状态机与命令 | `plsr_state.c`、`plsr_command.c` | 合法状态转换、命令幂等和优先级 | -| M07 | 输出资源管理 | `plsr_resource.c` | 高速点、DIR点及TIM9/TIM12计数资源的原子申请和释放 | -| M08 | 软元件与通信适配 | `plsr_device.c` | HSD/SFD/SM/SD/I、S0/S1及Modbus边界 | -| M09 | 位置、当量与持久化 | `plsr_position.c`、`plsr_persist.c` | 64位位置、换算、限位、Flash和Backup SRAM | -| M10 | 诊断与自测 | `plsr_diag.c` | 错误映射、分频记录、性能统计和自测 | - -建议工程目录: - -```text -PLSR/ - Inc/ - plsr.h - plsr_types.h - plsr_device.h - plsr_hal.h - Src/ - plsr_core.c - plsr_parser.c - plsr_path.c - plsr_profile.c - plsr_hal_f407.c - plsr_state.c - plsr_command.c - plsr_resource.c - plsr_device.c - plsr_position.c - plsr_persist.c - plsr_diag.c -``` - -## 3 功能模块设计 - -### 3.1 PLSR运动控制核心模块设计 - -#### 3.1.1 模块功能描述 - -运动控制核心静态创建四个 `PlsrAxis` 对象,负责初始化各子模块、运行 `PlsrTask`、接收中断事件、 -调用状态机、协调段调度和发布状态。模块不直接解释 Modbus 帧,也不直接操作 Flash 或具体 GPIO。 - -OS任务建议如下: - -| 任务 | 建议优先级 | 触发方式 | 职责 | -| --- | ---: | --- | --- | -| `PlsrTask` | 4 | 事件唤醒,最长1 ms巡检 | 命令、路径慢处理、等待、状态发布和保存请求 | -| 现有Modbus任务 | 5 | 1 ms轮询 | 通信收发和协议解析 | -| 测试任务 | 6及以后 | 按需 | 非实时测试和日志 | - -中断优先级首次配置建议如下,最终值通过 uC/OS-II 端口约束和上板测试冻结: - -| 抢占优先级 | 中断 | 用途 | -| ---: | --- | --- | -| 1 | 硬限位EXTI | 锁存限位并禁止继续越界输出 | -| 2 | TIM9/TIM12、TIM10/11/13/14 | 硬件计数完成、软件兜底计数、参数边界和尾脉冲关闭 | -| 3 | TIM6 | 0.1 ms速度控制基准 | -| 4 | EXT输入EXTI | 路径切段边沿锁存 | -| 5 | USART1、DMA2 | 现有Modbus通信 | -| 6 | USB OTG FS | 从当前优先级0调低 | -| 15 | SysTick | 现有1 ms OS Tick | - -实时中断不调用 OS API,只设置原子事件位。`PlsrTask` 获取事件后按急停、限位、立即停止、 -减速停止、暂停和正常事件的顺序处理。 - -#### 3.1.2 模块输入输出接口说明 - -```c -PlsrResult Plsr_Init(void); -void Plsr_Task(void *argument); -PlsrResult Plsr_Submit(const PlsrCall *call, uint32_t sequence); -PlsrResult Plsr_Command(const PlsrCommand *command); -bool Plsr_GetStatus(uint8_t axis, PlsrStatus *status); -void Plsr_IsrPost(uint8_t axis, uint32_t event_mask); -``` - -输入为调用参数、控制命令和硬件事件;输出为提交结果、状态快照、固定软元件状态和持久化请求。 - -#### 3.1.3 流程图 - -![PLSR单轴完整执行流程图](./PLSR单轴完整执行流程图.png) - -**图 3-1 PLSR单轴完整执行流程图** - -```text -系统启动 - -> 输出置安全状态 - -> 初始化设备访问、资源和持久化 - -> 初始化四轴对象 - -> 初始化定时器和EXTI - -> 自检 - -> 全部成功:进入IDLE - -> 任一失败:保持输出关闭并发布初始化错误 - -PlsrTask循环 - -> 读取ISR事件 - -> 按优先级处理停止和限位 - -> 处理命令 - -> 推进各轴状态机 - -> 处理等待和段跳转 - -> 发布一致状态 - -> 执行非实时保存请求 -``` - -### 3.2 参数解析与任务提交模块设计 - -#### 3.2.1 模块功能描述 - -该模块把 `PLSR S0 S1 S2 D` 转换为可执行任务。START 只在启动信号上升沿或新命令序号时接受。 -模块先完整校验,再原子申请资源,最后生成运行快照;任何失败都不得影响正在运行的合法任务。 - -S0校验内容: - -- 总段数为1~100; -- 末地址 `S0 + N*10 + 9` 使用检查过的64位运算; -- 保留字为0; -- 双字低字在低地址、高字在下一地址; -- 频率、脉冲数、等待编码、变量来源和跳转来源合法; -- 数据块位于允许的数据区; -- S0与S1不重叠。 - -S1只包含定位模式和起始段。S2只允许K0~K4,并校验默认速度、最高速度、起止频率、曲线、 -FOLLOW、补偿和刷新周期。D按输出模式检查Y0~Y3或Y0/Y2固定点对。 - -#### 3.2.2 模块输入输出接口说明 - -```c -PlsrResult PlsrParser_BuildSnapshot( - const PlsrCall *call, - PlsrJobSnapshot *snapshot, - PlsrErrorDetail *detail); - -PlsrResult PlsrParser_ReadLiveFrequency( - const PlsrJobSnapshot *snapshot, - uint16_t segment, - uint32_t *frequency_hz); -``` - -输入为 S0/S1 首地址、S2选择值、D及输出配置;输出为完整快照或带错误数据块号的失败结果。 - -#### 3.2.3 流程图 - -```text -收到START - -> 检查上升沿/命令序号 - -> 检查轴状态 - -> 读取S0总段数并计算边界 - -> 读取并校验全部S0段 - -> 读取并校验S1 - -> 读取并校验S2和D - -> 检查绝对位置、限位和组合参数 - -> 原子申请输出资源 - -> 生成不可变运行快照 - -> 成功返回运动核心 - -任一步失败 - -> 回滚已申请资源 - -> 保持输出无效 - -> 返回兼容码和增强错误 -``` - -运行期间仅允许重新读取当前段目标频率。S0其它字段、S1、S2、D和资源绑定均使用快照;非法动态 -频率写入被忽略,继续使用最近合法值并发布诊断。 - -### 3.3 路径解释与段调度模块设计 - -#### 3.3.1 模块功能描述 - -路径模块负责1~100段的进入、执行完成条件、等待、动态跳转和段事件。相对模式把段脉冲数解释为 -本段位移;绝对模式把它解释为目标坐标,并用目标坐标减当前逻辑位置求实际输出位移。 - -零值判断顺序固定为: - -1. 先检查脉冲数量; -2. 脉冲数为0时不输出,产生零段事件并跳转; -3. 脉冲数非0且频率为0时才使用S2默认速度; -4. 默认速度不合法时拒绝执行。 - -等待条件处理如下: - -| 编码 | 条件 | 模块处理 | -| --- | --- | --- | -| H00 | 脉冲完成 | 输出完成后立即跳转 | -| H01 | Wait时间 | 完成后计时;0按1 ms | -| H02 | Wait信号 | 完成后判断X/M/HM,未有效则保持WAIT | -| H03 | ACT时间 | 段开始计时;时间到放弃剩余脉冲,脉冲先完则等到时间 | -| H04 | EXT信号 | 新上升沿到达时放弃剩余脉冲;脉冲先完仍等待新边沿 | -| H05 | EXT或脉冲完成 | 原子锁存先到原因,先到者决定跳转 | - -快照保存变量来源类型和编号,Wait/ACT数值、Wait信号和动态跳转值在实际求值时读取,以保留信捷 -动态变量语义。跳转值0表示顺序执行,1~N表示指定段,自跳表示有意无限循环。 - -#### 3.3.2 模块输入输出接口说明 - -```c -PlsrResult PlsrPath_Enter(PlsrAxis *axis, uint16_t segment); -PlsrResult PlsrPath_OnOutputDone(PlsrAxis *axis); -PlsrResult PlsrPath_OnExt(PlsrAxis *axis, uint32_t edge_sequence); -PlsrResult PlsrPath_PollWait(PlsrAxis *axis, uint64_t now_ms); -``` - -输出包括下一段请求、正常完成请求、停止剩余脉冲请求和段事件。 - -#### 3.3.3 流程图 - -```text -进入段 - -> 检查段号 - -> 计算相对位移或绝对位移 - -> 位移为0:不输出并生成段事件 - -> 位移非0:解析方向和目标频率 - -> 启动输出/ACT/EXT监视 - -> 等待脉冲完成或条件事件 - -> 锁存段结束原因 - -> 产生I6000~I6399对应事件 - -> 求动态跳转值 - -> 下一段 / WAIT / COMPLETED / ERROR -``` - -每次任务调度最多连续处理100次无脉冲跳转,达到上限后主动让出CPU,避免无限循环阻塞系统。 - -### 3.4 速度规划模块设计 - -#### 3.4.1 模块功能描述 - -速度规划模块根据S2、段目标、剩余脉冲和下一段速度生成当前控制频率。TIM6提供10 kHz控制基准; -0.1 ms轴每次更新,1 ms轴每10次更新。无运行轴时关闭TIM6。 - -内部采用定点数:频率使用Q32.32,归一化曲线进度使用Q1.31,时间使用整数微秒,禁止在控制中断 -使用浮点三角函数。 - -三种曲线定义为: - -```text -直线:g(u) = u -S形: g(u) = 3u² - 2u³ -正弦:g(u) = (1 - cos(πu)) / 2 - -f(u) = f_start + (f_end - f_start) * g(u) -``` - -正弦函数使用只读查找表和线性插值。距离不足时通过有界二分求解峰值频率,自动形成三角或短距离 -曲线;无法满足起止速度和脉冲数时返回参数冲突。 - -完成方式在当前段脉冲结束后再改变到下一段速度;后续方式在当前段内预留变速脉冲,使段边界时已达到 -下一段入口速度。反向段必须先减速到0再切换方向或相序。 - -S2最高速度为0或超出硬件开放范围时拒绝启动。起始、目标和终止速度超过合法最高速度时限幅并发布 -`SPEED_CLAMPED`。FOLLOW性能范围为1~100,前馈补偿范围为0~100;具体运算公式在编码前按 -信捷手册相应参数定义冻结,禁止自行发明含义。 - -#### 3.4.2 模块输入输出接口说明 - -```c -PlsrResult PlsrProfile_Prepare( - const PlsrSegmentRuntime *segment, - const PlsrS2Snapshot *s2, - PlsrProfile *profile); - -void PlsrProfile_ControlTick(PlsrProfile *profile, uint32_t elapsed_us); -void PlsrProfile_SetTarget(PlsrProfile *profile, uint32_t target_hz); -``` - -输入为当前频率、目标频率、起止频率、加减速时间、曲线、剩余脉冲和衔接方式;输出为当前命令频率 -和待提交的定时器分频参数。 - -#### 3.4.3 流程图 - -```text -准备段曲线 - -> 校验S2和目标速度 - -> 计算加速/减速所需脉冲 - -> 距离足够:生成完整曲线 - -> 距离不足:求解短距离峰值 - -> 每0.1/1 ms推进定点曲线 - -> 计算当前命令频率 - -> 限幅并生成诊断 - -> 计算待提交PSC/ARR/CCR - -> 脉冲模块在安全周期边界应用 -``` - -运行中目标频率修改不改变剩余脉冲数。规划器在配置刷新周期内接收并开始过渡,物理周期参数在最近的 -安全周期边界切换;低频时不得为了刷新指标截断一个完整脉冲。 - -### 3.5 高速脉冲输出模块设计 - -#### 3.5.1 模块功能描述 - -高速输出模块使用硬件定时器维持脉冲边沿,OS不参与逐脉冲翻转。 - -![PLSR高速脉冲与硬件计数架构图](./PLSR高速脉冲与硬件计数架构图.svg) - -**图 3-2 PLSR高速脉冲与硬件计数架构图** - -| 逻辑点 | MCU引脚 | 定时器通道 | 定时器时钟 | -| --- | --- | --- | ---: | -| Q0/Y0 | PF6 | TIM10_CH1 | 168 MHz | -| Q1/Y1 | PF8 | TIM13_CH1 | 84 MHz | -| Q2/Y2 | PF7 | TIM11_CH1 | 168 MHz | -| Q3/Y3 | PF9 | TIM14_CH1 | 84 MHz | - -##### 3.5.1.1 混合计数架构 - -RM0090 Rev.22 第19.4.2节表101的TIM9/TIM12内部触发连接提供以下通路: - -| 硬件计数器 | 触发选择 | 内部计数来源 | 对应输出 | -| --- | --- | --- | --- | -| TIM9 | ITR2 | TIM10_OC | Q0 | -| TIM9 | ITR3 | TIM11_OC | Q2 | -| TIM12 | ITR2 | TIM13_OC | Q1 | -| TIM12 | ITR3 | TIM14_OC | Q3 | - -TIM9在同一时刻只能选择Q0或Q2中的一路,TIM12只能选择Q1或Q3中的一路。因此V1.0采用: - -```text -优先:TIM9/TIM12内部硬件累计 - ↓ 计数器不可达或已被其它轴占用 -兜底:对应输出定时器的周期中断软件累计 -``` - -计数方式在每段开始前确定并锁存到该段结束,不在脉冲活动期间切换。最多同时提供两套内部硬件 -计数;四轴PULSE/DIR并发时,至少两轴硬件计数、其余轴软件计数。资源分配优先级依次为: - -1. 已运行轴持有的计数器租约不得被新任务抢占; -2. 同一批待启动段发生竞争时,优先AB相,其次按预计软件中断负载从高到低选择; -3. 条件仍相同时,按命令提交顺序选择; -4. 无可用硬件计数器时自动使用软件计数,不因计数器不足拒绝合法运动。 - -可同时硬件计数的PULSE/DIR组合为Q0+Q1、Q0+Q3、Q2+Q1或Q2+Q3。Q0与Q2竞争TIM9, -Q1与Q3竞争TIM12。 - -AB相每个相位通道每完整正交周期只产生一次同类内部比较事件,因此一组AB轴只需选择其中一个相位 -作为周期计数基准。两组AB轴应通过匹配分配分别使用TIM9和TIM12,例如Q0/Q1轴选Q0→TIM9, -Q2/Q3轴选Q3→TIM12。 - -CW/CCW只对当前实际发脉冲的方向通道计数。发生方向切换时,在完全停止的段边界重新申请相应的 -TIM9或TIM12;申请失败则该段切换为软件计数,不能在脉冲活动期间改源。 - -定时器采用向上计数和PWM输出: - -```text -f_actual = f_timer / ((PSC + 1) * (ARR + 1)) -CCR1 = (ARR + 1) / 2 -``` - -分频算法选择能容纳16位ARR的最小预分频,并比较相邻合法候选,取绝对频率误差最小者;误差相同时 -选择更大的ARR以保留分辨率。记录目标频率、实际频率、PSC、ARR、CCR和误差。 - -硬件计数模式下不为每个PWM周期开启更新中断。TIM6控制刷新先短暂禁止输出定时器的更新装载,依次 -写入PSC、ARR和CCR预装载值,再恢复更新装载;完整的新参数在随后自然更新事件一次性生效。若更新 -边界在短暂禁止期间到达,则延后一个完整周期生效,不能出现混合分频值或截断周期。 - -PULSE/DIR先设置DIR,再等待默认10 ms或配置的方向建立时间,随后启动PWM。物理上升沿计1个指令 -脉冲。硬件计数时由TIM9/TIM12在外部时钟模式1下累计每周期一次的内部OC事件;软件计数时由输出 -定时器自然更新事件累计。进入目标脉冲时只武装一次尾边沿处理,最后一个脉冲下降沿到达后关闭通道, -防止出现下一个多余上升沿。 - -CW/CCW正向只启动CW端,反向只启动CCW端,另一路始终无效。检测到双通道同时活动时立即关闭配对 -输出并进入ERROR。 - -AB相固定配对如下: - -| 轴归属 | A相 | B相 | 正向状态顺序 | -| --- | --- | --- | --- | -| Y0 | Q0/TIM10 | Q1/TIM13 | `00→10→11→01→00` | -| Y2 | Q2/TIM11 | Q3/TIM14 | `00→10→11→01→00` | - -反向交换A、B领先关系。两个定时器预装四分之一周期相位差;从已分配的一个相位内部OC事件累计 -完整AB周期,落后相第四次跳变完成后关闭最后一周期。一个完整四状态周期只计1个指令脉冲,四次 -相位跳变不得分别计数。 - -##### 3.5.1.2 16位硬件计数扩展 - -TIM9和TIM12均为16位计数器。计数器配置为外部时钟模式1,ARR保持为`0xFFFF`,每收到一次选定的 -内部OC事件,CNT自动加1。软件使用“16位硬件CNT + 64位块累计”扩展完整INT32脉冲范围: - -```text -每65536个指令脉冲 - -> TIM9/TIM12产生一次更新中断 - -> 软件块累计加65536 - -最终低16位到达比较值 - -> 检查块累计是否已到目标高位 - -> 是:武装最终完整脉冲/AB周期的尾边沿关闭 - -> 否:清比较标志并继续硬件累计 -``` - -目标低16位不为0时使用捕获比较事件判断最终余数;目标恰为65536的整数倍时,使用更新事件和块累计 -判断完成,禁止把比较值0解释成启动瞬间已经完成。 - -100 kHz连续输出时,每个硬件计数轴的块中断频率约为`100000/65536=1.53次/秒`;除块中断、最终 -比较和异常外,不产生逐脉冲计数中断。零脉冲段在进入硬件层前处理,绝对值计算使用64位,避免 -INT32_MIN取反溢出。 - -TIM10/11/13/14的SMCR在寄存器表中为保留项,因此硬件计数器只能通知CPU达到目标,不能反向门控 -输出定时器。最终比较事件到达后不得立即截断 -活动波形,而应根据模式武装输出定时器的最终边沿:PULSE/DIR和CW/CCW在最后下降沿关闭,AB相在 -第四次状态跳变后同时关闭配对通道。`N=1`时在启动前直接武装尾边沿。内部OC事件相位、同步延迟和 -最终关闭窗口必须用逻辑分析仪确认。 - -四个高速定时器对应共享中断向量: - -| 定时器 | 中断向量 | -| --- | --- | -| TIM10 | `TIM1_UP_TIM10_IRQn` | -| TIM11 | `TIM1_TRG_COM_TIM11_IRQn` | -| TIM13 | `TIM8_UP_TIM13_IRQn` | -| TIM14 | `TIM8_TRG_COM_TIM14_IRQn` | - -两个硬件计数器的中断向量为: - -| 计数器 | 中断向量 | 用途 | -| --- | --- | --- | -| TIM9 | `TIM1_BRK_TIM9_IRQn` | Q0/Q2硬件块计数和最终比较 | -| TIM12 | `TIM8_BRK_TIM12_IRQn` | Q1/Q3硬件块计数和最终比较 | - -热路径采用直接寄存器访问,不调用通用 `HAL_TIM_IRQHandler()`。共享中断入口先检查本定时器标志, -并为TIM1/TIM8未来用途保留分派能力。硬件计数轴关闭输出定时器的周期更新中断;软件计数轴才启用 -周期更新中断。 - -#### 3.5.2 模块输入输出接口说明 - -```c -PlsrResult PlsrHw_Init(void); -PlsrResult PlsrHw_Configure(const PlsrHwRequest *request); -PlsrResult PlsrHw_Start(uint8_t axis, uint64_t pulses); -PlsrResult PlsrHwCounter_Configure(const PlsrCounterLease *lease, uint64_t target_pulses); -void PlsrHwCounter_Stop(const PlsrCounterLease *lease); -void PlsrHw_SetPendingDivider(uint8_t axis, const PlsrDivider *divider); -void PlsrHw_RequestTailStop(uint8_t axis); -void PlsrHw_ForceSafe(uint32_t resource_mask); -``` - -输入为模式、方向、频率、脉冲数量、方向点和极性;输出为周期完成事件、实际计数、分频诊断和硬件错误。 - -#### 3.5.3 流程图 - -```text -启动硬件输出 - -> 输出保持无效 - -> 配置GPIO复用和安全电平 - -> 装载PSC/ARR/CCR并清状态标志 - -> PULSE/DIR设置方向并等待建立时间 - -> AB装载配对相位 / CW选择单通道 - -> 尝试申请并配置TIM9/TIM12硬件计数 - 成功:关闭逐周期计数中断 - 失败:启用输出定时器软件周期计数 - -> 使能通道和定时器 - -> 硬件持续输出 - -> 硬件模式:每65536脉冲更新块累计 - 软件模式:周期ISR更新剩余量 - -> TIM6通过预装载安全更新分频 - -> 进入最后一个脉冲/AB周期 - -> 比较事件完成尾边沿 - -> 关闭输出并通知运动核心 -``` - -TIM10/11/13/14没有重复计数寄存器,TIM9/TIM12内部连接又只能覆盖两路。四轴100 kHz时,若两路 -硬件计数、两路软件计数,最坏约为每秒200,000次软件周期事件,加TIM6的10,000次控制事件和少量 -硬件块中断。完整功能开发前必须完成两种计数模式原型,测量ISR最坏执行时间、CPU占用、计数模式 -切换、USB/Modbus压力、尾脉冲和AB相位;未通过不得宣称四轴100 kHz验收完成。 - -### 3.6 状态机与命令模块设计 - -#### 3.6.1 模块功能描述 - -每轴状态固定为: - -```text -UNINITIALIZED、IDLE、ACCEL、RUN、DECEL、WAIT、PAUSED、 -COMPLETED、STOPPED、ERROR -``` - -![PLSR单轴状态机与命令优先级图](./PLSR单轴状态机与命令优先级图.svg) - -**图 3-3 PLSR单轴状态机与命令优先级图** - -主要合法转换如下: - -| 当前状态 | 事件 | 下一状态 | -| --- | --- | --- | -| UNINITIALIZED | 初始化成功 | IDLE | -| IDLE/COMPLETED/STOPPED | START成功 | ACCEL、RUN、WAIT或COMPLETED | -| ACCEL/RUN | 需要减速 | DECEL | -| 运动状态 | 等待条件未满足 | WAIT | -| 运动状态 | PAUSE且减速完成 | PAUSED | -| PAUSED | RESUME | ACCEL或RUN | -| 运行类状态 | 路径正常结束 | COMPLETED | -| 非终止状态 | STOP完成 | STOPPED | -| 可运行状态 | 不可恢复错误 | ERROR | -| ERROR | RESET_ERROR成功 | IDLE | - -未列出的转换由统一入口拒绝并记录非法转换。DONE只在正常完成时置1,下一次START成功接受时清零; -STOP和ERROR不得置DONE。WAIT和PAUSED期间BUSY为1、PULSE_ACTIVE为0。 - -支持START、STOP_DECEL、STOP_IMMEDIATE、PAUSE、RESUME、SET_POSITION、CLEAR_POSITION、 -CLEAR_TOTAL、SAVE_CONFIG、LOAD_CONFIG、RESET_ERROR和SELF_TEST。每轴保存最近命令序号与结果, -相同序号重发只返回上次结果,不重复执行。 - -#### 3.6.2 模块输入输出接口说明 - -```c -PlsrResult PlsrState_Transition( - PlsrAxis *axis, - PlsrState target, - PlsrTransitionReason reason); - -PlsrResult PlsrCommand_Execute( - PlsrAxis *axis, - const PlsrCommand *command); -``` - -#### 3.6.3 流程图 - -```text -收到命令 - -> 比较命令序号 - -> 重复序号:返回上次结果 - -> 检查当前状态是否允许 - -> 按命令执行动作 - -> 通过唯一状态转换入口改变状态 - -> 保存序号、结果和停止原因 - -> 发布BUSY/PULSE_ACTIVE/DONE/WAIT/ERROR -``` - -事件优先级固定为:软件急停 > 硬限位 > STOP_IMMEDIATE > STOP_DECEL > PAUSE > 正常运行。 -高优先级原因锁存后,低优先级命令不得覆盖。 - -### 3.7 输出资源管理模块设计 - -#### 3.7.1 模块功能描述 - -资源管理器维护高速资源Q0~Q3、普通输出资源Q0~Q7/Q10~Q17/Q20,以及内部硬件计数器 -TIM9/TIM12的占用位图。高速输出和DIR资源属于运动必需资源;硬件计数器属于优化资源,申请失败时 -允许使用软件计数兜底。 - -| 模式 | D允许值 | 高速资源 | DIR资源 | -| --- | --- | --- | --- | -| PULSE/DIR | Y0~Y3 | D对应的一个Q点 | 一个未占用有效输出点 | -| AB相 | Y0或Y2 | Q0/Q1或Q2/Q3 | 无 | -| CW/CCW | Y0或Y2 | Q0/Q1或Q2/Q3 | 无 | - -硬件计数资源规则: - -| 脉冲来源 | 可申请计数器 | 冲突组 | -| --- | --- | --- | -| Q0/TIM10 | TIM9 ITR2 | 与Q2互斥 | -| Q2/TIM11 | TIM9 ITR3 | 与Q0互斥 | -| Q1/TIM13 | TIM12 ITR2 | 与Q3互斥 | -| Q3/TIM14 | TIM12 ITR3 | 与Q1互斥 | - -单轴或两轴运行时优先选择能全部硬件计数的组合。四轴运行时,资源管理器分别从Q0/Q2组和Q1/Q3组 -选择一个轴使用硬件计数。既有运行租约不被抢占;同批竞争时按AB模式、预计中断负载和提交顺序选择。 -计数器租约只在段停止边界申请或释放;任务运行期间如果某段不能获得计数器,则该段使用软件计数。 - -方向点板级映射如下: - -| PLC点 | MCU引脚 | PLC点 | MCU引脚 | -| --- | --- | --- | --- | -| Q0 | PF6 | Q10 | PG7 | -| Q1 | PF8 | Q11 | PG6 | -| Q2 | PF7 | Q12 | PH9 | -| Q3 | PF9 | Q13 | PH8 | -| Q4 | PI8 | Q14 | PH7 | -| Q5 | PE6 | Q15 | PH6 | -| Q6 | PE5 | Q16 | PF11 | -| Q7 | PE4 | Q17 | PB0 | -| Q20 | PH5 | | | - -完整申请掩码在短临界区内一次判断和占用,禁止逐点申请后失败而留下半组资源。运行期间模式、D、 -配对关系、方向点和极性锁定。正常完成、STOP和ERROR统一通过幂等清理入口释放资源。 - -#### 3.7.2 模块输入输出接口说明 - -```c -PlsrResult PlsrResource_Reserve( - uint8_t owner_axis, - const PlsrResourceRequest *request, - PlsrResourceLease *lease); - -void PlsrResource_Release(PlsrResourceLease *lease); -PlsrCountMode PlsrResource_SelectCounter( - uint8_t owner_axis, - uint8_t pulse_source, - PlsrCounterLease *counter_lease); -``` - -#### 3.7.3 流程图 - -```text -构造完整资源掩码 - -> 检查模式与D - -> 检查高速点和DIR点合法性 - -> 进入短临界区 - -> 任一冲突:退出并返回RESOURCE_CONFLICT - -> 无冲突:一次性占用全部资源 - -> 尝试申请TIM9/TIM12计数优化资源 - 成功:锁存硬件计数租约 - 失败:锁存软件计数模式 - -> 配置硬件 - -> 配置失败:安全关闭并回滚 - -> 任务终止:安全关闭并幂等释放 -``` - -### 3.8 软元件与通信适配模块设计 - -#### 3.8.1 模块功能描述 - -HSD、SFD、SM、SD、I、S0和S1是逻辑软元件命名空间,不等同于“Modbus通信地址表”。 -PLSR内核通过统一设备接口访问逻辑软元件,Modbus只负责字节序、影子写入、命令序号和提交响应。 - -固定地址只采用 [PLSR地址映射.md](./PLSR地址映射.md): - -| 类别 | 固定范围 | -| --- | --- | -| K0配置 | HSD460~539 | -| 公共参数及K1~K4 | SFD900~1419 | -| 累计状态 | HSD0~15 | -| 运行/方向标志 | SM1000/1001、1020/1021、1040/1041、1060/1061 | -| 实时状态 | SD1000~1011、1020~1031、1040~1051、1060~1071 | -| 段事件 | I6000~6399 | -| 动态数据 | S0、S1 | - -固定字段使用显式描述表保存地址、位宽、有符号性、读写属性、合法范围、所属轴和持久化类别,不得通过 -自拟轴块改变信捷地址。保留字段仍占原地址,未实现字段不得挪作项目扩展。 - -32/64位状态采用版本化快照发布。Modbus断开不产生STOP;非法通信参数不得改变正在运行的任务。 -本设计书不新增PLSR全局通信地址区,也不恢复已删除的Modbus历史功能。 - -#### 3.8.2 模块输入输出接口说明 - -```c -bool PlsrDevice_Read(DeviceRef ref, int64_t *value); -bool PlsrDevice_Write(DeviceRef ref, int64_t value); -bool PlsrDevice_ReadBlock(DeviceRef first, uint16_t words, uint16_t *buffer); -void PlsrDevice_PublishAxis(uint8_t axis, const PlsrStatus *status); -void PlsrDevice_TriggerSegmentEvent(uint8_t axis, uint16_t segment); -``` - -#### 3.8.3 流程图 - -```text -外部读写请求 - -> 解析逻辑软元件类型和地址 - -> 检查位宽、权限和范围 - -> 参数写入影子区 - -> COMMIT时调用PLSR业务校验 - -> 成功:切换版本并返回序号/结果 - -> 失败:保持原有效参数 - -状态读取 - -> 读取版本号 - -> 复制完整状态 - -> 再读版本号 - -> 前后相同且为稳定版本:返回 - -> 否则重试 -``` - -### 3.9 位置、当量与持久化模块设计 - -#### 3.9.1 模块功能描述 - -每轴内部维护64位逻辑位置、本次任务累计、总累计和物理输出诊断计数,以及独立的 -`position_valid`。高速ISR只更新当前块计数;任务在块边界、段边界或检查点时合并64位数据。 - -发布HSD固定32位累计前检查INT32范围。超出范围时保持最近合法发布值,锁存 -`COUNTER_OVERFLOW`,不得回绕或静默截断。 - -当量换算按每转指令脉冲数和每转移动量形成有理数比例,计算前约分并检查溢出,每轴保存换算余数, -避免连续多段累计舍入漂移。任一分母为0时返回兼容错误码2。 - -软限位按逻辑位置和预计停止距离判断:禁止向越界方向启动,允许运动离开已触发限位;运动中触发默认 -减速停止。方向反转时插入内部齿隙补偿子段,补偿脉冲不计入用户段、逻辑位置或HSD,但计入物理输出 -诊断。 - -固定配置使用Flash A/B双槽: - -```text -magic | format_version | payload_length | sequence | payload | CRC32 -``` - -动态K0、64位位置、累计、有效标志和最近诊断保存到有VBAT维持的Backup SRAM双检查点。 -正常完成、正常停止、设置/清零位置和清累计后立即保存;运行中初始按10 ms及段边界保存诊断检查点。 -异常复位或上次BUSY时清除 `position_valid`,任何情况都不自动恢复未完成路径。 - -#### 3.9.2 模块输入输出接口说明 - -```c -PlsrResult PlsrPosition_Set(uint8_t axis, int64_t position); -PlsrResult PlsrPosition_Clear(uint8_t axis); -PlsrResult PlsrPosition_ConvertToPulse(uint8_t axis, int64_t value, int64_t *pulse); -PlsrResult PlsrPersist_SaveConfig(void); -PlsrResult PlsrPersist_LoadConfig(void); -void PlsrPersist_SaveCheckpoint(uint32_t reason); -``` - -#### 3.9.3 流程图 - -```text -脉冲块完成 - -> 原子交换当前块计数 - -> 检查64位加法溢出 - -> 更新逻辑位置和累计 - -> 检查软限位 - -> 检查HSD32位发布范围 - -> 更新状态快照 - -> 达到检查点条件:更新Backup SRAM - -上电恢复 - -> 输出保持安全 - -> 读取复位原因 - -> 校验Flash和Backup SRAM版本/CRC - -> 恢复允许的配置与诊断 - -> 异常复位或上次BUSY:清position_valid - -> 清全部运行资源 - -> 禁止自动启动 -``` - -### 3.10 诊断与自测模块设计 - -#### 3.10.1 模块功能描述 - -诊断模块记录每轴目标/实际频率、PSC/ARR/CCR、当前计数模式、TIM9/TIM12块累计、软件脉冲数、 -物理边沿诊断值、当前段、停止原因、状态机非法转换、资源冲突和ISR执行周期。 - -`SELF_TEST` 分为两级: - -- 默认不输出自测:地址表、S0/S1边界、跳转、分频、状态机、计数和CRC; -- 输出自测:需要明确测试授权,仅在断开执行机构或采取防护后输出指定低频波形。 - -#### 3.10.2 模块输入输出接口说明 - -```c -void PlsrDiag_RecordError(uint8_t axis, PlsrError error, const PlsrErrorDetail *detail); -void PlsrDiag_RecordDivider(uint8_t axis, const PlsrDivider *divider); -void PlsrDiag_RecordIsrCycles(uint8_t axis, uint32_t cycles); -PlsrResult PlsrDiag_SelfTest(uint32_t test_mask, bool allow_output); -``` - -#### 3.10.3 流程图 - -```text -收到SELF_TEST - -> 检查所有轴非BUSY - -> 执行静态地址和配置检查 - -> 执行分频、路径、计数和状态机检查 - -> 是否请求输出测试 - 否:汇总结果 - 是:检查显式授权和安全条件 - -> 输出受限测试波形 - -> 自动安全关闭 - -> 发布逐项结果和首个失败原因 -``` - -## 4 数据结构设计 - -### 4.1 数据结构设计 - -#### 4.1.1 基本枚举 - -```c -typedef enum { - PLSR_STATE_UNINITIALIZED, - PLSR_STATE_IDLE, - PLSR_STATE_ACCEL, - PLSR_STATE_RUN, - PLSR_STATE_DECEL, - PLSR_STATE_WAIT, - PLSR_STATE_PAUSED, - PLSR_STATE_COMPLETED, - PLSR_STATE_STOPPED, - PLSR_STATE_ERROR -} PlsrState; - -typedef enum { - PLSR_OUTPUT_PULSE_DIR = 0, - PLSR_OUTPUT_AB = 1, - PLSR_OUTPUT_CW_CCW = 2 -} PlsrOutputMode; - -typedef enum { - PLSR_POSITION_RELATIVE = 0, - PLSR_POSITION_ABSOLUTE = 1 -} PlsrPositionMode; - -typedef enum { - PLSR_COUNT_SOFTWARE = 0, - PLSR_COUNT_TIM9 = 1, - PLSR_COUNT_TIM12 = 2 -} PlsrCountMode; -``` - -#### 4.1.2 调用与命令 - -```c -typedef struct { - DeviceRef s0; - DeviceRef s1; - DeviceRef s2; - uint8_t d_axis; -} PlsrCall; - -typedef struct { - uint32_t sequence; - uint16_t axis; - uint16_t opcode; - int64_t argument; -} PlsrCommand; -``` - -#### 4.1.3 段快照 - -```c -typedef struct { - uint32_t target_frequency_hz; - int32_t pulse_or_position; - uint16_t wait_word; - int32_t wait_argument; - uint16_t jump_word; - int32_t jump_argument; -} PlsrSegmentSnapshot; -``` - -四轴各保留最多100段的紧凑快照。快照同时保存当前段频率源地址,专供受控动态调频;其它字段不得 -通过源地址重新读取。 - -#### 4.1.4 任务快照 - -```c -typedef struct { - uint16_t segment_count; - uint16_t start_segment; - PlsrPositionMode position_mode; - PlsrOutputMode output_mode; - uint8_t d_axis; - PlsrS2Snapshot s2; - PlsrResourceLease resources; - PlsrSegmentSnapshot segments[PLSR_MAX_SEGMENTS]; -} PlsrJobSnapshot; -``` - -硬件计数租约作为输出资源租约的一部分: - -```c -typedef struct { - PlsrCountMode mode; - uint8_t owner_axis; - uint8_t pulse_source; - uint8_t trigger_select; - bool valid; -} PlsrCounterLease; -``` - -TIM9和TIM12各只能存在一个有效租约。软件计数模式不占用硬件计数租约。 - -#### 4.1.5 轴运行对象 - -```c -typedef struct { - PlsrState state; - PlsrJobSnapshot job; - PlsrSegmentRuntime segment; - PlsrProfile profile; - PlsrPulseFastContext pulse_fast; - int64_t logical_position; - int64_t task_pulses; - int64_t total_pulses; - uint64_t physical_edges; - bool position_valid; - uint32_t event_flags; - uint32_t event_sequence; - uint32_t last_command_sequence; - PlsrCommandResult last_command_result; - PlsrError error; - PlsrStatusPublish publish; -} PlsrAxis; -``` - -#### 4.1.6 ISR快速上下文 - -```c -typedef struct { - volatile uint32_t remaining_in_block; - volatile uint32_t completed_in_block; - volatile uint32_t hardware_wrap_count; - volatile uint32_t pending_event; - volatile uint8_t divider_pending; - volatile uint8_t tail_stop_armed; - PlsrCountMode count_mode; - uint8_t pulse_source; - uint16_t final_compare; - uint64_t target_pulses; - PlsrDivider active_divider; - PlsrDivider pending_divider; -} PlsrPulseFastContext; -``` - -硬件计数模式下,`hardware_wrap_count`与TIM9/TIM12当前CNT组成完整累计;软件计数模式下使用 -`remaining_in_block`逐周期递减。`count_mode`在段活动期间只读,只有输出完全停止后才允许改变。 -快速上下文只保存ISR固定时间内需要的数据,不放置S0数组、Flash缓存和复杂状态对象。 - -#### 4.1.7 对外状态 - -```c -typedef struct { - uint32_t version; - PlsrState state; - uint16_t current_segment; - uint16_t total_segments; - int8_t direction; - uint8_t busy; - uint8_t pulse_active; - uint8_t done; - uint32_t command_frequency_hz; - uint32_t actual_frequency_hz; - PlsrCountMode count_mode; - uint32_t hardware_counter_value; - int64_t segment_sent; - int64_t segment_remaining; - int64_t logical_position; - int64_t task_pulses; - int64_t total_pulses; - PlsrError error; - uint32_t stop_reason; - uint32_t version_end; -} PlsrStatus; -``` - -#### 4.1.8 固定软元件描述 - -```c -typedef struct { - PlsrDeviceType type; - uint16_t address; - uint8_t width_words; - uint8_t axis; - uint8_t access; - uint8_t persistence; - int64_t minimum; - int64_t maximum; -} PlsrDeviceDescriptor; -``` - -描述表必须覆盖HSD460~539、SFD900~1419、HSD0~15、固定SM、固定SD和I6000~6399, -并通过静态断言检查重复地址、位宽和轴归属。 - -### 4.2 数据结构与程序的关系 - -#### 4.2.1 数据流关系 - -![PLSR数据流关系图](./PLSR数据流关系图.svg) - -**图 4-1 PLSR数据流关系图** - -图中蓝色表示配置和运动数据,橙色表示硬实时数据及中断事件,绿色表示持久化数据,紫色表示命令与 -状态接口。为方便纯文本环境阅读,保留如下简化关系。 - -```text -S0/S1/S2/D逻辑软元件 - | - v -PlsrParser_BuildSnapshot - | - v -PlsrJobSnapshot ----------> PlsrPath / PlsrProfile - | | - v v -PlsrAxis状态机 <---------- ISR事件和块计数 - | - +----> PlsrStatusPublish ----> HSD/SM/SD/I与Modbus读取 - | - +----> Flash / Backup SRAM检查点 -``` - -#### 4.2.2 数据所有权 - -| 数据 | 唯一写者 | 读取者 | 一致性方法 | -| --- | --- | --- | --- | -| ISR快速计数 | TIM9/TIM12块ISR或输出定时器兜底ISR | TIM6 ISR、PlsrTask | 计数模式锁存、32位原子交换 | -| 速度规划状态 | TIM6 ISR | 脉冲ISR、PlsrTask | 待提交双缓冲 | -| 状态机和路径游标 | PlsrTask | 状态发布 | 单写者 | -| 输出资源位图 | 资源管理器 | PlsrTask、诊断 | 短临界区 | -| 64位位置累计 | PlsrTask | 状态读取、保存 | 顺序锁/短临界区 | -| 对外状态影子 | 状态发布器 | Modbus、内部API | 版本号前后校验 | -| 固定配置镜像 | 参数模块 | 保存和设备访问 | 影子+COMMIT | - -通信层禁止直接写 `PlsrAxis`。`volatile`只保证访问可见性,不代替原子性、临界区和内存屏障。 - -#### 4.2.3 中断事件数据 - -每轴使用32位事件字: - -```text -bit0 PULSE_BLOCK_DONE -bit1 SEGMENT_OUTPUT_DONE -bit2 LIMIT_POSITIVE -bit3 LIMIT_NEGATIVE -bit4 EXT_RISING -bit5 STOP_IMMEDIATE_DONE -bit6 TIMER_FAULT -bit7 COUNTER_FAULT -bit8 HW_COUNTER_WRAP -bit9 HW_COUNTER_TARGET -``` - -可合并事件采用原子置位;不能丢失的段事件另带单调递增序号。临界区内禁止遍历100段、除法、CRC、 -Flash操作或通信发送,并使用DWT周期计数器测量最坏执行时间。 - -#### 4.2.4 固定运行状态发布 - -| 轴 | HSD累计 | SM标志 | SD实时状态 | 段事件 | -| --- | --- | --- | --- | --- | -| Y0 | HSD0~3 | SM1000、SM1001 | SD1000~1011 | I6000~6099 | -| Y1 | HSD4~7 | SM1020、SM1021 | SD1020~1031 | I6100~6199 | -| Y2 | HSD8~11 | SM1040、SM1041 | SD1040~1051 | I6200~6299 | -| Y3 | HSD12~15 | SM1060、SM1061 | SD1060~1071 | I6300~6399 | - -双字采用低地址低16位、下一地址高16位。第N段事件使用轴事件基址加 `N-1`,跳转目标不得改变原段 -事件地址。 - -## 5 错误处理设计 - -### 5.1 错误处理设计描述 - -#### 5.1.1 错误分层 - -| 层级 | 示例 | 处理 | -| --- | --- | --- | -| 告警 | `SPEED_CLAMPED` | 记录并继续运行 | -| 启动拒绝 | 参数越界、资源冲突、位置无效 | 不启动、不占资源、不破坏其它轴 | -| 轴错误 | 计数异常、定时器异常、动态跳转越界 | 安全停止该轴、释放资源并锁存ERROR | -| 全局严重错误 | HardFault、共享数据破坏 | 关闭全部高速输出并锁存全局故障 | - -错误处理统一遵循:先禁止新脉冲,再锁存原因,再发布状态,最后在任务上下文执行资源释放和允许的 -检查点保存。错误清除只回到IDLE,不自动恢复未完成任务。 - -#### 5.1.2 安全输出 - -以下时机调用统一 `PlsrHw_ForceSafe()`: - -- 上电及PLSR初始化前; -- START硬件配置失败; -- STOP_IMMEDIATE尾脉冲完成; -- 定时器、计数或互斥严重错误; -- HardFault、MemManage、BusFault、UsageFault; -- 看门狗复位后的早期初始化。 - -安全关闭直接关闭TIM10/11/13/14通道和计数器,并把相关GPIO恢复为配置的安全无效电平。 -故障处理函数不得写Flash。 - -#### 5.1.3 参数与运行错误 - -- 参数错误在START前尽量全部发现; -- 运行中非法目标频率不破坏当前合法运动,保留最近合法目标并发布诊断; -- 动态等待或跳转变量在运行时越界时进入ERROR; -- 剩余量下溢、TIM9/TIM12硬件累计与输出诊断不一致、定时器意外停止或AB/CW/CCW互斥破坏时 - 立即关闭输出; -- 兼容32位累计溢出时不得截断或回绕; -- 限位触发按正负方向发布兼容码,并禁止继续向越界方向启动; -- 多次STOP_IMMEDIATE按幂等命令处理。 - -#### 5.1.4 设计风险与关闭条件 - -| 编号 | 风险 | 处理及关闭条件 | -| --- | --- | --- | -| RISK-01 | 四轴100 kHz仍有两轴可能采用软件计数,最坏约200k周期事件/秒 | 分别验证两硬件+两软件计数组合,记录ISR最坏周期、CPU占用和通信压力结果 | -| RISK-02 | TIM9/TIM12只能累计,不能反向自动关闭输出源 | 最终比较只武装尾边沿,逐数量边界验证无额外或窄脉冲 | -| RISK-03 | AB配对定时器时钟分别为168/84 MHz | 全频段测量两组AB频率、相位及方向 | -| RISK-04 | USB当前中断优先级0 | 调低优先级并完成USB压力测试 | -| RISK-05 | 输出电路极性和可靠带宽待实测 | 测量Q0~Q3带载波形后冻结参数 | -| RISK-06 | 0.1 ms曲线计算量 | 定点化和预计算,DWT实测最坏执行时间小于周期预算 | -| RISK-07 | 16位通信写入造成32位动态频率撕裂 | 使用影子+COMMIT版本和内核二次校验 | -| RISK-08 | Backup SRAM不能保证掉电瞬间零误差 | 异常复位清position_valid,并记录恢复等级 | -| RISK-09 | 现有历史写入破坏S0/S1数据 | 删除历史写入并完成全地址越界测试 | -| RISK-10 | 信捷保留字段被扩展复用 | 固定描述表、静态断言和对标矩阵评审 | -| RISK-11 | FOLLOW公式尚未在需求中展开 | 编码前按手册冻结公式和测试向量 | -| RISK-12 | TIM9/TIM12内部OC事件相位和跨时钟域同步延迟 | 测量Q0~Q3的计数对应边沿、首脉冲、末脉冲和动态调频后累计一致性 | - -### 5.2 错误输出信息 - -#### 5.2.1 信捷兼容错误码 - -| 兼容码 | 含义 | 处理 | -| ---: | --- | --- | -| 1 | 脉冲数据段配置错误 | 拒绝启动,错误块号写SD的B+11 | -| 2 | 每转脉冲数或每转移动量为0 | 拒绝启动 | -| 3 | S2参数块号错误 | 拒绝启动 | -| 4 | 脉冲参数块号或范围错误 | 拒绝启动 | -| 5 | 正限位触发 | 减速停止并禁止继续正向启动 | -| 6 | 负限位触发 | 减速停止并禁止继续反向启动 | -| 15 | FOLLOW性能不在1~100 | 拒绝启动 | -| 16 | FOLLOW前馈不在0~100 | 拒绝启动 | -| 26 | 控制块或高速资源分配失败 | 拒绝启动或进入ERROR | - -#### 5.2.2 项目增强错误 - -| 错误 | 含义 | 级别 | -| --- | --- | --- | -| `DEFAULT_SPEED_INVALID` | 段频率0但S2默认速度非法 | 启动拒绝/轴错误 | -| `SEGMENT_OVERFLOW` | 路径段数超过100 | 启动拒绝 | -| `OUTPUT_MODE_INVALID` | 输出模式或D组合非法 | 启动拒绝 | -| `RESOURCE_CONFLICT` | 脉冲点或DIR点冲突 | 启动拒绝 | -| `PARAMETER_LOCKED` | 运行中修改锁定参数 | 写入拒绝 | -| `DONE_STATE_ERROR` | DONE状态转换异常 | 轴错误 | -| `SPEED_CLAMPED` | 速度被S2最高速度限幅 | 告警 | -| `POSITION_INVALID` | 绝对定位前位置无效 | 启动拒绝 | -| `COUNTER_OVERFLOW` | 内部或兼容累计溢出 | 轴错误 | -| `ILLEGAL_TRANSITION` | 非法状态机转换 | 轴错误 | -| `TIMER_FAULT` | 定时器初始化或运行异常 | 轴/全局错误 | -| `PULSE_COUNT_MISMATCH` | 软件计数与诊断计数不一致 | 轴错误 | -| `HW_COUNTER_FAULT` | TIM9/TIM12计数器意外停止、越权或累计不一致 | 轴错误 | -| `HW_COUNTER_FALLBACK` | 硬件计数器不可用,当前段使用软件计数 | 诊断提示,不终止任务 | - -#### 5.2.3 错误信息结构 - -```c -typedef struct { - PlsrError error; - uint16_t compatible_code; - uint8_t axis; - uint16_t segment; - uint16_t bad_block; - uint32_t command_sequence; - uint32_t stop_reason; - uint32_t detail_a; - uint32_t detail_b; - uint64_t timestamp; -} PlsrErrorDetail; -``` - -错误信息通过增强状态接口发布;兼容码和错误块号同时写入固定SD字段。相同错误重复发生可增加计数, -但不得覆盖更高优先级停止原因。 - -#### 5.2.4 验证要求 - -| 验证阶段 | 内容 | 合格条件 | -| --- | --- | --- | -| V1 | Q0~Q3固定频率与安全电平 | 引脚、极性、频率、占空比正确 | -| V2 | TIM9/TIM12单轴硬件计数及软件兜底计数 | 两种模式数量误差均为0,无窄脉冲和多余边沿 | -| V3 | 四轴100 kHz并发,两硬件计数+两软件计数 | 计数误差0,轴间无串扰,CPU和ISR数据可接受 | -| V4 | 两组AB及CW/CCW | 计数器分配、相序、周期、互斥和数量正确 | -| V5 | 0.1/1 ms曲线与调频 | 响应、曲线和段边界正确 | -| V6 | 多段、等待、跳转和事件 | 与S0语义一致 | -| V7 | STOP、PAUSE、限位和错误 | 优先级、尾脉冲和终态正确 | -| V8 | Modbus/USB压力及长稳 | 不影响脉冲和状态一致性 | -| V9 | Flash、Backup SRAM和复位 | CRC有效、不自动续跑、位置有效性正确 | - -测试记录应包含固件版本、参数快照、接线、测量设备、原始波形、实际边沿数、方向建立时间、AB相位、 -ISR平均/最坏周期、CPU占用和结论。 - -## 6 参考资料 - -1. [PLSR需求规格统计_V1.3.md](./PLSR需求规格统计_V1.3.md) -2. [PLSR地址映射.md](./PLSR地址映射.md) -3. [PLSR信捷对标追踪矩阵.md](./PLSR信捷对标追踪矩阵.md) -4. [实现PLSR指令.pdf](./实现PLSR指令.pdf) -5. [信捷XD/XL系列PLC定位控制手册](./信捷XD_XL系列PLC定位控制手册_PD02_20260510_V1.3.pdf) -6. [STM32F4xx参考手册 RM0090 Rev.22](./STM32F4xx参考手册_RM0090_Rev22.pdf) -7. 当前工程 `Core`、`Modbus`、`Middlewares`、`USB_DEVICE` 和 `EWARM` 目录 diff --git a/Document/PLSR_document/PLSR波形验收报告_2026-08-14_完成模式10段阶梯频率.md b/Document/PLSR_document/PLSR波形验收报告_2026-08-14_完成模式10段阶梯频率.md deleted file mode 100644 index ba207cc..0000000 --- a/Document/PLSR_document/PLSR波形验收报告_2026-08-14_完成模式10段阶梯频率.md +++ /dev/null @@ -1,250 +0,0 @@ -# PLSR 波形验收报告:完成模式 10 段阶梯频率 - -> 测试日期:2026-08-14 -> 测试方式:上位机 `plsr_control_panel.py` + 逻辑分析仪(6.25 MS/s,Y0 单通道数字采样) -> 波形文件:`Document\PLSR_document\波形\10段.bin`(28,565,856 采样 ≈ 4.571 s) -> 截图:`上位机图片\Snipaste_2026-08-14_13-19-30.png`(公共参数)、`Snipaste_2026-08-14_13-19-44.png`(10 段参数) -> 结论:**验收不通过**(脉冲总数正确,但段 2~8 恒速频率严重错误,且存在首脉冲偏长、段尾慢爬等已知问题复现) - ---- - -## 1. 测试配置(与截图一致) - -### 1.1 公共参数 - -| 字段 | 值 | -|---|---| -| 脉冲输出 | Y0 (0) | -| 方向输出 | Y12 (0) | -| 方向延时 ms | 0 | -| WAIT 输入 | X4 (0) | -| EXT 输入 | X4 (0) | -| 发送模式 | 脉冲发送完毕 (0)(完成模式) | -| 方向逻辑 | 正逻辑 (0) | -| 曲线模式 | 直线 (0) | -| 位置模式 | 相对位置 (0) | -| 段数 | 10 | -| 起始段 | 1 | -| 默认速度 Hz | 10000 | -| 启动速度 Hz | 100 | -| 停止速度 Hz | 100 | -| 加速时间 ms | 10 | -| 减速时间 ms | 10 | -| 输出模式 0x1200 | PULSE/DIR (0) | - -### 1.2 段参数(每段 100 脉冲,等待类型 外部或完成 4,WAIT/ACT/跳转均为 0) - -| 段 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| -| 配置频率 Hz | 1000 | 2000 | 3000 | 4000 | 5000 | 6000 | 7000 | 8000 | 9000 | 10000 | - ---- - -## 2. 波形总览 - -| 项目 | 实测 | 判定 | -|---|---|---| -| 总采样 | 28,565,856(@6.25MS/s = 4.571 s) | — | -| 上升沿(脉冲总数) | **1000** = 10 段 × 100 脉冲 | ✅ 正确 | -| 波形起始 | 采集开始后 1725.9 ms 低电平,随后才有脉冲 | ℹ️ 采集窗口早于启动,不计故障 | -| 段间间隙 | 每段之间低电平约 **5.4 ~ 6.0 ms** | ⚠️ 异常(Bug 4 报告为 ~2.5 ms,此处更大) | -| 每段内部结构 | 首脉冲(5 ms 长高)+ ~96 个恒速脉冲 + 3 个 100 Hz 慢爬 | ⚠️ 异常 | - ---- - -## 3. 逐段频率实测(核心结论) - -每段恒速段频率取排除首脉冲与尾慢爬后的中位数: - -| 段 | 配置频率 Hz | 实测恒速频率 Hz | 偏差 | 判定 | -|---|---:|---:|---:|:---:| -| 1 | 1000 | **1000** | 0 % | ✅ | -| 2 | 2000 | **1049** | −48 % | ❌ | -| 3 | 3000 | **1066** | −64 % | ❌ | -| 4 | 4000 | **1074** | −73 % | ❌ | -| 5 | 5000 | **1080** | −78 % | ❌ | -| 6 | 6000 | **1083** | −82 % | ❌ | -| 7 | 7000 | **1085** | −84 % | ❌ | -| 8 | 8000 | **1087** | −86 % | ❌ | -| 9 | 9000 | **9006**(含完整 ramp) | +0.07 % | ✅ | -| 10 | 10000 | **9905**(峰值,含完整 ramp) | −0.95 % | ✅ | - -### 3.1 逐段频率形态 - -- **段 1**:首脉冲 182 Hz → 恒速 1000 Hz ×94 → 尾部 3 个 100 Hz 慢爬。恒速正确。 -- **段 2~8**:首脉冲 ~183 Hz → 恒速 **~1049~1087 Hz**(每段仅比上一段高 15~25 Hz)→ 尾部 3 个 100 Hz 慢爬。**恒速频率几乎都落在 ~1 kHz 附近,与配置 2~8 kHz 严重不符**。 -- **段 9**:首脉冲 100 Hz → ramp 196→6463→9006 Hz → 恒速 9006 Hz → 平滑减速(9006→8480→8013→…→183 Hz)→ 尾部慢爬。**加减速 ramp 完整且频率正确**。 -- **段 10**:首脉冲 1050 Hz → ramp 1050→1928→…→9905 Hz(对称三角)→ 尾部 1050 Hz。**ramp 完整,峰值接近 10000 Hz**。 - -结论:加减速曲线路径(`PlsrRampAdvance`/短轮廓/Profile 队列)工作正常;**问题集中在完成模式下段 2~8 的恒速频率设定**,表现为"段启动后频率被限制在 ~1 kHz 附近缓慢爬升"。 - ---- - -## 4. 发现的问题清单 - -| # | 严重度 | 现象 | 疑似原因 | 关联已知问题 | -|---|---|---|---|---| -| 1 | **严重** | 段 2~8 恒速段输出 ~1049~1087 Hz,与配置 2000~8000 Hz 严重不符;段 9/10 正常 | 完成模式下多段恒速频率设定/段间预装交接(Handoff 双缓冲预装、频率队列写定时器 PSC/ARR)路径 | 未见对应历史记录,疑似新问题 | -| 2 | 中 | 每段第 1 个脉冲为约 5 ms 长高电平(~182 Hz),全 10 段复现 | 段启动首脉冲相位/定时器预载 | TC-PD-008 遗留"首脉冲偏长" | -| 3 | 中 | 每段尾部 3 个脉冲以 100 Hz(高 5 ms 低 5 ms,恰为停止速度)慢爬后停 | 减速预算不足时提前跳停止速度发完剩余 | 8-13 Bug 报告 Bug 1 复现 | -| 4 | 低 | 段间低电平间隙 5.4~6.0 ms | 有限路径段完成与下一段启动跨主循环周期 | 8-13 Bug 报告 Bug 4(原 ~2.5 ms,此处更大) | -| 5 | 信息 | 采集起始 1725.9 ms 无输出 | 逻辑分析仪先于启动命令开始采集 | 不计故障 | - ---- - -## 5. 验收判定 - -| 验收项 | 标准 | 实测 | 结果 | -|---|---|---|---| -| 脉冲总数 | 1000 | 1000 | ✅ | -| 段顺序 | 1→2→…→10 | 顺序正确 | ✅ | -| 每段恒速频率 | 与配置一致(±1 Hz 量化差) | 段 1/9/10 正确;**段 2~8 错误** | ❌ | -| 段 9/10 加减速 ramp | 平滑、峰值正确 | 196→9006 Hz、1050→9905 Hz | ✅ | -| 首脉冲宽度 | 该段频率对应半周期 | 实测恒为 5 ms | ❌ | -| 段尾无慢爬 | 无 100 Hz 尾段 | 每段 3 个 100 Hz 慢爬 | ❌ | -| 段间间隙 | ≤ 2 ms | 5.4~6.0 ms | ❌ | - -**总体结论:不通过。** 阻塞项为问题 #1(段 2~8 恒速频率错误)与 #2/#3(首脉冲偏长、段尾慢爬)。 - ---- - -## 6. 建议 - -1. **优先排查 #1**:在 `tests\plsr_host` 宿主测试中构造同配置(10 段、1000~10000 Hz 阶梯、每段 100 脉冲、完成模式),用 `PlsrTestOutputFrequency` 断言每段恒速频率,复现段 2~8 被限制在 ~1 kHz 的问题;重点审查完成模式段间交接(`PlsrBuildHandoffPlan`、`PlsrPreparedHandoffPlans` 双缓冲、`PlsrResolvedSegmentFrequency`、频率队列写 PSC/ARR 路径)。 -2. **修复后回归**:`tests\plsr_host\run_tests.ps1` 全量通过(基线 94 tests / 515,670 assertions / 0 failures),并新增"完成模式多段阶梯频率逐段正确输出"用例。 -3. **实板复测**:同配置重采波形,段 2~8 恒速频率与配置一致(±1 Hz),首脉冲宽度正常,段尾无 100 Hz 慢爬,段间间隙 ≤ 2 ms。 - -## 7. 分析脚本(复现用) - -`tmp\analyze_wave_bin.py`(总览/频率统计)、`tmp\wave_final.py`(按段边界切段统计)、`tmp\wave_pulse_seq.py`(逐脉冲频率序列)、`tmp\wave_runs.py`(原始电平段明细)。 - ---- - -## 8. 13:58 复测与慢尾修复状态 - -> 当前 `Document\PLSR_document\波形\10段.bin` 已在 13:58 被新波形覆盖。前文第 2~5 节保留首次测试记录,本节以当前文件为准。 - -### 8.1 当前波形复测结果 - -| 项目 | 结果 | -|---|---| -| 文件时间 | 2026-08-14 13:58:17 | -| 采样 | 17,729,200 点,6.25 MS/s,2.836672 s | -| 上升沿 | 1000,计数正确 | -| 段 1~9 峰值/恒速 | 1000、2000、3000.5、4001.3、5000、6003.8、7006.7、8002.6、9005.8 Hz | -| 段 10 峰值 | 9936.4 Hz,短轮廓达到目标的 99.36% | - -原阻塞问题“段 2~8 卡在约 1.05 kHz”已修复。当前文件仍能看到段尾提前到达 100 Hz,段 1~9 约残留 3~10 个低速脉冲;其低半周期将段间低电平拉长到约 5~6 ms。因此,当前 `10段.bin` 是**频率交接修复通过、慢尾修复前**的证据。 - -### 8.2 慢尾修复 - -根因是边界减速预算重复叠加了按频率和频差计算的余量,短斜坡被过早启动;同时首个减速频点要等下一次 1 ms 轮询才提交,长斜坡又依赖过大的提前量才能到达停止速度。 - -修复内容: - -1. 删除重复的频差余量,按曲线积分估算所需脉冲。 -2. 边界条件命中后立即提交首个减速频点,消除额外 1 ms 启动延迟。 -3. 仅当一个轮询周期的脉冲数不超过斜坡面积 5% 时保留轮询余量,避免短斜坡出现停止速度慢尾。 -4. 新增 10 段逐脉冲慢尾检查和 10 ms 边界斜坡专项回归。 - -### 8.3 自动验证结果 - -| 验证 | 结果 | -|---|---| -| Host 全量 | 100 tests / 525,879 assertions / 0 failures | -| 10 段 Host 慢尾 | 段 1~9 最后连续 `<=200 Hz` 脉冲均 `<=2` | -| 10 ms 边界专项 | 峰值 `>=99%`,最后连续 `<=200 Hz` 脉冲 `<=2` | -| IAR EWARM 8.3.4 | 0 errors / 1 warning | -| HEX | 194,945 B,SHA256 `12930062D965B1473B8AA3DEFFC950AF71E2056785BBCCF2DD0BE3F4A8360169` | -| 持久化区保护 | 烧录前后 SHA256 均为 `64946F391E62A1D9250E2E7F5D5E56DC55EC29F9CE6D269BE6808FD6205462C8` | -| COM5 Pulse/Direction 矩阵 | 12/12 通过 | -| COM5 延长 10 段 | 逐段捕获 1000~10000 Hz,总位置 10000,count error 0,mismatch 0 | -| 四路有限计数最终 IRQ | Y0~Y3 最大 344 / 400 / 340 / 404 cycles,均 `<1680` | -| 10 脉冲短轮廓性能 | IRQ 最大 1657 cycles,生产者最大 1380 cycles,均 `<1680` | -| 65,535 脉冲长 ramp 性能 | IRQ 最大 159 cycles;另测 ramp 更新最大 1320 cycles,均 `<1680` | - -仓库 timing 脚本已改为在旧 `PlsrIrqCount` 无样本时读取有限硬件计数的 block/final IRQ 统计,消除新架构下的误报。完整 timing 阶段的 Pulse/Direction 部分全部通过;阶段最后的 AB Y2/Y3 负向诊断仍报 `flags=0x00FA`,属于已暂缓的 AB 路径,不计入本次 Pulse/Direction 结论。 - -### 8.4 最终波形验收状态 - -新固件已烧录,板上已恢复为 10 段、每段 100 脉冲、1~10 kHz、位置 0、IDLE。还需用逻辑分析仪覆盖采集一次 `10段.bin`,确认: - -- 总上升沿为 1000; -- 段 1~9 无连续 100 Hz 慢尾(允许最多 2 个边界量化脉冲); -- 段间低电平不再被 100 Hz 半周期拉长到 5~6 ms; -- 各段峰值/恒速频率仍满足目标。 - -在新波形完成上述检查前,最终物理波形结论为:**待复测**。 - -### 8.5 14:34 第二次复测 - -`10段.bin` 于 14:34:09 再次覆盖,SHA256 为 `9C3D00869726986E0391C4D1D11E0E6A0A3601ACFE9D2BE01FAC24C0F94127B1`。 - -| 项目 | 结果 | -|---|---| -| 采样 | 11,009,376 点,6.25 MS/s,1.761500 s | -| 上升沿 | 1000,计数正确 | -| 段 1~10 峰值 | 1000、2000、3000.5、4001.3、5000、6003.8、7006.7、8002.6、9005.8、9952.2 Hz | -| 段 1~10 连续 `<=200 Hz` 尾脉冲 | 2、2、3、1、0、2、4、3、4、0 | -| 段 1~8 边界低电平 | 5.057920~5.989120 ms | -| 段 9 边界低电平 | 1.129280 ms | - -结论:频率与计数通过,但段 3、7、8、9 的慢尾仍超过 2 个,最终波形验收不通过。实测表明 Host 模型比硬件少覆盖约 2 个定时器预装边界脉冲;边界低电平还包含下一段以 100 Hz 启动时的低半周期,需与慢尾分别判断。 - -随后已删除估算函数中固定追加的 2 个预装脉冲余量。新版本 Host 仍为 `100 tests / 525,879 assertions / 0 failures`,IAR 为 `0 errors / 1 warning`,新 HEX SHA256 为 `4E4ED664CE05F364B7443711DC85E719960B52BCBCD5FFC347D76F0CCFE5D863`,已烧录并通过 COM5 延长 10 段计数/频率测试。当前板上已恢复 10 段 x 100 脉冲、位置 0、IDLE,等待第三次逻辑分析仪采样。 - -## 9. 完成模式连续轨迹语义修复 - -14:34 波形显示每个中间段都重新从 100 Hz 启动。根据最终确认的语义,10 个“脉冲发送完成”段组成一条连续轨迹:启动速度只作用于第 1 段,停止速度只作用于第 10 段,同向中间边界按相邻段目标频率衔接。 - -该语义已写入代码并完成以下验证: - -| 项目 | 结果 | -|---|---| -| Host 全量 | 102 tests / 526,542 assertions / 0 failures | -| 三段顺序/跳转专项 | 中间段不回到启动速度,跳转使用目标段频率 | -| 方向反转 | 不带速穿越,保留停机、方向建立和安全重启 | -| IAR EWARM 8.3.4 | 0 errors / 1 warning | -| HEX SHA256 | `7C6534FA05FB61BBA8CAF7838F36BAF908612CBDE525A17F23354E8C702FB5A2` | -| ST-LINK | `Verification...OK`;持久化 Sector 10~13 前后哈希一致 | -| COM5 三段板测 | 1000/2000/3000 Hz,位置 3000,错误 0,计数误差 0,曲线失配 0 | - -板上已恢复本报告第 1 节的 10 段配置并处于 IDLE。现有 `10段.bin` 来自旧语义固件;必须覆盖采集新波形后,才能验收启动/停止速度作用范围和段间低电平。当前最终物理波形状态仍为:**待复测**。 - -## 10. 15:24 第三次复测与末段慢尾修复 - -`10段.bin` 于 15:24:39 覆盖,SHA256 为 `49BB40FE59A70DF41A82122AC6EAE253C48661F64E2B74C27F3EE01B3E03860B`。 - -| 项目 | 实测 | 判定 | -|---|---:|:---:| -| 采样 | 10,011,664 点,6.25 MS/s,1.601866 s | — | -| 上升沿/下降沿 | 1000 / 1000 | 通过 | -| 段 1~10 恒速/峰值 | 1~10 kHz 均达到目标 | 通过 | -| 中间段启动速度 | 第 2~10 段均从对应目标频率开始,未回到 100 Hz | 通过 | -| 段间低电平 | 0.250080~0.949760 ms | 通过 | -| 第 10 段减速 | 10 kHz 平滑下降,停止速度只作用于第 10 段 | 通过 | -| 第 10 段连续 `<=200 Hz` 周期 | 4 个,末脉冲高电平 5.000160 ms | 不通过 | - -本次波形确认连续轨迹语义和段间低电平已修复,但发现最终段仍提前约 5 脉冲到达停止速度。根因是连续梯形面积与实际 1 ms 右端点离散更新面积不一致。 - -离散面积修复后的验证结果:Host `102 tests / 526,543 assertions / 0 failures`,IAR `0 errors / 1 warning`,HEX SHA256 `5851B7714454770DD8EF8A0ED8B01379791F4EF71BDF1B795B36639B18F258AB`,ST-LINK 校验及持久化区保护通过。COM5 同配置 10×100 脉冲计数通过,末段最终板内诊断频率为 3071 Hz,不再提前进入 100 Hz 慢尾。 - -板子已恢复同一配置并处于 IDLE,等待第四次覆盖采样。当前最终物理波形状态:**待复测**。 - -## 11. 非零加减速段间停波根因修复 - -用户确认当前 `10段.bin` 未被覆盖,因此不再重复分析该文件。另通过对比测试确认:加减速时间为 0 ms 时段间连续,非 0 ms 时出现约 250~900 us 间隔。 - -根因是 0 ms 配置使用硬件多段有限序列,而非 0 ms 配置此前退回每段独立有限计数,在主循环处理边界期间发生 stop/start。现已改为非零加减速同样保持一条硬件多段序列,首段加速、中间变速、末段减速都在序列持续运行期间完成。 - -修复后自动验证:Host `102 tests / 526,561 assertions / 0 failures`,9 个非末段边界均断言硬件序列和脉冲输出保持 active;IAR `0 errors / 1 warning`;HEX SHA256 为 `8778C24D3FDA364DC4B1D6FAD92E69D9090C5B8EC57D1C65DFCAC89EA0B5AC45`;ST-LINK 和持久化区保护通过;COM5 10×100 脉冲累计位置、逐段边界事件和末段诊断均通过。 - -当前板上为同一 10 段配置、位置 0、IDLE。物理零间隔结论等待新波形确认。 - -## 12. `bbb.bin` 单段短斜坡复核 - -`bbb.bin` 对应单段 1000 Hz x 100 脉冲,默认速度 10000 Hz,启动/停止 100 Hz,加减速 10 ms。直接解析原始边沿得到 100 个上升沿、100 个下降沿;首个上升沿至最后一个下降沿为 104.001120 ms,而不是理论/实测图片标注的约 526 ms。 - -图片中的长下降线是查看器缺陷:第 100 个脉冲后的采集空闲低电平被错误计入最后一个周期。查看器现已改为相邻上升沿计算周期,同一文件正确报告 100 个脉冲和 99 个可测完整周期。 - -原固件仍存在真实差异:首周期为 5.500160 ms(181.8129 Hz),末脉冲高电平仍为 0.500000 ms。根因是 1 ms 主循环无法及时提交持续时间仅约 1 ms 的启停斜坡。新固件已让不超过 1000 脉冲的单段 1 ms 短斜坡使用逐脉冲预计算,理论输出为首末约 550 Hz、中间 98 个 1000 Hz。Host `103 tests / 526,778 assertions / 0 failures`、IAR `0 errors / 1 warning`、烧录校验和持久化保护均已通过。COM5 板测位置 100、错误 0、expected/actual 100/100、count error 0、curve mismatch 0,末个诊断频率 550 Hz;等待覆盖采集新 `bbb.bin` 做最终物理波形验收。 diff --git a/Document/PLSR_document/PLSR测试报告_2026-08-12.md b/Document/PLSR_document/PLSR测试报告_2026-08-12.md deleted file mode 100644 index 1580c1f..0000000 --- a/Document/PLSR_document/PLSR测试报告_2026-08-12.md +++ /dev/null @@ -1,784 +0,0 @@ -# PLSR 2026 重写测试报告 - -> 测试日期:2026-08-12 -> -> 测试分支:`codex/plsr-2026-minimal` -> -> 测试代码基线:`bf314a2343002a3df78bce5049badb29d11eae2e` -> -> PLSR 实现提交:`c7b851659ea26f9af8563e146bf5332c3c8d6dfd` -> -> 分支起点:`e35595aae18690a46a03445865bd6218a166bd89` -> -> 结论口径:只记录本次 2026 PLSR 最小重写实际执行过的测试 - -## 1. 总结 - -本次重写的 Host 回归、Python 离线自检、IAR 全量构建、固件烧录、真实 Modbus RTU 板测、Y0~Y3 逐路选择、X4/X5 实体回环、硬复位持久化和最终默认状态复核均通过。 - -| 类别 | 最终结果 | 数量或关键结果 | -|---|---|---| -| C Host 回归 | PASS | 44 个测试组,1,694,007 次断言,0 失败 | -| Python 产品测试脚本离线自检 | PASS | 未打开串口 | -| Python 控制面板离线自检 | PASS | 未打开 GUI 或串口 | -| IAR EWARM 全量构建 | PASS | 0 errors,0 warnings | -| 固件烧录和校验 | PASS | `Verification...OK`,SHA256 与板测固件一致 | -| Modbus RTU/运动板测 | PASS | 9 组 | -| X4/X5 实体回环板测 | PASS | 9 个用例 | -| 硬复位持久化 | PASS | 配置和位置恢复,运动不自动续跑 | -| 最终默认状态复核 | PASS | 位置、配置、输入和夹具输出均恢复 | -| 逻辑分析仪测试 | 未执行 | 不在本轮验收范围内 | - -这里的“Y0~Y3 验证”是**同一个逻辑 PLSR 依次选择 Y0、Y1、Y2、Y3 作为脉冲输出**,不是四轴同时运动或四轴联动。 - -`1,694,007 assertions` 是逐脉冲检查、边界检查和随机算术比对产生的断言总数,不代表 169 万个独立测试场景。 - -## 2. 测试范围和口径 - -### 2.1 纳入本报告 - -- 本次重写新增的固定产品寄存器区:`0x1000~0x1197`、`0x2000~0x2006`、`0x3000`。 -- 单个逻辑 PLSR 的配置、状态、控制、运动、短段曲线、动态调频和停止。 -- 单个逻辑 PLSR 对 Y0~Y3 的逐路选择。 -- X4/X5 的 WAIT、EXT 和 EXT_OR_COMPLETE 实体输入回环。 -- 产品配置与逻辑位置的保存、硬复位恢复和默认值恢复。 -- 最小 Modbus RTU 的真实串口访问和异常响应。 -- 当前代码基线的 Host、Python、IAR 和固件产物复核。 - -### 2.2 不纳入本报告 - -旧工程曾做过的四轴并发、双 AB、软限位、长稳、USB CDC 并发和逻辑分析仪波形测试属于另一套实现,不能作为本次重写的通过证据,因此没有合并到本报告。 - -## 3. 环境与产物 - -| 项目 | 实际环境或结果 | -|---|---| -| 目标板 | 信捷 XDM-60T4-E,STM32F407IG | -| RTOS | uC/OS-II | -| 串口 | `COM5`,9600 bit/s,8E1,从站 1 | -| Host 编译器 | GCC 4.9.2 (`tdm64-1`) | -| Host 编译选项 | C99,`-Wall -Wextra -Werror`,`PLSR_HOST_TEST` | -| IAR | EWARM 8.3.4,配置 `Modbus` | -| IAR 工程 | `EWARM/Modbus.ewp` | -| HEX | `EWARM/Modbus/Exe/Modbus.hex` | -| HEX 文本文件大小 | 142,131 B | -| Intel HEX 有效数据 | 50,513 B | -| HEX 地址范围 | `0x08000000..0x0800C550` | -| SHA256 | `97956D9EF30A9B274140B9B82F412EDD77F9637F563FCC3F552BB4B140772343` | - -IAR 链接结果: - -```text -49,004 bytes of readonly code memory - 1,509 bytes of readonly data memory -18,660 bytes of readwrite data memory -Errors: none -Warnings: none -``` - -## 4. 最终自动化回归 - -### 4.1 C Host 回归 - -执行命令: - -```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass ` - -File .\tests\plsr_host\run_tests.ps1 -``` - -最终输出: - -```text -SUMMARY tests=44 assertions=1694007 failures=0 -``` - -44 个测试组全部 PASS: - -| # | 测试组 | 实际验证内容 | -|---:|---|---| -| 1 | `u64_by_u32_division` | 用 8 个边界被除数、9 个边界除数和 250,000 组确定性伪随机数据,比对 64 位除 32 位算法与 C 原生除法的商和余数。 | -| 2 | `protocol_boundaries` | 覆盖区间外、跨区、地址溢出、零长度、空缓冲、只读区、保留字、32 位字段不完整写入、非法值和控制字宽度,并确认失败写入不破坏原值。 | -| 3 | `wait_zero_one_ms` | 验证 `WAIT_TIME=0` 按最小 1 ms 处理,先进入 WAITING,1 ms 后才进入下一段。 | -| 4 | `act_zero_no_pulse` | 验证 `ACT_TIME=0` 的段不产生脉冲并在下一次 1 ms 调度跳过,后续段正常执行。 | -| 5 | `relative_absolute_zero` | 验证相对位移 0、相对位移 +5,以及当前位置等于绝对目标时的零位移完成。 | -| 6 | `self_jump_stop` | 验证单段自跳转可循环运行,并能由 STOP 正常终止。 | -| 7 | `stop_deceleration` | 验证 STOP 从 1000 Hz 按 1000 ms 减速至 100 Hz 后停止;重复 STOP 不重启减速,最终位置 902。 | -| 8 | `stop_pending_boundary` | 验证最后脉冲已完成但 Poll 尚未处理时收到 STOP,最终为 STOPPED 而非 COMPLETED。 | -| 9 | `direction_delay_one_ms` | 验证 1 ms 方向建立延时结束前不输出脉冲,满 1 ms 后启动。 | -| 10 | `ext_edge_at_natural_boundary` | 验证 EXT 边沿和自然末脉冲同时发生时只处理一次,不重复切段或计数。 | -| 11 | `ext_cut_natural_boundary_interleavings` | 覆盖 EXT 切断与自然边界的两种临界交错顺序,均正确进入后续 2000 Hz 段。 | -| 12 | `ext_edge_during_direction_delay` | 验证方向延时期间的 EXT 边沿不会在首脉冲前错误切段。 | -| 13 | `stopped_clears_pending_ext` | 验证 STOP 清除方向延时期间挂起的 EXT 边沿,重新 START 不消费旧边沿。 | -| 14 | `ext_edge_seamless_epoch` | 验证上一段的 EXT 竞态不会误切无缝衔接后的下一段。 | -| 15 | `ramp_seamless_epoch` | 验证上一段遗留的调速更新不会重定向无缝衔接后的下一段。 | -| 16 | `short_profile_boundary_matrix` | 覆盖直线、平滑、正弦三曲线与 1、2、10、99、100、101 脉冲,检查频率范围、升降趋势、对称性、计数和理论时长。 | -| 17 | `short_final_no_poll` | 验证三种曲线的 10 脉冲最终短段在脉冲间无 1 ms Poll 时仍逐脉冲减速并准确完成。 | -| 18 | `zero_to_maximum_ramp_duration` | 验证三种曲线从 0 加速到 100 kHz、1000 脉冲、20 ms 配置的单调性、峰值和总时长。 | -| 19 | `maximum_pulse_ramp_duration` | 验证三种曲线在 65,535 脉冲和 1311 ms 加速条件下采用可达峰值并准确计数。 | -| 20 | `maximum_single_ramp_no_poll` | 验证三种曲线的 65,535 脉冲单段在无 Poll 时持续逐脉冲更新并准确完成。 | -| 21 | `short_timer_failure` | 注入短段频率更新失败,验证立即关闭输出并进入 `ERROR/TIMER`,位置保留为 1。 | -| 22 | `stop_after_subsequent_handoff` | 验证无缝切入第二段后立即 STOP 能从新段频率正常减速并停止。 | -| 23 | `short_zero_speed_edges` | 验证起始/停止速度均为 0 时,1 脉冲和 2 脉冲短段仍以有效非零硬件频率准确执行。 | -| 24 | `short_dynamic_retarget` | 验证 50 脉冲短段中把目标频率由 1000 改为 2000 Hz 后安全上升,随后 STOP 正常停止。 | -| 25 | `short_ext_cut` | 验证短段执行 3 脉冲后触发 EXT,在下一脉冲边界切断,最终位置 4。 | -| 26 | `current_frequency_dynamic` | 验证当前段动态频率按安全脉冲边界生效,状态反映实际输出,运行中改脉冲数不改变启动快照。 | -| 27 | `poll_mailbox_failure` | 注入动态调频失败,验证 Poll 仅提交更新,失败在脉冲边界转为 `ERROR/TIMER`。 | -| 28 | `future_frequency_on_arrival` | 普通逐段模式中修改未来第二段频率,当前段不受影响,到达第二段时采用新值。 | -| 29 | `subsequent_future_frequency_handoff` | 无缝模式中修改未来段频率,验证预装衔接重建并以新频率无缝进入第二段。 | -| 30 | `future_frequency_last_pulse_race` | 验证未来段调频与当前段最后脉冲竞态时,第二段仍以新频率启动并准确完成。 | -| 31 | `latched_update_before_future_frequency_commit` | 验证屏蔽中断期间锁存的物理脉冲先消费旧衔接,再提交新的未来频率预装。 | -| 32 | `subsequent_no_poll_gap` | 验证两段只靠 ISR 无缝衔接,中间不依赖 Poll,输出不停止,最终位置 7。 | -| 33 | `three_single_pulse_no_poll` | 验证三个各 1 脉冲、1000/2000/3000 Hz 的段在无 Poll 时连续衔接。 | -| 34 | `handoff_queue_failures` | 两种时序下注入下一段频率排队失败,均关闭输出并转为 `ERROR/TIMER`。 | -| 35 | `command_state_restrictions` | 验证 IDLE/RUNNING 状态下 START、STOP、CLEAR、静态/动态配置写入及非法组合的限制。 | -| 36 | `command_mailbox_start_snapshot` | 验证 START 延迟执行与配置快照,后续写入和重复 START 不覆盖首个快照。 | -| 37 | `command_mailbox_stop_clear_deferred` | 验证重复 STOP/CLEAR 可幂等入队,状态变化延迟到 Poll,冲突命令返回 BUSY。 | -| 38 | `command_mailbox_validation` | 验证 START 入队前检查起始段和绝对位置有效性,非法配置不启动。 | -| 39 | `command_mailbox_start_failure` | 注入底层启动失败,验证 Poll 后进入 `ERROR/INVALID_RESOURCE`,CLEAR 可恢复。 | -| 40 | `wait_signal_positive` | 验证段后进入 WAITING,选定输入有效后由下一次 Poll 结束等待。 | -| 41 | `persistence_reinit` | 在 Host 存储模拟中验证输出、方向、2345 Hz 配置和位置 4 跨 `PlsrInit()` 恢复,CLEAR 后位置保持 0。 | -| 42 | `position_flash_save` | 验证位置稳定约 1000 ms 后仅保存一次,CLEAR 后延时产生第二次保存。 | -| 43 | `busy_reset_absolute_gate` | 模拟运动中复位,保留位置但取消绝对位置可信标志;CLEAR 后恢复绝对运动。 | -| 44 | `position_overflow` | 验证 `INT32_MAX` 正向溢出时立即停脉冲并进入 `ERROR/COUNT`。 | - -说明:Host 测试直接调用寄存器 API 和平台替身,不能代替真实 RTU 串口板测;真实 RTU 结果见第 7 节。 - -### 4.2 Python 产品测试脚本离线自检 - -执行命令: - -```powershell -py -B HostComputer\plsr_modbus_product_test.py --self-test -``` - -结果: - -```text -Self-test passed; no serial port was opened -``` - -覆盖: - -- Modbus CRC16 参考向量及线上低字节在前的字节序。 -- 6 组有符号 32 位数的双 WORD 拆分与合并。 -- 1200 Hz 请求的 1 Hz 容差边界。 -- ST-LINK GPIO 位读回文本解析。 - -### 4.3 Python 控制面板离线自检 - -执行命令: - -```powershell -py -B HostComputer\plsr_control_panel.py --self-test -``` - -结果: - -```text -PLSR control panel self-test passed; no GUI or serial port was opened -``` - -覆盖 9600 波特率、X4/X5 选项、10 段地址步进、整数解析和越界拒绝、公共区 20 WORD 编码、段区 8 WORD 编码、负脉冲补码及跳转段范围校验。 - -## 5. IAR 构建 - -执行命令: - -```powershell -& 'F:\IAR Systems\Embedded Workbench 8.3\common\bin\IarBuild.exe' ` - 'EWARM\Modbus.ewp' -build Modbus -log warnings -``` - -最终结果: - -```text -Total number of errors: 0 -Total number of warnings: 0 -``` - -`EWARM/Modbus/List/Modbus.map` 同时报告 `Errors: none`、`Warnings: none`,内存数据见第 3 节。 - -## 6. 烧录与固件产物校验 - -### 6.1 烧录 - -最终烧录使用 `EWARM/Modbus/Exe/Modbus.hex`,只擦除并写入应用占用的 Sector 0~3。ST-LINK 最终输出: - -```text -Verification...OK -``` - -开发过程中还用 STM32CubeProgrammer 执行过两次下载校验,用于确认当时固件可以下载和校验。它们属于中间版本的烧录链路记录,不是功能测试,也不作为最终交付固件的产物证据;最终结论以上述 ST-LINK 烧录、最终 SHA256 和后续完整板测为准。 - -### 6.2 持久化区保护 - -烧录前后均读取并检查 Sector 10/11。应用 HEX 地址上限为 `0x0800C550`,不覆盖持久化区;烧录流程也没有擦除 Sector 10/11。该检查用于确认后续持久化板测不是由重新烧录清空后伪造出的初始状态。 - -### 6.3 产物一致性 - -板测烧录件和当前分支重新构建产物的 SHA256 均为: - -```text -97956D9EF30A9B274140B9B82F412EDD77F9637F563FCC3F552BB4B140772343 -``` - -## 7. 真实 Modbus RTU 和运动板测 - -执行入口: - -```powershell -py -B HostComputer\plsr_modbus_product_test.py ` - --port COM5 --run --phase all --allow-motion -``` - -最终 `phase=all` 的 9 组测试全部 PASS: - -| # | 用例 | 实际验证内容 | 结果 | -|---:|---|---|---| -| 1 | `fixed_map_and_exceptions` | 真实 FC03/06/10 访问固定配置、段、状态和控制区;检查保留字读零、控制字读零;验证不支持 FC01 返回 0x01,非法/跨区/只读地址返回 0x02,保留字非零、半个 DWORD 和组合控制命令返回 0x03。 | PASS | -| 2 | `positive_negative_absolute_zero` | 验证正向相对、负向相对、绝对定位及零位移,检查方向、终态和逻辑位置。 | PASS | -| 3 | `all_outputs_100khz` | 同一个 PLSR 依次选择 Y0、Y1、Y2、Y3,每路请求 100 kHz、执行 1000 个逻辑脉冲并检查完成位置。不是四轴并发。 | PASS | -| 4 | `short_final_profiles` | 对直线、平滑、正弦三种曲线执行最终 10 脉冲短段,检查终态和准确位置。 | PASS | -| 5 | `wait_time` | 执行 10 脉冲后进入 WAITING,配置等待 120 ms;检查不会异常提前完成,之后正常完成。 | PASS | -| 6 | `act_time` | 2000 脉冲目标配合 80 ms ACT,检查时间到后在 0~2000 之间切断并正常完成。 | PASS | -| 7 | `dynamic_frequency_repeated_stop` | 当前段从 500 Hz 动态改为 1200 Hz;真实状态读回 1199 Hz,在允许的 1 Hz 容差内;运行中静态输出选择写入返回 BUSY;重复 STOP 正常减速停止且未跑满 3000 脉冲。 | PASS | -| 8 | `future_segment_frequency_on_arrival` | 普通逐段模式运行第一段时修改未来第二段频率,确认当前段仍为 400 Hz,进入第二段后采用 900 Hz。 | PASS | -| 9 | `subsequent_future_frequency_handoff` | SUBSEQUENT 模式运行第一段时修改未来段频率,确认当前段不变,第二段以 900 Hz 衔接并最终累计 400 脉冲。 | PASS | - -脚本在退出时停止仍在运行的任务,并恢复进入测试前保存的公共配置和前两段配置。 - -正式烧录后还在完整 `phase=all` 之前单独执行过一次 `--phase smoke`,固定映射和异常码检查 PASS。第 9 节恢复默认值并再次硬复位后又执行了一次 smoke,结果仍为 PASS。 - -## 8. X4/X5 实体回环板测 - -### 8.1 接线和方法 - -本轮保留的自动化回环接线: - -```text -Y4 -> X4 -Y5 -> X5 -``` - -脚本通过 ST-LINK 临时把 Y4/Y5 对应 GPIO 配置为输出,驱动 PLC 输入并直接读回 X4/X5 输入位;产品协议和正式固件没有为测试夹具新增私有寄存器。 - -开发期夹具最初尝试写 GPIO 的只写 BSRR 寄存器。旧版 `ST-LINK_CLI 3.5.0` 会在写后回读校验,因 BSRR 不可按普通寄存器回读而返回码 4。夹具随后改用 PI8/PE6 的 ODR 位带地址 `0x424402A0`、`0x42420298` 驱动输出。该失败是调试工具与只写寄存器的校验方式不兼容,不是接线、X4/X5 输入或 PLSR 功能故障。 - -执行入口: - -```powershell -py -B HostComputer\plsr_modbus_product_test.py ` - --port COM5 --run --phase x45 --allow-motion -``` - -### 8.2 九个用例 - -| # | 用例 | 实际验证内容 | 结果 | -|---:|---|---|---| -| 1 | `x45_electrical_scan` | Y4/Y5 均关闭时 X4/X5 均为低;分别打开 Y4、Y5 时对应输入为高、另一路保持低;关闭后恢复低,验证双路电气扫描和隔离。 | PASS | -| 2 | `X4_wait_signal` | 选择 X4;50 脉冲后进入 WAITING,X4 低时保持,Y4 拉有效后完成,位置仍为 50。 | PASS | -| 3 | `X4_ext_signal` | 选择 X4;5000 脉冲运动中由 Y4 触发 EXT_SIGNAL,提前切段并正常完成。 | PASS | -| 4 | `X4_ext_or_complete_natural` | 选择 X4;不触发输入,50 脉冲自然完成。 | PASS | -| 5 | `X4_ext_or_complete_trigger` | 选择 X4;5000 脉冲运动中由 Y4 外部触发,提前完成。 | PASS | -| 6 | `X5_wait_signal` | 选择 X5;50 脉冲后进入 WAITING,X5 低时保持,Y5 拉有效后完成,位置仍为 50。 | PASS | -| 7 | `X5_ext_signal` | 选择 X5;5000 脉冲运动中由 Y5 触发 EXT_SIGNAL,提前切段并正常完成。 | PASS | -| 8 | `X5_ext_or_complete_natural` | 选择 X5;不触发输入,50 脉冲自然完成。 | PASS | -| 9 | `X5_ext_or_complete_trigger` | 选择 X5;5000 脉冲运动中由 Y5 外部触发,提前完成。 | PASS | - -ODR 位带夹具修正后先完成独立电气扫描,再执行完整九项用例。异常清理顺序修正后又执行了第二次完整九项回归,结果仍全部 PASS。 - -### 8.3 最终夹具清理状态 - -测试结束或异常退出均按以下顺序清理:先关闭 Y4/Y5,再停止仍在运行的 PLSR,恢复测试前配置,最后把 PI8/PE6 恢复为输入高阻并清除上下拉。最终复核结果: - -- Y4/Y5 均关闭。 -- PI8/PE6 均为输入高阻、无内部上下拉。 -- X4/X5 读回均为低。 - -## 9. 持久化与硬复位板测 - -持久化测试使用真实目标板和 100 ms 硬复位,不以 Host 内存模拟代替。 - -### 9.1 保存准备 - -执行 `persistence-prepare`: - -- 清零逻辑位置。 -- 写入 500 Hz、7 脉冲的特征配置。 -- 执行运动并确认 COMPLETED、位置为 7。 -- 等待后台保存完成。 - -### 9.2 第一次硬复位和恢复检查 - -执行 100 ms 硬复位后运行 `persistence-verify --cleanup`,结果: - -- 上电状态为 IDLE,没有自动续跑复位前任务。 -- 逻辑位置恢复为 7。 -- 第一段频率恢复为 500 Hz。 -- 第一段脉冲数恢复为 7。 - -随后执行 CLEAR,把位置恢复为 0;写回默认配置并等待保存。 - -### 9.3 第二次硬复位和默认状态检查 - -再次执行 100 ms 硬复位后运行 `defaults-verify`,结果: - -- 状态为 IDLE,逻辑位置为 0。 -- 默认方向建立延时为 10 ms。 -- 默认速度为 1000 Hz,起始速度和停止速度均为 100 Hz。 -- 第 1 段为 1000 Hz、1000 脉冲。 -- 第 2~10 段频率均为 1000 Hz、脉冲数均为 0。 - -最后又执行一次固定映射 smoke,确认默认配置恢复后 RTU 访问和异常码仍通过。 - -## 10. 测试过程中出现的问题和修复回归 - -本节记录中间失败;这些结果不是最终失败,而是用于说明本轮调试实际发现并关闭了哪些问题。 - -| 阶段 | 中间现象 | 原因与处理 | 修复后结果 | -|---|---|---|---| -| 旧架构 PDU 试验 | `length == 8U`、`TestStatusAndControl() == 0` 失败 | 试验基于待废弃的旧 PDU 架构。需求复核后没有继续补旧架构,而是按 2026 固定产品接口重写;该试验路线整体废弃。 | 新固定接口由 Host `protocol_boundaries` 和真实 `fixed_map_and_exceptions` 覆盖并通过。 | -| 初期 Host | `12 tests / 262 assertions / 2 failures` | 失败项是 `wait_zero_rejected`、`act_zero_rejected`。复核明确需求后确认正确语义为 `WAIT=0` 按 1 ms,`ACT=0` 零脉冲跳段,代码和测试按正确语义修正。 | `wait_zero_one_ms`、`act_zero_no_pulse` 通过;最终 44/1,694,007/0。 | -| 扩充 EXT 测试 | Host 编译失败,`PLSR_EXT_SIGNAL` 未声明 | 测试扩充时枚举和测试引用未同步,补齐枚举/接口后重跑。 | Host 编译和完整回归通过。 | -| Modbus 裁剪中间态 | IAR `10 errors / 2 warnings` | 头文件先精简,旧源文件与 `main.c` 尚未同步清理;不是运动算法失败。随后完成最小 RTU 源文件和入口同步。 | IAR 全量构建 `0 errors / 0 warnings`。 | -| Host 脚本启动 | Windows 默认策略阻止直接运行 `.ps1` | 改用 `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ...`。 | Host 脚本正常执行并通过。 | -| X4/X5 夹具初版 | `ST-LINK_CLI 3.5.0` 写 BSRR 返回码 4 | BSRR 是只写寄存器,旧 CLI 的写后回读校验不适用。改用 PI8/PE6 的 ODR 位带地址驱动 Y4/Y5。 | 独立电气扫描和完整 X4/X5 九项通过;判定为夹具工具限制,不计为产品失败。 | -| X4/X5 夹具审查 | 异常退出清理顺序不够安全 | 调整为先关闭 Y4/Y5,再停止运动、恢复配置,最后释放 GPIO;保证清理期间不会继续保持输入触发。 | 完整 X4/X5 九项重新执行并通过,最终 GPIO/输入状态复核通过。 | - -中间失败均保留在本报告中,但最终交付结论以当前基线重新执行的结果为准。 - -## 11. 明确未执行和结论边界 - -以下项目本轮**未执行**,不得从本报告的 PASS 推导为已验证: - -- 未使用逻辑分析仪,因此没有验证端子真实占空比、每路真实边沿数、毛刺、窄脉冲、方向建立时间或段间物理波形连续性。 -- 未对 MCU 中断最坏响应时间和 100 kHz 下的 ISR 最坏时延做仪器测量。 -- 未做四轴同时运动或四轴联动;本实现只有一个逻辑 PLSR,Y0~Y3 是逐路选择。 -- 未做 USB CDC 与 Modbus/运动并发长稳。 -- 未做本次最小重写的长时间压力、断线重连或坏 CRC 长稳矩阵。 -- 未做真实 VBAT 断主电源测试。本轮实际执行的是调试器控制的 100 ms 硬复位;它能证明复位恢复路径,不能等价证明 VBAT/完全掉电保持。 -- Host 的持久化用例使用内存模拟;真实 Flash/复位结论仅来自第 9 节板测。 -- Host 直接调用寄存器 API,不证明 RTU 物理链路;真实 RTU 覆盖仅限第 7~9 节列出的板测。 -- 未用 IAR 8.40 构建;本轮实际验证版本为 IAR EWARM 8.3.4。 - -## 12. 最终状态 - -测试结束后目标板处于以下状态: - -- 固件为 SHA256 `97956D9EF30A9B274140B9B82F412EDD77F9637F563FCC3F552BB4B140772343` 对应版本。 -- PLSR 状态为 IDLE,逻辑位置为 0。 -- 固定产品寄存器配置已恢复默认值。 -- Y4/Y5 夹具输出已关闭,PI8/PE6 已释放为输入高阻。 -- X4/X5 均读回低。 -- 最终 `defaults-verify` 和固定映射 smoke 均 PASS。 - -本报告没有引用旧实现的四轴、双 AB、长稳或逻辑分析仪结果作为本次重写证据。 -## 13. 2026-08-13 第二批:单段硬件计数加减速 - -### 13.1 实现范围 - -- PULSE/DIR、单段、完成模式、相对位置。 -- 直线、S 曲线、正弦曲线加减速。 -- PWM 由输出定时器持续产生,TIM9/TIM12 通过内部触发硬件计数。 -- 曲线频率按 1 ms 节拍计算;新 PSC/ARR/CCR 在下一个 PWM 更新边界整体生效。 -- 每次曲线更新最多产生一次边界提交中断,不按每个输出脉冲进入业务中断。 -- 仅当段长可容纳完整加减速预算时进入本批硬件曲线快路径;短曲线留待后续批次。 - -### 13.2 自动化验证 - -| 项目 | 结果 | 关键结果 | -|---|---|---| -| C Host 完整回归 | PASS | 86 tests / 514,079 assertions / 0 failures | -| 第二批专属 Host 矩阵 | PASS | 三种曲线均走有限硬件计数路径,运行中位置递增,先加速后减速,最终 20,000 脉冲 | -| Python 产品测试离线自测 | PASS | 未打开串口 | -| Python 控制面板离线自测 | PASS | 未打开 GUI 或串口 | -| IAR clean build | PASS | 0 errors / 0 warnings | -| 固件烧录与校验 | PASS | Download verified successfully | - -板测固件: - -```text -HEX: EWARM/Modbus/Exe/Modbus.hex -SHA256: C2686C1E432E2F55B0695ECF21A82E4E61373CB952CB065EBB256523D2689D35 -``` - -### 13.3 实板波形与诊断 - -测试参数:Y0/Y12、10 kHz 起始、50 kHz 目标、10 kHz 停止、加减速各 100 ms、20,000 脉冲。 - -逻辑分析仪结果由操作者反馈:**波形分析全部通过**。 - -板内最终状态: - -| 项目 | 实测值 | 结果 | -|---|---:|---| -| 累计位置 | 20,000 | PASS | -| 运行状态 | 7(COMPLETED) | PASS | -| 诊断标志 | 0x007E | 计数、频率、曲线均已检查且 PASS | -| 期望脉冲数 | 20,000 | PASS | -| 实测脉冲数 | 20,000 | PASS | -| 计数误差 | 0 | PASS | - -### 13.4 性能门限 - -CPU 时钟 168 MHz,使用 Cortex-M4 DWT CYCCNT 实测。 - -| 路径 | 次数 | 最坏周期 | 最坏时间 | 结论 | -|---|---:|---:|---:|---| -| 1 ms 曲线计算及硬件参数排队 | 160 | 1,302 cycles | 7.75 us | PASS;按 1 kHz 最坏调度约占 0.78% CPU | -| PWM 边界参数提交中断 | 159 | 151 cycles | 0.90 us | PASS;每次曲线更新一次,不按输出脉冲触发 | -| 65536 脉冲分块中断 | 0 | 0 | 0 | 符合预期,本用例仅 20,000 脉冲 | -| 最终停止中断 | 1 | 276 cycles | 1.64 us | PASS;低于 500 cycles 门限 | - -第二批结论:单段长曲线的硬件计数、三种加减速曲线、精确停止、板内诊断和性能门限通过。 - -## 14. 2026-08-13 零加减速时间语义与 TC-PD-008 共性问题修复 - -### 14.1 修复语义 - -- 加速时间为 0 ms 时,起始速度参数无效,当前段从该段设定频率直接启动。 -- 减速时间为 0 ms 时,停止速度参数无效,当前段保持该段设定频率直至完成。 -- 有加速或减速时间时,原有起始速度、停止速度和三种曲线规划保持不变。 -- 后续模式同方向连续段仍允许按下一段频率在合法边界衔接。 - -修复覆盖普通启动、方向建立延时后的启动、短段预计算、有限脉冲硬件计数快路径、自然段尾和 STOP 路径,避免只有部分路径遵守零时间语义。 - -### 14.2 Host 脉冲级回归 - -新增 `zero_ramp_times_ignore_edge_speeds`,覆盖直线、S 曲线、正弦曲线和 1、2、10 脉冲: - -- 加减速均为 0 ms,起始/停止速度均故意设为 100 Hz:每个实际周期均为 100,000 Hz。 -- 仅加速为 0 ms:起始速度分别设为 100 Hz 和 50,000 Hz,两次实际逐脉冲频率序列完全一致。 -- 仅减速为 0 ms:停止速度分别设为 100 Hz 和 50,000 Hz,两次实际逐脉冲频率序列完全一致。 - -完整 Host 回归结果: - -```text -94 tests / 515,670 assertions / 0 failures -``` - -### 14.3 IAR、烧录和持久化区保护 - -| 项目 | 结果 | -|---|---| -| IAR EWARM 8.3.4 全量构建 | 0 errors / 1 warning;map 正文 `Errors: none`、`Warnings: none` | -| 只读代码 | 67,586 bytes | -| 只读数据 | 1,517 bytes | -| 读写数据 | 27,335 bytes | -| HEX SHA256 | `F0B4EC12C71471B4C377E440C5442A006DA67E1095B0696BB16E20586E1B03F7` | -| ST-LINK 烧录 | `Verification...OK`,MCU 复位并运行 | -| 持久化区保护 | `0x080C0000`~`0x080FFFFF` 分为四个 64 KiB 块,烧录前后 SHA256 逐块一致 | - -旧版 `ST-LINK_CLI 3.5.0` 对单次 256 KiB Dump 卡住,因此保护校验改为四次 64 KiB 分块回读;连接探测、程序烧录和四块回读均正常。 - -### 14.4 COM5 实板内部验证 - -执行 28 个用例: - -- 加减速均为 0 ms,起始/停止速度均为 100 Hz:三种曲线 x 1/2/10 脉冲。 -- 仅加速为 0 ms,起始速度为 50,000 Hz:三种曲线 x 1/2/10 脉冲。 -- 仅减速为 0 ms,停止速度为 50,000 Hz:三种曲线 x 1/2/10 脉冲。 -- 加减速均为 0 ms、100,000 Hz、100,000 脉冲的有限硬件计数长段。 - -全部 28 项满足:状态 7、错误 0、位置等于设定、诊断期望计数等于实际计数、计数误差 0、诊断原因 0、曲线失配数 0、最大频率误差 0。 - -额外读取三种曲线的零加减速 10 脉冲诊断字段,均为:请求频率 100,000 Hz、定时器期望频率 100,000 Hz、定时器实际频率 100,000 Hz、请求误差 0、样本数大于 0、失配数 0。 - -### 14.5 TC-PD-008 结论边界 - -验收表中 TC-PD-008 已填写的“多 1 个脉冲、首脉冲偏长、尾部窄脉冲”是修复前固件数据。本批固件已包含 PULSE/DIR 启动相位和最终边界终止修复,Host、板内计数及诊断均通过,但仍需用逻辑分析仪对当前 SHA256 固件复测,才能关闭端子外部 N+1、首周期和尾窄脉冲问题。原验收数据保留,不以板内结果覆盖。 - -## 15. 2026-08-14 默认频率、10 段交接与段尾慢脉冲修复 - -### 15.1 功能修复 - -- 段频率为 0 时运行使用默认频率,Modbus 寄存器仍读回原始值 0;启动前和运行中写 0 均覆盖。 -- 修复有限脉冲动态 ramp 的 queued→active 提交卡住后,段 2~8 长时间停留在约 1.05 kHz 的问题。 -- ramp 提交返回 STALE 时保持 ramp 活跃,终点频率必须成功提交后才结束。 -- 边界条件命中后立即提交首个减速频点,消除额外 1 ms 轮询延迟。 -- 删除重复的频差余量;仅当一个轮询周期的脉冲数不超过斜坡面积 5% 时保留轮询余量,避免短斜坡提前到达停止速度并发送慢尾脉冲。 -- timing 上位机在旧逐脉冲 IRQ 统计为空时读取有限硬件计数 block/final IRQ 统计,适配当前硬件计数架构。 - -### 15.2 Host 全量回归 - -新增和强化用例: - -- `finite_ramp_retries_terminal_frequency_after_stale` -- `complete_ten_segment_frequency_staircase` -- `ten_ms_boundary_ramp_no_slow_tail` -- `zero_segment_frequency_uses_default_speed` -- `dynamic_zero_frequency_uses_default_speed` - -10 段用例逐脉冲检查 1~10 kHz 峰值,并要求段 1~9 最后连续 `<=200 Hz` 的脉冲不超过 2 个;10 ms 边界专项同时要求峰值达到 99% 且无慢尾。 - -```text -100 tests / 525,879 assertions / 0 failures -``` - -### 15.3 IAR、固件与持久化区 - -| 项目 | 结果 | -|---|---| -| IAR EWARM 8.3.4 | 0 errors / 1 warning | -| 唯一警告 | `PlsrCounterTriggerSourceAxis` set but never used | -| 只读代码 | 67,770 bytes | -| 只读数据 | 1,517 bytes | -| 读写数据 | 39,623 bytes | -| HEX 大小 | 194,945 bytes | -| HEX SHA256 | `4E4ED664CE05F364B7443711DC85E719960B52BCBCD5FFC347D76F0CCFE5D863` | -| ST-LINK | 扇区 0~3 擦写,`Verification...OK`,复位运行 | -| 持久化区 | 最新烧录前后 SHA256 均为 `44094568B88CC30AFB82D70D343AC7E67DC374A568854E6426EE21D431349783` | - -### 15.4 COM5 Pulse/Direction 板测 - -产品矩阵 12 项全部通过:固定寄存器与异常码、正负/绝对/零位移、10 段顺序、从第 10 段启动、方向与极性矩阵、四路 100 kHz、短最终轮廓、WAIT、ACT、动态频率与重复 STOP、未来段改频、后续模式交接。 - -延长 10 段测试将每段脉冲数改为 1000,逐段捕获: - -```text -1000, 2000, 3000, 4000, 5000, -6000, 7000, 8000, 9000, 10000 Hz -``` - -最终位置 10,000、错误 0;末段诊断 expected 1000、actual 1000、count error 0、mismatch 0。测试后已恢复 10 段 x 100 脉冲、位置 0、IDLE。 - -### 15.5 DWT 性能 - -门限为 `<1680 cycles`(100 kHz 周期)。 - -| 路径 | 实测最大值 | 结果 | -|---|---:|---| -| 四路有限计数最终 IRQ(Y0~Y3) | 344 / 400 / 340 / 404 cycles | PASS | -| 10 脉冲短轮廓 IRQ | 1657 cycles | PASS | -| 10 脉冲短轮廓生产者 | 1380 cycles | PASS | -| 65,535 脉冲长 ramp IRQ | 159 cycles | PASS | -| 65,535 脉冲 ramp 更新 | 1320 cycles(197 次) | PASS | - -完整 timing 阶段的 Pulse/Direction 部分通过;最后进入 AB Y2/Y3 负向用例时,诊断标志为 `0x00FA`,该 AB 路径按当前调试范围暂缓,不计入本批 Pulse/Direction 结论。 - -### 15.6 逻辑分析仪状态 - -14:34 的第二次 `10段.bin` 已证明段 1~10 分别达到目标频率,但段 3、7、8、9 仍有 3~4 个连续低速尾脉冲。根据实测又移除了固定 2 脉冲预装余量并重新烧录;最终还需覆盖采集一次同配置波形,确认段尾连续低速脉冲不超过 2 个。段间约 5 ms 还包含下一段 100 Hz 启动的低半周期,需与慢尾分开判断。详细数据见 `PLSR波形验收报告_2026-08-14_完成模式10段阶梯频率.md`。 - -## 16. 2026-08-14 完成模式多段启动/停止速度语义修复 - -### 16.1 修复语义 - -当各段等待条件均为“外部或脉冲发送完成”,且由脉冲发送完成自然进入下一段时,多段按一条连续轨迹处理: - -- 启动速度只用于第一执行段; -- 同方向中间段在前一段内向下一段频率过渡,完成时把当前速度带入下一段; -- 停止速度只作为最后执行段的段尾目标; -- 跳转段使用实际跳转目标段频率,不误用顺序下一段; -- 外部截断、WAIT/ACT 结束、STOP 和方向反转不带速穿越,仍执行安全停机/换向流程; -- 硬件多段常频序列只在加速和减速时间都为 0 ms 时启用,避免优化路径跳过首段加速或末段减速。 - -短段脉冲预算不足时,最后一段可能在到达停止速度前耗尽设定脉冲;此时要求按减速斜率平滑收尾,不额外补发脉冲以强行达到停止速度。 - -### 16.2 Host 全量回归 - -新增 `complete_natural_boundaries_carry_speed` 和 `complete_jump_carries_speed`,并更新零时间有限序列、同向/反向方向写入的旧语义断言。覆盖三段同向顺序、跳转段、第一段启动速度、中间段速度携带、最后一段停止减速及反向安全路径。 - -```text -102 tests / 526,542 assertions / 0 failures -``` - -### 16.3 IAR、固件与烧录 - -| 项目 | 结果 | -|---|---| -| IAR EWARM 8.3.4 全量构建 | 0 errors / 1 warning | -| 唯一警告 | `PlsrCounterTriggerSourceAxis` set but never used | -| 只读代码 | 67,806 bytes | -| 只读数据 | 1,517 bytes | -| 读写数据 | 39,623 bytes | -| HEX 大小 | 195,043 bytes | -| HEX SHA256 | `7C6534FA05FB61BBA8CAF7838F36BAF908612CBDE525A17F23354E8C702FB5A2` | -| ST-LINK | Sector 0~3 擦写,`Verification...OK`,复位运行 | -| 持久化区 | Sector 10~13 分块读取,四块烧录前后 SHA256 分别一致 | - -### 16.4 COM5 实板内部验证 - -三段配置为 1000/2000/3000 Hz、每段 1000 脉冲,启动 100 Hz、停止 200 Hz、加减速 10 ms。结果: - -- 终态 `COMPLETED`,错误 0,位置 3000; -- 三段首次可读运行频率为 1000/2000/3000 Hz,中间段未回到 100 Hz; -- 末段诊断 expected 1000、actual 1000、count error 0、mismatch 0; -- 末段最后诊断请求频率为 1041 Hz,证明停止减速只发生在最后一段,且短段在达到 200 Hz 前按设定脉冲数正常结束; -- 固定映射与异常码 smoke 复测通过。 - -测试后恢复 10 段、每段 100 脉冲、1~10 kHz、默认 10 kHz、启动/停止 100 Hz、加减速 10 ms、位置 0、IDLE。 - -### 16.5 待逻辑分析仪确认 - -当前代码、Host、构建、烧录和板内计数均通过。新语义固件仍需覆盖采集 `10段.bin`,确认启动速度只出现在第一段、停止减速只出现在第 10 段,并重新测量段间低电平;旧波形不作为本固件的物理波形结论。 - -## 17. 2026-08-14 15:24 连续轨迹波形与末段慢尾修复 - -### 17.1 15:24 波形结果 - -波形 SHA256:`49BB40FE59A70DF41A82122AC6EAE253C48661F64E2B74C27F3EE01B3E03860B`,共 10,011,664 点,6.25 MS/s,1000 个上升沿和 1000 个下降沿。 - -- 第 2~10 段起始运行频率分别为 2000、3000、4000、5000、约 6000、7000、8000、9000、10000 Hz;中间段不再回到 100 Hz,连续轨迹语义通过。 -- 段 1~9恒速频率和向下一段的段尾过渡正确。 -- 9 个段间低电平为 0.250080~0.949760 ms,均小于 1 ms;旧固件约 5~6 ms 的低电平消失。 -- 第 10 段按 10000→100 Hz 减速,但脉冲 96~100 出现 183/100/100/100 Hz 周期和最终 5 ms 高电平;连续 `<=200 Hz` 周期为 4 个,超过最多 2 个的门限。 - -因此 15:24 波形证明多段启动/停止速度作用范围修复成功,但末段慢尾仍不通过。 - -### 17.2 末段慢尾根因与修复 - -边界估算原来使用连续梯形面积;实际 `PlsrRampAdvance` 每 1 ms 使用 `1/duration` 到 `duration/duration` 的右端点频率。10 kHz→100 Hz、10 ms 的连续面积约为 51 脉冲,右端点离散面积约为 46 脉冲,导致减速提前约 5 脉冲到达停止速度。 - -修复后: - -- 短斜坡按实际右端点离散面积估算,避免提前进入停止速度慢尾; -- 允许完整轮询余量的长斜坡补偿半个端点差,保持 65,535 脉冲长轮廓能够到达停止速度; -- 10 段 Host 用例现在也检查第 10 段连续 `<=200 Hz` 尾脉冲不超过 2 个。 - -### 17.3 修复后验证 - -| 项目 | 结果 | -|---|---| -| Host 全量 | 102 tests / 526,543 assertions / 0 failures | -| 65,535 脉冲三曲线长轮廓 | 末频回归通过 | -| IAR EWARM 8.3.4 | 0 errors / 1 warning | -| 只读代码/数据、读写数据 | 67,886 / 1,517 / 39,623 bytes | -| HEX 大小 | 195,268 bytes | -| HEX SHA256 | `5851B7714454770DD8EF8A0ED8B01379791F4EF71BDF1B795B36639B18F258AB` | -| ST-LINK | `Verification...OK`;Sector 10~13 烧录前后分块哈希一致 | -| COM5 10×100 | 位置 1000、错误 0、末段 expected/actual 100/100、count error 0、mismatch 0 | -| 末段最终板内诊断频率 | 3071 Hz,未提前进入 100 Hz 慢尾 | - -板上已恢复 10 段 x 100 脉冲、位置 0、IDLE。需再覆盖采集一次 `10段.bin`,确认物理端子的末段连续低速尾脉冲不超过 2 个。 - -## 18. 2026-08-14 非零加减速段间连续输出修复 - -### 18.1 根因 - -实测发现加减速时间为 0 ms 时段间没有间隔,而加减速时间非 0 ms 时仍有约 250~900 us 低电平。根因是两种配置走了不同硬件路径: - -- 0 ms:整条同向轨迹使用硬件多段有限序列,计数器在段边界直接装载下一段,定时器不停机; -- 非 0 ms:此前限制了硬件多段序列,每段分别启动有限计数,段完成后等待主循环处理边界再启动下一段,产生一个主循环周期以内的停波。 - -该间隔不是启动/停止速度数值本身造成,而是非零斜坡配置触发了逐段 stop/start 路径。 - -### 18.2 修复 - -- 非零加减速也使用同一条硬件多段有限序列; -- 序列第一步从启动速度开始,并在硬件序列持续运行期间执行 1 ms 斜坡更新; -- 中间硬件边界只切换软件段状态和诊断,不停止脉冲定时器; -- 最后一个段仍在同一序列内执行停止减速并由硬件总计数终止; -- 同向可连续段以外的方向反转和不满足序列条件的路径仍保留安全停机。 - -### 18.3 验证 - -| 项目 | 结果 | -|---|---| -| Host 全量 | 102 tests / 526,561 assertions / 0 failures | -| 非末段边界 active 断言 | 9 个边界均保持有限序列和脉冲输出 active | -| IAR EWARM 8.3.4 | 0 errors / 1 warning | -| 只读代码/数据、读写数据 | 67,954 / 1,517 / 39,623 bytes | -| HEX 大小 | 195,456 bytes | -| HEX SHA256 | `8778C24D3FDA364DC4B1D6FAD92E69D9090C5B8EC57D1C65DFCAC89EA0B5AC45` | -| ST-LINK | `Verification...OK`;Sector 10~13 烧录前后分块哈希一致 | -| COM5 10×100 | 位置 1000、错误 0、末段 expected/actual 100/100、count error 0、mismatch 0 | - -板上已恢复 10 段 x 100 脉冲、位置 0、IDLE。尚需新逻辑分析仪波形确认 9 个物理段边界不再出现额外低电平;未覆盖的旧 `10段.bin` 不重复分析。 - -## 19. 2026-08-14 单段 1 kHz 短斜坡与波形解析修复 - -### 19.1 `bbb.bin` 原始边沿复核 - -配置为 1 段、默认速度 10000 Hz、启动/停止速度 100 Hz、加减速时间 10 ms、段频率 1000 Hz、脉冲数 100。原始文件 `bbb.bin` 为 9,708,896 点、6.25 MS/s,共检测到 100 个上升沿和 100 个下降沿。 - -| 项目 | 原固件实测 | -|---|---:| -| 首个上升沿至最后一个下降沿 | 104.001120 ms | -| 首脉冲高/低电平 | 5.000160 / 0.500000 ms | -| 首个上升沿周期 | 5.500160 ms,即 181.8129 Hz | -| 中间可测周期 | 基本为 1.000000 ms,即 1000 Hz | -| 末脉冲高电平 | 0.500000 ms | - -原理论/实测图片中的约 526 ms 红色减速尾线不是硬件输出。旧查看器把第 100 个脉冲后的采集空闲低电平当成最后一个周期,从而生成一个接近 0 Hz 的伪频率点并与前一点连线。查看器已改为只使用相邻两个上升沿计算周期;同一文件现在报告 100 个脉冲、99 个可测完整周期,不再绘制伪尾线。 - -### 19.2 固件问题与修复 - -原固件仍有真实的首末脉冲轮廓问题。100→1000 Hz 和 1000→100 Hz 按默认速度 10000 Hz、加减速 10 ms 计算,斜坡持续时间均约 0.9 ms,离散为 1 ms。有限计数路径依赖 1 ms 主循环提交频率,无法在已经开始的 100 Hz 首个高半周期内及时改频,也无法保证末脉冲前提交停止斜坡。 - -修复后,单段脉冲数不超过 1000 且存在 1 ms 短斜坡时,优先使用整段逐脉冲预计算队列;正常的较长有限斜坡、多段连续硬件序列和零斜坡恒速路径保持不变。该配置的预计算结果为:首脉冲约 550 Hz、中间 98 个脉冲为 1000 Hz、末脉冲约 550 Hz,完整周期累计约 101.64 ms。 - -### 19.3 自动验证与烧录 - -| 项目 | 结果 | -|---|---| -| 新增精确回归 | 首/末 549~551 Hz;中间 98 个 1000 Hz;总周期 101.6~101.7 ms | -| Host 全量 | 103 tests / 526,778 assertions / 0 failures | -| IAR EWARM 8.3.4 | 0 errors / 1 warning | -| 只读代码/数据、读写数据 | 68,006 / 1,517 / 39,623 bytes | -| HEX 大小 | 195,612 bytes | -| HEX SHA256 | `068BDC9D417E3C1DEE377BA3053CBB850B32ED3B7EBBB1E9F7653623F29F713A` | -| 烧录 | STM32CubeProgrammer 下载、校验、复位成功 | -| 持久化区 `0x080C0000~0x080FFFFF` | 烧录前后 SHA256 均为 `FC494806B0465476E51678F961A3EC63873C32915A214B31DFB4307048D3949A` | - -COM5 板测已通过:位置 100、状态 COMPLETED、错误 0;诊断 expected/actual 为 100/100、count error 0、curve mismatch 0,末个诊断请求/定时器频率均为 550 Hz。测试后已清位置并恢复为 IDLE,保留同一单段配置。新逻辑分析仪波形仍待覆盖采集。 - -## 20. 2026-08-14 短斜坡单段/多段通用根治 - -### 20.1 架构修复 - -第 19 节的单段短轮廓分流仅能覆盖单段样本,不作为最终修复。根因是有限序列曾把一个逻辑段直接等同于一个硬件 step,首段加速和末段减速只能依赖 1 ms 主循环更新;只要斜坡在一个或少数脉冲内完成,单段和多段都会遇到同一问题。 - -最终实现将“硬件频率 step”和“逻辑段完成边界”拆开: - -- 首段入口短斜坡和最终段出口短斜坡在启动前按脉冲域生成硬件 step; -- 计数器 IRQ 只在脉冲边界预装下一组定时器参数,不在 IRQ 内计算曲线; -- 每个硬件 step 保存逻辑段内脉冲偏移,只有逻辑段最后一个 step 发布段完成事件; -- 段内位置、剩余脉冲、诊断计数仍按逻辑段累计,不把斜坡子 step 当成新段; -- step 索引扩展为 16 bit,统一上限为 2010;step 缓冲由 Core 静态持有,平台只保存运行期指针,避免四路重复占用 RAM; -- 已删除“单段、脉冲数不超过 1000、斜坡不超过 1 ms”的临时特判。 - -### 20.2 Host 回归 - -| 用例 | 覆盖内容 | 结果 | -|---|---|---| -| 单段 100 脉冲 | 100→1000→100 Hz,首末约 550 Hz,中间 98 个 1000 Hz | PASS | -| 3 段无 Poll | 入口斜坡、1000→2000→3000 Hz 硬件边界、最终出口斜坡;300 个脉冲期间不调用 `PlsrPoll1ms()` | PASS | -| 10 段无 Poll | 启停 100 Hz、段频率 1000 Hz、默认速度 10000 Hz、加减速 10 ms;9 个边界持续输出 | PASS | -| 既有完整回归 | 有限计数、动态频率、方向反转、短轮廓、AB 相、诊断、持久化和异常路径 | PASS | - -最终结果: - -```text -104 tests / 527,097 assertions / 0 failures -``` - -### 20.3 IAR、资源与固件 - -| 项目 | 结果 | -|---|---| -| IAR EWARM 8.3.4 | 0 errors / 1 个既有未使用变量 warning | -| 只读代码 / 只读数据 / 读写数据 | 69,258 / 1,517 / 87,096 bytes | -| 通用有限 step 缓冲 | 48,240 bytes | -| HEX 大小 | 199,130 bytes | -| HEX SHA256 | `9B296CF02101F78A70F4CCF2BB89DC75AA27E8261F8AED451764854EE2106C4C` | -| ST-LINK | 下载、校验、复位和运行成功 | - -### 20.4 最坏速率门限 - -板上使用 90 kHz→100 kHz、默认速度 10 kHz、加速时间 10 ms、2000 脉冲测试。该轮廓在定时器量化合并后触发 188 次硬件 step IRQ: - -| 指标 | 实测 | 100 kHz 门限 | -|---|---:|---:| -| block IRQ 最大周期 | 396 cycles,约 2.36 us | 1680 cycles,10 us | -| final IRQ 周期 | 410 cycles,约 2.44 us | 1680 cycles,10 us | -| 最小余量 | 约 7.56 us | 必须大于 0 | - -性能门限 PASS。IRQ 内无除法、开方或轮廓计算。 - -### 20.5 COM5 与持久化 - -同一最坏速率配置板测结果:状态 COMPLETED、位置 2000、错误 0、expected/actual 2000/2000、count error 0、curve mismatch 0、reason 0。 - -性能测试会按设计写入配置和位置。测试前已备份 `0x080C0000~0x080FFFFF`,测试后仅恢复 sector 10/11;恢复镜像 SHA256 与测试前均为 `7BE5A392BCCB26A510516B80F44FE8FEA287CE58E653325AA7964DDAEC38E4F1`。板上最终运行关闭 timing 的 release 固件。 diff --git a/Document/PLSR_document/PLSR需求规格统计_V1.3.md b/Document/PLSR_document/PLSR需求规格统计_V1.3.md deleted file mode 100644 index c9ff4ae..0000000 --- a/Document/PLSR_document/PLSR需求规格统计_V1.3.md +++ /dev/null @@ -1,1064 +0,0 @@ -# PLSR 需求规格书 - -> 文档版本:V1.3 Rev.B(正式需求规格基线)
-> 更新日期:2026-08-05
-> 目标平台:XDM-60T4-E
-> 软件平台:STM32F407 + uC/OS-II
-> 对标基线:信捷 XD/XL 系列 PLC 定位控制手册 PLSR(印刷页 5~87,重点为 49~56) - -## 目录 - -- [1 概述](#1-概述) - - [1.1 项目背景](#11-项目背景) - - [1.2 定义](#12-定义) - - [1.3 需求解释规则](#13-需求解释规则) - - [1.4 对标边界与冻结差异](#14-对标边界与冻结差异) -- [2 功能需求](#2-功能需求) - - [2.1 系统与硬件功能](#21-系统与硬件功能) - - [2.2 脉冲输出功能](#22-脉冲输出功能) - - [2.3 定位功能](#23-定位功能) - - [2.4 S0、S1、S2、D 功能模型](#24-s0s1s2d-功能模型) - - [2.5 单段与多段路径](#25-单段与多段路径) - - [2.6 加减速与段内变频](#26-加减速与段内变频) - - [2.7 调用与多段衔接模式](#27-调用与多段衔接模式) - - [2.8 运行控制](#28-运行控制) - - [2.9 状态机与状态监控](#29-状态机与状态监控) - - [2.10 Modbus 通信](#210-modbus-通信) - - [2.11 参数与累计数据保存](#211-参数与累计数据保存) - - [2.12 自测与诊断](#212-自测与诊断) - - [2.13 安全与异常处理](#213-安全与异常处理) -- [3 性能需求](#3-性能需求) - - [3.1 脉冲性能](#31-脉冲性能) - - [3.2 实时性能](#32-实时性能) - - [3.3 可靠性](#33-可靠性) - - [3.4 工程单位与换算边界](#34-工程单位与换算边界) -- [4 其它需求](#4-其它需求) - - [4.1 验收要求](#41-验收要求) - - [4.2 交付物](#42-交付物) - - [4.3 后续版本或加分项](#43-后续版本或加分项) - - [4.4 不在当前范围内](#44-不在当前范围内) - - [4.5 实施验证事项](#45-实施验证事项) -- [5 参考资料](#5-参考资料) - -## 1 概述 - -### 1.1 项目背景 - -本项目计划在新一代 XDM 系列 PLC 中实现一个高性能定位脉冲输出模块, 通过 -PLC 指令或 Modbus 命令驱动伺服驱动器,完成四轴独立位置控制。 - -项目当前已有以下基础: - -- STM32F407 硬件平台和 IAR 工程。 -- uC/OS-II 实时操作系统。 -- USART1 DMA Modbus RTU 从站。 -- USB CDC 框架。 -- Backup SRAM 数据保持基础。 -- PC 端 Modbus RTU 测试工具。 - -本项目以信捷 PLC 的 PLSR 功能作为最低功能基线:信捷 PLSR 已有的功能和 -可观察运行语义均应具备;本项目已有的四轴资源管理、Modbus原子提交、命令防重、 -64位累计、诊断和Backup -SRAM等增强功能继续保留。Y0~Y3的PLSR固定配置参数和固定状态地址必须与信捷手册 -一比一对应;S0、S1保持动态首地址语义,S2、D保持指令操作数语义;不复制XDPPro参数配置界面。 - -逐项对应关系见[PLSR信捷对标追踪矩阵](./PLSR信捷对标追踪矩阵.md)。矩阵中未关闭的 -差异不得宣称为"严格功能对标完成"。 - -V1.0 的目标范围如下: - -- 四轴独立 PULSE/DIR 脉冲输出,不包含多轴插补。 -- AB 相正交脉冲输出和方向控制,最多同时运行两轴。 -- 指定频率、脉冲数量和方向。 -- 相对定位和绝对定位。 -- 单段和多段路径。 -- 多种加减速曲线。 -- 异步返回调用。 -- 完成方式和后续方式两种多段衔接模式。 -- 运行中调频。 -- Modbus 配置和状态监控。 -- 参数及累计数据保存。 -- 自测、诊断和测试文档。 - -外部脉冲回读、编码器闭环和多轴插补等功能作为后续版本或加分项。 - -### 1.2 定义 - -| 名称 | 定义 | -| --- | --- | -| PLSR | 本项目的定位脉冲输出模块,支持单段及多段位置控制 | -| PULSE/DIR | 脉冲加方向的伺服控制接口,PULSE 表示位置增量,DIR 表示方向 | -| AB 相输出 | A、B 两路相差 90° 的正交脉冲接口,通过相位先后表示方向 | -| 指令位置 | PLC 根据已输出脉冲累计得到的位置,不等同于编码器实际位置 | -| 相对定位 | 以当前逻辑位置为基准移动指定距离 | -| 绝对定位 | 移动到指定逻辑坐标 | -| 异步返回 | 启动成功后立即返回,通过状态接口查询运动结果 | -| 完成方式 | 上一段脉冲发送完后,才开始向下一段频率加减速 | -| 后续方式 | 在上一段内提前变频,使段结束时已经达到下一段频率 | -| S0 | 路径数据块,保存段数、频率、脉冲数、等待和跳转参数 | -| S1 | 用户执行参数,仅保存定位模式和起始段 | -| S2 | K0~K4运动参数套组,保存速度限制、起止频率、加减速、补偿、FOLLOW和刷新周期 | -| D | 输出轴或脉冲输出点 | -| 控制周期 | PLSR 更新状态和速度规划的周期,由 S2 配置为 1 ms 或 0.1 ms | -| 检查点 | 为掉电恢复而保存的一致性累计数据快照 | -| 影子参数 | 上位机可编辑但尚未提交给运行模块的参数副本 | - -### 1.3 需求解释规则 - -本文使用以下约束词: - -- **应、必须、不得**:V1.0 强制需求,未满足即判定不合格。 -- **可、允许**:在满足前置条件时允许实现的行为。 -- **建议**:不影响功能验收的实现建议。 -- **信捷兼容**:外部可观察语义、数据布局或固定软元件地址与信捷手册一致。 -- **项目增强**:信捷 PLSR 未规定,但本项目在不破坏兼容语义的前提下增加的能力。 - -需求发生冲突时,按以下优先级解释: - -1. 本规格中明确标记为“冻结”的项目决定; -2. 已确认的项目需求; -3. 信捷 PLSR 手册的外部可观察行为; -4. 实现设计和内部数据结构。 - -`Y0~Y3`表示信捷兼容的逻辑脉冲轴;本项目硬件图纸使用`Q0~Q3`标识对应物理输出点, -两者在本规格中按`Y0↔Q0`、`Y1↔Q1`、`Y2↔Q2`、`Y3↔Q3`对应。除非特别说明, -涉及固定软元件时使用`Y`轴语义,涉及板级引脚和资源时使用`Q`点语义。 - -### 1.4 对标边界与冻结差异 - -V1.0 以信捷 PLSR 为最低功能基线,并保留以下已确认差异或增强: - -| 项目 | 信捷手册语义 | 本项目冻结语义 | 分类 | -| --- | --- | --- | --- | -| 段脉冲数为0 | 手册印刷页56将“频率为0或脉冲数为0”并列描述为采用默认速度,脉冲数为0时的数量行为不明确 | 跳过当前段,不输出脉冲并进入下一段;最后一段则完成 | 冻结差异 | -| AB相计数 | 手册仅规定AB相输出方式,未定义本项目计数口径 | 一个完整四状态正交周期计1个指令脉冲 | 冻结增强 | -| 累计位置 | 固定HSD状态为32位 | 固定地址保持32位兼容,内部另以64位累计并做溢出检查 | 增强 | -| 输出模式 | 单向脉冲、AB相 | 增加CW/CCW双脉冲,不占用或改写信捷固定字段语义 | 增强 | -| 参数提交 | 运行前直接读取软元件 | 增加影子参数、原子提交和不可变运行快照 | 增强 | -| 命令与诊断 | 主要通过指令、STOP和固定状态软元件 | 增加命令防重、状态机、自测及详细诊断 | 增强 | - -信捷固定地址、S0/S1相对布局和可观察状态不得因增强功能改变。逐项对应关系见 -[PLSR信捷对标追踪矩阵](./PLSR信捷对标追踪矩阵.md)。 - -## 2 功能需求 - -### 2.1 系统与硬件功能 - -| 编号 | 需求 | 优先级 | -| --- | --- | --- | -| FR-SYS-01 | 系统应运行在 STM32F407 和 uC/OS-II 平台上 | 必须 | -| FR-SYS-02 | 系统应使用 12 MHz 外部晶振并运行于 168 MHz 主频 | 必须 | -| FR-SYS-03 | 首版应在 XDM-60T4-E(STM32F407IG)上完成验收 | 必须 | -| FR-SYS-05 | 脉冲必须由硬件定时器产生,不得使用 OS 延时翻转 GPIO | 必须 | -| FR-SYS-06 | 上电、复位和初始化期间,脉冲及方向输出应处于安全无效状态 | 必须 | -| FR-SYS-07 | V1.0 应支持四轴独立运行,各轴具有独立状态和参数上下文 | 必须 | -| FR-SYS-08 | 四轴之间不要求直线、圆弧或其它形式的插补 | 必须 | - -已知主要输出点如下: - -| PLC 点 | MCU 引脚 | -| --- | --- | -| Q0 | PF6 | -| Q1 | PF8 | -| Q2 | PF7 | -| Q3 | PF9 | -| Q4 | PI8 | -| Q5 | PE6 | -| Q6 | PE5 | -| Q7 | PE4 | -| Q10 | PG7 | -| Q11 | PG6 | -| Q12 | PH9 | -| Q13 | PH8 | -| Q14 | PH7 | -| Q15 | PH6 | -| Q16 | PF11 | -| Q17 | PB0 | -| Q20 | PH5 | -| USART1_TX | PA9 | -| USART1_RX | PA10 | - -Q0~Q3 的脉冲映射为 Q0/PF6/TIM10_CH1、Q1/PF8/TIM13_CH1、 -Q2/PF7/TIM11_CH1、Q3/PF9/TIM14_CH1,仍应通过上板波形测试确认。 -方向点允许从 XDM-60T4-E 的有效输出点 `Q0~Q7、Q10~Q17、Q20` 中配置, -但不得与脉冲点或其它已占用输出点冲突;四轴脉冲全部启用时,方向点应从 -`Q4~Q7、Q10~Q17、Q20` 中选择。 - -### 2.2 脉冲输出功能 - -| 编号 | 需求 | V1.0 指标 | -| --- | --- | --- | -| FR-PUL-01 | 输出形式 | PULSE/DIR、AB 相、CW/CCW 双脉冲 | -| FR-PUL-02 | PULSE/DIR 有效计数边沿 | 固定为物理上升沿 | -| FR-PUL-03 | 脉冲占空比 | 50% | -| FR-PUL-04 | 输出频率范围 | 1~100,000 Hz | -| FR-PUL-05 | 单段脉冲数量 | 完整有符号32位,范围 -2,147,483,648~2,147,483,647 | -| FR-PUL-06 | 输出方向 | 正向、反向 | -| FR-PUL-07 | 方向有效极性 | 参数可配置 | -| FR-PUL-08 | 方向延时时间 | 默认10 ms,以ms为单位并可配置 | -| FR-PUL-09 | 输出点 | 脉冲点和方向点可配置且不得重复 | -| FR-PUL-10 | 运行调频 | 修改目标频率后不改变剩余脉冲数量 | -| FR-PUL-11 | 四轴运行 | 四轴可独立启动、停止、调频和查询状态 | -| FR-PUL-12 | AB 相方向和计数 | 通过A、B相位先后控制方向;一个完整A/B正交周期计1个指令脉冲 | -| FR-PUL-13 | AB 相轴数 | 最多同时运行两轴;D只允许选择Y0或Y2,分别占用Q0(A)/Q1(B)、Q2(A)/Q3(B)固定点对 | -| FR-PUL-14 | 高速点资源 | PULSE/DIR轴数 + 2 × AB轴数 + 2 × CW/CCW轴数不得超过4,启动前必须检查资源冲突 | -| FR-PUL-15 | AB 相频率 | 目标频率表示每秒完整AB周期数;每周期含4次相位跳变,但只计1个指令脉冲 | -| FR-PUL-16 | 默认速度 | 段频率为0时使用当前S2套组的脉冲默认速度 | -| FR-PUL-17 | 扩展频率 | 100,001~200,000 Hz作为非保证扩展区,仅在板级测试通过后开放并报告警告 | - -系统支持三种输出模式: - -| 模式编号 | 输出模式 | 说明 | -| --- | --- | --- | -| 0 | PULSE/DIR | 脉冲+方向输出 | -| 1 | AB 相 | 正交脉冲输出 | -| 2 | CW/CCW | 双脉冲方向输出 | - -要求: - -- 三种模式均应支持正反方向控制; -- 输出模式运行前不可修改; -- 输出点必须支持资源冲突检查; -- 不同轴之间高速输出资源不得重复占用。 - -高速输出资源按以下规则统一: - -- 物理高速资源固定为Q0~Q3,不改变其MCU引脚和定时器映射; -- D在每次START时申请对应高速资源,任务终止时释放;运行期间轴状态归属D指定的Y轴; -- PULSE/DIR占用1个高速资源;AB相和CW/CCW各占用2个高速资源; -- AB相及CW/CCW只能使用Q0/Q1或Q2/Q3有效点对,D分别取Y0或Y2; -- 正常完成、STOP或ERROR退出后必须释放本任务占用的高速资源; -- 运行期间输出模式、高速点绑定和定时器资源保持锁定。 - -CW/CCW模式下,正向只允许CW端输出脉冲,反向只允许CCW端输出脉冲;另一端和停止 -状态均保持无效电平,任何时刻禁止CW、CCW同时输出。 - -方向输出点、方向有效极性和方向延时只作用于PULSE/DIR模式。AB相和CW/CCW不使用独立DIR点; -发生方向反转时仍必须先减速到0,再按对应相序或通道重新启动。 - -- **FR-PUL-20:** PLSR任务正常完成、STOP停止或ERROR退出后必须释放高速输出资源。 -- **FR-PUL-21:** CW/CCW必须遵守上述互斥输出和停止安全电平规则。 -- **FR-PUL-22:** 单段脉冲数量为0时跳过当前段,不输出脉冲并进入下一段;若为最后一段则完成任务。 - -AB模式的段脉冲数量按完整正交周期累计。每完成`00→相邻相位→相邻相位→相邻相位→00` -的一轮四相位状态转换,段剩余脉冲数减1;四次相位跳变不得分别扣减脉冲数量。正反方向 -通过相位先后顺序区分。 - -PULSE/DIR模式每个有效上升沿计1个指令脉冲;CW/CCW模式在当前方向通道每输出一个完整 -脉冲计1个指令脉冲。段脉冲数量的符号决定相对模式方向,绝对模式方向由目标坐标与当前位置 -之差决定。实现`-2,147,483,648`的绝对值时必须先扩展到64位,禁止32位取反溢出。 - -最小有效脉宽、输出极性及最高可靠频率还应满足 PLC 输出电路和伺服驱动器 -的电气要求。100 kHz 是 V1.0 建议保证值,更高频率需通过硬件测试后开放。 - -当段目标频率为0时: - -使用当前 S2 默认速度。 - -如果: - - 默认速度 <= 0 - -则: - -- 禁止启动; -- 返回 DEFAULT_SPEED_INVALID。 - -### 2.3 定位功能 - -#### 2.3.1 相对定位 - -相对定位按下式计算: - -```text -目标位置 = 当前逻辑位置 + 相对位移 -``` - -- 相对位移为正数时正向运动,为负数时反向运动。 -- 正常完成后,逻辑位置应等于目标位置。 -- 中途停止时,应按实际已输出脉冲更新逻辑位置。 - -#### 2.3.2 绝对定位 - -绝对定位按下式计算: - -```text -输出位移 = 目标绝对位置 - 当前逻辑位置 -``` - -- 执行绝对定位前,当前位置必须有效。 -- 位置未建立或掉电恢复无效时,应拒绝启动并报告错误。 -- 系统应支持设置当前位置和当前位置清零。 -- 绝对定位只表示 PLC 逻辑坐标,不代表伺服具有绝对值编码器。 - -#### 2.3.3 位置与累计计数 - -系统内部应维护以下64位增强数据: - -- 当前逻辑位置。 -- 本次任务累计脉冲数。 -- 总累计脉冲数。 - -上位机应能够读取并清零累计数据。运行期间禁止清零或设置当前位置。 - -信捷兼容的固定累计状态`HSD0~15`仍按每个值两个16位寄存器组成32位数据;内部64位 -累计不得改变该固定地址布局。将内部值发布到信捷兼容状态前必须进行范围检查,超出32位 -范围时报告累计溢出,不得静默截断。 - -### 2.4 S0、S1、S2、D 功能模型 - -项目采用信捷PLSR的S0、S1、S2、D逻辑模型。Y0~Y3固定配置和状态地址必须按 -[PLSR地址映射](./PLSR地址映射.md)一比一对应;S0、S1保持动态首地址语义。 - -| 参数 | 功能 | 主要内容 | -| --- | --- | --- | -| S0 | 路径数据块 | 总段数、每段频率、脉冲数、等待及跳转 | -| S1 | 用户执行参数 | 仅包含相对/绝对模式和起始执行段;异步调用属于指令接口而非S1字段 | -| S2 | 运动参数套组 | K0~K4,包含默认频率、最高频率、起止频率、加减速、曲线、补偿、FOLLOW和刷新周期 | -| D | 输出轴 | 指定Y0~Y3脉冲轴;输出模式、方向点、极性和方向延时由该轴配置参数决定 | - -#### 2.4.1 指令接口 - -逻辑调用形式为: - -```text -PLSR S0 S1 S2 D -``` - -**FR-CMD-04:** PLSR指令必须支持`S0、S1、S2、D`四个操作数语义,分别表示路径数据、 -用户执行参数、K0~K4系统运动参数组和输出资源配置。 - -- 指令只在启动条件上升沿接受一次;启动条件随后断开不得终止已接受任务。 -- S0、S1允许使用项目支持的32位数据软元件首地址,且两个数据块不得重叠。 -- S2允许常数或受支持的32位数据软元件,其运行值必须为K0~K4。 -- D在兼容语义下指定Y0~Y3;项目实现映射到Q0~Q3高速资源。 -- PLSR调用为异步返回。完成方式和后续方式只描述多段速度衔接,不表示同步或异步调用。 - -#### 2.4.2 S0路径数据块 - -S0首地址由调用方动态指定。占用范围从`S0+0`开始,到最后一段 -`S0+N×10+8`双字结束;由于末字段为双字,实际最后一个16位字位于`S0+N×10+9`。 - -| 相对地址 | 数据类型 | 定义 | -| --- | --- | --- | -| S0+0 | INT32 | 总段数N,范围1~100 | -| S0+2~S0+9 | 8个WORD | 保留;写入时应为0,读取时不得解释为项目扩展字段 | -| S0+N×10+0 | INT32 | 第N段目标频率;非负,0表示使用当前S2默认速度 | -| S0+N×10+2 | INT32 | 第N段脉冲数量;相对模式为有符号位移,绝对模式为目标坐标 | -| S0+N×10+4 | WORD | 高8位为等待条件,低8位为等待参数来源类型 | -| S0+N×10+5 | INT32 | 等待常数或等待变量编号 | -| S0+N×10+7 | WORD | 低8位为跳转目标来源类型,高8位保留 | -| S0+N×10+8 | INT32 | 下一段常数或保存下一段号的变量编号 | - -表中的`N`在段字段公式中取1~总段数。所有双字均由连续两个16位字组成,低地址保存低16位、 -下一地址保存高16位;有符号值采用二进制补码。启动前必须验证完整数据块均位于允许的数据区内,且计算 -`S0+N×10+9`时不得发生地址溢出。 - -#### 2.4.3 S1用户参数块 - -| 相对地址 | 数据类型 | 定义 | -| --- | --- | --- | -| S1+0 | INT32 | 定位模式:0为相对模式,1为绝对模式;其它值非法 | -| S1+2 | INT32 | 起始执行段:0或1均从第1段开始,2~100从指定段开始 | - -起始段不得大于S0总段数。相对模式下,每段脉冲数量表示相对本段起点的位移;绝对模式下, -每段脉冲数量表示目标逻辑坐标,实际输出位移为该目标坐标减去进入本段时的逻辑位置。 - -#### 2.4.4 S2系统参数套组 - -每个输出轴必须提供第0~4套S2参数: - -- 第 0 套用于运行时或上位机动态调整。 -- 第 1~4 套用于保存预设运动配方。 - -每套S2至少包含: - -- 脉冲默认速度;段频率为0时采用该速度。 -- 默认速度加速时间、默认速度减速时间;二者与默认速度共同定义加减速斜率。 -- 齿隙补偿加减速时间。 -- 直线、S形、正弦三种加减速模式。 -- 最高速度限制、起始速度和终止速度。 -- FOLLOW性能参数,范围1~100。 -- FOLLOW前馈补偿百分比,范围0~100。 -- 脉冲频率刷新周期,0.1 ms或1 ms。 - -K0参数固定使用`HSD460~539`中对应轴的地址;K1~K4及公共参数固定使用 -`SFD900~1419`中对应轴的地址。各地址不得重排或复用,具体范围见 -[PLSR地址映射](./PLSR地址映射.md)。 - -S2 的控制刷新周期只允许配置为 `1 ms` 或 `0.1 ms`。系统OS Tick保持1 ms; -0.1 ms运动控制刷新应由独立硬件定时器触发,不得通过提高全局OS Tick实现。 -运行中修改当前段目标频率后,系统应在当前S2所配置的刷新周期内开始应用新目标;该指标表示 -调频响应时间,不表示脉冲输出周期。 - -#### 2.4.5 D输出资源 - -D的信捷兼容含义是脉冲输出端口。本项目中: - -- D选择逻辑轴Y0~Y3,并对应物理高速点Q0~Q3。 -- PULSE/DIR占用D所选的一个高速点,方向点来自该轴公共参数。 -- AB相和CW/CCW只允许D选择Y0或Y2,并分别占用Q0/Q1或Q2/Q3固定点对。 -- CW/CCW属于项目增强,模式选择保存在项目输出配置中,不改变信捷SFD900等固定字段含义。 -- D只指定输出资源,不自拟固定配置地址。 - -#### 2.4.6 参数快照与共享规则 - -S0和S1由调用方在未占用数据区选择不重叠的连续首地址。数据块不永久绑定到轴,多个空闲轴 -可以只读共享同一数据块。COMMIT成功时应为目标轴创建不可变运行快照,随后修改原数据块不得 -改变已提交任务,但下列兼容行为除外: - -- 当前正在执行段的目标频率允许更新,并按当前S2刷新周期生效; -- 共享S0被一个运行轴用于调频时,其他轴不得同时把该S0作为可写运行参数源; -- 其余段字段、S1、S2、D和输出资源修改仅在下一次成功提交并启动时生效。 - -### 2.5 单段与多段路径 - -系统应支持 1~100 段路径,每段至少包含: - -```text -有符号脉冲数量 -目标频率 -等待类型 -等待参数 -下一段编号 -``` - -S0应提供与信捷每段数据一致的相对布局和动态引用能力;S0首地址由调用方动态指定: - -- 等待条件和等待参数均可表达常数或受支持的数据/位变量引用。 -- 跳转目标可为常数或运行时数据变量引用,最终值范围0~100。 -- Wait时间范围1~2,147,483,647 ms,配置0时按1 ms处理;计时从本段脉冲完成后开始。 -- Wait信号在本段完成后判断;若信号已有效则立即跳段,否则保持WAIT直至有效。 -- ACT从本段开始输出时计时;时间到时放弃本段剩余脉冲并跳段;若脉冲先完成则等待ACT到时。 -- EXT采用输入上升沿;运动中触发立即放弃剩余脉冲并跳段,脉冲先完成则等待后续EXT边沿。 -- EXT或脉冲完成采用先到条件;EXT先到时放弃剩余脉冲,脉冲先完成时立即跳段。 -- 外部中断输入应优先用于EXT,以避免普通IO映射周期造成响应延迟。 - -等待条件编码必须与信捷S0布局一致: - -| 编码 | 条件 | 进入下一段的时机 | -| --- | --- | --- | -| H00 | 脉冲发送完成 | 本段规定脉冲发送完成后 | -| H01 | Wait时间 | 本段完成后开始计时,计时到达后 | -| H02 | Wait信号 | 本段完成且指定位信号有效后 | -| H03 | ACT时间 | 从本段开始计时,到时后;若脉冲先完成则等待到时 | -| H04 | EXT信号 | 指定X输入出现OFF→ON;若脉冲先完成则继续等待边沿 | -| H05 | EXT信号或脉冲完成 | 两者任一先到 | - -等待参数来源编码必须为:`H00=常数`、`H01=D`、`H02=HD`、`H03=FD`、`H04=X`、 -`H05=M`、`H06=HM`。Wait时间和ACT时间只允许常数、D、HD或FD,数值范围为 -0~2,147,483,647;H04和H05等待条件只允许使用X来源;Wait信号只允许使用X、M或HM。 -EXT使用外部中断输入时不得再由同一物理边沿重复触发普通外部中断业务。 - -跳转来源编码必须为:`H00=常数`、`H01=D`、`H02=HD`、`H03=FD`。解析出的跳转值: - -- 为0时顺序进入下一段;最后一段为0时任务正常完成; -- 为1~100时进入指定段,且不得超过S0总段数; -- 等于当前段时表示有意无限循环,但仍受STOP、限位、急停和错误控制; -- 来源类型、变量地址或最终值非法时,拒绝启动或进入ERROR,不得读取越界地址。 - -段参数的零值判断顺序固定为:先判断脉冲数量,再判断目标频率。脉冲数量为0时直接跳段, -不得因目标频率也为0而输出默认速度脉冲;脉冲数量非0且目标频率为0时才使用S2默认速度。 - -多段路径应满足: - -| 编号 | 需求 | 优先级 | -| --- | --- | --- | -| FR-SEG-01 | 每段可独立设置有符号脉冲数量 | 必须 | -| FR-SEG-02 | 每段可独立设置目标频率 | 必须 | -| FR-SEG-03 | 段起止频率由S2和相邻段目标频率自动推导,不要求每段显式配置 | 必须 | -| FR-SEG-04 | 同方向连续段允许不停顿变频 | 必须 | -| FR-SEG-05 | 反方向段切换前必须减速停止 | 必须 | -| FR-SEG-06 | 支持当前段完成后执行下一段 | 必须 | -| FR-SEG-07 | 支持等待指定时间及ACT指定时间后跳段 | 必须 | -| FR-SEG-08 | 支持等待指定输入点、EXT立即跳段及输入有效或本段完成 | 必须 | -| FR-SEG-09 | 支持指定下一段;跳转值为0时顺序执行,最后一段为0时结束 | 必须 | -| FR-SEG-10 | 不设置独立有限循环次数字段;有限循环由寄存器、计数器和动态下一段实现 | 必须 | -| FR-SEG-11 | 支持从指定段号开始;跳转到自身表示有意无限循环 | 必须 | -| FR-SEG-12 | 每个实际完成或被条件切换的段均产生带轴号、段号和原因的段事件 | 必须 | -| FR-SEG-13 | 最后一段使用等待类条件时保持WAIT/运行状态,直到条件成立或STOP,不得静默结束 | 必须 | -| FR-SEG-14 | 最大路径段数固定为100;超过限制时拒绝执行并返回SEGMENT_OVERFLOW | 必须 | - -运行期间除当前段目标频率外,其余路径参数修改不得影响当前任务。新参数应在下一次成功提交并 -启动时生效。项目内部可把S0编码解析为结构化字段,但不得改变S0对外相对布局。 - -### 2.6 加减速与段内变频 - -系统应支持: - -- 无加减速。 -- 直线或梯形加减速。 -- S 形加减速。 -- 正弦曲线加减速。 - -加减速应满足以下规则: - -1. S2最高速度必须处于硬件保证频率范围内;配置值为0或超过硬件允许范围时应拒绝启动。 -2. 起始频率、目标频率或结束频率高于当前S2最高速度时,应按信捷语义限幅到最高速度运行, - 同时发布`SPEED_CLAMPED`诊断告警;不得把限幅伪装成未经记录的正常输出。 -3. 加减速距离不足时,应自动生成三角或短距离曲线。 -4. 无法在指定脉冲数量内满足起止频率和曲线约束时,应报告参数冲突。 -5. 加减速过程中必须保证最终脉冲数量准确。 -6. 变频点应由脉冲位置和控制周期共同约束,不得只依赖 OS 延时。 -7. 运行中修改目标频率时,应按照当前曲线平滑过渡。 -8. 反向前应减速至停止,切换方向并等待方向建立时间。 - -### 2.7 调用与多段衔接模式 - -调用方式和脉冲衔接方式是两个独立配置项。 - -#### 2.7.1 调用方式 - -- V1.0 只提供异步返回:启动成功后立即返回,通过状态接口查询结果。 -- START被接受后即锁存执行;启动条件或通信请求结束不应中断运动,只有正常完成、STOP、限位、急停或错误才能结束。 - -#### 2.7.2 多段衔接方式 - -- 完成方式:上一段按其频率发送完规定脉冲后,才向下一段频率变化。 -- 后续方式:在上一段内提前变频,使上一段结束时已经达到下一段频率。 -- 两种方式都必须保证各段及整个任务的脉冲数量准确。 - -### 2.8 运行控制 - -#### 2.8.1 PLSR启动触发规则 - -PLSR启动采用上升沿触发方式。 - -| 编号 | 需求 | 优先级 | -| --- | --- | --- | -| FR-CMD-01 | PLSR启动必须检测启动信号上升沿 | 必须 | -| FR-CMD-02 | 启动成功后,解除启动条件不会影响当前任务 | 必须 | -| FR-CMD-03 | 已经接受的任务必须锁存执行参数,不受原参数区修改影响 | 必须 | - -禁止: - -- 启动信号持续有效导致重复启动; -- 通信结束或调用返回导致运动停止。 - -系统至少应支持以下命令: - -| 命令 | 行为 | 优先级 | -| --- | --- | --- | -| START | 校验并启动任务 | 必须 | -| STOP_DECEL | 按减速曲线停止 | 必须 | -| STOP_IMMEDIATE | 完成本次有效脉冲后关闭输出 | 必须 | -| PAUSE | 减速暂停并保存剩余路径 | 必须 | -| RESUME | 从暂停状态继续 | 必须 | -| SET_POSITION | 设置逻辑当前位置 | 必须 | -| CLEAR_POSITION | 逻辑当前位置清零 | 必须 | -| CLEAR_TOTAL | 累计脉冲数清零 | 必须 | -| SAVE_CONFIG | 保存稳定配置 | 必须 | -| LOAD_CONFIG | 加载已保存配置 | 必须 | -| RESET_ERROR | 清除可恢复错误 | 必须 | -| SELF_TEST | 执行内部自测 | 必须 | - -命令边界行为: - -- IDLE 状态收到 START 时,校验通过后接受。 -- 运行、减速、等待或暂停状态再次收到 START - 时拒绝执行,不重启任务,也不要求返回BUSY。 -- ERROR 状态收到 START 时,应拒绝执行,必须先清除错误。 -- 相同命令序号重复到达时,应返回上次结果,不得重复执行运动。 -- 多次 STOP_IMMEDIATE 应按幂等命令处理。 -- CLEAR_POSITION、SET_POSITION 和 CLEAR_TOTAL 在运行期间应被拒绝。 -- 发生数值溢出时应报告错误,不得依赖整数回绕。 - -#### 2.8.2 参数修改生效规则 - -运行过程中参数修改规则: - -| 参数 | 运行中修改 | -| --- | --- | -| 当前段目标频率 | 允许,在当前S2配置的0.1 ms或1 ms刷新周期内开始生效 | -| STOP命令 | 允许 | -| PAUSE命令 | 允许 | -| 方向 | 禁止 | -| 脉冲数量 | 禁止 | -| 路径段数据 | 禁止 | -| 输出模式 | 禁止 | -| 输出点配置 | 禁止 | -| 加减速参数 | 下一次启动生效 | - -启动时必须生成不可变运行快照。 - -**FR-PUL-23:** 运行过程中禁止直接修改方向;必须终止当前任务后重新提交并启动。 - -#### 2.8.3 PAUSE、RESUME、STOP语义 - -##### PAUSE - -暂停要求: - -1. 按当前减速曲线降低速度; -2. 输出频率降为0; -3. 保存当前段号; -4. 保存剩余脉冲数量; -5. 等待RESUME。 - -##### RESUME - -恢复: - -- 从暂停位置继续执行; -- 不重新加载路径; -- 不重新计算已完成段。 - -##### STOP_DECEL - -减速停止: - -- 按当前减速曲线停止; -- 当前任务结束。 - -##### STOP_IMMEDIATE - -立即停止: - -- 完成本次有效脉冲输出后关闭; -- 锁存停止原因; -- 不自动恢复。 - -### 2.9 状态机与状态监控 - -PLSR 至少应包含以下状态: - -```text -UNINITIALIZED 未初始化 -IDLE 空闲 -ACCEL 加速 -RUN 运行 -DECEL 减速 -WAIT 段间等待 -PAUSED 暂停 -COMPLETED 完成 -STOPPED 停止 -ERROR 错误 -``` - -上位机至少能够读取: - -- 当前状态和错误码。 -- 当前段号和总段数。 -- 当前方向。 -- 当前设定频率和目标频率。 -- 本段已发、剩余脉冲数。 -- 本次任务已发、剩余脉冲数。 -- 当前逻辑位置和累计脉冲数。 -- 最近一次完成或停止原因。 -- 当前 S2 参数套组。 -- 每轴正在发送脉冲标志。 -- 当前段已发送脉冲数,以及最近一次段事件的轴号、段号和结束原因。 - -绝对模式目标等于当前累计位置时不得输出脉冲;完成状态不得依赖必须出现一次 -"运行标志下降沿",应直接返回零位移完成结果。 - -增加 PLC 用户可观察状态: - -| 状态 | 说明 | -| --- | --- | -| BUSY | 当前轴存在已接受但尚未终止的任务,包括运动、WAIT和PAUSED | -| PULSE_ACTIVE | 当前物理脉冲正在输出;WAIT和PAUSED期间为0 | -| DONE | 当前任务正常完成并锁存 | -| WAIT | 当前任务正在等待段条件 | -| ERROR | 当前任务异常结束并锁存错误 | - -#### 2.9.1 DONE定义 - -采用方案A: - -- 运动正常完成后 DONE 置1; -- DONE保持有效; -- 下一次 START 成功接受后清零; -- DONE 不依赖 BUSY 下降沿。 -- STOP_DECEL、STOP_IMMEDIATE和ERROR结束不得置DONE。 - -**FR-STA-01:** DONE只表示正常完成,并按上述方案A产生、保持和清零。 - -#### 2.9.2 信捷兼容固定状态 - -四轴固定状态和段事件入口必须保持以下对应关系: - -| 逻辑轴 | 累计状态 | 运行/方向标志 | 实时状态 | 段事件入口 | -| --- | --- | --- | --- | --- | -| Y0/Q0 | HSD0~3 | SM1000、SM1001 | SD1000~1011 | I6000~6099 | -| Y1/Q1 | HSD4~7 | SM1020、SM1021 | SD1020~1031 | I6100~6199 | -| Y2/Q2 | HSD8~11 | SM1040、SM1041 | SD1040~1051 | I6200~6299 | -| Y3/Q3 | HSD12~15 | SM1060、SM1061 | SD1060~1071 | I6300~6399 | - -HSD每轴四个字的相对布局固定为:`+0/+1`累计指令脉冲量,`+2/+3`累计当量,均为有符号 -32位值。项目内部64位累计发布到HSD前必须做范围检查;超出INT32范围时锁存累计溢出错误, -不得回绕或静默截断。 - -SM运行和方向标志语义固定为: - -- 运行标志在脉冲任务处于运动或兼容WAIT期间为ON,任务结束后为OFF;项目增强的PAUSED等 - 详细状态通过独立状态接口提供,不改变固定SM地址。 -- 方向标志在正向输出时为ON,反向输出时为OFF;方向输出极性反转不得改变该逻辑方向语义。 -- 绝对目标等于当前位置时不输出脉冲,运行标志可以不产生ON或下降沿;DONE仍应正确置位。 - -每轴SD实时状态以对应轴起始地址`B`表示,其相对布局固定为: - -| 相对地址 | 定义 | -| --- | --- | -| B+0、B+1 | 当前段号,32位 | -| B+2、B+3 | 当前段已输出指令脉冲量,32位 | -| B+4、B+5 | 当前段已输出当量,32位 | -| B+6、B+7 | 当前输出频率,指令脉冲单位,32位 | -| B+8、B+9 | 当前输出速度,当量单位,32位 | -| B+10 | 信捷兼容脉冲错误码 | -| B+11 | 错误脉冲数据块号 | - -I6000~I6399按轴和段映射:第N段事件入口为该轴事件基址加`N-1`。每个实际执行段在结束或 -被条件切换时产生一次事件。 -事件处理应至少提供轴号、段号和结束原因;正常脉冲完成必须与信捷每段中断的可观察时机一致, -跳转顺序不得改变事件入口与原段号的对应关系。 - -### 2.10 Modbus 通信 - -现有 USART1 Modbus RTU 从站作为配置和监控通道。 - -系统应支持: - -- 读取 PLSR 配置和实时状态。 -- 写入单段和多段参数。 -- 下发启动、停止、暂停、继续、保存、清零和自测命令。 -- 校验参数范围和组合合法性。 -- 使用影子参数和提交命令,原子更新运行参数。 -- 对 32 位和 64 位状态提供一致性快照。 -- 非法通信参数不得破坏正在运行的任务。 - -协议规则如下: - -1. 16 位寄存器按 Modbus 大端字节序传输。 -2. 32/64 位数据的寄存器顺序必须在通信协议中统一定义。 -3. 参数提交应携带命令序号,响应返回相同序号、状态和错误码。 -4. 重复收到相同序号时,应返回上次结果,不重复启动运动。 -5. 参数提交成功与 START 接受成功应作为两个独立结果。 -6. 主站断开不停止已经接受的运动,也不要求记录通信断开状态。 -7. 运行中写影子参数不得改变当前任务,允许运行修改的目标频率除外。 -8. 协议应包含版本号,新增字段时不得改变既有寄存器语义。 - -PLSR内存地址、数据类型和固定占用规则见 -[PLSR地址映射](./PLSR地址映射.md)。地址分配必须避开现有演示、 -Backup -SRAM保持区。原Modbus通信历史功能不纳入PLSR版本,原历史寄存器区域释放给 -S0/S1动态数据池使用。 - -### 2.11 参数与累计数据保存 - -需要保存: - -- 脉冲和方向输出点。 -- 输出极性及方向建立时间。 -- S2 参数套组。 -- 默认运行和衔接模式。 -- 累计脉冲数。 -- 当前逻辑位置及有效标志。 - -保存规则如下: - -1. 固定配置参数使用内部 Flash 保存。 -2. 动态参数、累计位置和第0套动态S2参数使用有VBAT维持的 Backup SRAM - 保存。 -3. 不得每个脉冲写 Flash。 -4. 正常完成、正常停止、清零和设置位置后,应保存一致累计值。 -5. SAVE_CONFIG 返回成功后,重新上电必须完整恢复固定配置。 -6. 重新上电不得自动继续未完成运动。 -7. 异常掉电或异常复位后可恢复最近检查点供诊断,但必须清除 - `position_valid`。 -8. 重新清零、设置当前位置或完成回零前,禁止绝对定位。 -9. 任意掉电瞬间零脉冲误差恢复需要额外硬件,Backup SRAM单独不能保证。 - -测试报告应记录板卡是否安装 VBAT 以及最终采用的数据恢复等级。 XDM-60T4-E -已确认安装有效 VBAT,Backup SRAM 可用于保持动态检查点; -当前逻辑位置必须具有独立的 `position_valid` 标志。 - -#### 2.11.1 信捷等效脉冲公共参数 - -每个轴必须提供以下与PLSR直接相关的公共参数,字段地址严格采用 -[PLSR地址映射](./PLSR地址映射.md)中的信捷原地址,不得采用自定义轴块偏移: - -- 脉冲方向逻辑正/负配置。 -- 软限位启用、正极限值和负极限值。 -- 脉冲单位:脉冲、1 μm、0.01 mm、0.1 mm、1 mm。 -- 每转指令脉冲数和每转移动量。 -- 信捷兼容输出类型:PULSE/DIR或AB相;CW/CCW通过项目增强配置选择,不改变该固定字段编码。 -- 多段脉冲发送方式:完成方式或后续方式。 -- 方向输出点和方向延时时间;默认10 ms,单位ms,可配置。 -- 正向和反向齿隙补偿量。 -- 正负限位输入点及常开/常闭逻辑。 - -信捷固定地址中的电气原点、机械回零、Z相、CLR及闭环脉冲字段应保留原地址,不得挪作项目 -扩展用途;其中不属于V1.0 PLSR范围的功能无需实现。未使用的保留位必须按0写入并在读取时忽略。 - -工程单位必须在内核换算层支持,不再限定为上位机换算。换算应保存小数余数, -避免多段或重复运动的累计舍入漂移。 - -软限位按当前逻辑位置判断。到达对应软限位时执行减速停止,禁止继续向越界方向 -启动,但允许反向离开。软限位和硬限位可同时启用。 - -发生运动方向反转时,应按配置加入对应方向的齿隙补偿脉冲,并使用补偿加减速时间; -补偿段与正式运动段应分开执行。齿隙补偿脉冲不得计入本段指令脉冲、逻辑位置或信捷兼容HSD -累计值,但应计入项目增强的物理输出诊断计数,以便核对实际输出边沿。 - -### 2.12 自测与诊断 - -内部自测至少包括: - -- 输出点冲突检查。 -- 定时器时钟和分频结果检查。 -- 频率范围检查。 -- 路径段数、等待、跳转和循环检查。 -- 累计计数溢出检查。 -- 状态机非法转换检查。 - -外部诊断和测试至少包括: - -- 使用示波器或逻辑分析仪测量输出频率。 - -- 统计实际脉冲边沿数量。 - -- 检查占空比和方向建立时间。 - -- 检查梯形、S 形和正弦曲线。 - -- 进行连续运行和频繁启停压力测试。 - -| 诊断项 | 要求 | -| --- | --- | -| 软件累计脉冲 | 记录实际发送数量 | -| 硬件边沿计数 | 记录输出实际边沿 | -| 差值诊断 | 判断输出异常 | -| 频率记录 | 保存当前和最高频率 | -| 段事件记录 | 记录结束原因 | - -用于定位: - -- 参数错误; -- 状态机错误; -- 定时器异常; -- 输出资源异常。 - -### 2.13 安全与异常处理 - -- 上电、复位、HardFault 和看门狗复位时,脉冲输出必须保持无效。 -- 立即停止命令应具有高于普通运行命令的处理优先级。 -- 不可恢复错误发生后,应禁止装载新脉冲并锁存 ERROR 状态。 -- 清除错误不得自动重新启动未完成运动。 -- 配置限位输入后,限位有效时应禁止向该方向启动,但允许反向离开限位。 -- 运动中限位触发默认执行减速停止。 -- V1.0急停没有外部硬件切断回路;急停输入由软件立即禁止脉冲输出并锁存状态。 -- 软件急停不具备独立硬件安全回路的故障容错能力,不得宣称为安全认证急停。 - -至少应定义以下错误: - -```text -参数未初始化 -输出点非法或冲突 -默认速度为零或硬件频率超限 -脉冲数或段数非法 -段参数冲突 -跳转段号非法 -非预期闭环路径或运行保护超限 -绝对位置未建立 -模块忙 -非法状态命令 -定时器初始化失败 -Flash数据无效或保存失败 -等待输入超时 -立即停止 -累计计数溢出 -内部计数异常 -``` - -运动控制事件优先级: - - 急停 - > - 硬限位 - > - STOP_IMMEDIATE - > - STOP_DECEL - > - PAUSE - > - 正常运行 - -要求: - -- 高优先级事件不能被低优先级命令覆盖; -- 错误清除后禁止自动恢复运动。 - -### 2.13.1 错误码补充 - -固定实时状态中的信捷兼容错误码应至少实现下列与PLSR有关的取值: - -| 兼容码 | 说明 | 适用处理 | -| ---: | --- | --- | -| 1 | 脉冲数据段配置错误 | 拒绝启动,错误数据块号写入对应SD的`B+11` | -| 2 | 当量模式下每转脉冲数或每转移动量为0 | 拒绝启动 | -| 3 | S2系统参数块号错误 | 拒绝启动 | -| 4 | 脉冲参数块号或范围超过限制 | 拒绝启动 | -| 5 | 正限位触发后停止 | 按减速停止并禁止继续正向启动 | -| 6 | 负限位触发后停止 | 按减速停止并禁止继续反向启动 | -| 15 | FOLLOW性能参数不在1~100 | 拒绝启动 | -| 16 | FOLLOW前馈补偿不在0~100 | 拒绝启动 | -| 26 | 控制块或高速输出资源分配失败 | 拒绝启动或进入ERROR | - -项目增强诊断使用符号错误码,不得占用或改变上述固定兼容码: - -| 错误码 | 说明 | -| --- | --- | -| DEFAULT_SPEED_INVALID | 默认速度非法 | -| SEGMENT_OVERFLOW | 路径段数超过100 | -| OUTPUT_MODE_INVALID | 输出模式非法 | -| RESOURCE_CONFLICT | 输出资源冲突 | -| PARAMETER_LOCKED | 运行参数禁止修改 | -| DONE_STATE_ERROR | 完成状态异常 | -| SPEED_CLAMPED | 目标速度被S2最高速度限幅;属于告警,不单独终止任务 | -| POSITION_INVALID | 绝对定位前逻辑位置无效 | -| COUNTER_OVERFLOW | 内部或兼容累计计数溢出 | - -## 3 性能需求 - -### 3.1 脉冲性能 - -| 编号 | 性能项 | 验收指标 | -| --- | --- | --- | -| PR-01 | 保证频率范围 | 1 Hz~100 kHz | -| PR-02 | 固定频率误差 | 使用可实现的最佳定时器分频值,记录目标值、实际值及误差,不设统一百分比限值 | -| PR-03 | 正常完成脉冲误差 | 0 个脉冲 | -| PR-04 | 占空比 | 目标 50%,满足驱动器最小高低电平时间 | -| PR-05 | 方向建立时间 | 不小于配置值 | -| PR-06 | 运行调频开始生效 | 不超过当前 S2 配置的 1 ms 或 0.1 ms | -| PR-07 | 段边界 | 不得产生非配置停顿、窄脉冲或重复边沿 | - -### 3.2 实时性能 - -以下是 V1.0 初始预算,首次 100 kHz 压力测试后可根据实测调整,但必须经过 -设计评审并记录原因: - -| 编号 | 性能项 | 初始指标 | -| --- | --- | --- | -| PR-RT-01 | 控制更新周期 | S2 可配置为 1 ms 或 0.1 ms | -| PR-RT-02 | 脉冲产生 | 由硬件定时器维持,不受 OS 调度影响 | -| PR-RT-03 | 脉冲级中断最坏执行时间 | 实测并记录,不得导致脉冲数量错误 | -| PR-RT-04 | PLSR CPU 占用率 | 四轴 100 kHz 并发时以实测记录并验证不影响系统实时性 | -| PR-RT-05 | PLSR 静态 RAM | 实测并记录,不设固定8 KiB限制 | -| PR-RT-07 | 共享数据 | 使用原子快照或临界区保护 | -| PR-RT-08 | 通信压力 | Modbus/USB 压力下脉冲计数误差仍为 0 | - -### 3.3 可靠性 - -| 编号 | 性能项 | 验收指标 | -| --- | --- | --- | -| PR-REL-01 | 连续运行 | 测试时长由测试方案确定,运行期间不得出现计数错误或任务失联 | -| PR-REL-02 | 频繁启停 | 不产生额外脉冲和非法状态转换 | -| PR-REL-03 | 异常参数 | 拒绝执行且不影响当前合法任务 | -| PR-REL-04 | 重新上电 | 输出安全,不自动恢复运动 | -| PR-REL-05 | 参数恢复 | 已成功保存的固定配置和Backup SRAM动态数据能够按定义恢复 | -| PR-REL-06 | 通信并发 | 连续读写状态时运动结果保持准确 | - -### 3.4 工程单位与换算边界 - -- PLSR内核同时支持脉冲模式和当量模式,当量单位为1 μm、0.01 mm、0.1 - mm或1 mm。 -- 当量模式根据每转指令脉冲数和每转移动量换算频率与脉冲数量。 -- 换算层必须定义舍入方式并保存累计余数,连续多段运动不得持续产生单向舍入漂移。 -- 上位机仍可提前换算,但不得成为使用工程单位的唯一途径。 -- PLSR 累计脉冲表示指令位置,不等同于电机实际反馈位置。 - -## 4 其它需求 - -### 4.1 验收要求 - -| 编号 | 验收项 | 合格标准 | -| --- | --- | --- | -| AT-01 | 固定频率 | 1 Hz~100 kHz 使用最佳定时器分频值,并记录实际误差 | -| AT-02 | 固定脉冲数 | 正常完成误差为 0 个 | -| AT-03 | 正反转 | 方向电平和脉冲数量正确 | -| AT-04 | 相对定位 | 连续相对运动位置累计正确 | -| AT-05 | 绝对定位 | 从不同当前位置到同一目标位置结果正确 | -| AT-06 | 加减速 | 曲线、起止频率和脉冲总数正确 | -| AT-07 | 多段路径 | 段顺序、频率、数量和等待条件正确 | -| AT-08 | 完成方式 | 段结束后才向下一段频率变化 | -| AT-09 | 后续方式 | 段结束时已经达到下一段目标频率 | -| AT-10 | 动态调频 | 在 S2 配置的 1 ms 或 0.1 ms 内开始生效且无异常脉冲 | -| AT-11 | 停止控制 | 减速停止和立即停止符合定义 | -| AT-12 | 重复命令 | 相同命令序号不产生重复运动 | -| AT-13 | 掉电恢复 | 不自动运动,位置有效性符合恢复等级 | -| AT-14 | 上电安全 | 上电和复位期间无意外脉冲 | -| AT-15 | S0/S1布局 | 使用动态首地址验证完整相对布局、保留区、六种等待条件和四种跳转来源 | -| AT-16 | 最高速度限幅 | 目标速度高于S2最高速度时按最高速度运行并产生SPEED_CLAMPED告警 | -| AT-17 | 四轴独立运行 | 四轴同时以100 kHz运行时无丢脉冲、重复脉冲和异常周期,且状态和累计数据互不串扰 | -| AT-18 | AB 相输出 | 两个AB轴可独立运行;一个完整正交周期计1个指令脉冲,周期频率、相位方向和数量正确 | -| AT-19 | S0等待与跳转 | 六种等待条件、动态参数来源、提前/延后触发和0/指定/自身跳转语义与信捷一致 | -| AT-20 | 公共参数 | 单位换算、软限位、方向延时、齿隙补偿和FOLLOW参数均通过测试 | -| AT-21 | 零值及边界 | INT32最小值、频率0、脉冲数0跳段和绝对零位移行为符合本规格 | -| AT-22 | 段事件和监控 | 每段事件、运行标志、当前段计数和累计计数可被稳定读取 | -| AT-23 | 固定软元件 | HSD、SM、SD和I6000~I6399的地址、位宽、轴映射及可观察语义与信捷一致 | - -新增测试: - -- FR-TEST-01四轴100 kHz并发测试; -- PLSR启动上升沿测试; -- BUSY/DONE状态测试; -- DONE保持及清零测试; -- PULSE/DIR测试; -- AB相测试; -- CW/CCW测试; -- PAUSE/RESUME测试; -- STOP优先级测试; -- 参数运行修改测试; -- 输出诊断一致性测试。 - -正式测试应使用开发板、逻辑分析仪和PC Modbus测试工具,并记录测试固件、 -参数、接线、测量设备、原始波形和测试结论。 - -### 4.2 交付物 - -- PLSR 源代码和头文件。 -- 硬件定时器、GPIO 和板型适配代码。 -- Modbus 寄存器及命令协议。 -- Flash 参数保存模块。 -- 需求规格书。 -- 软件设计说明书。 -- 单元测试和上板测试方案。 -- 测试记录及测试报告。 -- 上位机调试或测试工具。 -- 示例程序和使用说明。 - -### 4.3 后续版本或加分项 - -- 完整机械回零。 - -### 4.4 不在当前范围内 - -- 编码器闭环控制和位置误差修正。 -- 多轴直线或圆弧插补。 -- 电子凸轮和龙门同步。 -- 完整复制信捷 PLSR、PLSF、DRVI、DRVA、ZRN、STOP 和 GOON 语法。 -- XDPPro参数配置界面兼容。 - -### 4.5 实施验证事项 - -以下事项不改变V1.0需求范围,属于设计、实现和上板验收阶段需要验证的内容: - -1. Q0~Q3 定时器输出映射的上板波形确认。 -2. 输出电路的最高可靠频率、有效电平和最小脉宽。 -3. 两个 AB 轴的定时器/DMA同步方案及上板波形验证。 -4. 四轴100 kHz并发及0.1 ms控制刷新时的CPU和中断资源实测结果。 -5. Backup SRAM检查点的具体更新时机和诊断数据允许损失量。 -6. 段脉冲数为0时无脉冲输出、跳过当前段并正确进入下一段或完成任务。 - -## 5 参考资料 - -1. [PLSR 嵌入式方向任务要求](./任务要求.png) -2. [实现 PLSR 指令](./实现PLSR指令.pdf) -3. [信捷 XD/XL 系列 PLC 定位控制手册](./信捷XD_XL系列PLC定位控制手册_PD02_20260510_V1.3.pdf) -4. [C 语言规范](../c语言规范.pdf) -5. 当前工程 `Core`、`Modbus`、`Middlewares`、`EWARM` 和 `HostComputer` - 目录 -6. [PLSR地址映射](./PLSR地址映射.md) -7. [PLSR信捷对标追踪矩阵](./PLSR信捷对标追踪矩阵.md) diff --git a/HostComputer/PLSR_Python_Tester.zip b/HostComputer/PLSR_Python_Tester.zip new file mode 100644 index 0000000..e544ad8 Binary files /dev/null and b/HostComputer/PLSR_Python_Tester.zip differ diff --git a/HostComputer/PLSR_Python_Tester/README.md b/HostComputer/PLSR_Python_Tester/README.md new file mode 100644 index 0000000..dcb8007 --- /dev/null +++ b/HostComputer/PLSR_Python_Tester/README.md @@ -0,0 +1,37 @@ +# PLSR Python Tester + +## 输出诊断 + +上位机新增“输出诊断”页。连接并启动脉冲后,程序会随状态轮询读取设备的诊断结果;运动完成后分别显示脉冲个数、脉冲频率和加减速曲线是否与设置一致,并显示实际/设定脉冲、实际/设定频率、最大频率偏差及三类异常次数。“清除诊断记录”只清除诊断结果,运行期间不能清除。 + +诊断状态追加在原状态区之后,原地址不变:`0x200B` 为诊断状态位,`0x200C` 为曲线类型,`0x200D` 为当前曲线阶段,`0x200E~0x2017` 为期望/实际计数与频率,`0x2018~0x201A` 为异常次数。向控制寄存器 `0x3000` 写入 `0x0020` 可清除诊断记录。 + +该功能诊断的是STM32内部实际执行情况:脉冲完成中断计数与TIM寄存器反算频率。若需要发现端子断线、外部干扰或驱动器漏收脉冲,仍需示波器或外部反馈计数输入。 + +本工具已按《参数表-2026.xlsx》更新,使用 Python/tkinter 和 Modbus RTU。 + +- 输出方式可选择:`PUL/DIR(脉冲+方向)`或`AB正交脉冲`,选择结果保存到设备Flash。 +- 脉冲发送模式可选择:`完成模式`在段边界停止后再启动,`后续模式`在条件允许且方向兼容时保持硬件PWM连续衔接。 +- 方向逻辑可选择正逻辑或负逻辑并保存到设备Flash;PUL/DIR运动完成、等待或暂停后方向端保持最后状态,下一次运动再按新方向更新。 +- PUL/DIR模式:Y0~Y3输出硬件PWM脉冲,Y12~Y15保持方向电平,原来的正反转接线和方向逻辑继续生效。 +- Y0~Y3分别使用PF6/TIM10、PF8/TIM13、PF7/TIM11、PF9/TIM14。 +- AB模式:A相端子可选Y0~Y3,B相端子可选Y12~Y15;正向A超前B 90°,反向B超前A 90°。 +- AB模式每完成一个四拍周期计为1个脉冲;两种模式空闲时输出均恢复高电平。 +- 相对模式支持完整32位有符号脉冲数,负数直接表示反向;原方向选项仍可把正数脉冲转换为反向。绝对模式由目标位置与当前累计位置自动判向。 + +- 公共参数:`0x1000~0x1015`,其中`0x1007`为脉冲加减速模式(0=直线、1=S曲线、2=正弦曲线),`0x100B~0x100C`是脉冲默认速度,`0x100F`为输出方式(0=PUL/DIR,1=AB正交)。 +- 运行中目标频率:`0x1016~0x1017`,写入后以控制位`0x0040`立即应用;该值不修改默认速度、段表或Flash。 +- 分段参数:第 n 段基址 `0x1100 + (n-1)*0x10`,每段使用偏移 `0~7`。 +- 状态:`0x2000~0x2006`。 +- 控制:`0x3000`,bit0 启动/继续、bit1 停止、bit2 清累计位置。 +- 所有双字参数均为低地址存低 16 位。 + +运行 `start_plsr_tester.bat`,或执行 `python main.py`。首次使用如果缺少串口库,请安装 `pyserial`。 + +验证AB相时,请将双通道示波器分别接到所选A相、B相端子,并共地测量。不要仅观察单只LED判断正反向;单路波形无法显示两相的超前关系。 + +测试协议编解码: + +```bat +python -m unittest test_protocol.py +``` diff --git a/HostComputer/PLSR_Python_Tester/__pycache__/main.cpython-312.pyc b/HostComputer/PLSR_Python_Tester/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..204611f Binary files /dev/null and b/HostComputer/PLSR_Python_Tester/__pycache__/main.cpython-312.pyc differ diff --git a/HostComputer/PLSR_Python_Tester/__pycache__/main.cpython-39.pyc b/HostComputer/PLSR_Python_Tester/__pycache__/main.cpython-39.pyc new file mode 100644 index 0000000..0817a33 Binary files /dev/null and b/HostComputer/PLSR_Python_Tester/__pycache__/main.cpython-39.pyc differ diff --git a/HostComputer/PLSR_Python_Tester/__pycache__/modbus_rtu.cpython-312.pyc b/HostComputer/PLSR_Python_Tester/__pycache__/modbus_rtu.cpython-312.pyc new file mode 100644 index 0000000..e1048f1 Binary files /dev/null and b/HostComputer/PLSR_Python_Tester/__pycache__/modbus_rtu.cpython-312.pyc differ diff --git a/HostComputer/PLSR_Python_Tester/__pycache__/modbus_rtu.cpython-39.pyc b/HostComputer/PLSR_Python_Tester/__pycache__/modbus_rtu.cpython-39.pyc new file mode 100644 index 0000000..93b6474 Binary files /dev/null and b/HostComputer/PLSR_Python_Tester/__pycache__/modbus_rtu.cpython-39.pyc differ diff --git a/HostComputer/PLSR_Python_Tester/__pycache__/plsr_protocol.cpython-312.pyc b/HostComputer/PLSR_Python_Tester/__pycache__/plsr_protocol.cpython-312.pyc new file mode 100644 index 0000000..576aa56 Binary files /dev/null and b/HostComputer/PLSR_Python_Tester/__pycache__/plsr_protocol.cpython-312.pyc differ diff --git a/HostComputer/PLSR_Python_Tester/__pycache__/plsr_protocol.cpython-39.pyc b/HostComputer/PLSR_Python_Tester/__pycache__/plsr_protocol.cpython-39.pyc new file mode 100644 index 0000000..48ac58d Binary files /dev/null and b/HostComputer/PLSR_Python_Tester/__pycache__/plsr_protocol.cpython-39.pyc differ diff --git a/HostComputer/PLSR_Python_Tester/__pycache__/test_protocol.cpython-312.pyc b/HostComputer/PLSR_Python_Tester/__pycache__/test_protocol.cpython-312.pyc new file mode 100644 index 0000000..516c58a Binary files /dev/null and b/HostComputer/PLSR_Python_Tester/__pycache__/test_protocol.cpython-312.pyc differ diff --git a/HostComputer/PLSR_Python_Tester/main.py b/HostComputer/PLSR_Python_Tester/main.py new file mode 100644 index 0000000..ac97335 --- /dev/null +++ b/HostComputer/PLSR_Python_Tester/main.py @@ -0,0 +1,2671 @@ +"""PLSR单轴脉冲测试上位机 - tkinter版本。""" + +from __future__ import annotations + +import json +import queue +import sys +import time +from datetime import datetime +from pathlib import Path +from typing import Dict, List + +import tkinter as tk +from tkinter import messagebox, ttk +from tkinter.scrolledtext import ScrolledText + +from modbus_rtu import ModbusClient, list_serial_ports +from plsr_protocol import ( + Command, + DiagnosticStatus, + DIAGNOSTIC_PHASE_TEXT, + DirectionLogic, + DIRECTION_LOGIC_TEXT, + DEFAULT_BAUD_RATE, + DEFAULT_SLAVE_ADDRESS, + SECONDARY_POINTS, + Error, + MAX_FREQUENCY_HZ, + MAX_RAMP_TIME_MS, + MIN_FREQUENCY_HZ, + MODE_TEXT, + PRESETS, + PROFILE_TEXT, + OUTPUT_MODE_TEXT, + PULSE_POINTS, + Register, + WaitCondition, + WAIT_CONDITION_TEXT, + INPUT_POINTS, + ProfileType, + OutputMode, + RunMode, + SendMode, + SEND_MODE_TEXT, + State, + STATUS_REGISTER_COUNT, + error_text, + calculate_execution_total, + join_i32, + join_u32, + parse_status, + point_id_from_label, + register_read_count, + segment_base, + signed_to_u32, + split_u32, + state_text, +) + + +APP_DIR = Path(__file__).resolve().parent +SETTINGS_FILE = APP_DIR / "settings.json" +DIRECTION_TEXT = {0: "正向", 1: "反向"} + + +class PlsrTesterApp: + POLL_INTERVAL_MS = 250 + EVENT_INTERVAL_MS = 40 + + def __init__(self, root: tk.Tk) -> None: + self.root = root + self.client = ModbusClient() + self.closing = False + self.expected_pulses = 0 + self.expected_constant_frequency = 0 + self.display_target = 0 + self.test_active = False + self.monitoring_active = False + self.seen_running = False + self.test_started_at = 0.0 + self.last_state = -1 + self.last_error = -1 + self.pending_frequency = 0 + self.pending_motion = None + self.pending_segments = None + self.written_segments = None + self.segments_written_ready = False + self.start_after_segment_write = False + self.multi_test_active = False + self.wait_signal_select = 0 + self.ext_signal_select = 0 + + self._build_style() + self._build_ui() + self.refresh_ports() + self.apply_preset(log_message=False) + self._load_settings() + self._set_connected(False) + + self.root.protocol("WM_DELETE_WINDOW", self.close) + self.root.after(self.EVENT_INTERVAL_MS, self._process_client_events) + self.root.after(self.POLL_INTERVAL_MS, self._poll_status) + self.log("上位机已启动。请选择USART1对应的COM端口。") + + def _build_style(self) -> None: + self.root.title("PLSR 单轴脉冲测试台 - Python") + self.root.geometry("1120x900") + self.root.minsize(980, 820) + self.root.configure(bg="#F3F6FA") + + style = ttk.Style(self.root) + if "clam" in style.theme_names(): + style.theme_use("clam") + style.configure("TFrame", background="#F3F6FA") + style.configure("Card.TFrame", background="#FFFFFF") + style.configure( + "TLabel", background="#F3F6FA", foreground="#172033", font=("Microsoft YaHei UI", 10) + ) + style.configure( + "Card.TLabel", background="#FFFFFF", foreground="#172033", font=("Microsoft YaHei UI", 10) + ) + style.configure( + "TLabelframe", background="#FFFFFF", bordercolor="#D8E1EB", relief="solid" + ) + style.configure( + "TLabelframe.Label", background="#FFFFFF", foreground="#29445F", font=("Microsoft YaHei UI", 10, "bold") + ) + style.configure("TButton", font=("Microsoft YaHei UI", 10), padding=(12, 7)) + style.configure("Primary.TButton", background="#1769AA", foreground="#FFFFFF") + style.map("Primary.TButton", background=[("active", "#0F5B98"), ("disabled", "#CBD4DE")]) + style.configure("Danger.TButton", background="#D64545", foreground="#FFFFFF") + style.map("Danger.TButton", background=[("active", "#BE3434"), ("disabled", "#CBD4DE")]) + style.configure("Secondary.TButton", background="#E5ECF4", foreground="#29445F") + style.map("Secondary.TButton", background=[("active", "#D6E1EC")]) + style.configure("TNotebook", background="#F3F6FA", borderwidth=0) + style.configure("TNotebook.Tab", padding=(16, 9), font=("Microsoft YaHei UI", 10)) + style.map( + "TNotebook.Tab", + background=[("selected", "#FFFFFF"), ("!selected", "#E4EAF1")], + foreground=[("selected", "#15558A"), ("!selected", "#53677C")], + ) + style.configure("Treeview", rowheight=54, font=("Microsoft YaHei UI", 9)) + style.configure("Treeview.Heading", font=("Microsoft YaHei UI", 9, "bold"), background="#E8EEF5") + style.configure("Horizontal.TProgressbar", troughcolor="#E7EDF3", background="#2B83D5") + + def _build_ui(self) -> None: + container = ttk.Frame(self.root, padding=(22, 17, 22, 20)) + container.pack(fill=tk.BOTH, expand=True) + + connection = ttk.LabelFrame(container, text="串口连接", padding=(14, 11)) + connection.pack(fill=tk.X, pady=(0, 12)) + connection.columnconfigure(8, weight=1) + ttk.Label(connection, text="端口", style="Card.TLabel").grid(row=0, column=0, padx=(0, 6)) + self.port_var = tk.StringVar() + self.port_combo = ttk.Combobox(connection, textvariable=self.port_var, state="readonly", width=8) + self.port_combo.grid(row=0, column=1, padx=(0, 6)) + self.refresh_button = ttk.Button( + connection, text="刷新", width=5, + style="Secondary.TButton", command=self.refresh_ports + ) + self.refresh_button.grid(row=0, column=2, padx=(0, 10)) + ttk.Label(connection, text="波特率", style="Card.TLabel").grid(row=0, column=3, padx=(0, 6)) + self.baud_var = tk.StringVar(value=str(DEFAULT_BAUD_RATE)) + self.baud_combo = ttk.Combobox( + connection, + textvariable=self.baud_var, + values=("115200",), + state="readonly", + width=7, + ) + self.baud_combo.grid(row=0, column=4, padx=(0, 10)) + ttk.Label(connection, text="格式 8-E-1", style="Card.TLabel").grid(row=0, column=5, padx=(0, 10)) + ttk.Label(connection, text="从站", style="Card.TLabel").grid(row=0, column=6, padx=(0, 6)) + self.slave_var = tk.IntVar(value=DEFAULT_SLAVE_ADDRESS) + self.slave_spin = ttk.Spinbox(connection, from_=1, to=247, textvariable=self.slave_var, width=4) + self.slave_spin.grid(row=0, column=7, padx=(0, 10)) + connection_actions = ttk.Frame(connection, style="Card.TFrame") + connection_actions.grid(row=0, column=8, sticky="e") + self.connect_button = ttk.Button( + connection_actions, + text="连接设备", + style="Primary.TButton", + command=self.toggle_connection, + ) + self.connect_button.pack(side=tk.LEFT) + self.connection_badge = tk.Label( + connection_actions, + text="未连接", + bg="#FDECEC", + fg="#A61B1B", + font=("Microsoft YaHei UI", 10, "bold"), + padx=10, + pady=7, + ) + self.connection_badge.pack(side=tk.LEFT, padx=(10, 0)) + + notebook = ttk.Notebook(container) + notebook.pack(fill=tk.BOTH, expand=True) + self.control_page = ttk.Frame(notebook, padding=(0, 14, 0, 0)) + self.diagnostic_page = ttk.Frame(notebook, padding=(0, 14, 0, 0)) + self.log_page = ttk.Frame(notebook, padding=(0, 14, 0, 0)) + notebook.add(self.control_page, text="控制、状态与多段脉冲") + notebook.add(self.diagnostic_page, text="输出诊断") + notebook.add(self.log_page, text="通信日志") + + # 单段控制、实时状态和多段参数统一放进同一个可滚动页面。 + # 内容按实际需要的高度排布,避免10段表格挤压上方按钮或撑大窗口。 + self.control_page.columnconfigure(0, weight=1) + self.control_page.rowconfigure(0, weight=1) + self.control_canvas = tk.Canvas( + self.control_page, + bg="#F3F6FA", + highlightthickness=0, + borderwidth=0, + ) + control_scrollbar = ttk.Scrollbar( + self.control_page, + orient=tk.VERTICAL, + command=self.control_canvas.yview, + ) + self.control_canvas.configure(yscrollcommand=control_scrollbar.set) + self.control_canvas.grid(row=0, column=0, sticky="nsew") + control_scrollbar.grid(row=0, column=1, sticky="ns") + + self.control_content = ttk.Frame(self.control_canvas) + self.control_window = self.control_canvas.create_window( + (0, 0), window=self.control_content, anchor="nw" + ) + self.control_canvas.bind("", self._resize_control_content) + self.control_content.bind("", self._update_control_scrollregion) + # 鼠标进入整个合并页后启用滚轮;进入Canvas中的子控件时不会失效。 + self.control_page.bind("", self._enable_control_mousewheel) + self.control_page.bind("", self._disable_control_mousewheel) + + self._build_control_page() + self._build_multi_page() + self._build_diagnostic_page() + self._build_log_page() + + def _resize_control_content(self, event) -> None: + """让滚动页内容始终占满可见宽度。""" + self.control_canvas.itemconfigure(self.control_window, width=event.width) + + def _update_control_scrollregion(self, _event=None) -> None: + """单段或多段区域尺寸变化后刷新垂直滚动范围。""" + self.control_canvas.configure(scrollregion=self.control_canvas.bbox("all")) + + def _enable_control_mousewheel(self, _event=None) -> None: + self.root.bind_all("", self._scroll_control_page) + + def _disable_control_mousewheel(self, _event=None) -> None: + self.root.unbind_all("") + + def _scroll_control_page(self, event) -> None: + if event.delta != 0: + self.control_canvas.yview_scroll(int(-event.delta / 120), "units") + + def _build_control_page(self) -> None: + """建立公共参数、10段参数和实时状态组成的主操作页。""" + self.control_content.columnconfigure(0, weight=3, uniform="motion") + self.control_content.columnconfigure(1, weight=2, uniform="motion") + # 左右区域位于同一可伸缩网格行,边框高度始终保持一致。 + self.control_content.rowconfigure(1, weight=1) + + common = ttk.LabelFrame( + self.control_content, text="公共参数", padding=(14, 10) + ) + common.grid( + row=0, column=0, columnspan=2, sticky="nsew", pady=(0, 10) + ) + for column in (1, 3, 5): + common.columnconfigure(column, weight=1) + + # 这些变量仍由既有通信、Flash同步和参数校验流程统一使用。 + self.preset_var = tk.StringVar(value=PRESETS[0]["name"]) + self.pulses_var = tk.StringVar(value="10") + self.run_mode_var = tk.StringVar(value=MODE_TEXT[RunMode.RELATIVE]) + self.target_caption_var = tk.StringVar(value="目标脉冲数") + self.direction_var = tk.StringVar(value=DIRECTION_TEXT[0]) + self.profile_var = tk.StringVar(value=PROFILE_TEXT[ProfileType.LINEAR]) + + self.direction_logic_var = tk.StringVar( + value=DIRECTION_LOGIC_TEXT[DirectionLogic.POSITIVE] + ) + self.output_mode_var = tk.StringVar( + value=OUTPUT_MODE_TEXT[OutputMode.PULSE_DIRECTION] + ) + self.send_mode_var = tk.StringVar(value=SEND_MODE_TEXT[SendMode.COMPLETE]) + self.pulse_point_var = tk.StringVar(value=PULSE_POINTS[0]) + self.secondary_point_var = tk.StringVar(value=SECONDARY_POINTS[0]) + self.frequency_var = tk.IntVar(value=1) + self.runtime_frequency_var = tk.IntVar(value=1) + self.acceleration_var = tk.IntVar(value=1000) + self.deceleration_var = tk.IntVar(value=1000) + self.maximum_frequency_var = tk.IntVar(value=MAX_FREQUENCY_HZ) + self.start_frequency_var = tk.IntVar(value=100) + self.end_frequency_var = tk.IntVar(value=100) + + def add_label(row: int, column: int, text: str) -> None: + ttk.Label(common, text=text, style="Card.TLabel").grid( + row=row, column=column, sticky="w", padx=(0, 8), pady=5 + ) + + add_label(0, 0, "脉冲方向逻辑") + self.direction_logic_combo = ttk.Combobox( + common, + textvariable=self.direction_logic_var, + values=tuple(DIRECTION_LOGIC_TEXT[item] for item in DirectionLogic), + state="readonly", + width=24, + ) + self.direction_logic_combo.grid(row=0, column=1, sticky="ew", padx=(0, 18), pady=5) + + add_label(0, 2, "脉冲类型") + self.output_mode_combo = ttk.Combobox( + common, + textvariable=self.output_mode_var, + values=tuple(OUTPUT_MODE_TEXT.values()), + state="readonly", + width=23, + ) + self.output_mode_combo.grid(row=0, column=3, sticky="ew", padx=(0, 18), pady=5) + self.output_mode_combo.bind("<>", self._on_output_mode_changed) + + add_label(0, 4, "脉冲发送模式") + self.send_mode_combo = ttk.Combobox( + common, + textvariable=self.send_mode_var, + values=tuple(SEND_MODE_TEXT[item] for item in SendMode), + state="readonly", + width=24, + ) + self.send_mode_combo.grid(row=0, column=5, sticky="ew", pady=5) + + add_label(1, 0, "脉冲输出端子") + self.pulse_point_combo = ttk.Combobox( + common, + textvariable=self.pulse_point_var, + values=list(PULSE_POINTS.values()), + state="readonly", + width=24, + ) + self.pulse_point_combo.grid(row=1, column=1, sticky="ew", padx=(0, 18), pady=5) + + add_label(1, 2, "脉冲方向端子") + self.direction_point_combo = ttk.Combobox( + common, + textvariable=self.secondary_point_var, + values=list(SECONDARY_POINTS.values()), + state="readonly", + width=23, + ) + self.direction_point_combo.grid(row=1, column=3, sticky="ew", padx=(0, 18), pady=5) + self.save_outputs_button = ttk.Button( + common, + text="保存公共参数", + style="Primary.TButton", + command=self.save_output_mapping, + ) + self.save_outputs_button.grid(row=1, column=4, columnspan=2, sticky="ew", pady=5) + + add_label(2, 0, "脉冲默认速度(Hz)") + self.frequency_spin = ttk.Spinbox( + common, + from_=MIN_FREQUENCY_HZ, + to=MAX_FREQUENCY_HZ, + textvariable=self.frequency_var, + ) + self.frequency_spin.grid(row=2, column=1, sticky="ew", padx=(0, 18), pady=5) + + add_label(2, 2, "脉冲默认加速时间(ms)") + self.acceleration_spin = ttk.Spinbox( + common, from_=0, to=MAX_RAMP_TIME_MS, textvariable=self.acceleration_var + ) + self.acceleration_spin.grid(row=2, column=3, sticky="ew", padx=(0, 18), pady=5) + + add_label(2, 4, "脉冲默认减速时间(ms)") + self.deceleration_spin = ttk.Spinbox( + common, from_=0, to=MAX_RAMP_TIME_MS, textvariable=self.deceleration_var + ) + self.deceleration_spin.grid(row=2, column=5, sticky="ew", pady=5) + + add_label(3, 0, "最高速度(Hz)") + self.maximum_frequency_spin = ttk.Spinbox( + common, + from_=MIN_FREQUENCY_HZ, + to=MAX_FREQUENCY_HZ, + textvariable=self.maximum_frequency_var, + ) + self.maximum_frequency_spin.grid(row=3, column=1, sticky="ew", padx=(0, 18), pady=5) + + add_label(3, 2, "起始速度(Hz)") + self.start_frequency_spin = ttk.Spinbox( + common, + from_=0, + to=MAX_FREQUENCY_HZ, + textvariable=self.start_frequency_var, + ) + self.start_frequency_spin.grid(row=3, column=3, sticky="ew", padx=(0, 18), pady=5) + + add_label(3, 4, "终止速度(Hz)") + self.end_frequency_spin = ttk.Spinbox( + common, + from_=0, + to=MAX_FREQUENCY_HZ, + textvariable=self.end_frequency_var, + ) + self.end_frequency_spin.grid(row=3, column=5, sticky="ew", pady=5) + + add_label(4, 0, "脉冲加减速模式") + self.profile_combo = ttk.Combobox( + common, + textvariable=self.profile_var, + values=tuple(PROFILE_TEXT[item] for item in ProfileType), + state="readonly", + width=24, + ) + self.profile_combo.grid(row=4, column=1, sticky="ew", padx=(0, 18), pady=5) + + add_label(5, 0, "运行中目标频率(Hz)") + runtime_frequency_box = ttk.Frame(common, style="Card.TFrame") + runtime_frequency_box.grid(row=5, column=1, sticky="ew", padx=(0, 18), pady=5) + runtime_frequency_box.columnconfigure(0, weight=1) + self.runtime_frequency_spin = ttk.Spinbox( + runtime_frequency_box, + from_=MIN_FREQUENCY_HZ, + to=MAX_FREQUENCY_HZ, + textvariable=self.runtime_frequency_var, + width=11, + ) + self.runtime_frequency_spin.grid(row=0, column=0, sticky="ew") + self.update_frequency_button = ttk.Button( + runtime_frequency_box, + text="立即修改当前段", + style="Secondary.TButton", + command=self.update_frequency, + ) + self.update_frequency_button.grid(row=0, column=1, padx=(6, 0)) + ttk.Label( + common, + text="仅影响本次当前段,不修改默认速度、段表或Flash", + style="Card.TLabel", + foreground="#5B7083", + ).grid(row=5, column=2, columnspan=4, sticky="w", pady=5) + + status = ttk.LabelFrame(self.control_content, text="实时状态", padding=16) + status.grid(row=1, column=1, sticky="nsew", padx=(8, 0)) + status.columnconfigure(1, weight=1) + self.state_badge = tk.Label( + status, + text="空闲", + bg="#E8EEF5", + fg="#425466", + font=("Microsoft YaHei UI", 10, "bold"), + padx=13, + pady=6, + ) + ttk.Label(status, text="运行状态", style="Card.TLabel").grid( + row=0, column=0, sticky="w", pady=6 + ) + self.state_badge.grid(row=0, column=1, sticky="e", pady=6) + + self.error_var = tk.StringVar(value="0 · 无错误") + self.direction_status_var = tk.StringVar(value=DIRECTION_TEXT[0]) + self.current_pulses_var = tk.StringVar(value="0") + self.current_frequency_var = tk.StringVar(value="0 Hz") + self.actual_frequency_var = tk.StringVar(value="0 Hz") + self.running_segment_var = tk.StringVar(value="未运行") + self.cumulative_position_var = tk.StringVar(value="0") + self.total_pulses_var = tk.StringVar(value="0") + status_rows = [ + ("错误码", self.error_var), + ("方向", self.direction_status_var), + ("当前设定频率", self.current_frequency_var), + ("运行中修改后的实际频率", self.actual_frequency_var), + ("当前运行段", self.running_segment_var), + ("当前Y点累计位置", self.cumulative_position_var), + ("总脉冲数(不计方向)", self.total_pulses_var), + ] + for index, (caption, variable) in enumerate(status_rows, start=1): + ttk.Label(status, text=caption, style="Card.TLabel").grid( + row=index, column=0, sticky="w", pady=7 + ) + ttk.Label( + status, + textvariable=variable, + style="Card.TLabel", + foreground="#183B56", + font=("Microsoft YaHei UI", 10, "bold"), + ).grid(row=index, column=1, sticky="e", pady=7) + + ttk.Label(status, text="本次运动进度", style="Card.TLabel").grid( + row=8, column=0, columnspan=2, sticky="w", pady=(14, 6) + ) + self.progress_var = tk.DoubleVar(value=0.0) + self.progress = ttk.Progressbar( + status, + maximum=100.0, + variable=self.progress_var, + style="Horizontal.TProgressbar", + ) + self.progress.grid(row=9, column=0, columnspan=2, sticky="ew") + self.pulse_fraction_var = tk.StringVar(value="0 / 0") + ttk.Label( + status, + textvariable=self.pulse_fraction_var, + style="Card.TLabel", + foreground="#6E7F91", + ).grid(row=10, column=0, columnspan=2, sticky="e", pady=(4, 10)) + + status_actions = ttk.Frame(status, style="Card.TFrame") + status_actions.grid(row=11, column=0, columnspan=2, sticky="ew") + for column in range(3): + status_actions.columnconfigure(column, weight=1) + self.read_button = ttk.Button( + status_actions, + text="立即读取状态", + style="Secondary.TButton", + command=self.request_status, + ) + self.read_button.grid(row=0, column=0, sticky="ew", padx=(0, 4)) + self.clear_position_button = ttk.Button( + status_actions, + text="清零当前Y点位置", + style="Danger.TButton", + command=self.clear_current_position, + ) + self.clear_position_button.grid(row=0, column=1, sticky="ew", padx=4) + self.clear_total_button = ttk.Button( + status_actions, + text="清零总脉冲数", + style="Danger.TButton", + command=self.clear_total_count, + ) + self.clear_total_button.grid(row=0, column=2, sticky="ew", padx=(4, 0)) + + def _build_control_page_legacy(self) -> None: + self.control_content.columnconfigure(0, weight=1, uniform="control") + self.control_content.columnconfigure(1, weight=1, uniform="control") + + params = ttk.LabelFrame(self.control_content, text="测试参数", padding=(14, 10)) + params.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) + params.columnconfigure(1, weight=1) + + mapping = tk.Frame(params, bg="#EAF4FF", padx=10, pady=7) + mapping.grid(row=0, column=0, columnspan=3, sticky="ew", pady=(0, 8)) + mapping.columnconfigure(1, weight=1) + mapping.columnconfigure(3, weight=1) + tk.Label( + mapping, + text="输出方式与端子映射(保存到设备Flash)", + bg="#EAF4FF", + fg="#15558A", + anchor="w", + font=("Microsoft YaHei UI", 10, "bold"), + ).grid(row=0, column=0, columnspan=5, sticky="w", pady=(0, 7)) + tk.Label(mapping, text="脉冲/A相", bg="#EAF4FF", fg="#29445F").grid( + row=1, column=0, sticky="w", padx=(0, 5) + ) + self.pulse_point_var = tk.StringVar(value=PULSE_POINTS[0]) + self.pulse_point_combo = ttk.Combobox( + mapping, + textvariable=self.pulse_point_var, + values=list(PULSE_POINTS.values()), + state="readonly", + width=21, + ) + self.pulse_point_combo.grid(row=1, column=1, sticky="ew", padx=(0, 9)) + tk.Label(mapping, text="方向/B相", bg="#EAF4FF", fg="#29445F").grid( + row=1, column=2, sticky="w", padx=(0, 5) + ) + # 第二输出点在协议中仍使用0~3编码,分别对应Y12~Y15。 + self.secondary_point_var = tk.StringVar(value=SECONDARY_POINTS[0]) + self.direction_point_combo = ttk.Combobox( + mapping, + textvariable=self.secondary_point_var, + values=list(SECONDARY_POINTS.values()), + state="readonly", + width=13, + ) + self.direction_point_combo.grid(row=1, column=3, sticky="ew", padx=(0, 9)) + self.save_outputs_button = ttk.Button( + mapping, + text="应用并保存", + style="Primary.TButton", + command=self.save_output_mapping, + ) + self.save_outputs_button.grid(row=1, column=4, sticky="ew") + tk.Label(mapping, text="输出方式", bg="#EAF4FF", fg="#29445F").grid( + row=2, column=0, sticky="w", padx=(0, 5), pady=(7, 0) + ) + self.output_mode_var = tk.StringVar( + value=OUTPUT_MODE_TEXT[OutputMode.PULSE_DIRECTION] + ) + self.output_mode_combo = ttk.Combobox( + mapping, + textvariable=self.output_mode_var, + values=tuple(OUTPUT_MODE_TEXT.values()), + state="readonly", + ) + self.output_mode_combo.grid( + row=2, column=1, columnspan=2, sticky="ew", padx=(0, 9), pady=(7, 0) + ) + self.output_mode_combo.bind("<>", self._on_output_mode_changed) + tk.Label(mapping, text="发送模式", bg="#EAF4FF", fg="#29445F").grid( + row=2, column=3, sticky="e", padx=(0, 5), pady=(7, 0) + ) + self.send_mode_var = tk.StringVar(value=SEND_MODE_TEXT[SendMode.COMPLETE]) + self.send_mode_combo = ttk.Combobox( + mapping, + textvariable=self.send_mode_var, + values=tuple(SEND_MODE_TEXT[item] for item in SendMode), + state="readonly", + width=24, + ) + self.send_mode_combo.grid(row=2, column=4, sticky="ew", pady=(7, 0)) + tk.Label(mapping, text="方向逻辑", bg="#EAF4FF", fg="#29445F").grid( + row=3, column=0, sticky="w", padx=(0, 5), pady=(7, 0) + ) + self.direction_logic_var = tk.StringVar( + value=DIRECTION_LOGIC_TEXT[DirectionLogic.POSITIVE] + ) + self.direction_logic_combo = ttk.Combobox( + mapping, + textvariable=self.direction_logic_var, + values=tuple(DIRECTION_LOGIC_TEXT[item] for item in DirectionLogic), + state="readonly", + ) + self.direction_logic_combo.grid( + row=3, column=1, columnspan=4, sticky="ew", pady=(7, 0) + ) + self.output_mode_hint = tk.Label( + mapping, + text="", + bg="#EAF4FF", + fg="#476A89", + anchor="w", + ) + # 详细说明通过选择项本身表达,不再单独占用一行,避免小屏幕裁掉运行按钮。 + self._on_output_mode_changed() + + ttk.Label(params, text="测试预设", style="Card.TLabel").grid(row=1, column=0, sticky="w", pady=4) + self.preset_var = tk.StringVar(value=PRESETS[0]["name"]) + self.preset_combo = ttk.Combobox( + params, + textvariable=self.preset_var, + values=[item["name"] for item in PRESETS], + state="readonly", + ) + self.preset_combo.grid(row=1, column=1, sticky="ew", padx=10, pady=4) + ttk.Button(params, text="应用", style="Secondary.TButton", command=self.apply_preset).grid( + row=1, column=2, pady=4 + ) + + ttk.Label(params, text="运行模式", style="Card.TLabel").grid(row=2, column=0, sticky="w", pady=4) + self.run_mode_var = tk.StringVar(value=MODE_TEXT[RunMode.RELATIVE]) + self.run_mode_combo = ttk.Combobox( + params, + textvariable=self.run_mode_var, + values=(MODE_TEXT[RunMode.RELATIVE], MODE_TEXT[RunMode.ABSOLUTE]), + state="readonly", + ) + self.run_mode_combo.grid(row=2, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=4) + self.run_mode_combo.bind("<>", self._on_run_mode_changed) + + self.target_caption_var = tk.StringVar(value="目标脉冲数") + ttk.Label(params, textvariable=self.target_caption_var, style="Card.TLabel").grid(row=3, column=0, sticky="w", pady=4) + self.pulses_var = tk.StringVar(value="10") + self.pulses_entry = ttk.Entry(params, textvariable=self.pulses_var) + self.pulses_entry.grid(row=3, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=4) + + ttk.Label(params, text="默认速度/匀速(Hz)", style="Card.TLabel").grid(row=4, column=0, sticky="w", pady=4) + self.frequency_var = tk.IntVar(value=1) + self.frequency_spin = ttk.Spinbox( + params, + from_=MIN_FREQUENCY_HZ, + to=MAX_FREQUENCY_HZ, + textvariable=self.frequency_var, + ) + self.frequency_spin.grid(row=4, column=1, sticky="ew", padx=10, pady=4) + self.update_frequency_button = ttk.Button( + params, + text="立即修改默认速度", + style="Secondary.TButton", + command=self.update_frequency, + ) + self.update_frequency_button.grid(row=4, column=2, sticky="ew", pady=4) + + ttk.Label(params, text="起始速度(Hz)", style="Card.TLabel").grid(row=5, column=0, sticky="w", pady=4) + self.start_frequency_var = tk.IntVar(value=100) + self.start_frequency_spin = ttk.Spinbox( + params, from_=0, to=MAX_FREQUENCY_HZ, + textvariable=self.start_frequency_var + ) + self.start_frequency_spin.grid(row=5, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=4) + + ttk.Label(params, text="终止速度(Hz)", style="Card.TLabel").grid(row=6, column=0, sticky="w", pady=4) + self.end_frequency_var = tk.IntVar(value=100) + self.end_frequency_spin = ttk.Spinbox( + params, from_=0, to=MAX_FREQUENCY_HZ, + textvariable=self.end_frequency_var + ) + self.end_frequency_spin.grid(row=6, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=4) + + ttk.Label(params, text="加减速模式", style="Card.TLabel").grid(row=7, column=0, sticky="w", pady=4) + self.profile_var = tk.StringVar(value=PROFILE_TEXT[ProfileType.LINEAR]) + self.profile_combo = ttk.Combobox( + params, + textvariable=self.profile_var, + values=tuple(PROFILE_TEXT[item] for item in ProfileType), + state="readonly", + ) + self.profile_combo.grid(row=7, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=4) + + ttk.Label(params, text="加速时间(ms)", style="Card.TLabel").grid(row=8, column=0, sticky="w", pady=4) + self.acceleration_var = tk.IntVar(value=1000) + self.acceleration_spin = ttk.Spinbox( + params, from_=0, to=MAX_RAMP_TIME_MS, textvariable=self.acceleration_var + ) + self.acceleration_spin.grid(row=8, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=4) + + ttk.Label(params, text="减速时间(ms)", style="Card.TLabel").grid(row=9, column=0, sticky="w", pady=4) + self.deceleration_var = tk.IntVar(value=1000) + self.deceleration_spin = ttk.Spinbox( + params, from_=0, to=MAX_RAMP_TIME_MS, textvariable=self.deceleration_var + ) + self.deceleration_spin.grid(row=9, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=4) + + ttk.Label(params, text="运动方向", style="Card.TLabel").grid(row=10, column=0, sticky="w", pady=4) + self.direction_var = tk.StringVar(value=DIRECTION_TEXT[0]) + self.direction_combo = ttk.Combobox( + params, + textvariable=self.direction_var, + values=(DIRECTION_TEXT[0], DIRECTION_TEXT[1]), + state="readonly", + ) + self.direction_combo.grid(row=10, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=4) + + button_box = ttk.Frame(params, style="Card.TFrame") + button_box.grid(row=11, column=0, columnspan=3, sticky="ew", pady=(8, 0)) + button_box.columnconfigure(0, weight=2) + button_box.columnconfigure(1, weight=1) + button_box.columnconfigure(2, weight=1) + self.start_button = ttk.Button( + button_box, + text="写参数并启动", + style="Primary.TButton", + command=self.start_motion, + ) + self.start_button.grid(row=0, column=0, sticky="ew", padx=(0, 6)) + self.stop_button = ttk.Button( + button_box, + text="暂停发送", + style="Danger.TButton", + command=self.stop_motion, + ) + self.stop_button.grid(row=0, column=1, sticky="ew", padx=6) + self.resume_button = ttk.Button( + button_box, + text="继续当前脉冲", + style="Secondary.TButton", + command=self.resume_motion, + ) + self.resume_button.grid(row=0, column=2, sticky="ew", padx=(0, 0)) + + status = ttk.LabelFrame(self.control_content, text="实时状态", padding=16) + status.grid(row=0, column=1, sticky="nsew", padx=(8, 0)) + status.columnconfigure(1, weight=1) + self.state_badge = tk.Label( + status, + text="空闲", + bg="#E8EEF5", + fg="#425466", + font=("Microsoft YaHei UI", 10, "bold"), + padx=13, + pady=6, + ) + ttk.Label(status, text="运行状态", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=6) + self.state_badge.grid(row=0, column=1, sticky="e", pady=6) + + self.error_var = tk.StringVar(value="0 · 无错误") + self.direction_status_var = tk.StringVar(value=DIRECTION_TEXT[0]) + self.current_pulses_var = tk.StringVar(value="0") + self.current_frequency_var = tk.StringVar(value="0 Hz") + self.actual_frequency_var = tk.StringVar(value="0 Hz") + self.running_segment_var = tk.StringVar(value="未运行") + self.cumulative_position_var = tk.StringVar(value="0") + self.total_pulses_var = tk.StringVar(value="0") + status_rows = [ + ("错误码", self.error_var), + ("方向", self.direction_status_var), + ("已发送脉冲", self.current_pulses_var), + ("当前设定频率", self.current_frequency_var), + ("运行中修改后的实际频率", self.actual_frequency_var), + ("当前运行段", self.running_segment_var), + ("当前Y点累计位置", self.cumulative_position_var), + ("总脉冲数(不计方向)", self.total_pulses_var), + ] + for index, (caption, variable) in enumerate(status_rows, start=1): + ttk.Label(status, text=caption, style="Card.TLabel").grid(row=index, column=0, sticky="w", pady=7) + ttk.Label( + status, + textvariable=variable, + style="Card.TLabel", + foreground="#183B56", + font=("Microsoft YaHei UI", 10, "bold"), + ).grid(row=index, column=1, sticky="e", pady=7) + + ttk.Label(status, text="本次运动进度", style="Card.TLabel").grid( + row=9, column=0, columnspan=2, sticky="w", pady=(14, 6) + ) + self.progress_var = tk.DoubleVar(value=0.0) + self.progress = ttk.Progressbar( + status, + maximum=100.0, + variable=self.progress_var, + style="Horizontal.TProgressbar", + ) + self.progress.grid(row=10, column=0, columnspan=2, sticky="ew") + self.pulse_fraction_var = tk.StringVar(value="0 / 0") + ttk.Label( + status, + textvariable=self.pulse_fraction_var, + style="Card.TLabel", + foreground="#6E7F91", + ).grid(row=11, column=0, columnspan=2, sticky="e", pady=(4, 10)) + + self.test_result_label = tk.Label( + status, + text="等待测试", + bg="#EDF1F5", + fg="#526170", + anchor="w", + justify="left", + wraplength=430, + padx=11, + pady=10, + font=("Microsoft YaHei UI", 9), + ) + self.test_result_label.grid(row=12, column=0, columnspan=2, sticky="ew", pady=(3, 11)) + + status_actions = ttk.Frame(status, style="Card.TFrame") + status_actions.grid(row=13, column=0, columnspan=2, sticky="ew") + status_actions.columnconfigure(0, weight=1) + status_actions.columnconfigure(1, weight=1) + status_actions.columnconfigure(2, weight=1) + self.read_button = ttk.Button( + status_actions, + text="立即读取状态", + style="Secondary.TButton", + command=self.request_status, + ) + self.read_button.grid(row=0, column=0, sticky="ew", padx=(0, 4)) + self.clear_position_button = ttk.Button( + status_actions, + text="清零当前Y点位置", + style="Danger.TButton", + command=self.clear_current_position, + ) + self.clear_position_button.grid(row=0, column=1, sticky="ew", padx=4) + self.clear_total_button = ttk.Button( + status_actions, + text="清零总脉冲数", + style="Danger.TButton", + command=self.clear_total_count, + ) + self.clear_total_button.grid(row=0, column=2, sticky="ew", padx=(4, 0)) + + def _build_multi_page(self) -> None: + """建立1~10段脉冲表;脉冲数符号决定各段运动方向。""" + self.multi_page = ttk.Frame(self.control_content) + self.multi_page.grid(row=1, column=0, sticky="nsew", padx=(0, 8)) + card = ttk.LabelFrame(self.multi_page, text="10段参数配置", padding=12) + card.pack(fill=tk.BOTH, expand=True) + card.columnconfigure(0, weight=1) + + header = ttk.Frame(card, style="Card.TFrame") + header.grid(row=0, column=0, sticky="ew", pady=(0, 12)) + ttk.Label(header, text="脉冲总段数", style="Card.TLabel").pack(side=tk.LEFT) + self.total_segments_var = tk.IntVar(value=3) + ttk.Spinbox(header, from_=1, to=10, width=7, + textvariable=self.total_segments_var).pack(side=tk.LEFT, padx=(8, 24)) + ttk.Label(header, text="起始执行段数", style="Card.TLabel").pack(side=tk.LEFT) + self.start_segment_var = tk.IntVar(value=1) + ttk.Spinbox(header, from_=1, to=10, width=7, + textvariable=self.start_segment_var).pack(side=tk.LEFT, padx=(8, 24)) + self.current_segment_var = tk.StringVar(value="0(未执行)") + ttk.Label(header, text="当前段", style="Card.TLabel").pack(side=tk.LEFT) + ttk.Label(header, textvariable=self.current_segment_var, + style="Card.TLabel", foreground="#15558A").pack(side=tk.LEFT, padx=8) + + table = ttk.Frame(card, style="Card.TFrame") + table.grid(row=1, column=0, sticky="nsew") + headings = ("段", "有符号脉冲数", "频率(Hz)", "跳转段", "等待条件", "条件值(ms/X点)") + for column, text in enumerate(headings): + table.columnconfigure(column, weight=1 if column in (1, 2, 4, 5) else 0) + ttk.Label(table, text=text, style="Card.TLabel", + font=("Microsoft YaHei UI", 9, "bold")).grid( + row=0, column=column, padx=4, pady=4, sticky="ew") + + self.segment_pulse_vars = [] + self.segment_frequency_vars = [] + self.segment_jump_vars = [] + self.segment_wait_vars = [] + self.segment_condition_vars = [] + for index in range(10): + pulses = tk.StringVar(value=str((index + 1) * 100)) + frequency = tk.StringVar(value=str((index + 1) * 100)) + self.segment_pulse_vars.append(pulses) + self.segment_frequency_vars.append(frequency) + jump = tk.IntVar(value=0) + wait = tk.StringVar( + value=WAIT_CONDITION_TEXT[WaitCondition.WAIT_TIME] + ) + condition = tk.StringVar(value="0") + self.segment_jump_vars.append(jump) + self.segment_wait_vars.append(wait) + self.segment_condition_vars.append(condition) + row = index + 1 + ttk.Label(table, text=str(index + 1), style="Card.TLabel").grid( + row=row, column=0, padx=4, pady=3) + ttk.Entry(table, textvariable=pulses, width=11).grid( + row=row, column=1, padx=4, pady=3, sticky="ew") + ttk.Entry(table, textvariable=frequency, width=9).grid( + row=row, column=2, padx=4, pady=3, sticky="ew") + ttk.Spinbox(table, from_=0, to=10, textvariable=jump, width=5).grid( + row=row, column=3, padx=4, pady=3, sticky="ew") + ttk.Combobox(table, textvariable=wait, + values=tuple(WAIT_CONDITION_TEXT.values()), state="readonly", + width=13).grid(row=row, column=4, padx=4, pady=3, sticky="ew") + ttk.Entry(table, textvariable=condition, width=10).grid( + row=row, column=5, padx=4, pady=3, sticky="ew") + + segment_actions = ttk.Frame(card, style="Card.TFrame") + segment_actions.grid(row=2, column=0, sticky="ew", pady=(14, 0)) + segment_actions.columnconfigure(0, weight=1) + segment_actions.columnconfigure(1, weight=1) + segment_actions.columnconfigure(2, weight=1) + segment_actions.columnconfigure(3, weight=1) + self.write_segments_button = ttk.Button( + segment_actions, text="写入段表", style="Secondary.TButton", + command=self.write_segment_table, + ) + self.write_segments_button.grid(row=0, column=0, sticky="ew", padx=(0, 4)) + self.start_segments_button = ttk.Button( + segment_actions, text="启动", style="Primary.TButton", + command=self.start_segment_motion, + ) + self.start_segments_button.grid(row=0, column=1, sticky="ew", padx=4) + self.stop_button = ttk.Button( + segment_actions, text="暂停", style="Danger.TButton", + command=self.stop_motion, + ) + self.stop_button.grid(row=0, column=2, sticky="ew", padx=4) + self.resume_button = ttk.Button( + segment_actions, text="继续", style="Secondary.TButton", + command=self.resume_motion, + ) + self.resume_button.grid(row=0, column=3, sticky="ew", padx=(4, 0)) + + def _build_diagnostic_page(self) -> None: + """构建固件在线诊断结果页。""" + card = ttk.LabelFrame( + self.diagnostic_page, text="PLSR输出信号在线诊断", padding=(18, 14) + ) + card.pack(fill=tk.BOTH, expand=True) + card.columnconfigure(1, weight=1) + + self.diag_overall_var = tk.StringVar(value="未运行诊断") + self.diag_count_var = tk.StringVar(value="未诊断") + self.diag_frequency_var = tk.StringVar(value="未诊断") + self.diag_profile_var = tk.StringVar(value="未诊断") + self.diag_phase_var = tk.StringVar(value="空闲") + self.diag_pulses_var = tk.StringVar(value="0 / 0") + self.diag_frequency_values_var = tk.StringVar(value="0 Hz / 0 Hz") + self.diag_max_error_var = tk.StringVar(value="0 Hz") + self.diag_error_counts_var = tk.StringVar(value="个数0,频率0,曲线0") + + rows = [ + ("综合结果", self.diag_overall_var), + ("脉冲个数", self.diag_count_var), + ("脉冲频率", self.diag_frequency_var), + ("加减速曲线", self.diag_profile_var), + ("当前曲线阶段", self.diag_phase_var), + ("实际/设定脉冲", self.diag_pulses_var), + ("实际/设定频率", self.diag_frequency_values_var), + ("最大频率偏差", self.diag_max_error_var), + ("异常记录", self.diag_error_counts_var), + ] + for row, (caption, variable) in enumerate(rows): + ttk.Label(card, text=caption).grid( + row=row, column=0, sticky="w", padx=(0, 24), pady=7 + ) + ttk.Label(card, textvariable=variable).grid( + row=row, column=1, sticky="w", pady=7 + ) + + actions = ttk.Frame(card) + actions.grid(row=len(rows), column=0, columnspan=2, sticky="ew", pady=(14, 8)) + actions.columnconfigure(0, weight=1) + actions.columnconfigure(1, weight=1) + ttk.Button( + actions, text="立即读取诊断", style="Secondary.TButton", + command=self.request_diagnostic, + ).grid(row=0, column=0, sticky="ew", padx=(0, 6)) + ttk.Button( + actions, text="清除诊断记录", style="Danger.TButton", + command=self.clear_diagnostic, + ).grid(row=0, column=1, sticky="ew", padx=(6, 0)) + + tk.Label( + card, + text=("说明:诊断依据硬件定时器实际分频参数和脉冲完成中断计数," + "可发现内部配置、频率及曲线执行异常;若要诊断端子断线或驱动器漏收," + "还需将输出接回计数输入或使用示波器。"), + bg="#FFF3D8", fg="#865600", anchor="w", justify=tk.LEFT, + padx=10, pady=9, wraplength=820, + ).grid(row=len(rows) + 1, column=0, columnspan=2, sticky="ew", pady=(8, 0)) + + def _build_log_page(self) -> None: + toolbar = ttk.Frame(self.log_page) + toolbar.pack(fill=tk.X, pady=(0, 8)) + ttk.Label( + toolbar, + text="显示应用事件以及实际发送、接收的Modbus RTU十六进制帧。", + foreground="#6E7F91", + ).pack(side=tk.LEFT) + ttk.Button( + toolbar, + text="清空日志", + style="Secondary.TButton", + command=self.clear_log, + ).pack(side=tk.RIGHT) + self.log_text = ScrolledText( + self.log_page, + wrap=tk.WORD, + bg="#101A26", + fg="#D9E5F2", + insertbackground="#FFFFFF", + font=("Consolas", 10), + relief=tk.FLAT, + padx=10, + pady=10, + ) + self.log_text.pack(fill=tk.BOTH, expand=True) + self.log_text.configure(state=tk.DISABLED) + + def refresh_ports(self) -> None: + previous = self.port_var.get() + ports = list_serial_ports() + self.port_combo["values"] = ports + if previous in ports: + self.port_var.set(previous) + elif ports: + self.port_var.set(ports[0]) + else: + self.port_var.set("") + self.log(f"检测到串口:{', '.join(ports) if ports else '无'}") + + def toggle_connection(self) -> None: + if self.client.is_open: + self._disconnect("串口已手动断开。") + return + port = self.port_var.get().strip() + if not port: + messagebox.showwarning("未选择串口", "没有检测到可用串口,请连接设备后刷新。") + return + try: + slave = int(self.slave_var.get()) + baud = int(self.baud_var.get()) + self.client.open(port, baud, slave) + except Exception as exc: + messagebox.showerror("连接失败", str(exc)) + self.log(f"连接失败:{exc}") + return + self._set_connected(True) + self.log(f"已连接 {port},{baud},8-E-1,从站{slave}。") + self.client.read_holding( + int(Register.PULSE_POINT), register_read_count(), "INITIAL_SYSTEM" + ) + + def _disconnect(self, reason: str) -> None: + """关闭串口并统一恢复界面状态。""" + if self.client.is_open: + self.client.close() + self.pending_motion = None + self.pending_segments = None + self.written_segments = None + self.segments_written_ready = False + self.start_after_segment_write = False + # 断开时丢弃已排队的旧事件,避免日志又出现一组过期TX/RX。 + while True: + try: + self.client.events.get_nowait() + except queue.Empty: + break + self.test_active = False + self.monitoring_active = False + self.seen_running = False + self._set_connected(False) + self.log(reason) + + def _set_connected(self, connected: bool) -> None: + self.connection_badge.configure( + text="已连接" if connected else "未连接", + bg="#DDF7EC" if connected else "#FDECEC", + fg="#087A55" if connected else "#A61B1B", + ) + self.connect_button.configure(text="断开连接" if connected else "连接设备") + port_state = tk.DISABLED if connected else "readonly" + self.port_combo.configure(state=port_state) + self.baud_combo.configure(state=port_state) + self.slave_spin.configure(state=tk.DISABLED if connected else tk.NORMAL) + self.refresh_button.configure(state=tk.DISABLED if connected else tk.NORMAL) + control_state = tk.NORMAL if connected else tk.DISABLED + self.stop_button.configure(state=control_state) + self.resume_button.configure(state=control_state) + self.update_frequency_button.configure(state=control_state) + self.save_outputs_button.configure(state=control_state) + self.read_button.configure(state=control_state) + self.clear_position_button.configure(state=control_state) + self.clear_total_button.configure(state=control_state) + self.write_segments_button.configure(state=control_state) + # 未手动写入段表时也允许点击启动;启动流程会先 + # 自动写入当前界面的默认参数,设备确认后再启动。 + self.start_segments_button.configure( + state=(tk.NORMAL if connected else tk.DISABLED) + ) + + def apply_preset(self, log_message: bool = True) -> None: + selected = self.preset_var.get() + preset = next((item for item in PRESETS if item["name"] == selected), PRESETS[0]) + self.pulses_var.set(str(preset["pulses"])) + self.frequency_var.set(preset["frequency"]) + self.runtime_frequency_var.set(preset["frequency"]) + self.acceleration_var.set(preset["acceleration_ms"]) + self.deceleration_var.set(preset["deceleration_ms"]) + self.start_frequency_var.set(preset["start_frequency"]) + self.end_frequency_var.set(preset["end_frequency"]) + self.display_target = preset["pulses"] + if log_message: + self.log(f"已应用预设:{preset['name']}") + + def _selected_output_mode(self) -> OutputMode: + return next( + (mode for mode, text in OUTPUT_MODE_TEXT.items() + if text == self.output_mode_var.get()), + OutputMode.PULSE_DIRECTION, + ) + + def _selected_send_mode(self) -> SendMode: + return next( + (mode for mode, text in SEND_MODE_TEXT.items() + if text == self.send_mode_var.get()), + SendMode.COMPLETE, + ) + + def _selected_direction_logic(self) -> DirectionLogic: + return next( + (logic for logic, text in DIRECTION_LOGIC_TEXT.items() + if text == self.direction_logic_var.get()), + DirectionLogic.POSITIVE, + ) + + def _direction_display_text(self, direction: int) -> str: + if self._selected_output_mode() == OutputMode.AB_QUADRATURE: + return ( + "正向(A相超前B相90°)" + if direction == 0 else "反向(B相超前A相90°)" + ) + return DIRECTION_TEXT[1 if direction else 0] + + def _on_output_mode_changed(self, _event=None) -> None: + if hasattr(self, "direction_status_var"): + direction = 1 if self.direction_var.get() == DIRECTION_TEXT[1] else 0 + self.direction_status_var.set(self._direction_display_text(direction)) + + def _on_run_mode_changed(self, _event=None) -> None: + """根据模式切换目标含义;绝对模式的实际方向由固件自动计算。""" + absolute = self.run_mode_var.get() == MODE_TEXT[RunMode.ABSOLUTE] + if hasattr(self, "target_caption_var"): + self.target_caption_var.set("目标绝对位置" if absolute else "目标脉冲数") + + def _read_motion_inputs(self) -> Dict[str, int]: + try: + pulses = int(self.pulses_var.get().strip()) + frequency = int(self.frequency_var.get()) + acceleration_ms = int(self.acceleration_var.get()) + deceleration_ms = int(self.deceleration_var.get()) + maximum_frequency = int(self.maximum_frequency_var.get()) + start_frequency = int(self.start_frequency_var.get()) + end_frequency = int(self.end_frequency_var.get()) + except ValueError as exc: + raise ValueError("脉冲数、三项速度和加减速时间必须是整数") from exc + run_mode = ( + RunMode.ABSOLUTE + if self.run_mode_var.get() == MODE_TEXT[RunMode.ABSOLUTE] + else RunMode.RELATIVE + ) + direction = 1 if self.direction_var.get() == DIRECTION_TEXT[1] else 0 + if run_mode == RunMode.RELATIVE: + if not -0x80000000 <= pulses <= 0x7FFFFFFF: + raise ValueError("相对脉冲数必须在-2147483648到2147483647之间") + # 负数按需求直接表示反向;正数仍允许通过“运动方向”选择反向, + # 兼容原有上位机操作。INT32_MIN只能表示为反向2147483648脉冲。 + if pulses < 0: + direction = 1 + magnitude = abs(pulses) + command_pulses = -magnitude if direction else magnitude + target_u32 = signed_to_u32(command_pulses) + else: + target_u32 = signed_to_u32(pulses) + if not MIN_FREQUENCY_HZ <= maximum_frequency <= MAX_FREQUENCY_HZ: + raise ValueError("最高速度必须在1到100000Hz之间") + if not MIN_FREQUENCY_HZ <= frequency <= maximum_frequency: + raise ValueError(f"默认速度必须在1到最高速度{maximum_frequency}Hz之间") + if not 0 <= acceleration_ms <= MAX_RAMP_TIME_MS: + raise ValueError(f"加速时间必须在0到{MAX_RAMP_TIME_MS}ms之间") + if not 0 <= deceleration_ms <= MAX_RAMP_TIME_MS: + raise ValueError(f"减速时间必须在0到{MAX_RAMP_TIME_MS}ms之间") + if not 0 <= start_frequency <= frequency: + raise ValueError("起始速度必须在0Hz到默认速度之间") + if not 0 <= end_frequency <= frequency: + raise ValueError("终止速度必须在0Hz到默认速度之间") + profile_type = next( + (item for item, text in PROFILE_TEXT.items() if text == self.profile_var.get()), + ProfileType.LINEAR, + ) + return { + "pulses": pulses, + "target_u32": target_u32, + "frequency": frequency, + "direction": direction, + "run_mode": int(run_mode), + "profile_type": int(profile_type), + "acceleration_ms": acceleration_ms, + "deceleration_ms": deceleration_ms, + "start_frequency": start_frequency, + "end_frequency": end_frequency, + "maximum_frequency": maximum_frequency, + } + + def start_motion(self) -> None: + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + try: + params = self._read_motion_inputs() + except ValueError as exc: + messagebox.showwarning("参数错误", str(exc)) + return + if params["run_mode"] == int(RunMode.ABSOLUTE): + current_position = int(self.cumulative_position_var.get()) + self.expected_pulses = abs(params["pulses"] - current_position) + else: + self.expected_pulses = abs(params["pulses"]) + self.display_target = self.expected_pulses + self.pending_motion = params + self.expected_constant_frequency = ( + params["frequency"] + if params["start_frequency"] == params["frequency"] + and params["end_frequency"] == params["frequency"] + and params["acceleration_ms"] == 0 + and params["deceleration_ms"] == 0 + else 0 + ) + pulse_point = point_id_from_label(PULSE_POINTS, self.pulse_point_var.get()) + direction_point = point_id_from_label(SECONDARY_POINTS, self.secondary_point_var.get()) + profile_values = [pulse_point, direction_point, 0, 0, + int(self._selected_send_mode()), 20, + int(self._selected_direction_logic()), + params["profile_type"], params["run_mode"], 1, 1] + profile_values += split_u32(params["frequency"]) + profile_values += split_u32(params["start_frequency"]) + profile_values += [int(self._selected_output_mode())] + self.client.write_multiple( + int(Register.PULSE_POINT), profile_values, "SET_SYSTEM_A" + ) + self.test_active = True + self.multi_test_active = False + # 参数写入链完成并收到START响应后再开始周期查询,避免查询帧插队。 + self.monitoring_active = False + self.seen_running = False + self.test_started_at = time.monotonic() + if params["run_mode"] == int(RunMode.ABSOLUTE): + actual_direction = 1 if params["pulses"] < current_position else 0 + else: + actual_direction = params["direction"] + self.direction_status_var.set(self._direction_display_text(actual_direction)) + self._set_test_result("启动命令发送中,等待设备确认。", "running") + self.log(f"提交单段启动:目标{params['pulses']},频率{params['frequency']}Hz。") + + def write_segment_table(self, start_after_write: bool = False) -> None: + """校验并写入公共曲线参数、段数、起始段和段表。 + + start_after_write为True时,用于“未写段表直接启动”的自动 + 流程:设备完成段表保存后再发送启动命令。 + """ + self.start_after_segment_write = False + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + try: + total_segments = int(self.total_segments_var.get()) + start_segment = int(self.start_segment_var.get()) + start_frequency = int(self.start_frequency_var.get()) + end_frequency = int(self.end_frequency_var.get()) + acceleration_ms = int(self.acceleration_var.get()) + deceleration_ms = int(self.deceleration_var.get()) + default_frequency = int(self.frequency_var.get()) + maximum_frequency = int(self.maximum_frequency_var.get()) + if not 1 <= total_segments <= 10: + raise ValueError("脉冲总段数必须在1到10之间") + if not 1 <= start_segment <= total_segments: + raise ValueError("起始执行段数必须在1到脉冲总段数之间") + if not 0 <= acceleration_ms <= MAX_RAMP_TIME_MS: + raise ValueError(f"加速时间必须在0到{MAX_RAMP_TIME_MS}ms之间") + if not 0 <= deceleration_ms <= MAX_RAMP_TIME_MS: + raise ValueError(f"减速时间必须在0到{MAX_RAMP_TIME_MS}ms之间") + if not MIN_FREQUENCY_HZ <= maximum_frequency <= MAX_FREQUENCY_HZ: + raise ValueError("最高速度必须在1到100000Hz之间") + if not MIN_FREQUENCY_HZ <= default_frequency <= maximum_frequency: + raise ValueError(f"默认速度必须在1到最高速度{maximum_frequency}Hz之间") + if not 0 <= start_frequency <= maximum_frequency: + raise ValueError(f"起始速度必须在0到最高速度{maximum_frequency}Hz之间") + if not 0 <= end_frequency <= maximum_frequency: + raise ValueError(f"终止速度必须在0到最高速度{maximum_frequency}Hz之间") + segments = [] + for index in range(total_segments): + pulses = int(self.segment_pulse_vars[index].get().strip()) + frequency = int(self.segment_frequency_vars[index].get().strip()) + jump = int(self.segment_jump_vars[index].get()) + condition_value = int(self.segment_condition_vars[index].get().strip()) + wait_condition = next( + (item for item, text in WAIT_CONDITION_TEXT.items() + if text == self.segment_wait_vars[index].get()), WaitCondition.WAIT_TIME) + if not -0x80000000 <= pulses <= 0x7FFFFFFF: + raise ValueError(f"第{index + 1}段脉冲数必须在-2147483648到2147483647之间") + if not 0 <= frequency <= maximum_frequency: + raise ValueError( + f"第{index + 1}段频率必须为0或1到最高速度{maximum_frequency}Hz" + ) + effective_frequency = default_frequency if frequency == 0 else frequency + if start_frequency > effective_frequency or end_frequency > effective_frequency: + raise ValueError(f"第{index + 1}段实际目标速度不能低于起始或终止速度") + if not 0 <= jump <= total_segments: + raise ValueError(f"第{index + 1}段跳转段必须是0到{total_segments}") + if not 0 <= condition_value <= 0xFFFFFFFF: + raise ValueError(f"第{index + 1}段条件值超出32位无符号范围") + if wait_condition in (WaitCondition.WAIT_SIGNAL, + WaitCondition.EXT_SIGNAL, + WaitCondition.EXT_OR_COMPLETE) and condition_value not in INPUT_POINTS: + raise ValueError(f"第{index + 1}段信号X点无效") + segments.append((pulses, frequency, jump, + int(wait_condition), condition_value)) + except (ValueError, tk.TclError) as exc: + messagebox.showwarning("多段参数错误", str(exc)) + return + execution_total, execution_has_cycle = calculate_execution_total( + segments, start_segment + ) + + profile_type = next( + (item for item, text in PROFILE_TEXT.items() if text == self.profile_var.get()), + ProfileType.LINEAR, + ) + direction = 1 if segments[start_segment - 1][0] < 0 else 0 + self.pending_segments = { + "total": total_segments, "start": start_segment, + "segments": segments, "direction": direction, + "profile_type": int(profile_type), "acceleration_ms": acceleration_ms, + "deceleration_ms": deceleration_ms, "start_frequency": start_frequency, + "end_frequency": end_frequency, "default_frequency": default_frequency, + "maximum_frequency": maximum_frequency, + "execution_total": execution_total, + "execution_has_cycle": execution_has_cycle, + } + wait_points = { + item[4] for item in segments + if item[3] == int(WaitCondition.WAIT_SIGNAL) + } + ext_points = { + item[4] for item in segments + if item[3] in (int(WaitCondition.EXT_SIGNAL), + int(WaitCondition.EXT_OR_COMPLETE)) + } + if any(point not in INPUT_POINTS for point in wait_points | ext_points): + messagebox.showwarning("信号点无效", "参数表仅允许WAIT/EXT选择X4或X5。") + self.pending_segments = None + return + if len(wait_points) > 1 or len(ext_points) > 1: + messagebox.showwarning( + "全局信号配置冲突", + "参数表中WAIT信号和EXT信号分别是全局配置,同类条件必须统一使用X4或X5。", + ) + self.pending_segments = None + return + wait_select = next(iter(wait_points), 4) - 4 + ext_select = next(iter(ext_points), 4) - 4 + self.wait_signal_select = wait_select + self.ext_signal_select = ext_select + pulse_point = point_id_from_label(PULSE_POINTS, self.pulse_point_var.get()) + direction_point = point_id_from_label(SECONDARY_POINTS, self.secondary_point_var.get()) + profile_values = [pulse_point, direction_point, wait_select, ext_select, + int(self._selected_send_mode()), 20, + int(self._selected_direction_logic()), + int(profile_type), int(RunMode.RELATIVE), + total_segments, start_segment] + # 0x100B保存公共默认速度;各段自己的运行速度仍来自段表。 + profile_values += split_u32(default_frequency) + profile_values += split_u32(start_frequency) + profile_values += [int(self._selected_output_mode())] + self.start_after_segment_write = bool(start_after_write) + self.client.write_multiple(int(Register.PULSE_POINT), profile_values, + "SET_SEGMENT_SYSTEM_A") + self.written_segments = None + self.segments_written_ready = False + self.start_segments_button.configure(state=tk.DISABLED) + self.test_active = False + self.multi_test_active = False + self.monitoring_active = False + self._set_test_result("正在写入多段参数和段表,不会自动启动。", "running") + if execution_has_cycle: + self.log(f"提交段表写入:共{total_segments}段,从第{start_segment}段开始," + "检测到循环跳转。") + else: + self.log(f"提交段表写入:共{total_segments}段,从第{start_segment}段开始," + f"预计输出{execution_total}个脉冲。") + + def start_segment_motion(self) -> None: + """启动多段运动;未写段表时先自动写入当前默认参数。""" + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + if self.last_state in ( + int(State.RUNNING), int(State.WAITING), int(State.PAUSED) + ): + messagebox.showwarning("当前不可启动", "设备正在运行、等待或暂停,请先结束当前运动。") + return + if not self.segments_written_ready or self.written_segments is None: + self.log("段表尚未写入,正在按当前默认参数自动写入后启动。") + self.write_segment_table(start_after_write=True) + return + + params = self.written_segments + execution_has_cycle = bool(params["execution_has_cycle"]) + execution_total = int(params["execution_total"]) + self.expected_pulses = 0 if execution_has_cycle else execution_total + self.display_target = 0 if execution_has_cycle else execution_total + self.test_active = True + self.multi_test_active = True + self.expected_constant_frequency = 0 + self.monitoring_active = False + self.seen_running = False + self.test_started_at = time.monotonic() + self.direction_status_var.set( + self._direction_display_text(int(params["direction"])) + ) + self._set_test_result("多段启动命令发送中,等待设备确认。", "running") + self.client.write_single( + int(Register.CONTROL), int(Command.START_SEGMENTS), "START_SEGMENTS" + ) + self.log( + f"提交多段启动:共{params['total']}段,从第{params['start']}段开始。" + ) + + def stop_motion(self) -> None: + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + self.client.write_single(int(Register.CONTROL), int(Command.STOP), "STOP") + self.test_active = False + self.monitoring_active = False + self._set_test_result("暂停命令发送中。", "running") + self.log("提交暂停命令,将保留当前脉冲位置。") + + def resume_motion(self) -> None: + """继续STM32中已暂停的运动,不重写参数也不清零本次计数。""" + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + self.client.write_single( + int(Register.CONTROL), int(Command.RESUME), "RESUME" + ) + self.expected_pulses = self.display_target + self.test_active = True + self.monitoring_active = True + self.seen_running = False + self.test_started_at = time.monotonic() + self._set_test_result("继续命令发送中,等待设备确认。", "running") + self.log("提交继续命令,将从暂停的脉冲位置继续。") + + def update_frequency(self) -> None: + """写入独立的瞬时目标频率,不改变默认速度、段表或当前脉冲计数。""" + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + if self.last_state not in (int(State.RUNNING), int(State.PAUSED)): + messagebox.showwarning("当前不可改频", "只有运行中或暂停状态可以修改频率。") + return + try: + frequency = int(self.runtime_frequency_var.get()) + maximum_frequency = int(self.maximum_frequency_var.get()) + start_frequency = int(self.start_frequency_var.get()) + end_frequency = int(self.end_frequency_var.get()) + except (ValueError, tk.TclError): + messagebox.showwarning("参数错误", "频率必须是整数。") + return + if not MIN_FREQUENCY_HZ <= maximum_frequency <= MAX_FREQUENCY_HZ: + messagebox.showwarning("参数错误", "最高速度必须在1到100000Hz之间。") + return + if not MIN_FREQUENCY_HZ <= frequency <= maximum_frequency: + messagebox.showwarning( + "参数错误", f"频率必须在1到最高速度{maximum_frequency}Hz之间。" + ) + return + if frequency < start_frequency or frequency < end_frequency: + messagebox.showwarning( + "参数错误", "运行中目标频率不能低于起始速度或终止速度。" + ) + return + + self.pending_frequency = frequency + self.client.write_multiple( + int(Register.RUNTIME_FREQ_LO), + split_u32(frequency), + "SET_RUNTIME_FREQ_VALUE", + ) + self._set_test_result(f"正在将频率修改为{frequency}Hz。", "running") + self.log(f"提交动态改频:{frequency}Hz,不清零当前脉冲计数。") + + def save_output_mapping(self) -> None: + """一次写入12项公共参数,并请求设备保存到Flash。""" + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + if self.last_state in ( + int(State.RUNNING), int(State.PAUSED), int(State.WAITING) + ): + messagebox.showwarning( + "当前不可配置", + "请等待脉冲完成;暂停中的任务需要先完成或重新启动后再保存点位。", + ) + return + try: + pulse_point = point_id_from_label(PULSE_POINTS, self.pulse_point_var.get()) + direction_point = point_id_from_label( + SECONDARY_POINTS, self.secondary_point_var.get() + ) + default_frequency = int(self.frequency_var.get()) + maximum_frequency = int(self.maximum_frequency_var.get()) + start_frequency = int(self.start_frequency_var.get()) + end_frequency = int(self.end_frequency_var.get()) + acceleration_ms = int(self.acceleration_var.get()) + deceleration_ms = int(self.deceleration_var.get()) + total_segments = int(self.total_segments_var.get()) + start_segment = int(self.start_segment_var.get()) + if not MIN_FREQUENCY_HZ <= maximum_frequency <= MAX_FREQUENCY_HZ: + raise ValueError("最高速度必须在1到100000Hz之间") + if not MIN_FREQUENCY_HZ <= default_frequency <= maximum_frequency: + raise ValueError("默认速度必须在1Hz到最高速度之间") + if not 0 <= start_frequency <= maximum_frequency: + raise ValueError("起始速度必须在0Hz到最高速度之间") + if not 0 <= end_frequency <= maximum_frequency: + raise ValueError("终止速度必须在0Hz到最高速度之间") + if not 0 <= acceleration_ms <= MAX_RAMP_TIME_MS: + raise ValueError(f"默认加速时间必须在0到{MAX_RAMP_TIME_MS}ms之间") + if not 0 <= deceleration_ms <= MAX_RAMP_TIME_MS: + raise ValueError(f"默认减速时间必须在0到{MAX_RAMP_TIME_MS}ms之间") + if not 1 <= total_segments <= 10: + raise ValueError("脉冲总段数必须在1到10之间") + if not 1 <= start_segment <= total_segments: + raise ValueError("起始执行段数必须在有效段范围内") + for index in range(total_segments): + segment_frequency = int(self.segment_frequency_vars[index].get()) + if not 0 <= segment_frequency <= maximum_frequency: + raise ValueError(f"第{index + 1}段频率必须为0或不超过公共最高速度") + effective_frequency = ( + default_frequency if segment_frequency == 0 else segment_frequency + ) + if start_frequency > effective_frequency or end_frequency > effective_frequency: + raise ValueError(f"第{index + 1}段实际目标速度不能低于公共起始或终止速度") + except (ValueError, tk.TclError) as exc: + messagebox.showwarning("公共参数错误", str(exc)) + return + + profile_type = next( + (item for item, text in PROFILE_TEXT.items() if text == self.profile_var.get()), + ProfileType.LINEAR, + ) + values = [ + pulse_point, + direction_point, + int(getattr(self, "wait_signal_select", 0)), + int(getattr(self, "ext_signal_select", 0)), + int(self._selected_send_mode()), + 20, + int(self._selected_direction_logic()), + int(profile_type), + int(RunMode.RELATIVE), + total_segments, + start_segment, + ] + values += split_u32(default_frequency) + values += split_u32(start_frequency) + values += [int(self._selected_output_mode())] + values += split_u32(end_frequency) + values += [acceleration_ms, deceleration_ms] + values += split_u32(maximum_frequency) + self.client.write_multiple( + int(Register.PULSE_POINT), + values, + "SET_PUBLIC_PARAMETERS", + ) + self._set_test_result( + "正在应用并保存全部公共参数。", + "running", + ) + self.log( + f"提交公共参数:{PROFILE_TEXT[profile_type]}," + f"默认{default_frequency}Hz,最高{maximum_frequency}Hz," + f"起始{start_frequency}Hz,终止{end_frequency}Hz," + f"加速{acceleration_ms}ms,减速{deceleration_ms}ms,请求掉电保存。" + ) + + def clear_current_position(self) -> None: + """清零当前所选脉冲Y点的累计位置。""" + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + if not messagebox.askyesno( + "确认清零", "确定将当前脉冲Y点的累计位置清零吗?其他脉冲Y点不受影响。" + ): + return + self.client.write_single( + int(Register.CONTROL), int(Command.CLEAR_POSITION), "CLEAR_POSITION" + ) + self.log("提交当前脉冲Y点累计位置清零命令。") + + def clear_total_count(self) -> None: + """清零不计方向和输出点的全局总脉冲数。""" + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + if not messagebox.askyesno( + "确认清零", "确定将掉电保持的总脉冲数清零吗?Y0~Y3累计位置不受影响。" + ): + return + self.client.write_single( + int(Register.CONTROL), int(Command.CLEAR_TOTAL), "CLEAR_TOTAL" + ) + self.log("提交总脉冲数清零命令,等待设备确认。") + + def request_diagnostic(self) -> None: + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + if not self.client.is_busy: + self.client.read_holding( + int(Register.CUMULATIVE_POSITION_LO), + STATUS_REGISTER_COUNT, + "DIAGNOSTIC_STATUS", + ) + + def clear_diagnostic(self) -> None: + if not self.client.is_open: + messagebox.showwarning("尚未连接", "请先连接串口设备。") + return + self.client.write_single( + int(Register.CONTROL), + int(Command.CLEAR_DIAGNOSTIC), + "CLEAR_DIAGNOSTIC", + ) + self.log("已提交诊断记录清零命令。") + + def request_status(self) -> None: + if self.client.is_open and not self.client.is_busy: + # 状态区保持轮询;AB实际方向在启动时或多段切换时同步到界面。 + self.client.read_holding( + int(Register.CUMULATIVE_POSITION_LO), + STATUS_REGISTER_COUNT, + "POLL", + ) + + def _request_public_save_status(self) -> None: + """稍作延时后读取错误码,确认Flash任务已完成。""" + if self.closing: + return + if not self.client.is_open: + message = "公共参数已写入,但连接已断开,无法确认Flash保存结果。" + self._set_test_result(message, "fail") + self.log(message) + messagebox.showerror("无法确认保存结果", message) + return + if self.client.is_busy: + self.root.after(50, self._request_public_save_status) + return + self.client.read_holding( + int(Register.CUMULATIVE_POSITION_LO), + STATUS_REGISTER_COUNT, + "PUBLIC_SAVE_STATUS", + ) + + def _request_segment_save_status(self) -> None: + """等待设备完成段表Flash保存,再判定写入成功。""" + if self.closing: + return + if not self.client.is_open: + self._abort_segment_table_write( + "段表已写入寄存器,但连接已断开,无法确认Flash保存结果。" + ) + return + if self.client.is_busy: + self.root.after(50, self._request_segment_save_status) + return + self.client.read_holding( + int(Register.CUMULATIVE_POSITION_LO), + STATUS_REGISTER_COUNT, + "SEGMENT_SAVE_STATUS", + ) + + def _poll_status(self) -> None: + if self.closing: + return + if self.monitoring_active: + self.request_status() + self.root.after(self.POLL_INTERVAL_MS, self._poll_status) + + def _process_client_events(self) -> None: + if self.closing: + return + while True: + try: + event = self.client.events.get_nowait() + except queue.Empty: + break + kind = event[0] + if kind == "tx": + self.log(f"TX {event[1].hex(' ').upper()}") + elif kind == "rx": + self.log(f"RX {event[1].hex(' ').upper()}") + elif kind == "read": + try: + self._handle_read(event[1], event[2], event[3]) + except (IndexError, ValueError) as exc: + # 状态回读异常不能终止Tkinter事件循环,否则后续启动写入 + # 的确认事件也不会再被处理,界面会一直停在“等待确认”。 + self.log(f"寄存器数据解析失败:{exc}") + self._set_test_result(f"设备回读数据不完整:{exc}", "fail") + self.test_active = False + self.monitoring_active = False + elif kind == "write": + self._handle_write(event[1], event[2], event[3], event[4]) + elif kind == "retry": + _, context, attempt = event + self.log(f"{context}未收到响应,正在进行第{attempt}次发送。") + elif kind == "exception": + _, function, code, context = event + message = f"Modbus异常:功能码0x{function:02X},异常码0x{code:02X},操作{context}。" + self.log(message) + self._set_test_result(message, "fail") + if ((isinstance(context, str) and + context.startswith("SET_SEGMENT_")) or + context in ("SAVE_SEGMENT_CONFIGURATION", + "SEGMENT_SAVE_STATUS")): + self._abort_segment_table_write(message) + if context in ( + "SET_PUBLIC_PARAMETERS", + "SAVE_PUBLIC", + "PUBLIC_CONFIG_STATUS", + "PUBLIC_SAVE_STATUS", + ): + messagebox.showerror("公共参数保存失败", message) + self.test_active = False + self.monitoring_active = False + elif kind == "error": + _, context, message = event + text = f"{context}:{message}" if context else message + self.log(f"通信错误:{text}") + if ((isinstance(context, str) and + context.startswith("SET_SEGMENT_")) or + context in ("SAVE_SEGMENT_CONFIGURATION", + "SEGMENT_SAVE_STATUS")): + self._abort_segment_table_write(text) + if context in ( + "SET_PUBLIC_PARAMETERS", + "SAVE_PUBLIC", + "PUBLIC_CONFIG_STATUS", + "PUBLIC_SAVE_STATUS", + ): + messagebox.showerror("公共参数保存失败", text) + if self.test_active: + self._set_test_result(f"测试中断:{text}", "fail") + self.test_active = False + self.monitoring_active = False + self.root.after(self.EVENT_INTERVAL_MS, self._process_client_events) + + def _handle_read(self, address: int, values: List[int], context: str) -> None: + if address == int(Register.CUMULATIVE_POSITION_LO) and len(values) >= 11: + status = parse_status(values) + self._update_status(status) + if context == "INITIAL_STATUS": + self._set_test_result("设备参数和状态读取完成。", "neutral") + self.log("设备初始化同步完成,可以写入参数并启动。") + elif context == "PUBLIC_SAVE_STATUS": + error_code = status["error"] + if error_code == int(Error.NONE): + message = "12项公共参数已成功保存到设备Flash。" + self._set_test_result(message, "pass") + self.log(message) + messagebox.showinfo("保存成功", message) + else: + message = ( + f"公共参数保存失败:错误码{error_code}," + f"{error_text(error_code)}。" + ) + self._set_test_result(message, "fail") + self.log(message) + messagebox.showerror("保存失败", message) + elif context == "SEGMENT_SAVE_STATUS": + error_code = status["error"] + if error_code == int(Error.NONE): + self._finish_segment_table_write() + else: + self._abort_segment_table_write( + f"段表保存失败:错误码{error_code}," + f"{error_text(error_code)}。" + ) + return + if address == int(Register.PULSE_POINT) and len(values) >= 22: + pulse_point = values[0] + direction_point = values[1] + self.wait_signal_select = values[2] if values[2] in (0, 1) else 0 + self.ext_signal_select = values[3] if values[3] in (0, 1) else 0 + if pulse_point in PULSE_POINTS: + self.pulse_point_var.set(PULSE_POINTS[pulse_point]) + if direction_point in SECONDARY_POINTS: + self.secondary_point_var.set(SECONDARY_POINTS[direction_point]) + if values[4] in [int(item) for item in SendMode]: + self.send_mode_var.set(SEND_MODE_TEXT[SendMode(values[4])]) + if values[6] in [int(item) for item in DirectionLogic]: + self.direction_logic_var.set( + DIRECTION_LOGIC_TEXT[DirectionLogic(values[6])] + ) + self.profile_var.set(PROFILE_TEXT.get(ProfileType(values[7]), PROFILE_TEXT[ProfileType.LINEAR])) + self.run_mode_var.set(MODE_TEXT[RunMode.ABSOLUTE if values[8] else RunMode.RELATIVE]) + self.total_segments_var.set(values[9]) + self.start_segment_var.set(values[10]) + self.frequency_var.set(join_u32(values[11], values[12])) + self.start_frequency_var.set(join_u32(values[13], values[14])) + output_mode = values[15] + if output_mode in [int(item) for item in OutputMode]: + self.output_mode_var.set(OUTPUT_MODE_TEXT[OutputMode(output_mode)]) + self._on_output_mode_changed() + self.end_frequency_var.set(join_u32(values[16], values[17])) + self.acceleration_var.set(values[18]) + self.deceleration_var.set(values[19]) + self.maximum_frequency_var.set(join_u32(values[20], values[21])) + if context == "INITIAL_SYSTEM": + self.client.read_holding( + int(Register.CUMULATIVE_POSITION_LO), + STATUS_REGISTER_COUNT, + "INITIAL_STATUS", + ) + elif context == "PUBLIC_CONFIG_STATUS": + self.log("公共参数回读完成,正在确认Flash保存结果。") + self.root.after(200, self._request_public_save_status) + return + if address == int(Register.END_FREQ_LO) and len(values) >= 4: + self.end_frequency_var.set(join_u32(values[0], values[1])) + self.acceleration_var.set(values[2]) + self.deceleration_var.set(values[3]) + if context == "INITIAL_RAMP": + self.client.read_holding( + int(Register.CUMULATIVE_POSITION_LO), STATUS_REGISTER_COUNT, "INITIAL_STATUS" + ) + return + if False and address == int(Register.COMMAND) and len(values) >= 28: + run_mode = RunMode.ABSOLUTE if values[18] == 1 else RunMode.RELATIVE + target_raw = join_u32(values[2], values[3]) + target = join_i32(values[2], values[3]) if run_mode == RunMode.ABSOLUTE else target_raw + frequency = join_u32(values[4], values[5]) + self.pulses_var.set(str(target)) + self.run_mode_var.set(MODE_TEXT[run_mode]) + self._on_run_mode_changed() + self.frequency_var.set(max(MIN_FREQUENCY_HZ, min(frequency, MAX_FREQUENCY_HZ))) + profile_value = values[19] + if profile_value in [int(item) for item in ProfileType]: + self.profile_var.set(PROFILE_TEXT[ProfileType(profile_value)]) + self.acceleration_var.set(join_u32(values[20], values[21])) + self.deceleration_var.set(join_u32(values[22], values[23])) + self.start_frequency_var.set(join_u32(values[24], values[25])) + self.end_frequency_var.set(join_u32(values[26], values[27])) + self.direction_var.set(DIRECTION_TEXT[1 if values[1] else 0]) + self.direction_status_var.set(self._direction_display_text(1 if values[1] else 0)) + current_position = join_i32(values[12], values[13]) + self.display_target = ( + abs(target - current_position) + if run_mode == RunMode.ABSOLUTE + else target + ) + self._update_status(parse_status(values[6:18])) + pulse_point = values[14] + direction_point = values[15] + if pulse_point in PULSE_POINTS: + self.pulse_point_var.set(PULSE_POINTS[pulse_point]) + if direction_point in SECONDARY_POINTS: + self.secondary_point_var.set(SECONDARY_POINTS[direction_point]) + if len(values) >= register_read_count(Register.COMMAND): + self._load_segment_registers(values, int(Register.COMMAND)) + if context == "OUTPUT_CONFIG_STATUS": + if values[7] == int(Error.NONE): + self._set_test_result( + f"输出映射已保存:第一端子Y{pulse_point},第二端子Y{direction_point + 12}。", + "pass", + ) + self.log("设备已确认映射生效并写入Flash;断电重启后会自动恢复。") + else: + self._set_test_result( + f"模块映射保存失败:{error_text(values[7])}。", "fail" + ) + else: + self.log( + f"已同步设备参数:第一端子Y{pulse_point},第二端子Y{direction_point + 12}," + f"{self.profile_var.get()},加速{self.acceleration_var.get()}ms," + f"减速{self.deceleration_var.get()}ms,起始{self.start_frequency_var.get()}Hz," + f"默认{self.frequency_var.get()}Hz,终止{self.end_frequency_var.get()}Hz。" + ) + elif address == int(Register.DIRECTION) and len(values) >= 17: + actual_direction = 1 if values[0] else 0 + self.direction_status_var.set(self._direction_display_text(actual_direction)) + self._update_status(parse_status(values[5:17])) + if len(values) >= register_read_count(Register.DIRECTION): + self._load_segment_registers(values, int(Register.DIRECTION)) + if context == "STOP_STATUS": + self.log("已回读一次暂停位置和实际方向,后续不再周期查询。") + elif context == "FREQ_STATUS": + self.log("已回读动态改频后的状态和实际方向。") + elif address == int(Register.STATE) and len(values) >= 12: + self._update_status(parse_status(values)) + if context == "STOP_STATUS": + self.log("已回读一次暂停位置,后续不再周期查询。") + elif context == "FREQ_STATUS": + self.log("已回读动态改频后的状态。") + elif address == int(Register.CUMULATIVE_POSITION_HI) and len(values) >= 2: + position = join_i32(values[0], values[1]) + self.cumulative_position_var.set(str(position)) + self.log(f"当前脉冲Y点累计位置已更新为 {position}。") + elif address == int(Register.TOTAL_PULSES_LO) and len(values) >= 2: + total = join_u32(values[0], values[1]) + self.total_pulses_var.set(str(total)) + self.log(f"总脉冲数已更新为 {total}。") + _ = context + + def _load_segment_registers(self, values: List[int], base_address: int) -> None: + """把一次连续回读中的多段配置和当前段进度同步到界面。""" + def at(address: int) -> int: + index = address - base_address + if index < 0 or index >= len(values): + raise ValueError( + f"寄存器回读不完整:需要0x{address:04X}," + f"实际范围0x{base_address:04X}~" + f"0x{base_address + len(values) - 1:04X}" + ) + return values[index] + + total = at(int(Register.TOTAL_SEGMENTS)) + start = at(int(Register.START_SEGMENT)) + current = at(int(Register.CURRENT_SEGMENT)) + if 1 <= total <= 10: + self.total_segments_var.set(total) + if 1 <= start <= 10: + self.start_segment_var.set(start) + self.current_segment_var.set(str(current) if current else "0(未执行)") + table_base = int(Register.SEGMENT_TABLE_BASE) + for index in range(10): + register = table_base + index * 4 + pulses = join_i32(at(register), at(register + 1)) + frequency = join_u32(at(register + 2), at(register + 3)) + self.segment_pulse_vars[index].set(str(pulses)) + self.segment_frequency_vars[index].set(str(frequency)) + meta = int(Register.SEGMENT_META_BASE) + index * 4 + jump = at(meta) + wait_value = at(meta + 1) + condition = join_u32(at(meta + 2), at(meta + 3)) + self.segment_jump_vars[index].set(jump) + if wait_value in [int(item) for item in WaitCondition]: + self.segment_wait_vars[index].set( + WAIT_CONDITION_TEXT[WaitCondition(wait_value)]) + self.segment_condition_vars[index].set(str(condition)) + + def _handle_write( + self, function: int, address: int, quantity_or_value: int, context: str + ) -> None: + if context in ("SET_SYSTEM_A", "SET_SEGMENT_SYSTEM_A"): + params = self.pending_motion if context == "SET_SYSTEM_A" else self.pending_segments + if params is None: + self.monitoring_active = False + return + self.client.write_multiple( + int(Register.END_FREQ_LO), + split_u32(params["end_frequency"]) + + [params["acceleration_ms"], params["deceleration_ms"]] + + split_u32(params["maximum_frequency"]), + "SET_SYSTEM_B" if context == "SET_SYSTEM_A" else "SET_SEGMENT_SYSTEM_B", + ) + return + if context == "SET_SYSTEM_B": + params = self.pending_motion + if params is None: + return + segment_target = params["target_u32"] + values = split_u32(params["frequency"]) + values += split_u32(segment_target) + values += [int(WaitCondition.WAIT_TIME), 0, 0, 0] + self.client.write_multiple(segment_base(0), values, "SET_SINGLE_SEGMENT") + return + if context == "SET_SINGLE_SEGMENT": + self.client.write_single(int(Register.CONTROL), int(Command.START), "START") + self.pending_motion = None + return + if context == "SET_SEGMENT_SYSTEM_B": + self._write_segment_at(0) + return + if context.startswith("SET_SEGMENT_") and context[12:].isdigit(): + self._write_segment_at(int(context[12:]) + 1) + return + if context == "SET_PROFILE": + params = self.pending_motion + if params is None: + self._set_test_result("启动参数已丢失,请重新启动。", "fail") + self.monitoring_active = False + return + self.client.write_single( + int(Register.RUN_MODE), params["run_mode"], "SET_RUN_MODE" + ) + self.log("加减速参数写入成功,正在写入运行模式。") + elif context == "SET_SEGMENT_PROFILE": + params = self.pending_segments + if params is None: + self._set_test_result("多段参数已丢失,请重新启动。", "fail") + self.monitoring_active = False + return + self.client.write_multiple( + int(Register.TOTAL_SEGMENTS), + [params["total"], params["start"]], + "SET_SEGMENT_RANGE", + ) + self.log("公共曲线参数写入成功,正在写入总段数和起始段。") + elif context == "SET_SEGMENT_RANGE": + params = self.pending_segments + if params is None: + return + values = [] + for index in range(10): + segment = params["segments"][index] if index < params["total"] else (0, 0, 0, 0, 0) + pulses, frequency = segment[0], segment[1] + values += split_u32(pulses & 0xFFFFFFFF) + split_u32(frequency) + self.client.write_multiple(int(Register.SEGMENT_TABLE_BASE), values, + "SET_SEGMENT_TABLE") + self.log("段范围写入成功,正在写入10段参数表。") + elif context == "SET_SEGMENT_TABLE": + params = self.pending_segments + if params is None: + return + values = [] + for index in range(10): + segment = params["segments"][index] if index < params["total"] else (0, 0, 0, 0, 0) + values += [segment[2], segment[3]] + split_u32(segment[4]) + self.client.write_multiple(int(Register.SEGMENT_META_BASE), values, + "SET_SEGMENT_META") + self.log("10段脉冲/频率写入成功,正在写入跳转和等待条件。") + elif context == "SET_SEGMENT_META": + params = self.pending_segments + if params is None: + return + self.client.write_single( + int(Register.CONTROL), + int(Command.SAVE_PUBLIC), + "SAVE_SEGMENT_CONFIGURATION", + ) + elif context == "START_SEGMENTS": + self.monitoring_active = True + self._set_test_result("设备已确认多段启动命令,正在监控运行。", "running") + self.log("多段启动命令写入成功。") + elif context == "SET_RUN_MODE": + params = self.pending_motion + if params is None: + self._set_test_result("启动参数已丢失,请重新启动。", "fail") + self.monitoring_active = False + return + values = [int(Command.START), params["direction"]] + values += split_u32(params["target_u32"]) + values += split_u32(params["frequency"]) + self.client.write_multiple(int(Register.COMMAND), values, "START") + self.pending_motion = None + self.log("运行模式写入成功,正在发送启动参数。") + elif context == "START": + self.monitoring_active = True + self._set_test_result("设备已确认启动命令,正在监控运行状态。", "running") + self.log("启动参数写入成功。") + elif context == "STOP": + self.monitoring_active = False + self._set_test_result("设备已确认暂停,可点击“继续当前脉冲”。", "neutral") + self.log("暂停命令写入成功,串口保持连接,已停止周期查询。") + if self.client.is_open: + self.client.read_holding( + int(Register.CUMULATIVE_POSITION_LO), + STATUS_REGISTER_COUNT, + "STOP_STATUS", + ) + elif context == "RESUME": + self.monitoring_active = True + self._set_test_result("设备已确认继续命令,正在监控剩余脉冲。", "running") + self.log("继续命令写入成功,已恢复周期查询。") + elif context == "SET_RUNTIME_FREQ_VALUE": + self.client.write_single( + int(Register.CONTROL), + int(Command.UPDATE_FREQUENCY), + "APPLY_RUNTIME_FREQ", + ) + self.log("运行中目标频率写入成功,正在发送立即应用命令。") + elif context == "APPLY_RUNTIME_FREQ": + self.log("设备已接收运行中改频命令,将按公共加减速斜率调整当前段。") + if self.client.is_open and not self.monitoring_active: + self.client.read_holding( + int(Register.CUMULATIVE_POSITION_LO), + STATUS_REGISTER_COUNT, + "FREQ_STATUS", + ) + elif context == "SET_PUBLIC_PARAMETERS": + self.client.write_single( + int(Register.CONTROL), int(Command.SAVE_PUBLIC), "SAVE_PUBLIC" + ) + self.log("公共参数寄存器写入成功,正在请求设备保存Flash。") + elif context == "SAVE_PUBLIC": + self.log("设备已接收公共参数保存命令,正在回读确认。") + if self.client.is_open: + self.client.read_holding( + int(Register.PULSE_POINT), + register_read_count(), + "PUBLIC_CONFIG_STATUS", + ) + elif context == "SAVE_SEGMENT_CONFIGURATION": + self.log("设备已接收段表保存命令,正在确认Flash保存结果。") + self.root.after(200, self._request_segment_save_status) + elif context == "SET_OUTPUT_POINTS": + self.client.write_single( + int(Register.OUTPUT_MODE), + int(self._selected_output_mode()), + "SET_OUTPUT_MODE", + ) + self.log("输出点寄存器写入成功,正在写入输出方式。") + elif context == "SET_OUTPUT_MODE": + self.client.write_single( + int(Register.SEND_MODE), + int(self._selected_send_mode()), + "SET_SEND_MODE", + ) + self.log("输出方式写入成功,正在写入完成/后续发送模式。") + elif context == "SET_SEND_MODE": + self.client.write_single( + int(Register.DIRECTION_LOGIC), + int(self._selected_direction_logic()), + "SET_DIRECTION_LOGIC", + ) + self.log("发送模式写入成功,正在写入正/负方向逻辑。") + elif context == "SET_DIRECTION_LOGIC": + self.client.write_single( + int(Register.CONTROL), int(Command.SAVE_OUTPUTS), "SAVE_OUTPUTS" + ) + self.log("方向逻辑写入成功,正在发送应用并保存命令。") + elif context == "SAVE_OUTPUTS": + self.log("设备已接收保存命令,正在回读Flash保存结果。") + if self.client.is_open: + self.client.read_holding( + int(Register.PULSE_POINT), + register_read_count(), + "OUTPUT_CONFIG_STATUS", + ) + elif context == "CLEAR_TOTAL": + self.total_pulses_var.set("0") + self.log("总脉冲数清零命令写入成功,正在回读确认。") + if self.client.is_open: + self.client.read_holding( + int(Register.TOTAL_PULSES_LO), 2, "TOTAL_AFTER_CLEAR" + ) + elif context == "CLEAR_POSITION": + self.cumulative_position_var.set("0") + self.log("当前Y点位置清零命令写入成功,正在回读确认。") + if self.client.is_open: + self.client.read_holding( + int(Register.CUMULATIVE_POSITION_LO), + 2, + "POSITION_AFTER_CLEAR", + ) + elif context == "CLEAR_DIAGNOSTIC": + self.log("诊断记录已清零,正在回读确认。") + self.request_diagnostic() + _ = function, address, quantity_or_value + + def _finish_segment_table_write(self) -> None: + """设备确认段表已写入Flash后,开放启动或自动启动。""" + params = self.pending_segments + if params is None: + return + start_after_write = self.start_after_segment_write + self.start_after_segment_write = False + self.written_segments = params + self.pending_segments = None + self.segments_written_ready = True + if self.client.is_open: + self.start_segments_button.configure(state=tk.NORMAL) + self._set_test_result("段表写入完成,可以点击“启动”。", "pass") + self.log("多段系统参数和段表写入成功,设备尚未启动。") + messagebox.showinfo( + "写入成功", + f"段表写入成功:有效段数{params['total']}," + f"起始执行段{params['start']}。\n" + "参数已保存到设备Flash。", + ) + if start_after_write: + self.root.after(0, self.start_segment_motion) + + def _abort_segment_table_write(self, message: str) -> None: + """统一结束失败的段表写入或自动启动流程。""" + self.pending_segments = None + self.written_segments = None + self.segments_written_ready = False + self.start_after_segment_write = False + if self.client.is_open: + # 失败后仍允许再次点击启动,重试自动写入。 + self.start_segments_button.configure(state=tk.NORMAL) + self._set_test_result(message, "fail") + self.log(message) + messagebox.showerror("段表写入失败", message) + + def _write_segment_at(self, index: int) -> None: + """按参数表每段0x10步长依次写入8个有效寄存器。""" + params = self.pending_segments + if params is None: + return + if index >= params["total"]: + # 段表写完后由设备统一校验公共参数和10段表并保存Flash。 + self.client.write_single( + int(Register.CONTROL), + int(Command.SAVE_PUBLIC), + "SAVE_SEGMENT_CONFIGURATION", + ) + return + segment = params["segments"][index] + pulses, frequency, jump, wait_condition, condition_value = segment + wait_time = condition_value if wait_condition == int(WaitCondition.WAIT_TIME) else 0 + act_time = condition_value if wait_condition == int(WaitCondition.ACT_TIME) else 0 + values = split_u32(frequency) + split_u32(pulses & 0xFFFFFFFF) + values += [wait_condition, wait_time, act_time, jump] + self.client.write_multiple(segment_base(index), values, f"SET_SEGMENT_{index}") + + def _update_status(self, status: Dict[str, int]) -> None: + state = status["state"] + error = status["error"] + current = status["current_pulses"] + current_segment = status["current_segment"] + display_segment = current_segment + # 固件在总段数为1时复用单段执行器并上报段号0;只要本次由段表 + # 启动,上位机仍按段表的起始段显示目标频率和段号。 + if current_segment == 0 and self.multi_test_active: + if self.written_segments is not None: + display_segment = int(self.written_segments.get("start", 1)) + else: + display_segment = max(1, int(self.start_segment_var.get())) + self._set_state_badge(state) + self.error_var.set(f"{error} · {error_text(error)}") + self.current_pulses_var.set(str(current)) + self.actual_frequency_var.set(f"{status['current_frequency']} Hz") + + # “设定频率”显示当前段的目标值;“实际频率”直接使用设备状态区 + # 上报的定时器生效值。加减速或运行中改频时,两者可能暂时不同。 + configured_frequency = self.frequency_var.get() + if 1 <= display_segment <= len(self.segment_frequency_vars): + try: + configured_frequency = int( + self.segment_frequency_vars[display_segment - 1].get() + ) + if configured_frequency == 0: + configured_frequency = int(self.frequency_var.get()) + except ValueError: + pass + self.current_frequency_var.set(f"{configured_frequency} Hz") + + # 单段运行时固件按协议上报段号0;多段运行上报1~10。 + self.current_segment_var.set( + str(display_segment) if display_segment else "0(未执行)" + ) + if display_segment > 0: + suffix = "(等待)" if state == int(State.WAITING) else "" + self.running_segment_var.set(f"第 {display_segment} 段{suffix}") + elif state in (int(State.RUNNING), int(State.PAUSED)): + suffix = "(暂停)" if state == int(State.PAUSED) else "" + self.running_segment_var.set(f"单段{suffix}") + else: + self.running_segment_var.set("未运行") + + self.cumulative_position_var.set(str(status["cumulative_position"])) + self.total_pulses_var.set(str(status["total_pulses"])) + self._update_diagnostic(status) + + # 多段运动可逐段改变正反向,当前段变化后同步显示实际方向。 + if self.multi_test_active and 1 <= display_segment <= len(self.segment_pulse_vars): + try: + segment_direction = 1 if int(self.segment_pulse_vars[display_segment - 1].get()) < 0 else 0 + self.direction_status_var.set(self._direction_display_text(segment_direction)) + except ValueError: + pass + + target = self.expected_pulses if self.test_active and self.expected_pulses else self.display_target + self.pulse_fraction_var.set(f"{current} / {target}") + progress = min(100.0, (current * 100.0 / target)) if target else 0.0 + self.progress_var.set(progress) + + running = state == int(State.RUNNING) + if ( + self.test_active + and not self.multi_test_active + and running + and self.expected_constant_frequency + and status["current_frequency"] != self.expected_constant_frequency + ): + actual_frequency = status["current_frequency"] + expected_frequency = self.expected_constant_frequency + self.test_active = False + self.monitoring_active = False + self.client.write_single( + int(Register.CONTROL), int(Command.STOP), "FREQUENCY_MISMATCH_STOP" + ) + self._set_test_result( + f"测试失败:设定{expected_frequency}Hz,设备实际运行" + f"{actual_frequency}Hz,已自动暂停输出。", + "fail", + ) + self.log( + f"频率一致性检查失败:设定{expected_frequency}Hz," + f"状态寄存器报告{actual_frequency}Hz,已发送暂停命令。" + ) + return + frequency_applied = False + if self.pending_frequency: + if error != int(Error.NONE): + self.log( + f"动态改频失败:设备错误{error},{error_text(error)}。" + ) + self.pending_frequency = 0 + elif running: + self.log( + f"动态改频已生效:新目标{self.pending_frequency}Hz," + f"当前曲线频率{status['current_frequency']}Hz。" + ) + self._set_test_result( + f"目标频率已修改为{self.pending_frequency}Hz,曲线立即重新计算。", + "running", + ) + self.pending_frequency = 0 + frequency_applied = True + elif state == int(State.PAUSED): + self.log( + f"新频率{self.pending_frequency}Hz已保存,将在继续发送时生效。" + ) + self._set_test_result( + f"频率{self.pending_frequency}Hz已保存,继续发送时生效。", + "neutral", + ) + self.pending_frequency = 0 + + if running: + self.seen_running = True + if self.test_active and not frequency_applied: + self._set_test_result( + f"运行中:{state_text(state)},已发送{current}个脉冲。", + "running", + ) + elif self.test_active and state == int(State.WAITING): + self._set_test_result("当前段已停止输出,正在等待设定条件。", "running") + elif self.test_active and state == int(State.COMPLETE): + elapsed_ok = (time.monotonic() - self.test_started_at) >= 0.05 + diagnostic_flags = status.get("diagnostic_status", 0) + diagnostic_ok = ( + (diagnostic_flags & int(DiagnosticStatus.COMPLETE)) != 0 + and (diagnostic_flags & ( + int(DiagnosticStatus.COUNT_FAIL) + | int(DiagnosticStatus.FREQUENCY_FAIL) + | int(DiagnosticStatus.PROFILE_FAIL) + )) == 0 + ) + if ( + error == int(Error.NONE) + and (self.multi_test_active or current == self.expected_pulses) + and (self.seen_running or elapsed_ok) + and diagnostic_ok + ): + if self._selected_output_mode() == OutputMode.AB_QUADRATURE: + verification = "请用双通道示波器核对A/B相周期数及90°相位关系。" + else: + verification = "请核对脉冲端边沿数以及方向端的稳定电平。" + self._set_test_result( + f"测试通过:设备完成并报告{current}个脉冲," + f"个数、频率和加减速曲线诊断均通过。{verification}", + "pass", + ) + self.log("测试通过:完成状态、脉冲计数、频率和曲线诊断均符合目标。") + else: + self._set_test_result( + f"测试失败:目标{self.expected_pulses},设备报告{current}," + f"错误码{error};诊断异常数为个数" + f"{status.get('diagnostic_count_errors', 0)}、频率" + f"{status.get('diagnostic_frequency_errors', 0)}、曲线" + f"{status.get('diagnostic_profile_errors', 0)}。", + "fail", + ) + self.test_active = False + self.multi_test_active = False + self.monitoring_active = False + self.log("脉冲输出完成,串口保持连接,已停止周期查询。") + elif self.test_active and (state == int(State.ERROR) or error != int(Error.NONE)): + self._set_test_result(f"测试失败:{error_text(error)}。", "fail") + self.test_active = False + self.monitoring_active = False + + if state != self.last_state or error != self.last_error: + self.log(f"状态变化:{state_text(state)},错误:{error_text(error)}。") + self.last_state = state + self.last_error = error + + def _update_diagnostic(self, status: Dict[str, int]) -> None: + """把固件诊断位和采样值转换为易读结果。""" + flags = status.get("diagnostic_status", 0) + + def result_text(pass_flag: DiagnosticStatus, + fail_flag: DiagnosticStatus) -> str: + if flags & int(fail_flag): + return "异常" + if flags & int(pass_flag): + return "一致" + if flags & int(DiagnosticStatus.ACTIVE): + return "诊断中" + return "未诊断" + + if flags & int(DiagnosticStatus.ACTIVE): + overall = "诊断中" + elif flags & (int(DiagnosticStatus.COUNT_FAIL) | + int(DiagnosticStatus.FREQUENCY_FAIL) | + int(DiagnosticStatus.PROFILE_FAIL)): + overall = "诊断失败" + elif flags & int(DiagnosticStatus.COMPLETE): + overall = "诊断通过" + else: + overall = "未运行诊断" + + profile_value = status.get("diagnostic_profile_type", 0) + try: + profile_name = PROFILE_TEXT[ProfileType(profile_value)] + except (ValueError, KeyError): + profile_name = f"未知曲线{profile_value}" + phase = DIAGNOSTIC_PHASE_TEXT.get( + status.get("diagnostic_phase", 0), "未知阶段" + ) + + self.diag_overall_var.set(overall) + self.diag_count_var.set(result_text( + DiagnosticStatus.COUNT_PASS, DiagnosticStatus.COUNT_FAIL + )) + self.diag_frequency_var.set(result_text( + DiagnosticStatus.FREQUENCY_PASS, DiagnosticStatus.FREQUENCY_FAIL + )) + self.diag_profile_var.set( + f"{result_text(DiagnosticStatus.PROFILE_PASS, DiagnosticStatus.PROFILE_FAIL)}" + f"({profile_name})" + ) + self.diag_phase_var.set(phase) + self.diag_pulses_var.set( + f"{status.get('diagnostic_observed_pulses', 0)} / " + f"{status.get('diagnostic_expected_pulses', 0)}" + ) + self.diag_frequency_values_var.set( + f"{status.get('diagnostic_actual_frequency', 0)} Hz / " + f"{status.get('diagnostic_expected_frequency', 0)} Hz" + ) + self.diag_max_error_var.set( + f"{status.get('diagnostic_max_frequency_error', 0)} Hz" + ) + self.diag_error_counts_var.set( + f"个数{status.get('diagnostic_count_errors', 0)}," + f"频率{status.get('diagnostic_frequency_errors', 0)}," + f"曲线{status.get('diagnostic_profile_errors', 0)}" + ) + + def _set_state_badge(self, state: int) -> None: + if state == int(State.RUNNING): + colors = ("#DDEEFF", "#15558A") + elif state == int(State.COMPLETE): + colors = ("#DDF7EC", "#087A55") + elif state == int(State.ERROR): + colors = ("#FDECEC", "#B42318") + elif state == int(State.PAUSED): + colors = ("#FFF3D8", "#865600") + elif state == int(State.WAITING): + colors = ("#E8E2FF", "#5B35A5") + else: + colors = ("#E8EEF5", "#425466") + self.state_badge.configure(text=state_text(state), bg=colors[0], fg=colors[1]) + + def _set_test_result(self, text: str, kind: str) -> None: + # 主页面不再显示独立的运行提示条;保留最近结果供流程和调试使用。 + self.last_result_text = text + self.last_result_kind = kind + + def log(self, message: str) -> None: + if not hasattr(self, "log_text"): + return + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + self.log_text.configure(state=tk.NORMAL) + self.log_text.insert(tk.END, f"[{timestamp}] {message}\n") + self.log_text.see(tk.END) + self.log_text.configure(state=tk.DISABLED) + + def clear_log(self) -> None: + self.log_text.configure(state=tk.NORMAL) + self.log_text.delete("1.0", tk.END) + self.log_text.configure(state=tk.DISABLED) + self.log("日志已清空。") + + def _load_settings(self) -> None: + try: + settings = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + return + port = str(settings.get("port", "")) + if port in self.port_combo["values"]: + self.port_var.set(port) + self.slave_var.set(int(settings.get("slave", DEFAULT_SLAVE_ADDRESS))) + preset_index = max(0, min(int(settings.get("preset", 0)), len(PRESETS) - 1)) + self.preset_var.set(PRESETS[preset_index]["name"]) + self.pulses_var.set(str(settings.get("pulses", self.pulses_var.get()))) + self.frequency_var.set(int(settings.get("frequency", self.frequency_var.get()))) + self.runtime_frequency_var.set( + int(settings.get("runtime_frequency", self.frequency_var.get())) + ) + self.maximum_frequency_var.set( + int(settings.get("maximum_frequency", MAX_FREQUENCY_HZ)) + ) + saved_profile = int(settings.get("profile_type", int(ProfileType.LINEAR))) + self.profile_var.set( + PROFILE_TEXT[ProfileType(saved_profile)] + if saved_profile in [int(item) for item in ProfileType] + else PROFILE_TEXT[ProfileType.LINEAR] + ) + self.acceleration_var.set(int(settings.get("acceleration_ms", 1000))) + self.deceleration_var.set(int(settings.get("deceleration_ms", 1000))) + legacy_speed_default = min(100, max(1, int(self.frequency_var.get()))) + self.start_frequency_var.set( + int(settings.get("start_frequency", legacy_speed_default)) + ) + self.end_frequency_var.set( + int(settings.get("end_frequency", legacy_speed_default)) + ) + self.direction_var.set( + DIRECTION_TEXT[1 if int(settings.get("direction", 0)) else 0] + ) + saved_run_mode = int(settings.get("run_mode", int(RunMode.RELATIVE))) + self.run_mode_var.set( + MODE_TEXT[RunMode.ABSOLUTE] + if saved_run_mode == int(RunMode.ABSOLUTE) + else MODE_TEXT[RunMode.RELATIVE] + ) + self._on_run_mode_changed() + saved_send_mode = int(settings.get("send_mode", int(SendMode.COMPLETE))) + self.send_mode_var.set( + SEND_MODE_TEXT[SendMode(saved_send_mode)] + if saved_send_mode in [int(item) for item in SendMode] + else SEND_MODE_TEXT[SendMode.COMPLETE] + ) + saved_direction_logic = int(settings.get( + "direction_logic", int(DirectionLogic.POSITIVE) + )) + self.direction_logic_var.set( + DIRECTION_LOGIC_TEXT[DirectionLogic(saved_direction_logic)] + if saved_direction_logic in [int(item) for item in DirectionLogic] + else DIRECTION_LOGIC_TEXT[DirectionLogic.POSITIVE] + ) + saved_pulse_point = int(settings.get("pulse_point", 0)) + saved_direction_point = int(settings.get( + "secondary_point", + settings.get("b_phase_point", settings.get("direction_point", 0)), + )) + if saved_pulse_point in PULSE_POINTS: + self.pulse_point_var.set(PULSE_POINTS[saved_pulse_point]) + if saved_direction_point in SECONDARY_POINTS: + self.secondary_point_var.set(SECONDARY_POINTS[saved_direction_point]) + saved_output_mode = int(settings.get("output_mode", int(OutputMode.PULSE_DIRECTION))) + if saved_output_mode in [int(item) for item in OutputMode]: + self.output_mode_var.set(OUTPUT_MODE_TEXT[OutputMode(saved_output_mode)]) + self._on_output_mode_changed() + try: + self.display_target = int(self.pulses_var.get()) + except ValueError: + self.display_target = 0 + + def _save_settings(self) -> None: + preset_index = next( + (index for index, item in enumerate(PRESETS) if item["name"] == self.preset_var.get()), + 0, + ) + settings = { + "port": self.port_var.get(), + "slave": self.slave_var.get(), + "preset": preset_index, + "pulses": self.pulses_var.get(), + "frequency": self.frequency_var.get(), + "runtime_frequency": self.runtime_frequency_var.get(), + "maximum_frequency": self.maximum_frequency_var.get(), + "profile_type": next( + (int(item) for item, text in PROFILE_TEXT.items() + if text == self.profile_var.get()), + int(ProfileType.LINEAR), + ), + "acceleration_ms": self.acceleration_var.get(), + "deceleration_ms": self.deceleration_var.get(), + "start_frequency": self.start_frequency_var.get(), + "end_frequency": self.end_frequency_var.get(), + "direction": 1 if self.direction_var.get() == DIRECTION_TEXT[1] else 0, + "run_mode": ( + int(RunMode.ABSOLUTE) + if self.run_mode_var.get() == MODE_TEXT[RunMode.ABSOLUTE] + else int(RunMode.RELATIVE) + ), + "send_mode": int(self._selected_send_mode()), + "direction_logic": int(self._selected_direction_logic()), + "pulse_point": point_id_from_label(PULSE_POINTS, self.pulse_point_var.get()), + "secondary_point": point_id_from_label( + SECONDARY_POINTS, self.secondary_point_var.get() + ), + "output_mode": int(self._selected_output_mode()), + } + try: + SETTINGS_FILE.write_text( + json.dumps(settings, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except OSError as exc: + self.log(f"保存界面设置失败:{exc}") + + def close(self) -> None: + if self.closing: + return + self.closing = True + self._save_settings() + self.client.close() + self.root.destroy() + + +def main() -> int: + if sys.platform != "win32": + print("该工具使用Windows原生串口API,只支持Windows。") + return 1 + root = tk.Tk() + PlsrTesterApp(root) + root.mainloop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/HostComputer/PLSR_Python_Tester/modbus_rtu.py b/HostComputer/PLSR_Python_Tester/modbus_rtu.py new file mode 100644 index 0000000..07bf326 --- /dev/null +++ b/HostComputer/PLSR_Python_Tester/modbus_rtu.py @@ -0,0 +1,474 @@ +"""不依赖pyserial的Windows原生串口Modbus RTU客户端。""" + +from __future__ import annotations + +import ctypes +import queue +import threading +import time +import winreg +from ctypes import wintypes +from dataclasses import dataclass +from typing import List, Optional, Tuple + + +GENERIC_READ = 0x80000000 +GENERIC_WRITE = 0x40000000 +OPEN_EXISTING = 3 +FILE_ATTRIBUTE_NORMAL = 0x80 +PURGE_TXABORT = 0x0001 +PURGE_RXABORT = 0x0002 +PURGE_TXCLEAR = 0x0004 +PURGE_RXCLEAR = 0x0008 +INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value + + +class DCB(ctypes.Structure): + _fields_ = [ + ("DCBlength", wintypes.DWORD), + ("BaudRate", wintypes.DWORD), + ("flags", wintypes.DWORD), + ("wReserved", wintypes.WORD), + ("XonLim", wintypes.WORD), + ("XoffLim", wintypes.WORD), + ("ByteSize", wintypes.BYTE), + ("Parity", wintypes.BYTE), + ("StopBits", wintypes.BYTE), + ("XonChar", ctypes.c_char), + ("XoffChar", ctypes.c_char), + ("ErrorChar", ctypes.c_char), + ("EofChar", ctypes.c_char), + ("EvtChar", ctypes.c_char), + ("wReserved1", wintypes.WORD), + ] + + +class COMMTIMEOUTS(ctypes.Structure): + _fields_ = [ + ("ReadIntervalTimeout", wintypes.DWORD), + ("ReadTotalTimeoutMultiplier", wintypes.DWORD), + ("ReadTotalTimeoutConstant", wintypes.DWORD), + ("WriteTotalTimeoutMultiplier", wintypes.DWORD), + ("WriteTotalTimeoutConstant", wintypes.DWORD), + ] + + +def list_serial_ports() -> List[str]: + """从Windows注册表枚举COM端口,不需要pyserial。""" + ports = set() + path = r"HARDWARE\DEVICEMAP\SERIALCOMM" + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as key: + index = 0 + while True: + try: + _, value, _ = winreg.EnumValue(key, index) + ports.add(str(value)) + index += 1 + except OSError: + break + except OSError: + pass + + def sort_key(name: str) -> Tuple[str, int]: + prefix = "".join(ch for ch in name if not ch.isdigit()) + digits = "".join(ch for ch in name if ch.isdigit()) + return prefix, int(digits or 0) + + return sorted(ports, key=sort_key) + + +class WinSerialPort: + """使用同步Win32 API访问串口;由后台线程调用。""" + + def __init__(self) -> None: + self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + self._handle: Optional[int] = None + self._configure_signatures() + + def _configure_signatures(self) -> None: + kernel = self._kernel32 + kernel.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + kernel.CreateFileW.restype = wintypes.HANDLE + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + kernel.CloseHandle.restype = wintypes.BOOL + kernel.BuildCommDCBW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(DCB)] + kernel.BuildCommDCBW.restype = wintypes.BOOL + kernel.SetCommState.argtypes = [wintypes.HANDLE, ctypes.POINTER(DCB)] + kernel.SetCommState.restype = wintypes.BOOL + kernel.SetCommTimeouts.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(COMMTIMEOUTS), + ] + kernel.SetCommTimeouts.restype = wintypes.BOOL + kernel.SetupComm.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD] + kernel.SetupComm.restype = wintypes.BOOL + kernel.PurgeComm.argtypes = [wintypes.HANDLE, wintypes.DWORD] + kernel.PurgeComm.restype = wintypes.BOOL + kernel.ReadFile.argtypes = [ + wintypes.HANDLE, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + wintypes.LPVOID, + ] + kernel.ReadFile.restype = wintypes.BOOL + kernel.WriteFile.argtypes = [ + wintypes.HANDLE, + wintypes.LPCVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + wintypes.LPVOID, + ] + kernel.WriteFile.restype = wintypes.BOOL + + @property + def is_open(self) -> bool: + return self._handle is not None + + def open(self, port_name: str, baud_rate: int = 115200) -> None: + self.close() + device_name = rf"\\.\{port_name}" + handle = self._kernel32.CreateFileW( + device_name, + GENERIC_READ | GENERIC_WRITE, + 0, + None, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + None, + ) + if handle == INVALID_HANDLE_VALUE: + self._raise_last_error(f"打开{port_name}失败") + self._handle = handle + + try: + dcb = DCB() + dcb.DCBlength = ctypes.sizeof(DCB) + config = f"baud={baud_rate} parity=e data=8 stop=1" + if not self._kernel32.BuildCommDCBW(config, ctypes.byref(dcb)): + self._raise_last_error("生成串口参数失败") + if not self._kernel32.SetCommState(self._handle, ctypes.byref(dcb)): + self._raise_last_error("设置串口参数失败") + + timeouts = COMMTIMEOUTS( + ReadIntervalTimeout=20, + ReadTotalTimeoutMultiplier=0, + ReadTotalTimeoutConstant=20, + WriteTotalTimeoutMultiplier=0, + WriteTotalTimeoutConstant=300, + ) + if not self._kernel32.SetCommTimeouts( + self._handle, ctypes.byref(timeouts) + ): + self._raise_last_error("设置串口超时失败") + if not self._kernel32.SetupComm(self._handle, 4096, 4096): + self._raise_last_error("设置串口缓冲区失败") + self.purge() + except Exception: + self.close() + raise + + def close(self) -> None: + if self._handle is not None: + self._kernel32.CloseHandle(self._handle) + self._handle = None + + def purge(self) -> None: + self._require_open() + flags = PURGE_TXABORT | PURGE_RXABORT | PURGE_TXCLEAR | PURGE_RXCLEAR + if not self._kernel32.PurgeComm(self._handle, flags): + self._raise_last_error("清理串口缓冲区失败") + + def write(self, data: bytes) -> None: + self._require_open() + buffer = ctypes.create_string_buffer(data) + written = wintypes.DWORD(0) + if not self._kernel32.WriteFile( + self._handle, + buffer, + len(data), + ctypes.byref(written), + None, + ): + self._raise_last_error("串口发送失败") + if written.value != len(data): + raise OSError(f"串口只发送了{written.value}/{len(data)}字节") + + def read(self, maximum: int = 256) -> bytes: + self._require_open() + buffer = ctypes.create_string_buffer(maximum) + received = wintypes.DWORD(0) + if not self._kernel32.ReadFile( + self._handle, + buffer, + maximum, + ctypes.byref(received), + None, + ): + self._raise_last_error("串口接收失败") + return buffer.raw[: received.value] + + def _require_open(self) -> None: + if self._handle is None: + raise OSError("串口尚未打开") + + @staticmethod + def _raise_last_error(prefix: str) -> None: + code = ctypes.get_last_error() + raise OSError(code, f"{prefix}:{ctypes.FormatError(code).strip()}") + + +def crc16(data: bytes) -> int: + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + crc = ((crc >> 1) ^ 0xA001) if (crc & 1) else (crc >> 1) + return crc & 0xFFFF + + +def add_crc(data: bytes) -> bytes: + crc = crc16(data) + return data + bytes((crc & 0xFF, (crc >> 8) & 0xFF)) + + +def valid_crc(frame: bytes) -> bool: + if len(frame) < 4: + return False + received = frame[-2] | (frame[-1] << 8) + return crc16(frame[:-2]) == received + + +def u16(value: int) -> bytes: + return bytes(((value >> 8) & 0xFF, value & 0xFF)) + + +def read_u16(data: bytes, offset: int) -> int: + return (data[offset] << 8) | data[offset + 1] + + +def build_read_holding(slave: int, address: int, quantity: int) -> bytes: + if not 1 <= slave <= 247: + raise ValueError("从站地址必须在1到247之间") + if not 1 <= quantity <= 125: + raise ValueError("读取数量必须在1到125之间") + return add_crc(bytes((slave, 0x03)) + u16(address) + u16(quantity)) + + +def build_write_single(slave: int, address: int, value: int) -> bytes: + if not 1 <= slave <= 247: + raise ValueError("从站地址必须在1到247之间") + return add_crc(bytes((slave, 0x06)) + u16(address) + u16(value)) + + +def build_write_multiple(slave: int, address: int, values: List[int]) -> bytes: + if not 1 <= slave <= 247: + raise ValueError("从站地址必须在1到247之间") + if not 1 <= len(values) <= 123: + raise ValueError("写入数量必须在1到123之间") + payload = b"".join(u16(value) for value in values) + body = ( + bytes((slave, 0x10)) + + u16(address) + + u16(len(values)) + + bytes((len(payload),)) + + payload + ) + return add_crc(body) + + +@dataclass(frozen=True) +class Request: + frame: bytes + function: int + address: int + quantity_or_value: int + context: str + + +class ModbusClient: + """单事务后台客户端;GUI通过events队列接收结果。""" + + # STM32从站由RTOS任务轮询。连续请求之间留出恢复时间,避免上一帧刚发送 + # 完成时下一帧已经进入USART,导致从站状态机漏掉请求。 + INTER_REQUEST_DELAY_S = 0.03 + RESPONSE_TIMEOUT_S = 0.8 + MAX_ATTEMPTS = 2 + + def __init__(self) -> None: + self.events: "queue.Queue[tuple]" = queue.Queue() + self._requests: "queue.Queue[Optional[Request]]" = queue.Queue() + self._serial = WinSerialPort() + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._busy_event = threading.Event() + self.slave_address = 1 + self.port_name = "" + self._last_request_finished = 0.0 + + @property + def is_open(self) -> bool: + return self._serial.is_open + + @property + def is_busy(self) -> bool: + return self._busy_event.is_set() or not self._requests.empty() + + def open(self, port_name: str, baud_rate: int, slave_address: int) -> None: + if not 1 <= slave_address <= 247: + raise ValueError("从站地址必须在1到247之间") + self.close() + self._serial.open(port_name, baud_rate) + self.slave_address = slave_address + self.port_name = port_name + self._stop_event.clear() + self._thread = threading.Thread( + target=self._worker_loop, + name="ModbusRtuWorker", + daemon=True, + ) + self._thread.start() + + def close(self) -> None: + self._stop_event.set() + if self._thread is not None and self._thread.is_alive(): + self._requests.put(None) + self._thread.join(timeout=1.0) + self._thread = None + self._busy_event.clear() + while not self._requests.empty(): + try: + self._requests.get_nowait() + except queue.Empty: + break + self._serial.close() + self._last_request_finished = 0.0 + + def read_holding(self, address: int, quantity: int, context: str = "") -> None: + frame = build_read_holding(self.slave_address, address, quantity) + self._requests.put(Request(frame, 0x03, address, quantity, context)) + + def write_single(self, address: int, value: int, context: str = "") -> None: + frame = build_write_single(self.slave_address, address, value) + self._requests.put(Request(frame, 0x06, address, value, context)) + + def write_multiple( + self, address: int, values: List[int], context: str = "" + ) -> None: + frame = build_write_multiple(self.slave_address, address, values) + self._requests.put(Request(frame, 0x10, address, len(values), context)) + + def _worker_loop(self) -> None: + while not self._stop_event.is_set(): + try: + request = self._requests.get(timeout=0.1) + except queue.Empty: + continue + if request is None: + break + self._busy_event.set() + try: + for attempt in range(1, self.MAX_ATTEMPTS + 1): + try: + self._wait_inter_request_gap() + self._execute(request) + break + except TimeoutError: + if attempt >= self.MAX_ATTEMPTS: + raise + self.events.put(("retry", request.context, attempt + 1)) + except Exception as exc: # 将后台异常送回GUI线程 + self.events.put(("error", request.context, str(exc))) + finally: + self._last_request_finished = time.monotonic() + self._busy_event.clear() + + def _wait_inter_request_gap(self) -> None: + remaining = ( + self._last_request_finished + self.INTER_REQUEST_DELAY_S + - time.monotonic() + ) + if remaining > 0: + time.sleep(remaining) + + def _execute(self, request: Request) -> None: + self._serial.purge() + self._serial.write(request.frame) + self.events.put(("tx", request.frame)) + + deadline = time.monotonic() + self.RESPONSE_TIMEOUT_S + response = bytearray() + expected_length: Optional[int] = None + + while time.monotonic() < deadline and not self._stop_event.is_set(): + chunk = self._serial.read(256) + if chunk: + response.extend(chunk) + if len(response) >= 2: + function = response[1] + if function == (request.function | 0x80): + expected_length = 5 + elif function == 0x03 and len(response) >= 3: + expected_length = 5 + response[2] + elif function in (0x06, 0x10): + expected_length = 8 + if expected_length is not None and len(response) >= expected_length: + break + + if expected_length is None or len(response) < expected_length: + raise TimeoutError("等待从站响应超时") + + frame = bytes(response[:expected_length]) + self.events.put(("rx", frame)) + if not valid_crc(frame): + raise ValueError("响应CRC校验失败") + if frame[0] != self.slave_address: + raise ValueError("响应从站地址不匹配") + + function = frame[1] + if function == (request.function | 0x80): + self.events.put( + ("exception", request.function, frame[2], request.context) + ) + return + if function != request.function: + raise ValueError("响应功能码不匹配") + + if function == 0x03: + byte_count = frame[2] + if byte_count != request.quantity_or_value * 2: + raise ValueError("读取响应数据长度不匹配") + values = [ + read_u16(frame, offset) + for offset in range(3, 3 + byte_count, 2) + ] + self.events.put( + ("read", request.address, values, request.context) + ) + return + + echoed_address = read_u16(frame, 2) + echoed_value = read_u16(frame, 4) + if ( + echoed_address != request.address + or echoed_value != request.quantity_or_value + ): + raise ValueError("写响应回显与请求不一致") + self.events.put( + ( + "write", + function, + echoed_address, + echoed_value, + request.context, + ) + ) diff --git a/HostComputer/PLSR_Python_Tester/plsr_protocol.py b/HostComputer/PLSR_Python_Tester/plsr_protocol.py new file mode 100644 index 0000000..65a345c --- /dev/null +++ b/HostComputer/PLSR_Python_Tester/plsr_protocol.py @@ -0,0 +1,371 @@ +"""PLSR 参数表-2026 的 Modbus RTU 地址、枚举和编解码工具。""" + +from enum import IntEnum +from typing import Dict, List, Sequence, Tuple + +DEFAULT_SLAVE_ADDRESS = 1 +DEFAULT_BAUD_RATE = 115200 +MIN_FREQUENCY_HZ = 1 +MAX_FREQUENCY_HZ = 100000 +MAX_RAMP_TIME_MS = 0xFFFF + + +class Register(IntEnum): + PULSE_POINT = 0x1000 + DIRECTION_POINT = 0x1001 + WAIT_SIGNAL = 0x1002 + EXT_SIGNAL = 0x1003 + SEND_MODE = 0x1004 + DIRECTION_DELAY_MS = 0x1005 + DIRECTION_LOGIC = 0x1006 + PROFILE_TYPE = 0x1007 + RUN_MODE = 0x1008 + TOTAL_SEGMENTS = 0x1009 + START_SEGMENT = 0x100A + TARGET_FREQ_LO = 0x100B + TARGET_FREQ_HI = 0x100C + START_FREQ_LO = 0x100D + START_FREQ_HI = 0x100E + OUTPUT_MODE = 0x100F + END_FREQ_LO = 0x1010 + END_FREQ_HI = 0x1011 + ACCEL_TIME = 0x1012 + DECEL_TIME = 0x1013 + MAX_FREQ_LO = 0x1014 + MAX_FREQ_HI = 0x1015 + # 瞬时运行参数,不属于公共参数,也不保存到Flash。 + RUNTIME_FREQ_LO = 0x1016 + RUNTIME_FREQ_HI = 0x1017 + + SEGMENT_TABLE_BASE = 0x1100 + + CUMULATIVE_POSITION_LO = 0x2000 + CUMULATIVE_POSITION_HI = 0x2001 + CURRENT_FREQ_LO = 0x2002 + CURRENT_FREQ_HI = 0x2003 + STATE = 0x2004 + CURRENT_SEGMENT = 0x2005 + ERROR = 0x2006 + CURRENT_PULSES_LO = 0x2007 + CURRENT_PULSES_HI = 0x2008 + TOTAL_PULSES_LO = 0x2009 + TOTAL_PULSES_HI = 0x200A + DIAG_STATUS = 0x200B + DIAG_PROFILE_TYPE = 0x200C + DIAG_PHASE = 0x200D + DIAG_EXPECTED_PULSES_LO = 0x200E + DIAG_EXPECTED_PULSES_HI = 0x200F + DIAG_OBSERVED_PULSES_LO = 0x2010 + DIAG_OBSERVED_PULSES_HI = 0x2011 + DIAG_EXPECTED_FREQ_LO = 0x2012 + DIAG_EXPECTED_FREQ_HI = 0x2013 + DIAG_ACTUAL_FREQ_LO = 0x2014 + DIAG_ACTUAL_FREQ_HI = 0x2015 + DIAG_MAX_FREQ_ERROR_LO = 0x2016 + DIAG_MAX_FREQ_ERROR_HI = 0x2017 + DIAG_COUNT_ERRORS = 0x2018 + DIAG_FREQ_ERRORS = 0x2019 + DIAG_PROFILE_ERRORS = 0x201A + CONTROL = 0x3000 + + # 旧界面源码的兼容别名;实际通讯流程只使用上面的参数表地址。 + COMMAND = CONTROL + DIRECTION = DIRECTION_LOGIC + SEGMENT_META_BASE = SEGMENT_TABLE_BASE + SEGMENT_PULSES_DONE_HI = CURRENT_FREQ_LO + SEGMENT_PULSES_DONE_LO = CURRENT_FREQ_HI + ACTIVE_WAIT = STATE + WAIT_ELAPSED_HI = CURRENT_SEGMENT + WAIT_ELAPSED_LO = ERROR + + +SEGMENT_STRIDE = 0x10 +SEGMENT_USED_REGISTERS = 8 + + +def segment_base(index: int) -> int: + if not 0 <= index < 10: + raise ValueError("段索引必须为0~9") + return int(Register.SEGMENT_TABLE_BASE) + index * SEGMENT_STRIDE + + +def register_read_count(_first: Register = Register.PULSE_POINT) -> int: + """全部公共参数连续块0x1000~0x1015的长度。""" + return int(Register.MAX_FREQ_HI) - int(Register.PULSE_POINT) + 1 + + +class Command(IntEnum): + START = 0x0001 + STOP = 0x0002 + CLEAR_POSITION = 0x0004 + RESUME = 0x0001 # 暂停状态下再次置START即继续 + # 兼容旧界面分支;新参数表没有这些独立命令码。 + CLEAR_TOTAL = 0x0008 + SAVE_PUBLIC = 0x0010 + SAVE_OUTPUTS = SAVE_PUBLIC # 兼容旧名称;设备端现在保存全部公共参数 + CLEAR_DIAGNOSTIC = 0x0020 + UPDATE_FREQUENCY = 0x0040 + START_SEGMENTS = 0x0001 + + +STATUS_REGISTER_COUNT = ( + int(Register.DIAG_PROFILE_ERRORS) - int(Register.CUMULATIVE_POSITION_LO) + 1 +) + + +class DiagnosticStatus(IntEnum): + ACTIVE = 0x0001 + COMPLETE = 0x0002 + COUNT_PASS = 0x0004 + FREQUENCY_PASS = 0x0008 + PROFILE_PASS = 0x0010 + COUNT_FAIL = 0x0100 + FREQUENCY_FAIL = 0x0200 + PROFILE_FAIL = 0x0400 + + +DIAGNOSTIC_PHASE_TEXT = { + 0: "空闲", + 1: "加速段", + 2: "匀速段", + 3: "减速段", +} + + +class State(IntEnum): + IDLE = 0 + RUNNING = 1 + COMPLETE = 2 + ERROR = 3 + PAUSED = 4 + WAITING = 5 + + +class WaitCondition(IntEnum): + WAIT_TIME = 0 + WAIT_SIGNAL = 1 + ACT_TIME = 2 + EXT_SIGNAL = 3 + EXT_OR_COMPLETE = 4 + + +class RunMode(IntEnum): + RELATIVE = 0 + ABSOLUTE = 1 + + +class SendMode(IntEnum): + COMPLETE = 0 + FOLLOW = 1 + + +class DirectionLogic(IntEnum): + POSITIVE = 0 + NEGATIVE = 1 + + +class ProfileType(IntEnum): + LINEAR = 0 + S_CURVE = 1 + SINE = 2 + + +class OutputMode(IntEnum): + PULSE_DIRECTION = 0 + AB_QUADRATURE = 1 + + +class Error(IntEnum): + NONE = 0 + BUSY = 1 + INVALID_PULSE_COUNT = 2 + INVALID_FREQUENCY = 3 + INVALID_DIRECTION = 4 + TIMER_RANGE = 5 + INVALID_COMMAND = 6 + NOT_PAUSED = 7 + NOT_ACTIVE = 8 + INVALID_PULSE_POINT = 9 + INVALID_DIRECTION_POINT = 10 + SAME_OUTPUT_POINT = 11 + FLASH_SAVE = 12 + INVALID_MODE = 13 + INVALID_PROFILE = 14 + INVALID_RAMP_TIME = 15 + INVALID_SPEED = 16 + INVALID_SEGMENT_COUNT = 17 + INVALID_START_SEGMENT = 18 + INVALID_SEGMENT_PARAM = 19 + SEGMENT_TOTAL_OVERFLOW = 20 + INVALID_WAIT_CONDITION = 21 + INVALID_INPUT_POINT = 22 + + +STATE_TEXT = { + State.IDLE: "空闲", State.RUNNING: "运行中", State.COMPLETE: "完成", + State.ERROR: "错误", State.PAUSED: "已暂停", State.WAITING: "条件等待中", +} +ERROR_TEXT = {item: item.name for item in Error} +ERROR_TEXT[Error.NONE] = "无错误" +WAIT_CONDITION_TEXT = { + WaitCondition.WAIT_TIME: "Wait时间", + WaitCondition.WAIT_SIGNAL: "Wait信号(全局X4/X5)", + WaitCondition.ACT_TIME: "ACT时间", + WaitCondition.EXT_SIGNAL: "EXT信号(全局X4/X5)", + WaitCondition.EXT_OR_COMPLETE: "EXT信号或脉冲完成", +} +MODE_TEXT = {RunMode.RELATIVE: "相对模式", RunMode.ABSOLUTE: "绝对模式"} +SEND_MODE_TEXT = { + SendMode.COMPLETE: "完成模式(段间停止)", + SendMode.FOLLOW: "后续模式(满足条件时连续衔接)", +} +DIRECTION_LOGIC_TEXT = { + DirectionLogic.POSITIVE: "正逻辑(正向ON,反向OFF)", + DirectionLogic.NEGATIVE: "负逻辑(正向OFF,反向ON)", +} +PROFILE_TEXT = { + ProfileType.LINEAR: "直线加减速", + ProfileType.S_CURVE: "S曲线加减速", + ProfileType.SINE: "正弦曲线加减速", +} +OUTPUT_MODE_TEXT = { + OutputMode.PULSE_DIRECTION: "PUL/DIR(脉冲+方向)", + OutputMode.AB_QUADRATURE: "AB正交脉冲", +} +INPUT_POINTS = (4, 5) +PULSE_POINTS = { + 0: "Y0 / PF6 / TIM10_CH1", 1: "Y1 / PF8 / TIM13_CH1", + 2: "Y2 / PF7 / TIM11_CH1", 3: "Y3 / PF9 / TIM14_CH1", +} +SECONDARY_POINTS = { + 0: "Y12 / PH9", 1: "Y13 / PH8", 2: "Y14 / PH7", 3: "Y15 / PH6", +} + + +def point_id_from_label(options: Dict[int, str], label: str) -> int: + for point, text in options.items(): + if text == label: + return point + raise ValueError(f"未知输出点:{label}") + + +def calculate_execution_total( + segments: Sequence[Sequence[int]], start_segment: int +) -> Tuple[int, bool]: + """按跳转段实际路径累计脉冲;检测到循环时返回(cycle前累计值, True)。""" + if not segments or not 1 <= start_segment <= len(segments): + raise ValueError("起始执行段超出有效段范围") + + current = start_segment + visited = set() + total = 0 + while current not in visited: + visited.add(current) + segment = segments[current - 1] + total += abs(int(segment[0])) + jump = int(segment[2]) + if jump == 0: + if current >= len(segments): + return total, False + current += 1 + else: + if not 1 <= jump <= len(segments): + raise ValueError("跳转段超出有效段范围") + current = jump + return total, True + + +PRESETS = [ + {"name": "LED可视测试:10脉冲 / 1Hz", "pulses": 10, "frequency": 1, + "acceleration_ms": 0, "deceleration_ms": 0, + "start_frequency": 1, "end_frequency": 1}, + {"name": "基础测试:1000脉冲 / 1000Hz", "pulses": 1000, "frequency": 1000, + "acceleration_ms": 1000, "deceleration_ms": 1000, + "start_frequency": 100, "end_frequency": 100}, + {"name": "长行程测试:100000脉冲 / 5000Hz", "pulses": 100000, "frequency": 5000, + "acceleration_ms": 1000, "deceleration_ms": 1000, + "start_frequency": 100, "end_frequency": 100}, + {"name": "最高频率测试:100000脉冲 / 100000Hz", "pulses": 100000, + "frequency": MAX_FREQUENCY_HZ, "acceleration_ms": 500, + "deceleration_ms": 500, "start_frequency": 100, "end_frequency": 100}, +] + + +def split_u32(value: int) -> List[int]: + """参数表规定:低地址存低16位。""" + if not 0 <= value <= 0xFFFFFFFF: + raise ValueError("32位参数超出范围") + return [value & 0xFFFF, (value >> 16) & 0xFFFF] + + +def signed_to_u32(value: int) -> int: + if not -0x80000000 <= value <= 0x7FFFFFFF: + raise ValueError("有符号32位参数超出范围") + return value & 0xFFFFFFFF + + +def join_u32(low: int, high: int) -> int: + return (low & 0xFFFF) | ((high & 0xFFFF) << 16) + + +def join_i32(low: int, high: int) -> int: + value = join_u32(low, high) + return value - 0x100000000 if value >= 0x80000000 else value + + +def state_text(value: int) -> str: + try: + return STATE_TEXT[State(value)] + except (ValueError, KeyError): + return f"未知状态 {value}" + + +def error_text(value: int) -> str: + try: + return ERROR_TEXT[Error(value)] + except (ValueError, KeyError): + return f"未知错误 {value}" + + +def parse_status(registers: List[int]) -> Dict[str, int]: + """解析0x2000开始的状态区;兼容旧固件的11寄存器响应。""" + if len(registers) < 11: + raise ValueError("状态寄存器数量不足11个") + result = { + "cumulative_position": join_i32(registers[0], registers[1]), + "current_frequency": join_u32(registers[2], registers[3]), + "state": registers[4], + "current_segment": registers[5], + "error": registers[6], + "current_pulses": join_u32(registers[7], registers[8]), + "total_pulses": join_u32(registers[9], registers[10]), + } + if len(registers) >= STATUS_REGISTER_COUNT: + result.update({ + "diagnostic_status": registers[11], + "diagnostic_profile_type": registers[12], + "diagnostic_phase": registers[13], + "diagnostic_expected_pulses": join_u32(registers[14], registers[15]), + "diagnostic_observed_pulses": join_u32(registers[16], registers[17]), + "diagnostic_expected_frequency": join_u32(registers[18], registers[19]), + "diagnostic_actual_frequency": join_u32(registers[20], registers[21]), + "diagnostic_max_frequency_error": join_u32(registers[22], registers[23]), + "diagnostic_count_errors": registers[24], + "diagnostic_frequency_errors": registers[25], + "diagnostic_profile_errors": registers[26], + }) + else: + result.update({ + "diagnostic_status": 0, + "diagnostic_profile_type": 0, + "diagnostic_phase": 0, + "diagnostic_expected_pulses": 0, + "diagnostic_observed_pulses": 0, + "diagnostic_expected_frequency": 0, + "diagnostic_actual_frequency": 0, + "diagnostic_max_frequency_error": 0, + "diagnostic_count_errors": 0, + "diagnostic_frequency_errors": 0, + "diagnostic_profile_errors": 0, + }) + return result diff --git a/HostComputer/PLSR_Python_Tester/settings.json b/HostComputer/PLSR_Python_Tester/settings.json new file mode 100644 index 0000000..529c1df --- /dev/null +++ b/HostComputer/PLSR_Python_Tester/settings.json @@ -0,0 +1,21 @@ +{ + "port": "COM5", + "slave": 1, + "preset": 0, + "pulses": "10", + "frequency": 1000, + "runtime_frequency": 1000, + "maximum_frequency": 100000, + "profile_type": 0, + "acceleration_ms": 100, + "deceleration_ms": 100, + "start_frequency": 0, + "end_frequency": 0, + "direction": 0, + "run_mode": 0, + "send_mode": 0, + "direction_logic": 0, + "pulse_point": 0, + "secondary_point": 0, + "output_mode": 0 +} \ No newline at end of file diff --git a/HostComputer/PLSR_Python_Tester/start_plsr_tester.bat b/HostComputer/PLSR_Python_Tester/start_plsr_tester.bat new file mode 100644 index 0000000..550a8bc --- /dev/null +++ b/HostComputer/PLSR_Python_Tester/start_plsr_tester.bat @@ -0,0 +1,27 @@ +@echo off +setlocal +cd /d "%~dp0" + +if exist "D:\Anaconda3\python.exe" ( + "D:\Anaconda3\python.exe" -B main.py + goto :done +) + +where py >nul 2>nul +if not errorlevel 1 ( + py -3 -B main.py + goto :done +) + +where python >nul 2>nul +if not errorlevel 1 ( + python -B main.py + goto :done +) + +echo Python 3 was not found. +echo Install Python 3 and enable "Add Python to PATH", then try again. +pause + +:done +endlocal diff --git a/HostComputer/PLSR_Python_Tester/test_protocol.py b/HostComputer/PLSR_Python_Tester/test_protocol.py new file mode 100644 index 0000000..5a87365 --- /dev/null +++ b/HostComputer/PLSR_Python_Tester/test_protocol.py @@ -0,0 +1,152 @@ +import unittest + +from modbus_rtu import build_read_holding, build_write_multiple, build_write_single, valid_crc +from plsr_protocol import ( + PULSE_POINTS, + SECONDARY_POINTS, + Command, + DirectionLogic, + Register, + SendMode, + STATUS_REGISTER_COUNT, + WaitCondition, + calculate_execution_total, + join_i32, + join_u32, + parse_status, + segment_base, + signed_to_u32, + split_u32, + register_read_count, +) + + +class ParameterTable2026Tests(unittest.TestCase): + def test_low_word_is_at_low_address(self): + self.assertEqual(split_u32(0x12345678), [0x5678, 0x1234]) + self.assertEqual(join_u32(0x5678, 0x1234), 0x12345678) + self.assertEqual(join_i32(0xFF9C, 0xFFFF), -100) + + def test_system_and_status_addresses(self): + self.assertEqual(int(Register.PULSE_POINT), 0x1000) + self.assertEqual(int(Register.MAX_FREQ_HI), 0x1015) + self.assertEqual(register_read_count(), 0x16) + self.assertEqual(int(Register.CUMULATIVE_POSITION_LO), 0x2000) + self.assertEqual(int(Register.CONTROL), 0x3000) + + def test_segment_stride(self): + self.assertEqual(segment_base(0), 0x1100) + self.assertEqual(segment_base(9), 0x1190) + + def test_start_control_frame(self): + frame = build_write_single(1, int(Register.CONTROL), int(Command.START)) + self.assertTrue(valid_crc(frame)) + self.assertEqual(frame[:6], bytes.fromhex("01 06 30 00 00 01")) + + def test_segment_frame_layout(self): + values = split_u32(1000) + split_u32((-200) & 0xFFFFFFFF) + values += [int(WaitCondition.WAIT_TIME), 500, 0, 2] + frame = build_write_multiple(1, segment_base(0), values) + self.assertTrue(valid_crc(frame)) + self.assertEqual(frame[2:7], bytes.fromhex("11 00 00 08 10")) + + def test_status_read_frame(self): + frame = build_read_holding( + 1, int(Register.CUMULATIVE_POSITION_LO), STATUS_REGISTER_COUNT + ) + self.assertTrue(valid_crc(frame)) + self.assertEqual(frame[:6], bytes.fromhex("01 03 20 00 00 1B")) + + def test_status_pulse_counts(self): + status = parse_status([30, 0, 0, 0, 2, 0, 0, 10, 0, 30, 0]) + self.assertEqual(status["current_pulses"], 10) + self.assertEqual(status["total_pulses"], 30) + + def test_diagnostic_status_values(self): + registers = [0] * STATUS_REGISTER_COUNT + registers[11] = 0x001E + registers[12] = 2 + registers[13] = 3 + registers[14:16] = split_u32(1000) + registers[16:18] = split_u32(1000) + registers[18:20] = split_u32(5000) + registers[20:22] = split_u32(4998) + registers[22:24] = split_u32(2) + status = parse_status(registers) + self.assertEqual(status["diagnostic_expected_pulses"], 1000) + self.assertEqual(status["diagnostic_observed_pulses"], 1000) + self.assertEqual(status["diagnostic_actual_frequency"], 4998) + self.assertEqual(status["diagnostic_max_frequency_error"], 2) + + def test_ab_reverse_segment_uses_signed_pulse_count(self): + self.assertEqual(split_u32(signed_to_u32(-100)), [0xFF9C, 0xFFFF]) + + def test_b_phase_point_encoding(self): + self.assertEqual(SECONDARY_POINTS[0].split()[0], "Y12") + self.assertEqual(SECONDARY_POINTS[3].split()[0], "Y15") + + def test_output_mode_register(self): + self.assertEqual(int(Register.OUTPUT_MODE), 0x100F) + + def test_send_mode_register_values(self): + self.assertEqual(int(Register.SEND_MODE), 0x1004) + self.assertEqual(int(SendMode.COMPLETE), 0) + self.assertEqual(int(SendMode.FOLLOW), 1) + + def test_direction_logic_register_values(self): + self.assertEqual(int(Register.DIRECTION_LOGIC), 0x1006) + self.assertEqual(int(DirectionLogic.POSITIVE), 0) + self.assertEqual(int(DirectionLogic.NEGATIVE), 1) + + def test_y3_uses_pf9_tim14(self): + self.assertEqual(PULSE_POINTS[3], "Y3 / PF9 / TIM14_CH1") + + def test_execution_total_starts_from_selected_segment(self): + segments = [ + (100, 1000, 0), + (200, 1000, 0), + (-300, 1000, 0), + ] + self.assertEqual(calculate_execution_total(segments, 2), (500, False)) + + def test_execution_total_follows_jump_and_detects_cycle(self): + segments = [ + (100, 1000, 3), + (200, 1000, 0), + (300, 1000, 1), + ] + self.assertEqual(calculate_execution_total(segments, 1), (400, True)) + + def test_clear_total_control_frame(self): + frame = build_write_single( + 1, int(Register.CONTROL), int(Command.CLEAR_TOTAL) + ) + self.assertTrue(valid_crc(frame)) + self.assertEqual(frame[:6], bytes.fromhex("01 06 30 00 00 08")) + + def test_save_outputs_control_frame(self): + frame = build_write_single( + 1, int(Register.CONTROL), int(Command.SAVE_OUTPUTS) + ) + self.assertTrue(valid_crc(frame)) + self.assertEqual(frame[:6], bytes.fromhex("01 06 30 00 00 10")) + + def test_clear_diagnostic_control_frame(self): + frame = build_write_single( + 1, int(Register.CONTROL), int(Command.CLEAR_DIAGNOSTIC) + ) + self.assertTrue(valid_crc(frame)) + self.assertEqual(frame[:6], bytes.fromhex("01 06 30 00 00 20")) + + def test_runtime_frequency_is_separate_from_default_speed(self): + self.assertEqual(int(Register.TARGET_FREQ_LO), 0x100B) + self.assertEqual(int(Register.RUNTIME_FREQ_LO), 0x1016) + frame = build_write_single( + 1, int(Register.CONTROL), int(Command.UPDATE_FREQUENCY) + ) + self.assertTrue(valid_crc(frame)) + self.assertEqual(frame[:6], bytes.fromhex("01 06 30 00 00 40")) + + +if __name__ == "__main__": + unittest.main() diff --git a/HostComputer/PythonTest.zip b/HostComputer/PythonTest.zip new file mode 100644 index 0000000..1981dd0 Binary files /dev/null and b/HostComputer/PythonTest.zip differ diff --git a/HostComputer/PythonTest/README.md b/HostComputer/PythonTest/README.md new file mode 100644 index 0000000..3e0890b --- /dev/null +++ b/HostComputer/PythonTest/README.md @@ -0,0 +1,144 @@ +# PLSR Python 测试工具 + +该工具直接通过 Modbus RTU 测试当前 XDM-60T4-E/STM32 PLSR 固件。串口参数默认是 `115200、8E1、站号 1`。 + +默认 `spec` 映射严格对应 `document/参数表-2026.xlsx`:配置区 `0x1000`、10 段路径区 `0x1100..0x1197`、监控区 `0x2000`、控制寄存器 `0x3000`。双字按高字/低字组合,脉冲个数按有符号 32 位处理,频率范围按 `1..100000 Hz` 校验。`legacy` 映射保留当前旧版单段固件的零基地址 `0..107`。 + +## 最简单的打开方式 + +直接双击本目录中的 `start_plsr_test.bat`。启动程序会自动检查 Python 和 pyserial,然后打开中文图形上位机;检测到 COM9 时会优先选中它。确认参数为 `115200 / 8-E-1 / 站号 1` 后,点击“连接设备”。 + +图形界面提供以下与当前固件一致的功能: + +- 设置需求版脉冲端子 Y0..Y3、方向端子 Y12..Y15; +- 设置有符号脉冲个数、`1–100000 Hz` 频率和正反方向; +- 读取配置区、10 段路径区、监控区和控制寄存器; +- 启动/继续、受控停止、重新运行、复位和累计脉冲清零; +- 完成/后续发送模式,以及相对/绝对运行模式; +- 低速正反转测试灯验证; +- 实时状态、累计脉冲、停止原因、错误码和原始寄存器诊断; +- 有上限的 TX/RX 通信日志,可清空或导出,避免日志无限增长。 + +命令行工具 `plsr_test.py` 和文字菜单 `plsr_console.py` 仍保留,可用于自动化和协议排查。 + +## 逻辑分析仪波形查看器 + +双击 `start_logic_csv_parser.bat` 可以直接打开逻辑分析仪波形查看器,不需要进入 IAR 编译。 + +查看器支持正点原子导出的两种文件: + +- `CSV`:自动读取采样率、数字时间轴和所有通道; +- `BIN`:按原始采样点读取,需要填写采集时使用的采样率和通道数。 + +读取后会根据相邻同方向脉冲边沿的周期计算瞬时频率,并显示“横轴时间、纵轴频率 Hz”的运动轨迹。鼠标滚轮用于缩放时间轴,按住鼠标左键水平拖动可以平移,`适应窗口`用于恢复完整曲线。`显示通道`支持填写 `自动`、`4`、`0,1,4` 或 `0-7`。 + +`测量边沿`默认选择自动,也可以指定上升沿或下降沿。`关键时间(ms)`可以填写 `100` 或 `100,200,500`,图中会在对应时间绘制标记线,并显示该节点之后最近一个完整周期测得的频率。横轴的 `0 ms` 从第一个有效脉冲边沿开始计算。 + +注意:正点原子导出的原始 `BIN` 文件没有保存采样率。界面中的 BIN 采样率必须与采集时的设备采样率一致,否则横轴时间和计算频率会不正确。 + +原来的 CSV 转换命令行脚本 `parse_logic_csv.py` 仍然保留,图形波形查看器脚本为 `parse_logic_gui.py`。 + +通信采用严格的“一次请求、一次响应”:上一帧结束或超时后才发送下一帧,并对站号、功能码、帧长度和 CRC 进行校验。接收器可以跳过残留字节重新寻找合法帧,避免 Qt 工具出现的 `0x40` 被误判成功能码的问题。 + +## 环境准备 + +在本目录执行: + +```powershell +python -m pip install -r requirements.txt +python plsr_test.py self-test +python plsr_test.py ports +``` + +如果 Windows 的 `python` 是无效商店别名,请在 PyCharm 解释器或已安装的 Python 环境中执行相同命令。 + +## 推荐测试顺序 + +先断开伺服或机械负载,只接测试灯或示波器。以下示例使用 `COM9`,实际端口以 `ports` 输出为准。 + +1. 只读检查通信和当前保存的输出点: + +```powershell +python plsr_test.py --port COM9 status +``` + +2. 需求版配置 Y0 输出脉冲、Y12 控制方向(`spec` 映射): + +```powershell +python plsr_test.py --port COM9 --map spec configure --pulse-output 0 --direction-output 0 +``` + +输出点、系统参数、10 段参数和累计脉冲由固件自动保存到 Flash,不需要上位机额外发送保存命令。 + +3. 执行低速正反转自动测试: + +```powershell +python plsr_test.py --port COM9 --map spec auto-test --pulses 6 --frequency 2 --confirm-safe +``` + +预期:正向和反向均完成 6 个脉冲,监控区 `0x2000/0x2001` 的有符号累计值可读;反向时脉冲点仍持续输出,方向点电平改变。 + +4. 持续监视状态: + +```powershell +python plsr_test.py --port COM9 --map spec monitor --interval 0.5 +``` + +按 `Ctrl+C` 停止监视。 + +## 单项命令 + +```powershell +python plsr_test.py --port COM9 --map spec run --pulses 10 --frequency 1 --direction forward --confirm-safe +python plsr_test.py --port COM9 --map spec run --pulses -10 --frequency 1 --direction reverse --confirm-safe +python plsr_test.py --port COM9 --map spec stop +python plsr_test.py --port COM9 --map spec immediate-stop +python plsr_test.py --port COM9 --map spec reset +python plsr_test.py --port COM9 --map spec clear +``` + +增加 `-v` 可以显示原始 TX/RX 帧: + +```powershell +python plsr_test.py --port COM9 -v status +``` + +## 停止、继续和重新运行 + +- “受控停止”会完成已经开始的当前脉冲后暂停,避免产生半个脉冲; +- 再点“启动/继续脉冲”,参数未改变时只发送剩余脉冲,例如 100 个脉冲在 50 停止后继续发送 50 个; +- “重新运行脉冲”强制从本次任务的 0 进度重新发送完整预设数量,但不会清空全局累计脉冲; +- “清零累计脉冲”只清累计值,“复位状态”只复位运行状态和错误状态; +- 立即停止仍保留在命令行 `immediate-stop` 中用于故障处置,它会立即关闭输出,当前脉冲可能被截断。 + +## 发送模式和运行模式 + +- 完成模式:本段结束时保持本段速度,下一段开始后再向下一段速度加减速; +- 后续模式:本段后半段提前向下一段速度加减速,本段结束时已经到达下一段速度; +- 两种模式运行中再次发送启动命令都返回忙,不用于表示调用方是否等待; +- 相对模式:脉冲数表示本次要发送的数量,例如设置 `100` 就发送 100 个; +- 绝对模式:脉冲数表示目标位置。目标位置等于当前累计位置时不输出;不等时只发送两者的差值。 + +## 速度和加减速 + +- 起始速度:开始输出时使用的速度; +- 默认速度:加速完成后的匀速速度; +- 终止速度:停止前减速阶段最后使用的速度; +- 加速时间和减速时间:分别表示加速、减速阶段的时间; +- 加减速模式 `0` 为直线,`1` 为 S 曲线,`2` 为正弦曲线。 + +当前固件在每个完整脉冲结束时更新下一脉冲的频率,参数简单,便于使用示波器观察速度变化。 + +## 掉电保存验证 + +固件在脉冲完成、受控/立即停止、累计清零或参数修改后,将系统参数、10 段参数、输出映射和累计脉冲写入带 CRC 的 Flash 记录。重新上电并连接后,“立即读取状态”应显示断电前最后一次稳定保存的累计值。 + +没有掉电检测、备份电池或 FRAM 时,运行中直接切断电源无法保证最后一个正在输出的脉冲零丢失。现场掉电保持验收应在运动完成或受控停止后断电;上电不会自动恢复断电前未完成的运动。 + +## 离线回归 + +离线测试不需要串口,也不需要安装 pyserial: + +```powershell +python -m unittest -v test_plsr_test.py +``` diff --git a/HostComputer/PythonTest/__pycache__/logic_waveform.cpython-312.pyc b/HostComputer/PythonTest/__pycache__/logic_waveform.cpython-312.pyc new file mode 100644 index 0000000..b0d3494 Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/logic_waveform.cpython-312.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/parse_logic_csv.cpython-312.pyc b/HostComputer/PythonTest/__pycache__/parse_logic_csv.cpython-312.pyc new file mode 100644 index 0000000..3128cf7 Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/parse_logic_csv.cpython-312.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/parse_logic_gui.cpython-312.pyc b/HostComputer/PythonTest/__pycache__/parse_logic_gui.cpython-312.pyc new file mode 100644 index 0000000..67b693c Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/parse_logic_gui.cpython-312.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/plsr_console.cpython-310.pyc b/HostComputer/PythonTest/__pycache__/plsr_console.cpython-310.pyc new file mode 100644 index 0000000..c493326 Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/plsr_console.cpython-310.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/plsr_console.cpython-312.pyc b/HostComputer/PythonTest/__pycache__/plsr_console.cpython-312.pyc new file mode 100644 index 0000000..4f251be Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/plsr_console.cpython-312.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/plsr_gui.cpython-310.pyc b/HostComputer/PythonTest/__pycache__/plsr_gui.cpython-310.pyc new file mode 100644 index 0000000..7dc1248 Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/plsr_gui.cpython-310.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/plsr_gui.cpython-312.pyc b/HostComputer/PythonTest/__pycache__/plsr_gui.cpython-312.pyc new file mode 100644 index 0000000..c551766 Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/plsr_gui.cpython-312.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/plsr_test.cpython-310.pyc b/HostComputer/PythonTest/__pycache__/plsr_test.cpython-310.pyc new file mode 100644 index 0000000..280a16c Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/plsr_test.cpython-310.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/plsr_test.cpython-312.pyc b/HostComputer/PythonTest/__pycache__/plsr_test.cpython-312.pyc new file mode 100644 index 0000000..b41747b Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/plsr_test.cpython-312.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/test_logic_waveform.cpython-312.pyc b/HostComputer/PythonTest/__pycache__/test_logic_waveform.cpython-312.pyc new file mode 100644 index 0000000..788f9d5 Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/test_logic_waveform.cpython-312.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/test_plsr_gui.cpython-310.pyc b/HostComputer/PythonTest/__pycache__/test_plsr_gui.cpython-310.pyc new file mode 100644 index 0000000..266262f Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/test_plsr_gui.cpython-310.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/test_plsr_gui.cpython-312.pyc b/HostComputer/PythonTest/__pycache__/test_plsr_gui.cpython-312.pyc new file mode 100644 index 0000000..9b1a11c Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/test_plsr_gui.cpython-312.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/test_plsr_test.cpython-310.pyc b/HostComputer/PythonTest/__pycache__/test_plsr_test.cpython-310.pyc new file mode 100644 index 0000000..992ea04 Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/test_plsr_test.cpython-310.pyc differ diff --git a/HostComputer/PythonTest/__pycache__/test_plsr_test.cpython-312.pyc b/HostComputer/PythonTest/__pycache__/test_plsr_test.cpython-312.pyc new file mode 100644 index 0000000..6857b62 Binary files /dev/null and b/HostComputer/PythonTest/__pycache__/test_plsr_test.cpython-312.pyc differ diff --git a/HostComputer/PythonTest/logic_waveform.py b/HostComputer/PythonTest/logic_waveform.py new file mode 100644 index 0000000..bdc7e0c --- /dev/null +++ b/HostComputer/PythonTest/logic_waveform.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +"""读取正点原子逻辑分析仪导出的 CSV 和 BIN 波形数据。""" + +from __future__ import annotations + +import array +import bisect +import csv +import io +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from parse_logic_csv import CsvParseError, detect_delimiter, read_text, remove_comment_lines + + +RATE_UNITS = { + "hz": 1.0, + "khz": 1_000.0, + "mhz": 1_000_000.0, + "ghz": 1_000_000_000.0, +} +MAX_BIN_TRANSITIONS = 2_000_000 +MAX_MARKER_COUNT = 10 + + +@dataclass +class WaveformChannel: + """保存一个数字通道的初始电平和跳变点。""" + + index: int + name: str + times: list[float] + levels: list[int] + + @property + def transition_count(self) -> int: + """返回真实跳变次数,不包含初始点。""" + return max(0, len(self.times) - 1) + + def average_frequency(self) -> float | None: + """使用相邻上升沿估算平均频率。""" + rising_times = [ + time_value + for time_value, level in zip(self.times[1:], self.levels[1:]) + if level == 1 + ] + if len(rising_times) < 2: + return None + duration = rising_times[-1] - rising_times[0] + if duration <= 0: + return None + return (len(rising_times) - 1) / duration + + +@dataclass +class WaveformData: + """保存一个波形文件的公共信息。""" + + path: Path + source_type: str + sample_rate: float | None + sample_count: int | None + record_count: int + start_time: float + end_time: float + channels: list[WaveformChannel] + + @property + def duration(self) -> float: + """返回当前文件的可见时间长度。""" + return max(0.0, self.end_time - self.start_time) + + +@dataclass +class FrequencyCurve: + """保存由同方向脉冲边沿计算出的频率曲线。""" + + channel_index: int + channel_name: str + edge_name: str + times: list[float] + frequencies: list[float] + + @property + def start_time(self) -> float: + """返回曲线第一个有效脉冲边沿时间。""" + return self.times[0] + + @property + def end_time(self) -> float: + """返回曲线最后一个有效脉冲边沿时间。""" + return self.times[-1] + + def frequency_at(self, time_value: float) -> tuple[float, float]: + """返回指定时间之后最近一个完整周期的时间和频率。""" + index = bisect.bisect_left(self.times, time_value) + if index >= len(self.times): + index = len(self.times) - 1 + return self.times[index], self.frequencies[index] + + +def build_frequency_curve( + channel: WaveformChannel, edge_mode: str = "auto" +) -> FrequencyCurve: + """使用相邻同方向边沿的周期计算瞬时脉冲频率。""" + if edge_mode not in ("auto", "rising", "falling"): + raise CsvParseError(f"不支持的测量边沿:{edge_mode}") + if len(channel.times) < 3: + raise CsvParseError(f"{channel.name} 的脉冲边沿不足,无法计算频率") + + # times[0] 只是文件中的初始电平状态,不能当成真实边沿。 + transitions = list(zip(channel.times[1:], channel.levels[1:])) + if edge_mode == "auto": + selected_level = transitions[0][1] + else: + selected_level = 1 if edge_mode == "rising" else 0 + edge_name = "上升沿" if selected_level == 1 else "下降沿" + edge_times = [time_value for time_value, level in transitions if level == selected_level] + + # 自动边沿不足时再尝试另一种边沿,避免文件从脉冲中间开始造成误判。 + if len(edge_times) < 2 and edge_mode == "auto": + selected_level = 1 - selected_level + edge_name = "上升沿" if selected_level == 1 else "下降沿" + edge_times = [ + time_value for time_value, level in transitions if level == selected_level + ] + if len(edge_times) < 2: + raise CsvParseError(f"{channel.name} 的{edge_name}不足,无法计算频率") + + frequencies: list[float] = [] + valid_edge_times: list[float] = [edge_times[0]] + for index in range(1, len(edge_times)): + period = edge_times[index] - edge_times[index - 1] + if period <= 0: + continue + frequencies.append(1.0 / period) + valid_edge_times.append(edge_times[index]) + if not frequencies: + raise CsvParseError(f"{channel.name} 没有有效脉冲周期") + + # 起点还没有完整周期,使用第一个完整周期的频率补齐曲线起点。 + curve_frequencies = [frequencies[0], *frequencies] + return FrequencyCurve( + channel_index=channel.index, + channel_name=channel.name, + edge_name=edge_name, + times=valid_edge_times, + frequencies=curve_frequencies, + ) + + +def build_frequency_curves( + channels: Iterable[WaveformChannel], edge_mode: str = "auto" +) -> list[FrequencyCurve]: + """为所有具有足够脉冲边沿的通道生成频率曲线。""" + curves: list[FrequencyCurve] = [] + for channel in channels: + try: + curves.append(build_frequency_curve(channel, edge_mode)) + except CsvParseError: + continue + if not curves: + raise CsvParseError("所选通道没有足够的脉冲边沿,无法生成频率曲线") + return curves + + +def parse_marker_times_ms(text: str) -> list[float]: + """解析最多十个毫秒关键时间,并转换为秒。""" + if not text.strip(): + return [] + markers: set[float] = set() + for part in re.split(r"[,,\s]+", text.strip()): + if not part: + continue + try: + milliseconds = float(part) + except ValueError as exc: + raise CsvParseError(f"无法识别关键时间:{part!r}") from exc + if milliseconds < 0: + raise CsvParseError("关键时间不能小于 0 ms") + markers.add(milliseconds / 1_000.0) + if len(markers) > MAX_MARKER_COUNT: + raise CsvParseError(f"关键时间最多设置 {MAX_MARKER_COUNT} 个") + return sorted(markers) + + +def parse_engineering_number(text: str) -> float: + """解析 20 MHz、500 kHz 等带单位的数值。""" + match = re.fullmatch( + r"\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmMgG]?[hH][zZ])?\s*", text + ) + if not match: + raise CsvParseError(f"无法识别采样率:{text!r}") + number = float(match.group(1)) + unit = (match.group(2) or "Hz").lower() + return number * RATE_UNITS[unit] + + +def parse_scaled_count(text: str) -> int | None: + """解析 125.351087 M 形式的采样点数量。""" + match = re.fullmatch(r"\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmMgG])?\s*", text) + if not match: + return None + scales = {"": 1.0, "k": 1_000.0, "m": 1_000_000.0, "g": 1_000_000_000.0} + return int(float(match.group(1)) * scales[(match.group(2) or "").lower()]) + + +def parse_metadata(text: str) -> tuple[float | None, int | None]: + """读取 CSV 注释中的采样率和采样数量。""" + sample_rate: float | None = None + sample_count: int | None = None + for line in text.splitlines(): + stripped = line.strip() + if not stripped.startswith(";"): + continue + content = stripped[1:].strip() + key, separator, value = content.partition(":") + if not separator: + continue + key = key.strip().lower() + value = value.strip() + if key == "sample rate": + sample_rate = parse_engineering_number(value) + elif key == "sample count": + sample_count = parse_scaled_count(value) + return sample_rate, sample_count + + +def find_numeric_time_column(header: list[str], rows: list[list[str]]) -> int: + """优先选择正点原子 CSV 中的 Time(s) 数字时间列。""" + lowered = [name.strip().lower() for name in header] + preferred_names = ("time(s)", "time (s)", "time", "timestamp", "t", "时间") + for name in preferred_names: + if name in lowered: + return lowered.index(name) + + # 没有标准名称时,选择前几行都能转换成浮点数的列。 + for column in range(len(header)): + valid = True + for row in rows[:20]: + if column >= len(row): + valid = False + break + try: + float(row[column].strip().lstrip("'")) + except ValueError: + valid = False + break + if valid: + return column + raise CsvParseError("找不到可用的数字时间列") + + +def find_channel_columns( + header: list[str], rows: list[list[str]], time_index: int +) -> list[tuple[int, int, str]]: + """识别 Channel 0、CH1、D2 等数字通道列。""" + channels: list[tuple[int, int, str]] = [] + used_indexes: set[int] = set() + patterns = ( + re.compile(r"^channel\s*[_-]?\s*(\d+)$", re.IGNORECASE), + re.compile(r"^(?:ch|d)\s*[_-]?\s*(\d+)$", re.IGNORECASE), + ) + + for column, name in enumerate(header): + if column == time_index: + continue + channel_index: int | None = None + for pattern in patterns: + match = pattern.fullmatch(name.strip()) + if match: + channel_index = int(match.group(1)) + break + if channel_index is None or channel_index in used_indexes: + continue + channels.append((column, channel_index, name.strip())) + used_indexes.add(channel_index) + + if channels: + return sorted(channels, key=lambda item: item[1]) + + # 兼容只有“时间,电平”两列的简单文件。 + for column, name in enumerate(header): + if column == time_index: + continue + valid = True + for row in rows[:100]: + if column >= len(row) or row[column].strip() not in ("0", "1"): + valid = False + break + if valid: + channels.append((column, len(channels), name.strip() or f"Channel {len(channels)}")) + if not channels: + raise CsvParseError("CSV 中没有找到高低电平通道") + return channels + + +def load_csv_waveform(path: Path, encoding: str = "auto") -> WaveformData: + """读取 CSV 中的全部数字通道并保存跳变点。""" + text = read_text(path, encoding) + sample_rate, sample_count = parse_metadata(text) + clean_text = remove_comment_lines(text) + if not clean_text: + raise CsvParseError("CSV 中没有有效数据") + + delimiter = detect_delimiter(clean_text) + rows = list(csv.reader(io.StringIO(clean_text), delimiter=delimiter)) + if len(rows) < 2: + raise CsvParseError("CSV 缺少表头或采样数据") + + header = [name.strip() for name in rows[0]] + data_rows = [row for row in rows[1:] if row] + time_index = find_numeric_time_column(header, data_rows) + channel_columns = find_channel_columns(header, data_rows, time_index) + channel_map = { + column: WaveformChannel(index, name, [], []) + for column, index, name in channel_columns + } + + first_time: float | None = None + last_time: float | None = None + previous_levels: dict[int, int] = {} + valid_rows = 0 + for line_number, row in enumerate(data_rows, start=2): + needed_column = max([time_index, *channel_map.keys()]) + if len(row) <= needed_column: + raise CsvParseError(f"CSV 第 {line_number} 行列数不足") + try: + time_value = float(row[time_index].strip().lstrip("'")) + except ValueError as exc: + raise CsvParseError(f"CSV 第 {line_number} 行时间不是数字") from exc + if last_time is not None and time_value < last_time: + raise CsvParseError(f"CSV 第 {line_number} 行时间顺序错误") + + if first_time is None: + first_time = time_value + last_time = time_value + valid_rows += 1 + for column, channel in channel_map.items(): + level_text = row[column].strip() + if level_text not in ("0", "1"): + raise CsvParseError( + f"CSV 第 {line_number} 行的 {channel.name} 不是 0 或 1" + ) + level = int(level_text) + if column not in previous_levels or previous_levels[column] != level: + channel.times.append(time_value) + channel.levels.append(level) + previous_levels[column] = level + + if first_time is None or last_time is None: + raise CsvParseError("CSV 中没有有效采样点") + return WaveformData( + path=path, + source_type="CSV", + sample_rate=sample_rate, + sample_count=sample_count, + record_count=valid_rows, + start_time=first_time, + end_time=last_time, + channels=list(channel_map.values()), + ) + + +def parse_channel_selection(text: str, channel_count: int) -> list[int] | None: + """解析自动、4、0,1,4、0-7 等通道选择写法。""" + stripped = text.strip().lower() + if not stripped or stripped in ("自动", "auto", "all", "全部"): + return None + + selected: set[int] = set() + for part in re.split(r"[,,\s]+", stripped): + if not part: + continue + if "-" in part: + start_text, end_text = part.split("-", 1) + if not start_text.isdigit() or not end_text.isdigit(): + raise CsvParseError(f"无法识别通道范围:{part}") + start = int(start_text) + end = int(end_text) + if start > end: + start, end = end, start + selected.update(range(start, end + 1)) + elif part.isdigit(): + selected.add(int(part)) + else: + match = re.fullmatch(r"(?:channel|ch|d)\s*(\d+)", part) + if not match: + raise CsvParseError(f"无法识别通道:{part}") + selected.add(int(match.group(1))) + + invalid = [index for index in sorted(selected) if not 0 <= index < channel_count] + if invalid: + raise CsvParseError(f"通道超出 0~{channel_count - 1} 范围:{invalid}") + return sorted(selected) + + +def _sample_values(data: bytes, sample_width: int) -> Iterable[int]: + """把原始字节按每个采样点 1 或 2 字节解释。""" + if sample_width == 1: + return data + values = array.array("H") + values.frombytes(data) + if sys.byteorder != "little": + values.byteswap() + return values + + +def load_bin_waveform( + path: Path, + sample_rate: float, + channel_count: int, + selected_indexes: list[int] | None = None, +) -> WaveformData: + """按无文件头的原始采样格式读取 BIN。""" + if sample_rate <= 0: + raise CsvParseError("BIN 采样率必须大于 0") + if not 1 <= channel_count <= 16: + raise CsvParseError("BIN 通道数只支持 1~16") + sample_width = 1 if channel_count <= 8 else 2 + data = path.read_bytes() + if not data: + raise CsvParseError("BIN 文件为空") + if len(data) % sample_width != 0: + raise CsvParseError(f"BIN 文件长度不能按 {sample_width} 字节采样点整除") + + values = _sample_values(data, sample_width) + del data + sample_count = len(values) # type: ignore[arg-type] + selected = selected_indexes or list(range(channel_count)) + selected_mask = sum(1 << index for index in selected) + first_value = values[0] # type: ignore[index] + channel_map = { + index: WaveformChannel( + index=index, + name=f"Channel {index}", + times=[0.0], + levels=[(first_value >> index) & 1], + ) + for index in selected + } + + previous_value = first_value + transition_total = 0 + for sample_index in range(1, sample_count): + value = values[sample_index] # type: ignore[index] + changed = (value ^ previous_value) & selected_mask + while changed: + lowest_bit = changed & -changed + channel_index = lowest_bit.bit_length() - 1 + channel = channel_map[channel_index] + channel.times.append(sample_index / sample_rate) + channel.levels.append((value >> channel_index) & 1) + transition_total += 1 + if transition_total > MAX_BIN_TRANSITIONS: + raise CsvParseError( + "BIN 跳变点过多,请在“显示通道”中只填写需要查看的通道" + ) + changed ^= lowest_bit + previous_value = value + + end_time = 0.0 if sample_count <= 1 else (sample_count - 1) / sample_rate + return WaveformData( + path=path, + source_type="BIN", + sample_rate=sample_rate, + sample_count=sample_count, + record_count=sample_count, + start_time=0.0, + end_time=end_time, + channels=list(channel_map.values()), + ) + + +def load_waveform( + path: Path, + bin_sample_rate: float, + bin_channel_count: int, + channel_text: str, +) -> tuple[WaveformData, list[WaveformChannel]]: + """根据扩展名读取文件,并返回需要显示的通道。""" + suffix = path.suffix.lower() + if suffix == ".csv": + data = load_csv_waveform(path) + selected_indexes = parse_channel_selection(channel_text, len(data.channels)) + if selected_indexes is None: + visible = [channel for channel in data.channels if channel.transition_count > 0] + if not visible: + visible = data.channels[:8] + else: + selected_set = set(selected_indexes) + visible = [channel for channel in data.channels if channel.index in selected_set] + elif suffix == ".bin": + selected_indexes = parse_channel_selection(channel_text, bin_channel_count) + data = load_bin_waveform( + path, + sample_rate=bin_sample_rate, + channel_count=bin_channel_count, + selected_indexes=selected_indexes, + ) + if selected_indexes is None: + visible = [channel for channel in data.channels if channel.transition_count > 0] + if not visible: + visible = data.channels[:8] + else: + visible = data.channels + else: + raise CsvParseError("只支持正点原子导出的 CSV 或 BIN 文件") + + if not visible: + raise CsvParseError("所选通道没有可显示的数据") + return data, visible diff --git a/HostComputer/PythonTest/parse_logic_csv.py b/HostComputer/PythonTest/parse_logic_csv.py new file mode 100644 index 0000000..83a98b3 --- /dev/null +++ b/HostComputer/PythonTest/parse_logic_csv.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""解析正点原子逻辑分析仪导出的 CSV 文件。 + +文件开头以分号开头的说明行会被忽略。输出文件默认不写表头, +每行格式为:时间,电平 + +示例: + python parse_logic_csv.py input.csv output.csv --channel CH1 --mode changes + python parse_logic_csv.py input.csv output.csv --channel 2 --mode all +""" + +from __future__ import annotations + +import argparse +import csv +import io +import sys +from pathlib import Path +from typing import Iterable, Sequence + + +TIME_NAMES = {"time", "timestamp", "时间", "时刻", "t"} +LEVEL_NAMES = { + "0": "0", + "1": "1", + "low": "0", + "l": "0", + "false": "0", + "off": "0", + "低": "0", + "低电平": "0", + "high": "1", + "h": "1", + "true": "1", + "on": "1", + "高": "1", + "高电平": "1", +} + + +class CsvParseError(ValueError): + """CSV 格式或参数不符合预期。""" + + +def read_text(path: Path, encoding: str) -> str: + """读取文本,utf-8 失败时自动尝试常见的 GBK 编码。""" + if encoding != "auto": + return path.read_text(encoding=encoding) + data = path.read_bytes() + for candidate in ("utf-8-sig", "gbk", "utf-16"): + try: + return data.decode(candidate) + except UnicodeDecodeError: + continue + raise CsvParseError(f"无法识别文件编码:{path}") + + +def remove_comment_lines(text: str) -> str: + """删除空行和以分号开头的逻辑分析仪说明行。""" + lines = [] + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith(";"): + continue + lines.append(line) + return "\n".join(lines) + + +def detect_delimiter(text: str) -> str: + """识别逗号、制表符或分号分隔格式。""" + sample_lines = text.splitlines()[:20] + sample = "\n".join(sample_lines) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",\t;") + return dialect.delimiter + except csv.Error: + counts = {delimiter: sample.count(delimiter) for delimiter in (",", "\t", ";")} + delimiter = max(counts, key=counts.get) + if counts[delimiter] == 0: + raise CsvParseError("无法识别 CSV 分隔符,请使用逗号、制表符或分号") + return delimiter + + +def parse_level(value: str) -> str: + """把常见的高低电平写法统一为 0 或 1。""" + text = value.strip().lower() + if text in LEVEL_NAMES: + return LEVEL_NAMES[text] + try: + number = float(text) + except ValueError as exc: + raise CsvParseError(f"无法识别电平值:{value!r}") from exc + if number == 0: + return "0" + if number == 1: + return "1" + raise CsvParseError(f"电平必须为0或1,实际得到:{value!r}") + + +def find_column(header: Sequence[str], name: str, description: str) -> int: + """按列名或列序号查找列,列序号从0开始。""" + name = name.strip() + if name.isdigit(): + index = int(name) + if 0 <= index < len(header): + return index + raise CsvParseError(f"{description}列序号超出范围:{index}") + lowered = [item.strip().lower() for item in header] + target = name.lower() + if target in lowered: + return lowered.index(target) + choices = ", ".join(header) + raise CsvParseError(f"找不到{description}列 {name!r},可用列:{choices}") + + +def find_time_column(header: Sequence[str]) -> int: + """优先查找常见时间列,找不到时默认使用第0列。""" + for index, name in enumerate(header): + if name.strip().lower() in TIME_NAMES: + return index + return 0 + + +def parse_rows(text: str, channel: str, time_column: str | None) -> list[tuple[str, str]]: + """读取 CSV 并返回有效的(时间,电平)采样点。""" + clean_text = remove_comment_lines(text) + if not clean_text: + raise CsvParseError("文件中没有有效数据") + delimiter = detect_delimiter(clean_text) + rows = list(csv.reader(io.StringIO(clean_text), delimiter=delimiter)) + if not rows or len(rows[0]) < 2: + raise CsvParseError("CSV 至少需要时间列和一个通道列") + + header = [item.strip() for item in rows[0]] + channel_column = find_column(header, channel, "通道") + time_index = ( + find_column(header, time_column, "时间") + if time_column is not None + else find_time_column(header) + ) + points: list[tuple[str, str]] = [] + for line_number, row in enumerate(rows[1:], start=2): + if len(row) <= max(channel_column, time_index): + raise CsvParseError(f"第{line_number}行列数不足") + time_value = row[time_index].strip() + if not time_value: + raise CsvParseError(f"第{line_number}行时间为空") + points.append((time_value, parse_level(row[channel_column]))) + if not points: + raise CsvParseError("CSV 表头后没有采样数据") + return points + + +def compress_level_changes(points: Iterable[tuple[str, str]]) -> list[tuple[str, str]]: + """只保留首个采样点和电平发生变化的采样点。""" + output: list[tuple[str, str]] = [] + previous_level: str | None = None + for point in points: + if previous_level is None or point[1] != previous_level: + output.append(point) + previous_level = point[1] + return output + + +def write_points(path: Path, points: Iterable[tuple[str, str]]) -> int: + """写出时间、电平两列,不写表头。""" + count = 0 + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.writer(file, lineterminator="\n") + for time_value, level in points: + writer.writerow((time_value, level)) + count += 1 + return count + + +def build_argument_parser() -> argparse.ArgumentParser: + """创建命令行参数。""" + parser = argparse.ArgumentParser( + description="解析正点原子逻辑分析仪 CSV,输出时间和电平两列" + ) + parser.add_argument("input", type=Path, help="逻辑分析仪导出的 CSV 文件") + parser.add_argument("output", type=Path, help="输出 CSV 文件") + parser.add_argument( + "--channel", + required=True, + help="通道名称,例如 CH1、D0;也可以填写列序号,序号从0开始", + ) + parser.add_argument( + "--mode", + choices=("all", "changes"), + default="changes", + help="all输出全部采样点,changes只输出电平跳变坐标点(默认)", + ) + parser.add_argument( + "--time-column", + help="时间列名称或列序号,默认自动识别,找不到时使用第0列", + ) + parser.add_argument( + "--encoding", + default="auto", + choices=("auto", "utf-8-sig", "gbk", "utf-16"), + help="输入文件编码,默认自动识别", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """命令行入口。""" + parser = build_argument_parser() + args = parser.parse_args(argv) + try: + points = parse_rows(read_text(args.input, args.encoding), args.channel, args.time_column) + output_points = points if args.mode == "all" else compress_level_changes(points) + output_count = write_points(args.output, output_points) + except (OSError, CsvParseError) as exc: + parser.error(str(exc)) + return 2 + + compression = 0.0 if not points else (1.0 - output_count / len(points)) * 100.0 + print(f"输入采样点数:{len(points)}") + print(f"输出点数:{output_count}") + print(f"点数压缩率:{compression:.2f}%") + print(f"输出文件:{args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/HostComputer/PythonTest/parse_logic_gui.py b/HostComputer/PythonTest/parse_logic_gui.py new file mode 100644 index 0000000..f90b3b8 --- /dev/null +++ b/HostComputer/PythonTest/parse_logic_gui.py @@ -0,0 +1,765 @@ +#!/usr/bin/env python3 +"""正点原子逻辑分析仪脉冲频率轨迹查看器。""" + +from __future__ import annotations + +import bisect +import math +import queue +import threading +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox, ttk + +from logic_waveform import ( + RATE_UNITS, + FrequencyCurve, + WaveformChannel, + WaveformData, + build_frequency_curves, + load_waveform, + parse_marker_times_ms, +) +from parse_logic_csv import CsvParseError + + +CHANNEL_COLORS = ( + "#2563eb", + "#dc2626", + "#16a34a", + "#ea580c", + "#7c3aed", + "#0891b2", + "#c026d3", + "#4d7c0f", +) +EDGE_MODES = { + "自动": "auto", + "上升沿": "rising", + "下降沿": "falling", +} + + +class LogicWaveformApp(tk.Tk): + """读取逻辑分析仪文件并显示频率随时间的变化。""" + + def __init__(self) -> None: + super().__init__() + self.title("正点原子逻辑分析仪脉冲频率轨迹查看器") + self.geometry("1180x760") + self.minsize(880, 580) + + self.input_var = tk.StringVar() + self.sample_rate_var = tk.StringVar(value="20") + self.sample_rate_unit_var = tk.StringVar(value="MHz") + self.bin_channel_count_var = tk.StringVar(value="16") + self.channel_var = tk.StringVar(value="自动") + self.edge_var = tk.StringVar(value="自动") + self.marker_var = tk.StringVar(value="100") + self.status_var = tk.StringVar(value="请选择正点原子导出的 CSV 或 BIN 文件") + self.file_info_var = tk.StringVar(value="尚未读取频率轨迹") + self.cursor_var = tk.StringVar(value="光标:-") + self.marker_info_var = tk.StringVar(value="关键节点:-") + + self.result_queue: queue.Queue[tuple[str, object]] = queue.Queue() + self.waveform_data: WaveformData | None = None + self.visible_channels: list[WaveformChannel] = [] + self.frequency_curves: list[FrequencyCurve] = [] + self.marker_times: list[float] = [0.1] + self.frequency_origin = 0.0 + self.view_start = 0.0 + self.view_end = 1.0 + self.drag_state: tuple[int, float, float] | None = None + self.redraw_job: str | None = None + + self.load_button: ttk.Button + self.canvas: tk.Canvas + self._build_widgets() + + def _build_widgets(self) -> None: + """创建文件参数区和频率曲线显示区。""" + self.columnconfigure(0, weight=1) + self.rowconfigure(3, weight=1) + + header = ttk.Frame(self, padding=(16, 14, 16, 8)) + header.grid(row=0, column=0, sticky="ew") + header.columnconfigure(0, weight=1) + ttk.Label( + header, + text="脉冲频率轨迹查看器", + font=("Microsoft YaHei UI", 15, "bold"), + ).grid(row=0, column=0, sticky="w") + ttk.Label(header, textvariable=self.status_var).grid( + row=0, column=1, sticky="e" + ) + + controls = ttk.Frame(self, padding=(16, 0, 16, 8)) + controls.grid(row=1, column=0, sticky="ew") + controls.columnconfigure(1, weight=1) + ttk.Label(controls, text="波形文件").grid(row=0, column=0, sticky="w") + ttk.Entry(controls, textvariable=self.input_var).grid( + row=0, column=1, sticky="ew", padx=(8, 8) + ) + ttk.Button(controls, text="选择文件", command=self._choose_input).grid( + row=0, column=2, padx=(0, 8) + ) + self.load_button = ttk.Button( + controls, text="读取并显示", command=self._start_load + ) + self.load_button.grid(row=0, column=3) + + options = ttk.Frame(self, padding=(16, 0, 16, 10)) + options.grid(row=2, column=0, sticky="ew") + ttk.Label(options, text="BIN采样率").grid(row=0, column=0, sticky="w") + ttk.Entry(options, textvariable=self.sample_rate_var, width=9).grid( + row=0, column=1, padx=(8, 4) + ) + ttk.Combobox( + options, + textvariable=self.sample_rate_unit_var, + values=("Hz", "kHz", "MHz", "GHz"), + state="readonly", + width=6, + ).grid(row=0, column=2, padx=(0, 18)) + + ttk.Label(options, text="BIN通道数").grid(row=0, column=3, sticky="w") + ttk.Combobox( + options, + textvariable=self.bin_channel_count_var, + values=("8", "16"), + state="readonly", + width=5, + ).grid(row=0, column=4, padx=(8, 18)) + + ttk.Label(options, text="显示通道").grid(row=0, column=5, sticky="w") + ttk.Entry(options, textvariable=self.channel_var, width=14).grid( + row=0, column=6, padx=(8, 8) + ) + + ttk.Label(options, text="测量边沿").grid(row=1, column=0, sticky="w", pady=(8, 0)) + edge_box = ttk.Combobox( + options, + textvariable=self.edge_var, + values=tuple(EDGE_MODES), + state="readonly", + width=8, + ) + edge_box.grid(row=1, column=1, sticky="w", padx=(8, 18), pady=(8, 0)) + edge_box.bind("<>", lambda _event: self._apply_plot_settings()) + + ttk.Label(options, text="关键时间(ms,逗号分隔,最多10个)").grid( + row=1, column=3, sticky="w", pady=(8, 0) + ) + marker_entry = ttk.Entry(options, textvariable=self.marker_var, width=28) + marker_entry.grid(row=1, column=4, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 0)) + marker_entry.bind("", lambda _event: self._apply_plot_settings()) + ttk.Button(options, text="更新曲线", command=self._apply_plot_settings).grid( + row=1, column=6, sticky="w", pady=(8, 0) + ) + + viewer = ttk.Frame(self, padding=(16, 0, 16, 10)) + viewer.grid(row=3, column=0, sticky="nsew") + viewer.columnconfigure(0, weight=1) + viewer.rowconfigure(2, weight=1) + + info_bar = ttk.Frame(viewer) + info_bar.grid(row=0, column=0, sticky="ew", pady=(0, 6)) + info_bar.columnconfigure(0, weight=1) + ttk.Label(info_bar, textvariable=self.file_info_var).grid( + row=0, column=0, sticky="w" + ) + ttk.Label(info_bar, textvariable=self.cursor_var).grid( + row=0, column=1, sticky="e" + ) + + toolbar = ttk.Frame(viewer) + toolbar.grid(row=1, column=0, sticky="ew", pady=(0, 6)) + toolbar.columnconfigure(3, weight=1) + ttk.Button(toolbar, text="放大", command=lambda: self._zoom_view(0.6)).grid( + row=0, column=0 + ) + ttk.Button(toolbar, text="缩小", command=lambda: self._zoom_view(1.6)).grid( + row=0, column=1, padx=(6, 0) + ) + ttk.Button(toolbar, text="适应窗口", command=self._fit_view).grid( + row=0, column=2, padx=(6, 0) + ) + ttk.Label( + toolbar, + textvariable=self.marker_info_var, + wraplength=800, + ).grid( + row=1, column=0, columnspan=4, sticky="w", pady=(6, 0) + ) + + self.canvas = tk.Canvas( + viewer, + background="#ffffff", + highlightthickness=1, + highlightbackground="#aeb4bc", + cursor="crosshair", + ) + self.canvas.grid(row=2, column=0, sticky="nsew") + self.canvas.bind("", lambda _event: self._schedule_redraw()) + self.canvas.bind("", self._on_mouse_wheel) + self.canvas.bind("", self._start_drag) + self.canvas.bind("", self._drag_view) + self.canvas.bind("", self._end_drag) + self.canvas.bind("", self._show_cursor) + self.canvas.bind("", self._hide_cursor) + + def _choose_input(self) -> None: + """选择 CSV 或 BIN 文件并立即读取。""" + path = filedialog.askopenfilename( + title="选择正点原子逻辑分析仪波形文件", + filetypes=( + ("逻辑分析仪文件", "*.csv *.bin"), + ("CSV 文件", "*.csv"), + ("BIN 文件", "*.bin"), + ("所有文件", "*.*"), + ), + ) + if not path: + return + self.input_var.set(path) + self._start_load() + + def _get_bin_sample_rate(self) -> float: + """读取界面中的 BIN 采样率。""" + try: + value = float(self.sample_rate_var.get().strip()) + except ValueError as exc: + raise CsvParseError("BIN 采样率必须是数字") from exc + if value <= 0: + raise CsvParseError("BIN 采样率必须大于 0") + return value * RATE_UNITS[self.sample_rate_unit_var.get().lower()] + + def _start_load(self) -> None: + """校验参数并在后台读取波形文件。""" + input_name = self.input_var.get().strip() + if not input_name: + messagebox.showwarning("未选择文件", "请先选择 CSV 或 BIN 文件。") + return + path = Path(input_name) + if not path.is_file(): + messagebox.showerror("文件不存在", f"找不到文件:\n{path}") + return + try: + sample_rate = self._get_bin_sample_rate() + channel_count = int(self.bin_channel_count_var.get()) + parse_marker_times_ms(self.marker_var.get()) + except (ValueError, CsvParseError) as exc: + messagebox.showerror("参数错误", str(exc)) + return + + self.load_button.configure(state="disabled") + self.status_var.set("正在读取并计算频率……") + self.file_info_var.set(f"正在读取:{path.name}") + worker = threading.Thread( + target=self._load_worker, + args=(path, sample_rate, channel_count, self.channel_var.get()), + daemon=True, + ) + worker.start() + self.after(100, self._check_load_result) + + def _load_worker( + self, + path: Path, + sample_rate: float, + channel_count: int, + channel_text: str, + ) -> None: + """在线程中解析较大的 BIN 文件。""" + try: + result = load_waveform(path, sample_rate, channel_count, channel_text) + self.result_queue.put(("ok", result)) + except (OSError, CsvParseError) as exc: + self.result_queue.put(("error", str(exc))) + + def _check_load_result(self) -> None: + """接收后台解析结果并生成频率曲线。""" + try: + result_type, result = self.result_queue.get_nowait() + except queue.Empty: + self.after(100, self._check_load_result) + return + + self.load_button.configure(state="normal") + if result_type == "error": + self.status_var.set("读取失败") + self.file_info_var.set("波形文件读取失败") + messagebox.showerror("读取失败", str(result)) + return + + data, channels = result # type: ignore[misc] + self.waveform_data = data + self.visible_channels = channels + try: + self._rebuild_frequency_curves() + except CsvParseError as exc: + self.status_var.set("频率计算失败") + messagebox.showerror("频率计算失败", str(exc)) + return + self.file_info_var.set(self._build_file_info(data)) + self.status_var.set(f"已生成 {len(self.frequency_curves)} 条频率曲线") + + def _apply_plot_settings(self) -> None: + """应用测量边沿和关键时间设置。""" + if self.waveform_data is None: + return + try: + self._rebuild_frequency_curves() + except CsvParseError as exc: + messagebox.showerror("曲线设置错误", str(exc)) + + def _rebuild_frequency_curves(self) -> None: + """按当前边沿设置重新计算频率轨迹。""" + edge_mode = EDGE_MODES[self.edge_var.get()] + self.marker_times = parse_marker_times_ms(self.marker_var.get()) + self.frequency_curves = build_frequency_curves( + self.visible_channels, edge_mode + ) + self.frequency_origin = min(curve.start_time for curve in self.frequency_curves) + self.view_start = self.frequency_origin + self.view_end = max(curve.end_time for curve in self.frequency_curves) + if self.view_end <= self.view_start: + self.view_end = self.view_start + 1e-6 + self._update_marker_summary() + self.cursor_var.set("光标:-") + self._schedule_redraw() + + @staticmethod + def _build_file_info(data: WaveformData) -> str: + """生成文件采样信息。""" + parts = [data.source_type] + if data.sample_rate: + parts.append(f"采样率 {format_frequency(data.sample_rate)}") + if data.sample_count is not None: + parts.append(f"采样点 {data.sample_count:,}") + if data.source_type == "CSV": + parts.append(f"记录行 {data.record_count:,}") + return " | ".join(parts) + + def _update_marker_summary(self) -> None: + """在工具栏显示所有关键节点的测量结果。""" + if not self.marker_times or not self.frequency_curves: + self.marker_info_var.set("关键节点:-") + return + summaries = [] + for marker in self.marker_times: + target_time = self.frequency_origin + marker + values = [] + for curve in self.frequency_curves: + if target_time > curve.end_time: + values.append(f"{curve.channel_name} 超出范围") + continue + measured_time, frequency = curve.frequency_at(target_time) + measured_ms = (measured_time - self.frequency_origin) * 1_000.0 + values.append( + f"{curve.channel_name} {format_frequency(frequency)}" + f"@{measured_ms:.3f}ms" + ) + summaries.append(f"{marker * 1000:g}ms:" + " | ".join(values)) + self.marker_info_var.set(" ".join(summaries)) + + def _plot_bounds(self) -> tuple[float, float, float, float]: + """返回频率曲线绘图区边界。""" + width = max(1, self.canvas.winfo_width()) + height = max(1, self.canvas.winfo_height()) + return 92.0, 35.0, max(93.0, width - 24.0), max(36.0, height - 58.0) + + def _schedule_redraw(self) -> None: + """合并连续的重绘请求。""" + if self.redraw_job is None: + self.redraw_job = self.after_idle(self._draw_frequency_chart) + + def _draw_frequency_chart(self) -> None: + """绘制频率随时间变化的曲线。""" + self.redraw_job = None + self.canvas.delete("all") + if not self.frequency_curves: + self.canvas.create_text( + self.canvas.winfo_width() / 2, + self.canvas.winfo_height() / 2, + text="请选择包含脉冲的 CSV 或 BIN 文件", + fill="#6b7280", + font=("Microsoft YaHei UI", 12), + ) + return + + left, top, right, bottom = self._plot_bounds() + plot_width = right - left + plot_height = bottom - top + time_span = max(self.view_end - self.view_start, 1e-15) + maximum_frequency = nice_frequency_max(self._visible_max_frequency()) + + # 绘制横向频率网格和纵轴刻度。 + for tick in range(9): + ratio = tick / 8.0 + y = bottom - ratio * plot_height + frequency = ratio * maximum_frequency + self.canvas.create_line(left, y, right, y, fill="#e2e5e9", width=1) + self.canvas.create_text( + left - 9, + y, + anchor="e", + text=format_hz_tick(frequency), + fill="#4b5563", + font=("Consolas", 9), + ) + + # 绘制时间网格,横轴从第一个有效脉冲边沿开始计时。 + time_scale, time_unit = choose_time_unit(time_span) + for tick in range(11): + ratio = tick / 10.0 + x = left + ratio * plot_width + time_value = self.view_start + ratio * time_span + relative_time = (time_value - self.frequency_origin) * time_scale + self.canvas.create_line(x, top, x, bottom, fill="#edf0f3", width=1) + self.canvas.create_text( + x, + bottom + 18, + text=format_tick(relative_time), + fill="#4b5563", + font=("Consolas", 9), + ) + + self.canvas.create_text( + 20, + (top + bottom) / 2, + text="脉冲频率 (Hz)", + angle=90, + fill="#111827", + font=("Microsoft YaHei UI", 10), + ) + self.canvas.create_text( + (left + right) / 2, + bottom + 42, + text=f"脉冲开始后的时间 ({time_unit})", + fill="#111827", + font=("Microsoft YaHei UI", 10), + ) + + for curve_number, curve in enumerate(self.frequency_curves): + color = CHANNEL_COLORS[curve.channel_index % len(CHANNEL_COLORS)] + self._draw_curve( + curve, left, right, top, bottom, maximum_frequency, color + ) + self.canvas.create_line( + left + curve_number * 190, + 16, + left + 20 + curve_number * 190, + 16, + fill=color, + width=3, + ) + self.canvas.create_text( + left + 26 + curve_number * 190, + 16, + anchor="w", + text=f"{curve.channel_name}({curve.edge_name})", + fill="#1f2937", + font=("Microsoft YaHei UI", 9, "bold"), + ) + + self._draw_markers(left, right, top, bottom, maximum_frequency) + self.canvas.create_line(left, top, left, bottom, fill="#4b5563", width=1) + self.canvas.create_line(left, bottom, right, bottom, fill="#4b5563", width=1) + + def _visible_max_frequency(self) -> float: + """读取当前时间窗口中的最大频率。""" + maximum = 0.0 + for curve in self.frequency_curves: + start = max(0, bisect.bisect_left(curve.times, self.view_start) - 1) + end = min(len(curve.times), bisect.bisect_right(curve.times, self.view_end) + 1) + if start < end: + maximum = max(maximum, max(curve.frequencies[start:end])) + return max(maximum, 1.0) + + def _draw_curve( + self, + curve: FrequencyCurve, + left: float, + right: float, + top: float, + bottom: float, + maximum_frequency: float, + color: str, + ) -> None: + """绘制一条频率轨迹。""" + start = max(0, bisect.bisect_left(curve.times, self.view_start) - 1) + end = min(len(curve.times), bisect.bisect_right(curve.times, self.view_end) + 1) + if end - start < 2: + return + time_span = self.view_end - self.view_start + plot_width = right - left + plot_height = bottom - top + indexes = list(range(start, end)) + maximum_points = max(100, int(plot_width * 3)) + if len(indexes) > maximum_points: + indexes = decimate_curve_indexes( + curve.frequencies, start, end, maximum_points + ) + + coordinates: list[float] = [] + for index in indexes: + x = left + (curve.times[index] - self.view_start) / time_span * plot_width + y = bottom - curve.frequencies[index] / maximum_frequency * plot_height + coordinates.extend((x, y)) + self.canvas.create_line(*coordinates, fill=color, width=2, smooth=False) + + def _draw_markers( + self, + left: float, + right: float, + top: float, + bottom: float, + maximum_frequency: float, + ) -> None: + """标出用户设置的关键时间和对应频率。""" + time_span = self.view_end - self.view_start + plot_height = bottom - top + for marker in self.marker_times: + target_time = self.frequency_origin + marker + if not self.view_start <= target_time <= self.view_end: + continue + x = left + (target_time - self.view_start) / time_span * (right - left) + self.canvas.create_line( + x, top, x, bottom, fill="#d97706", width=1, dash=(5, 4) + ) + self.canvas.create_text( + x + 5, + top + 4, + anchor="nw", + text=f"{marker * 1000:g} ms", + fill="#92400e", + font=("Microsoft YaHei UI", 9, "bold"), + ) + for curve_number, curve in enumerate(self.frequency_curves): + if target_time > curve.end_time: + continue + measured_time, frequency = curve.frequency_at(target_time) + point_x = left + (measured_time - self.view_start) / time_span * (right - left) + point_y = bottom - frequency / maximum_frequency * plot_height + color = CHANNEL_COLORS[curve.channel_index % len(CHANNEL_COLORS)] + self.canvas.create_oval( + point_x - 4, + point_y - 4, + point_x + 4, + point_y + 4, + fill="#ffffff", + outline=color, + width=2, + ) + self.canvas.create_text( + point_x + 7, + point_y - 7 - curve_number * 17, + anchor="sw", + text=format_frequency(frequency), + fill=color, + font=("Microsoft YaHei UI", 9, "bold"), + ) + + def _full_time_range(self) -> tuple[float, float]: + """返回所有频率曲线的完整时间范围。""" + start = self.frequency_origin + end = max(curve.end_time for curve in self.frequency_curves) + return start, max(end, start + 1e-15) + + def _fit_view(self) -> None: + """恢复频率曲线的完整时间范围。""" + if not self.frequency_curves: + return + self.view_start, self.view_end = self._full_time_range() + self._schedule_redraw() + + def _zoom_view(self, factor: float, anchor_x: float | None = None) -> None: + """以鼠标位置或窗口中心为基准缩放时间轴。""" + if not self.frequency_curves: + return + left, _top, right, _bottom = self._plot_bounds() + ratio = 0.5 + if anchor_x is not None and right > left: + ratio = min(1.0, max(0.0, (anchor_x - left) / (right - left))) + old_span = self.view_end - self.view_start + full_start, full_end = self._full_time_range() + full_span = full_end - full_start + minimum_span = max(full_span / 1_000_000_000.0, 1e-12) + new_span = min(full_span, max(minimum_span, old_span * factor)) + anchor_time = self.view_start + old_span * ratio + new_start = anchor_time - new_span * ratio + self._set_view_range(new_start, new_start + new_span) + + def _set_view_range(self, start: float, end: float) -> None: + """限制时间窗口不能移出频率曲线范围。""" + if not self.frequency_curves: + return + full_start, full_end = self._full_time_range() + span = min(end - start, full_end - full_start) + if start < full_start: + start = full_start + if start + span > full_end: + start = full_end - span + self.view_start = start + self.view_end = start + span + self._schedule_redraw() + + def _on_mouse_wheel(self, event: tk.Event) -> None: + """使用鼠标滚轮缩放时间轴。""" + factor = 0.75 if event.delta > 0 else 1.35 + self._zoom_view(factor, float(event.x)) + + def _start_drag(self, event: tk.Event) -> None: + """记录拖动开始位置。""" + if self.frequency_curves: + self.drag_state = (event.x, self.view_start, self.view_end) + + def _drag_view(self, event: tk.Event) -> None: + """按鼠标水平位移平移时间轴。""" + if self.drag_state is None: + return + start_x, original_start, original_end = self.drag_state + left, _top, right, _bottom = self._plot_bounds() + if right <= left: + return + time_shift = -(event.x - start_x) / (right - left) * ( + original_end - original_start + ) + self._set_view_range(original_start + time_shift, original_end + time_shift) + + def _end_drag(self, _event: tk.Event) -> None: + """结束鼠标拖动。""" + self.drag_state = None + + def _show_cursor(self, event: tk.Event) -> None: + """显示鼠标时间位置和各通道的实测频率。""" + if not self.frequency_curves: + return + left, top, right, bottom = self._plot_bounds() + if not left <= event.x <= right or not top <= event.y <= bottom: + self._hide_cursor(event) + return + ratio = (event.x - left) / (right - left) + time_value = self.view_start + ratio * (self.view_end - self.view_start) + relative_time = time_value - self.frequency_origin + values = [] + for curve in self.frequency_curves: + _measured_time, frequency = curve.frequency_at(time_value) + values.append(f"{curve.channel_name} {format_frequency(frequency)}") + self.canvas.delete("cursor") + self.canvas.create_line( + event.x, + top, + event.x, + bottom, + fill="#374151", + dash=(3, 3), + tags="cursor", + ) + self.cursor_var.set( + f"光标 {format_duration(relative_time)}:" + " | ".join(values) + ) + + def _hide_cursor(self, _event: tk.Event | None = None) -> None: + """清除鼠标光标线。""" + self.canvas.delete("cursor") + self.cursor_var.set("光标:-") + + +def nice_frequency_max(value: float) -> float: + """把纵轴最大频率向上取为 1、2、5、10 的整倍数。""" + if value <= 0: + return 1.0 + exponent = 10 ** math.floor(math.log10(value)) + fraction = value / exponent + for candidate in (1.0, 2.0, 5.0, 10.0): + if fraction <= candidate: + return candidate * exponent + return 10.0 * exponent + + +def decimate_curve_indexes( + frequencies: list[float], start: int, end: int, maximum_points: int +) -> list[int]: + """抽取曲线点,同时保留每个时间桶内的最高值和最低值。""" + point_count = end - start + if point_count <= maximum_points: + return list(range(start, end)) + bucket_count = max(1, maximum_points // 2) + indexes = [start] + for bucket in range(bucket_count): + bucket_start = start + int(bucket * point_count / bucket_count) + bucket_end = start + int((bucket + 1) * point_count / bucket_count) + bucket_end = min(end, max(bucket_start + 1, bucket_end)) + minimum_index = min( + range(bucket_start, bucket_end), key=frequencies.__getitem__ + ) + maximum_index = max( + range(bucket_start, bucket_end), key=frequencies.__getitem__ + ) + indexes.extend(sorted((minimum_index, maximum_index))) + indexes.append(end - 1) + return sorted(set(indexes)) + + +def choose_time_unit(span: float) -> tuple[float, str]: + """根据当前时间范围选择合适的横轴单位。""" + if span >= 1.0: + return 1.0, "s" + if span >= 0.001: + return 1_000.0, "ms" + if span >= 0.000001: + return 1_000_000.0, "us" + return 1_000_000_000.0, "ns" + + +def format_tick(value: float) -> str: + """格式化时间轴刻度。""" + absolute = abs(value) + if absolute >= 1000: + return f"{value:.0f}" + if absolute >= 10: + return f"{value:.2f}" + return f"{value:.3f}" + + +def format_hz_tick(frequency: float) -> str: + """以 Hz 为单位格式化纵轴刻度。""" + if frequency >= 100: + return f"{frequency:.0f}" + if frequency >= 10: + return f"{frequency:.1f}" + return f"{frequency:.2f}" + + +def format_frequency(frequency: float) -> str: + """使用 Hz、kHz、MHz 或 GHz 显示测量结果。""" + if frequency >= 1_000_000_000: + return f"{frequency / 1_000_000_000:.4g} GHz" + if frequency >= 1_000_000: + return f"{frequency / 1_000_000:.4g} MHz" + if frequency >= 1_000: + return f"{frequency / 1_000:.4g} kHz" + return f"{frequency:.4g} Hz" + + +def format_duration(duration: float) -> str: + """使用合适单位显示时间长度。""" + if duration >= 1.0: + return f"{duration:.6g} s" + if duration >= 0.001: + return f"{duration * 1_000:.6g} ms" + if duration >= 0.000001: + return f"{duration * 1_000_000:.6g} us" + return f"{duration * 1_000_000_000:.6g} ns" + + +def main() -> None: + """启动脉冲频率轨迹查看器。""" + app = LogicWaveformApp() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/HostComputer/PythonTest/plsr_console.py b/HostComputer/PythonTest/plsr_console.py new file mode 100644 index 0000000..054ee31 --- /dev/null +++ b/HostComputer/PythonTest/plsr_console.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Interactive console for technicians testing the PLSR firmware.""" + +from __future__ import annotations + +import time + +import plsr_test + + +def ask_int(prompt: str, default: int, minimum: int, maximum: int) -> int: + while True: + raw = input(f"{prompt} [{default}]: ").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError: + print("请输入整数。") + continue + if minimum <= value <= maximum: + return value + print(f"允许范围:{minimum}..{maximum}") + + +def ask_yes(prompt: str) -> bool: + return input(f"{prompt} [y/N]: ").strip().lower() in ("y", "yes") + + +def choose_port() -> str: + if plsr_test.list_ports is None: + raise plsr_test.PlsrError("缺少 pyserial,请重新运行启动批处理。") + ports = sorted(plsr_test.list_ports.comports(), key=lambda item: item.device) + if ports: + print("\n检测到的串口:") + for index, item in enumerate(ports, 1): + print(f" {index}. {item.device:<8} {item.description}") + devices = [item.device for item in ports] + default = "COM9" if "COM9" in devices else (devices[0] if devices else "COM9") + selected = input(f"请输入测试串口 [{default}]: ").strip().upper() + return selected or default + + +def confirm_motion() -> bool: + print("\n安全确认:断开机械/伺服负载,仅连接测试灯或示波器。") + return ask_yes("已满足上述条件,允许输出脉冲吗?") + + +def show_menu(port: str, protocol: str) -> None: + print( + f""" +================ PLSR Python 测试工具 ================ +当前串口:{port} 参数:115200 / 8E1 / 站号1 / 映射:{protocol} + 1. 读取一次状态 + 2. 持续监视状态 + 3. 配置并保存脉冲点/方向点 + 4. 正向低速灯测 + 5. 反向低速灯测 + 6. 正反向自动测试 + 7. 受控停止 + 8. 立即停止 + 9. 复位 + 10. 清零累计脉冲 + 11. 显示原始通信帧开关 + 12. 更换串口 + 0. 退出 +====================================================== +""" + ) + + +def run_motion(client: plsr_test.ModbusRtuClient, direction: int) -> None: + if not confirm_motion(): + print("已取消。") + return + if client.protocol == plsr_test.MAP_SPEC: + pulses = ask_int("脉冲数(可负,0 不允许)", 10, -2147483648, 2147483647) + while pulses == 0: + print("脉冲数不能为 0。") + pulses = ask_int("脉冲数(可负,0 不允许)", 10, -2147483648, 2147483647) + frequency = ask_int("频率 Hz(1..100000,肉眼观察建议 1..5)", 2, 1, 100000) + else: + pulses = ask_int("脉冲数", 10, 1, 0xFFFFFFFF) + frequency = ask_int("频率 Hz(肉眼观察建议 1..5)", 2, 1, 1000) + client.start_motion(pulses, frequency, direction) + result = plsr_test.wait_for_terminal(client, max(10.0, pulses / frequency + 5.0)) + if result.state == 5: + print("PASS:运动正常完成。") + else: + print(f"FAIL:运动结束状态为 {result.state_name}。") + + +def auto_test(client: plsr_test.ModbusRtuClient) -> None: + if not confirm_motion(): + print("已取消。") + return + if client.protocol == plsr_test.MAP_SPEC: + pulse_output = ask_int("脉冲输出 Y0..Y3", 0, 0, 3) + direction_output = ask_int("方向输出 Y12..Y15(填 0..3)", 0, 0, 3) + else: + pulse_output = ask_int("脉冲输出 Q 点", 4, 0, 7) + direction_output = ask_int("方向输出 Q 点", 1, 0, 7) + if pulse_output == direction_output: + print("脉冲点和方向点不能相同。") + return + pulses = ask_int("每个方向的脉冲数", 6, 1, 10000) + frequency = ask_int("频率 Hz", 2, 1, 100000 if client.protocol == plsr_test.MAP_SPEC else 1000) + save = False if client.protocol == plsr_test.MAP_SPEC else ask_yes("将 Q 点配置保存到 Flash 吗?") + plsr_test.print_snapshot(client.configure_outputs(pulse_output, direction_output, save)) + client.command(plsr_test.COMMAND_CLEAR_COUNT) + + print("\n开始正向测试,请观察脉冲灯。") + client.start_motion(pulses, frequency, 0) + forward = plsr_test.wait_for_terminal(client, max(10.0, pulses / frequency + 5.0)) + if forward.state != 5 or forward.total_pulses != pulses: + raise plsr_test.PlsrError( + f"正向失败:state={forward.state_name}, total={forward.total_pulses}, expected={pulses}" + ) + + print("\n开始反向测试,脉冲灯应继续闪烁,方向点电平应改变。") + client.start_motion(pulses, frequency, 1) + reverse = plsr_test.wait_for_terminal(client, max(10.0, pulses / frequency + 5.0)) + expected = pulses * 2 + if reverse.state != 5 or reverse.total_pulses != expected: + raise plsr_test.PlsrError( + f"反向失败:state={reverse.state_name}, total={reverse.total_pulses}, expected={expected}" + ) + print(f"PASS:正反向自动测试完成,累计脉冲={reverse.total_pulses}。") + + +def run_menu(port: str, protocol: str) -> str | None: + verbose = False + with plsr_test.ModbusRtuClient(port, timeout=1.0, retries=2, verbose=verbose, protocol=protocol) as client: + print("\n串口连接成功。") + try: + plsr_test.print_snapshot(client.read_snapshot()) + except plsr_test.PlsrError as exc: + print(f"首次状态读取失败:{exc}") + + while True: + show_menu(port, protocol) + choice = input("请选择操作: ").strip() + try: + if choice == "0": + return None + if choice == "1": + plsr_test.print_snapshot(client.read_snapshot()) + elif choice == "2": + print("持续监视中,按 Ctrl+C 返回菜单。") + try: + while True: + plsr_test.print_snapshot(client.read_snapshot()) + time.sleep(0.5) + except KeyboardInterrupt: + print("\n已停止监视。") + elif choice == "3": + if protocol == plsr_test.MAP_SPEC: + pulse = ask_int("脉冲输出 Y0..Y3", 0, 0, 3) + direction = ask_int("方向输出 Y12..Y15(填 0..3)", 0, 0, 3) + else: + pulse = ask_int("脉冲输出 Q 点", 4, 0, 7) + direction = ask_int("方向输出 Q 点", 1, 0, 7) + result = client.configure_outputs(pulse, direction, save=False) + plsr_test.print_snapshot(result) + print("配置已写入设备。需求版是否掉电保存由固件保存策略决定。") + elif choice == "4": + run_motion(client, 0) + elif choice == "5": + run_motion(client, 1) + elif choice == "6": + auto_test(client) + elif choice == "7": + client.command(plsr_test.COMMAND_STOP) + plsr_test.print_snapshot(client.read_snapshot()) + elif choice == "8": + client.command(plsr_test.COMMAND_IMMEDIATE_STOP) + plsr_test.print_snapshot(client.read_snapshot()) + elif choice == "9": + client.command(plsr_test.COMMAND_RESET) + plsr_test.print_snapshot(client.read_snapshot()) + elif choice == "10": + client.command(plsr_test.COMMAND_CLEAR_COUNT) + plsr_test.print_snapshot(client.read_snapshot()) + elif choice == "11": + verbose = not verbose + client.verbose = verbose + print(f"原始帧显示:{'开启' if verbose else '关闭'}") + elif choice == "12": + return choose_port() + else: + print("无效选项。") + except (OSError, ValueError, plsr_test.PlsrError) as exc: + print(f"操作失败:{exc}") + + +def main() -> int: + print("PLSR Python 测试工具") + protocol = input("协议映射 spec(参数表-2026)/ legacy(旧固件) [spec]: ").strip().lower() or plsr_test.MAP_SPEC + if protocol not in (plsr_test.MAP_SPEC, plsr_test.MAP_LEGACY): + print("无效映射,使用 spec。") + protocol = plsr_test.MAP_SPEC + port = choose_port() + while port: + try: + port = run_menu(port, protocol) + except (OSError, plsr_test.PlsrError) as exc: + print(f"\n无法使用 {port}:{exc}") + if not ask_yes("重新选择串口吗?"): + return 1 + port = choose_port() + print("测试工具已退出。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/HostComputer/PythonTest/plsr_gui.py b/HostComputer/PythonTest/plsr_gui.py new file mode 100644 index 0000000..8d5b952 --- /dev/null +++ b/HostComputer/PythonTest/plsr_gui.py @@ -0,0 +1,1308 @@ +#!/usr/bin/env python3 +"""Tkinter upper-computer test application for the PLSR Modbus firmware.""" + +from __future__ import annotations + +import queue +import threading +import time +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox, ttk +from typing import Any, Optional + +from plsr_test import ( + COMMAND_CLEAR_COUNT, + COMMAND_IMMEDIATE_STOP, + COMMAND_RESTART, + COMMAND_RESET, + COMMAND_STOP, + CommunicationError, + ModbusRtuClient, + PlsrError, + Snapshot, + MAP_SPEC, + MAP_LEGACY, + REG_SEGMENT_BASE, + REG_MONITOR_TOTAL_PULSES, + REG_CONTROL, + PlsrConfiguration, + SegmentParameters, + validate_frequency, + list_ports, +) + + +APP_TITLE = "PLSR 单轴脉冲测试台" +POLL_INTERVAL_MS = 500 +MAX_LOG_LINES = 800 + +STATE_TEXT = { + 0: "空闲", + 1: "准备", + 2: "运行中", + 3: "等待", + 4: "减速中", + 5: "完成", + 6: "已停止", + 7: "错误", +} +STOP_REASON_TEXT = { + 0: "0 · 无", + 1: "1 · 受控停止", + 2: "2 · 立即停止", +} +ERROR_TEXT = { + 0: "0 · 无错误", + 1: "1 · 参数无效", + 2: "2 · 设备忙", + 3: "3 · 定时器资源错误", +} +REGISTER_NAMES = { + 0: "运行状态", + 1: "状态标志", + 3: "当前频率 Hz", + 5: "停止原因", + 6: "最后错误", + 10: "累计脉冲高字", + 11: "累计脉冲低字", + 100: "命令", + 102: "目标脉冲高字", + 103: "目标脉冲低字", + 104: "设定频率 Hz", + 105: "方向 0正/1反", + 106: "脉冲输出 Q 点", + 107: "方向输出 Q 点", +} +SPEC_REGISTER_NAMES = { + 0x1000: "脉冲发送端子 Y0..Y3", 0x1001: "脉冲方向端子 Y12..Y15", + 0x1002: "WAIT 信号 X4/X5", 0x1003: "EXT 信号 X4/X5", + 0x1004: "发送模式", 0x1005: "方向延时 ms", 0x1006: "方向逻辑", + 0x1007: "加减速模式", 0x1008: "运行模式", 0x1009: "总段数", + 0x100A: "起始执行段", 0x100B: "默认速度高字", 0x100C: "默认速度低字", + 0x100D: "起始速度高字", 0x100E: "起始速度低字", 0x1010: "终止速度高字", + 0x1011: "终止速度低字", 0x1012: "加速时间 ms", 0x1013: "减速时间 ms", + 0x2000: "累计脉冲高字", 0x2001: "累计脉冲低字", 0x2002: "频率高字", + 0x2003: "频率低字", 0x2004: "运行状态", 0x2005: "当前执行段", + 0x2006: "错误码", 0x2007: "停止原因", + 0x3000: "控制 bit0启动/bit1停止/bit2清零/bit3重启/bit4复位/bit5立即停止", +} + + +class GuiModbusClient(ModbusRtuClient): + """Send raw-frame messages to Tk through a thread-safe queue.""" + + def __init__(self, *args: Any, event_queue: queue.Queue, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._event_queue = event_queue + + def _log_frame(self, prefix: str, frame: bytes) -> None: + now = time.time() + timestamp = time.strftime("%H:%M:%S", time.localtime(now)) + f".{int(now % 1 * 1000):03d}" + self._event_queue.put( + { + "type": "log", + "line": f"{timestamp} {prefix} {frame.hex(' ').upper()}", + } + ) + + +class SerialWorker(threading.Thread): + """Own the serial object so only one request can be active at a time.""" + + def __init__(self, jobs: queue.Queue, events: queue.Queue) -> None: + super().__init__(name="plsr-serial-worker", daemon=True) + self.jobs = jobs + self.events = events + self.client: Optional[GuiModbusClient] = None + + def _require_client(self) -> GuiModbusClient: + if self.client is None: + raise CommunicationError("设备尚未连接") + return self.client + + def run(self) -> None: + while True: + job = self.jobs.get() + name = job["name"] + if name == "shutdown": + if self.client is not None: + self.client.close() + return + + try: + result: Any = None + if name == "connect": + if self.client is not None: + self.client.close() + self.client = GuiModbusClient( + port=job["port"], + baud_rate=job["baud"], + slave=job["slave"], + timeout=0.6, + retries=2, + verbose=False, + event_queue=self.events, + protocol=job.get("protocol", MAP_SPEC), + ) + try: + self.client.open() + result = self.client.read_snapshot() + except Exception: + self.client.close() + self.client = None + raise + elif name == "disconnect": + if self.client is not None: + self.client.close() + self.client = None + elif name == "snapshot": + result = self._require_client().read_snapshot() + elif name == "read_config": + result = self._require_client().read_configuration() + elif name == "write_config": + client = self._require_client() + client.write_configuration(job["config"]) + result = client.read_configuration() + elif name == "write_config_command": + client = self._require_client() + client.write_configuration(job["config"]) + client.command(job["command"]) + result = client.read_snapshot() + elif name == "raw": + client = self._require_client() + if client.protocol == MAP_SPEC: + result = {"config": client.read_holding(0x1000, 0x14), + "segments": [client.read_holding(REG_SEGMENT_BASE + i * 0x10, 8) for i in range(10)], + "monitor": client.read_holding(REG_MONITOR_TOTAL_PULSES, 8), + "control": client.read_holding(REG_CONTROL, 1)} + else: + result = {"status": client.read_holding(0, 32), "control": client.read_holding(100, 8)} + elif name == "configure": + result = self._require_client().configure_outputs( + job["pulse_output"], job["direction_output"], save=True + ) + elif name == "start": + client = self._require_client() + client.start_motion(job["pulses"], job["frequency"], + job["direction"], job.get("restart", False)) + result = client.read_snapshot() + elif name == "frequency": + result = self._require_client().set_running_frequency( + job["frequency"] + ) + elif name == "command": + client = self._require_client() + client.command(job["command"]) + result = client.read_snapshot() + else: + raise ValueError(f"未知后台任务: {name}") + + self.events.put( + { + "type": "result", + "name": name, + "purpose": job.get("purpose", ""), + "result": result, + } + ) + except Exception as exc: + self.events.put( + { + "type": "error", + "name": name, + "purpose": job.get("purpose", ""), + "error": str(exc), + } + ) + + +class PlsrGui(tk.Tk): + def __init__(self) -> None: + super().__init__() + self.title(f"{APP_TITLE} - Python") + self.geometry("1120x820") + self.minsize(960, 680) + self.configure(background="#f2f5f8") + + self.jobs: queue.Queue = queue.Queue() + self.events: queue.Queue = queue.Queue() + self.worker = SerialWorker(self.jobs, self.events) + self.worker.start() + + self.connected = False + self.job_active = False + self.poll_active = False + self.deferred_job: Optional[dict[str, Any]] = None + self.last_snapshot: Optional[Snapshot] = None + self.communication_errors = 0 + self.closing = False + self.log_line_count = 0 + self.auto_phase: Optional[str] = None + self.run_baseline = 0 + self.run_target = 0 + self.motion_plan: Optional[tuple[int, int, int]] = None + self.lamp_test_baseline = 0 + + self._create_variables() + self._configure_style() + self._build_ui() + self._refresh_ports() + self._update_controls() + self.protocol("WM_DELETE_WINDOW", self._on_close) + self.after(50, self._drain_events) + self.after(POLL_INTERVAL_MS, self._poll_tick) + + def _create_variables(self) -> None: + self.port_var = tk.StringVar(value="COM9") + self.baud_var = tk.StringVar(value="115200") + self.slave_var = tk.StringVar(value="1") + self.connection_var = tk.StringVar(value="未连接") + self.operation_var = tk.StringVar(value="等待连接") + self.pulse_output_var = tk.StringVar(value="Y0") + self.direction_output_var = tk.StringVar(value="Y12") + self.pulses_var = tk.StringVar(value="10") + self.frequency_var = tk.StringVar(value="2") + self.direction_var = tk.IntVar(value=0) + self.capture_frames_var = tk.BooleanVar(value=True) + self.protocol_var = tk.StringVar(value=MAP_SPEC) + + self.wait_input_var = tk.StringVar(value="X4") + self.ext_input_var = tk.StringVar(value="X4") + self.send_mode_var = tk.StringVar(value="完成模式") + self.direction_delay_var = tk.StringVar(value="0") + self.direction_logic_var = tk.StringVar(value="正逻辑") + self.accel_mode_var = tk.StringVar(value="直线") + self.run_mode_var = tk.StringVar(value="相对") + self.segment_count_var = tk.StringVar(value="1") + self.start_segment_var = tk.StringVar(value="1") + self.default_speed_var = tk.StringVar(value="1000") + self.start_speed_var = tk.StringVar(value="1") + self.end_speed_var = tk.StringVar(value="1") + self.accel_time_var = tk.StringVar(value="0") + self.decel_time_var = tk.StringVar(value="0") + self.segment_index_var = tk.StringVar(value="1") + self.segment_frequency_var = tk.StringVar(value="1000") + self.segment_pulses_var = tk.StringVar(value="10") + self.segment_wait_var = tk.StringVar(value="WAIT时间") + self.segment_wait_time_var = tk.StringVar(value="0") + self.segment_act_time_var = tk.StringVar(value="0") + self.segment_jump_var = tk.StringVar(value="0") + self.segment_models = [SegmentParameters() for _ in range(10)] + + self.state_var = tk.StringVar(value="--") + self.flags_var = tk.StringVar(value="--") + self.current_frequency_var = tk.StringVar(value="--") + self.total_pulses_var = tk.StringVar(value="--") + self.stop_reason_var = tk.StringVar(value="--") + self.error_var = tk.StringVar(value="--") + self.saved_outputs_var = tk.StringVar(value="--") + self.progress_text_var = tk.StringVar(value="0 / 0") + + def _configure_style(self) -> None: + style = ttk.Style(self) + if "clam" in style.theme_names(): + style.theme_use("clam") + default_font = ("Microsoft YaHei UI", 9) + self.option_add("*Font", default_font) + style.configure("TFrame", background="#f2f5f8") + style.configure("Surface.TFrame", background="#ffffff") + style.configure("TLabel", background="#ffffff", foreground="#20364d") + style.configure("TCheckbutton", background="#ffffff", foreground="#20364d") + style.configure("TRadiobutton", background="#ffffff", foreground="#20364d") + style.configure("Header.TLabel", background="#f2f5f8", foreground="#123354", font=("Microsoft YaHei UI", 20, "bold")) + style.configure("Subtitle.TLabel", background="#f2f5f8", foreground="#61758a") + style.configure("Section.TLabel", background="#ffffff", foreground="#173b61", font=("Microsoft YaHei UI", 10, "bold")) + style.configure("Value.TLabel", background="#ffffff", foreground="#102f4f", font=("Microsoft YaHei UI", 10, "bold")) + style.configure("StatusOff.TLabel", background="#fdebea", foreground="#b42318", padding=(12, 7), font=("Microsoft YaHei UI", 9, "bold")) + style.configure("StatusOn.TLabel", background="#e7f6ec", foreground="#167044", padding=(12, 7), font=("Microsoft YaHei UI", 9, "bold")) + style.configure("Primary.TButton", foreground="#ffffff", background="#1f6fa8", padding=(10, 6), font=("Microsoft YaHei UI", 9, "bold")) + style.map("Primary.TButton", background=[("active", "#185d8e"), ("disabled", "#aab7c2")]) + style.configure("Danger.TButton", foreground="#ffffff", background="#c93b3b", padding=(10, 6), font=("Microsoft YaHei UI", 9, "bold")) + style.map("Danger.TButton", background=[("active", "#a92f2f"), ("disabled", "#c9b2b2")]) + style.configure("TLabelframe", background="#ffffff", padding=10) + style.configure("TLabelframe.Label", background="#ffffff", foreground="#173b61", font=("Microsoft YaHei UI", 10, "bold")) + style.configure("TNotebook", background="#f2f5f8", borderwidth=0) + style.configure("TNotebook.Tab", padding=(16, 7)) + + def _build_ui(self) -> None: + outer = ttk.Frame(self, padding=(22, 14, 22, 12)) + outer.grid(row=0, column=0, sticky="nsew") + self.rowconfigure(0, weight=1) + self.columnconfigure(0, weight=1) + outer.columnconfigure(0, weight=1) + outer.rowconfigure(3, weight=1) + + header = ttk.Frame(outer) + header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + header.columnconfigure(0, weight=1) + ttk.Label(header, text=APP_TITLE, style="Header.TLabel").grid(row=0, column=0, sticky="w") + ttk.Label( + header, + text="XDM-60T4-E / STM32 · Modbus RTU · 可配置并掉电保存 Q 点", + style="Subtitle.TLabel", + ).grid(row=1, column=0, sticky="w", pady=(2, 0)) + self.connection_label = ttk.Label(header, textvariable=self.connection_var, style="StatusOff.TLabel") + self.connection_label.grid(row=0, column=1, rowspan=2, sticky="e") + + connection = ttk.LabelFrame(outer, text="串口连接") + connection.grid(row=1, column=0, sticky="ew", pady=(0, 10)) + connection.columnconfigure(8, weight=1) + ttk.Label(connection, text="端口").grid(row=0, column=0, padx=(0, 6)) + self.port_combo = ttk.Combobox(connection, textvariable=self.port_var, width=12, state="readonly") + self.port_combo.grid(row=0, column=1, padx=(0, 8)) + self.refresh_button = ttk.Button(connection, text="刷新", command=self._refresh_ports, width=9) + self.refresh_button.grid(row=0, column=2, padx=(0, 20)) + ttk.Label(connection, text="波特率").grid(row=0, column=3, padx=(0, 6)) + self.baud_combo = ttk.Combobox(connection, textvariable=self.baud_var, values=("9600", "19200", "38400", "57600", "115200"), width=10, state="readonly") + self.baud_combo.grid(row=0, column=4, padx=(0, 18)) + ttk.Label(connection, text="格式 8-E-1").grid(row=0, column=5, padx=(0, 18)) + ttk.Label(connection, text="站号").grid(row=0, column=6, padx=(0, 6)) + self.slave_spin = ttk.Spinbox(connection, textvariable=self.slave_var, from_=1, to=247, width=7) + self.slave_spin.grid(row=0, column=7) + ttk.Label(connection, text="映射").grid(row=0, column=8, padx=(16, 6)) + self.protocol_combo = ttk.Combobox(connection, textvariable=self.protocol_var, values=(MAP_SPEC, MAP_LEGACY), width=9, state="readonly") + self.protocol_combo.grid(row=0, column=9, padx=(0, 8)) + self.connect_button = ttk.Button(connection, text="连接设备", command=self._toggle_connection, style="Primary.TButton", width=14) + self.connect_button.grid(row=0, column=10, sticky="e", padx=(8, 0)) + + self.operation_label = ttk.Label(outer, textvariable=self.operation_var, style="Subtitle.TLabel") + self.operation_label.grid(row=2, column=0, sticky="w", pady=(0, 6)) + + notebook = ttk.Notebook(outer) + notebook.grid(row=3, column=0, sticky="nsew") + control_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10) + config_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10) + register_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10) + log_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10) + notebook.add(control_tab, text="控制与状态") + notebook.add(config_tab, text="完整参数与多段") + notebook.add(register_tab, text="寄存器诊断") + notebook.add(log_tab, text="通信日志") + self._build_control_tab(control_tab) + self._build_config_tab(config_tab) + self._build_register_tab(register_tab) + self._build_log_tab(log_tab) + notebook.select(control_tab) + + ttk.Label( + outer, + text="安全提示:首次发脉冲前请断开机械负载,仅连接测试灯或示波器;先低频验证 Q 点和方向电平。", + foreground="#a13b22", + background="#f2f5f8", + ).grid(row=4, column=0, sticky="w", pady=(9, 0)) + + def _build_control_tab(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1, uniform="panel") + parent.columnconfigure(1, weight=1, uniform="panel") + parent.rowconfigure(0, weight=1) + + left = ttk.Frame(parent, style="Surface.TFrame") + right = ttk.Frame(parent, style="Surface.TFrame") + left.grid(row=0, column=0, sticky="nsew", padx=(0, 7)) + right.grid(row=0, column=1, sticky="nsew", padx=(7, 0)) + left.columnconfigure(0, weight=1) + right.columnconfigure(0, weight=1) + + output_box = ttk.LabelFrame(left, text="模块输出映射(保存到设备 Flash)") + output_box.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + output_box.columnconfigure(1, weight=1) + output_box.columnconfigure(3, weight=1) + ttk.Label(output_box, text="脉冲点").grid(row=0, column=0, sticky="w", padx=(0, 6)) + self.pulse_output_combo = ttk.Combobox(output_box, textvariable=self.pulse_output_var, values=tuple(f"Y{i}" for i in range(4)), state="readonly", width=5) + self.pulse_output_combo.grid(row=0, column=1, sticky="ew", padx=(0, 16)) + ttk.Label(output_box, text="方向点").grid(row=0, column=2, sticky="w", padx=(0, 6)) + self.direction_output_combo = ttk.Combobox(output_box, textvariable=self.direction_output_var, values=tuple(f"Y{12 + i}" for i in range(4)), state="readonly", width=5) + self.direction_output_combo.grid(row=0, column=3, sticky="ew", padx=(0, 12)) + self.save_outputs_button = ttk.Button(output_box, text="写入并保存", command=self._save_outputs, style="Primary.TButton") + self.save_outputs_button.grid(row=0, column=4) + ttk.Label(output_box, text="保存成功后断电不会丢失,下次上电自动恢复。", foreground="#61758a", background="#ffffff").grid(row=1, column=0, columnspan=5, sticky="w", pady=(9, 0)) + + motion_box = ttk.LabelFrame(left, text="运动参数") + motion_box.grid(row=1, column=0, sticky="ew", pady=(0, 10)) + motion_box.columnconfigure(1, weight=1) + ttk.Label(motion_box, text="目标脉冲数").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=5) + self.pulses_spin = ttk.Spinbox(motion_box, textvariable=self.pulses_var, from_=-2147483648, to=2147483647) + self.pulses_spin.grid(row=0, column=1, sticky="ew", pady=5) + ttk.Label(motion_box, text="频率").grid(row=1, column=0, sticky="w", padx=(0, 12), pady=5) + freq_line = ttk.Frame(motion_box, style="Surface.TFrame") + freq_line.grid(row=1, column=1, sticky="ew", pady=5) + freq_line.columnconfigure(0, weight=1) + self.frequency_spin = ttk.Spinbox(freq_line, textvariable=self.frequency_var, from_=1, to=100000) + self.frequency_spin.grid(row=0, column=0, sticky="ew") + ttk.Label(freq_line, text="Hz(1–100000)", background="#ffffff").grid(row=0, column=1, padx=(8, 0)) + self.apply_frequency_button = ttk.Button( + freq_line, text="应用频率", command=self._apply_running_frequency + ) + self.apply_frequency_button.grid(row=0, column=2, padx=(10, 0)) + ttk.Label(motion_box, text="运行方向").grid(row=2, column=0, sticky="w", padx=(0, 12), pady=5) + direction_line = ttk.Frame(motion_box, style="Surface.TFrame") + direction_line.grid(row=2, column=1, sticky="w", pady=5) + self.forward_radio = ttk.Radiobutton(direction_line, text="正向(0)", variable=self.direction_var, value=0) + self.reverse_radio = ttk.Radiobutton(direction_line, text="反向(1)", variable=self.direction_var, value=1) + self.forward_radio.grid(row=0, column=0, padx=(0, 18)) + self.reverse_radio.grid(row=0, column=1) + + actions = ttk.LabelFrame(left, text="运行控制") + actions.grid(row=2, column=0, sticky="ew") + for column in range(3): + actions.columnconfigure(column, weight=1) + self.start_button = ttk.Button(actions, text="启动/继续脉冲", command=self._start_motion, style="Primary.TButton") + self.stop_button = ttk.Button(actions, text="受控停止", command=lambda: self._send_command(COMMAND_STOP, "stop")) + self.emergency_button = ttk.Button(actions, text="重新运行脉冲", command=self._restart_motion, style="Danger.TButton") + self.start_button.grid(row=0, column=0, sticky="ew", padx=(0, 6), pady=(0, 7)) + self.stop_button.grid(row=0, column=1, sticky="ew", padx=6, pady=(0, 7)) + self.emergency_button.grid(row=0, column=2, sticky="ew", padx=(6, 0), pady=(0, 7)) + self.lamp_test_button = ttk.Button(actions, text="低速正反转灯测", command=self._start_lamp_test) + self.reset_button = ttk.Button(actions, text="复位状态", command=lambda: self._send_command(COMMAND_RESET, "reset")) + self.clear_button = ttk.Button(actions, text="清零累计脉冲", command=lambda: self._send_command(COMMAND_CLEAR_COUNT, "clear")) + self.lamp_test_button.grid(row=1, column=0, sticky="ew", padx=(0, 6)) + self.reset_button.grid(row=1, column=1, sticky="ew", padx=6) + self.clear_button.grid(row=1, column=2, sticky="ew", padx=(6, 0)) + + status_box = ttk.LabelFrame(right, text="实时状态") + status_box.grid(row=0, column=0, sticky="nsew") + status_box.columnconfigure(1, weight=1) + rows = ( + ("运行状态", self.state_var), + ("状态标志", self.flags_var), + ("当前频率", self.current_frequency_var), + ("累计脉冲", self.total_pulses_var), + ("停止原因", self.stop_reason_var), + ("最后错误", self.error_var), + ("设备当前 Q 点", self.saved_outputs_var), + ) + for row, (label, variable) in enumerate(rows): + ttk.Label(status_box, text=label).grid(row=row, column=0, sticky="w", padx=(0, 24), pady=5) + ttk.Label(status_box, textvariable=variable, style="Value.TLabel").grid(row=row, column=1, sticky="e", pady=5) + ttk.Separator(status_box).grid(row=len(rows), column=0, columnspan=2, sticky="ew", pady=(5, 8)) + ttk.Label(status_box, text="本次运动进度").grid(row=len(rows) + 1, column=0, columnspan=2, sticky="w") + self.progress = ttk.Progressbar(status_box, maximum=100, value=0) + self.progress.grid(row=len(rows) + 2, column=0, columnspan=2, sticky="ew", pady=(8, 4)) + ttk.Label(status_box, textvariable=self.progress_text_var, background="#ffffff", foreground="#61758a").grid(row=len(rows) + 3, column=0, columnspan=2, sticky="e") + self.read_now_button = ttk.Button(status_box, text="立即读取状态", command=lambda: self._submit("snapshot", purpose="manual")) + self.read_now_button.grid(row=len(rows) + 4, column=0, columnspan=2, sticky="ew", pady=(10, 0)) + + def _build_register_tab(self, parent: ttk.Frame) -> None: + parent.rowconfigure(1, weight=1) + parent.columnconfigure(0, weight=1) + toolbar = ttk.Frame(parent, style="Surface.TFrame") + toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 8)) + toolbar.columnconfigure(0, weight=1) + ttk.Label(toolbar, text="需求版读取 1000/1100/2000/3000;legacy 读取 0/100。", background="#ffffff", foreground="#61758a").grid(row=0, column=0, sticky="w") + self.raw_button = ttk.Button(toolbar, text="刷新寄存器", command=lambda: self._submit("raw", purpose="raw"), style="Primary.TButton") + self.raw_button.grid(row=0, column=1, sticky="e") + tree_frame = ttk.Frame(parent, style="Surface.TFrame") + tree_frame.grid(row=1, column=0, sticky="nsew") + tree_frame.rowconfigure(0, weight=1) + tree_frame.columnconfigure(0, weight=1) + self.register_tree = ttk.Treeview(tree_frame, columns=("address", "hex", "value", "meaning"), show="headings", selectmode="browse") + self.register_tree.heading("address", text="地址") + self.register_tree.heading("hex", text="十六进制") + self.register_tree.heading("value", text="无符号值") + self.register_tree.heading("meaning", text="项目定义") + self.register_tree.column("address", width=90, anchor="center", stretch=False) + self.register_tree.column("hex", width=130, anchor="center", stretch=False) + self.register_tree.column("value", width=140, anchor="e", stretch=False) + self.register_tree.column("meaning", width=340, anchor="w") + scroll = ttk.Scrollbar(tree_frame, orient="vertical", command=self.register_tree.yview) + self.register_tree.configure(yscrollcommand=scroll.set) + self.register_tree.grid(row=0, column=0, sticky="nsew") + scroll.grid(row=0, column=1, sticky="ns") + + def _build_config_tab(self, parent: ttk.Frame) -> None: + parent.columnconfigure(0, weight=1) + parent.rowconfigure(1, weight=1) + + global_box = ttk.LabelFrame(parent, text="系统与运动参数(0x1000..0x1013)") + global_box.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + for column in (1, 3, 5, 7): + global_box.columnconfigure(column, weight=1) + + fields = ( + ("WAIT信号", self.wait_input_var, ("X4", "X5")), + ("EXT信号", self.ext_input_var, ("X4", "X5")), + ("发送模式", self.send_mode_var, ("完成模式", "后续模式")), + ("方向逻辑", self.direction_logic_var, ("正逻辑", "负逻辑")), + ("加减速模式", self.accel_mode_var, ("直线", "S曲线", "正弦曲线")), + ("运行模式", self.run_mode_var, ("相对", "绝对")), + ) + for index, (label, variable, values) in enumerate(fields): + row, pair = divmod(index, 3) + column = pair * 2 + ttk.Label(global_box, text=label).grid(row=row, column=column, sticky="w", padx=(0, 6), pady=4) + ttk.Combobox(global_box, textvariable=variable, values=values, state="readonly", width=12).grid(row=row, column=column + 1, sticky="ew", padx=(0, 14), pady=4) + + numeric_fields = ( + ("方向延时 ms", self.direction_delay_var, 0, 65535), + ("总段数", self.segment_count_var, 1, 10), + ("起始段", self.start_segment_var, 1, 10), + ("默认速度 Hz", self.default_speed_var, 1, 100000), + ("起始速度 Hz", self.start_speed_var, 0, 100000), + ("终止速度 Hz", self.end_speed_var, 0, 100000), + ("加速时间 ms", self.accel_time_var, 0, 65535), + ("减速时间 ms", self.decel_time_var, 0, 65535), + ) + for index, (label, variable, minimum, maximum) in enumerate(numeric_fields): + row, pair = divmod(index, 4) + row += 2 + column = pair * 2 + ttk.Label(global_box, text=label).grid(row=row, column=column, sticky="w", padx=(0, 6), pady=4) + ttk.Spinbox(global_box, textvariable=variable, from_=minimum, to=maximum, width=12).grid(row=row, column=column + 1, sticky="ew", padx=(0, 14), pady=4) + + body = ttk.Frame(parent, style="Surface.TFrame") + body.grid(row=1, column=0, sticky="nsew") + body.columnconfigure(0, weight=3) + body.columnconfigure(1, weight=2) + body.rowconfigure(0, weight=1) + + table_box = ttk.LabelFrame(body, text="1~10 段参数(0x1100..0x1197)") + table_box.grid(row=0, column=0, sticky="nsew", padx=(0, 7)) + table_box.rowconfigure(0, weight=1) + table_box.columnconfigure(0, weight=1) + self.segment_tree = ttk.Treeview(table_box, columns=("segment", "frequency", "pulses", "wait", "wait_ms", "act_ms", "jump"), show="headings", height=10) + for key, title, width in (("segment", "段", 45), ("frequency", "频率 Hz", 85), ("pulses", "脉冲数", 100), ("wait", "等待条件", 130), ("wait_ms", "WAIT ms", 75), ("act_ms", "ACT ms", 75), ("jump", "跳转", 55)): + self.segment_tree.heading(key, text=title) + self.segment_tree.column(key, width=width, anchor="center", stretch=key == "wait") + self.segment_tree.grid(row=0, column=0, sticky="nsew") + self.segment_tree.bind("<>", self._select_segment) + + editor = ttk.LabelFrame(body, text="当前段编辑") + editor.grid(row=0, column=1, sticky="nsew", padx=(7, 0)) + editor.columnconfigure(1, weight=1) + segment_fields = ( + ("段号", self.segment_index_var, 1, 10), + ("频率 Hz", self.segment_frequency_var, 0, 100000), + ("脉冲数", self.segment_pulses_var, -2147483648, 2147483647), + ("WAIT时间 ms", self.segment_wait_time_var, 0, 65535), + ("ACT时间 ms", self.segment_act_time_var, 0, 65535), + ("跳转段(0默认)", self.segment_jump_var, 0, 10), + ) + for row, (label, variable, minimum, maximum) in enumerate(segment_fields): + ttk.Label(editor, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=5) + widget = ttk.Spinbox(editor, textvariable=variable, from_=minimum, to=maximum) + if row == 0: + widget.configure(state="readonly") + widget.grid(row=row, column=1, sticky="ew", pady=5) + ttk.Label(editor, text="等待条件").grid(row=6, column=0, sticky="w", padx=(0, 8), pady=5) + ttk.Combobox(editor, textvariable=self.segment_wait_var, + values=("WAIT时间", "WAIT信号", "ACT时间", "EXT信号", "EXT或完成"), + state="readonly").grid(row=6, column=1, sticky="ew", pady=5) + self.apply_segment_button = ttk.Button(editor, text="应用当前段", command=self._apply_segment_editor) + self.apply_segment_button.grid(row=7, column=0, columnspan=2, sticky="ew", pady=(10, 5)) + button_line = ttk.Frame(editor, style="Surface.TFrame") + button_line.grid(row=8, column=0, columnspan=2, sticky="ew") + button_line.columnconfigure((0, 1), weight=1) + self.read_config_button = ttk.Button(button_line, text="从设备读取全部", command=self._read_full_config) + self.write_config_button = ttk.Button(button_line, text="校验并写入全部", command=self._write_full_config, style="Primary.TButton") + self.read_config_button.grid(row=0, column=0, sticky="ew", padx=(0, 4)) + self.write_config_button.grid(row=0, column=1, sticky="ew", padx=(4, 0)) + config_actions = ttk.Frame(editor, style="Surface.TFrame") + config_actions.grid(row=9, column=0, columnspan=2, sticky="ew", pady=(8, 0)) + config_actions.columnconfigure((0, 1, 2), weight=1) + self.config_start_button = ttk.Button( + config_actions, text="写入并启动/继续", + command=lambda: self._start_config_sequence(False), style="Primary.TButton") + self.config_restart_button = ttk.Button( + config_actions, text="写入并重新启动", + command=lambda: self._start_config_sequence(True), style="Danger.TButton") + self.config_stop_button = ttk.Button( + config_actions, text="停止", command=lambda: self._send_command(COMMAND_STOP, "stop")) + self.config_start_button.grid(row=0, column=0, sticky="ew", padx=(0, 4)) + self.config_restart_button.grid(row=0, column=1, sticky="ew", padx=4) + self.config_stop_button.grid(row=0, column=2, sticky="ew", padx=(4, 0)) + config_actions2 = ttk.Frame(editor, style="Surface.TFrame") + config_actions2.grid(row=10, column=0, columnspan=2, sticky="ew", pady=(5, 0)) + config_actions2.columnconfigure((0, 1, 2), weight=1) + self.config_emergency_button = ttk.Button( + config_actions2, text="立即停止", + command=lambda: self._send_command(COMMAND_IMMEDIATE_STOP, "immediate_stop")) + self.config_reset_button = ttk.Button( + config_actions2, text="复位", command=lambda: self._send_command(COMMAND_RESET, "reset")) + self.config_clear_button = ttk.Button( + config_actions2, text="清零累计脉冲", + command=lambda: self._send_command(COMMAND_CLEAR_COUNT, "clear")) + self.config_emergency_button.grid(row=0, column=0, sticky="ew", padx=(0, 4)) + self.config_reset_button.grid(row=0, column=1, sticky="ew", padx=4) + self.config_clear_button.grid(row=0, column=2, sticky="ew", padx=(4, 0)) + ttk.Label(editor, text="X4/X5、实际频率和曲线需配合示波器/人工触发验收。", wraplength=300, foreground="#61758a", background="#ffffff").grid(row=11, column=0, columnspan=2, sticky="w", pady=(10, 0)) + self._refresh_segment_tree() + + def _build_log_tab(self, parent: ttk.Frame) -> None: + parent.rowconfigure(1, weight=1) + parent.columnconfigure(0, weight=1) + toolbar = ttk.Frame(parent, style="Surface.TFrame") + toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 8)) + toolbar.columnconfigure(0, weight=1) + ttk.Checkbutton(toolbar, text="记录原始 TX / RX 帧", variable=self.capture_frames_var).grid(row=0, column=0, sticky="w") + ttk.Label(toolbar, text=f"最多保留 {MAX_LOG_LINES} 行", background="#ffffff", foreground="#61758a").grid(row=0, column=1, padx=12) + ttk.Button(toolbar, text="导出", command=self._export_log).grid(row=0, column=2, padx=(0, 6)) + ttk.Button(toolbar, text="清空", command=self._clear_log).grid(row=0, column=3) + text_frame = ttk.Frame(parent, style="Surface.TFrame") + text_frame.grid(row=1, column=0, sticky="nsew") + text_frame.rowconfigure(0, weight=1) + text_frame.columnconfigure(0, weight=1) + self.log_text = tk.Text(text_frame, wrap="none", state="disabled", background="#0e1b28", foreground="#dce7f0", insertbackground="#ffffff", font=("Cascadia Mono", 9), padx=10, pady=8, relief="flat") + y_scroll = ttk.Scrollbar(text_frame, orient="vertical", command=self.log_text.yview) + x_scroll = ttk.Scrollbar(text_frame, orient="horizontal", command=self.log_text.xview) + self.log_text.configure(yscrollcommand=y_scroll.set, xscrollcommand=x_scroll.set) + self.log_text.grid(row=0, column=0, sticky="nsew") + y_scroll.grid(row=0, column=1, sticky="ns") + x_scroll.grid(row=1, column=0, sticky="ew") + + def _refresh_ports(self) -> None: + if list_ports is None: + messagebox.showerror("缺少依赖", "未安装 pyserial,请先运行 start_plsr_test.bat。") + return + current = self.port_var.get() + ports = sorted((item.device for item in list_ports.comports()), key=str.upper) + self.port_combo["values"] = ports + if current in ports: + self.port_var.set(current) + elif "COM9" in ports: + self.port_var.set("COM9") + elif ports: + self.port_var.set(ports[0]) + else: + self.port_var.set("") + self.operation_var.set(f"检测到 {len(ports)} 个串口" if ports else "未检测到串口,请检查 USB 转串口连接") + + def _toggle_connection(self) -> None: + if self.job_active: + return + if self.connected: + self.auto_phase = None + self._submit("disconnect", purpose="disconnect") + return + try: + port = self.port_var.get().strip() + if not port: + raise ValueError("请先选择串口") + baud = int(self.baud_var.get()) + slave = int(self.slave_var.get()) + if not 1 <= slave <= 247: + raise ValueError("站号必须为 1–247") + except ValueError as exc: + messagebox.showerror("连接参数错误", str(exc)) + return + self.connection_var.set("连接中…") + self.operation_var.set(f"正在连接 {port},{baud} / 8-E-1 / 站号 {slave}") + self._submit("connect", purpose="connect", port=port, baud=baud, slave=slave, protocol=self.protocol_var.get()) + + def _submit(self, name: str, purpose: str = "", **data: Any) -> bool: + job = {"name": name, "purpose": purpose, **data} + if self.job_active: + if self.poll_active and purpose != "poll" and self.deferred_job is None: + self.deferred_job = job + self._update_controls() + return True + return False + if name not in ("connect", "disconnect") and not self.connected: + messagebox.showwarning("设备未连接", "请先连接设备。") + return False + self._start_job(job) + return True + + def _start_job(self, job: dict[str, Any]) -> None: + self.job_active = True + self.poll_active = job["name"] == "snapshot" and job.get("purpose") == "poll" + self.jobs.put(job) + if not self.poll_active: + self._update_controls() + + def _start_deferred_job(self) -> None: + if self.deferred_job is None or self.job_active: + return + job = self.deferred_job + self.deferred_job = None + self._start_job(job) + + def _parse_outputs(self) -> tuple[int, int]: + if self.protocol_var.get() == MAP_SPEC: + pulse = int(self.pulse_output_var.get().removeprefix("Y")) + direction = int(self.direction_output_var.get().removeprefix("Y")) - 12 + if not 0 <= pulse <= 3 or not 0 <= direction <= 3: + raise ValueError("需求版输出必须为 Y0..Y3 与 Y12..Y15") + else: + pulse = int(self.pulse_output_var.get().removeprefix("Q")) + direction = int(self.direction_output_var.get().removeprefix("Q")) + if pulse == direction: + raise ValueError("脉冲点与方向点不能相同") + return pulse, direction + + def _parse_motion(self) -> tuple[int, int, int]: + pulses = int(self.pulses_var.get()) + frequency = int(self.frequency_var.get()) + direction = int(self.direction_var.get()) + if self.protocol_var.get() == MAP_SPEC: + if pulses == 0 or not -2147483648 <= pulses <= 2147483647: + raise ValueError("需求版脉冲个数必须为 -2147483648..2147483647 且不能为 0") + validate_frequency(frequency) + else: + if not 1 <= pulses <= 0xFFFFFFFF: + raise ValueError("legacy 目标脉冲数必须为 1–4294967295") + if not 1 <= frequency <= 1000: + raise ValueError("legacy 频率必须为 1–1000 Hz") + return pulses, frequency, direction + + def _segment_from_editor(self) -> SegmentParameters: + wait_values = {"WAIT时间": 0, "WAIT信号": 1, "ACT时间": 2, "EXT信号": 3, "EXT或完成": 4} + segment = SegmentParameters( + frequency_hz=int(self.segment_frequency_var.get()), + pulses=int(self.segment_pulses_var.get()), + wait_condition=wait_values[self.segment_wait_var.get()], + wait_time_ms=int(self.segment_wait_time_var.get()), + act_time_ms=int(self.segment_act_time_var.get()), + jump_segment=int(self.segment_jump_var.get()), + ) + segment.validate(int(self.segment_index_var.get())) + return segment + + def _apply_segment_editor(self) -> None: + try: + index = int(self.segment_index_var.get()) + self.segment_models[index - 1] = self._segment_from_editor() + except (KeyError, ValueError) as exc: + messagebox.showerror("段参数错误", str(exc)) + return + self._refresh_segment_tree(select=index) + self.operation_var.set(f"第 {index} 段参数已应用到待写配置") + + def _select_segment(self, _event: object = None) -> None: + selection = self.segment_tree.selection() + if not selection: + return + index = int(self.segment_tree.item(selection[0], "values")[0]) + self._load_segment_editor(index) + + def _load_segment_editor(self, index: int) -> None: + wait_names = ("WAIT时间", "WAIT信号", "ACT时间", "EXT信号", "EXT或完成") + segment = self.segment_models[index - 1] + self.segment_index_var.set(str(index)) + self.segment_frequency_var.set(str(segment.frequency_hz)) + self.segment_pulses_var.set(str(segment.pulses)) + self.segment_wait_var.set(wait_names[segment.wait_condition] if 0 <= segment.wait_condition < 5 else "WAIT时间") + self.segment_wait_time_var.set(str(segment.wait_time_ms)) + self.segment_act_time_var.set(str(segment.act_time_ms)) + self.segment_jump_var.set(str(segment.jump_segment)) + + def _refresh_segment_tree(self, select: int = 1) -> None: + wait_names = ("WAIT时间", "WAIT信号", "ACT时间", "EXT信号", "EXT或完成") + self.segment_tree.delete(*self.segment_tree.get_children()) + selected = None + for index, segment in enumerate(self.segment_models, 1): + item = self.segment_tree.insert("", "end", values=(index, segment.frequency_hz, segment.pulses, + wait_names[segment.wait_condition] if 0 <= segment.wait_condition < 5 else segment.wait_condition, + segment.wait_time_ms, segment.act_time_ms, segment.jump_segment)) + if index == select: + selected = item + if selected: + self.segment_tree.selection_set(selected) + self.segment_tree.focus(selected) + + def _configuration_from_ui(self) -> PlsrConfiguration: + self.segment_models[int(self.segment_index_var.get()) - 1] = self._segment_from_editor() + cfg = PlsrConfiguration( + pulse_output=self._parse_outputs()[0], + direction_output=self._parse_outputs()[1], + wait_input=0 if self.wait_input_var.get() == "X4" else 1, + ext_input=0 if self.ext_input_var.get() == "X4" else 1, + send_mode=0 if self.send_mode_var.get() == "完成模式" else 1, + direction_delay_ms=int(self.direction_delay_var.get()), + direction_logic=0 if self.direction_logic_var.get() == "正逻辑" else 1, + accel_mode={"直线": 0, "S曲线": 1, "正弦曲线": 2}[self.accel_mode_var.get()], + run_mode=0 if self.run_mode_var.get() == "相对" else 1, + segment_count=int(self.segment_count_var.get()), + start_segment=int(self.start_segment_var.get()), + default_speed_hz=int(self.default_speed_var.get()), + start_speed_hz=int(self.start_speed_var.get()), + end_speed_hz=int(self.end_speed_var.get()), + accel_time_ms=int(self.accel_time_var.get()), + decel_time_ms=int(self.decel_time_var.get()), + segments=list(self.segment_models), + ) + cfg.validate() + return cfg + + def _apply_configuration(self, cfg: PlsrConfiguration) -> None: + self.pulse_output_var.set(f"Y{cfg.pulse_output}") + self.direction_output_var.set(f"Y{cfg.direction_output + 12}") + self.wait_input_var.set(f"X{cfg.wait_input + 4}") + self.ext_input_var.set(f"X{cfg.ext_input + 4}") + self.send_mode_var.set(("完成模式", "后续模式")[cfg.send_mode]) + self.direction_delay_var.set(str(cfg.direction_delay_ms)) + self.direction_logic_var.set(("正逻辑", "负逻辑")[cfg.direction_logic]) + self.accel_mode_var.set(("直线", "S曲线", "正弦曲线")[cfg.accel_mode]) + self.run_mode_var.set(("相对", "绝对")[cfg.run_mode]) + self.segment_count_var.set(str(cfg.segment_count)) + self.start_segment_var.set(str(cfg.start_segment)) + self.default_speed_var.set(str(cfg.default_speed_hz)) + self.start_speed_var.set(str(cfg.start_speed_hz)) + self.end_speed_var.set(str(cfg.end_speed_hz)) + self.accel_time_var.set(str(cfg.accel_time_ms)) + self.decel_time_var.set(str(cfg.decel_time_ms)) + self.segment_models = list(cfg.segments) + self._refresh_segment_tree(select=cfg.start_segment) + self._load_segment_editor(cfg.start_segment) + + def _read_full_config(self) -> None: + if self.protocol_var.get() != MAP_SPEC: + messagebox.showwarning("映射不支持", "完整参数页仅适用于 spec 参数表映射。") + return + self.operation_var.set("正在读取系统参数和 10 段路径") + self._submit("read_config", purpose="read_config") + + def _write_full_config(self) -> None: + if self.protocol_var.get() != MAP_SPEC: + messagebox.showwarning("映射不支持", "完整参数页仅适用于 spec 参数表映射。") + return + try: + config = self._configuration_from_ui() + except (KeyError, ValueError) as exc: + messagebox.showerror("完整参数校验失败", str(exc)) + return + self._refresh_segment_tree(select=config.start_segment) + self.operation_var.set(f"正在写入完整配置:{config.segment_count} 段,从第 {config.start_segment} 段执行") + self._submit("write_config", purpose="write_config", config=config) + + def _confirm_motion(self, title: str, detail: str) -> bool: + pulse = self.pulse_output_var.get() + direction = self.direction_output_var.get() + return messagebox.askyesno( + title, + "即将产生真实硬件输出。\n\n" + f"脉冲点:{pulse}\n方向点:{direction}\n{detail}\n\n" + "请确认机械负载已断开,当前只连接测试灯或示波器,且现场允许输出。", + icon="warning", + ) + + def _save_outputs(self) -> None: + try: + pulse, direction = self._parse_outputs() + except ValueError as exc: + messagebox.showerror("Q 点设置错误", str(exc)) + return + output_text = (f"脉冲 Y{pulse},方向 Y{direction + 12}" if self.protocol_var.get() == MAP_SPEC + else f"脉冲 Q{pulse},方向 Q{direction}") + self.operation_var.set(f"正在写入输出映射:{output_text}") + self._submit("configure", purpose="save_outputs", pulse_output=pulse, direction_output=direction) + + def _start_motion(self) -> None: + self._start_or_restart_motion(False) + + def _restart_motion(self) -> None: + self._start_or_restart_motion(True) + + def _apply_running_frequency(self) -> None: + try: + frequency = int(self.frequency_var.get()) + validate_frequency(frequency) + except ValueError as exc: + messagebox.showerror("频率参数错误", str(exc)) + return + self.operation_var.set(f"正在将运行频率修改为 {frequency} Hz") + self._submit( + "frequency", purpose="update_frequency", frequency=frequency + ) + + def _start_or_restart_motion(self, restart: bool) -> None: + try: + pulses, frequency, direction = self._parse_motion() + pulse, direction_output = self._parse_outputs() + if self.last_snapshot is None or ( + pulse != self.last_snapshot.pulse_output + or direction_output != self.last_snapshot.direction_output + ): + raise ValueError("所选 Q 点尚未写入设备,请先点击“写入并保存”") + except ValueError as exc: + messagebox.showerror("运动参数错误", str(exc)) + return + direction_text = "反向" if direction else "正向" + action = "重新运行" if restart else "启动/继续" + if not self._confirm_motion(f"确认{action}脉冲", f"{direction_text},{pulses} 脉冲,{frequency} Hz"): + return + self.auto_phase = None + plan = (abs(pulses), frequency, direction) + continuing = (not restart and self.last_snapshot is not None + and self.last_snapshot.state == 6 and self.motion_plan == plan) + if not continuing: + self.run_baseline = self.last_snapshot.total_pulses if self.last_snapshot else 0 + self.run_target = abs(pulses) + self.motion_plan = plan + self._reset_progress() + self.operation_var.set(f"正在{action}:{direction_text} / {pulses} 脉冲 / {frequency} Hz") + self._submit("start", purpose="normal_restart" if restart else "normal_start", + pulses=pulses, frequency=frequency, direction=direction, + restart=restart) + + def _start_config_sequence(self, restart: bool) -> None: + if self.protocol_var.get() != MAP_SPEC: + messagebox.showwarning("映射不支持", "完整参数与多段启动仅适用于 spec 参数表映射。") + return + try: + config = self._configuration_from_ui() + except (KeyError, ValueError) as exc: + messagebox.showerror("完整参数校验失败", str(exc)) + return + action = "重新启动" if restart else "启动/继续" + detail = ( + f"{action}完整多段任务\n" + f"起始段:{config.start_segment} / 共 {config.segment_count} 段\n" + f"发送模式:{'完成模式' if config.send_mode == 0 else '后续模式'}\n" + "设备将先写入当前页全部参数,再执行启动命令。" + ) + if not self._confirm_motion(f"确认{action}多段脉冲", detail): + return + self._refresh_segment_tree(select=config.start_segment) + self.auto_phase = None + purpose = "config_restart" if restart else "config_start" + self.operation_var.set(f"正在写入并{action}完整多段任务") + self._submit( + "write_config_command", purpose=purpose, config=config, + command=COMMAND_RESTART if restart else COMMAND_START, + ) + + def _start_lamp_test(self) -> None: + try: + pulse, direction = self._parse_outputs() + except ValueError as exc: + messagebox.showerror("Q 点设置错误", str(exc)) + return + if not self._confirm_motion("确认低速正反转灯测", "正向 6 脉冲 / 2 Hz,然后反向 6 脉冲 / 2 Hz"): + return + self.auto_phase = "config" + self.lamp_test_baseline = self.last_snapshot.total_pulses if self.last_snapshot else 0 + self.run_baseline = self.lamp_test_baseline + self.run_target = 12 + self._reset_progress() + self.operation_var.set("灯测 1/4:保存 Q 点配置") + self._submit("configure", purpose="auto_config", pulse_output=pulse, direction_output=direction) + + def _send_command(self, command: int, purpose: str) -> None: + if purpose in ("stop", "immediate_stop"): + self.auto_phase = None + labels = { + "stop": "正在发送受控停止命令", + "immediate_stop": "正在发送立即停止命令", + "reset": "正在复位状态", + "clear": "正在清零累计脉冲", + } + self.operation_var.set(labels[purpose]) + self._submit("command", purpose=purpose, command=command) + + def _drain_events(self) -> None: + try: + while True: + event = self.events.get_nowait() + if event["type"] == "log": + if self.capture_frames_var.get(): + self._append_log(event["line"]) + elif event["type"] == "result": + self._handle_result(event) + elif event["type"] == "error": + self._handle_error(event) + except queue.Empty: + pass + if not self.closing: + self.after(50, self._drain_events) + + def _handle_result(self, event: dict[str, Any]) -> None: + self.job_active = False + self.poll_active = False + self.communication_errors = 0 + name = event["name"] + purpose = event["purpose"] + result = event["result"] + + if name == "connect": + self.connected = True + self.connection_var.set("已连接") + self.connection_label.configure(style="StatusOn.TLabel") + self.connect_button.configure(text="断开连接") + self.operation_var.set(f"连接成功:{self.port_var.get()} / {self.baud_var.get()} / 8-E-1 / 站号 {self.slave_var.get()}") + self._append_log(f"{time.strftime('%H:%M:%S')} INFO 已连接 {self.port_var.get()}") + self._apply_snapshot(result) + self._sync_output_selectors(result) + elif name == "disconnect": + self._set_disconnected("已断开连接") + elif name == "raw": + self._show_raw_registers(result) + self.operation_var.set("寄存器读取完成") + elif isinstance(result, PlsrConfiguration): + self._apply_configuration(result) + self.operation_var.set("完整参数已从设备回读" if purpose == "read_config" else "完整参数写入并回读校验完成") + elif isinstance(result, Snapshot): + self._apply_snapshot(result) + if name == "configure": + self._sync_output_selectors(result) + if purpose == "save_outputs": + output_text = (f"脉冲 Y{result.pulse_output},方向 Y{result.direction_output + 12}" + if result.protocol == MAP_SPEC else + f"脉冲 Q{result.pulse_output},方向 Q{result.direction_output}") + self.operation_var.set(f"输出映射已写入:{output_text}") + elif purpose == "auto_config": + self.auto_phase = "forward" + self.run_baseline = result.total_pulses + self.lamp_test_baseline = result.total_pulses + self.operation_var.set("灯测 2/3:正向 6 脉冲 / 2 Hz") + self._submit("start", purpose="auto_forward", pulses=6, frequency=2, + direction=0, restart=True) + elif purpose == "auto_forward": + self.operation_var.set("灯测正向运行中:观察脉冲灯亮灭") + elif purpose == "auto_reverse": + self.operation_var.set("灯测反向运行中:脉冲灯仍应亮灭,方向点电平应改变") + elif purpose == "normal_start": + self.operation_var.set("运动已启动/继续,正在轮询状态") + elif purpose == "normal_restart": + self.operation_var.set("运动已从本次任务起点重新运行,正在轮询状态") + elif purpose == "config_start": + self.operation_var.set("完整多段参数已写入,任务已启动/继续") + elif purpose == "config_restart": + self.operation_var.set("完整多段参数已写入,任务已重新启动") + elif purpose == "update_frequency": + self.operation_var.set( + f"运行频率已修改为 {result.current_frequency} Hz" + ) + elif purpose == "clear": + self.run_baseline = result.total_pulses + self.run_target = 0 + self._reset_progress() + self.operation_var.set("累计脉冲已清零") + elif purpose == "reset": + self.operation_var.set("状态已复位") + elif purpose == "stop": + self.operation_var.set("受控停止命令已执行") + elif purpose == "immediate_stop": + self.operation_var.set("立即停止命令已执行") + elif name == "snapshot": + if purpose == "manual": + self.operation_var.set("状态读取完成") + self._advance_lamp_test(result) + self._start_deferred_job() + self._update_controls() + + def _handle_error(self, event: dict[str, Any]) -> None: + self.job_active = False + self.poll_active = False + name = event["name"] + error = event["error"] + purpose = event["purpose"] + self._append_log(f"{time.strftime('%H:%M:%S')} ERROR {error}") + self.operation_var.set(f"通信/操作失败:{error}") + if name == "connect": + self._set_disconnected("连接失败") + messagebox.showerror("连接失败", error) + else: + if purpose.startswith("auto_"): + self.auto_phase = None + messagebox.showerror("灯测中止", error) + if name == "snapshot": + self.communication_errors += 1 + if self.communication_errors >= 3: + self._set_disconnected("通信中断") + messagebox.showerror("通信中断", "连续 3 次读取失败,已停止自动轮询。\n请检查串口占用、接线和设备供电后重新连接。") + self._start_deferred_job() + self._update_controls() + + def _apply_snapshot(self, snapshot: Snapshot) -> None: + self.last_snapshot = snapshot + state = STATE_TEXT.get(snapshot.state, f"未知({snapshot.state})") + self.state_var.set(f"{state}({snapshot.state})") + flag_names = [] + if snapshot.flags & 0x0001: + flag_names.append("运行") + if snapshot.flags & 0x0002: + flag_names.append("完成") + if snapshot.flags & 0x0004: + flag_names.append("错误") + suffix = " / ".join(flag_names) if flag_names else "无" + self.flags_var.set(f"0x{snapshot.flags:04X} · {suffix}") + self.current_frequency_var.set(f"{snapshot.current_frequency} Hz") + self.total_pulses_var.set(f"{snapshot.total_pulses:,}") + self.stop_reason_var.set(STOP_REASON_TEXT.get(snapshot.stop_reason, str(snapshot.stop_reason))) + self.error_var.set(ERROR_TEXT.get(snapshot.last_error, f"{snapshot.last_error} · 未知错误")) + self.saved_outputs_var.set((f"脉冲 Y{snapshot.pulse_output} / 方向 Y{snapshot.direction_output + 12}") + if snapshot.protocol == MAP_SPEC else + f"脉冲 Q{snapshot.pulse_output} / 方向 Q{snapshot.direction_output}") + self._update_progress(snapshot) + + def _sync_output_selectors(self, snapshot: Snapshot) -> None: + if snapshot.protocol == MAP_SPEC: + self.pulse_output_var.set(f"Y{snapshot.pulse_output}") + self.direction_output_var.set(f"Y{snapshot.direction_output + 12}") + else: + self.pulse_output_var.set(f"Q{snapshot.pulse_output}") + self.direction_output_var.set(f"Q{snapshot.direction_output}") + + def _advance_lamp_test(self, snapshot: Snapshot) -> None: + if self.auto_phase == "forward" and snapshot.state in (5, 6, 7): + actual = snapshot.total_pulses - self.run_baseline + if snapshot.state != 5 or actual != 6: + self.auto_phase = None + messagebox.showerror("正向灯测失败", f"状态:{STATE_TEXT.get(snapshot.state, snapshot.state)}\n本阶段累计:{actual},期望:6") + return + self.auto_phase = "reverse" + self.run_baseline = snapshot.total_pulses + self.operation_var.set("灯测 3/3:反向 6 脉冲 / 2 Hz") + self._submit("start", purpose="auto_reverse", pulses=6, frequency=2, + direction=1, restart=True) + elif self.auto_phase == "reverse" and snapshot.state in (5, 6, 7): + actual = abs(snapshot.total_pulses - self.run_baseline) + self.auto_phase = None + if snapshot.state == 5 and actual == 6 and snapshot.total_pulses == self.lamp_test_baseline: + self.operation_var.set("低速正反转灯测通过:正向和反向各 6 脉冲") + messagebox.showinfo("灯测完成", "软件计数验证通过:正向 6 脉冲,反向 6 脉冲。\n请同时确认反向时脉冲灯仍亮灭,方向点电平已改变。") + else: + messagebox.showerror("反向灯测失败", f"状态:{STATE_TEXT.get(snapshot.state, snapshot.state)}\n反向阶段脉冲:{actual},期望:6\n灯测前累计:{self.lamp_test_baseline},灯测后累计:{snapshot.total_pulses}") + + def _update_progress(self, snapshot: Snapshot) -> None: + if self.run_target <= 0: + return + done = abs(snapshot.total_pulses - self.run_baseline) + percent = min(100.0, done * 100.0 / self.run_target) + self.progress.configure(value=percent) + self.progress_text_var.set(f"{min(done, self.run_target):,} / {self.run_target:,}({percent:.0f}%)") + + def _reset_progress(self) -> None: + self.progress.configure(value=0) + self.progress_text_var.set(f"0 / {self.run_target:,}" if self.run_target else "0 / 0") + + def _show_raw_registers(self, values: dict[str, list[int]]) -> None: + self.register_tree.delete(*self.register_tree.get_children()) + if "status" in values: + for address, value in enumerate(values["status"]): + self.register_tree.insert("", "end", values=(address, f"0x{value:04X}", value, REGISTER_NAMES.get(address, "保留/未使用"))) + for offset, value in enumerate(values["control"]): + address = 100 + offset + self.register_tree.insert("", "end", values=(address, f"0x{value:04X}", value, REGISTER_NAMES.get(address, "保留/未使用"))) + return + for offset, value in enumerate(values["config"]): + address = 0x1000 + offset + self.register_tree.insert("", "end", values=(f"0x{address:04X}", f"0x{value:04X}", value, SPEC_REGISTER_NAMES.get(address, "配置/保留"))) + for index, segment in enumerate(values["segments"], 1): + base = REG_SEGMENT_BASE + (index - 1) * 0x10 + for offset, value in enumerate(segment): + address = base + offset + self.register_tree.insert("", "end", values=(f"0x{address:04X}", f"0x{value:04X}", value, f"第{index}段 / 偏移 {offset}")) + for offset, value in enumerate(values["monitor"]): + address = REG_MONITOR_TOTAL_PULSES + offset + self.register_tree.insert("", "end", values=(f"0x{address:04X}", f"0x{value:04X}", value, SPEC_REGISTER_NAMES.get(address, "监控/保留"))) + value = values["control"][0] + self.register_tree.insert("", "end", values=("0x3000", f"0x{value:04X}", value, SPEC_REGISTER_NAMES[0x3000])) + + def _poll_tick(self) -> None: + if not self.closing and self.connected and not self.job_active: + self._submit("snapshot", purpose="poll") + if not self.closing: + self.after(POLL_INTERVAL_MS, self._poll_tick) + + def _update_controls(self) -> None: + ui_busy = (self.job_active and not self.poll_active) or self.deferred_job is not None + connected_idle = self.connected and not ui_busy + device_running = self.last_snapshot is not None and self.last_snapshot.state in (1, 2, 3, 4) + can_start = connected_idle and not device_running + can_stop = connected_idle and device_running + + self._set_widget_state(self.connect_button, "disabled" if ui_busy else "normal") + serial_state = "disabled" if self.connected or ui_busy else "readonly" + self._set_widget_state(self.port_combo, serial_state) + self._set_widget_state(self.baud_combo, serial_state) + self._set_widget_state(self.slave_spin, "disabled" if self.connected or ui_busy else "normal") + self._set_widget_state(self.refresh_button, "disabled" if self.connected or ui_busy else "normal") + + for widget in (self.save_outputs_button, self.read_now_button, self.raw_button, self.reset_button, self.clear_button): + self._set_widget_state(widget, "normal" if connected_idle and not device_running else "disabled") + for widget in (self.read_config_button, self.write_config_button, self.apply_segment_button): + allowed = connected_idle and not device_running and self.protocol_var.get() == MAP_SPEC + self._set_widget_state(widget, "normal" if allowed else "disabled") + config_start_allowed = connected_idle and not device_running and self.protocol_var.get() == MAP_SPEC + config_stop_allowed = connected_idle and device_running and self.protocol_var.get() == MAP_SPEC + config_idle_allowed = connected_idle and not device_running and self.protocol_var.get() == MAP_SPEC + for widget in (self.config_start_button, self.config_restart_button): + self._set_widget_state(widget, "normal" if config_start_allowed else "disabled") + self._set_widget_state(self.config_stop_button, "normal" if config_stop_allowed else "disabled") + for widget in (self.config_emergency_button,): + self._set_widget_state(widget, "normal" if config_stop_allowed else "disabled") + for widget in (self.config_reset_button, self.config_clear_button): + self._set_widget_state(widget, "normal" if config_idle_allowed else "disabled") + self._set_widget_state(self.start_button, "normal" if can_start else "disabled") + self._set_widget_state(self.lamp_test_button, "normal" if can_start else "disabled") + self._set_widget_state(self.stop_button, "normal" if can_stop else "disabled") + self._set_widget_state(self.emergency_button, "normal" if can_start else "disabled") + config_state = "readonly" if connected_idle and not device_running else "disabled" + self._set_widget_state(self.pulse_output_combo, config_state) + self._set_widget_state(self.direction_output_combo, config_state) + motion_state = "normal" if can_start else "disabled" + self._set_widget_state(self.pulses_spin, motion_state) + self._set_widget_state( + self.frequency_spin, "normal" if connected_idle else "disabled" + ) + self._set_widget_state( + self.apply_frequency_button, + "normal" if connected_idle and device_running else "disabled", + ) + self._set_widget_state(self.forward_radio, motion_state) + self._set_widget_state(self.reverse_radio, motion_state) + + @staticmethod + def _set_widget_state(widget: ttk.Widget, state: str) -> None: + if str(widget.cget("state")) != state: + widget.configure(state=state) + + def _set_disconnected(self, message: str) -> None: + self.connected = False + self.job_active = False + self.poll_active = False + self.deferred_job = None + self.auto_phase = None + self.last_snapshot = None + self.connection_var.set(message) + self.connection_label.configure(style="StatusOff.TLabel") + self.connect_button.configure(text="连接设备") + self.operation_var.set(message) + self._update_controls() + + def _append_log(self, line: str) -> None: + self.log_text.configure(state="normal") + self.log_text.insert("end", line + "\n") + self.log_line_count += 1 + if self.log_line_count > MAX_LOG_LINES: + remove_count = min(100, self.log_line_count - MAX_LOG_LINES) + self.log_text.delete("1.0", f"{remove_count + 1}.0") + self.log_line_count -= remove_count + self.log_text.see("end") + self.log_text.configure(state="disabled") + + def _clear_log(self) -> None: + self.log_text.configure(state="normal") + self.log_text.delete("1.0", "end") + self.log_text.configure(state="disabled") + self.log_line_count = 0 + + def _export_log(self) -> None: + path = filedialog.asksaveasfilename( + title="导出通信日志", + defaultextension=".log", + filetypes=(("日志文件", "*.log"), ("文本文件", "*.txt"), ("所有文件", "*.*")), + initialfile=f"plsr-{time.strftime('%Y%m%d-%H%M%S')}.log", + ) + if not path: + return + Path(path).write_text(self.log_text.get("1.0", "end-1c"), encoding="utf-8") + self.operation_var.set(f"日志已导出:{path}") + + def _on_close(self) -> None: + if self.closing: + return + self.closing = True + self.jobs.put({"name": "shutdown"}) + self.after(150, self.destroy) + + +def main() -> None: + app = PlsrGui() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/HostComputer/PythonTest/plsr_test.py b/HostComputer/PythonTest/plsr_test.py new file mode 100644 index 0000000..a6f1016 --- /dev/null +++ b/HostComputer/PythonTest/plsr_test.py @@ -0,0 +1,741 @@ +#!/usr/bin/env python3 +"""PLSR Modbus RTU test client. + +The ``spec`` map follows ``document/参数表-2026.xlsx``. ``legacy`` keeps +compatibility with the first single-segment firmware image in this project. +""" + +from __future__ import annotations + +import argparse +import sys +import time +from dataclasses import dataclass, field +from typing import Optional, Sequence + +try: + import serial + from serial.tools import list_ports +except ImportError: + serial = None + list_ports = None + + +SLAVE_ADDRESS = 1 +DEFAULT_BAUD_RATE = 115200 +MAP_SPEC = "spec" +MAP_LEGACY = "legacy" + +# Requirement-map addresses. These are holding-register offsets, not 4xxxx +# display addresses. A 32-bit value uses high word first, low word second. +REG_CFG_PULSE_OUTPUT = 0x1000 +REG_CFG_DIRECTION_OUTPUT = 0x1001 +REG_CFG_WAIT_INPUT = 0x1002 +REG_CFG_EXT_INPUT = 0x1003 +REG_CFG_SEND_MODE = 0x1004 +REG_CFG_DIRECTION_DELAY_MS = 0x1005 +REG_CFG_DIRECTION_LOGIC = 0x1006 +REG_CFG_ACCEL_MODE = 0x1007 +REG_CFG_RUN_MODE = 0x1008 +REG_CFG_SEGMENT_COUNT = 0x1009 +REG_CFG_START_SEGMENT = 0x100A +REG_CFG_DEFAULT_SPEED = 0x100B +REG_CFG_START_SPEED = 0x100D +REG_CFG_END_SPEED = 0x1010 +REG_CFG_ACCEL_TIME_MS = 0x1012 +REG_CFG_DECEL_TIME_MS = 0x1013 +REG_SEGMENT_BASE = 0x1100 +REG_SEGMENT_STRIDE = 0x10 +REG_MONITOR_TOTAL_PULSES = 0x2000 +REG_MONITOR_FREQUENCY = 0x2002 +REG_MONITOR_STATE = 0x2004 +REG_MONITOR_SEGMENT = 0x2005 +REG_MONITOR_ERROR = 0x2006 +REG_MONITOR_STOP_REASON = 0x2007 +REG_CONTROL = 0x3000 + +# Legacy zero-based map used by the current minimal firmware image. +REG_STATE = 0 +REG_FLAGS = 1 +REG_CURRENT_FREQUENCY = 3 +REG_STOP_REASON = 5 +REG_LAST_ERROR = 6 +REG_TOTAL_PULSES_HIGH = 10 +REG_TOTAL_PULSES_LOW = 11 +REG_COMMAND = 100 +REG_PULSES_HIGH = 102 +REG_PULSES_LOW = 103 +REG_FREQUENCY = 104 +REG_DIRECTION = 105 +REG_PULSE_OUTPUT = 106 +REG_DIRECTION_OUTPUT = 107 + +COMMAND_START = 1 +COMMAND_STOP = 2 +COMMAND_RESET = 3 +COMMAND_CLEAR_COUNT = 4 +COMMAND_IMMEDIATE_STOP = 5 +COMMAND_SAVE_CONFIG = 6 +COMMAND_RESTART = 7 + +CONTROL_START = 1 << 0 +CONTROL_STOP = 1 << 1 +CONTROL_CLEAR_COUNT = 1 << 2 +CONTROL_RESTART = 1 << 3 +CONTROL_RESET = 1 << 4 +CONTROL_IMMEDIATE_STOP = 1 << 5 + +STATE_NAMES = { + 0: "Idle", + 1: "Armed", + 2: "Busy", + 3: "Wait", + 4: "Decelerating", + 5: "Done", + 6: "Stopped", + 7: "Error", +} +ERROR_NAMES = { + 0: "OK", + 1: "Invalid argument", + 2: "Busy", + 3: "Resource", + 4: "State", + 5: "Wait timeout", + 6: "Path range", + 7: "Loop limit", + 8: "External stop", + 9: "Config CRC", + 10: "Communication", + 11: "Timer", +} +WAIT_CONDITION_NAMES = { + 0: "WAIT time", + 1: "WAIT signal", + 2: "ACT time", + 3: "EXT signal", + 4: "EXT signal or pulse done", +} + +MANDATORY_TEST_ITEMS = ( + ("M01", "Y0..Y3 pulse output selection", "automatic register round-trip + oscilloscope"), + ("M02", "Y12..Y15 direction output selection and logic", "automatic register round-trip + level observation"), + ("M03", "positive/negative signed pulse count", "automatic motion/count + direction observation"), + ("M04", "1..100000 Hz and online frequency update", "register boundary + oscilloscope/frequency counter"), + ("M05", "complete/follow-up send mode", "state transition observation"), + ("M06", "relative/absolute mode including equal-position no-op", "motion/count observation"), + ("M07", "linear/S-curve/sine acceleration", "configuration round-trip + oscilloscope capture"), + ("M08", "start/default/end speed and acceleration/deceleration time", "configuration round-trip + waveform timing"), + ("M09", "1..10 segments and selectable start segment", "10-segment round-trip + execution sequence"), + ("M10", "WAIT time and WAIT signal", "timing + manual X4/X5 trigger"), + ("M11", "ACT time and EXT signal", "timing + manual X4/X5 trigger"), + ("M12", "EXT signal or pulse completion", "manual EXT trigger and natural completion branches"), + ("M13", "jump=0/default next/end and explicit jump", "current-segment monitor sequence"), + ("M14", "cumulative signed count and communication clear", "automatic monitor/control check"), + ("M15", "stop, state, current segment and error diagnostics", "automatic state/error monitor check"), + ("M16", "power-loss save/load and power-up parameter restore", "manual power-cycle and readback"), +) + + +def mandatory_test_checklist() -> str: + return "\n".join(f"{code} {name} [{method}]" for code, name, method in MANDATORY_TEST_ITEMS) + + +class PlsrError(RuntimeError): + pass + + +class CommunicationError(PlsrError): + pass + + +class ModbusException(PlsrError): + def __init__(self, function: int, code: int) -> None: + super().__init__(f"Modbus exception: function=0x{function:02X}, code=0x{code:02X}") + self.function = function + self.code = code + + +def crc16(data: bytes) -> int: + value = 0xFFFF + for byte in data: + value ^= byte + for _ in range(8): + value = ((value >> 1) ^ 0xA001) if value & 1 else value >> 1 + return value + + +def with_crc(body: bytes) -> bytes: + value = crc16(body) + return body + bytes((value & 0xFF, value >> 8)) + + +def read_holding_request(slave: int, start: int, count: int) -> bytes: + return with_crc(bytes((slave, 0x03)) + start.to_bytes(2, "big") + count.to_bytes(2, "big")) + + +def write_register_request(slave: int, address: int, value: int) -> bytes: + return with_crc(bytes((slave, 0x06)) + address.to_bytes(2, "big") + value.to_bytes(2, "big")) + + +def write_registers_request(slave: int, address: int, values: Sequence[int]) -> bytes: + if not 1 <= len(values) <= 123: + raise ValueError("multiple-register write count must be 1..123") + payload = b"".join(int(v).to_bytes(2, "big") for v in values) + body = bytes((slave, 0x10)) + address.to_bytes(2, "big") + len(values).to_bytes(2, "big") + return with_crc(body + bytes((len(payload),)) + payload) + + +def _frame_length(buffer: bytearray, offset: int, expected_function: int) -> Optional[int]: + if len(buffer) - offset < 2: + return None + function = buffer[offset + 1] + if function == (expected_function | 0x80): + return 5 + if function != expected_function: + return -1 + if function in (0x01, 0x03): + if len(buffer) - offset < 3: + return None + return 5 + buffer[offset + 2] + if function in (0x05, 0x06, 0x0F, 0x10): + return 8 + return -1 + + +def extract_response(buffer: bytearray, slave: int, expected_function: int) -> Optional[bytes]: + """Find a valid response while tolerating stale bytes and fragmentation.""" + for offset in range(max(0, len(buffer) - 260), len(buffer)): + if buffer[offset] != slave: + continue + length = _frame_length(buffer, offset, expected_function) + if length is None: + continue + if length < 0 or len(buffer) - offset < length: + continue + candidate = bytes(buffer[offset : offset + length]) + if int.from_bytes(candidate[-2:], "little") == crc16(candidate[:-2]): + del buffer[: offset + length] + return candidate + if len(buffer) > 512: + del buffer[:-260] + return None + + +def encode_u32(value: int) -> tuple[int, int]: + if not 0 <= value <= 0xFFFFFFFF: + raise ValueError("unsigned 32-bit value must be 0..4294967295") + return (value >> 16) & 0xFFFF, value & 0xFFFF + + +def encode_i32(value: int) -> tuple[int, int]: + if not -0x80000000 <= value <= 0x7FFFFFFF: + raise ValueError("signed 32-bit value must be -2147483648..2147483647") + return encode_u32(value & 0xFFFFFFFF) + + +def decode_u32(high: int, low: int) -> int: + return ((high & 0xFFFF) << 16) | (low & 0xFFFF) + + +def decode_i32(high: int, low: int) -> int: + value = decode_u32(high, low) + return value - 0x100000000 if value & 0x80000000 else value + + +def validate_frequency(value: int, *, allow_zero: bool = False) -> None: + """Validate a frequency value used by the PLSR protocol.""" + minimum = 0 if allow_zero else 1 + if not minimum <= value <= 100_000: + lower = "0" if allow_zero else "1" + raise ValueError(f"frequency must be {lower}..100000 Hz") + + +@dataclass +class SegmentParameters: + frequency_hz: int = 1000 + pulses: int = 0 + wait_condition: int = 0 + wait_time_ms: int = 0 + act_time_ms: int = 0 + jump_segment: int = 0 + + def validate(self, index: int, total_segments: int = 10) -> None: + validate_frequency(self.frequency_hz, allow_zero=True) + if not -0x80000000 <= self.pulses <= 0x7FFFFFFF: + raise ValueError(f"segment {index}: pulses must be signed 32-bit") + if self.wait_condition not in WAIT_CONDITION_NAMES: + raise ValueError(f"segment {index}: wait condition must be 0..4") + if not 0 <= self.wait_time_ms <= 0xFFFF or not 0 <= self.act_time_ms <= 0xFFFF: + raise ValueError(f"segment {index}: wait/act time must be 0..65535 ms") + if not 0 <= self.jump_segment <= total_segments: + raise ValueError(f"segment {index}: jump segment must be 0..{total_segments}") + + +@dataclass +class PlsrConfiguration: + pulse_output: int = 0 + direction_output: int = 0 + wait_input: int = 0 + ext_input: int = 0 + send_mode: int = 0 + direction_delay_ms: int = 0 + direction_logic: int = 0 + accel_mode: int = 0 + run_mode: int = 0 + segment_count: int = 1 + start_segment: int = 1 + default_speed_hz: int = 1000 + start_speed_hz: int = 1 + end_speed_hz: int = 1 + accel_time_ms: int = 0 + decel_time_ms: int = 0 + segments: list[SegmentParameters] = field(default_factory=lambda: [SegmentParameters() for _ in range(10)]) + + def validate(self) -> None: + if not 0 <= self.pulse_output <= 3: + raise ValueError("pulse output must be Y0..Y3 (0..3)") + if not 0 <= self.direction_output <= 3: + raise ValueError("direction output must be Y12..Y15 (0..3)") + if not 0 <= self.wait_input <= 1 or not 0 <= self.ext_input <= 1: + raise ValueError("WAIT/EXT input must be X4 or X5 (0..1)") + if self.send_mode not in (0, 1): + raise ValueError("send mode must be 0 (complete) or 1 (follow-up)") + if self.direction_logic not in (0, 1): + raise ValueError("direction logic must be 0 (positive) or 1 (negative)") + if self.accel_mode not in (0, 1, 2): + raise ValueError("acceleration mode must be 0, 1 or 2") + if self.run_mode not in (0, 1): + raise ValueError("run mode must be 0 (relative) or 1 (absolute)") + if not 0 <= self.direction_delay_ms <= 0xFFFF: + raise ValueError("direction delay must be 0..65535 ms") + if not 1 <= self.segment_count <= 10: + raise ValueError("segment count must be 1..10") + if not 1 <= self.start_segment <= self.segment_count: + raise ValueError("start segment must be within 1..segment count") + validate_frequency(self.default_speed_hz) + validate_frequency(self.start_speed_hz, allow_zero=True) + validate_frequency(self.end_speed_hz, allow_zero=True) + if not 0 <= self.accel_time_ms <= 0xFFFF or not 0 <= self.decel_time_ms <= 0xFFFF: + raise ValueError("acceleration/deceleration time must be 0..65535 ms") + if len(self.segments) < 10: + raise ValueError("configuration must contain 10 segment slots") + for index in range(self.segment_count): + self.segments[index].validate(index + 1, self.segment_count) + + +@dataclass(frozen=True) +class Snapshot: + # First eight fields retain the legacy GUI/test API. + state: int + flags: int + current_frequency: int + stop_reason: int + last_error: int + total_pulses: int + pulse_output: int + direction_output: int + current_segment: int = 0 + protocol: str = MAP_LEGACY + + @property + def state_name(self) -> str: + return STATE_NAMES.get(self.state, f"Unknown({self.state})") + + @property + def error_name(self) -> str: + return ERROR_NAMES.get(self.last_error, f"Unknown({self.last_error})") + + +class ModbusRtuClient: + def __init__(self, port: str, baud_rate: int = DEFAULT_BAUD_RATE, slave: int = SLAVE_ADDRESS, + timeout: float = 0.6, retries: int = 2, verbose: bool = False, + protocol: str = MAP_LEGACY) -> None: + if protocol not in (MAP_SPEC, MAP_LEGACY): + raise ValueError("protocol must be 'spec' or 'legacy'") + self.port, self.baud_rate, self.slave = port, baud_rate, slave + self.timeout, self.retries, self.verbose, self.protocol = timeout, retries, verbose, protocol + self._serial = None + + def __enter__(self) -> "ModbusRtuClient": + self.open() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def open(self) -> None: + if serial is None: + raise PlsrError("pyserial is not installed; run: python -m pip install -r requirements.txt") + self._serial = serial.Serial(port=self.port, baudrate=self.baud_rate, bytesize=serial.EIGHTBITS, + parity=serial.PARITY_EVEN, stopbits=serial.STOPBITS_ONE, + timeout=0.05, write_timeout=self.timeout) + self._serial.reset_input_buffer() + self._serial.reset_output_buffer() + + def close(self) -> None: + if self._serial is not None: + self._serial.close() + self._serial = None + + def _log_frame(self, prefix: str, frame: bytes) -> None: + if self.verbose: + print(f"{time.strftime('%H:%M:%S')} {prefix} {frame.hex(' ').upper()}") + + def _receive(self, expected_function: int) -> bytes: + if self._serial is None: + raise CommunicationError("serial port is not open") + deadline, buffer = time.monotonic() + self.timeout, bytearray() + while time.monotonic() < deadline: + waiting = self._serial.in_waiting + chunk = self._serial.read(waiting if waiting > 0 else 1) + if chunk: + buffer.extend(chunk) + response = extract_response(buffer, self.slave, expected_function) + if response is not None: + self._log_frame("RX", response) + if response[1] == (expected_function | 0x80): + raise ModbusException(expected_function, response[2]) + return response + tail = bytes(buffer[-32:]).hex(" ").upper() if buffer else "" + raise CommunicationError(f"response timeout; received tail: {tail}") + + def transaction(self, request: bytes, expected_function: int) -> bytes: + if self._serial is None: + raise CommunicationError("serial port is not open") + last_error: Optional[Exception] = None + for attempt in range(self.retries + 1): + if attempt: + time.sleep(0.1) + try: + if self._serial is None: + self.open() + self._serial.reset_input_buffer() + self._log_frame("TX", request) + written = self._serial.write(request) + self._serial.flush() + if written != len(request): + last_error = CommunicationError( + f"short serial write: {written}/{len(request)}" + ) + continue + return self._receive(expected_function) + except ModbusException: + raise + except CommunicationError as exc: + last_error = exc + if (attempt > 0) and (attempt < self.retries): + try: + self.close() + self.open() + except (OSError, PlsrError) as reopen_error: + last_error = reopen_error + except OSError as exc: + last_error = exc + if attempt < self.retries: + try: + self.close() + self.open() + except (OSError, PlsrError) as reopen_error: + last_error = reopen_error + raise CommunicationError(f"transaction failed after {self.retries + 1} attempts: {last_error}") + + def read_holding(self, start: int, count: int) -> list[int]: + if not 0 <= start <= 0xFFFF or not 1 <= count <= 125: + raise ValueError("invalid holding-register range") + response = self.transaction(read_holding_request(self.slave, start, count), 0x03) + if response[2] != count * 2: + raise CommunicationError(f"unexpected byte count: {response[2]}, expected {count * 2}") + payload = response[3:-2] + return [int.from_bytes(payload[i:i + 2], "big") for i in range(0, len(payload), 2)] + + def write_register(self, address: int, value: int) -> None: + if not 0 <= address <= 0xFFFF or not 0 <= value <= 0xFFFF: + raise ValueError("register address/value out of range") + request = write_register_request(self.slave, address, value) + if self.transaction(request, 0x06) != request: + raise CommunicationError("write response does not echo the request") + + def write_registers(self, address: int, values: Sequence[int]) -> None: + request = write_registers_request(self.slave, address, values) + response = self.transaction(request, 0x10) + if response[:6] != request[:6] or int.from_bytes(response[-2:], "little") != crc16(response[:-2]): + raise CommunicationError("multiple-write response does not echo address/count") + + def read_u32(self, address: int) -> int: + values = self.read_holding(address, 2) + return decode_u32(values[0], values[1]) + + def read_i32(self, address: int) -> int: + values = self.read_holding(address, 2) + return decode_i32(values[0], values[1]) + + def write_u32(self, address: int, value: int) -> None: + self.write_registers(address, encode_u32(value)) + + def write_i32(self, address: int, value: int) -> None: + self.write_registers(address, encode_i32(value)) + + def set_running_frequency(self, frequency: int) -> Snapshot: + """修改当前运行段频率,不重新启动脉冲任务。""" + validate_frequency(frequency) + if self.protocol == MAP_LEGACY: + self.write_register(REG_FREQUENCY, frequency) + return self.read_snapshot() + + snapshot = self.read_snapshot() + if snapshot.state not in (1, 2, 3, 4): + raise PlsrError("motion is not running") + if not 1 <= snapshot.current_segment <= 10: + raise PlsrError("current segment is invalid") + address = REG_SEGMENT_BASE + (snapshot.current_segment - 1) * REG_SEGMENT_STRIDE + self.write_u32(address, frequency) + return self.read_snapshot() + + def command(self, value: int) -> None: + if self.protocol == MAP_LEGACY: + self.write_register(REG_COMMAND, value) + return + if value == COMMAND_START: + self.write_register(REG_CONTROL, CONTROL_START) + elif value == COMMAND_STOP: + self.write_register(REG_CONTROL, CONTROL_STOP) + elif value == COMMAND_IMMEDIATE_STOP: + self.write_register(REG_CONTROL, CONTROL_IMMEDIATE_STOP) + elif value == COMMAND_CLEAR_COUNT: + self.write_register(REG_CONTROL, CONTROL_CLEAR_COUNT) + elif value == COMMAND_RESET: + self.write_register(REG_CONTROL, CONTROL_RESET) + elif value == COMMAND_RESTART: + self.write_register(REG_CONTROL, CONTROL_RESTART) + elif value == COMMAND_SAVE_CONFIG: + raise PlsrError("the requirement map has no save bit; configuration persistence is firmware-managed") + else: + raise ValueError(f"unsupported command: {value}") + + def read_snapshot(self) -> Snapshot: + if self.protocol == MAP_LEGACY: + status = self.read_holding(0, 32) + outputs = self.read_holding(REG_PULSE_OUTPUT, 2) + return Snapshot(status[REG_STATE], status[REG_FLAGS], status[REG_CURRENT_FREQUENCY], + status[REG_STOP_REASON], status[REG_LAST_ERROR], + decode_u32(status[REG_TOTAL_PULSES_HIGH], status[REG_TOTAL_PULSES_LOW]), + outputs[0], outputs[1]) + monitor = self.read_holding(REG_MONITOR_TOTAL_PULSES, 8) + config = self.read_holding(REG_CFG_PULSE_OUTPUT, 2) + state = monitor[4] + flags = 1 if state in (1, 2, 3, 4) else (2 if state == 5 else (4 if state == 7 else 0)) + return Snapshot(state, flags, decode_u32(monitor[2], monitor[3]), monitor[7], monitor[6], + decode_i32(monitor[0], monitor[1]), config[0], config[1], monitor[5], MAP_SPEC) + + def read_configuration(self) -> PlsrConfiguration: + if self.protocol != MAP_SPEC: + raise PlsrError("configuration object is only available in spec protocol") + values = self.read_holding(REG_CFG_PULSE_OUTPUT, REG_CFG_DECEL_TIME_MS - REG_CFG_PULSE_OUTPUT + 1) + cfg = PlsrConfiguration( + pulse_output=values[0], direction_output=values[1], wait_input=values[2], ext_input=values[3], + send_mode=values[4], direction_delay_ms=values[5], direction_logic=values[6], accel_mode=values[7], + run_mode=values[8], segment_count=values[9], start_segment=values[10], + default_speed_hz=decode_u32(values[11], values[12]), start_speed_hz=decode_u32(values[13], values[14]), + end_speed_hz=decode_u32(values[16], values[17]), accel_time_ms=values[18], decel_time_ms=values[19], + ) + cfg.segments = [self.read_segment(index + 1) for index in range(10)] + return cfg + + def write_configuration(self, config: PlsrConfiguration, save: bool = False) -> None: + if self.protocol != MAP_SPEC: + raise PlsrError("configuration object is only available in spec protocol") + config.validate() + values = [config.pulse_output, config.direction_output, config.wait_input, config.ext_input, + config.send_mode, config.direction_delay_ms, config.direction_logic, config.accel_mode, + config.run_mode, config.segment_count, config.start_segment] + values.extend(encode_u32(config.default_speed_hz)) + values.extend(encode_u32(config.start_speed_hz)) + values.append(0) # 0x100F reserved + values.extend(encode_u32(config.end_speed_hz)) + values.extend((config.accel_time_ms, config.decel_time_ms)) + self.write_registers(REG_CFG_PULSE_OUTPUT, values) + for index in range(1, config.segment_count + 1): + self.write_segment(index, config.segments[index - 1]) + if save: + # There is no save register in the revised parameter table. Keep + # the argument for GUI compatibility and make the limitation clear. + raise PlsrError("configuration written to RAM; add the firmware save command before requesting Flash save") + + def read_segment(self, index: int) -> SegmentParameters: + if self.protocol != MAP_SPEC or not 1 <= index <= 10: + raise ValueError("segment index must be 1..10") + base = REG_SEGMENT_BASE + (index - 1) * REG_SEGMENT_STRIDE + values = self.read_holding(base, 8) + return SegmentParameters(decode_u32(values[0], values[1]), decode_i32(values[2], values[3]), + values[4], values[5], values[6], values[7]) + + def write_segment(self, index: int, segment: SegmentParameters) -> None: + if self.protocol != MAP_SPEC or not 1 <= index <= 10: + raise ValueError("segment index must be 1..10") + segment.validate(index) + base = REG_SEGMENT_BASE + (index - 1) * REG_SEGMENT_STRIDE + self.write_registers(base, [*encode_u32(segment.frequency_hz), *encode_i32(segment.pulses), + segment.wait_condition, segment.wait_time_ms, segment.act_time_ms, + segment.jump_segment]) + + def configure_outputs(self, pulse_output: int, direction_output: int, save: bool = False) -> Snapshot: + if self.protocol == MAP_LEGACY: + if not 0 <= pulse_output <= 7 or not 0 <= direction_output <= 7 or pulse_output == direction_output: + raise ValueError("legacy output points must be distinct Q0..Q7") + snapshot = self.read_snapshot() + if snapshot.state == 2: + raise PlsrError("configuration is blocked while motion is Busy") + self.write_register(REG_PULSE_OUTPUT, pulse_output) + self.write_register(REG_DIRECTION_OUTPUT, direction_output) + if save: + self.command(COMMAND_SAVE_CONFIG) + return self.read_snapshot() + if not 0 <= pulse_output <= 3 or not 0 <= direction_output <= 3: + raise ValueError("outputs must be Y0..Y3 and Y12..Y15") + if self.read_snapshot().state in (1, 2, 3, 4): + raise PlsrError("configuration is blocked while motion is active") + self.write_register(REG_CFG_PULSE_OUTPUT, pulse_output) + self.write_register(REG_CFG_DIRECTION_OUTPUT, direction_output) + return self.read_snapshot() + + def start_motion(self, pulses: int, frequency: int, direction: int = 0, + restart: bool = False) -> None: + if self.protocol == MAP_LEGACY: + if not 1 <= pulses <= 0xFFFFFFFF: + raise ValueError("legacy pulses must be 1..4294967295") + if not 1 <= frequency <= 1000: + raise ValueError("legacy firmware test module supports 1..1000 Hz") + if direction not in (0, 1): + raise ValueError("direction must be 0 or 1") + self.write_register(REG_PULSES_HIGH, (pulses >> 16) & 0xFFFF) + self.write_register(REG_PULSES_LOW, pulses & 0xFFFF) + self.write_register(REG_FREQUENCY, frequency) + self.write_register(REG_DIRECTION, direction) + self.command(COMMAND_RESTART if restart else COMMAND_START) + return + validate_frequency(frequency) + if pulses == 0 or not -0x80000000 <= pulses <= 0x7FFFFFFF: + raise ValueError("spec pulse count must be non-zero signed 32-bit") + if direction not in (0, 1): + raise ValueError("direction must be 0 or 1") + if direction == 1 and pulses > 0: + pulses = -pulses + cfg = self.read_configuration() + cfg.segments[cfg.start_segment - 1] = SegmentParameters(frequency, pulses) + cfg.segment_count = max(cfg.segment_count, cfg.start_segment) + cfg.validate() + self.write_segment(cfg.start_segment, cfg.segments[cfg.start_segment - 1]) + self.command(COMMAND_RESTART if restart else COMMAND_START) + + +def print_snapshot(snapshot: Snapshot) -> None: + print(f"state={snapshot.state_name:<14} segment={snapshot.current_segment:<2} " + f"frequency={snapshot.current_frequency}Hz total_pulses={snapshot.total_pulses} " + f"error={snapshot.last_error}:{snapshot.error_name} outputs={snapshot.pulse_output}/{snapshot.direction_output}") + + +def wait_for_terminal(client: ModbusRtuClient, timeout: float, interval: float = 0.25) -> Snapshot: + deadline, previous = time.monotonic() + timeout, None + while time.monotonic() < deadline: + snapshot = client.read_snapshot() + current = (snapshot.state, snapshot.total_pulses, snapshot.last_error) + if current != previous: + print_snapshot(snapshot) + previous = current + if snapshot.state in (5, 6, 7): + return snapshot + time.sleep(interval) + try: + client.command(COMMAND_STOP) + except PlsrError: + pass + raise PlsrError("motion timeout; stop command was attempted") + + +def require_safe_confirmation(args: argparse.Namespace) -> None: + if not args.confirm_safe: + raise PlsrError("motion output is blocked; disconnect the mechanism and add --confirm-safe") + + +def offline_self_test() -> None: + assert crc16(bytes.fromhex("01 03 00 00 00 0A")) == 0xCDC5 + assert read_holding_request(1, 0, 32).hex(" ") == "01 03 00 00 00 20 44 12" + assert encode_i32(-1) == (0xFFFF, 0xFFFF) + assert decode_i32(0x8000, 0) == -2147483648 + request = write_registers_request(1, REG_SEGMENT_BASE, [100, 0, 0xFFFF, 0xFFFF, 0, 0, 0, 0]) + assert request[1] == 0x10 and request[6] == 16 + response = with_crc(bytes.fromhex("01 03 04 00 05 00 06")) + noisy = bytearray(bytes.fromhex("40 00 00") + response) + assert extract_response(noisy, 1, 0x03) == response + print("PASS CRC16, signed double-word, FC16 and response resynchronization") + + +def add_connection_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--port", help="serial port, for example COM9") + parser.add_argument("--baud", type=int, default=DEFAULT_BAUD_RATE) + parser.add_argument("--slave", type=int, default=SLAVE_ADDRESS) + parser.add_argument("--timeout", type=float, default=0.6) + parser.add_argument("--retries", type=int, default=2) + parser.add_argument("--map", choices=(MAP_SPEC, MAP_LEGACY), default=MAP_SPEC, + help="spec follows 参数表-2026.xlsx; legacy follows the first firmware image") + parser.add_argument("-v", "--verbose", action="store_true") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="PLSR firmware Modbus RTU test tool") + add_connection_options(parser) + subs = parser.add_subparsers(dest="command_name", required=True) + subs.add_parser("ports") + subs.add_parser("self-test") + subs.add_parser("mandatory-list") + subs.add_parser("status") + monitor = subs.add_parser("monitor"); monitor.add_argument("--interval", type=float, default=0.5) + run = subs.add_parser("run"); run.add_argument("--pulses", type=int, default=10); run.add_argument("--frequency", type=int, default=2) + run.add_argument("--direction", choices=("forward", "reverse"), default="forward"); run.add_argument("--wait-timeout", type=float, default=30.0); run.add_argument("--no-wait", action="store_true"); run.add_argument("--confirm-safe", action="store_true") + subs.add_parser("stop"); subs.add_parser("immediate-stop"); subs.add_parser("reset"); subs.add_parser("clear") + config = subs.add_parser("configure"); config.add_argument("--pulse-output", type=int, required=True); config.add_argument("--direction-output", type=int, required=True); config.add_argument("--save", action="store_true") + auto = subs.add_parser("auto-test"); auto.add_argument("--pulses", type=int, default=6); auto.add_argument("--frequency", type=int, default=2); auto.add_argument("--confirm-safe", action="store_true"); auto.add_argument("--wait-timeout", type=float, default=30.0) + return parser + + +def run_connected_command(args: argparse.Namespace) -> None: + if not args.port: + raise PlsrError("--port is required for this command") + with ModbusRtuClient(args.port, args.baud, args.slave, args.timeout, args.retries, args.verbose, args.map) as client: + if args.command_name == "status": print_snapshot(client.read_snapshot()) + elif args.command_name == "monitor": + while True: print_snapshot(client.read_snapshot()); time.sleep(max(0.1, args.interval)) + elif args.command_name == "run": + require_safe_confirmation(args); client.start_motion(args.pulses, args.frequency, int(args.direction == "reverse")) + if not args.no_wait and wait_for_terminal(client, args.wait_timeout).state != 5: raise PlsrError("motion did not finish normally") + elif args.command_name == "configure": print_snapshot(client.configure_outputs(args.pulse_output, args.direction_output, args.save)) + elif args.command_name == "stop": client.command(COMMAND_STOP); print_snapshot(client.read_snapshot()) + elif args.command_name == "immediate-stop": client.command(COMMAND_IMMEDIATE_STOP); print_snapshot(client.read_snapshot()) + elif args.command_name == "reset": client.command(COMMAND_RESET); print_snapshot(client.read_snapshot()) + elif args.command_name == "clear": client.command(COMMAND_CLEAR_COUNT); print_snapshot(client.read_snapshot()) + elif args.command_name == "auto-test": + require_safe_confirmation(args); client.command(COMMAND_CLEAR_COUNT); client.start_motion(args.pulses, args.frequency, 0); first = wait_for_terminal(client, args.wait_timeout) + if first.state != 5: raise PlsrError("forward test failed") + client.start_motion(args.pulses, args.frequency, 1); second = wait_for_terminal(client, args.wait_timeout) + if second.state != 5: raise PlsrError("reverse test failed") + print("PASS forward/reverse mandatory smoke test") + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command_name == "self-test": offline_self_test() + elif args.command_name == "mandatory-list": print(mandatory_test_checklist()) + elif args.command_name == "ports": + if list_ports is None: raise PlsrError("pyserial is not installed") + for item in sorted(list_ports.comports(), key=lambda p: p.device): print(f"{item.device:<8} {item.description}") + else: run_connected_command(args) + return 0 + except KeyboardInterrupt: return 130 + except (OSError, ValueError, PlsrError) as exc: + print(f"ERROR: {exc}", file=sys.stderr); return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/HostComputer/PythonTest/requirements.txt b/HostComputer/PythonTest/requirements.txt new file mode 100644 index 0000000..731aac6 --- /dev/null +++ b/HostComputer/PythonTest/requirements.txt @@ -0,0 +1 @@ +pyserial>=3.5,<4 diff --git a/HostComputer/PythonTest/start_logic_csv_parser.bat b/HostComputer/PythonTest/start_logic_csv_parser.bat new file mode 100644 index 0000000..1762cbc --- /dev/null +++ b/HostComputer/PythonTest/start_logic_csv_parser.bat @@ -0,0 +1,25 @@ +@echo off +setlocal +title Logic Waveform Viewer +cd /d "%~dp0" + +set "PYTHON_EXE=" +if exist "%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\pythonw.exe" set "PYTHON_EXE=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\pythonw.exe" +if defined PYTHON_EXE goto python_found + +where pythonw >nul 2>nul +if not errorlevel 1 set "PYTHON_EXE=pythonw" +if defined PYTHON_EXE goto python_found + +where python >nul 2>nul +if not errorlevel 1 set "PYTHON_EXE=python" +if defined PYTHON_EXE goto python_found + +echo No usable Python runtime was found. +echo Please install Python 3.9 or later. +pause +exit /b 1 + +:python_found +"%PYTHON_EXE%" "%~dp0parse_logic_gui.py" +exit /b %ERRORLEVEL% diff --git a/HostComputer/PythonTest/start_plsr_test.bat b/HostComputer/PythonTest/start_plsr_test.bat new file mode 100644 index 0000000..e5a794c --- /dev/null +++ b/HostComputer/PythonTest/start_plsr_test.bat @@ -0,0 +1,51 @@ +@echo off +setlocal +chcp 65001 >nul +title PLSR Python Upper Computer +cd /d "%~dp0" +set "PYTHONUTF8=1" +set "PYTHONIOENCODING=utf-8" + +set "PYTHON_EXE=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" +set "PYTHON_ARGS=" + +if exist "%PYTHON_EXE%" goto python_found + +where py >nul 2>nul +if not errorlevel 1 ( + set "PYTHON_EXE=py" + set "PYTHON_ARGS=-3" + goto python_found +) + +where python >nul 2>nul +if not errorlevel 1 ( + set "PYTHON_EXE=python" + goto python_found +) + +echo No usable Python runtime was found. +echo Install Python 3.9 or later, then run this file again. +pause +exit /b 1 + +:python_found +"%PYTHON_EXE%" %PYTHON_ARGS% -c "import serial" >nul 2>nul +if errorlevel 1 ( + echo Installing pyserial... + "%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt + if errorlevel 1 ( + echo Failed to install pyserial. + pause + exit /b 1 + ) +) + +"%PYTHON_EXE%" %PYTHON_ARGS% plsr_gui.py +set "PLSR_EXIT=%ERRORLEVEL%" +if not "%PLSR_EXIT%"=="0" ( + echo. + echo PLSR GUI exited with code %PLSR_EXIT%. + pause +) +exit /b %PLSR_EXIT% diff --git a/HostComputer/PythonTest/test_logic_waveform.py b/HostComputer/PythonTest/test_logic_waveform.py new file mode 100644 index 0000000..c7a64bd --- /dev/null +++ b/HostComputer/PythonTest/test_logic_waveform.py @@ -0,0 +1,112 @@ +"""测试逻辑分析仪 CSV/BIN 波形解析。""" + +from __future__ import annotations + +import struct +import tempfile +import unittest +from pathlib import Path + +from logic_waveform import ( + WaveformChannel, + build_frequency_curve, + load_bin_waveform, + load_csv_waveform, + parse_channel_selection, + parse_marker_times_ms, +) +from parse_logic_csv import CsvParseError + + +class LogicWaveformTest(unittest.TestCase): + """验证两种导出格式的关键解析结果。""" + + def test_load_csv_waveform(self) -> None: + """CSV 应识别采样信息、数字时间列和通道跳变。""" + content = """; Sample rate: 20 MHz +; Sample count: 10 +SystemTime,Time(s),Channel 0,Channel 1 +'2026-08-14 10:00:00,0.0,0,1 +'2026-08-14 10:00:00,0.1,1,1 +'2026-08-14 10:00:00,0.2,1,0 +""" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "wave.csv" + path.write_text(content, encoding="utf-8") + data = load_csv_waveform(path) + + self.assertEqual(data.sample_rate, 20_000_000) + self.assertEqual(data.sample_count, 10) + self.assertEqual(data.record_count, 3) + self.assertEqual(data.start_time, 0.0) + self.assertEqual(data.end_time, 0.2) + self.assertEqual(data.channels[0].times, [0.0, 0.1]) + self.assertEqual(data.channels[0].levels, [0, 1]) + self.assertEqual(data.channels[1].times, [0.0, 0.2]) + self.assertEqual(data.channels[1].levels, [1, 0]) + + def test_load_16_channel_bin_waveform(self) -> None: + """16 通道 BIN 应按小端 16 位采样点读取。""" + samples = (0x0000, 0x0010, 0x0010, 0x0000) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "wave.bin" + path.write_bytes(struct.pack("<4H", *samples)) + data = load_bin_waveform(path, 10.0, 16, [4]) + + channel = data.channels[0] + self.assertEqual(data.sample_count, 4) + self.assertAlmostEqual(data.duration, 0.3) + self.assertEqual(channel.index, 4) + self.assertEqual(channel.times, [0.0, 0.1, 0.3]) + self.assertEqual(channel.levels, [0, 1, 0]) + + def test_parse_channel_selection(self) -> None: + """通道输入应支持单个、列表和范围。""" + self.assertIsNone(parse_channel_selection("自动", 16)) + self.assertEqual(parse_channel_selection("4", 16), [4]) + self.assertEqual(parse_channel_selection("0,2,4-6", 16), [0, 2, 4, 5, 6]) + + def test_build_frequency_curve_with_automatic_falling_edge(self) -> None: + """负逻辑脉冲应自动使用第一个真实下降沿计算频率。""" + channel = WaveformChannel( + index=4, + name="Channel 4", + times=[0.0, 0.1, 0.11, 0.2, 0.21, 0.3, 0.31], + levels=[1, 0, 1, 0, 1, 0, 1], + ) + curve = build_frequency_curve(channel, "auto") + + self.assertEqual(curve.edge_name, "下降沿") + self.assertEqual(curve.times, [0.1, 0.2, 0.3]) + for frequency in curve.frequencies: + self.assertAlmostEqual(frequency, 10.0) + measured_time, frequency = curve.frequency_at(0.2) + self.assertEqual(measured_time, 0.2) + self.assertEqual(frequency, 10.0) + + def test_build_frequency_curve_with_rising_edge(self) -> None: + """指定上升沿时应按相邻上升沿周期换算频率。""" + channel = WaveformChannel( + index=0, + name="Channel 0", + times=[0.0, 0.01, 0.015, 0.02, 0.0225, 0.025, 0.0275], + levels=[0, 1, 0, 1, 0, 1, 0], + ) + curve = build_frequency_curve(channel, "rising") + + self.assertEqual(curve.edge_name, "上升沿") + self.assertAlmostEqual(curve.frequencies[0], 100.0) + self.assertAlmostEqual(curve.frequencies[-1], 200.0) + + def test_parse_marker_times_ms(self) -> None: + """关键时间应支持多个毫秒数值。""" + self.assertEqual(parse_marker_times_ms("100, 200,500"), [0.1, 0.2, 0.5]) + + def test_parse_marker_times_ms_rejects_more_than_ten_unique_values(self) -> None: + """关键时间超过十个时应给出明确错误。""" + with self.assertRaisesRegex(CsvParseError, "最多设置 10 个"): + parse_marker_times_ms(",".join(str(value) for value in range(11))) + + +if __name__ == "__main__": + unittest.main() diff --git a/HostComputer/PythonTest/test_plsr_gui.py b/HostComputer/PythonTest/test_plsr_gui.py new file mode 100644 index 0000000..bdddcdc --- /dev/null +++ b/HostComputer/PythonTest/test_plsr_gui.py @@ -0,0 +1,113 @@ +import queue +import unittest +from unittest.mock import patch + +from plsr_gui import PlsrGui +from plsr_test import COMMAND_RESET, Snapshot + + +class PollingUiTests(unittest.TestCase): + def setUp(self): + self.app = PlsrGui() + self.app.withdraw() + self.worker_jobs = self.app.jobs + self.app.jobs = queue.Queue() + self.snapshot = Snapshot(0, 0, 0, 0, 0, 0, 4, 1) + self.app.connected = True + self.app.last_snapshot = self.snapshot + self.app._update_controls() + + def tearDown(self): + self.app.closing = True + self.worker_jobs.put({"name": "shutdown"}) + self.app.destroy() + + def _states(self): + widgets = ( + self.app.connect_button, + self.app.save_outputs_button, + self.app.start_button, + self.app.stop_button, + self.app.emergency_button, + self.app.lamp_test_button, + self.app.reset_button, + self.app.clear_button, + self.app.pulse_output_combo, + self.app.direction_output_combo, + self.app.pulses_spin, + self.app.frequency_spin, + self.app.apply_frequency_button, + self.app.forward_radio, + self.app.reverse_radio, + ) + return tuple(str(widget.cget("state")) for widget in widgets) + + def test_running_state_allows_frequency_update_only(self): + self.app.last_snapshot = Snapshot(2, 1, 1000, 0, 0, 10, 0, 0, 1) + self.app._update_controls() + + self.assertEqual(str(self.app.frequency_spin.cget("state")), "normal") + self.assertEqual( + str(self.app.apply_frequency_button.cget("state")), "normal" + ) + self.assertEqual(str(self.app.pulses_spin.cget("state")), "disabled") + self.assertEqual(str(self.app.forward_radio.cget("state")), "disabled") + + def test_background_poll_does_not_toggle_controls(self): + before = self._states() + + self.assertTrue(self.app._submit("snapshot", purpose="poll")) + self.assertTrue(self.app.poll_active) + self.assertEqual(self._states(), before) + + self.app._handle_result( + { + "type": "result", + "name": "snapshot", + "purpose": "poll", + "result": self.snapshot, + } + ) + self.assertEqual(self._states(), before) + + def test_click_during_poll_is_deferred(self): + self.app._submit("snapshot", purpose="poll") + + accepted = self.app._submit( + "command", purpose="reset", command=COMMAND_RESET + ) + + self.assertTrue(accepted) + self.assertIsNotNone(self.app.deferred_job) + self.app._handle_result( + { + "type": "result", + "name": "snapshot", + "purpose": "poll", + "result": self.snapshot, + } + ) + first = self.app.jobs.get_nowait() + second = self.app.jobs.get_nowait() + self.assertEqual(first["purpose"], "poll") + self.assertEqual(second["purpose"], "reset") + self.assertTrue(self.app.job_active) + self.assertFalse(self.app.poll_active) + + def test_lamp_test_keeps_existing_cumulative_count(self): + self.app.auto_phase = "forward" + self.app.run_baseline = 40 + self.app.lamp_test_baseline = 40 + + self.app._advance_lamp_test(Snapshot(5, 2, 2, 0, 0, 46, 4, 1)) + reverse = self.app.jobs.get_nowait() + self.assertEqual(reverse["purpose"], "auto_reverse") + self.assertTrue(reverse["restart"]) + + with patch("plsr_gui.messagebox.showinfo"): + self.app._advance_lamp_test(Snapshot(5, 2, 2, 0, 0, 40, 4, 1)) + self.assertIsNone(self.app.auto_phase) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/HostComputer/PythonTest/test_plsr_test.py b/HostComputer/PythonTest/test_plsr_test.py new file mode 100644 index 0000000..97b4470 --- /dev/null +++ b/HostComputer/PythonTest/test_plsr_test.py @@ -0,0 +1,168 @@ +import unittest + +import plsr_test + + +class FakeSerial: + def __init__(self, response): + self.response = response + self.rx = bytearray() + self.writes = [] + + @property + def in_waiting(self): + return len(self.rx) + + def reset_input_buffer(self): + self.rx.clear() + + def write(self, data): + self.writes.append(bytes(data)) + self.rx.extend(bytes.fromhex("40 00") + self.response) + return len(data) + + def flush(self): + pass + + def read(self, size): + chunk_size = min(size, 3, len(self.rx)) + data = bytes(self.rx[:chunk_size]) + del self.rx[:chunk_size] + return data + + +class ProtocolTests(unittest.TestCase): + def test_known_crc(self): + self.assertEqual(plsr_test.crc16(bytes.fromhex("01 03 00 00 00 0A")), 0xCDC5) + + def test_status_request(self): + frame = plsr_test.read_holding_request(1, 0, 32) + self.assertEqual(frame.hex(" "), "01 03 00 00 00 20 44 12") + + def test_write_register_request(self): + frame = plsr_test.write_register_request(1, 100, 1) + self.assertEqual(frame, plsr_test.with_crc(bytes.fromhex("01 06 00 64 00 01"))) + + def test_parser_recovers_from_stale_bytes(self): + response = plsr_test.with_crc(bytes.fromhex("01 03 04 00 05 00 06")) + buffer = bytearray(bytes.fromhex("40 00 00 03 40") + response) + self.assertEqual(plsr_test.extract_response(buffer, 1, 3), response) + + def test_parser_waits_for_partial_frame(self): + response = plsr_test.with_crc(bytes.fromhex("01 03 04 00 05 00 06")) + buffer = bytearray(response[:5]) + self.assertIsNone(plsr_test.extract_response(buffer, 1, 3)) + buffer.extend(response[5:]) + self.assertEqual(plsr_test.extract_response(buffer, 1, 3), response) + + def test_snapshot_combines_high_and_low_words(self): + snapshot = plsr_test.Snapshot(5, 2, 10, 0, 0, (1 << 16) | 2, 4, 1) + self.assertEqual(snapshot.total_pulses, 65538) + self.assertEqual(snapshot.state_name, "Done") + + def test_spec_snapshot_reads_stop_reason_and_signed_total(self): + class SnapshotClient(plsr_test.ModbusRtuClient): + def read_holding(self, address, count): + if address == plsr_test.REG_MONITOR_TOTAL_PULSES: + return [0xFFFF, 0xFFFE, 0, 2, 6, 1, 0, 1] + return [2, 1] + + snapshot = SnapshotClient("FAKE", protocol=plsr_test.MAP_SPEC).read_snapshot() + self.assertEqual(snapshot.total_pulses, -2) + self.assertEqual(snapshot.stop_reason, 1) + self.assertEqual(snapshot.state_name, "Stopped") + + def test_client_handles_fragmented_noisy_response(self): + response = plsr_test.with_crc(bytes.fromhex("01 03 04 00 05 00 06")) + fake = FakeSerial(response) + client = plsr_test.ModbusRtuClient("FAKE", timeout=0.1, retries=0) + client._serial = fake + self.assertEqual(client.read_holding(0, 2), [5, 6]) + self.assertEqual(len(fake.writes), 1) + + def test_signed_double_word_round_trip(self): + self.assertEqual(plsr_test.encode_i32(-2147483648), (0x8000, 0x0000)) + self.assertEqual(plsr_test.encode_i32(-1), (0xFFFF, 0xFFFF)) + self.assertEqual(plsr_test.decode_i32(0x7FFF, 0xFFFF), 2147483647) + self.assertEqual(plsr_test.decode_i32(0x8000, 0), -2147483648) + + def test_spec_configuration_validation(self): + cfg = plsr_test.PlsrConfiguration( + pulse_output=3, direction_output=3, segment_count=10, + segments=[plsr_test.SegmentParameters(100000, -1, 4, 10, 20, 0) for _ in range(10)], + ) + cfg.validate() + zero_speed_cfg = plsr_test.PlsrConfiguration( + start_speed_hz=0, + end_speed_hz=0, + segments=[plsr_test.SegmentParameters(0, 1)] + [plsr_test.SegmentParameters() for _ in range(9)], + ) + zero_speed_cfg.validate() + with self.assertRaisesRegex(ValueError, "frequency must be 1..100000 Hz"): + plsr_test.PlsrConfiguration(default_speed_hz=0).validate() + with self.assertRaises(ValueError): + plsr_test.PlsrConfiguration(segment_count=11).validate() + + def test_send_and_run_modes_accept_only_zero_or_one(self): + config = plsr_test.PlsrConfiguration(send_mode=1, run_mode=1) + config.validate() + with self.assertRaises(ValueError): + plsr_test.PlsrConfiguration(send_mode=2).validate() + with self.assertRaises(ValueError): + plsr_test.PlsrConfiguration(run_mode=2).validate() + + def test_spec_segment_request_uses_fc16_and_signed_count(self): + frame = plsr_test.write_registers_request( + 1, plsr_test.REG_SEGMENT_BASE, + [100000 >> 16, 100000 & 0xFFFF, 0xFFFF, 0xFFFF, 4, 10, 20, 0], + ) + self.assertEqual(frame[1], 0x10) + self.assertEqual(frame[2:6], bytes.fromhex("11 00 00 08")) + self.assertEqual(frame[6], 16) + + def test_spec_commands_have_independent_control_bits(self): + client = plsr_test.ModbusRtuClient("FAKE", protocol=plsr_test.MAP_SPEC) + writes = [] + client.write_register = lambda address, value: writes.append((address, value)) + + client.command(plsr_test.COMMAND_STOP) + client.command(plsr_test.COMMAND_IMMEDIATE_STOP) + client.command(plsr_test.COMMAND_CLEAR_COUNT) + client.command(plsr_test.COMMAND_RESET) + client.command(plsr_test.COMMAND_RESTART) + + self.assertEqual(writes, [ + (plsr_test.REG_CONTROL, plsr_test.CONTROL_STOP), + (plsr_test.REG_CONTROL, plsr_test.CONTROL_IMMEDIATE_STOP), + (plsr_test.REG_CONTROL, plsr_test.CONTROL_CLEAR_COUNT), + (plsr_test.REG_CONTROL, plsr_test.CONTROL_RESET), + (plsr_test.REG_CONTROL, plsr_test.CONTROL_RESTART), + ]) + + def test_running_frequency_updates_current_segment_only(self): + client = plsr_test.ModbusRtuClient( + "FAKE", protocol=plsr_test.MAP_SPEC + ) + snapshots = [ + plsr_test.Snapshot( + 2, 1, 1000, 0, 0, 25, 0, 0, 3, plsr_test.MAP_SPEC + ), + plsr_test.Snapshot( + 2, 1, 5000, 0, 0, 25, 0, 0, 3, plsr_test.MAP_SPEC + ), + ] + writes = [] + client.read_snapshot = lambda: snapshots.pop(0) + client.write_u32 = lambda address, value: writes.append((address, value)) + + result = client.set_running_frequency(5000) + + expected_address = ( + plsr_test.REG_SEGMENT_BASE + 2 * plsr_test.REG_SEGMENT_STRIDE + ) + self.assertEqual(writes, [(expected_address, 5000)]) + self.assertEqual(result.current_frequency, 5000) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/HostComputer/PythonTest/完整参数与多段脉冲操作说明.md b/HostComputer/PythonTest/完整参数与多段脉冲操作说明.md new file mode 100644 index 0000000..31fdd3c --- /dev/null +++ b/HostComputer/PythonTest/完整参数与多段脉冲操作说明.md @@ -0,0 +1,266 @@ +# PLSR 完整参数与多段脉冲操作说明 + +## 1. 文档用途 + +本文档用于说明 Python 上位机中“完整参数与多段”页面的使用方法,并提供可以直接照着填写的测试示例。 + +测试前请先断开伺服和机械负载,只连接测试灯或示波器。确认输出点、电源和公共端接线正确后,再进行低频测试。 + +## 2. 让参数生效的正确顺序 + +完整参数和多段脉冲必须按照下面的顺序操作: + +1. 串口映射选择 `spec`,连接设备。 +2. 打开“完整参数与多段”页面。 +3. 设置上方“系统与运动参数”。 +4. 逐段编辑参数,每编辑完一段都点击“应用当前段”。 +5. 全部段编辑完成后,点击“校验并写入全部”。 +6. 等待界面提示“完整参数写入并回读校验完成”。 +7. 点击“从设备读取全部”,确认读回的数据和设置一致。 +8. 回到“控制与状态”页面。 +9. 把“目标脉冲数”和“频率”设置为起始段相同的值。 +10. 点击“启动/继续脉冲”,多段路径才会真正开始执行。 + +> “应用当前段”只把右侧编辑框的数据更新到上位机左侧表格,不会写入设备。 +> +> “校验并写入全部”才会把系统参数和有效段写入设备。 +> +> “从设备读取全部”只读取参数,不会启动脉冲。 + +## 3. 当前界面的重要注意事项 + +目前“控制与状态”页面的“启动/继续脉冲”会把该页面中的目标脉冲数和频率再次写入起始段。 + +因此,多段测试时必须保证: + +- 控制页“目标脉冲数” = 起始段“脉冲数”; +- 控制页“频率” = 起始段“频率”; +- 起始段为正脉冲时选择正向; +- 起始段为负脉冲时选择反向。 + +如果两边数值不同,起始段会被控制页数值覆盖,但后续段仍使用完整参数页中的设置。 + +## 4. 系统与运动参数说明 + +| 参数 | 说明 | 建议测试值 | +|---|---|---:| +| WAIT信号 | `WAIT信号`条件使用的输入点 | X4 | +| EXT信号 | `EXT信号`条件使用的输入点 | X5 | +| 发送模式 | 决定段边界速度处理方式 | 先用完成模式 | +| 方向逻辑 | 方向输出的逻辑设置 | 正逻辑 | +| 加减速模式 | 直线、S曲线、正弦曲线 | 先用直线 | +| 运行模式 | 相对位置或绝对位置 | 先用相对 | +| 方向延时 | 方向变化后的等待时间 | 10 ms | +| 总段数 | 本次有效段数量 | 1~10 | +| 起始段 | 从指定段开始执行 | 1 | +| 默认速度 | 公共匀速参考值 | 1000 Hz | +| 起始速度 | 第一段或换向后开始时的速度 | 100 Hz | +| 终止速度 | 最后一段结束前的目标速度 | 100 Hz | +| 加速时间 | 从起始速度变化到当前段速度的时间 | 100 ms | +| 减速时间 | 最后一段变化到终止速度的时间 | 100 ms | + +当前固件注意事项: + +- 每一段实际运行目标频率以该段的“频率”参数为准;该值为 `0 Hz` 时,按定位手册规则使用“默认速度”发送脉冲; +- “默认速度 / 默认加速时间”和“默认速度 / 默认减速时间”分别决定固定的加减速斜率,当前段起始速度和目标速度只决定斜率作用的区间,不参与斜率计算; +- “方向逻辑”和“方向延时”目前可以写入和保存,但还没有接入实际方向输出时序,暂时不能作为最终验收项。 + +固定斜率定义: + +```text +加速斜率 = 默认速度 / 默认加速时间 +减速斜率 = 默认速度 / 默认减速时间 +``` + +例如默认速度为 `1000 Hz`、默认加速时间为 `100 ms` 时,加速斜率为 `10 Hz/ms`;起始速度为 `100 Hz`、当前段目标为 `500 Hz` 时,应在约 `40 ms` 达到目标。实现按已流逝时间更新频率,不按脉冲序号平均分配加速段。 + +### 4.1 完成模式 + +本段脉冲发送完成时,速度仍保持本段速度。下一段开始后,再从上一段速度向下一段速度变化。 + +示例: + +- 第1段:500 Hz,1000个脉冲; +- 第2段:1000 Hz,2000个脉冲。 + +第1段结束时仍是500 Hz,第2段开始后再由500 Hz向1000 Hz变化。 + +### 4.2 后续模式 + +本段脉冲发送完成前,速度已经向下一段速度变化。本段结束时已经达到下一段速度,下一段直接接着运行。 + +使用上面的示例时,第1段后半部分会从500 Hz向1000 Hz变化,第2段开始时直接使用1000 Hz。 + +### 4.3 相对模式 + +每段“脉冲数”表示该段需要移动的脉冲数量: + +- 正数表示正向; +- 负数表示反向; +- 每一段都在上一段结束位置的基础上继续移动。 + +### 4.4 绝对模式 + +每段“脉冲数”表示目标累计位置: + +- 当前累计位置小于目标位置时正向运行; +- 当前累计位置大于目标位置时反向运行; +- 当前累计位置等于目标位置时,本段不输出脉冲。 + +绝对模式测试前建议先点击“清零累计脉冲”,避免原有累计位置影响测试结果。 + +## 5. 单段参数说明 + +| 参数 | 范围 | 说明 | +|---|---:|---| +| 频率 | 0~100000 Hz | 当前段目标频率;0 表示使用系统默认速度 | +| 脉冲数 | -2147483648~2147483647 | 正数正向,负数反向;有效段不能设置为0 | +| 等待条件 | 5种 | 当前段完成后或条件满足后切换 | +| WAIT时间 | 0~65535 ms | `WAIT时间`使用的等待时间 | +| ACT时间 | 0~65535 ms | `ACT时间`使用的时间 | +| 跳转段 | 0~总段数 | 0表示默认进入下一段,最后一段为0表示结束 | + +### 5.1 跳转段规则 + +- 中间段设置为0:进入下一段; +- 最后一段设置为0:结束整个多段任务; +- 设置为指定段号:条件满足后跳转到该段; +- 跳转到自身:会形成循环,测试时要准备受控停止; +- 跳转段不能大于“总段数”。 + +## 6. 等待条件的当前行为 + +| 等待条件 | 当前固件行为 | 测试方法 | +|---|---|---| +| WAIT时间 | 当前段完成后等待指定毫秒,再跳转 | 设置500 ms,观察两段之间的间隔 | +| WAIT信号 | 当前段完成后等待X4或X5有效,再跳转 | 将对应X点拉为有效低电平 | +| ACT时间 | 当前实现为本段完成后按ACT时间等待 | 设置500 ms,观察段间间隔 | +| EXT信号 | 当前实现为本段完成后等待X4或X5有效 | 本段完成后触发对应X点 | +| EXT或完成 | 当前段完成后直接跳转 | 当前不能验证运行中EXT提前切段 | + +注意:题目的最终定义要求 `ACT时间`、`EXT信号`、`EXT或完成`能够在当前段运行过程中提前切段。当前固件尚未完整实现这个提前切段行为,现阶段不要按该预期进行验收。 + +X4、X5当前按低电平有效处理。输入悬空可能造成误触发,测试时应保证输入电平稳定。 + +## 7. 三段连续运行测试示例 + +这组参数不需要外部输入信号,适合第一次验证多段功能。 + +### 7.1 系统参数 + +| 参数 | 设置值 | +|---|---:| +| 发送模式 | 完成模式 | +| 加减速模式 | 直线 | +| 运行模式 | 相对 | +| 总段数 | 3 | +| 起始段 | 1 | +| 默认速度 | 1000 Hz | +| 起始速度 | 100 Hz | +| 终止速度 | 100 Hz | +| 加速时间 | 100 ms | +| 减速时间 | 100 ms | + +### 7.2 段参数 + +| 段 | 频率 | 脉冲数 | 等待条件 | WAIT时间 | ACT时间 | 跳转段 | +|---:|---:|---:|---|---:|---:|---:| +| 1 | 500 Hz | 1000 | WAIT时间 | 500 ms | 0 | 0 | +| 2 | 1000 Hz | 2000 | WAIT时间 | 500 ms | 0 | 0 | +| 3 | 2000 Hz | -1000 | WAIT时间 | 0 ms | 0 | 0 | + +### 7.3 编辑步骤 + +1. 右侧“段号”选择1,填写第1段参数,点击“应用当前段”。 +2. “段号”选择2,填写第2段参数,点击“应用当前段”。 +3. “段号”选择3,填写第3段参数,点击“应用当前段”。 +4. 检查左侧表格的前3行是否正确。 +5. 点击“校验并写入全部”。 +6. 点击“从设备读取全部”,确认参数没有变化。 +7. 回到“控制与状态”页面。 +8. 设置目标脉冲数为1000,频率为500 Hz,方向选择正向。 +9. 点击“启动/继续脉冲”。 + +### 7.4 预期结果 + +1. 第1段以500 Hz发送1000个正向脉冲; +2. 等待约500 ms; +3. 第2段以1000 Hz发送2000个正向脉冲; +4. 等待约500 ms; +5. 第3段以2000 Hz发送1000个反向脉冲; +6. 最后一段跳转段为0,任务结束; +7. 如果开始前累计值为0,最终累计值应约为2000。 + +计算过程:`1000 + 2000 - 1000 = 2000`。 + +## 8. WAIT信号测试示例 + +1. 总段数设置为2,起始段设置为1。 +2. WAIT信号选择X4。 +3. 第1段设置500 Hz、1000脉冲、等待条件选择WAIT信号、跳转段为0。 +4. 第2段设置1000 Hz、1000脉冲、等待条件选择WAIT时间、WAIT时间为0、跳转段为0。 +5. 逐段点击“应用当前段”,再点击“校验并写入全部”。 +6. 控制页设置1000脉冲、500 Hz、正向,然后启动。 +7. 第1段结束后,运行状态应显示等待。 +8. 将X4输入拉为有效低电平,第2段应开始运行。 + +## 9. 跳转循环测试示例 + +循环测试有持续输出风险,只能在测试灯或示波器条件下进行。 + +1. 总段数设置为2。 +2. 第1段跳转段设置为2。 +3. 第2段跳转段设置为1。 +4. 两段等待条件都设置WAIT时间,WAIT时间建议500 ms。 +5. 启动后第1段和第2段会循环运行。 +6. 使用“受控停止”结束测试。 + +## 10. 运行中修改频率 + +1. 启动多段任务。 +2. 在“控制与状态”页面修改频率输入框。 +3. 点击“应用频率”。 +4. 新频率只修改当前正在运行的段,不暂停任务,也不清零剩余脉冲。 +5. 进入下一段后,下一段仍使用其自身配置的频率。 + +## 11. 常见问题 + +### 11.1 点击“应用当前段”后设备没有动作 + +这是正常现象。“应用当前段”只更新上位机临时表格,还需要点击“校验并写入全部”,最后回到控制页点击启动。 + +### 11.2 写入后只有第1段运行 + +检查以下内容: + +- 总段数是否大于1; +- 第2段到最后有效段的脉冲数是否为0; +- 中间段的跳转段是否超过总段数; +- 当前段是否停在WAIT信号或EXT信号等待; +- 最后错误是否为参数无效。 + +### 11.3 参数写入后又变了 + +控制页启动时会覆盖起始段的脉冲数和频率。启动前应保证控制页数值与起始段一致。 + +### 11.4 设备一直显示等待 + +如果等待条件为WAIT信号或EXT信号,检查所选X4/X5是否已经达到有效低电平。没有接输入信号时,建议先使用WAIT时间测试。 + +### 11.5 修改了总段数,但第4段没有执行 + +只有段号小于或等于总段数的段才有效。例如总段数为3时,第4~10段不会执行。 + +## 12. 推荐验收顺序 + +1. 单段、低频、无加减速; +2. 三段连续运行; +3. 正反向累计计数; +4. WAIT时间; +5. WAIT信号; +6. 完成模式与后续模式波形; +7. 运行中修改频率; +8. S曲线和正弦曲线; +9. 绝对模式; +10. 跳转循环; +11. ACT和EXT提前切段功能在固件完善后再验收。 diff --git a/HostComputer/PythonTest/逻辑分析仪波形查看器_交接文档.md b/HostComputer/PythonTest/逻辑分析仪波形查看器_交接文档.md new file mode 100644 index 0000000..7d08d3e --- /dev/null +++ b/HostComputer/PythonTest/逻辑分析仪波形查看器_交接文档.md @@ -0,0 +1,152 @@ +# 正点原子逻辑分析仪波形查看器交接文档 + +## 1. 当前目标 + +本工具用于直接查看正点原子 ATK-Logic 导出的逻辑分析仪数据,不进入 IAR,不需要先转换成 CSV 再用 Excel 查看。 + +当前主图不是高低电平图,而是: + +- 横轴:脉冲开始后的时间; +- 纵轴:脉冲频率,单位 Hz; +- 曲线:由相邻同方向脉冲边沿的时间差计算得到的瞬时频率; +- 关键节点:例如 `100 ms`,在图上画竖线、圆点并标出该节点后的最近完整周期频率。 + +## 2. 直接使用 + +双击: + +```text +PythonTest\start_logic_csv_parser.bat +``` + +不需要打开 IAR,也不需要编译 PLC 工程。 + +界面操作: + +1. 点击“选择文件”,选择 `.csv` 或 `.bin`; +2. CSV 直接点击读取; +3. BIN 先填写采集时的采样率和通道数,再点击“读取并显示”; +4. “显示通道”可以填写 `自动`、`4`、`0,1,4` 或 `0-7`; +5. “测量边沿”可以选自动、上升沿、下降沿; +6. “关键时间(ms)”可以填写 `100` 或 `100,200,500`; +7. 鼠标滚轮缩放时间,按住鼠标左键水平拖动,点击“适应窗口”恢复全范围。 + +## 3. 文件结构 + +### `logic_waveform.py` + +解析和计算核心: + +- `load_csv_waveform()`:读取 CSV 元数据、时间列和通道跳变; +- `load_bin_waveform()`:读取无文件头的原始 BIN; +- `load_waveform()`:根据扩展名选择 CSV 或 BIN; +- `WaveformChannel`:保存一个数字通道的跳变时间和电平; +- `FrequencyCurve`:保存频率曲线时间点和频率值; +- `build_frequency_curve()`:按边沿计算瞬时频率; +- `build_frequency_curves()`:批量生成通道曲线; +- `parse_marker_times_ms()`:解析关键时间输入。 + +### `parse_logic_gui.py` + +Tkinter 图形界面: + +- 文件选择和后台读取; +- 频率曲线绘制; +- Hz 纵轴和时间横轴; +- 关键时间标记; +- 缩放、拖动、光标频率显示; +- 自动、上升沿、下降沿选择。 + +### `parse_logic_csv.py` + +原来的 CSV 通用解析和 CSV 输出命令行工具仍然保留,GUI 当前不把保存 CSV 作为主流程。 + +### `test_logic_waveform.py` + +波形解析测试,覆盖: + +- CSV 元数据和通道跳变; +- 16 通道 BIN; +- 通道选择; +- 上升沿、下降沿和自动边沿频率计算; +- 关键时间解析。 + +## 4. 频率计算定义 + +原始 CSV/BIN 中保存的是数字电平,不是频率。程序按以下方式换算: + +```text +频率 = 1 / 相邻同方向边沿的时间差 +``` + +注意事项: + +- `times[0]` 是文件初始电平状态,不作为真实边沿; +- 自动模式使用捕获到的第一个真实边沿方向; +- 负逻辑脉冲通常应使用下降沿,正逻辑脉冲通常应使用上升沿; +- 第一个完整周期的频率会补到曲线起点; +- 关键时间显示的是该时间之后最近一个完整周期的实测频率,不是理论插值值; +- 横轴 `0 ms` 是第一个有效脉冲边沿,不是 CSV 文件的系统时间起点。 + +## 5. 已验证真实文件 + +测试目录: + +```text +D:\xinjiedianqi\Study\TrainCamp_zengbingjie_PLSR\测试脉冲数据 +``` + +### `DL16.csv` + +- 文件注释采样率:20 MHz; +- 原始采样点数:125,351,087; +- CSV 跳变记录:8,001 行; +- 自动识别到 `Channel 4` 有脉冲变化; +- 自动选择下降沿; +- `100 ms` 节点最近测量时间约 `100.094 ms`; +- 该节点频率约 `361.984 Hz`; +- 随后曲线继续升高到约 `500 Hz`。 + +### `zxjs.bin` + +- 文件大小约 49 MB; +- 该 BIN 没有文件头和采样率字段; +- 按 16 通道、每采样点 2 字节可读取; +- 测试使用 20 MHz、指定 Channel 4 时可以成功生成频率曲线; +- BIN 的采样率必须由用户按实际采集设置填写,否则时间和频率都会按比例错误。 + +## 6. 测试命令 + +在项目根目录执行: + +```powershell +python -m unittest discover -s PythonTest -p "test_*.py" +python -m py_compile PythonTest\logic_waveform.py PythonTest\parse_logic_gui.py +``` + +当前最后一次验证结果: + +```text +Ran 24 tests +OK +``` + +双击启动文件也已验证能够创建 `pythonw.exe` 图形进程。 + +## 7. 后续建议 + +后续如果继续完善,优先考虑: + +1. 增加“目标频率/理论加速曲线”输入,与实测曲线叠加对比; +2. 增加加速度单位 `Hz/s` 显示; +3. 对长时间空闲段显示 0 Hz,而不是只显示跨空闲段后的低频点; +4. 增加频率曲线 CSV/图片导出,但不要恢复为唯一查看方式; +5. 如果获得正点原子 BIN 的官方采样率或工程文件格式,再把 BIN 的采样率自动识别补齐。 + +## 8. 当前已知限制 + +- BIN 原始数据没有采样率,程序无法仅凭 BIN 内容推断真实时间; +- 频率是由同方向边沿测得的周期计算出来的,边沿抖动会反映在曲线上; +- 当前关键时间默认按“第一个有效脉冲边沿”为零点,不使用 CSV 中的绝对系统时间; +- 当前主界面显示频率曲线,原始 0/1 电平阶梯图不再是主视图; +- 工作区本身存在其他 PLC、文档和测试文件修改,后续对话不要使用 `git reset --hard` 或整体回退。 diff --git a/PLSR/Inc/plsr.h b/PLSR/Inc/plsr.h index 21d753d..c6c8223 100644 --- a/PLSR/Inc/plsr.h +++ b/PLSR/Inc/plsr.h @@ -16,6 +16,8 @@ extern "C" { #define PLSR_OUTPUT_MODE_ADDRESS (0x1200U) #define PLSR_DIAGNOSTIC_FIRST_ADDRESS (0x2100U) #define PLSR_DIAGNOSTIC_LAST_ADDRESS (0x2119U) +#define PLSR_QUEUE_DIAGNOSTIC_FIRST_ADDRESS (0x2200U) +#define PLSR_QUEUE_DIAGNOSTIC_LAST_ADDRESS (0x2208U) #define PLSR_DIAGNOSTIC_CONTROL_ADDRESS (0x3100U) typedef enum diff --git a/PLSR/Src/plsr.c b/PLSR/Src/plsr.c index 8dfb72d..5302966 100644 --- a/PLSR/Src/plsr.c +++ b/PLSR/Src/plsr.c @@ -49,6 +49,12 @@ #define PLSR_POSITION_CHECKPOINT_MS (10U) #define PLSR_PROFILE_QUEUE_CAPACITY (1024U) #define PLSR_PROFILE_QUEUE_MASK (PLSR_PROFILE_QUEUE_CAPACITY - 1U) +#if ((PLSR_PROFILE_QUEUE_CAPACITY == 0U) \ + || ((PLSR_PROFILE_QUEUE_CAPACITY \ + & (PLSR_PROFILE_QUEUE_CAPACITY - 1U)) != 0U) \ + || (PLSR_PROFILE_QUEUE_CAPACITY > 65535U)) +#error "PLSR profile queue capacity must be a power of two not exceeding 65535" +#endif /* A profile item can represent only one pulse. At the 100 kHz limit these thresholds provide 4 ms of startup data, refill toward 8 ms, and cap each 1 ms producer pass at 2 ms worth of newly planned items. */ @@ -56,8 +62,22 @@ #define PLSR_PROFILE_STARTUP_BUDGET (400U) #define PLSR_PROFILE_REFILL_TARGET (800U) #define PLSR_PROFILE_REFILL_BUDGET (200U) +#define PLSR_PROFILE_LOW_WATER (100U) +#define PLSR_PROFILE_LOW_WATER_CLEAR (200U) #define PLSR_HANDOFF_WARMUP_ITEMS (8U) -#define PLSR_REPLAN_WARMUP_ITEMS (8U) +#define PLSR_REPLAN_PREFIX_ITEMS (512U) +#define PLSR_REPLAN_PREFIX_TIME_US (5000ULL) +#define PLSR_REPLAN_BUILD_ITEMS (400U) +#define PLSR_BOUNDARY_EVENT_CAPACITY (2U) +#if ((PLSR_BOUNDARY_EVENT_CAPACITY == 0U) \ + || ((PLSR_BOUNDARY_EVENT_CAPACITY \ + & (PLSR_BOUNDARY_EVENT_CAPACITY - 1U)) != 0U) \ + || (PLSR_BOUNDARY_EVENT_CAPACITY > 127U)) +#error "PLSR boundary event capacity must be a power of two up to 127" +#endif +#define PLSR_QUEUE_DIAG_LOW_WATER (0x0001U) +#define PLSR_QUEUE_DIAG_UNDERRUN (0x0002U) +#define PLSR_QUEUE_DIAG_PLANNER_CLIPPED (0x0004U) #define PLSR_RAMP_POLL_RESERVE_AREA_DIVISOR (20ULL) /* 功能诊断(计数/频率/曲线检查)总开关。 * 0 = 全部关闭:诊断采集、结算与锁定均不执行,Modbus 诊断寄存器保持全 0, @@ -135,6 +155,15 @@ typedef struct volatile uint8_t valid; } PLSR_HANDOFF_PLAN; +typedef struct +{ + uint64_t magnitude; + uint32_t firstFrequencyHz; + uint32_t completedPulses; + uint8_t nextSegment; + uint8_t positive; +} PLSR_COUNTED_BOUNDARY_EVENT; + typedef enum { PLSR_WORD_OK = 0, @@ -223,15 +252,19 @@ static volatile PLSR_STATUS PlsrRunStatus = PLSR_STATUS_UNINITIALIZED; static PLSR_ERROR PlsrError = PLSR_ERROR_NONE; static PLSR_RAMP PlsrRamp; static PLSR_SHORT_PROFILE PlsrShortProfile; -static PLSR_PROFILE_QUEUE PlsrProfileQueue; +static PLSR_PROFILE_QUEUE PlsrProfileQueues[2]; +static volatile uint8_t PlsrProfileQueueBank; +#define PlsrProfileQueue (PlsrProfileQueues[PlsrProfileQueueBank]) static PLSR_HANDOFF_PLAN PlsrHandoffPlan; static volatile uint64_t PlsrCountedObservedPublished; static volatile uint8_t PlsrCountedHandoffStaged; /* 段边界/流故障事件:IRQ 只置事件并快照(handoff 计划 + 边界累计计数), 全部簿记由 1ms 任务消费事件时完成(IRQ 内不做状态转换与错误标志写入)。 */ -static volatile uint8_t PlsrCountedBoundaryEvent; -static volatile PLSR_HANDOFF_PLAN PlsrCountedBoundaryPlan; -static volatile uint64_t PlsrCountedBoundaryObserved; +static PLSR_COUNTED_BOUNDARY_EVENT + PlsrCountedBoundaryEvents[PLSR_BOUNDARY_EVENT_CAPACITY]; +static volatile uint8_t PlsrCountedBoundaryWriteIndex; +static volatile uint8_t PlsrCountedBoundaryReadIndex; +static volatile uint8_t PlsrCountedBoundaryBusy; static volatile uint8_t PlsrCountedFaultEvent; static PLSR_HANDOFF_PLAN PlsrPreparedHandoffPlans[2][PLSR_SEGMENT_COUNT_MAX]; @@ -266,6 +299,12 @@ static volatile PLSR_DIAGNOSTIC PlsrDiagnostic; static volatile uint64_t PlsrDiagnosticSegmentCounterBase; static volatile uint32_t PlsrDiagnosticQueuedExpectedHz; static volatile uint16_t PlsrDiagnosticFailedChecks; +static volatile uint16_t PlsrProfileQueueMinimumDepth; +static volatile uint16_t PlsrProfileQueueDiagnosticFlags; +static volatile uint8_t PlsrProfileQueueLowWaterLatched; +static volatile uint32_t PlsrProfileQueueLowWaterEvents; +static volatile uint32_t PlsrProfileQueueUnderruns; +static volatile uint32_t PlsrPlannerClippedCount; #if defined(PLSR_DEBUG_TIMING) && (PLSR_DEBUG_TIMING != 0) volatile uint32_t PlsrProfileProducerItemCount; @@ -318,6 +357,8 @@ static uint8_t PlsrPrepareShortProfile(PLSR_SHORT_PROFILE *profile, uint64_t pulseCount); static uint8_t PlsrShortProfileTakeRun(PLSR_SHORT_PROFILE *profile, PLSR_PROFILE_ENTRY *entry); +static void PlsrProfileRecordPlannerStatus( + const PLSR_SHORT_PROFILE *profile); static uint8_t PlsrReplanPulseDir(uint32_t targetHz, uint32_t totalPulses); static void PlsrProfileQueueReset(void); @@ -551,6 +592,12 @@ static void PlsrDiagnosticReset(void) /* 关闭状态保持诊断区清零,Modbus 读诊断寄存器恒为 0。 */ (void)memset((void *)&PlsrDiagnostic, 0, sizeof(PlsrDiagnostic)); #endif + PlsrProfileQueueMinimumDepth = 0xFFFFU; + PlsrProfileQueueDiagnosticFlags = 0U; + PlsrProfileQueueLowWaterLatched = 0U; + PlsrProfileQueueLowWaterEvents = 0UL; + PlsrProfileQueueUnderruns = 0UL; + PlsrPlannerClippedCount = 0UL; } static void PlsrDiagnosticBeginSegment(uint8_t segmentNumber, @@ -1133,6 +1180,8 @@ static uint8_t PlsrAddressIsProduct(uint16_t address) && (address <= PLSR_STATUS_LAST_ADDRESS)) || ((address >= PLSR_DIAGNOSTIC_FIRST_ADDRESS) && (address <= PLSR_DIAGNOSTIC_LAST_ADDRESS)) + || ((address >= PLSR_QUEUE_DIAGNOSTIC_FIRST_ADDRESS) + && (address <= PLSR_QUEUE_DIAGNOSTIC_LAST_ADDRESS)) || (address == PLSR_CONTROL_ADDRESS) || (address == PLSR_DIAGNOSTIC_CONTROL_ADDRESS)) ? 1U : 0U; } @@ -1750,7 +1799,8 @@ static uint8_t PlsrShortProfileTakeRun(PLSR_SHORT_PROFILE *profile, return 0U; } entry->setting = profile->pendingItem.setting; - entry->requestedFrequencyHz = entry->setting.actualFrequencyHz; + entry->requestedFrequencyHz = + profile->pendingItem.requestedFrequencyHz; entry->repeatCount = repeatCount; entry->startsNextSegment = 0U; profile->pendingRepeats -= repeatCount; @@ -1763,55 +1813,211 @@ static uint8_t PlsrShortProfileTakeRun(PLSR_SHORT_PROFILE *profile, return 1U; } -/* Caller holds the platform critical section. The currently preloaded - period is the single committed pulse; the new stream starts after it. */ +/* Build the replacement stream in the inactive queue bank while the IRQ keeps + consuming the published bank. A bounded prefix from the old queue bridges + the construction interval; the final bank flip is the only critical part. */ static uint8_t PlsrReplanPulseDir(uint32_t targetHz, uint32_t totalPulses) { - uint32_t appliedHz; + PLSR_PROFILE_QUEUE *source; + PLSR_PROFILE_QUEUE *destination; + PLSR_SHORT_PROFILE replacement; + PLSR_SHORT_PROFILE candidate; + PLSR_PROFILE_ENTRY entry; + uint64_t prefixTimeUs = 0ULL; + uint64_t prefixPulses = 0ULL; + uint32_t committedPulses; + uint32_t tailFrequencyHz; + uint32_t sourceGeneration; + uint32_t snapshotReadIndex; + uint32_t snapshotWriteIndex; + uint32_t currentReadIndex; + uint32_t availablePlanPulses; + uint32_t replacementPulses; + uint32_t prefixCount = 0UL; + uint32_t consumedPrefix; + uint32_t sourceIndex; + uint32_t criticalState; uint32_t epoch; uint8_t segmentNumber; uint8_t handoffBank; - uint16_t fillBudget = PLSR_REPLAN_WARMUP_ITEMS; + uint8_t sourceBank; + uint8_t destinationBank; + uint8_t boundaryWriteIndex; + uint8_t pipelineStartsNextSegment; + uint8_t tailWasClipped = 0U; + uint16_t fillBudget = PLSR_REPLAN_BUILD_ITEMS; - PlsrRamp.active = 0U; - PlsrShortProfile.active = 0U; - PlsrProfileQueueInvalidateGeneration(); - PlsrHandoffPlan.valid = 0U; - if (totalPulses <= 1UL) + criticalState = PlsrPlatformEnterCritical(); + sourceBank = PlsrProfileQueueBank; + destinationBank = (uint8_t)(sourceBank ^ 1U); + source = &PlsrProfileQueues[sourceBank]; + destination = &PlsrProfileQueues[destinationBank]; + sourceGeneration = source->generation; + snapshotReadIndex = source->readIndex; + snapshotWriteIndex = source->writeIndex; + epoch = PlsrSegmentEpoch; + segmentNumber = PlsrCurrentSegment; + handoffBank = PlsrPreparedHandoffBank; + boundaryWriteIndex = PlsrCountedBoundaryWriteIndex; + if ((PlsrPulseActive == 0U) || (segmentNumber == 0U) + || (segmentNumber > PlsrActiveConfig.segmentCount) + || (boundaryWriteIndex != PlsrCountedBoundaryReadIndex) + || (PlsrCountedFaultEvent != 0U) + || (PlsrPlatformFinitePipelineSnapshot( + (uint8_t)PlsrActiveConfig.pulseOutput, + &committedPulses, &tailFrequencyHz, + &pipelineStartsNextSegment) == 0U) + || (pipelineStartsNextSegment != 0U)) { - return 1U; + PlsrPlatformExitCritical(criticalState); + return 0U; } + PlsrPlatformExitCritical(criticalState); - appliedHz = PlsrQueuedFrequencyHz; - if (appliedHz == 0UL) - { - appliedHz = PlsrCurrentFrequencyHz; + if (totalPulses < committedPulses) + { + totalPulses = committedPulses; + } + availablePlanPulses = totalPulses - committedPulses; + + destination->readIndex = 0UL; + destination->writeIndex = 0UL; + destination->repeatRemaining = 0UL; + destination->producerEpoch = epoch; + destination->producerSegment = segmentNumber; + destination->preparedHandoffBank = handoffBank; + destination->generatorComplete = 0U; + destination->active = 0U; + destination->producerProfile.active = 0U; + + sourceIndex = snapshotReadIndex; + while ((sourceIndex != snapshotWriteIndex) + && (prefixCount < PLSR_REPLAN_PREFIX_ITEMS) + && (prefixPulses < availablePlanPulses) + && (prefixTimeUs < PLSR_REPLAN_PREFIX_TIME_US)) + { + uint32_t remainingPrefixPulses = + availablePlanPulses - (uint32_t)prefixPulses; + uint32_t frequencyHz; + uint64_t remainingPrefixTimeUs; + uint64_t timeLimitedPulses; + + entry = source->entries[sourceIndex & PLSR_PROFILE_QUEUE_MASK]; + if ((entry.repeatCount == 0UL) + || (entry.startsNextSegment != 0U)) + { + break; + } + frequencyHz = entry.setting.actualFrequencyHz; + if (frequencyHz == 0UL) + { + return 0U; + } + remainingPrefixTimeUs = + PLSR_REPLAN_PREFIX_TIME_US - prefixTimeUs; + timeLimitedPulses = + (remainingPrefixTimeUs * frequencyHz + 999999ULL) / 1000000ULL; + if (timeLimitedPulses == 0ULL) + { + timeLimitedPulses = 1ULL; + } + if (entry.repeatCount > timeLimitedPulses) + { + entry.repeatCount = (uint32_t)timeLimitedPulses; + tailWasClipped = 1U; + } + if (entry.repeatCount > remainingPrefixPulses) + { + entry.repeatCount = remainingPrefixPulses; + tailWasClipped = 1U; + } + destination->entries[prefixCount] = entry; + prefixCount++; + sourceIndex++; + prefixPulses += entry.repeatCount; + prefixTimeUs += + ((uint64_t)entry.repeatCount * 1000000ULL + + frequencyHz - 1UL) / frequencyHz; + tailFrequencyHz = frequencyHz; + } + + replacementPulses = availablePlanPulses - (uint32_t)prefixPulses; + if (replacementPulses != 0UL) + { + if ((tailFrequencyHz == 0UL) + || (PlsrPrepareShortProfile(&replacement, segmentNumber, + tailFrequencyHz, targetHz, + replacementPulses) == 0U)) + { + return 0U; + } + PlsrCopyShortProfile(&destination->producerProfile, &replacement); + destination->active = 1U; + candidate = replacement; + while ((fillBudget != 0U) + && (prefixCount < PLSR_PROFILE_QUEUE_CAPACITY) + && (candidate.active != 0U)) + { + fillBudget--; + if (PlsrShortProfileTakeRun(&candidate, &entry) == 0U) + { + return 0U; + } + destination->entries[prefixCount++] = entry; + } + PlsrCopyShortProfile(&destination->producerProfile, &candidate); + destination->generatorComplete = + ((candidate.active == 0U) + || (candidate.nextPeriod >= candidate.pulseCount)) ? 1U : 0U; } - if ((appliedHz == 0UL) - || (PlsrPrepareShortProfile(&PlsrShortProfile, - PlsrCurrentSegment, - appliedHz, targetHz, - totalPulses - 1UL) == 0U)) + else { - return 0U; + (void)memset(&replacement, 0, sizeof(replacement)); + replacement.endHz = tailFrequencyHz; + destination->generatorComplete = 1U; + destination->active = (prefixCount != 0UL) ? 1U : 0U; } - PlsrRefreshCurrentHandoffPlan(PlsrShortProfile.endHz); + destination->writeIndex = prefixCount; - epoch = PlsrSegmentEpoch; - segmentNumber = PlsrCurrentSegment; - handoffBank = PlsrPreparedHandoffBank; - (void)PlsrProfileQueueBegin(&PlsrShortProfile, epoch, - segmentNumber, handoffBank); - if (PlsrProfileQueueFill(PLSR_REPLAN_WARMUP_ITEMS, - &fillBudget) == 0U) + criticalState = PlsrPlatformEnterCritical(); + source = &PlsrProfileQueues[sourceBank]; + currentReadIndex = source->readIndex; + if ((PlsrProfileQueueBank != sourceBank) + || (source->generation != sourceGeneration) + || (source->writeIndex != snapshotWriteIndex) + || (PlsrSegmentEpoch != epoch) + || (PlsrCurrentSegment != segmentNumber) + || (PlsrCountedBoundaryWriteIndex != boundaryWriteIndex) + || (PlsrCountedFaultEvent != 0U) + || (currentReadIndex < snapshotReadIndex) + || ((currentReadIndex - snapshotReadIndex) > prefixCount) + || ((tailWasClipped != 0U) + && ((currentReadIndex - snapshotReadIndex) >= prefixCount))) { - PlsrProfileQueueReset(); - PlsrShortProfile.active = 0U; + PlsrPlatformExitCritical(criticalState); return 0U; } + consumedPrefix = currentReadIndex - snapshotReadIndex; + destination->readIndex = consumedPrefix; + destination->generation = sourceGeneration + 1UL; + PlsrRamp.active = 0U; + PlsrHandoffPlan.valid = 0U; + PlsrCountedHandoffStaged = 0U; + PlsrProfileQueueBank = destinationBank; + PlsrCopyShortProfile(&PlsrShortProfile, + &destination->producerProfile); + PlsrRemainingPulses = totalPulses; + PlsrProfileQueueMinimumDepth = + (uint16_t)(prefixCount - consumedPrefix); + PlsrProfileQueueLowWaterLatched = 0U; PlsrExecutor.generation++; - PlsrExecutor.mode = PLSR_EXEC_STREAM; + PlsrExecutor.mode = (destination->generatorComplete != 0U) + ? PLSR_EXEC_STEP_TABLE : PLSR_EXEC_STREAM; + PlsrPlatformExitCritical(criticalState); + + PlsrProfileRecordPlannerStatus(&replacement); + PlsrRefreshCurrentHandoffPlan(replacement.endHz); return 1U; } @@ -1829,20 +2035,76 @@ static uint16_t PlsrProfileQueueCount(void) return (uint16_t)(writeIndex - readIndex); } +static void PlsrProfileQueueRecordDepth(uint16_t depth) +{ + if (depth < PlsrProfileQueueMinimumDepth) + { + PlsrProfileQueueMinimumDepth = depth; + } + if (depth <= PLSR_PROFILE_LOW_WATER) + { + PlsrProfileQueueDiagnosticFlags |= PLSR_QUEUE_DIAG_LOW_WATER; + if (PlsrProfileQueueLowWaterLatched == 0U) + { + PlsrProfileQueueLowWaterLatched = 1U; + if (PlsrProfileQueueLowWaterEvents != 0xFFFFFFFFUL) + { + PlsrProfileQueueLowWaterEvents++; + } + } + } + else if (depth >= PLSR_PROFILE_LOW_WATER_CLEAR) + { + PlsrProfileQueueLowWaterLatched = 0U; + } +} + +static void PlsrProfileQueueRecordUnderrun(void) +{ + PlsrProfileQueueDiagnosticFlags |= PLSR_QUEUE_DIAG_UNDERRUN; + if (PlsrProfileQueueUnderruns != 0xFFFFFFFFUL) + { + PlsrProfileQueueUnderruns++; + } +} + +static void PlsrProfileRecordPlannerStatus( + const PLSR_SHORT_PROFILE *profile) +{ + if ((profile != NULL) && (profile->planner.clipped != 0U)) + { + uint32_t criticalState = PlsrPlatformEnterCritical(); + + PlsrProfileQueueDiagnosticFlags |= + PLSR_QUEUE_DIAG_PLANNER_CLIPPED; + if (PlsrPlannerClippedCount != 0xFFFFFFFFUL) + { + PlsrPlannerClippedCount++; + } + PlsrPlatformExitCritical(criticalState); + } +} + static void PlsrProfileQueueReset(void) { uint32_t criticalState = PlsrPlatformEnterCritical(); + uint8_t bank; - PlsrProfileQueue.generation++; - PlsrProfileQueue.readIndex = 0U; - PlsrProfileQueue.writeIndex = 0U; - PlsrProfileQueue.active = 0U; - PlsrProfileQueue.generatorComplete = 0U; - PlsrProfileQueue.producerEpoch = 0UL; - PlsrProfileQueue.repeatRemaining = 0UL; - PlsrProfileQueue.producerSegment = 0U; - PlsrProfileQueue.preparedHandoffBank = 0U; - PlsrProfileQueue.producerProfile.active = 0U; + for (bank = 0U; bank < 2U; bank++) + { + PlsrProfileQueues[bank].generation++; + PlsrProfileQueues[bank].readIndex = 0U; + PlsrProfileQueues[bank].writeIndex = 0U; + PlsrProfileQueues[bank].active = 0U; + PlsrProfileQueues[bank].generatorComplete = 0U; + PlsrProfileQueues[bank].producerEpoch = 0UL; + PlsrProfileQueues[bank].repeatRemaining = 0UL; + PlsrProfileQueues[bank].producerSegment = 0U; + PlsrProfileQueues[bank].preparedHandoffBank = 0U; + PlsrProfileQueues[bank].producerProfile.active = 0U; + } + PlsrProfileQueueBank = 0U; + PlsrProfileQueueLowWaterLatched = 0U; PlsrPlatformExitCritical(criticalState); } @@ -1853,24 +2115,28 @@ static uint8_t PlsrProfileQueueBegin( uint8_t preparedHandoffBank) { uint32_t criticalState = PlsrPlatformEnterCritical(); - - PlsrProfileQueue.generation++; - PlsrProfileQueue.readIndex = 0U; - PlsrProfileQueue.writeIndex = 0U; - PlsrProfileQueue.producerEpoch = producerEpoch; - PlsrProfileQueue.producerSegment = producerSegment; - PlsrProfileQueue.preparedHandoffBank = preparedHandoffBank; - PlsrProfileQueue.generatorComplete = 0U; - PlsrProfileQueue.repeatRemaining = 0UL; - PlsrCopyShortProfile(&PlsrProfileQueue.producerProfile, - producerProfile); - PlsrProfileQueue.active = + PLSR_PROFILE_QUEUE *queue = + &PlsrProfileQueues[PlsrProfileQueueBank]; + + queue->generation++; + queue->readIndex = 0U; + queue->writeIndex = 0U; + queue->producerEpoch = producerEpoch; + queue->producerSegment = producerSegment; + queue->preparedHandoffBank = preparedHandoffBank; + queue->generatorComplete = 0U; + queue->repeatRemaining = 0UL; + PlsrProfileQueueMinimumDepth = PLSR_PROFILE_QUEUE_CAPACITY; + PlsrProfileQueueLowWaterLatched = 0U; + PlsrCopyShortProfile(&queue->producerProfile, producerProfile); + PlsrProfileRecordPlannerStatus(producerProfile); + queue->active = ((producerProfile->active != 0U) && (producerProfile->nextPeriod < producerProfile->pulseCount)) ? 1U : 0U; - if (PlsrProfileQueue.active == 0U) + if (queue->active == 0U) { - PlsrProfileQueue.generatorComplete = 1U; + queue->generatorComplete = 1U; } PlsrPlatformExitCritical(criticalState); return 1U; @@ -1879,13 +2145,15 @@ static uint8_t PlsrProfileQueueBegin( static void PlsrProfileQueueInvalidateGeneration(void) { uint32_t criticalState = PlsrPlatformEnterCritical(); - - PlsrProfileQueue.generation++; - PlsrProfileQueue.active = 0U; - PlsrProfileQueue.generatorComplete = 0U; - PlsrProfileQueue.readIndex = 0U; - PlsrProfileQueue.writeIndex = 0U; - PlsrProfileQueue.repeatRemaining = 0UL; + PLSR_PROFILE_QUEUE *queue = + &PlsrProfileQueues[PlsrProfileQueueBank]; + + queue->generation++; + queue->active = 0U; + queue->generatorComplete = 0U; + queue->readIndex = 0U; + queue->writeIndex = 0U; + queue->repeatRemaining = 0UL; PlsrPlatformExitCritical(criticalState); } @@ -1978,6 +2246,10 @@ static uint8_t PlsrProfileQueueFill(uint16_t targetCount, PlsrProfileQueue.entries[writeIndex & PLSR_PROFILE_QUEUE_MASK] = entry; PlsrCopyShortProfile(&PlsrProfileQueue.producerProfile, &candidate); PlsrProfileQueue.writeIndex = writeIndex + 1UL; + if (PlsrProfileQueueCount() >= PLSR_PROFILE_LOW_WATER_CLEAR) + { + PlsrProfileQueueLowWaterLatched = 0U; + } if ((candidate.active == 0U) || (candidate.nextPeriod >= candidate.pulseCount)) { @@ -1999,11 +2271,13 @@ static PLSR_PLATFORM_QUEUE_RESULT PlsrProfileQueueCommitNext( uint32_t nextReadIndex; uint32_t writeIndex; uint32_t actualFrequencyHz; + uint32_t repeatRemaining; PLSR_PLATFORM_QUEUE_RESULT result; - if (PlsrProfileQueue.repeatRemaining != 0UL) + repeatRemaining = PlsrProfileQueue.repeatRemaining; + if (repeatRemaining != 0UL) { - PlsrProfileQueue.repeatRemaining--; + PlsrProfileQueue.repeatRemaining = repeatRemaining - 1UL; return PLSR_PLATFORM_QUEUE_APPLIED; } @@ -2024,6 +2298,7 @@ static PLSR_PLATFORM_QUEUE_RESULT PlsrProfileQueueCommitNext( PlsrShortProfile.active = 0U; return PLSR_PLATFORM_QUEUE_APPLIED; } + PlsrProfileQueueRecordUnderrun(); return PLSR_PLATFORM_QUEUE_FAILED; } @@ -2037,6 +2312,11 @@ static PLSR_PLATFORM_QUEUE_RESULT PlsrProfileQueueCommitNext( nextReadIndex = readIndex + 1UL; PlsrProfileQueue.readIndex = nextReadIndex; PlsrProfileQueue.repeatRemaining = entry->repeatCount - 1UL; + if (PlsrProfileQueue.generatorComplete == 0U) + { + PlsrProfileQueueRecordDepth( + (uint16_t)(PlsrProfileQueue.writeIndex - nextReadIndex)); + } PlsrDeferredFrequencyPending = 0U; PlsrQueuedFrequencyHz = actualFrequencyHz; PlsrDiagnosticRecordFrequency(entry->requestedFrequencyHz, @@ -2282,6 +2562,8 @@ static uint8_t PlsrBeginSegmentOutput(uint32_t startFrequencyHz) PlsrDiagnosticQueuedExpectedHz = firstRun.setting.actualFrequencyHz; PlsrCountedObservedPublished = PlsrPlatformObservedPulses( (uint8_t)PlsrActiveConfig.pulseOutput); + PlsrProfileQueueMinimumDepth = PlsrProfileQueueCount(); + PlsrProfileQueueLowWaterLatched = 0U; PlsrPulseActive = 1U; PlsrExecutor.generation++; PlsrExecutor.mode = (PlsrProfileQueue.generatorComplete != 0U) @@ -2349,6 +2631,7 @@ static uint8_t PlsrStartSegment(uint8_t segmentNumber, uint8_t directionLevel; uint8_t directionChanged; uint32_t startFrequencyHz; + PLSR_PROFILE_QUEUE *queue; if ((segmentNumber == 0U) || (segmentNumber > PlsrActiveConfig.segmentCount)) @@ -2382,9 +2665,10 @@ static uint8_t PlsrStartSegment(uint8_t segmentNumber, PlsrHandoffPlan.valid = 0U; PlsrShortProfile.active = 0U; PlsrShortProfile.nextPeriod = 0U; - if ((allowCarry == 0U) || (PlsrProfileQueue.active == 0U) - || (PlsrProfileQueue.producerEpoch != PlsrSegmentEpoch) - || (PlsrProfileQueue.producerSegment != segmentNumber)) + queue = &PlsrProfileQueues[PlsrProfileQueueBank]; + if ((allowCarry == 0U) || (queue->active == 0U) + || (queue->producerEpoch != PlsrSegmentEpoch) + || (queue->producerSegment != segmentNumber)) { PlsrProfileQueueReset(); } @@ -2527,6 +2811,10 @@ static void PlsrFinishCompleted(void) PlsrShortProfile.active = 0U; PlsrProfileQueueReset(); PlsrHandoffPlan.valid = 0U; + PlsrCountedBoundaryWriteIndex = 0U; + PlsrCountedBoundaryReadIndex = 0U; + PlsrCountedBoundaryBusy = 0U; + PlsrCountedFaultEvent = 0U; PlsrTimerErrorPending = 0U; PlsrRamp.active = 0U; PlsrExecutor.mode = PLSR_EXEC_IDLE; @@ -2561,6 +2849,10 @@ static void PlsrFinishStopped(void) PlsrShortProfile.active = 0U; PlsrProfileQueueReset(); PlsrHandoffPlan.valid = 0U; + PlsrCountedBoundaryWriteIndex = 0U; + PlsrCountedBoundaryReadIndex = 0U; + PlsrCountedBoundaryBusy = 0U; + PlsrCountedFaultEvent = 0U; PlsrTimerErrorPending = 0U; PlsrRamp.active = 0U; PlsrExecutor.mode = PLSR_EXEC_IDLE; @@ -2595,6 +2887,10 @@ static void PlsrEnterError(PLSR_ERROR error) PlsrShortProfile.active = 0U; PlsrProfileQueueReset(); PlsrHandoffPlan.valid = 0U; + PlsrCountedBoundaryWriteIndex = 0U; + PlsrCountedBoundaryReadIndex = 0U; + PlsrCountedBoundaryBusy = 0U; + PlsrCountedFaultEvent = 0U; PlsrTimerErrorPending = 0U; PlsrRamp.active = 0U; PlsrExecutor.mode = PLSR_EXEC_IDLE; @@ -2833,12 +3129,14 @@ static uint8_t PlsrStageCountedHandoff(void) uint16_t required; uint8_t currentSegment; uint8_t index; + PLSR_PROFILE_QUEUE *queue = + &PlsrProfileQueues[PlsrProfileQueueBank]; if ((PlsrActiveConfig.outputMode != PLSR_OUTPUT_PULSE_DIR) || (PlsrCountedHandoffStaged != 0U) || (PlsrStopRequested != 0U) || (PlsrCutRequested != 0U) - || (PlsrProfileQueue.generatorComplete == 0U)) + || (queue->generatorComplete == 0U)) { return 1U; } @@ -2861,19 +3159,19 @@ static uint8_t PlsrStageCountedHandoff(void) ? 1U : 0U)); criticalState = PlsrPlatformEnterCritical(); if ((PlsrCountedHandoffStaged != 0U) - || (PlsrProfileQueue.generatorComplete == 0U) + || (queue->generatorComplete == 0U) || ((uint16_t)(PLSR_PROFILE_QUEUE_CAPACITY - PlsrProfileQueueCount()) < required)) { PlsrPlatformExitCritical(criticalState); return 1U; } - writeIndex = PlsrProfileQueue.writeIndex; + writeIndex = queue->writeIndex; entry.setting = candidate.firstSetting; entry.requestedFrequencyHz = candidate.firstRequestedFrequencyHz; entry.repeatCount = candidate.firstRepeatCount; entry.startsNextSegment = 1U; - PlsrProfileQueue.entries[writeIndex & PLSR_PROFILE_QUEUE_MASK] = entry; + queue->entries[writeIndex & PLSR_PROFILE_QUEUE_MASK] = entry; writeIndex++; if (candidate.secondRepeatCount != 0UL) { @@ -2882,26 +3180,25 @@ static uint8_t PlsrStageCountedHandoff(void) candidate.secondRequestedFrequencyHz; entry.repeatCount = candidate.secondRepeatCount; entry.startsNextSegment = 0U; - PlsrProfileQueue.entries[writeIndex & PLSR_PROFILE_QUEUE_MASK] = entry; + queue->entries[writeIndex & PLSR_PROFILE_QUEUE_MASK] = entry; writeIndex++; } for (index = 0U; index < candidate.warmupCount; index++) { entry = candidate.warmup[index]; entry.startsNextSegment = 0U; - PlsrProfileQueue.entries[writeIndex & PLSR_PROFILE_QUEUE_MASK] = entry; + queue->entries[writeIndex & PLSR_PROFILE_QUEUE_MASK] = entry; writeIndex++; } - PlsrProfileQueue.writeIndex = writeIndex; - PlsrCopyShortProfile(&PlsrProfileQueue.producerProfile, - &candidate.profile); - PlsrProfileQueue.producerEpoch = PlsrSegmentEpoch + 1UL; - PlsrProfileQueue.producerSegment = candidate.nextSegment; - PlsrProfileQueue.generatorComplete = + queue->writeIndex = writeIndex; + PlsrCopyShortProfile(&queue->producerProfile, &candidate.profile); + queue->producerEpoch = PlsrSegmentEpoch + 1UL; + queue->producerSegment = candidate.nextSegment; + queue->generatorComplete = ((candidate.profile.active == 0U) || (candidate.profile.nextPeriod >= candidate.profile.pulseCount)) ? 1U : 0U; - PlsrProfileQueue.active = 1U; + queue->active = 1U; PlsrHandoffPlan = candidate; PlsrHandoffPlan.valid = 1U; PlsrCountedHandoffStaged = 1U; @@ -2985,6 +3282,7 @@ uint8_t PlsrExecTakeCountedRunIrq( { return PLSR_EXEC_RUN_DONE; } + PlsrProfileQueueRecordUnderrun(); return PLSR_EXEC_RUN_FAILED; } entry = &PlsrProfileQueue.entries[readIndex & PLSR_PROFILE_QUEUE_MASK]; @@ -3007,6 +3305,11 @@ uint8_t PlsrExecTakeCountedRunIrq( *startsNextSegment = entry->startsNextSegment; nextReadIndex = readIndex + 1UL; PlsrProfileQueue.readIndex = nextReadIndex; + if (PlsrProfileQueue.generatorComplete == 0U) + { + PlsrProfileQueueRecordDepth( + (uint16_t)(PlsrProfileQueue.writeIndex - nextReadIndex)); + } /* 记录硬件即将生效的频率(事实镜像),不做任何状态转换。 */ PlsrQueuedFrequencyHz = entry->setting.actualFrequencyHz; return PLSR_EXEC_RUN_READY; @@ -3015,6 +3318,9 @@ uint8_t PlsrExecTakeCountedRunIrq( void PlsrExecCountedSegmentBoundaryIrq(uint8_t pulseOutput, uint32_t completedPulses) { + PLSR_COUNTED_BOUNDARY_EVENT *event; + uint8_t writeIndex; + if (pulseOutput != (uint8_t)PlsrActiveConfig.pulseOutput) { return; @@ -3024,12 +3330,25 @@ void PlsrExecCountedSegmentBoundaryIrq(uint8_t pulseOutput, PlsrCountedFaultEvent = 1U; return; } - /* 只置事件并快照:handoff 计划 + 边界累计计数(由平台层在 run 完成 - 累加后传入,IRQ 内不做带副作用的计数读取)。段切换簿记全部由任务 - 在 PlsrExecServiceCountedBoundaryEvent 完成。 */ - PlsrCountedBoundaryPlan = PlsrHandoffPlan; - PlsrCountedBoundaryObserved = completedPulses; - PlsrCountedBoundaryEvent = 1U; + writeIndex = PlsrCountedBoundaryWriteIndex; + if ((PlsrCountedBoundaryBusy != 0U) + || ((uint8_t)(writeIndex - PlsrCountedBoundaryReadIndex) + >= PLSR_BOUNDARY_EVENT_CAPACITY)) + { + PlsrCountedFaultEvent = 1U; + return; + } + event = &PlsrCountedBoundaryEvents[ + writeIndex & (PLSR_BOUNDARY_EVENT_CAPACITY - 1U)]; + event->magnitude = PlsrHandoffPlan.magnitude; + event->firstFrequencyHz = PlsrHandoffPlan.firstFrequencyHz; + event->completedPulses = completedPulses; + event->nextSegment = PlsrHandoffPlan.nextSegment; + event->positive = PlsrHandoffPlan.positive; +#if defined(__ICCARM__) + __DMB(); +#endif + PlsrCountedBoundaryWriteIndex = (uint8_t)(writeIndex + 1U); } static void PlsrExecServiceCountedBoundaryEvent(void); @@ -3042,6 +3361,7 @@ static void PlsrExecServiceCountedEvents(void) { PlsrCountedFaultEvent = 0U; PlsrTimerErrorPending = 1U; + return; } PlsrExecServiceCountedBoundaryEvent(); } @@ -3050,7 +3370,7 @@ static void PlsrExecServiceCountedEvents(void) 硬件连续性不依赖本函数(下一段首 run 已由队列预装载),晚 1ms 消费无影响。 */ static void PlsrExecServiceCountedBoundaryEvent(void) { - PLSR_HANDOFF_PLAN plan; + PLSR_COUNTED_BOUNDARY_EVENT event; uint64_t observed; uint64_t delta; int64_t nextPosition; @@ -3058,14 +3378,24 @@ static void PlsrExecServiceCountedBoundaryEvent(void) uint32_t queuedFrequencyHz; uint8_t nextSegment; - if (PlsrCountedBoundaryEvent == 0U) + uint32_t criticalState; + uint8_t readIndex; + + criticalState = PlsrPlatformEnterCritical(); + readIndex = PlsrCountedBoundaryReadIndex; + if (readIndex == PlsrCountedBoundaryWriteIndex) { + PlsrPlatformExitCritical(criticalState); return; } - PlsrCountedBoundaryEvent = 0U; - plan = PlsrCountedBoundaryPlan; - observed = PlsrCountedBoundaryObserved; - nextSegment = plan.nextSegment; + event = PlsrCountedBoundaryEvents[ + readIndex & (PLSR_BOUNDARY_EVENT_CAPACITY - 1U)]; + PlsrCountedBoundaryReadIndex = (uint8_t)(readIndex + 1U); + PlsrCountedBoundaryBusy = 1U; + PlsrPlatformExitCritical(criticalState); + + observed = event.completedPulses; + nextSegment = event.nextSegment; if ((nextSegment == 0U) || (nextSegment > PlsrActiveConfig.segmentCount)) { @@ -3120,10 +3450,10 @@ static void PlsrExecServiceCountedBoundaryEvent(void) PlsrDiagnosticFinishSegment(1U); PlsrSegmentEpoch++; PlsrCurrentSegment = nextSegment; - PlsrRemainingPulses = plan.magnitude; - PlsrCountPositive = plan.positive; - PlsrCurrentFrequencyHz = plan.firstFrequencyHz; - PlsrBoundaryFrequencyHz = plan.firstFrequencyHz; + PlsrRemainingPulses = event.magnitude; + PlsrCountPositive = event.positive; + PlsrCurrentFrequencyHz = event.firstFrequencyHz; + PlsrBoundaryFrequencyHz = event.firstFrequencyHz; PlsrSegmentClockStarted = 1U; PlsrSegmentElapsedMs = 0UL; PlsrWaitElapsedMs = 0UL; @@ -3138,7 +3468,7 @@ static void PlsrExecServiceCountedBoundaryEvent(void) PlsrPlatformReadInput((uint8_t)PlsrActiveConfig.extInput); PlsrCopyShortProfile(&PlsrShortProfile, &PlsrProfileQueue.producerProfile); - PlsrDiagnosticBeginSegment(nextSegment, plan.magnitude, plan.positive); + PlsrDiagnosticBeginSegment(nextSegment, event.magnitude, event.positive); PlsrSeamlessHandoffPending = 1U; PlsrCountedHandoffStaged = 0U; PlsrHandoffPlan.valid = 0U; @@ -3160,6 +3490,9 @@ static void PlsrExecServiceCountedBoundaryEvent(void) { PlsrRunStatus = PLSR_STATUS_RUNNING; } + criticalState = PlsrPlatformEnterCritical(); + PlsrCountedBoundaryBusy = 0U; + PlsrPlatformExitCritical(criticalState); } void PlsrExecCountedStreamFaultIrq(uint8_t pulseOutput) @@ -3211,26 +3544,46 @@ static void PlsrActivateHandoffQueueFromIrq( uint32_t producerEpoch) { uint8_t index; + PLSR_PROFILE_QUEUE *queue = + &PlsrProfileQueues[PlsrProfileQueueBank]; + uint32_t readIndex = queue->readIndex; + uint32_t writeIndex = queue->writeIndex; + uint32_t queuedEpoch = queue->producerEpoch; + uint8_t queuedSegment = queue->producerSegment; + uint8_t queueActive = queue->active; + + if ((queueActive != 0U) && (queuedEpoch == producerEpoch) + && (queuedSegment == plan->nextSegment) + && (readIndex != writeIndex)) + { + PlsrCopyShortProfile(&PlsrShortProfile, + &queue->producerProfile); + PlsrShortProfile.active = 1U; + PlsrProfileQueueMinimumDepth = PlsrProfileQueueCount(); + PlsrProfileQueueLowWaterLatched = 0U; + return; + } - PlsrProfileQueue.generation++; - PlsrProfileQueue.readIndex = 0U; - PlsrProfileQueue.writeIndex = plan->warmupCount; + /* Fallback for a stale/missing prepared bank. This path is fault + containment only; normal AB handoff uses the 400-item task-built queue. */ + queue->generation++; + queue->readIndex = 0U; + queue->writeIndex = plan->warmupCount; for (index = 0U; index < plan->warmupCount; index++) { - PlsrProfileQueue.entries[index] = plan->warmup[index]; + queue->entries[index] = plan->warmup[index]; } - PlsrCopyShortProfile(&PlsrProfileQueue.producerProfile, - &plan->profile); - PlsrProfileQueue.producerEpoch = producerEpoch; - PlsrProfileQueue.producerSegment = plan->nextSegment; - PlsrProfileQueue.preparedHandoffBank = PlsrPreparedHandoffBank; - PlsrProfileQueue.generatorComplete = + PlsrCopyShortProfile(&queue->producerProfile, &plan->profile); + queue->producerEpoch = producerEpoch; + queue->producerSegment = plan->nextSegment; + queue->preparedHandoffBank = PlsrPreparedHandoffBank; + queue->generatorComplete = (plan->profile.active == 0U) ? 1U : 0U; - PlsrProfileQueue.active = + queue->active = ((plan->warmupCount != 0U) || (plan->profile.active != 0U)) ? 1U : 0U; PlsrCopyShortProfile(&PlsrShortProfile, &plan->profile); - if (PlsrProfileQueue.active != 0U) + if (queue->active != 0U) { PlsrShortProfile.active = 1U; } @@ -3637,7 +3990,8 @@ uint8_t PlsrInit(void) (void)memset(&PlsrActiveConfig, 0, sizeof(PlsrActiveConfig)); (void)memset(&PlsrRamp, 0, sizeof(PlsrRamp)); (void)memset(&PlsrShortProfile, 0, sizeof(PlsrShortProfile)); - (void)memset(&PlsrProfileQueue, 0, sizeof(PlsrProfileQueue)); + (void)memset(PlsrProfileQueues, 0, sizeof(PlsrProfileQueues)); + PlsrProfileQueueBank = 0U; (void)memset(&PlsrHandoffPlan, 0, sizeof(PlsrHandoffPlan)); (void)memset(&PlsrExecutor, 0, sizeof(PlsrExecutor)); (void)memset(PlsrPreparedHandoffPlans, 0, @@ -3651,9 +4005,11 @@ uint8_t PlsrInit(void) PlsrCountOverflowPending = 0U; PlsrCountedObservedPublished = 0ULL; PlsrCountedHandoffStaged = 0U; - PlsrCountedBoundaryEvent = 0U; - PlsrCountedBoundaryPlan.valid = 0U; - PlsrCountedBoundaryObserved = 0ULL; + PlsrCountedBoundaryWriteIndex = 0U; + PlsrCountedBoundaryReadIndex = 0U; + PlsrCountedBoundaryBusy = 0U; + (void)memset(PlsrCountedBoundaryEvents, 0, + sizeof(PlsrCountedBoundaryEvents)); PlsrCountedFaultEvent = 0U; PlsrPositionCheckpointDirty = 0U; PlsrFrequencyUpdatePending = 0U; @@ -3808,10 +4164,6 @@ static uint8_t PlsrExecuteStop(void) PlsrStopRequested = 1U; PlsrStopPulsesRemaining = 0U; - PlsrShortProfile.active = 0U; - PlsrProfileQueueInvalidateGeneration(); - PlsrHandoffPlan.valid = 0U; - PlsrInvalidateHandoffPlans(); if (PlsrBoundaryPending != 0U) { PlsrPlatformExitCritical(criticalState); @@ -3823,6 +4175,13 @@ static uint8_t PlsrExecuteStop(void) PlsrFinishStopped(); return 1U; } + if (PlsrActiveConfig.outputMode != PLSR_OUTPUT_PULSE_DIR) + { + PlsrShortProfile.active = 0U; + PlsrProfileQueueInvalidateGeneration(); + PlsrHandoffPlan.valid = 0U; + PlsrInvalidateHandoffPlans(); + } stopTargetHz = PlsrEffectiveStopFrequency( PlsrActiveConfig.segments[PlsrCurrentSegment - 1U].frequencyHz); @@ -3845,13 +4204,15 @@ static uint8_t PlsrExecuteStop(void) drainPulses = 1ULL; } PlsrRemainingPulses = drainPulses; + PlsrPlatformExitCritical(criticalState); if (PlsrReplanPulseDir(stopTargetHz, (uint32_t)drainPulses) == 0U) { - PlsrPlatformExitCritical(criticalState); PlsrEnterError(PLSR_ERROR_TIMER); return 1U; } + PlsrInvalidateHandoffPlans(); + criticalState = PlsrPlatformEnterCritical(); PlsrRunStatus = PLSR_STATUS_DECELERATING; PlsrPlatformExitCritical(criticalState); return 1U; @@ -4032,10 +4393,12 @@ void PlsrPoll1ms(void) uint8_t extEdge; uint8_t activeSegmentNumber; uint8_t applyDynamicFrequency = 0U; + uint8_t pulseDirReplanRequested = 0U; uint8_t pulseDirReplanFailed = 0U; PLSR_SEGMENT_CONFIG *activeSegment; uint32_t criticalState; uint32_t newTargetHz; + uint32_t pulseDirReplanPulses = 0UL; uint32_t pollEpoch; uint16_t fillBudget = PLSR_PROFILE_REFILL_BUDGET; @@ -4052,6 +4415,11 @@ void PlsrPoll1ms(void) /* 段边界事件必须在任何计数同步之前消费:快照是 IRQ 边界时刻的 硬件计数,晚消费会把下一 run 的脉冲算进旧段。 */ PlsrExecServiceCountedEvents(); + if (PlsrTimerErrorPending != 0U) + { + PlsrEnterError(PLSR_ERROR_TIMER); + return; + } PlsrPollPositionCheckpoint(); @@ -4065,8 +4433,14 @@ void PlsrPoll1ms(void) return; } + if (PlsrProfileQueueCount() <= PLSR_PROFILE_LOW_WATER) + { + fillBudget = PLSR_PROFILE_STARTUP_BUDGET; + } + if (((PlsrExecutor.mode == PLSR_EXEC_STEP_TABLE) - || (PlsrExecutor.mode == PLSR_EXEC_STREAM)) + || (PlsrExecutor.mode == PLSR_EXEC_STREAM) + || (PlsrExecutor.mode == PLSR_EXEC_AB_LEGACY)) && (PlsrProfileQueue.active != 0U) && (PlsrProfileQueue.generatorComplete == 0U) && (PlsrProfileQueueFill(PLSR_PROFILE_REFILL_TARGET, @@ -4186,22 +4560,25 @@ void PlsrPoll1ms(void) { PlsrFrequencyUpdatePending = 0U; activeSegment->frequencyHz = newTargetHz; - PlsrShortProfile.active = 0U; - PlsrProfileQueueInvalidateGeneration(); PlsrBoundaryRampStarted = 0U; if (PlsrActiveConfig.outputMode == PLSR_OUTPUT_PULSE_DIR) { uint64_t remaining = PlsrRemainingPulses; - if ((remaining == 0ULL) || (remaining > 0xFFFFFFFFULL) - || (PlsrReplanPulseDir(newTargetHz, - (uint32_t)remaining) == 0U)) + if ((remaining == 0ULL) || (remaining > 0xFFFFFFFFULL)) { pulseDirReplanFailed = 1U; } + else + { + pulseDirReplanPulses = (uint32_t)remaining; + pulseDirReplanRequested = 1U; + } } else { + PlsrShortProfile.active = 0U; + PlsrProfileQueueInvalidateGeneration(); PlsrRampStart(PlsrCurrentFrequencyHz, newTargetHz); if (PlsrRamp.active == 0U) { @@ -4211,6 +4588,13 @@ void PlsrPoll1ms(void) } PlsrPlatformExitCritical(criticalState); + if ((pulseDirReplanRequested != 0U) + && (PlsrReplanPulseDir(newTargetHz, + pulseDirReplanPulses) == 0U)) + { + pulseDirReplanFailed = 1U; + } + if (pulseDirReplanFailed != 0U) { PlsrEnterErrorIfEpoch(PLSR_ERROR_TIMER, pollEpoch); @@ -4467,6 +4851,7 @@ PLSR_MB_RESULT PlsrModbusReadHolding(uint16_t startAddress, uint32_t frequency; uint16_t statusWords[7]; uint16_t diagnosticWords[26]; + uint16_t queueDiagnosticWords[9]; if (values == NULL) { @@ -4516,6 +4901,15 @@ PLSR_MB_RESULT PlsrModbusReadHolding(uint16_t startAddress, diagnosticWords[23] = PlsrHighWord(PlsrDiagnostic.curveMaxAbsErrorHz); diagnosticWords[24] = PlsrLowWord(PlsrDiagnostic.firstMismatchSample); diagnosticWords[25] = PlsrHighWord(PlsrDiagnostic.firstMismatchSample); + queueDiagnosticWords[0] = PlsrProfileQueueDiagnosticFlags; + queueDiagnosticWords[1] = PlsrProfileQueueMinimumDepth; + queueDiagnosticWords[2] = PlsrProfileQueueCount(); + queueDiagnosticWords[3] = PlsrLowWord(PlsrProfileQueueLowWaterEvents); + queueDiagnosticWords[4] = PlsrHighWord(PlsrProfileQueueLowWaterEvents); + queueDiagnosticWords[5] = PlsrLowWord(PlsrProfileQueueUnderruns); + queueDiagnosticWords[6] = PlsrHighWord(PlsrProfileQueueUnderruns); + queueDiagnosticWords[7] = PlsrLowWord(PlsrPlannerClippedCount); + queueDiagnosticWords[8] = PlsrHighWord(PlsrPlannerClippedCount); PlsrPlatformExitCritical(criticalState); for (index = 0U; index < quantity; index++) @@ -4542,6 +4936,12 @@ PLSR_MB_RESULT PlsrModbusReadHolding(uint16_t startAddress, values[index] = diagnosticWords[address - PLSR_DIAGNOSTIC_FIRST_ADDRESS]; } + else if ((address >= PLSR_QUEUE_DIAGNOSTIC_FIRST_ADDRESS) + && (address <= PLSR_QUEUE_DIAGNOSTIC_LAST_ADDRESS)) + { + values[index] = queueDiagnosticWords[ + address - PLSR_QUEUE_DIAGNOSTIC_FIRST_ADDRESS]; + } else if (address == PLSR_CONTROL_ADDRESS) { values[index] = 0U; @@ -4597,6 +4997,12 @@ PLSR_MB_RESULT PlsrModbusWriteHolding(uint16_t startAddress, return PLSR_MB_ILLEGAL_ADDRESS; } + if ((startAddress >= PLSR_QUEUE_DIAGNOSTIC_FIRST_ADDRESS) + && (startAddress <= PLSR_QUEUE_DIAGNOSTIC_LAST_ADDRESS)) + { + return PLSR_MB_ILLEGAL_ADDRESS; + } + if (startAddress == PLSR_DIAGNOSTIC_CONTROL_ADDRESS) { if (quantity != 1U) diff --git a/PLSR/Src/plsr_planner.c b/PLSR/Src/plsr_planner.c index 690fcfc..f2e7cfa 100644 --- a/PLSR/Src/plsr_planner.c +++ b/PLSR/Src/plsr_planner.c @@ -448,7 +448,8 @@ static uint8_t PlsrPlannerSameSetting( static uint8_t PlsrPlannerBuildStep(PLSR_PLANNER_CONTEXT *context, uint32_t requestedHz, - PLSR_PLATFORM_TIMER_SETTING *setting) + PLSR_PLATFORM_TIMER_SETTING *setting, + uint32_t *normalizedRequestedHz) { if (requestedHz == 0UL) { @@ -458,6 +459,7 @@ static uint8_t PlsrPlannerBuildStep(PLSR_PLANNER_CONTEXT *context, { requestedHz = PLSR_FREQUENCY_MAX_HZ; } + *normalizedRequestedHz = requestedHz; return PlsrPlatformBuildTimerSetting(context->block.pulseOutput, PLSR_OUTPUT_PULSE_DIR, requestedHz, setting); @@ -465,7 +467,8 @@ static uint8_t PlsrPlannerBuildStep(PLSR_PLANNER_CONTEXT *context, static uint8_t PlsrPlannerTakeRampStep( PLSR_PLANNER_CONTEXT *context, - PLSR_PLATFORM_TIMER_SETTING *setting) + PLSR_PLATFORM_TIMER_SETTING *setting, + uint32_t *requestedFrequencyHz) { uint64_t nextBoundaryQ32; uint64_t desiredDeltaQ32; @@ -526,7 +529,8 @@ static uint8_t PlsrPlannerTakeRampStep( { requestedHz = context->lastRampHz; } - if (PlsrPlannerBuildStep(context, (uint32_t)requestedHz, setting) == 0U) + if (PlsrPlannerBuildStep(context, (uint32_t)requestedHz, setting, + requestedFrequencyHz) == 0U) { return 0U; } @@ -548,6 +552,7 @@ static uint8_t PlsrPlannerTakeRampStep( static uint8_t PlsrPlannerTakeStep(PLSR_PLANNER_CONTEXT *context, PLSR_PLATFORM_TIMER_SETTING *setting, + uint32_t *requestedFrequencyHz, uint32_t *repeatCount) { uint32_t entryEnd = context->entryPulses; @@ -565,20 +570,23 @@ static uint8_t PlsrPlannerTakeStep(PLSR_PLANNER_CONTEXT *context, PlsrPlannerStartRamp(context, 1U, context->startHz, context->peakHz, context->entryPulses); } - return PlsrPlannerTakeRampStep(context, setting); + return PlsrPlannerTakeRampStep(context, setting, + requestedFrequencyHz); } if (context->generatedPulses < steadyEnd) { *repeatCount = steadyEnd - context->generatedPulses; context->rampKind = 0U; - return PlsrPlannerBuildStep(context, context->peakHz, setting); + return PlsrPlannerBuildStep(context, context->peakHz, setting, + requestedFrequencyHz); } if (context->rampKind != 2U) { PlsrPlannerStartRamp(context, 2U, context->peakHz, context->endHz, context->exitPulses); } - return PlsrPlannerTakeRampStep(context, setting); + return PlsrPlannerTakeRampStep(context, setting, + requestedFrequencyHz); } PLSR_PLANNER_STATUS PlsrPlannerBegin(PLSR_PLANNER_CONTEXT *context, @@ -695,6 +703,7 @@ uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context, uint16_t capacity) { PLSR_PLATFORM_TIMER_SETTING setting; + uint32_t requestedFrequencyHz; uint32_t repeatCount; uint16_t produced = 0U; @@ -706,7 +715,8 @@ uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context, while ((produced < capacity) && (context->generatedPulses < context->block.pulseBudget)) { - if (PlsrPlannerTakeStep(context, &setting, &repeatCount) == 0U) + if (PlsrPlannerTakeStep(context, &setting, &requestedFrequencyHz, + &repeatCount) == 0U) { context->active = 0U; break; @@ -714,6 +724,8 @@ uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context, if ((produced != 0U) && (PlsrPlannerSameSetting(&output[produced - 1U].setting, &setting) != 0U) + && (output[produced - 1U].requestedFrequencyHz + == requestedFrequencyHz) && (output[produced - 1U].repeatCount <= 0xFFFFFFFFUL - repeatCount)) { @@ -722,6 +734,7 @@ uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context, else { output[produced].setting = setting; + output[produced].requestedFrequencyHz = requestedFrequencyHz; output[produced].repeatCount = repeatCount; produced++; } diff --git a/PLSR/Src/plsr_planner.h b/PLSR/Src/plsr_planner.h index 590e81e..cc55d4a 100644 --- a/PLSR/Src/plsr_planner.h +++ b/PLSR/Src/plsr_planner.h @@ -35,6 +35,7 @@ typedef struct typedef struct { PLSR_PLATFORM_TIMER_SETTING setting; + uint32_t requestedFrequencyHz; uint32_t repeatCount; } PLSR_STREAM_ITEM; diff --git a/PLSR/Src/plsr_platform.h b/PLSR/Src/plsr_platform.h index b2c40ef..2b11fab 100644 --- a/PLSR/Src/plsr_platform.h +++ b/PLSR/Src/plsr_platform.h @@ -91,6 +91,10 @@ uint8_t PlsrPlatformRetargetFiniteStop(uint8_t pulseOutput, uint32_t drainPulses); uint8_t PlsrPlatformFiniteRetargetReady(uint8_t pulseOutput, uint32_t *activeFrequencyHz); +uint8_t PlsrPlatformFinitePipelineSnapshot(uint8_t pulseOutput, + uint32_t *committedPulses, + uint32_t *tailFrequencyHz, + uint8_t *startsNextSegment); uint8_t PlsrPlatformFiniteProgress(uint8_t pulseOutput, uint32_t *completedPulses); uint8_t PlsrPlatformTakeFiniteCompletion(uint8_t pulseOutput, diff --git a/PLSR/Src/plsr_platform_f407.c b/PLSR/Src/plsr_platform_f407.c index 6447538..c937ac3 100644 --- a/PLSR/Src/plsr_platform_f407.c +++ b/PLSR/Src/plsr_platform_f407.c @@ -527,6 +527,27 @@ uint8_t PlsrPlatformFiniteRetargetReady(uint8_t pulseOutput, return (PlsrHostFiniteStepCount[pulseOutput] == 0U) ? 1U : 0U; } +uint8_t PlsrPlatformFinitePipelineSnapshot(uint8_t pulseOutput, + uint32_t *committedPulses, + uint32_t *tailFrequencyHz, + uint8_t *startsNextSegment) +{ + uint32_t remaining; + + if ((pulseOutput > 3U) || (committedPulses == NULL) + || (tailFrequencyHz == NULL) || (startsNextSegment == NULL) + || (PlsrHostFiniteActive[pulseOutput] == 0U)) + { + return 0U; + } + remaining = PlsrHostFiniteTarget[pulseOutput] + - PlsrHostFiniteEmitted[pulseOutput]; + *committedPulses = remaining; + *tailFrequencyHz = PlsrHostQueuedFrequency[pulseOutput]; + *startsNextSegment = 0U; + return 1U; +} + uint8_t PlsrPlatformFiniteProgress(uint8_t pulseOutput, uint32_t *completedPulses) { @@ -3410,6 +3431,63 @@ uint8_t PlsrPlatformFiniteRetargetReady(uint8_t pulseOutput, return ready; } +uint8_t PlsrPlatformFinitePipelineSnapshot(uint8_t pulseOutput, + uint32_t *committedPulses, + uint32_t *tailFrequencyHz, + uint8_t *startsNextSegment) +{ + TIM_TypeDef *counter; + uint32_t criticalState; + uint32_t blockCount; + uint32_t remaining; + + if ((pulseOutput > 3U) || (committedPulses == NULL) + || (tailFrequencyHz == NULL) || (startsNextSegment == NULL)) + { + return 0U; + } + criticalState = PlsrPlatformEnterCritical(); + if ((PlsrFiniteActive[pulseOutput] == 0U) + || (PlsrFiniteStreamActive[pulseOutput] == 0U) + || (PlsrCounterIndexByOutput[pulseOutput] >= PLSR_COUNTER_COUNT)) + { + PlsrPlatformExitCritical(criticalState); + return 0U; + } + counter = PlsrCounters[PlsrCounterIndexByOutput[pulseOutput]]; + blockCount = (uint16_t)counter->CNT; + if (blockCount >= PlsrFiniteCounterPreload[pulseOutput]) + { + blockCount -= PlsrFiniteCounterPreload[pulseOutput]; + } + else + { + blockCount = 0UL; + } + remaining = PlsrFiniteRemainingPulses[pulseOutput]; + if (blockCount > remaining) + { + blockCount = remaining; + } + remaining -= blockCount; + *tailFrequencyHz = PlsrTimerActiveFrequencyHz[pulseOutput]; + *startsNextSegment = 0U; + if (PlsrFiniteStreamNextValid[pulseOutput] != 0U) + { + uint32_t nextPulses = PlsrFiniteStreamNextPulses[pulseOutput]; + + remaining = (nextPulses > (0xFFFFFFFFUL - remaining)) + ? 0xFFFFFFFFUL : remaining + nextPulses; + *tailFrequencyHz = + PlsrFiniteStreamNextSetting[pulseOutput].actualFrequencyHz; + *startsNextSegment = + PlsrFiniteStreamNextStartsSegment[pulseOutput]; + } + *committedPulses = remaining; + PlsrPlatformExitCritical(criticalState); + return 1U; +} + uint8_t PlsrPlatformFiniteProgress(uint8_t pulseOutput, uint32_t *completedPulses) {