.NET 10 + WPF + Prism 封箱机上位机实战:串口指令应答模型是整个系统的灵魂

🧭 前言

工业上位机这个领域,跑起来容易,跑得稳难。设备指令一发出去,应答什么时候回来、会不会不回来、回来的是不是你等的这条,全都要兜住;再叠加故障码解析、标签打印、参数下发这些活,代码很容易写成一团乱麻。下面这套东西换个活法:用 Prism 做应用外壳把页面切开,用统一的指令应答模型把串口通信的"下发-应答-超时"链路全部收口,界面只负责看,不负责想。它是真实交付到电商仓库产线的商业版本,不是纸面架构。

📦 项目介绍

一套面向自动封箱设备的 WPF 上位机,运行时是 .NET 10,界面走 WPF + Prism 应用外壳,通信侧接下位机控制板(串口指令集),打印侧直发 TSC 指令驱动标签打印机,数据落 MySQL(阿里云 RDS),多台设备共享订单与报表。

设备完整动作链路:纸箱进入流水线后,自动完成取箱 → 开箱 → 装货 → 锁箱封箱 → 打印并贴面单小票。上位机的职责是把操作工在屏幕上的点击翻译成下位机能执行的串口指令,再把下位机的执行结果翻译回人话显示在界面上。

操作工点"锁箱" → 上位机封装指令 → 串口下发 → 下位机驱动电机执行
     ↑                                              |
     └── UI 弹出结果/故障提示 ← 故障码解析 ← 应答帧返回

⚙️ 项目功能

功能 说明
动作指令下发 取箱、开箱、锁箱、取票、贴票、余票读取、状态读取,全部走统一指令门面
参数管理 箱规参数(11 种纸箱规格)Excel 批量导入;脉冲当量、速度、尺寸等参数下发前回读比对
故障码解析 32 位故障码低位语义码 + 高位位标志叠加解析,翻译成操作工能看懂的现场提示
订单调试 单步下发每条指令的调试页,现场排查问题不碰代码
系统监控 设备运行状态、任务执行步骤实时刷新
系统报表 订单与产量数据落云数据库,多设备共享,报表分页查询
标签打印 TSC 指令集直发打印面单小票,模板占位符替换,运营改排版不改代码
版本校验 客户端启动时 MD5 比对下位机固件版本,防止程序与固件不配套

✨ 项目特点

  • 串口指令收口在一个模型里。每条指令带动作 ID 下发,下位机应答原样带回 ID,上位机按 ID 配对——串口上跑十条指令也不会张冠李戴。
  • 超时是正常返回值,不是异常。设备断电、丢包、线缆松动,指令方法最终都会拿到统一的 CmdResult(成功/失败/超时),调用侧不会炸出未处理异常,也不会被吊死。
  • UI 线程零串口感知。所有指令通过 ViewModel 命令发到设备层,应答事件切回 UI 线程刷新,界面冻结这类事故在源头杜绝。
  • 故障码写给操作工看。"气源总气压不足"比 "fault 0x00100000" 对现场有价值一百倍,还要告诉对方去检查气泵。
  • 打印模板化。面单小票是「指令模板 + 占位符」模式,位图元素和文本元素分别处理,换标签排版不动代码。
  • 参数双确认。脉冲当量、速度写错会撞坏机械,下发前回读比对,确认一致才生效。

🧰 项目技术

类别 技术
运行时 .NET 10
UI WPF
应用外壳 Prism(DryIoc 容器)
串口通信 System.IO.Ports + 自研指令应答层
数据持久化 SqlSugar + MySQL(阿里云 RDS,CodeFirst 建表)
Excel 导入 MiniExcel
标签打印 TSC 指令集(Gprinter)
身份校验 MD5(固件/版本一致性)

💻 项目代码

指令应答模型是整个系统的灵魂,核心三个部件:

① 动作 ID 池:给每条指令发一张"号牌"

// 线程安全的任务包:Hashtable.Synchronized 保证多线程收发不串号
TaskActionIDPool = Hashtable.Synchronized(new Hashtable());
RebackCmdPool = new Hashtable();

public ushort GetAnFreeActionID()
{
    // 从动作号池取一个空闲 ID,随指令一起下发
    // 下位机应答时原样带回这个 ID,上位机据此配对
}

每条下发指令携带一个 actionId,下位机应答时原样带回。上位机收到应答帧后按 ID 回查 RebackCmdPool,就知道这条应答对应哪次请求。

② 超时看门狗:不能永远等应答

public void CheckCmdReceiveTimeOut()
{
    while (cancelTokenSource != null && !cancelTokenSource.IsCancellationRequested)
    {
        lock (RebackCmdPool.SyncRoot)
        {
            foreach (ushort key in RebackCmdPool.Keys)
            {
                var model = RebackCmdPool[key] as RebackCmdModel;
                if (model.state == CmdSyncState.WaitReceive)
                {
                    if (model.TimeOut < model.HasWaitTime)
                    {
                        model.state = CmdSyncState.TimeOut;
                        model.resetEvent.Set();   // 超时也要激活,调用方不能被吊死
                    }
                    else
                    {
                        model.UpdateWaitTime(200);
                    }
                }
            }
        }
        Thread.Sleep(200);
    }
}

等待方用 AutoResetEvent 同步阻塞,UI 侧的指令方法最终拿到 CmdResult 才返回;看门狗线程随设备启停(StartDevice 启动、StopDevice 时 Cancel),设备断开后不空转。

③ 指令门面:业务层只管"说人话"

SendCmd_GetBoxCommand(boxCode)            // 取箱
SendCmd_OpenBoxCommand(boxCode)           // 开箱
SendCmd_LockBoxCommand(l, w, h, isPrint)  // 锁箱(可附带是否打印小票)
SendCmd_GetTicketCommand()                // 取票
SendCmd_PasteTicketCommand()              // 贴票
SendCmd_ReadStateCommand()                // 状态读取
SendCmd_ResidueReadCommand()              // 余票读取
SendCmd_ArgsDownCommand(...)              // 参数下发

方法内部统一走「取动作 ID → 组帧 → 发送 → 登记应答池 → 等结果 → 记日志」的固定流程。新增一条设备指令 = DriverManager 加一个方法 + 门面加一行转发,纯机械动作。

④ 应答路由:收到帧后按动作 ID 送回给等它的人

private void RoteCmdReback(byte[] cmdData)
{
    OnSerialMessageReceive?.Invoke(this, cmdData);

    // 上报类指令(箱规上报 B4 / 急停 D0 / 急停释放 D1)不走应答池,直接广播事件
    if ((DiviceType == DiviceTypeEnum.OpenBox && cmdData[3] == 0xB4)
        || cmdData[3] == 0xD0 || cmdData[3] == 0xD1)
    {
        OnReceiveReportedMessage?.Invoke(cmdData, (CmdTypeEnum)cmdData[3]);
    }
    else
    {
        // 帧内第 4~5 字节是动作 ID(大端),据此找到等待者并唤醒
        ushort actionid = (ushort)((cmdData[4] << 8) | cmdData[5]);
        lock (RebackCmdPool.SyncRoot)
        {
            if (RebackCmdPool.ContainsKey(actionid))
            {
                var m = RebackCmdPool[actionid] as RebackCmdModel;
                if (m.state == CmdSyncState.WaitReceive)
                {
                    m.data = cmdData;
                    m.state = CmdSyncState.HasReceiveData;
                    m.resetEvent.Set();
                }
            }
        }
    }
}

路由里有个容易被忽略的细节:主动上报帧和应答帧走两条通道。急停(D0/D1)、箱规上报这类指令没有请求方,属于设备随时可能"喊一嗓子",必须走事件广播给 UI;其余应答帧进池配对。混在一起处理,急停这种高优先级消息就会被指令超时逻辑吃掉。

⑤ 串口组包:设备探测里的完整收帧流程

设备探测(握手)是把整条收发链路看明白的最佳样本——三次重试开串口、按报文长度字段循环收帧、超时兜底:

SerialPort spTemp = new SerialPort(portName, 115200, Parity.None, 8, StopBits.One);
for (int i = 0; i < 3; i++)          // 尝试打开串口三次
{
    if (spTemp.IsOpen) break;
    spTemp.Close(); Thread.Sleep(50); spTemp.Open();
}

spTemp.Write(nameCommand, 0, nameCommand.Length);   // 下发版本查询指令

// 等帧头:最多等 6 秒,主版无响应直接判定失败
while (spTemp.BytesToRead < 2) { /* 超时抛出"主版无响应" */ }

List<byte> recList = new List<byte>();
byte[] recData = new byte[spTemp.BytesToRead];
spTemp.Read(recData, 0, recData.Length);
recList.AddRange(recData);

int length = recData[2] + 4;         // 报文总长度 = 长度字段 + 帧头帧尾校验
while (recList.Count < length)       // 按长度字段循环收帧,杜绝半包
{
    if (spTemp.BytesToRead > 0)
    {
        recData = new byte[spTemp.BytesToRead];
        spTemp.Read(recData, 0, recData.Length);
        recList.AddRange(recData);
    }
    Thread.Sleep(10);
}

// 帧尾校验:0x7b 开头 0x7d 结尾才算完整帧,再取出固件版本号
if (recList[0] == 0x7b && recList[^1] == 0x7d) { /* bordInfo = 版本四字节 */ }

这段代码浓缩了串口编程的三条铁律:打开端口要重试(USB 转串口驱动偶发打开失败)、收帧按长度字段循环读(一次 Read 不能假设拿到整帧)、帧尾特征校验(0x7b...0x7d 不完整就继续等,宁慢勿错)。

故障码解析:位标志要拆开说

下位机故障码是 32 位整数,低位是语义码、高位是位标志(多个报警可能同时置位):

UInt32 fault_code = data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3];

// 低位语义码:当前动作的专属故障
if (fault_code == 2) errorstr += "小票传感器A未检测到故障";

// 高位位标志:全局电机/气源报警,可能叠加出现
if ((fault_code & (0x01 << 16)) != 0) errorstr += "步进-宽度左电机报警";
if ((fault_code & (0x01 << 17)) != 0) errorstr += "步进-宽度右电机报警";
if ((fault_code & (0x01 << 18)) != 0) errorstr += "步进-高度电机报警";
if ((fault_code & (0x01 << 19)) != 0) errorstr += "步进-推箱电机复位报警";
if ((fault_code & (0x01 << 20)) != 0) errorstr += "气源总气压不足";

注意两点:同一条指令在不同设备型号上故障语义不同,解析必须带设备类型分支;提示文本要写给操作工看,甚至要告诉对方下一步去检查什么。

打印:模板占位符 + TSC 指令直发

TscHelper.openport("Gprinter GP-3150TN");
TscHelper.clearbuffer();
foreach (string item in print_Template.ValueJson)
{
    if (item.StartsWith("BITMAP"))
    {
        // 模板里的位图元素:读 bmp → 转 TSC BITMAP 指令 → 发二进制数据
        var image = ProcessBmpImage(...);
        TscHelper.sendcommand($"BITMAP {x},{y},{width/8},{height},0,");
        TscHelper.sendBinaryData(image.Item1, image.Item1.Length);
    }
    else
    {
        // 文本元素:占位符替换(订单号/随机码/箱码)
        TscHelper.sendcommand(item
            .Replace("{OrderNo}", dic["OrderNo"])
            .Replace("{Random}", dic["Random"])
            .Replace("{boxCode}", dic["boxCode"]) + "\r\n");
    }
}
TscHelper.closeport();

🖥️ 项目效果

封箱机上位机-系统日志界面

启动后是单窗口外壳,顶部页签切换系统任务、系统日志、报表、指令调试、参数设置、关于六个页面。系统日志页是一块控制台风格的实时终端,设备握手、指令收发、动作步骤推送全部在这里滚动输出——上图就是程序启动后打印的版本横幅。

封箱机上位机-系统任务界面

系统任务页是操作工每天面对的主界面:左侧按钮列控制单条任务的开始、暂停、重复、增删,右侧是任务队列,蓝色高亮条表示当前正在执行的那一条,底部实时显示「当日已封箱」计数与累计运行时长。

产线上的完整闭环:操作工选箱规下单 → 设备自动取箱开箱装货 → 锁箱的同时打印面单小票 → 贴票完成 → 订单与产量数据实时上报云数据库。

调试阶段的价值在「指令调试页」:工程师可以单步下发取箱、开箱、取票、贴票每一条指令,配合系统日志的动作步骤推送,现场任何一步卡住都能快速定位是机械问题、气压问题还是通信问题。这套程序已在客户产线稳定运行,换设备型号基本只需要换指令表和故障码表。

📝 总结

项目最值得看的不在架构图,而在串口链路上踩过的坑:

  1. 串口粘包/半包:不能假设一次 Read 拿到一整帧,必须按帧头帧尾自行组包,宁可慢不能错;
  2. Hashtable 必须用 Hashtable.Synchronized:收发是两个线程,动作 ID 池不做线程安全会出现"应答配对错乱"这种极难复现的 Bug;
  3. UI 线程不碰串口:指令走 ViewModel 命令下发,应答事件切回 Dispatcher 刷新,否则偶发界面冻结;
  4. 超时要激活等待方:看门狗判定超时后必须 Set() 唤醒阻塞的调用方,否则一次丢包就永久挂起一条业务;
  5. 参数下发双确认:脉冲当量写错会撞坏机械,回读比对一致才生效;
  6. 版本校验内置:客户端与下位机固件 MD5 比对,杜绝现场"程序和固件不配套"这种说不清的故障。

工业上位机的难点从来不是炫技的技术点,而是把"下发-执行-应答-超时-报错"这条链路做到每一环都可控。动作 ID 池 + 应答超时看门狗这套模型,换任何设备都能直接复用——架构可以参考,踩坑记录更值得抄。

posted @ 2026-11-12 15:21  源动力技术栈  阅读(43)  评论(0)    收藏  举报