LPC 自治照度闭环:RS485 Modbus 采集 + 自适应模型控制 + HTTP/网页参数可调
This commit is contained in:
commit
ecf2702ede
BIN
0140650.pd2
Normal file
BIN
0140650.pd2
Normal file
Binary file not shown.
15
Get-Remote.ps1
Normal file
15
Get-Remote.ps1
Normal 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
17
Run-Remote.ps1
Normal 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
113
SETUP.md
Normal 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
140
SUMMARY.md
Normal 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
105
TEST_REPORT.md
Normal 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
70
Test-Rs485.ps1
Normal 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
97
USAGE.md
Normal 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
77
Verify-Rs485.ps1
Normal 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
7
api_smoke.sh
Normal 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
|
||||||
82
docs/modbus-illuminance-packet-formats.md
Normal file
82
docs/modbus-illuminance-packet-formats.md
Normal 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
|
||||||
|
```
|
||||||
BIN
docs/renke-illuminance-485-200k-manual.pdf
Normal file
BIN
docs/renke-illuminance-485-200k-manual.pdf
Normal file
Binary file not shown.
19
loop_test.sh
Normal file
19
loop_test.sh
Normal 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
7
lpc_fxloop.lua
Normal 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
6
lpc_lux_deadband.lua
Normal 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
6
lpc_lux_fade.lua
Normal 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
7
lpc_lux_period.lua
Normal 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
89
lpc_modbus_lux.lua
Normal 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
20
lpc_serial_brt.lua
Normal 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
16
lpc_serial_fx.lua
Normal 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
49
lpc_serial_lux.lua
Normal 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
22
lpc_serial_lx2.lua
Normal 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
9
lpc_serial_lxt.lua
Normal 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
20
lpc_serial_sc.lua
Normal 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
449
lpc_web.py
Normal 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
48
lux_test.sh
Normal 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
21
lx2_test.sh
Normal 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
190
rs485_verification_plan.md
Normal 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
85
test_rs485.py
Normal 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
92
ui.ps1
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user