LPC 自治照度闭环:RS485 Modbus 采集 + 自适应模型控制 + HTTP/网页参数可调

This commit is contained in:
tonycao 2026-10-10 20:28:10 +08:00
commit ecf2702ede
30 changed files with 1878 additions and 0 deletions

BIN
0140650.pd2 Normal file

Binary file not shown.

15
Get-Remote.ps1 Normal file
View File

@ -0,0 +1,15 @@
param(
[Parameter(Mandatory = $true)][string]$Remote,
[Parameter(Mandatory = $true)][string]$Local
)
$ErrorActionPreference = 'Stop'
Import-Module Posh-SSH
$pw = ConvertTo-SecureString '0' -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential('tonycao', $pw)
$s = New-SFTPSession -ComputerName 192.168.58.7 -Credential $cred -AcceptKey -Force
try {
Get-SFTPItem -SessionId $s.SessionId -Path $Remote -Destination $Local -Force
Write-Host "OK -> $Local"
} finally {
Remove-SFTPSession -SessionId $s.SessionId | Out-Null
}

17
Run-Remote.ps1 Normal file
View File

@ -0,0 +1,17 @@
param(
[Parameter(Mandatory = $true)][string]$Cmd,
[int]$TimeoutSec = 90
)
$ErrorActionPreference = 'Stop'
Import-Module Posh-SSH
$pw = ConvertTo-SecureString '0' -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential('tonycao', $pw)
$s = New-SSHSession -ComputerName 192.168.58.7 -Credential $cred -AcceptKey -Force
try {
$r = Invoke-SSHCommand -SessionId $s.SessionId -Command $Cmd -TimeOut $TimeoutSec
foreach ($l in $r.Output) { Write-Host $l }
foreach ($l in $r.Error) { Write-Host "STDERR: $l" }
Write-Host "EXIT:$($r.ExitStatus)"
} finally {
Remove-SSHSession -SessionId $s.SessionId | Out-Null
}

113
SETUP.md Normal file
View File

@ -0,0 +1,113 @@
# 搭建步骤说明 —— RS485 → Pharos LPC 灯光控制系统
日期:2026-10-09
## 1. 系统组成
```
发送方(PC / ESP32 / 任意串口主机)
│ RS485 总线 A/B 双绞线(9600 8N1,ASCII 帧 \n 结尾)
▼
Pharos LPC 控制器(0140650,IP 192.168.58.109)
│ DMX universe 1 通道 1~32
▼
32 路灯光(Generic Conventional 8 bit 调光通道)
照度计(仁科 485 型,Modbus 地址 2,4800 8N1)挂在同一 485 总线,
由 Ubuntu 主机(192.168.58.7,/dev/ttyUSB0)轮询后转发 LUX=/LX2= 帧给 LPC。
```
## 2. 硬件接线
1. 发送方串口(USB-485 或 ESP32 UART+485 模块)A→A、B→B 接入 LPC 的 RS485 端口。
2. LPC 串口参数:RS485 模式,9600 8N1(Designer → Network 视图 → Interfaces → Serial Port 设置)。
3. DMX 输出:LPC DMX1 口接灯具/调光硅箱。
4. 照度计接同一总线(注意其为 4800 波特率,与 LPC 9600 混跑,互不干扰)。
## 3. Designer 2 项目搭建(0140650.pd2)
### 3.1 灯具与 Patch
1. Layout 视图:Fixture Library 选 `Generic > Conventional 8 bit`,取消 Auto finish,
在网格上连续点击放置 32 个(8 列 × 4 行),Finish。自动归入组 `All Conventional 8 bit`。
2. Patch 视图:从左侧灯具树**拖拽**灯具到 DMX universe 1 的通道格子(点击/双击格子无效),
完成灯具 1~32 → 通道 1~32。
3. Patch 视图 → DMX universe 1 → eDMX Pass-Through 设为 **None**(本地模式;
直通优先级高于本地输出,二者不可叠加)。
### 3.2 Timeline(FX 效果)
Timeline 视图 → 工具栏 New 建 4 条 Timeline。块放置方法:
**双击右侧 Built-in 面板的效果图标(画布出现蓝框)→ 单击画布空白处提交**;
块只在选中时渲染;紧邻后块会锁定前块 Release 字段(先设好 Release 再放下一块);
属性在右侧面板直接编辑(Start / Length / Fade / Release)。
| Timeline | 效果 | 配置 |
|----------|------|------|
| 1 | 常亮 | White 块 0-30s,Level 255 |
| 2 | 呼吸 | 4 个 White 块:0-8 / 8-16 / 16-24 / 24-30s,Fade 4 / Release 4(末块 3/3) |
| 3 | 闪烁 | White 块 0-30s,Shape=Square,Base 0 / Size 255 / Loop Period 1.00s |
| 4 | 缓变 | White 块 0-30s,Fade 15 / Release 15 |
**循环播放(2026-10-10 补)**:Timeline 默认播完 Length 即 released(灯灭)。
工具栏 **Loop 按钮**(↻ 单环图标,悬停提示 "Loop",位置约 x=358;标尺末端出现循环标记为开启)
已给 4 条 Timeline 逐一开启,但实测单独开启对控制器运行**不生效**;
实际由触发器 **#13 FXLoop**(Type=Timeline Ended, Timeline=Any → Run Script 重启该时间线)实现循环。
注意区分另两个相似按钮:**Hold At End**(→|,x=326,末端保持不释放)和
**Enable Looping**(↻ 带两点,x=768,仅编辑器仿真循环,不上传控制器)。
### 3.3 触发器(Trigger 视图)
工具栏 Trigger → New... → Serial Input,每个触发器加 Action → Run Script → Quick Script 粘贴对应 Lua。
| # | String | 脚本文件 | 功能 |
|---|--------|----------|------|
| 2 | `BRT=<3d>` | lpc_serial_brt.lua | 手动亮度 0~100% → master_intensity |
| 8 | `LUX=<6d>` | lpc_serial_lux.lua | 照度闭环自动调光 |
| 9 | `LXT=<6d>` | lpc_serial_lxt.lua | 设定目标照度(变量 lux_target) |
| 10 | `LX2=<6d>` | lpc_serial_lx2.lua | 照度→亮度对数映射(指示模式) |
| 11 | `FX=<2d>` | lpc_serial_fx.lua | 效果选择:清 Override + `get_timeline(N):start_release_others()` |
| 12 | `SC=<2d>` | lpc_serial_sc.lua | 场景选择:释放全部 Timeline + `get_group_override(0):set_intensity(level, 1.0)` |
| 13 | Timeline Ended(Any) | lpc_fxloop.lua | 时间线到末尾自动重启实现循环;SC 释放属 Timeline Released 不会误触发 |
SC 亮度预设:01=255 全亮 / 02=128 半亮 / 03=26 微亮 / 04=0 全灭。
### 3.4 保存与上传
1. 保存:Ctrl+S 或 Project 视图 → Save Project(标题栏的 "*" 是应用图标不是未保存标记;
以文件 LastWriteTime / Recent Projects 列表的 Last modified 更新为准)。
2. Network 视图 → Upload → 选中设备行 → Upload All → 等 "Upload complete" → Close。
3. **注意:Upload 会清空控制器上的 Override**;SC 场景需重新下发。
4. 归档:把 `C:\Users\tonycao\Documents\0140650.pd2` 复制到本项目目录。
## 4. Ubuntu 侧部署(192.168.58.7)
```bash
# 文件
~/rs485-lpc_test_rs485.py # 串口收发工具
~/rs485-lpc/lux_sync.py # 照度轮询→BRT 帧推送(反逻辑:越暗越亮)
~/rs485-lpc/scan_modbus.py # Modbus 地址/寄存器扫描
~/rs485-lpc/loop_test.sh # BRT 回归
~/rs485-lpc/lux_test.sh # 照度闭环回归
~/rs485-lpc/api_smoke.sh # LPC API 冒烟
# 启动照度同步(后台常驻)
setsid bash -c 'nohup python3 ~/rs485-lpc/lux_sync.py > ~/rs485-lpc/lux_sync.log 2>&1 < /dev/null &'
```
上传文件用 `Put-Remote.ps1`(-Remote 参数必须是目录 `/home/tonycao/rs485-lpc`),
远程执行用 `Run-Remote.ps1 -Cmd "..."`(Posh-SSH,密码 "0";Windows 自带 ssh.exe 会挂死,勿用)。
## 5. 验证
```bash
# 发帧(Ubuntu)
python3 ~/rs485-lpc_test_rs485.py -s 'FX=02'
# 查 LPC 状态(Windows)
curl.exe http://192.168.58.109/api/timeline # running/released/none
curl.exe "http://192.168.58.109/api/output?universe=dmx:1" # 32 通道实时值
curl.exe http://192.168.58.109/api/group # master_intensity 回读
```
回归脚本:`loop_test.sh`(5/5)、`lux_test.sh`(7/7)、`lx2_test.sh`(8/8)。

140
SUMMARY.md Normal file
View File

@ -0,0 +1,140 @@
# RS485 → Pharos LPC 灯光控制项目总结
日期:2026-10-09(2026-10-10 更新:**LPC 自治架构**——照度采集+闭环移入 LPC Lua,Ubuntu 只做监控/记录)
状态:✅ 全部验证通过(LPC 自治闭环实测收敛 484 Lux≈目标 500;网页控制台 v3 回归全过:FX/SC/BRT 经 HTTP 触发器下发、联动开关切换 lux_link_on、历史曲线含 LPC lux)
## LPC 自治架构(2026-10-10)
- 传感器、LPC 串口、Ubuntu USB-485 同一条 RS485 总线;LPC 串口改 **RS485/4800/8N1** 后由 LPC 独占主机轮询,Ubuntu 串口命令链路废弃
- 新增触发器:**#14 LuxPoll**(Real Time 高级掩码每秒)→ Output Serial hex `02030002000265F8`(Modbus 读地址 2 输入寄存器 0x0002,32 位 lux);**#15 LuxIn**(Serial Input,Hex 匹配 `020304<4c><2c>`,`<4c>` 捕获 32 位 lux 到变量 1,`<2c>` 吞 CRC)→ `lpc_modbus_lux.lua`(`lux_value=lux` 全局暴露 + `lux_link_on` 开关 + `lux_poll_s` 调节间隔闸门 + `lux_fade` 渐变 + **自适应模型闭环**:在线辨识 k=Δlux/Δlevel(EMA、限 100~10000、限幅时不采样),步长 `err/k` 一步到位、上限 20%,误差变号减半防震荡,基准亮度 `lux_cur_level` 累加避免硬件量化吞小步长、偏差>5% 重同步;替代旧 P 调节 GAIN 0.3/最小步长 2% 的死区极限环);**#16/#17 LuxLinkOn/Off**(ASCII `LLON`/`LLOFF`);**#18 LuxFadeSet**(`FAD=<4d>`,0.1s 单位→`lux_fade`)/ **#19 LuxPeriodSet**(`PER=<4d>` 1~60→`lux_poll_s`)/ **#20 LuxDeadbandSet**(`DBD=<4d>` 1~1000→`lux_deadband`,默认 1 Lux),均可 HTTP `?num=N&var=V` 触发
- **注意**:LPC Lua 直接调 `os.time()` 会中断脚本(无报错日志),须 `pcall(function() return os.time() end)` 兜底(失败退化为计数分频)
- lux 对外暴露用 **Lua 全局变量 + `GET /api/lua?variables=lux_value`**(`set_text_slot` 在 Lua 不可用;text slot 仅 HTTP PUT/GET)
- HTTP 触发器带参:`POST /api/trigger?num=N&var=V`(实测 #9 var=600→lux_target=600)
- 网页控制台 v3:`/send` 改 `POST /api/trigger`(BRT→#2、LUX→#8、LXT→#9、LX2→#10、FX→#11、SC→#12),`/luxlink` 改 #16/#17 + 读 `lux_link_on`/`lux_value`,采样器不再碰串口;`lux_sync.py`/`poll_lux.py` 停用(避免双主机抢总线)
- 实测:上传后 T1 重启,lux 0→(升光)→饱和回落→收敛 484 Lux(死区内),level 稳定 100%;LLON/LLOFF HTTP 切换正常
## 系统架构
```
照度传感器(地址2) ─┐
发送方(PC/ESP32)─┼─ RS485 总线(4800 8N1,2026-10-10 起)──> Pharos LPC 0140650 (192.168.58.109)
Ubuntu USB-485 ────┘ (LPC 为主机每秒轮询传感器;ASCII 命令帧仍可用)
│ Serial Input 触发器 + Quick Script (Lua)
├─> get_group(0):set_master_intensity() → "All Fixtures" 组
└─> 32 × Generic Conventional 8 bit 灯具 → DMX universe 1 通道 1~32
```
## Patch(2026-10-09 新增)
- Layout 1:32 个 `Generic > Conventional 8 bit` 灯具(8 列 × 4 行),自动归入组 `All Conventional 8 bit`
- Patch:灯具 1~32 → DMX universe 1 通道 1~32(`dmx:1:N`),Controller 2 占用 32/512
- 调光粒度:Group 0 `All Fixtures` 的 master_intensity 作为总调光作用于全部 32 通道(现有 BRT/LUX/LX2 脚本无需修改)
- **eDMX Pass-Through 已改为 None**(本地模式):实测直通优先级高于本地输出(外部源发黑场时本地 255 也出不来,通道显示 `=0`),二者无法叠加,只能二选一;如需恢复直通:Patch 视图 → DMX universe 1 → eDMX Pass-Through 下拉选 `1: ArtNet In`
- **灯具强度来源**:组 master_intensity 只是缩放系数,灯具自身 intensity 须由 Timeline/Override 提供;当前用 `PUT /api/override {"target":"group","num":0,"intensity":255}` 拉满。注意 **Upload 会清空 Override**,上传后需重新下发
- 输出回读:`GET /api/output?universe=dmx:1`(`"0"`=已patch,`"=0"`=外部直通,`"!255"`=override)
- `/api/fixtures` 现返回 32 个灯具;`/api/group` 返回 `All Fixtures`(num 0) + `All Conventional 8 bit`
- GUI 操作要点:Patch 视图从左侧灯具树**拖拽**灯具到通道网格格子即完成 Patch(点击/双击格子无效)
## 通信协议(ASCII 帧,`\n` 结尾)
| 帧格式 | 含义 | LPC 触发器 |
|--------|------|-----------|
| `BRT=NNN` | 手动亮度 0~100%(3 位补零) | #2 |
| `LUX=NNNNNN` | 实测照度 0~200000 Lux(6 位补零)→ 闭环自动调光 | #8 Lux Auto Dim |
| `LXT=NNNNNN` | 设定目标照度(默认 500 Lux,全局变量 `lux_target`) | #9 Lux Target Set |
| `LX2=NNNNNN` | 照度→亮度对数映射(指示模式,灯=照度计):`level=log(lux+1)/log(200001)`,0.5s 渐变 | #10 Lux Display Map |
| `FX=NN` | 灯光效果 01~04 → 启动 Timeline N(2 位数字) | #11 FXSelect |
| `SC=NN` | 场景 01~04 → 释放全部 Timeline + 组 Override 静态亮度(2 位数字) | #12 SC Select |
## FX/SC 效果与场景(2026-10-09 新增)
- **Timeline(FX)**:T1 常亮(White 0-30s 直出 255);T2 呼吸(4 个 White 块 0-8/8-16/16-24/24-30s,Fade 4/Release 4,末块 3/3);T3 闪烁(White 0-30s,Shape=Square,Base 0 / Size 255 / Period 1s);T4 缓变(White 0-30s,Fade 15/Release 15)
- **场景(SC)**:SC=01 全亮 255 / 02 半亮 128 / 03 微亮 26 / 04 全灭 0,由 `get_group_override(0):set_intensity(level, 1.0)` 实现(0-255 整数)
- **互斥逻辑**:FX 脚本先 `get_group_override(0):clear(1.0)` 再 `get_timeline(n):start_release_others()`;SC 脚本先 `release()` 全部 4 条 Timeline 再设 Override,避免互相遮蔽
- **循环播放(2026-10-10 修复)**:Timeline 播完 Length 默认 released(灯灭)。工具栏 Loop 按钮(x=358,标尺末端出循环标记)已开启但实测对控制器不生效;实际靠触发器 **#13 FXLoop**(Timeline Ended, Any → `get_timeline(tv.integer):start()`)重启实现无限循环。SC 的 release() 属 Timeline Released,不会触发 FXLoop 误重启。实测 FX=01 两次跨过 30s 边界 pos 回绕、state 恒 running、onstage 恒 true
- **照度叠加不变**:BRT/LUX/LX2 仍写 Group 0 master_intensity,作为总调光系数乘在 Timeline/Override 基础亮度之上
- Timeline GUI 操作要点:**双击右侧 Built-in 面板效果进入放置模式(画布蓝框),再单击画布空白处提交**(拖拽不可靠;落在已有块上会被拒绝);块只在选中时渲染;后块紧邻时前块 Release 字段锁定置灰(需在放置后块之前设好 Release);Ctrl+C/V 复制块无效
LX2 映射对照:1 Lux→6% / 10→19% / 100→38% / 500→51% / 1k→57% / 10k→75% / 100k→94% / 200k→100%(与 LUX 闭环相互独立,后到的帧生效)
闭环调光参数:死区 ±50 Lux,步进 = |误差|/目标 × 0.3(限幅 2%~20%),亮度限幅 5%~100%,1 秒渐变。
## 成果文件清单
### Windows 侧 `C:\Users\tonycao\work\lpc-rs485\`
| 文件 | 说明 |
|------|------|
| `rs485_verification_plan.md` | 设计/验证文档(含实测结论、通配符语法、API 要点) |
| `SETUP.md` | 搭建步骤说明(硬件接线 / Designer 项目 / Timeline / 触发器 / Ubuntu 部署) |
| `USAGE.md` | 使用说明(协议命令表 / 发送方式 / API 查询 / 常见问题) |
| `TEST_REPORT.md` | 功能测试报告(50 项全部通过,含 FX/SC 现场回归取证) |
| `SUMMARY.md` | 本文档 |
| `lpc_serial_brt.lua` | LPC 触发器 #2 脚本:BRT 手动亮度 |
| `lpc_serial_lux.lua` | LPC 触发器 #8 脚本:LUX 闭环自动调光 |
| `lpc_serial_lxt.lua` | LPC 触发器 #9 脚本:LXT 设目标照度 |
| `lpc_serial_lx2.lua` | LPC 触发器 #10 脚本:LX2 照度→亮度对数映射 |
| `lpc_serial_fx.lua` | LPC 触发器 #11 脚本:FX 效果选择(清 Override + 启 Timeline N) |
| `lpc_serial_sc.lua` | LPC 触发器 #12 脚本:SC 场景选择(释放 Timeline + Override 亮度预设) |
| `lpc_fxloop.lua` | LPC 触发器 #13 脚本:Timeline Ended → 重启该时间线(FX 循环播放) |
| `lpc_modbus_lux.lua` | LPC 触发器 #15 脚本:Modbus 照度闭环(复用 #8 逻辑 + `lux_value` 全局暴露 + `lux_link_on` 开关) |
| `lpc_web.py` | 网页控制台 v3(部署于 Ubuntu `~/rs485-lpc/`,http://192.168.58.7:8090):实时状态 + FX/SC/BRT(HTTP 触发器)+ 联动开关(#16/#17)+ 48h 历史曲线(30s 采样 LPC API,存 `history.jsonl`) |
| `lx2_test.sh` | LX2 映射回归(8 档照度→亮度对照,PASS=8) |
| `loop_test.sh` | BRT 闭环回归(5 项,PASS=5) |
| `lux_test.sh` | 照度闭环回归(7 项:死区/降光/升光/上下限幅,PASS=7) |
| `api_smoke.sh` | LPC HTTP API 冒烟测试 |
| `test_rs485.py` | Ubuntu 串口收发工具(`-l/-p/-b/-s/-w/-i`) |
| `Test-Rs485.ps1` | Windows 串口自收发测试 |
| `Verify-Rs485.ps1` | 离线 S1~S4 验证脚本(16/16 PASS,需 UTF-8 BOM) |
| `ui.ps1` | Designer 2 GUI 自动化(点击/双击/右键/拖拽/SendKeys/截图/窗口最大化) |
| `Run-Remote.ps1` | Posh-SSH 远程执行(Ubuntu 192.168.58.7) |
| `Get-Remote.ps1` | Posh-SSH SFTP 下载 |
### Designer 2 项目
| 文件 | 说明 |
|------|------|
| `0140650.pd2`(本目录) | 项目文件(触发器 #1/#2/#8-#20 含 LuxPoll/LuxIn/LuxLinkOn/Off/LuxFadeSet/LuxPeriodSet/LuxDeadbandSet + 4 条 Loop Timeline,串口 RS485 4800 8N1),已上传 LPC(2026-10-10 19:56 归档) |
### 参考文档 `docs\`
| 文件 | 说明 |
|------|------|
| `renke-illuminance-485-200k-manual.pdf` | 仁科光照度变送器(485 型 20w)使用说明书 |
| `modbus-illuminance-packet-formats.md` | 仁科/耘农 Modbus 帧格式实测笔记 |
### Ubuntu 侧(192.168.58.7,USB-485 = `/dev/ttyUSB0`)
| 路径 | 说明 |
|------|------|
| `~/rs485-lpc/loop_test.sh` | BRT 回归(与 Windows 侧同步) |
| `~/rs485-lpc/lux_test.sh` | 照度闭环回归(已部署,7/7 PASS) |
| `~/rs485-lpc/api_smoke.sh` | LPC HTTP API 冒烟测试 |
| `~/rs485-lpc_test_rs485.py` | 串口发送工具 |
## 验证记录
- 2026-10-09 离线 S1~S4:16/16 PASS
- 2026-10-09 BRT 闭环:5/5 PASS(100/0/50/80/25)
- 2026-10-09 LUX 闭环回归:死区 / 超目标降光 / 欠目标升光 / 上下限限幅 全部通过(lux_test.sh 7/7 PASS)
- 2026-10-09 LX2 照度指示映射:8 档对数映射全过(lx2_test.sh 8/8 PASS)
- 2026-10-09 Patch 32 通道(DMX 1~32)并 Upload;回归抽测 BRT=050→level 50、BRT=000→level 0 通过
- 2026-10-09 FX/SC:FX=01 常亮 255(T1 running)/ FX=02 呼吸(255→202 渐变)/ FX=03 闪烁(255/0 交替)/ FX=04 缓变(39→75→110 缓升);SC=02→128 / SC=04→0(Override 生效、Timeline 释放);FX 与 SC 互斥切换正常
- 2026-10-10 FX 循环修复:发现 FX 播完 30s 即 released 灯灭;Loop 工具栏按钮单独开启无效,加触发器 #13 FXLoop(Timeline Ended 重启)后实测 FX=01 连续 2 个循环 pos 回绕(29500→1550→…→27980→20)输出不掉 0;FX=02 跨 30s 仍 running;SC=04 释放后 35s 保持 released 无误重启
- 2026-10-10 照度功能验证:传感器在线(Modbus 地址 2 @4800,实测 26 Lux);手动 LUX 闭环升/降/死区正常(LUX=10→level 50→70,LUX=10000→50,LUX=500 死区不动);LX2=10→level≈20;lux_sync.py 端到端 26 Lux → BRT=73 → group level=73 → 输出随动
- 2026-10-10 LPC 自治改造:触发器 #14(每秒 Modbus 轮询)+#15(hex 通配符捕获+Lua 闭环)+#16/#17(联动开关)建成并上传;LPC 日志确认 #14 Matched 207 次 / #15 Captured 2 variables 244 次;闭环实测 lux 0→饱和→收敛 484(目标 500±50 死区),`/api/lua?variables=lux_value` 实时可读;HTTP 带参触发(`?num=9&var=600`)验证通过;网页控制台 v3 回归:FX=02→T2 running、BRT=060→level 60、luxlink off/on→lux_link_on false/true、history 采样含 LPC lux=479
## 关键技术结论(官方文档未载)
1. **串行通配符语法**:`<3d>` = 恰好 3 位数字(数字在前,类型 `[cCdDxXsS]`);`?` `*` `#` `%` 均为普通字符
2. **Designer 2 触发变量**:`get_trigger_variable(1)`(v1 的 `variable[1]` 已废弃)
3. **组亮度 API**:`get_group(n):set_master_intensity(level[, fade])`,读当前值用 `grp.master_intensity_level.integer / .range`
4. **LPC HTTP API 免认证**:`GET /api/group`(`level` 字段回读)、`/api/trigger`、`/api/log`
5. **LPC 自治照度闭环(2026-10-10)**:Serial Input hex 匹配串支持通配符 `<4c>`(捕获 32 位)/`<2c>`(吞 16 位 CRC);Real Time 触发器高级掩码秒级分辨率可每秒触发 Output Serial 静态 hex 帧(Modbus 轮询);lux 经 Lua 全局变量 + `GET /api/lua?variables=` 暴露(`set_text_slot` 非 Lua 函数);HTTP 触发器带参 `POST /api/trigger?num=N&var=V`。**GUI 自动化**:Serial Input/Output 的 String 框逐键输 `<` 不进(中文布局/过滤),须 Set-Clipboard + ^v 粘贴;Upload 对话框须先点选设备行再点 Upload All
6. **Timeline 循环(2026-10-10)**:工具栏三个易混按钮——Hold At End(→|)、Loop(↻ 单环,运行时循环)、Enable Looping(↻ 两点,仅编辑器仿真,不上传)。实测 Loop 按钮开启(标尺末端有循环标记、保存、上传)后控制器仍在 Length 处 released;可靠方案是 **Timeline Ended 触发器 + `tl:start()`**(Timeline Ended/Started/Released 在 Timeline=Any 时把时间线号捕获到变量 1;Ended 不在 Hold 时触发,Released 是独立类型,SC 释放不会误触发)。本地帮助:`C:\Program Files\Pharos Controls\Designer 2\help\content\help\reference\timeline\working with timelines.htm`
## 仁科照度计参考(esp32-voice 项目)
- 大量程 0~200000 Lux 型:32 位寄存器对 0x0002(hi)/0x0003(lo),单位 1 Lux
- ESP32 侧实现:`src/config.rs`(MODBUS_ILLUMINANCE_REG=0x0002, COUNT=2)、`src/modbus/master.rs` `poll_illuminance()`
- 文档:esp32-voice `docs/` 原件;本项目 `docs/` 已归档副本(20w 说明书 PDF + 帧格式笔记)

105
TEST_REPORT.md Normal file
View File

@ -0,0 +1,105 @@
# 功能测试报告 —— RS485 → Pharos LPC 灯光控制系统
日期:2026-10-09
被测系统:Pharos LPC(0140650,192.168.58.109,固件 2.16.3)+ 32 通道 DMX 灯具
测试方法:经 RS485 总线(9600 8N1)发送 ASCII 命令帧,通过 LPC HTTP API 回读
`/api/timeline`、`/api/output?universe=dmx:1`、`/api/group` 验证实际输出。
结论:**全部测试通过(55/55,含 2026-10-10 循环修复与照度端到端补测)**。
## 1. 串口通信链路
| # | 测试项 | 方法 | 预期 | 结果 |
|---|--------|------|------|------|
| 1.1 | 串口自收发 | Ubuntu `test_rs485.py` 自发收 | TX=RX | ✅ |
| 1.2 | Windows→Ubuntu 透传 | Test-Rs485.ps1 | 帧完整到达 | ✅ |
| 1.3 | 离线协议验证 S1~S4 | Verify-Rs485.ps1 | 16/16 PASS | ✅ |
| 1.4 | LPC 串口触发解析 | 发 `BRT=050` 查 `/api/group` | level=50 | ✅ |
## 2. 手动调光 BRT=NNN(触发器 #2)
| # | 命令 | 预期 level | 实测 | 结果 |
|---|------|-----------|------|------|
| 2.1 | `BRT=100` | 100 | 100 | ✅ |
| 2.2 | `BRT=000` | 0 | 0 | ✅ |
| 2.3 | `BRT=050` | 50 | 50 | ✅ |
| 2.4 | `BRT=080` | 80 | 80 | ✅ |
| 2.5 | `BRT=025` | 25 | 25 | ✅ |
(loop_test.sh 闭环回归 5/5 PASS)
## 3. 灯光效果 FX=NN(触发器 #11 → Timeline 1~4)
| # | 命令 | 预期 | 实测(/api/timeline + /api/output) | 结果 |
|---|------|------|-----------------------------------|------|
| 3.1 | `FX=01` 常亮 | T1 running,输出 255 稳定 | T1 running,ch=255 | ✅ |
| 3.2 | `FX=02` 呼吸 | T2 running,亮度周期性渐变 | T2 running;采样 255→202→…渐变 | ✅ |
| 3.3 | `FX=03` 闪烁 | T3 running,0/255 交替(1s 周期) | T3 running;连续采样 255,0,0,255,0,255 | ✅ |
| 3.4 | `FX=04` 缓变 | T4 running,30s 缓升缓降 | T4 running;采样 39→75→110 缓升 | ✅ |
| 3.5 | FX 互斥 | 启动 T(N) 时其余释放 | 每次仅目标 Timeline 为 running,其余 released | ✅ |
| 3.6 | FX 清除场景覆盖 | FX 后 SC 的 Override 被清除 | FX=01 后 SC=02 的 128 覆盖消失,恢复 255 | ✅ |
| 3.7 | FX 循环播放(2026-10-10) | 播完 30s 自动回绕不释放 | FX=01 后连续观测 2 个循环:pos 29500→1550→…→27980→20,state 恒 running、onstage 恒 true,输出不掉 0 | ✅ |
| 3.8 | FX=02 跨边界(2026-10-10) | 呼吸效果跨过 30s 仍运行 | t+34s:T2 running pos=4560,输出非零 | ✅ |
## 4. 场景 SC=NN(触发器 #12 → 组 Override)
| # | 命令 | 预期输出 | 实测(ch1~ch4) | 结果 |
|---|------|---------|----------------|------|
| 4.1 | `SC=01` 全亮 | 255 | 255,255,255,255 | ✅ |
| 4.2 | `SC=02` 半亮 | 128 | 128,128,128,128 | ✅ |
| 4.3 | `SC=03` 微亮 | 26(10%) | 26,26,26,26 | ✅ |
| 4.4 | `SC=04` 全灭 | 0 | 0,0,0,0 | ✅ |
| 4.5 | SC 停 Timeline | SC 后 4 条 Timeline 全部 released | timelines=[1:released 2:released 3:released 4:released] | ✅ |
| 4.6 | SC 释放不被 FXLoop 误重启(2026-10-10) | SC=04 后保持 released(Released ≠ Ended) | SC=04 后 35s:全部 released/none,ch=0 | ✅ |
## 5. 亮度叠加(Timeline/场景 × 总调光)
| # | 测试项 | 命令序列 | 预期 | 实测 | 结果 |
|---|--------|---------|------|------|------|
| 5.1 | 场景×BRT | `SC=01` + `BRT=050` | 255×50%≈128 | 128 | ✅ |
| 5.2 | 32 通道一致性 | 同上 | ch1~ch32 全相等 | distinct=128(唯一值) | ✅ |
| 5.3 | 恢复 | `BRT=100` + `FX=01` | 255,level=100 | 255,level=100 | ✅ |
## 6. 照度闭环(LUX/LXT/LX2,触发器 #8/#9/#10)
| # | 测试项 | 预期 | 结果 |
|---|--------|------|------|
| 6.1 | 死区(±50 Lux 不动) | 误差<死区时亮度不变 | ✅ |
| 6.2 | 超目标降光 | 实测>目标+死区 → 降亮度 | ✅ |
| 6.3 | 欠目标升光 | 实测<目标-死区 → 升亮度 | ✅ |
| 6.4 | 下限限幅 5% | 不低于 5% | ✅ |
| 6.5 | 上限限幅 100% | 不超过 100% | ✅ |
| 6.6 | LXT 目标设定 | lux_target 全局变量更新 | ✅ |
| 6.7 | LX2 对数映射 8 档 | 1Lux→6% … 200k→100% | ✅ 8/8 |
| 6.8 | 照度计在线读取(2026-10-10) | Modbus 地址 2 @4800 读 reg 0x0002×2 | 实测 26 Lux(室内遮光) | ✅ |
| 6.9 | lux_sync.py 端到端(2026-10-10) | 轮询照度→BRT 帧→LPC 总调光随动 | 26 Lux → BRT=73 → /api/group level=73,输出 148 随动 | ✅ |
(lux_test.sh 7/7 PASS;lx2_test.sh 8/8 PASS;照度计实测定位:Modbus 地址 2,4800 8N1,寄存器 0x0002×2)
## 7. 系统健壮性
| # | 测试项 | 预期 | 实测 | 结果 |
|---|--------|------|------|------|
| 7.1 | Upload 清 Override | 上传后场景覆盖失效(已知行为) | 复现,文档已注明需重发命令 | ✅ |
| 7.2 | Pass-Through=None 本地模式 | 本地输出不受外部源压制 | 全通道 255 输出 | ✅ |
| 7.3 | 连续快速切换 | FX→SC→FX 交替 | 状态机正确互斥,无残留 | ✅ |
| 7.4 | 非法编号 | FX/SC 超出 01~04 | 脚本忽略(levels 表 nil 守卫、n 范围判断) | ✅(代码审查) |
## 8. 测试结论
| 功能域 | 用例数 | 通过 |
|--------|-------|------|
| 串口通信链路 | 4 | 4 |
| 手动调光 BRT | 5 | 5 |
| 效果 FX | 8 | 8 |
| 场景 SC | 6 | 6 |
| 亮度叠加 | 3 | 3 |
| 照度闭环 | 9 | 9 |
| 健壮性 | 4 | 4 |
| 离线协议 | 16 | 16 |
| **合计** | **55 项(含离线 16 项)** | **全部通过** |
> 2026-10-10 修订:发现"FX 播完 30s 即 released 灯灭"缺陷(3.7/3.8 原结论"持续运行"不成立),
> 已通过新增触发器 #13 FXLoop(Timeline Ended → 重启时间线)修复并复测通过;
> 照度功能补测 6.8/6.9 端到端链路。
系统功能完备,可交付使用。使用方法见 `USAGE.md`,搭建步骤见 `SETUP.md`。

70
Test-Rs485.ps1 Normal file
View File

@ -0,0 +1,70 @@
param(
[switch]$ListPorts,
[string]$PortName = 'COM3',
[int]$Baud = 9600,
[string]$Parity = 'None',
[string]$Send,
[int]$Listen = 0,
[switch]$Interactive
)
if ($ListPorts) {
[System.IO.Ports.SerialPort]::GetPortNames() | Sort-Object
return
}
$parityValue = [Enum]::Parse([System.IO.Ports.Parity], $Parity)
$sp = New-Object System.IO.Ports.SerialPort($PortName, $Baud, $parityValue, 8, [System.IO.Ports.StopBits]::One)
$sp.ReadTimeout = 300
$sp.NewLine = "`n"
$sp.Encoding = [System.Text.Encoding]::ASCII
try {
$sp.Open()
Write-Host "opened $PortName @ $Baud 8N1, Ctrl+C to exit"
if ($Send) {
$payload = $Send -replace '\\n', "`n" -replace '\\r', "`r"
$sp.Write($payload)
Write-Host ("TX: " + ($payload -replace "`n", '\n' -replace "`r", '\r'))
}
if ($Interactive) {
while ($true) {
$line = Read-Host 'TX'
if ([string]::IsNullOrEmpty($line)) { continue }
$sp.Write($line + "`n")
Start-Sleep -Milliseconds 200
try {
while ($true) {
$rx = $sp.ReadExisting()
if ($rx) { Write-Host ("RX: " + ($rx -replace "`n", '\n' -replace "`r", '\r')) }
break
}
} catch {}
}
}
if ($Listen -gt 0) {
$deadline = (Get-Date).AddSeconds($Listen)
$buffer = ''
while ((Get-Date) -lt $deadline) {
try {
$chunk = $sp.ReadExisting()
if (-not $chunk) {
Start-Sleep -Milliseconds 50
continue
}
$buffer += $chunk
$parts = $buffer -split "`n"
$buffer = $parts[-1]
$lines = $parts[0..($parts.Count - 2)]
foreach ($line in $lines) {
Write-Host ("RX: " + ($line -replace "`r", '') -replace '`n', '')
}
} catch {}
}
}
} finally {
if ($sp.IsOpen) { $sp.Close() }
}

97
USAGE.md Normal file
View File

@ -0,0 +1,97 @@
# 使用说明 —— RS485 → Pharos LPC 灯光控制系统
日期:2026-10-09(2026-10-10 更新:LPC 自治架构——照度采集与闭环全部移到 LPC Lua,Ubuntu 只做监控/记录)
## 1. 控制协议
通过 RS485 总线发送 ASCII 文本帧(**4800 8N1**(2026-10-10 起由 9600 改为 4800,与照度传感器共总线),`\n` 结尾)即可控制灯光:
| 命令 | 格式 | 功能 | 示例 |
|------|------|------|------|
| 手动亮度 | `BRT=NNN` | 总亮度 0~100%(3 位补零) | `BRT=080` |
| 灯光效果 | `FX=NN` | 01 常亮 / 02 呼吸 / 03 闪烁 / 04 缓变 | `FX=02` |
| 场景 | `SC=NN` | 01 全亮 / 02 半亮 / 03 微亮 / 04 全灭 | `SC=01` |
| 照度闭环 | `LUX=NNNNNN` | 上报实测照度(Lux),自动调光到目标 | `LUX=000350` |
| 目标照度 | `LXT=NNNNNN` | 设定闭环目标(默认 500 Lux) | `LXT=000500` |
| 照度指示 | `LX2=NNNNNN` | 灯亮度直接映射照度(对数映射) | `LX2=001000` |
**FX 与 SC 互斥**:发 FX 会清除 SC 的亮度覆盖并启动对应 Timeline;
发 SC 会停掉所有 Timeline 并设定静态亮度。后到的命令生效。
FX 效果会一直循环播放,直到收到下一条 FX/SC 命令(由 FXLoop 触发器在播完时自动重启)。
**亮度叠加关系**:`最终亮度 = Timeline/场景基础亮度 × BRT/LUX/LX2 设定的总调光系数`。
例如 FX=01(常亮 255)+ BRT=050 → 实际输出约 50%。
## 2. 发送方式
### ESP32 / 单片机
串口 9600 8N1,直接 `printf("FX=02\n")` 即可。
### Ubuntu 主机(192.168.58.7)
```bash
python3 ~/rs485-lpc_test_rs485.py -s 'FX=02'
```
### Windows 本机
```powershell
powershell -File Run-Remote.ps1 -Cmd "python3 ~/rs485-lpc_test_rs485.py -s 'SC=01'"
```
## 2.5 网页控制台(2026-10-10 新增)
浏览器访问 **http://192.168.58.7:8090/**(手机/电脑均可,与 LPC 同网段):
- 实时显示:4 条 Timeline 状态/进度、DMX 通道 1~32 输出柱状图、总亮度(1.5s 刷新)
- 控制:FX 效果(常亮/呼吸/闪烁/缓变)、SC 场景(全亮/半亮/微亮/全灭)、总亮度滑杆(均经 LPC HTTP 触发器下发,不走串口)
- **照度联动开关**:切换 LPC 全局变量 `lux_link_on`(HTTP 触发器 #16 开 / #17 关),并实时显示 LPC 采集的照度 Lux 值
- **48 小时历史曲线**(60s 刷新):输出%(实线)+ BRT%(虚线,左轴)与照度 lux(右轴,对数刻度);
数据由服务每 30s 从 LPC HTTP API 采样一次,持久化在 `~/rs485-lpc/history.jsonl`,重启不丢
服务为 Ubuntu 侧 `~/rs485-lpc/lpc_web.py`(端口 8090;8080 被 esp32-voice 的 light-web 占用,勿动)。
重启须分两条命令执行(pkill 与启动不能同一条,否则 pkill 会误杀自身会话导致服务起不来):
```powershell
Run-Remote.ps1 -Cmd "pkill -f 'lpc_web.py'"
Run-Remote.ps1 -Cmd "setsid bash -c 'nohup python3 ~/rs485-lpc/lpc_web.py > ~/rs485-lpc/lpc_web.log 2>&1 < /dev/null &'"
```
## 3. 状态查询(免认证 HTTP API)
```bash
curl.exe http://192.168.58.109/api/timeline # 各 Timeline 运行状态
curl.exe "http://192.168.58.109/api/output?universe=dmx:1" # 32 通道实时输出
curl.exe http://192.168.58.109/api/group # 总调光系数
curl.exe "http://192.168.58.109/api/lua?variables=lux_value,lux_target,lux_link_on,lux_cur_level" # 闭环全局变量
```
远程触发(网页控制台即用此方式):
```bash
curl.exe -X POST "http://192.168.58.109/api/trigger?num=11&var=2" # 触发器 #11 FXSelect,var=2 → 呼吸
curl.exe -X POST "http://192.168.58.109/api/trigger?num=16" # 开照度联动(#17 关)
curl.exe -X POST "http://192.168.58.109/api/timeline" -H "Content-Type: application/json" -d @tl.json # tl.json: {"action":"start","num":1}
```
输出值前缀含义:`"255"`=正常输出,`"!255"`=Override,`"=0"`=外部直通源。
## 4. 照度自动调光(LPC 自治,2026-10-10 起)
照度采集与闭环全部在 LPC 内运行,**不依赖 Ubuntu/网络**:
- 触发器 **#14 LuxPoll**(Real Time,每秒)→ Output Serial 发 Modbus 轮询帧 `02 03 00 02 00 02 65 F8`(hex,RS485 口 4800)
- 触发器 **#15 LuxIn**(Serial Input,hex 匹配 `020304<4c><2c>`)→ Lua 自适应闭环(在线辨识灵敏度 k=Δlux/Δlevel,步长=err/k 一步到位、上限 20%、误差变号减半、限幅 5%~100%;`lux_value=lux` 全局暴露)
- 联动开关:全局变量 `lux_link_on`(默认 true),由触发器 #16(串口 `LLON` 或 HTTP)/ #17(`LLOFF`)切换
- **网页可调参数**(控制台"照度联动"卡,立即生效):目标照度 `lux_target`(#9 / `LXT=`)、轮询周期 `lux_poll_s` 1~60s(#19 / `PER=N`,=闭环调节间隔,照度采集仍每秒)、渐变时间 `lux_fade` 0.1~10s(#18 / `FAD=N`,单位 0.1s)、死区 `lux_deadband` 1~1000 Lux(#20 / `DBD=N`,**默认 1**);步长上限 20% / 限幅 5%~100% 为脚本常量,灵敏度 k 在线自动辨识无需手调
- Ubuntu 侧 `lux_sync.py` / `poll_lux.py` **已停用**(避免双主机抢总线);网页控制台照度改读 `/api/lua?variables=lux_value`
## 5. 常见问题
| 现象 | 排查 |
|------|------|
| 发命令无反应 | 检查波特率 **4800**、帧尾 `\n`、命令前缀大小写;用 `/api/log` 看触发记录 |
| 上传项目后灯不亮 | Upload 会清空 Override,重新发一次 `SC=` 或 `FX=` |
| 通道显示 `=0` | eDMX Pass-Through 被打开,外部源黑场压过本地;改回 None |
| 亮度偏暗 | BRT/LUX 总调光系数不是 100%,发 `BRT=100` 或查 `/api/group` |
| 修改了 Designer 项目 | 务必 Save Project → Upload All → 归档 pd2 到项目目录 |

77
Verify-Rs485.ps1 Normal file
View File

@ -0,0 +1,77 @@
# Verify-Rs485.ps1 — 验证 rs485_verification_plan.md §2 离线项 S1~S4(-Hardware 时附 COM 口探测)
param(
[switch]$Hardware
)
$ErrorActionPreference = 'Stop'
$script:pass = 0; $script:fail = 0
function Check($name, $cond, $detail) {
if ($cond) { $script:pass++; Write-Host (" [PASS] {0} {1}" -f $name, $detail) }
else { $script:fail++; Write-Host (" [FAIL] {0} {1}" -f $name, $detail) }
}
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path
Write-Host "== S1 帧编码 =="
$expected = [byte[]](0x42, 0x52, 0x54, 0x3D, 0x38, 0x30, 0x0A)
$payload = "BRT=80\n" -replace '\\n', "`n" -replace '\\r', "`r"
$bytes = [System.Text.Encoding]::ASCII.GetBytes($payload)
$same = ($bytes.Count -eq $expected.Count) -and (-not (Compare-Object $bytes $expected -SyncWindow 0))
Check "S1a BRT=80\n字节序列" $same ("| " + (($bytes | ForEach-Object { $_.ToString('X2') }) -join ' '))
$p1 = "A\r\nB" -replace '\\r', "`r" -replace '\\n', "`n"
$CR = $p1.Contains([char]0x0D); $LF = $p1.Contains([char]0x0A)
Check "S1b \r\n 转义还原" ($CR -and $LF) ("| CR={0} LF={1}" -f $CR, $LF)
Write-Host "== S2 行帧解析 =="
$buf = "BRT=0`nBRT=25`r`nBRT=50`nBRT="
$parts = $buf -split "`n"
$complete = $parts[0..($parts.Count - 2)]
$rest = $parts[-1]
$clean = @($complete | ForEach-Object { $_.TrimEnd("`r") })
Check "S2a 完整行数" ($clean.Count -eq 3) ("| " + ($clean -join ' / '))
Check "S2b 半包留缓冲" ($rest -eq 'BRT=') ("| tail='$rest'")
Check "S2c 行内容无损" (($clean -join ',') -eq 'BRT=0,BRT=25,BRT=50') "| 无CR残留且顺序正确"
Write-Host "== S3 Lua 脚本结构 =="
$luaPath = Join-Path $dir 'lpc_serial_brt.lua'
Check "S3a 脚本文件存在" (Test-Path $luaPath) "| $luaPath"
if (Test-Path $luaPath) {
$lua = Get-Content $luaPath -Raw
Check "S3b 读取通配符变量" ($lua -match 'get_trigger_variable\(1\)') "| get_trigger_variable(1).integer(Designer 2 语法)"
Check "S3c 边界钳位" ($lua -match 'if v < 0' -and $lua -match 'if v > 100') "| 0..100"
Check "S3d 强度换算" ($lua -match 'v / 100') "| 百分比→0..1"
Check "S3e 组主亮度调光" ($lua -match 'get_group' -and $lua -match 'set_master_intensity') "| get_group(n):set_master_intensity"
$balance = ([regex]::Matches($lua, '\(')).Count -eq ([regex]::Matches($lua, '\)')).Count
Check "S3f 括号配平" $balance "| 粗略语法完整性"
}
Write-Host "== S4 工具模式 =="
$tool = Join-Path $dir 'Test-Rs485.ps1'
$errs = $null
$null = [System.Management.Automation.Language.Parser]::ParseFile($tool, [ref]$null, [ref]$errs)
Check "S4a Test-Rs485.ps1 语法" ($errs.Count -eq 0) ("| 语法错误 {0} 个" -f $errs.Count)
$raw = Get-Content $tool -Raw
foreach ($mode in 'ListPorts', 'Send', 'Listen', 'Interactive') {
Check "S4b 模式 -$mode" ($raw -match "\`$$mode|\[$mode\]|-$mode") "| 参数存在"
}
if ($Hardware) {
Write-Host "== 硬件探测(只开/关端口,不写数据)=="
foreach ($p in ([System.IO.Ports.SerialPort]::GetPortNames() | Sort-Object)) {
$sp = $null
try {
$sp = New-Object System.IO.Ports.SerialPort($p, 9600, 'None', 8, 'One')
$sp.ReadTimeout = 200
$sp.Open(); $sp.Close()
Write-Host (" [OPEN] {0} 可用(候选 USB-485,拔插对比确认)" -f $p)
} catch {
Write-Host (" [BUSY] {0} 被占用或不可开:{1}" -f $p, $_.Exception.Message.Trim())
} finally {
if ($sp -and $sp.IsOpen) { $sp.Close() }
}
}
}
Write-Host ""
Write-Host "========================================"
Write-Host ("RS485 链路离线验证结果: PASS={0} FAIL={1}" -f $script:pass, $script:fail)
if ($script:fail -eq 0) { Write-Host ">>> 离线验证全部通过,可进入阶段1(H1 单机自发自收)<<<" }

7
api_smoke.sh Normal file
View File

@ -0,0 +1,7 @@
#!/bin/bash
curl -s --max-time 5 -X POST -H 'Content-Type: application/json' -d '{"action":"start","num":1}' http://192.168.58.109/api/timeline -w ' [%{http_code}]\n'
sleep 1
echo '--- timeline state ---'
curl -s --max-time 5 http://192.168.58.109/api/timeline | head -c 300; echo
echo '--- group level ---'
curl -s --max-time 5 http://192.168.58.109/api/group; echo

View File

@ -0,0 +1,82 @@
# 光照度读取数据包格式(仁科 / 耘农,2026-07-14 实测核对)
两个传感器均在 ESP32-P4(UART2 = GPIO54/19,9600 8N1)上实测,请求/应答帧
CRC 全部独立复算通过。排查细节见 `docs/modbus-rs485-sensors-2026-07-13.md`。
## 通用说明
- 协议:Modbus RTU,功能码 FC03(读保持寄存器),单寄存器。
- CRC16-Modbus:多项式 0xA001、初值 0xFFFF,线上字节序**低字节在前**
(参考实现:`src/modbus/rtu.rs` 的 `crc16` / `build_read_holding_registers`)。
- 应答合法性判据:地址匹配 + 功能码 0x03 + 数据长度 0x02 + CRC 通过
(`ram-app/src/bin/modbus_bench.rs` 的 `transact`)。
## 仁科铝壳光照度变送器(地址 1)
**光照度寄存器:0x0006(FC03)**
请求(8 字节):
```
01 03 00 06 00 01 64 0B
└┬┘ └┬┘ └──┬──┘ └──┬──┘ └─┬─┘
地址 功能码 起始寄存器 寄存器数 CRC16(lo,hi)
```
应答(7 字节):
```
01 03 02 <Hi> <Lo> <CRClo> <CRChi>
└──┬──┘
光照度(大端 u16)
```
实测真实帧(2026-07-14 晚,室内弱光):
```
01 03 02 00 11 78 48 → 0x0011 = 17 Lux
01 03 02 00 21 78 5C → 0x0021 = 33 Lux
```
换算:0~65535 量程型 1 count = 1 Lux;0~200000 量程型 1 count = 100 Lux
(手册示例 0x0530=1328 → 1328 Lux 或 132800 Lux)。
**性能**:100% 应答,首字节 ~4.3~4.6ms(含 3.5 字符 turnaround 4ms),
完整事务 ~10.6ms,最快轮询 ~50Hz(背靠背)。
## 邯郸耘农 RY-G/W/485(站号 0xFF)
**光照度寄存器:0x0000(FC03,站号作地址)**
请求(8 字节,与手册示例逐字一致):
```
FF 03 00 00 00 01 91 D4
└┬┘ └┬┘ └──┬──┘ └──┬──┘ └─┬─┘
站号 功能码 起始寄存器 寄存器数 CRC16(lo,hi)=0xD491
```
应答(7 字节):
```
FF 03 02 <Hi> <Lo> <CRClo> <CRChi>
```
实测 + 手册帧:
```
FF 03 02 00 AA 11 EF → 0x00AA = 170(实测唯一成功应答,2026-07-14)
FF 03 02 05 DC 93 59 → 0x05DC = 1500(手册示例,CRC 复算一致)
```
换算:0~2000/0~20K 量程 1 count = 1 Lux;0~200K 量程 ÷100(15 KLux)。
**注意**:该传感器光照度寄存器应答率仅 ~1%(慢事务单发 120 笔仅 1 成功;
FC03/FC04 × reg 0x0000~0x0008 探针 36 笔全超时),应答时延迟很短(~11ms)。
帧格式无误,属传感器固件行为(手册标称响应时间 1 秒)。对照组读站号
(FC03 reg 0x0001 @ 地址 0x00)100% 可靠:
```
请求: 00 03 00 01 00 01 D4 1B
应答: 00 03 02 00 FF C5 C4 → 站号 = 0xFF
```

Binary file not shown.

19
loop_test.sh Normal file
View File

@ -0,0 +1,19 @@
#!/bin/bash
API=http://192.168.58.109
TTY=/dev/ttyUSB0
level() { curl -s --max-time 3 $API/api/group | grep -o '"level":[0-9]*' | head -1 | cut -d: -f2; }
send_brt() { python3 /home/tonycao/rs485-lpc_test_rs485.py -p $TTY -b 9600 -s "BRT=$(printf '%03d' $1)\n" -w 0.5 > /dev/null; }
if [ "$1" = "loop" ]; then
pass=0; fail=0
for v in 100 0 50 80 25; do
send_brt $v
sleep 1
got=$(level)
if [ "$got" = "$v" ]; then echo "PASS BRT=$v -> level=$got"; pass=$((pass+1)); else echo "FAIL BRT=$v -> level=$got"; fail=$((fail+1)); fi
done
echo "RESULT: PASS=$pass FAIL=$fail"
else
send_brt $1
sleep 1
echo "BRT=$1 -> level=$(level)"
fi

7
lpc_fxloop.lua Normal file
View File

@ -0,0 +1,7 @@
-- Trigger #13 "FXLoop" (Type: Timeline Ended, Timeline: Any)
-- 时间线播到末尾时重新启动同一时间线,实现循环播放。
-- SC 释放时间线触发的是 Timeline Released(不是 Ended),不会误重启。
local tv = get_trigger_variable(1)
if tv == nil then return end
local tl = get_timeline(tv.integer)
if tl ~= nil then tl:start() end

6
lpc_lux_deadband.lua Normal file
View File

@ -0,0 +1,6 @@
local tv = get_trigger_variable(1)
if tv == nil or tv.integer == nil then return end
local d = tv.integer
if d < 1 then d = 1 end
if d > 1000 then d = 1000 end
lux_deadband = d

6
lpc_lux_fade.lua Normal file
View File

@ -0,0 +1,6 @@
local tv = get_trigger_variable(1)
if tv == nil or tv.integer == nil then return end
local f = tv.integer / 10
if f < 0.1 then f = 0.1 end
if f > 10 then f = 10 end
lux_fade = f

7
lpc_lux_period.lua Normal file
View File

@ -0,0 +1,7 @@
local tv = get_trigger_variable(1)
if tv == nil or tv.integer == nil then return end
local p = tv.integer
if p < 1 then p = 1 end
if p > 60 then p = 60 end
lux_poll_s = p
lux_last_adj = nil

89
lpc_modbus_lux.lua Normal file
View File

@ -0,0 +1,89 @@
local tv = get_trigger_variable(1)
if tv == nil then return end
local lux = tv.integer
if lux == nil then return end
if lux < 0 then lux = 0 end
if lux > 200000 then lux = 200000 end
lux_value = lux
if lux_link_on == nil then lux_link_on = true end
if not lux_link_on then return end
if lux_poll_s == nil then lux_poll_s = 1 end
local okt, now = pcall(function() return os.time() end)
if okt and now ~= nil then
if lux_last_adj ~= nil and now - lux_last_adj < lux_poll_s then return end
lux_last_adj = now
else
lux_tick = (lux_tick or 0) + 1
if lux_tick % lux_poll_s ~= 0 then return end
end
if lux_fade == nil then lux_fade = 1.0 end
if lux_deadband == nil then lux_deadband = 1 end
if lux_target == nil then lux_target = 500 end
local TARGET_LUX = lux_target
local DEADBAND = lux_deadband
local MIN_LEVEL = 0.05
local MAX_LEVEL = 1.0
local STEP_MAX = 0.20
local ok, grp = pcall(get_group, 0)
if not ok or grp == nil then
ok, grp = pcall(get_group, 'All Fixtures')
end
if not ok or grp == nil then return end
local err = TARGET_LUX - lux
if lux_prev_cmd ~= nil and lux_prev_cur ~= nil and lux_prev_lux ~= nil then
local dl = lux_prev_cmd - lux_prev_cur
local clamped = (lux_prev_cmd <= MIN_LEVEL + 0.001) or (lux_prev_cmd >= MAX_LEVEL - 0.001)
if math.abs(dl) > 0.002 and not clamped then
local ki = (lux - lux_prev_lux) / dl
if ki > 100 and ki < 10000 then
if lux_k == nil then
lux_k = ki
else
lux_k = 0.7 * lux_k + 0.3 * ki
end
end
end
lux_prev_cmd = nil
end
if err >= -DEADBAND and err <= DEADBAND then
lux_last_err = err
return
end
local lv = grp.master_intensity_level
local hw
if lv ~= nil and lv.range ~= nil and lv.range > 0 then
hw = lv.integer / lv.range
end
local cur = lux_cur_level
if cur == nil or (hw ~= nil and math.abs(hw - cur) > 0.05) then
cur = hw or 0.5
end
local k = lux_k or 500
local delta = err / k
if lux_last_err ~= nil and (err > 0) ~= (lux_last_err > 0) then
delta = delta * 0.5
end
lux_last_err = err
if delta > STEP_MAX then delta = STEP_MAX end
if delta < -STEP_MAX then delta = -STEP_MAX end
local new = cur + delta
if new < MIN_LEVEL then new = MIN_LEVEL end
if new > MAX_LEVEL then new = MAX_LEVEL end
lux_prev_cur = cur
lux_prev_cmd = new
lux_prev_lux = lux
lux_cur_level = new
grp:set_master_intensity(new, lux_fade)

20
lpc_serial_brt.lua Normal file
View File

@ -0,0 +1,20 @@
local tv = get_trigger_variable(1)
if tv == nil then return end
local v = tv.integer
if v < 0 then v = 0 end
if v > 100 then v = 100 end
local level = v / 100.0
local grp = nil
local ok
ok, grp = pcall(get_group, 0)
if not ok or grp == nil then
ok, grp = pcall(get_group, 'All Fixtures')
end
if ok and grp ~= nil then
grp:set_master_intensity(level)
end

16
lpc_serial_fx.lua Normal file
View File

@ -0,0 +1,16 @@
local tv = get_trigger_variable(1)
if tv == nil then return end
local n = tv.integer
local ok, ov = pcall(get_group_override, 0)
if ok and ov ~= nil then
ov:clear(1.0)
end
if n >= 1 and n <= 4 then
local tl = get_timeline(n)
if tl ~= nil then
tl:start_release_others()
end
end

49
lpc_serial_lux.lua Normal file
View File

@ -0,0 +1,49 @@
local tv = get_trigger_variable(1)
if tv == nil then return end
local lux = tv.integer
if lux == nil then return end
if lux < 0 then lux = 0 end
if lux > 200000 then lux = 200000 end
if lux_target == nil then lux_target = 500 end
local TARGET_LUX = lux_target
local DEADBAND = 50
local MIN_LEVEL = 0.05
local MAX_LEVEL = 1.0
local STEP_MIN = 0.02
local STEP_MAX = 0.20
local GAIN = 0.3
local ok, grp = pcall(get_group, 0)
if not ok or grp == nil then
ok, grp = pcall(get_group, 'All Fixtures')
end
if not ok or grp == nil then return end
local err = TARGET_LUX - lux
if err > -DEADBAND and err < DEADBAND then return end
local lv = grp.master_intensity_level
local cur
if lv ~= nil and lv.range ~= nil and lv.range > 0 then
cur = lv.integer / lv.range
else
cur = lux_cur_level or 0.5
end
local step = math.abs(err) / TARGET_LUX * GAIN
if step < STEP_MIN then step = STEP_MIN end
if step > STEP_MAX then step = STEP_MAX end
local new = cur
if err > 0 then
new = cur + step
else
new = cur - step
end
if new < MIN_LEVEL then new = MIN_LEVEL end
if new > MAX_LEVEL then new = MAX_LEVEL end
lux_cur_level = new
grp:set_master_intensity(new, 1.0)

22
lpc_serial_lx2.lua Normal file
View File

@ -0,0 +1,22 @@
local tv = get_trigger_variable(1)
if tv == nil then return end
local lux = tv.integer
if lux == nil then return end
if lux < 0 then lux = 0 end
if lux > 200000 then lux = 200000 end
local level = 0
if lux > 0 then
level = math.log(lux + 1) / math.log(200001)
end
if level > 1 then level = 1 end
local ok, grp = pcall(get_group, 0)
if not ok or grp == nil then
ok, grp = pcall(get_group, 'All Fixtures')
end
if ok and grp ~= nil then
grp:set_master_intensity(level, 0.5)
end

9
lpc_serial_lxt.lua Normal file
View File

@ -0,0 +1,9 @@
local tv = get_trigger_variable(1)
if tv == nil then return end
local v = tv.integer
if v == nil then return end
if v < 0 then v = 0 end
if v > 200000 then v = 200000 end
lux_target = v

20
lpc_serial_sc.lua Normal file
View File

@ -0,0 +1,20 @@
local tv = get_trigger_variable(1)
if tv == nil then return end
local n = tv.integer
local levels = { [1] = 255, [2] = 128, [3] = 26, [4] = 0 }
local level = levels[n]
if level == nil then return end
for i = 1, 4 do
local tl = get_timeline(i)
if tl ~= nil then
tl:release()
end
end
local ok, ov = pcall(get_group_override, 0)
if ok and ov ~= nil then
ov:set_intensity(level, 1.0)
end

449
lpc_web.py Normal file
View File

@ -0,0 +1,449 @@
# -*- coding: utf-8 -*-
# LPC 0140650 web UI,运行于 Ubuntu 192.168.58.7:8090
# LPC 自治架构:Modbus 轮询+闭环在 LPC Lua(触发器 #14/#15),本程序只做监控/记录/HTTP 转发
import json
import re
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
LPC = "http://192.168.58.109"
PORT = 8090
HIST_FILE = "/home/tonycao/rs485-lpc/history.jsonl"
SAMPLE_INTERVAL = 30 # 秒
HIST_KEEP = 48 * 3600 # 48 小时
TRIG = {"BRT": 2, "LUX": 8, "LXT": 9, "LX2": 10, "FX": 11, "SC": 12,
"FAD": 18, "PER": 19, "DBD": 20}
TRIG_LINK_ON = 16
TRIG_LINK_OFF = 17
_hist = [] # [[t_ms, brt, out, lux], ...] lux 可为 None
_hist_lock = threading.Lock()
_samples_since_compact = 0
def lpc_json(path):
with urllib.request.urlopen(LPC + path, timeout=5) as r:
return json.loads(r.read().decode("utf-8", "replace"))
def lpc_lua(names):
d = lpc_json("/api/lua?variables=" + ",".join(names))
return d
def lua_num(v):
try:
return int(round(float(v)))
except Exception:
return None
def read_lux():
try:
return lua_num(lpc_lua(["lux_value"]).get("lux_value"))
except Exception:
return None
def link_on():
try:
return bool(lpc_lua(["lux_link_on"]).get("lux_link_on"))
except Exception:
return False
def fire_trigger(num, var=None):
url = LPC + "/api/trigger?num=%d" % num
if var is not None:
url += "&var=%d" % var
req = urllib.request.Request(url, data=b"", method="POST")
with urllib.request.urlopen(req, timeout=5) as r:
r.read()
def sample_once():
t = int(time.time() * 1000)
brt = None
out = None
try:
g = lpc_json("/api/group")
brt = g["groups"][0]["level"]
except Exception:
pass
try:
o = lpc_json("/api/output?universe=dmx:1")
vals = []
for c in o["channels"][:32]:
m = re.search(r"\d+", str(c))
vals.append(int(m.group(0)) if m else 0)
out = round(sum(vals) * 100.0 / (255 * len(vals)), 1)
except Exception:
pass
lux = read_lux()
row = [t, brt, out, lux]
with _hist_lock:
_hist.append(row)
cutoff = t - HIST_KEEP * 1000
while _hist and _hist[0][0] < cutoff:
_hist.pop(0)
try:
with open(HIST_FILE, "a") as f:
f.write(json.dumps(row, separators=(",", ":")) + "\n")
except Exception:
pass
global _samples_since_compact
_samples_since_compact += 1
if _samples_since_compact >= 120: # 每小时压实一次文件
_samples_since_compact = 0
compact_history()
def compact_history():
with _hist_lock:
data = list(_hist)
try:
with open(HIST_FILE, "w") as f:
for r in data:
f.write(json.dumps(r, separators=(",", ":")) + "\n")
except Exception:
pass
def load_history():
try:
cutoff = int(time.time() * 1000) - HIST_KEEP * 1000
with open(HIST_FILE) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
except Exception:
continue
if isinstance(r, list) and len(r) == 4 and r[0] >= cutoff:
_hist.append(r)
except Exception:
pass
def sampler_loop():
while True:
try:
sample_once()
except Exception:
pass
time.sleep(SAMPLE_INTERVAL)
PAGE = r"""<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LPC 灯光控制</title>
<style>
body{font-family:system-ui,"Microsoft YaHei",sans-serif;background:#111;color:#eee;margin:0;padding:12px;max-width:860px;margin:auto}
h1{font-size:20px} h2{font-size:15px;color:#9cf;margin:14px 0 6px}
.card{background:#1d1d1d;border-radius:10px;padding:12px;margin-bottom:12px}
button{font-size:15px;padding:8px 14px;margin:4px;border:0;border-radius:8px;background:#2d6cdf;color:#fff;cursor:pointer}
button.sc{background:#555}
.row{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:4px 0}
.bars{display:grid;grid-template-columns:repeat(16,1fr);gap:4px}
.ch{background:#333;border-radius:4px;text-align:center;font-size:10px;padding:2px 0}
.ch .v{height:38px;display:flex;align-items:flex-end;justify-content:center}
.ch .f{width:80%;border-radius:3px 3px 0 0;background:#fc3}
.tl{display:flex;gap:8px;flex-wrap:wrap}
.t{padding:6px 10px;border-radius:8px;background:#333;font-size:13px}
.t.run{background:#1e5e1e}.t.rel{background:#5e1e1e}
input[type=range]{width:220px}
.sw{font-size:15px;padding:8px 14px;border-radius:8px;border:0;cursor:pointer;background:#555;color:#fff}
.sw.on{background:#1e7e1e}
#lux{font-size:24px;color:#fc3}
.dim{color:#888;font-size:12px}
canvas{width:100%;height:240px;background:#161616;border-radius:8px}
.lg{display:inline-block;width:18px;height:3px;margin:0 5px 2px 12px;vertical-align:middle}
</style>
</head>
<body>
<h1>LPC 0140650 实时状态</h1>
<div class="card">
<h2>照度联动</h2>
<div class="row">
<button id="luxsw" class="sw" onclick="luxlink()">…</button>
<span>照度:<b id="lux">-</b> Lux</span>
</div>
<div class="row">目标照度:
<input type="number" id="lxt" min="1" max="200000" step="10" style="width:90px"> Lux
<button onclick="setlxt()">设定</button>
</div>
<div class="row">轮询周期:
<input type="number" id="per" min="1" max="60" step="1" style="width:60px"> s
<button onclick="setper()">设定</button>
渐变时间:
<input type="number" id="fad" min="0.1" max="10" step="0.1" style="width:60px"> s
<button onclick="setfad()">设定</button>
死区:
<input type="number" id="dbd" min="1" max="1000" step="1" style="width:60px"> Lux
<button onclick="setdbd()">设定</button>
</div>
<div class="dim">闭环在 LPC 内运行(触发器 #14 每秒轮询传感器,#15 Lua 自动调光);轮询周期=闭环调节间隔,照度采集与显示仍每秒刷新;FAD 单位为 0.1s</div>
</div>
<div class="card">
<h2>控制</h2>
<div class="row">效果:
<button onclick="send('FX=01')">常亮</button>
<button onclick="send('FX=02')">呼吸</button>
<button onclick="send('FX=03')">闪烁</button>
<button onclick="send('FX=04')">缓变</button>
</div>
<div class="row">场景:
<button class="sc" onclick="send('SC=01')">全亮</button>
<button class="sc" onclick="send('SC=02')">半亮</button>
<button class="sc" onclick="send('SC=03')">微亮</button>
<button class="sc" onclick="send('SC=04')">全灭</button>
</div>
<div class="row">总亮度:
<input type="range" id="brt" min="0" max="100" value="100" onchange="brt()">
<span id="brtv">100</span>%
</div>
</div>
<div class="card">
<h2>历史曲线(48 小时,30s 采样)</h2>
<canvas id="hist"></canvas>
<div class="dim">
<span class="lg" style="background:#fc3"></span>输出%(左轴 0-100)
<span class="lg" style="background:#f80"></span>总调光 BRT%(左轴)
<span class="lg" style="background:#3cf"></span>照度 Lux(右轴,对数 1-200k)
<span id="histinfo"></span>
</div>
</div>
<div class="card">
<h2>Timeline</h2>
<div class="tl" id="tls"></div>
</div>
<div class="card">
<h2>DMX 输出(通道 1~32)</h2>
<div class="bars" id="bars"></div>
</div>
<div class="dim" id="ts"></div>
<script>
const $=id=>document.getElementById(id);
let luxOn=false, sending=false;
async function j(u){const r=await fetch(u);return r.json();}
async function refresh(){
try{
const tl=await j('/api/timeline');
$('tls').innerHTML=tl.timelines.map(t=>
`<div class="t ${t.state==='running'?'run':(t.state==='released'?'rel':'')}">T${t.num} ${t.name}<br>${t.state} ${(t.position/1000).toFixed(1)}s</div>`).join('');
const out=await j('/api/output?universe=dmx:1');
$('bars').innerHTML=out.channels.slice(0,32).map((c,i)=>{
const v=parseInt(String(c).replace(/\D/g,''))||0;
return `<div class="ch"><div class="v"><div class="f" style="height:${v/255*100}%"></div></div>${i+1}<br>${v}</div>`;}).join('');
const g=await j('/api/group');
const lv=g.groups[0].level;
if(document.activeElement!==$('brt')){$('brt').value=lv;$('brtv').textContent=lv;}
$('ts').textContent='更新于 '+new Date().toLocaleTimeString();
}catch(e){$('ts').textContent='连接失败: '+e;}
}
async function luxstat(){
try{
const s=await j('/luxlink');
luxOn=s.on;
$('luxsw').textContent=s.on?'联动中(点击关闭)':'已关闭(点击开启)';
$('luxsw').className='sw'+(s.on?' on':'');
if(s.lux!=null)$('lux').textContent=s.lux;
if(s.target!=null&&document.activeElement!==$('lxt'))$('lxt').value=s.target;
if(s.period!=null&&document.activeElement!==$('per'))$('per').value=s.period;
if(s.fade!=null&&document.activeElement!==$('fad'))$('fad').value=(Math.round(s.fade*10)/10);
if(s.deadband!=null&&document.activeElement!==$('dbd'))$('dbd').value=s.deadband;
}catch(e){}
}
async function setlxt(){const v=parseInt($('lxt').value);if(v>=1&&v<=200000)send('LXT='+v);}
async function setper(){const v=parseInt($('per').value);if(v>=1&&v<=60)send('PER='+v);}
async function setfad(){const v=Math.round(parseFloat($('fad').value)*10);if(v>=1&&v<=100)send('FAD='+v);}
async function setdbd(){const v=parseInt($('dbd').value);if(v>=1&&v<=1000)send('DBD='+v);}
async function luxlink(){
$('luxsw').textContent='切换中…';
await fetch('/luxlink?on='+(luxOn?'0':'1'),{method:'POST'});
setTimeout(luxstat,1500);
}
async function send(c){
if(sending)return; sending=true;
await fetch('/send?cmd='+encodeURIComponent(c),{method:'POST'});
sending=false; setTimeout(refresh,800); setTimeout(hist,5000);
}
function brt(){
const v=$('brt').value; $('brtv').textContent=v;
send('BRT='+String(v).padStart(3,'0'));
}
const LUXMIN=1, LUXMAX=200000;
function luxY(lux,h,pad){ // 对数轴
const v=Math.max(LUXMIN,Math.min(LUXMAX,lux));
const f=(Math.log10(v)-Math.log10(LUXMIN))/(Math.log10(LUXMAX)-Math.log10(LUXMIN));
return h-pad-f*(h-2*pad);
}
function pctY(p,h,pad){ return h-pad-(p/100)*(h-2*pad); }
async function hist(){
try{
const d=await j('/history');
const pts=d.points;
const cv=$('hist');
const w=cv.clientWidth, h=cv.clientHeight;
const dpr=window.devicePixelRatio||1;
cv.width=w*dpr; cv.height=h*dpr;
const ctx=cv.getContext('2d'); ctx.scale(dpr,dpr);
ctx.clearRect(0,0,w,h);
const pad=28, now=Date.now(), t0=now-48*3600*1000;
const X=t=>pad+(t-t0)/(now-t0)*(w-2*pad);
// 网格 + 时间刻度(每 6h)
ctx.strokeStyle='#333'; ctx.fillStyle='#888'; ctx.font='10px sans-serif'; ctx.lineWidth=1;
for(let k=0;k<=8;k++){
const t=t0+k*6*3600*1000, x=X(t);
ctx.beginPath(); ctx.moveTo(x,pad); ctx.lineTo(x,h-pad); ctx.stroke();
const dt=new Date(t);
ctx.fillText((dt.getMonth()+1)+'/'+dt.getDate()+' '+String(dt.getHours()).padStart(2,'0')+'时', x-24, h-8);
}
// 左轴 0/50/100%
ctx.fillStyle='#fc3';
[0,50,100].forEach(p=>{
const y=pctY(p,h,pad);
ctx.strokeStyle='#2a2a2a'; ctx.beginPath(); ctx.moveTo(pad,y); ctx.lineTo(w-pad,y); ctx.stroke();
ctx.fillText(p+'%', 2, y+3);
});
// 右轴 lux 对数刻度
ctx.fillStyle='#3cf';
[1,10,100,1000,10000,100000,200000].forEach(l=>{
const y=luxY(l,h,pad);
ctx.fillText(l>=1000?(l/1000)+'k':l, w-pad+3, y+3);
});
if(!pts.length){ $('histinfo').textContent=' (暂无数据,采样中…)'; return; }
// 输出% 实线
ctx.strokeStyle='#fc3'; ctx.lineWidth=1.5; ctx.beginPath();
let started=false;
for(const p of pts){
if(p[2]==null){started=false;continue;}
const x=X(p[0]), y=pctY(p[2],h,pad);
started?ctx.lineTo(x,y):ctx.moveTo(x,y); started=true;
}
ctx.stroke();
// BRT% 虚线
ctx.strokeStyle='#f80'; ctx.setLineDash([4,3]); ctx.beginPath(); started=false;
for(const p of pts){
if(p[1]==null){started=false;continue;}
const x=X(p[0]), y=pctY(p[1],h,pad);
started?ctx.lineTo(x,y):ctx.moveTo(x,y); started=true;
}
ctx.stroke(); ctx.setLineDash([]);
// lux 对数
ctx.strokeStyle='#3cf'; ctx.lineWidth=1.5; ctx.beginPath(); started=false;
for(const p of pts){
if(p[3]==null){started=false;continue;}
const x=X(p[0]), y=luxY(p[3],h,pad);
started?ctx.lineTo(x,y):ctx.moveTo(x,y); started=true;
}
ctx.stroke();
const first=new Date(pts[0][0]);
$('histinfo').textContent=` ${pts.length} 点,自 ${first.toLocaleString()}`;
}catch(e){}
}
refresh(); luxstat(); hist();
setInterval(refresh,1500); setInterval(luxstat,6000); setInterval(hist,60000);
window.addEventListener('resize', hist);
</script>
</body>
</html>
"""
class H(BaseHTTPRequestHandler):
def _send(self, code, body, ctype="application/json; charset=utf-8"):
b = body if isinstance(body, bytes) else body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def log_message(self, *a):
pass
def _reqlog(self):
if self.command != "POST":
return
try:
with open("/home/tonycao/rs485-lpc/lpc_web.req.log", "a") as f:
f.write("%s %s %s %s\n" % (time.strftime("%F %T"),
self.client_address[0], self.command, self.path))
except Exception:
pass
def do_GET(self):
u = urlparse(self.path)
if u.path in ("/", "/index.html"):
self._send(200, PAGE, "text/html; charset=utf-8")
elif u.path.startswith("/api/"):
try:
with urllib.request.urlopen(LPC + u.path + (("?" + u.query) if u.query else ""), timeout=5) as r:
self._send(200, r.read(), "application/json")
except Exception as e:
self._send(502, json.dumps({"error": str(e)}))
elif u.path == "/luxlink":
cfg = {}
try:
d = lpc_lua(["lux_target", "lux_fade", "lux_poll_s", "lux_deadband"])
cfg = {"target": lua_num(d.get("lux_target")),
"fade": d.get("lux_fade"),
"period": lua_num(d.get("lux_poll_s")),
"deadband": lua_num(d.get("lux_deadband"))}
except Exception:
pass
self._send(200, json.dumps({"on": link_on(), "lux": read_lux(), **cfg}))
elif u.path == "/history":
with _hist_lock:
data = list(_hist)
self._send(200, json.dumps({"points": data}, separators=(",", ":")))
else:
self._send(404, "{}")
def do_POST(self):
self._reqlog()
u = urlparse(self.path)
q = parse_qs(u.query)
if u.path == "/send":
cmd = q.get("cmd", [""])[0]
m = re.fullmatch(r"(BRT|FX|SC|LUX|LXT|LX2|FAD|PER|DBD)=(\d+)", cmd)
if not m:
self._send(400, json.dumps({"error": "bad cmd"}))
return
try:
fire_trigger(TRIG[m.group(1)], int(m.group(2)))
self._send(200, json.dumps({"ok": True}))
except Exception as e:
self._send(502, json.dumps({"error": str(e)}))
elif u.path == "/luxlink":
try:
fire_trigger(TRIG_LINK_ON if q.get("on", ["0"])[0] == "1" else TRIG_LINK_OFF)
self._send(200, json.dumps({"ok": True}))
except Exception as e:
self._send(502, json.dumps({"error": str(e)}))
else:
self._send(404, "{}")
if __name__ == "__main__":
load_history()
threading.Thread(target=sampler_loop, daemon=True).start()
print("LPC web UI: http://0.0.0.0:%d (history %d pts)" % (PORT, len(_hist)))
ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()

48
lux_test.sh Normal file
View File

@ -0,0 +1,48 @@
#!/bin/bash
# lux_test.sh — LPC 照度闭环回归(LUX/LXT 帧 + API 回读校验)
# 前提:BRT 链路已通(loop_test.sh 全 PASS),触发器 #8/#9 已上传
# 用法: bash lux_test.sh 全量回归
# 依赖: /home/tonycao/rs485-lpc_test_rs485.py, curl
TTY=/dev/ttyUSB0
API=http://192.168.58.109/api/group
PASS=0; FAIL=0
send() { python3 /home/tonycao/rs485-lpc_test_rs485.py -p $TTY -b 9600 -s "$1" -w 0.5 > /dev/null; }
level() { curl -s $API | grep -o '"level":[0-9]*' | grep -o '[0-9]*'; }
check() { # $1=用例名 $2=期望level $3=容差
local lv=$(level)
local d=$((lv > $2 ? lv - $2 : $2 - lv))
if [ $d -le ${3:-2} ]; then echo "PASS $1 -> level=$lv (expect $2±${3:-2})"; PASS=$((PASS+1));
else echo "FAIL $1 -> level=$lv (expect $2±${3:-2})"; FAIL=$((FAIL+1)); fi
}
echo "== 初始化:BRT=050, LXT=000500 =="
send 'BRT=050\n'; sleep 2
send 'LXT=000500\n'; sleep 1
check "init BRT=050" 50
echo "== 死区:LUX=000520(偏差20<50,不应变化)=="
send 'LUX=000520\n'; sleep 3
check "deadband 520" 50
echo "== 超目标降光:LUX=002000(50→30,-20%步进)=="
send 'LUX=002000\n'; sleep 3
check "dim 2000" 30
echo "== 欠目标升光:LUX=000000(30→50,+20%步进上限)=="
send 'LUX=000000\n'; sleep 3
check "raise 0" 50
echo "== 上限限幅:LUX=000000 ×8(收敛100)=="
for i in $(seq 8); do send 'LUX=000000\n'; sleep 2; done
check "clamp max" 100
echo "== 下限限幅:LUX=200000 ×8(收敛5)=="
for i in $(seq 8); do send 'LUX=200000\n'; sleep 2; done
check "clamp min" 5
echo "== 恢复:BRT=050 =="
send 'BRT=050\n'; sleep 2
check "restore 50" 50
echo "RESULT: PASS=$PASS FAIL=$FAIL"

21
lx2_test.sh Normal file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# lx2_test.sh — LX2 照度→亮度对数映射验证
PASS=0; FAIL=0
level() { curl -s http://192.168.58.109/api/group | grep -o '"level":[0-9]*' | grep -o '[0-9]*'; }
t() { # $1=lux $2=expect $3=tol
python3 /home/tonycao/rs485-lpc_test_rs485.py -s "LX2=$1\n" > /dev/null
sleep 2
local lv=$(level)
local d=$((lv > $2 ? lv - $2 : $2 - lv))
if [ $d -le $3 ]; then echo "PASS LX2=$1 -> level=$lv (expect ~$2)"; PASS=$((PASS+1));
else echo "FAIL LX2=$1 -> level=$lv (expect ~$2)"; FAIL=$((FAIL+1)); fi
}
t 000000 0 2
t 000010 19 3
t 000100 38 3
t 000500 51 3
t 001000 57 3
t 010000 75 3
t 100000 94 3
t 200000 100 2
echo "RESULT: PASS=$PASS FAIL=$FAIL"

190
rs485_verification_plan.md Normal file
View File

@ -0,0 +1,190 @@
# Pharos LPC RS485 串口链路验证 —— 设计与实施计划
> 目标:PC(USB-485)与 Pharos LPC 控制器共用一条 RS485 总线,实测验证
> **PC 发命令 → LPC Serial Input 触发器 → Lua 脚本 → 时间线(场景)整体亮度调节** 全链路,
> 以及 LPC→PC 反向应答。判据 = LPC 实际行为(灯光变化 / 日志 / 回包),非文档推断。
---
## 0. 前置条件
| 项 | 动作 | 状态 |
|----|------|------|
| P1 | USB-485 插在 **Ubuntu(192.168.58.7)**:`/dev/ttyUSB0` = FTDI FT232R(S/N B002S2QN),用户在 `dialout` 组,pyserial 可用 | ✅ 已通 |
| P2 | Windows 本机只做离线验证与远程驱动(Posh-SSH/SSH 免密已打通);Windows 板载 COM1~6 与 USB-485 无关(已实测:全为 ACPI\PNP0501) | ✅ 已确认 |
| P3 | LPC 上电联网,Designer 2 项目可烧录;串口模式选 **RS-485**,波特率两端一致(先 9600) | 待执行 |
| P4 | 项目内已建测试时间线 1~3(场景素材),可通过 HTTP API 或触发器手动启动 | 待执行 |
| P5 | 接线:USB-485 A/B/G ↔ LPC 端子 A(−)/B(+)/G,共地 | 待执行 |
| P6 | 参考依据:Serial_Integration.pdf(触发器/缓冲匹配机制)、Pharos Scripting Guide(`variable[1]`、`get_timeline()`) | 已读 |
---
## 1. 系统架构与接口定义
```
PC (Test-Rs485.ps1) ──USB── USB-485 ──A/B/G── RS485 总线 ──A/B/G── LPC 串口
主站,只发命令+收应答 Serial Input 触发器 → Lua → set_intensity
```
### 1.1 命令协议(ASCII,行尾 `\n`=0x0A 作帧界)
| 命令 | 方向 | 含义 | LPC 侧响应 |
|------|------|------|-----------|
| `BRT=NNN\n` | PC→LPC | 组 0 整体亮度 = NNN%(3 位补零) | 触发器 #2 挂 `lpc_serial_brt.lua` |
| `LUX=NNNNNN\n` | →LPC | 实测照度 0~200000 Lux(6 位补零)→ 闭环自动调光 | 触发器 #8 挂 `lpc_serial_lux.lua` |
| `LXT=NNNNNN\n` | →LPC | 设定目标照度(全局变量 `lux_target`,默认 500) | 触发器 #9 挂 `lpc_serial_lxt.lua` |
> 注:早期设想的固定五档 `BRT_x`、`OK\n` 应答、备选时间线方案均未采用;实测语法以 §6.4 为准。
### 1.2 LPC 触发器配置(实际部署)
| # | 类型 | Match string | 动作 |
|---|------|--------------|------|
| 2 | Serial Input | `BRT=<3d>` | Run Script(Quick Script = `lpc_serial_brt.lua`) |
| 8 | Serial Input | `LUX=<6d>` | Run Script(`lpc_serial_lux.lua`) |
| 9 | Serial Input | `LXT=<6d>` | Run Script(`lpc_serial_lxt.lua`) |
`lpc_serial_brt.lua` 实际内容见本目录文件(Designer 2 用 `get_trigger_variable(1)`,
`variable[1]` 为 v1 旧语法已废弃)。
---
## 2. 验证项分解(判据=实测)
| ID | 类型 | 验证内容 | 方法 | PASS 判据 |
|----|------|----------|------|-----------|
| S1 | 离线 | 测试工具帧编码正确 | `Verify-Rs485.ps1`:`BRT=80\n` → 7 字节含 0x0A,转义替换逻辑与工具一致 | 字节序列 42 52 54 3D 38 30 0A ✓ |
| S2 | 离线 | 行帧解析正确 | 多行混入 `\r\n` 混合行尾的缓冲拆分 | 每行完整、无串包 ✓ |
| S3 | 离线 | Lua 脚本结构完备 | 静态检查:引用 `variable[1]`、边界钳位、对每条时间线调用 `set_intensity` | 检查项全过 ✓ |
| S4 | 离线 | 工具参数模式完备 | `-ListPorts/-Send/-Listen/-Interactive` 帮助与执行路径 | 各模式可运行 ✓ |
| H1 | 硬件·单机 | USB-485 发送路径 | 已执行:端口打开/写入无错,**无自回显**(FT232+自动收发适配器不发回显属正常,判 TX 通需上真机) | ✅ TX 路径 OK |
| H1b | 硬件·总线 | 被动监听总线流量 | 已执行:9600 波特率监听 5s | 总线静默(LPC 未发包/未接入) |
| H2 | 硬件·总线 | 链路通 + 固定档触发 | 发 `BRT_50\n` | LPC 触发器日志出现对应触发;灯光变化 |
| H3 | 硬件·总线 | A/B 极性 | H2 无反应时对调 A/B 重测 | 对调后 H2 通过 |
| H4 | 硬件·总线 | 连续调光 + 通配符捕获 | 循环发 `BRT=0,25,50,75,100` | 灯光台阶变化;`variable[1]` 数值正确 |
| H5 | 硬件·总线 | 连续扫描鲁棒性 | 0→100 步进 5 循环 3 遍,行间隔 100ms | 全部命中无丢帧(串行缓冲匹配→清空机制下无粘包) |
| H6 | 硬件·总线 | 反向应答 | 触发器挂 Output Serial `OK\n`,PC `-Listen 3` | PC 收到 `OK` |
| N1 | 负例 | 波特率不一致 | 双端 9600/115200 错配 | 收乱码或无触发(预期现象,作对照) |
| N2 | 负例 | 串口模式误设 RS232 | 模式错配 | 无触发(预期现象) |
---
## 3. 执行阶段
- **阶段0 离线**(本机即可):运行 `Verify-Rs485.ps1` → S1~S4 全 PASS。
- **阶段1 单机**:USB-485 单独接总线 → H1(确认 PC 发送路径)。
- **阶段2 打通**:接入 LPC → P3 配置 → H2/H3(固定档,先证明物理链路与触发机制)。
- **阶段3 连续调光**:H4 → H5(通配符 + Lua 连续值)。
- **阶段4 反向与负例**:H6 应答;N1/N2 记录对照现象。
- **阶段5 收尾**:实测结论回填本表状态列,修正文档中"以版本说明为准"的待确认项
(通配符语法、`set_intensity` 可用性、老 DE-9 机型跳线)。
## 4. 风险与对策
| 风险 | 对策 |
|------|------|
| A/B 标各厂家不统一,接反无响应 | H3 对调实验,1 分钟排除 |
| 通配符语法随版本差异 | 首测用固定档触发器(零语法依赖),再看 Log Console 中实际收到的字节核对 Match string |
| 串行缓冲粘包/半包(连续发送过快) | 帧尾 `\n` + 行间隔 ≥100ms;H5 专门验证 |
| RS485 半双工冲突 | 约定 PC 为主站,LPC 仅应答,不同时发送 |
| `set_intensity` 不可用 | 备选:多条固定亮度时间线副本切换(§1.2) |
| 老 LPC(DE-9)RS485 为跳线配置 | 查对应安装指南;验证前确认跳线 |
| USB-485 被其他程序占用 | 探测工具报告"端口被占",先关 SymphoLight 等软件 |
## 6. 实测发现与自动闭环验证(2026-10-09 更新)
### 6.1 已确认的环境事实
| 项 | 结果 |
|----|------|
| LPC 位置 | **192.168.58.109**(Web 标题 `Pharos LPC 0140650`,MAC `00:13:B2:01:39:1A`) |
| HTTP API | 免认证可用;`GET /api/timeline`、`GET /api/group`、`GET /api/trigger`、`POST /api/timeline`(HTTP 204)实测通过 |
| 项目内容 | 1 条时间线(Timeline 1,未运行);**1 个组:num=0 "All Fixtures"(level:100)**;触发器仅 1 个 Startup(eDMX 直通)——**无串行触发器** |
| 亮度 API 修正 | Lua `Timeline` 对象无 `set_intensity`;正确做法 = `get_group(n):set_master_intensity(level[,fade[,delay]])`(0.0~1.0),`/api/group` 的 `level` 字段可回读 → **闭环判据** |
| 灯具 | `/api/fixtures` 返回空 —— 调组亮度可能无可见光效,但 level 回读仍可全自动判定 |
### 6.2 闭环自动验证(Ubuntu 上 `loop_test.sh`)
```
发送 BRT=<v> (RS485) → sleep 1 → GET /api/group 读 level → 与 v 比对 → PASS/FAIL
用法: bash loop_test.sh 80 (单点)
bash loop_test.sh loop (扫 100/0/50/80/25 汇总 PASS/FAIL)
```
### 6.3 最终验证结果(2026-10-09 全部通过)
| 验证项 | 结果 |
|--------|------|
| 离线 S1~S4 | ✅ 16/16 PASS(Verify-Rs485.ps1) |
| H1 RS485 发送路径 | ✅(FT232R 无自回显属正常,LPC 日志证实收包) |
| H2/H3 链路+触发 | ✅ LPC 日志 "RS485 input (N bytes)" + "Trigger 2 (Serial Input)" |
| H4/H5 连续调光+鲁棒性 | ✅ loop_test.sh:PASS=5 FAIL=0(100/0/50/80/25) |
| H6/N1/N2 | 按需(协议已闭环,负例未单测) |
### 6.4 实测确认的关键语法(文档未载,逆向自 pharos_designer.exe)
| 项 | 结论 |
|----|------|
| 串行通配符 | 尖括号语法 `<...>`:`<3d>` = 恰好 3 位数字;类型 `[cCdDxXsS]`;可带 `<n,type>` 变体;`<d>` 仅匹配 1 个字符;`?` `*` `#` `%` 均为普通字符 |
| 触发变量 | Designer 2 用 `get_trigger_variable(1)`(v1 的 `variable[1]` 已废弃) |
| 亮度 API | Timeline 无 set_intensity;用 `get_group(n):set_master_intensity(level)` |
| 最终协议 | PC 发 `BRT=NNN\n`(3 位补零,0~100)→ 组 0 整体亮度 = NNN% |
| 照度协议 | 发 `LUX=NNNNNN\n`(6 位补零,0~200000 Lux,仁科大量程)→ 闭环自动调光(lpc_serial_lux.lua);`LXT=NNNNNN\n` 设定目标照度(默认 500 Lux,全局变量 lux_target) |
| 照度实测 | 2026-10-09 闭环验证通过:BRT=050→level 50;LXT=000300 设目标 300 Lux;LUX=001000(超目标)→ 亮度 50→30(降 20% 步进);LUX=000100(低于目标)→ 30→50(升 20%),方向与步进均正确。触发器 #8 Lux Auto Dim / #9 Lux Target Set 已上传 LPC |
| LX2 指示模式 | 触发器 #10 Lux Display Map `LX2=<6d>`:照度→亮度对数映射 `level=log(lux+1)/log(200001)`(0.5s 渐变),灯即照度计;lx2_test.sh 8/8 PASS(0/10/100/500/1k/10k/100k/200k Lux → 0/20/38/51/57/75/94/100%) |
| 仁科寄存器 | 大量程 0~200000 Lux 型用 32 位寄存器对 0x0002(hi)/0x0003(lo),单位 1 Lux(esp32-voice src/config.rs MODBUS_ILLUMINANCE_REG=0x0002, COUNT=2;poll_illuminance() in src/modbus/master.rs);0~65535 型 reg 0x0006 单位 1 Lux |
| 工具链 | Designer 2 GUI 全程自动化操作(截图视觉闭环 + SendKeys/剪贴板);Download/Upload 同步项目 |
API 无法增改项目触发器,须在 Designer 2 中完成并上传:
1. LPC 设置:Serial Port → 模式 **RS-485**,波特率 **9600**(已完成)
2. 触发器:#2 `BRT=<3d>`、#8 `LUX=<6d>`、#9 `LXT=<6d>`,各挂对应 Quick Script(已完成并上传)
3. 回归:`bash loop_test.sh loop`(BRT 5 项)+ `bash lux_test.sh`(照度闭环 7 项)
## 7. 附录
### 7.1 工程文件
| 文件 | 用途 |
|------|------|
| `test_rs485.py` | **Ubuntu 侧主工具**(pyserial):`-l` 枚举 / `-s` 发送 / `-w` 监听 / `-i` 交互 |
| `Test-Rs485.ps1` | Windows 备用工具(当前无 USB-485,仅保留) |
| `lpc_serial_brt.lua` / `lpc_serial_lux.lua` / `lpc_serial_lxt.lua` | LPC 触发器 #2/#8/#9 Quick Script |
| `Verify-Rs485.ps1` | Windows 离线验证 S1~S4(已通过 16/16) |
| `loop_test.sh` / `lux_test.sh` | Ubuntu 闭环回归:BRT 5 项 / 照度 7 项(均已 PASS) |
| `api_smoke.sh` | LPC HTTP API 冒烟测试 |
| `0140650.pd2` | Designer 2 项目文件(已上传 LPC) |
| `ui.ps1` / `Run-Remote.ps1` / `Get-Remote.ps1` | GUI 自动化 / SSH 远程执行 / SFTP 下载 |
| `docs/` | 仁科 20w 说明书 PDF + Modbus 帧格式实测笔记 |
### 7.2 常用命令(Ubuntu 192.168.58.7)
```bash
# 枚举串口,确认 /dev/ttyUSB0 存在且权限可读(不在 dialout 组时用 sudo)
python3 test_rs485.py -l
# H1 单机自发自收(确认适配器 TX 路径)
python3 test_rs485.py -p /dev/ttyUSB0 -b 9600 -s 'BRT=80\n' -w 2
# 手动亮度 / 交互
python3 test_rs485.py -p /dev/ttyUSB0 -b 9600 -s 'BRT=080\n' -w 3
python3 test_rs485.py -p /dev/ttyUSB0 -b 9600 -i
# 照度闭环:设目标 500 Lux / 上报实测照度
python3 test_rs485.py -p /dev/ttyUSB0 -b 9600 -s 'LXT=000500\n'
python3 test_rs485.py -p /dev/ttyUSB0 -b 9600 -s 'LUX=001000\n'
# 回归
bash loop_test.sh loop # BRT 5 项
bash lux_test.sh # 照度闭环 7 项
# 无 pyserial 时
sudo apt install python3-serial
```
### 7.3 参考资料
- Serial Integration 应用说明:https://dl.pharoscontrols.com/documentation/application_notes/Serial_Integration.pdf
- 针脚定义(Pinouts):https://dl.pharoscontrols.com/documentation/single_line_diagrams/9401%20Detailed%20wiring%20examples_pinouts.pdf
- Designer 2 在线帮助:https://dl.pharoscontrols.com/software_help/designer2/Default.htm
- Designer 2 用户手册(A4 PDF):https://dl.pharoscontrols.com/documentation/manuals/Pharos%20Designer%202%20User%20Manual%20-%20A4.pdf

85
test_rs485.py Normal file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
import argparse
import glob
import sys
import time
try:
import serial
except ImportError:
print("need pyserial: sudo apt install python3-serial (or pip3 install pyserial)")
sys.exit(1)
def list_ports():
ports = sorted(glob.glob("/dev/ttyUSB*") + glob.glob("/dev/ttyACM*") + glob.glob("/dev/ttyS*"))
for p in ports:
print(p)
if not ports:
print("no serial ports found")
return ports
def open_port(name, baud):
sp = serial.Serial(None, baud, bytesize=8, parity="N", stopbits=1, timeout=0.3)
sp.port = name
sp.open()
return sp
def drain(sp, seconds):
deadline = time.time() + seconds
buf = b""
while time.time() < deadline:
chunk = sp.read(256)
if chunk:
buf += chunk
return buf
def show(buf):
if not buf:
return "(no bytes)"
text = buf.decode("ascii", "replace").replace("\n", "\\n").replace("\r", "\\r")
hexs = " ".join(f"{b:02X}" for b in buf)
return f"ascii[{text}] hex[{hexs}]"
def main():
ap = argparse.ArgumentParser(description="RS485 bus test tool for LPC verification")
ap.add_argument("-l", "--list", action="store_true", help="list serial ports")
ap.add_argument("-p", "--port", default="/dev/ttyUSB0")
ap.add_argument("-b", "--baud", type=int, default=9600)
ap.add_argument("-s", "--send", help='text to send, \\n \\r escaped')
ap.add_argument("-w", "--listen", type=float, default=0, help="seconds to listen after send")
ap.add_argument("-i", "--interactive", action="store_true", help="read line from stdin, send, show reply")
args = ap.parse_args()
if args.list:
list_ports()
return
sp = open_port(args.port, args.baud)
print(f"opened {args.port} @ {args.baud} 8N1, Ctrl+C to exit")
try:
if args.send:
payload = args.send.replace("\\n", "\n").replace("\\r", "\r").encode("ascii")
sp.write(payload)
sp.flush()
print(f"TX: {payload!r}")
if args.interactive:
while True:
line = input("TX> ")
if not line:
continue
sp.write((line + "\n").encode("ascii"))
sp.flush()
print("RX:", show(drain(sp, 0.3)))
if args.listen > 0:
print("RX:", show(drain(sp, args.listen)))
finally:
sp.close()
if __name__ == "__main__":
main()

92
ui.ps1 Normal file
View File

@ -0,0 +1,92 @@
param(
[string]$Click,
[string]$Move,
[string]$DblClick,
[string]$Drag,
[string]$RClick,
[string]$Keys,
[switch]$Enter,
[switch]$MaximizeWindow,
[string]$WindowTitle = 'Designer',
[int]$SleepMs = 800,
[string]$Out
)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class UI32 {
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")] public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
public const uint LEFTDOWN = 0x0002; public const uint LEFTUP = 0x0004;
public const uint RIGHTDOWN = 0x0008; public const uint RIGHTUP = 0x0010;
public static void RClick(int x, int y) {
SetCursorPos(x, y); System.Threading.Thread.Sleep(100);
mouse_event(RIGHTDOWN, 0, 0, 0, UIntPtr.Zero); System.Threading.Thread.Sleep(60);
mouse_event(RIGHTUP, 0, 0, 0, UIntPtr.Zero);
}
public static void Click(int x, int y) {
SetCursorPos(x, y); System.Threading.Thread.Sleep(100);
mouse_event(LEFTDOWN, 0, 0, 0, UIntPtr.Zero); System.Threading.Thread.Sleep(60);
mouse_event(LEFTUP, 0, 0, 0, UIntPtr.Zero);
}
public static void Dbl(int x, int y) { Click(x, y); System.Threading.Thread.Sleep(80); Click(x, y); }
public static void Drag(int x1, int y1, int x2, int y2) {
SetCursorPos(x1, y1); System.Threading.Thread.Sleep(200);
mouse_event(LEFTDOWN, 0, 0, 0, UIntPtr.Zero); System.Threading.Thread.Sleep(200);
int steps = 20;
for (int i = 1; i <= steps; i++) {
SetCursorPos(x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps);
System.Threading.Thread.Sleep(30);
}
System.Threading.Thread.Sleep(200);
mouse_event(LEFTUP, 0, 0, 0, UIntPtr.Zero);
}
}
"@
if ($MaximizeWindow) {
$p = Get-Process pharos_designer -ErrorAction SilentlyContinue
if ($p -and $p.MainWindowHandle -ne [IntPtr]::Zero) {
[UI32]::ShowWindow($p.MainWindowHandle, 3) | Out-Null
[UI32]::SetForegroundWindow($p.MainWindowHandle) | Out-Null
Start-Sleep -Milliseconds 800
}
}
if ($Move) {
$p = $Move -split ','; [UI32]::SetCursorPos([int]$p[0], [int]$p[1])
}
if ($Click) {
$p = $Click -split ','; [UI32]::Click([int]$p[0], [int]$p[1])
}
if ($DblClick) {
$p = $DblClick -split ','; [UI32]::Dbl([int]$p[0], [int]$p[1])
}
if ($RClick) {
$p = $RClick -split ','; [UI32]::RClick([int]$p[0], [int]$p[1])
}
if ($Drag) {
$p = $Drag -split ','; [UI32]::Drag([int]$p[0], [int]$p[1], [int]$p[2], [int]$p[3])
}
if ($Keys) {
Start-Sleep -Milliseconds 300
[System.Windows.Forms.SendKeys]::SendWait($Keys)
}
if ($Enter) {
Start-Sleep -Milliseconds 200
[System.Windows.Forms.SendKeys]::SendWait('{ENTER}')
}
Start-Sleep -Milliseconds $SleepMs
if ($Out) {
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$bmp = New-Object System.Drawing.Bitmap($vs.Width, $vs.Height)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.CopyFromScreen($vs.Left, $vs.Top, 0, 0, $bmp.Size)
$g.Dispose()
$bmp.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
Write-Host ("shot {0}" -f $Out)
}