简介:一套基于TCP/IP的展厅智能中控软件程序,适合展厅、多媒体展项及小型集成项目使用。采用可视化所见所得的编辑方式,通过鼠标拖曳即可完成界面布局,无需编程,降低了非技术人员的配置门槛。UI素材全部存放于userData文件夹,支持用户替换为自备的JPG/PNG图片,便于快速贴合项目视觉需求。软件覆盖TCP、UDP、串口、PJLink与网络唤醒等常用控制协议,支持指令集一键开启/关闭分组、键盘按键绑定指令、自定义延时控制灯光等设备,并可增删页面和按钮;同时支持平板与电脑端无线同步数据,适配Windows与Android双平台。
资源共373个文件,压缩包约88MB,内容以272个PNG和56个JPG素材图片为主,另含exe可执行程序、apk安卓安装包、dll运行库、xml配置及docx说明文档等,结构完整,解压后即可对照部署。目前已有2103人学习使用,适用于需要快速搭建或改造展厅中控界面的集成商、项目经理及运维人员。通过程序文件、素材目录及说明文档,可掌握自定义UI、指令集配置和设备联动方法,作为展厅中控项目的直接参考起点。
1. 展厅智能中控:一块平板点一下,整个展厅按顺序动起来
在展馆现场,最怕的不是设备坏,而是讲解员拿着平板按了「参观模式」,投影却迟迟不出画面,灯光先亮了、音响后响了,场面一度很尴尬。这套基于 TCP/IP 的展厅智能中控软件,干的就是这件事:把展项涉及的大屏、投影、灯光、音响、电动幕布统一接到一张局域网里,通过触控一体机下发一条指令,软件按预设顺序和延时,把每个设备该做的动作一次做完。它适合做展厅展馆项目集成的工程师、想用软件替代遥控器的现场弱电施工人员,也适合刚接触中控方向、想找一套能落地架构做参考的开发者。核心价值一句话:用一套软件和 TCP/IP 协议,替代原来一屋子遥控器和人工开关机。
2. 整体架构与协议设计:三层结构加一套可复用的指令帧
2.1 为什么中控系统统一走 TCP/IP
早年展厅中控是典型的 RS-232/485 串口直连,每台设备一根线拉到机房里的中控主机,设备一多,线束和距离问题就压到施工头上。串口超过 15 米就要加转换器,调试时还得抱着笔记本蹲到设备旁边试。现在情况变了,投影仪、LED 发送卡基本都带 RJ45 网口,支持 LAN 控制;老一点的设备也能通过串口服务器把 RS-232/485 转成网络口。中控主机只需要在局域网里按 IP 找到设备,把指令以 TCP 报文发过去,布线、距离、扩展都舒服很多。
这个选择还有个实际好处:展厅的中控主机一般放在机房或弱电间,而触控面板在展项旁边,两者之间本来就要走网络。统一走 TCP/IP 之后,中控主机、串口服务器、触控一体机可以在同一个二层网络里,交换机和网线都是现成的,施工队不用再放多芯控制线。常见做法是把展厅内网规划成独立网段,所有受控设备给静态 IP,后续加设备只是往配置表里加一行的事。
2.2 软件三层结构与目录划分
整个软件按三层组织,职责边界清晰:
- UI 层:WinForms 场景面板,面向讲解员和操作员,提供大按钮、当前场景状态、设备在线与离线指示。
- 调度层:场景引擎和指令队列,负责解析场景配置、按步骤派发指令、处理设备回读和超时。
- 连接层:TCP 连接池和设备网关,负责真正的 Socket 收发、心跳保活、掉线重连。
目录结构如下:
ExhibitionController/ ├── App.config # 全局配置:日志级别、本机端口、重连间隔 ├── Config/ │ ├── devices.json # 设备地址表与指令集 │ └── scenes.json # 场景步骤定义 ├── Core/ │ ├── Connection/ │ │ ├── DeviceConnection.cs # 单设备 TCP 连接封装 │ │ └── ConnectionPool.cs # 连接池:按 deviceId 取连接 │ ├── Protocol/ │ │ ├── FrameBuilder.cs # 指令帧构建与校验 │ │ └── ResponseParser.cs # 回读解析与状态映射 │ ├── Engine/ │ │ └── SceneEngine.cs # 场景执行引擎 │ └── Models/ │ ├── DeviceConfig.cs │ └── SceneConfig.cs ├── UI/ │ ├── MainForm.cs # 主场景面板 │ └── DeviceTestForm.cs # 单设备调试窗口 └── Program.csModel 层负责把 devices.json 和 scenes.json 反序列化成强类型对象,Engine 只跟 Model 打交道,不直接碰文件。这样换设备型号或者加展厅时,改配置就能覆盖大部分需求,不用动代码。UI 层和调度层之间通过接口调用,现场如果想把 WinForms 换成触屏浏览器页面,只要保留后两层,重新实现 UI 即可。
2.3 指令帧与设备地址表设计
展厅现场的协议五花八门,主要分两类:一类是 ASCII 文本指令,比如很多投影仪和音频处理器认 "PWR ON\r\n" 这种带回车换行的字符串;另一类是 HEX 帧,比如网络继电器模块常见 "AA 01 03 FF" 这种十六进制报文,中间加不加空格、末尾要不要校验和,每种模块都不一样。我把每台设备的协议内容全部收进 devices.json,给每台设备一个唯一 deviceId:
{ "devices": [ { "deviceId": "projector_main", "name": "主投影", "host": "192.168.1.50", "port": 5000, "protocol": "tcp", "frameType": "ascii", "keepAlive": false, "commands": { "powerOn": "PWR ON\r\n", "powerOff": "PWR OFF\r\n", "statusQuery": "PWR QRY\r\n" } }, { "deviceId": "relay_light", "name": "灯光继电器", "host": "192.168.1.60", "port": 4001, "protocol": "tcp", "frameType": "hex", "appendChecksum": true, "keepAlive": true, "commands": { "channelOn:1": "AA 01 01", "channelOff:1": "AA 01 00" } } ] }字段说明:
- host 和 port 是设备或串口服务器的 TCP 地址,展厅内网一般规划成独立的 192.168.1.x 网段,避免和办公网冲突。
- frameType 决定 FrameBuilder 按 ASCII 还是 HEX 解析指令原文,取值为 "ascii" 或 "hex"。
- keepAlive 决定连接策略:投影仪这类设备对长连接不友好,设为 false,每次发指令前连、发完就关;继电器模块响应快、不怕保持连接,设为 true 可以省去反复建连的开销。
- commands 是命令字典,key 是场景里引用时用的指令名,value 是设备真正认的报文原文。场景配置里只写 "powerOn",不关心底下是 ASCII 还是 HEX。
- appendChecksum 为 true 时,FrameBuilder 会在 HEX 帧末尾自动追加 XOR 校验字节,现场继电器模块基本都用这类校验。
3. 核心模块拆解:一条触摸指令从 UI 到设备的完整链路
3.1 连接管理层:TcpClient 连接池与掉线重连
连接层是整个软件的地基。现场几十台设备,如果每个按钮都临时 new 一个 TcpClient,连接管理会非常混乱,掉线也没人管。我这边用 ConnectionPool 按 deviceId 管理连接,核心是下面这个 DeviceConnection 类:
public class DeviceConnection { private TcpClient _client; private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); public DeviceConfig Config { get; } public DeviceConnection(DeviceConfig config) { Config = config; _client = new TcpClient(); } public bool IsConnected => _client?.Connected ?? false; public async Task<bool> ConnectAsync(CancellationToken ct) { if (IsConnected) return true; // TcpClient.Connected 只反映最近一次 IO 状态,所以连接必须包超时 using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(3000); try { await _client.ConnectAsync(Config.Host, Config.Port, timeoutCts.Token); _client.NoDelay = true; // 关闭小包延迟聚合,避免指令被憋住 return true; } catch (Exception ex) { Log.Error($"连接失败 {Config.Host}:{Config.Port}, {ex.Message}"); return false; } } public async Task<byte[]> SendAsync(byte[] data, int waitReplyMs = 300, CancellationToken ct = default) { // 同一设备的发送串行化,避免指令交叉 await _sendLock.WaitAsync(ct); try { if (!IsConnected && !await ConnectAsync(ct)) return null; var stream = _client.GetStream(); await stream.WriteAsync(data, ct); await stream.FlushAsync(ct); // 常规做法:给设备留一个应答窗口,没有应答也不阻塞场景 await Task.Delay(waitReplyMs, ct); if (!stream.DataAvailable) return null; using var ms = new MemoryStream(); await stream.CopyToAsync(ms, ct); return ms.ToArray(); } finally { _sendLock.Release(); } } }逻辑说明:
- ConnectAsync 用 CancellationTokenSource.CancelAfter(3000) 做连接超时,防止设备 IP 不可达时 UI 卡死,这是现场最容易出问题的地方。
- NoDelay = true 是关键一行。TCP 默认对小报文做延迟聚合,展厅指令往往只有十几个字节,不关掉会出现指令延迟几秒才发出的玄学现象。
- SendAsync 用 SemaphoreSlim 把同一设备的发送串行化;多个场景线程同时操作一台设备时,后到的指令会排队,不会交叉。
- 应答窗口 waitReplyMs 默认 300 毫秒,够大多数继电器模块回应;投影仪查询类指令可以把这个参数调到 800。
3.2 指令构建:把字符串配置转成可发送的字节帧
FrameBuilder 的职责是根据配置文件里的 frameType 把指令原文变成字节数组。现场 ASCII 和 HEX 指令都有,统一走一个入口:
public static byte[] Build(CommandConfig cmd) { if (cmd.FrameType == "ascii") { // ASCII 帧:很多投影仪、音频处理器认 \r\n 结尾 return Encoding.ASCII.GetBytes(cmd.CommandText); } // HEX 帧:如 "AA 01 03",先去掉空格再转字节 var hex = cmd.CommandText.Replace(" ", ""); var bytes = Convert.FromHexString(hex); // 部分继电器模块要求末尾追加 XOR 校验,按配置决定是否自动计算 if (cmd.AppendChecksum) { byte xor = 0; foreach (var b in bytes) xor ^= b; return bytes.Append(xor).ToArray(); } return bytes; }逻辑说明和参数说明:
- ASCII 指令直接按编码发,注意大多数投影仪指令必须以 \r\n 结尾,否则设备不认,这一点在配置里写原文的时候就要带全。
- Convert.FromHexString 是 .NET 5+ 自带的方法,能把 HEX 字符串转字节数组;配置里写 "AA 01 03",程序自动忽略空格。
- AppendChecksum 为 true 时程序自动算异或并追加到末尾,配置里只写指令主体,省得手写校验出错。
- 配套的 ResponseParser 负责把回读报文映射成状态文本,比如投影仪回 "PWR=ON" 显示为开机状态。状态查询类指令建议统一走它,别在 UI 层直接解析字节。
3.3 场景执行引擎:顺序、延时与回读确认
场景是本软件入口级的功能。我在 scenes.json 里定义场景,每个场景是若干 step 的有序集合,SceneEngine 拿到场景配置后逐个执行:
public async Task ExecuteAsync(SceneConfig scene, CancellationToken ct) { Log.Info($"开始执行场景: {scene.Name}"); // 场景级锁:避免操作员快速连点两个场景,导致设备指令交错 await _sceneLock.WaitAsync(ct); try { foreach (var step in scene.Steps) { ct.ThrowIfCancellationRequested(); var device = _pool.Get(step.DeviceId); var cmd = device.Config.Commands[step.Command]; var frame = FrameBuilder.Build(cmd); await device.SendAsync(frame, step.WaitReplyMs, ct); if (step.DelayAfterMs > 0) await Task.Delay(step.DelayAfterMs, ct); Log.Info($"步骤完成: {device.Config.Name} / {step.Command}"); } } finally { _sceneLock.Release(); } }逻辑说明和参数说明:
- _sceneLock 是场景级 SemaphoreSlim(1,1)。展厅里最怕两个场景同时在跑,比如「参观模式」还没执行完,讲解员又点了「离馆模式」,两个线程同时给同一台设备发指令,继电器会乱跳。这把锁把所有场景串行化,后点的排队。
- CancellationToken 让软件退出时能立刻中断场景,不会出现点了关闭软件、设备还在一个个动作的情况。
- step.DelayAfterMs 是每步执行完后的等待时间,决定设备动作的节奏。投影仪开机到画面稳定需要好几秒,后面幕布的动作必须等够时间再发,否则幕布降下来画面还没出。
- WaitReplyMs 是给回读确认留的窗口,需要确认状态的步骤调大,不需要的直接设 0 跳过等待。
UI 层调用就一行:MainForm 里按钮点击后await _sceneEngine.ExecuteAsync(sceneConfig, _cts.Token),async/await 保证 UI 线程不阻塞,按钮上还能显示「执行中」状态。
4. 运行与部署:配置文件、串口服务器与现场联调
4.1 开发环境与运行依赖
这个项目用 C# / .NET 6 + WinForms 实现,主要考虑是中控主机在现场通常是 Windows 触控一体机,WinForms 对触屏适配和旧设备兼容都省事。运行和开发环境清单:
- Windows 10/11 系统,开发机和中控主机同版本更省心
- .NET 6 SDK;机上如果没有 SDK,可以用
dotnet publish出单文件自包含包,直接拷到一体机运行 - 第三方包:无强依赖,JSON 序列化用 System.Text.Json,日志自己写文件
编译命令很简单:
dotnet build ExhibitionController.csproj -c Release编译产出直接拷到触控一体机上就能跑。现场硬件方面,至少需要一台触控一体机或普通 PC 加触摸屏、若干台串口服务器(RS-232/485 转网络)、网络继电器模块,以及带 LAN 控制口的投影仪或 LED 发送卡。
提示:触控一体机的屏幕分辨率先确认,WinForms 窗体按目标分辨率设计,别在现场发现按钮被任务栏挡住。
4.2 设备配置与场景配置
我把场景配置单独放在 scenes.json,跟 devices.json 分开,方便现场调试时只动场景不改设备表。一个典型场景如下:
{ "scenes": [ { "sceneId": "welcome", "name": "参观模式", "steps": [ { "deviceId": "relay_light", "command": "channelOn:1", "waitReplyMs": 200, "delayAfterMs": 800 }, { "deviceId": "projector_main", "command": "powerOn", "waitReplyMs": 500, "delayAfterMs": 5000 }, { "deviceId": "screen_curtain", "command": "down", "waitReplyMs": 300, "delayAfterMs": 3500 }, { "deviceId": "audio_mixer", "command": "unmute", "waitReplyMs": 200, "delayAfterMs": 0 } ] }, { "sceneId": "close", "name": "离馆模式", "steps": [ { "deviceId": "projector_main", "command": "powerOff", "waitReplyMs": 500, "delayAfterMs": 3000 }, { "deviceId": "screen_curtain", "command": "up", "waitReplyMs": 300, "delayAfterMs": 2000 }, { "deviceId": "relay_light", "command": "channelOff:1", "waitReplyMs": 200, "delayAfterMs": 0 } ] } ] }参数说明:
- sceneId 是程序内部引用的索引,name 是面板上显示的中文名。
- steps 按顺序执行,command 必须跟 devices.json 里某台设备的 commands 键对上。程序启动时会做一次配置校验,扫描所有场景步骤引用的 deviceId 和 command,发现缺失直接弹窗,避免现场跑一半才发现配错了。
- 这种配置方式的另一个好处:换展厅时只需要改设备 IP 和场景步骤,程序本体不用动。
4.3 现场联调四步走
现场联调我坚持一个顺序,跳过任何一步都会在正式运行时翻车:
- 先用 TCP 调试助手类工具直连每台设备,把 devices.json 里要用的指令逐条验证一遍。这一步确认的是设备真实协议行为,很多厂商文档写的指令和实物并不一致。
- 改好 devices.json 后,用软件自带的 DeviceTestForm 单设备调试窗口再测一遍,确认程序发的字节和调试助手发的完全一样。这一步能筛掉编码错误、大小写问题、漏了 \r\n 之类的低级坑。
- 接着跑场景,重点观察每步之间的实际耗时。投影仪从收到指令到画面稳定需要多久、幕布从下降到到位需要多久,把这些实测值填进 delayAfterMs。
- 最后做破坏性测试:拔掉一台设备的网线再插回,验证掉线重连逻辑;连续快点两个场景,验证场景锁不会把设备搞乱。
联调期间给每台设备做一张地址规划表,记 deviceId、IP 端口、安装位置、串口服务器映射的串口号。这张表在现场排查问题时比代码还有用,我一般打印一份贴机柜里。
5. 避坑手册:展厅现场最容易翻车的五个问题
5.1 粘包和半包导致指令丢失
现象:继电器偶尔有一两个通道不动作,灯不亮,重发一次又好了。
原因:TCP 是流式协议,多个小报文可能被合并成一次发送(粘包),也可能被拆成两次到达(半包)。串口服务器在 TCP 转 RS-232 时还会按自己的缓冲做二次合并,问题就暴露在继电器这种对时序敏感的设备上。
解决:应答读取时按配置的帧长截取,不依赖流是否读完;对继电器这类指令天然幂等的设备,每条指令间隔 50 毫秒连发两次,即使第一次丢了第二次也能补上。
5.2 投影仪 TCP 连接频繁掉线
现象:投影仪控制一两次之后失效,界面显示离线,重启软件又好了。
原因:部分投影仪的 LAN 控制模块会主动踢掉空闲连接,甚至同一 IP 只允许建立一个连接。如果程序用 keepAlive 长连接,闲置几分钟就被设备断开,而 TcpClient.Connected 不会立刻感知。
解决:把投影仪这类设备的 keepAlive 设为 false,每次发指令新建连接、发完即关;如果实在要长连接,就每 30 秒发一条状态查询当心跳,防止被设备踢。
5.3 场景执行卡死,第二台设备迟迟不动
现象:点「参观模式」,第一台设备动了,第二台开始界面就转圈,整个软件像死了一样。
原因:老写法里用阻塞式等待等设备应答,而某台设备根本没连接,ConnectAsync 又没设超时,卡在连接上。
解决:所有 IO 走异步加超时,ConnectionPool 里拿不到连接时返回错误并记录日志,SceneEngine 捕获后继续执行后续步骤。
注意:场景执行的原则是单步失败可以,场景不能卡死。
5.4 串口服务器默认 IP 与现场冲突
现象:设备都装完了,某天灯光突然自己乱动作,像被人远程控制了一样。
原因:现场多台串口服务器出厂默认 IP 相同,安装时没逐一改成规划地址;后续接上网关或开启 DHCP 后,地址冲突导致数据串到了别的设备上。
解决:装一台改一台,改完立刻把 IP、MAC、安装位置写进地址规划表并贴在机柜里。接串口服务器前先断开展厅业务网,改好静态 IP 再上架。
5.5 多线程并发发送导致指令乱序
现象:同时点了「参观模式」和「离馆模式」,灯光和投影的动作交叉执行,展厅变成灯光秀现场。
原因:两个场景线程同时在跑,各自直接往设备 TcpClient 写数据,没有统一的锁;某些步骤还有应答等待,时序全乱。
解决:SceneEngine 加场景级锁保证同一时刻只有一个场景在执行;DeviceConnection 的发送锁保证同一设备上的指令按提交顺序发出;UI 上执行期间禁用其他场景按钮。三层防住,现场才不会乱。
6. 二次开发技巧:把固定场景改造成配置驱动与扩展点
6.1 给场景步骤加条件分支
当前 SceneEngine 是无条件顺序执行,实际展厅里常有「如果回读状态不是开机,才发开机指令」这种需求。我在 StepConfig 里加了几段配置,让步骤支持简单条件:
// StepConfig 增加: // public string ConditionDevice { get; set; } // 回读哪台设备 // public string ConditionMatch { get; set; } // 期望的回读关键字 if (!string.IsNullOrEmpty(step.ConditionDevice)) { var status = await _pool.Get(step.ConditionDevice) .SendAsync(queryFrame, 600, ct); if (status == null || !Encoding.ASCII.GetString(status).Contains(step.ConditionMatch)) { Log.Warn($"条件不满足,跳过步骤: {step.Command}"); continue; } }改造后,投影仪已经开机时就不会重复发开机指令,避免部分设备对重复指令有副作用。条件回读的窗口时间建议不小于 500 毫秒,否则容易误判。
6.2 加定时任务与无人值守模式
展厅日常运营需要开馆自动开设备、闭馆自动关设备。我在 Program.cs 里挂一个 1 分钟粒度的 Timer,到点触发对应场景:
var timer = new System.Threading.Timer(async _ => { var now = DateTime.Now; if (now.TimeOfDay >= openTime && now.TimeOfDay < openTime.Add(TimeSpan.FromMinutes(5))) await _sceneEngine.ExecuteAsync(GetScene("open"), CancellationToken.None); }, null, TimeSpan.Zero, TimeSpan.FromMinutes(1));定时任务的坑在于无人值守时没人处理异常,所以场景里每个步骤都必须有超时和日志,单步失败不能阻塞整条链路。我一般还会让定时场景先检查设备在线率,离线设备超过一半就直接记日志告警,不盲跑。
6.3 统一日志,给现场留一条退路
最后一项改造是日志。现场排查问题时,最值钱的信息就是「这条指令到底发没发、设备回了什么」。Log 类按天滚动写文件,每次场景执行记录每步耗时,每次指令收发记录十六进制原文,再在 App.config 加一个 Debug 开关,只有开启时才记录收发明细,避免平时刷爆日志文件。
这套架构我跟过三个展厅项目后最大的教训是:永远不要相信设备文档和实物行为一致,文档说支持网络控制的设备,到了现场可能只认某一版固件的指令格式。从那以后我每次进场联调,都强制走一遍「先用 TCP 工具验证指令、再进单设备调试、最后跑整场景」的顺序,调试顺序比代码本身更值得遵守。希望帮到你。
本文还有配套的精品资源,点击获取