commit ecf2702ede79802bf413475b5a542e3facdf8955 Author: tonycao Date: Sat Oct 10 20:28:10 2026 +0800 LPC 自治照度闭环:RS485 Modbus 采集 + 自适应模型控制 + HTTP/网页参数可调 diff --git a/0140650.pd2 b/0140650.pd2 new file mode 100644 index 0000000..a117842 Binary files /dev/null and b/0140650.pd2 differ diff --git a/Get-Remote.ps1 b/Get-Remote.ps1 new file mode 100644 index 0000000..c5b0ddd --- /dev/null +++ b/Get-Remote.ps1 @@ -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 +} diff --git a/Run-Remote.ps1 b/Run-Remote.ps1 new file mode 100644 index 0000000..bb35421 --- /dev/null +++ b/Run-Remote.ps1 @@ -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 +} diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..bbfb727 --- /dev/null +++ b/SETUP.md @@ -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)。 diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..5eb2d01 --- /dev/null +++ b/SUMMARY.md @@ -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 + 帧格式笔记) diff --git a/TEST_REPORT.md b/TEST_REPORT.md new file mode 100644 index 0000000..0ffddf6 --- /dev/null +++ b/TEST_REPORT.md @@ -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`。 diff --git a/Test-Rs485.ps1 b/Test-Rs485.ps1 new file mode 100644 index 0000000..40f9bb1 --- /dev/null +++ b/Test-Rs485.ps1 @@ -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() } +} diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..ec39d46 --- /dev/null +++ b/USAGE.md @@ -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 到项目目录 | diff --git a/Verify-Rs485.ps1 b/Verify-Rs485.ps1 new file mode 100644 index 0000000..1dc9e4d --- /dev/null +++ b/Verify-Rs485.ps1 @@ -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 单机自发自收)<<<" } diff --git a/api_smoke.sh b/api_smoke.sh new file mode 100644 index 0000000..1e7abe1 --- /dev/null +++ b/api_smoke.sh @@ -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 diff --git a/docs/modbus-illuminance-packet-formats.md b/docs/modbus-illuminance-packet-formats.md new file mode 100644 index 0000000..d17d5a3 --- /dev/null +++ b/docs/modbus-illuminance-packet-formats.md @@ -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 + └──┬──┘ + 光照度(大端 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 +``` + +实测 + 手册帧: + +``` +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 +``` diff --git a/docs/renke-illuminance-485-200k-manual.pdf b/docs/renke-illuminance-485-200k-manual.pdf new file mode 100644 index 0000000..20bf43b Binary files /dev/null and b/docs/renke-illuminance-485-200k-manual.pdf differ diff --git a/loop_test.sh b/loop_test.sh new file mode 100644 index 0000000..6d4c813 --- /dev/null +++ b/loop_test.sh @@ -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 \ No newline at end of file diff --git a/lpc_fxloop.lua b/lpc_fxloop.lua new file mode 100644 index 0000000..cf23612 --- /dev/null +++ b/lpc_fxloop.lua @@ -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 diff --git a/lpc_lux_deadband.lua b/lpc_lux_deadband.lua new file mode 100644 index 0000000..34e2125 --- /dev/null +++ b/lpc_lux_deadband.lua @@ -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 diff --git a/lpc_lux_fade.lua b/lpc_lux_fade.lua new file mode 100644 index 0000000..4475a12 --- /dev/null +++ b/lpc_lux_fade.lua @@ -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 diff --git a/lpc_lux_period.lua b/lpc_lux_period.lua new file mode 100644 index 0000000..595a997 --- /dev/null +++ b/lpc_lux_period.lua @@ -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 diff --git a/lpc_modbus_lux.lua b/lpc_modbus_lux.lua new file mode 100644 index 0000000..5eb4da6 --- /dev/null +++ b/lpc_modbus_lux.lua @@ -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) diff --git a/lpc_serial_brt.lua b/lpc_serial_brt.lua new file mode 100644 index 0000000..f480e85 --- /dev/null +++ b/lpc_serial_brt.lua @@ -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 diff --git a/lpc_serial_fx.lua b/lpc_serial_fx.lua new file mode 100644 index 0000000..fa44b26 --- /dev/null +++ b/lpc_serial_fx.lua @@ -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 diff --git a/lpc_serial_lux.lua b/lpc_serial_lux.lua new file mode 100644 index 0000000..6811fd6 --- /dev/null +++ b/lpc_serial_lux.lua @@ -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) diff --git a/lpc_serial_lx2.lua b/lpc_serial_lx2.lua new file mode 100644 index 0000000..288f8d4 --- /dev/null +++ b/lpc_serial_lx2.lua @@ -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 diff --git a/lpc_serial_lxt.lua b/lpc_serial_lxt.lua new file mode 100644 index 0000000..c00a35d --- /dev/null +++ b/lpc_serial_lxt.lua @@ -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 diff --git a/lpc_serial_sc.lua b/lpc_serial_sc.lua new file mode 100644 index 0000000..a864faa --- /dev/null +++ b/lpc_serial_sc.lua @@ -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 diff --git a/lpc_web.py b/lpc_web.py new file mode 100644 index 0000000..f7aee10 --- /dev/null +++ b/lpc_web.py @@ -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""" + + + + +LPC 灯光控制 + + + +

LPC 0140650 实时状态

+ +
+

照度联动

+
+ + 照度:- Lux +
+
目标照度: + Lux + +
+
轮询周期: + s + + 渐变时间: + s + + 死区: + Lux + +
+
闭环在 LPC 内运行(触发器 #14 每秒轮询传感器,#15 Lua 自动调光);轮询周期=闭环调节间隔,照度采集与显示仍每秒刷新;FAD 单位为 0.1s
+
+ +
+

控制

+
效果: + + + + +
+
场景: + + + + +
+
总亮度: + + 100% +
+
+ +
+

历史曲线(48 小时,30s 采样)

+ +
+ 输出%(左轴 0-100) + 总调光 BRT%(左轴) + 照度 Lux(右轴,对数 1-200k) + +
+
+ +
+

Timeline

+
+
+ +
+

DMX 输出(通道 1~32)

+
+
+ +
+ + + + +""" + +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() diff --git a/lux_test.sh b/lux_test.sh new file mode 100644 index 0000000..266c079 --- /dev/null +++ b/lux_test.sh @@ -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" diff --git a/lx2_test.sh b/lx2_test.sh new file mode 100644 index 0000000..7a42d74 --- /dev/null +++ b/lx2_test.sh @@ -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" diff --git a/rs485_verification_plan.md b/rs485_verification_plan.md new file mode 100644 index 0000000..ba4cffa --- /dev/null +++ b/rs485_verification_plan.md @@ -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= (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]`;可带 `` 变体;`` 仅匹配 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 diff --git a/test_rs485.py b/test_rs485.py new file mode 100644 index 0000000..f39a0da --- /dev/null +++ b/test_rs485.py @@ -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() diff --git a/ui.ps1 b/ui.ps1 new file mode 100644 index 0000000..c00ef3a --- /dev/null +++ b/ui.ps1 @@ -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) +}