STM32从零到量产开发:四路继电器工业控制模块开发 -上位机主窗口设计说明
1. 模块概述
MainForm 是 P02HostPC 命名空间下的核心上位机主窗口模块,专为 P02 4通道继电器控制器设计。该模块面向工业控制场景,提供了完整的设备交互、状态监控与参数配置能力。
核心能力列表
- 继电器控制:支持4路继电器独立 ON/OFF 切换、全开/全关批量操作,以及基于位掩码(0-15)的批量控制。
- 状态监控:实时显示4路数字量输入(DI)LED状态,并支持只读查询 INFO、PARAM、STATS、FS(故障安全)等系统信息。
- 参数配置:支持修改从机地址(ADDR)、波特率(BAUD)、故障安全使能(FS_EN)及超时时间(FS_SEC),并支持恢复出厂设置。
- 通信保障:内置定时轮询机制自动刷新面板,集成连接看门狗与自动重连机制,确保工业现场通信稳定性。
- 调试与测试 :提供带时间戳的 TX/RX 日志终端、命令行透传功能,以及支持无头环境(Headless)的自助联调模式(
--selftest)。
2. 架构设计
数据流与线程模型
本模块采用异步事件驱动架构,严格分离 UI 线程与后台通信线程,确保界面响应流畅。
数据流路径:
按钮点击 → _comm.Enqueue(cmd, OnXxx) → 后台线程收响应 → OnXxx解析(Protocol.TryParse*) → SafeUI更新控件 + 日志
线程模型:
- UI 线程:负责所有控件的创建、布局与展示更新
- 后台读线程:SerialComm 内部线程负责串口数据接收,触发响应回调
- SafeUI 机制 :所有回调中的 UI 更新经
Invoke切回 UI 线程执行,杜绝跨线程异常
3. UI布局设计
界面采用纯代码构建(DesignerCategory("Code")),非 WinForms Designer 生成,布局结构清晰,按功能划分为以下区域:
| 区域名称 | 包含控件 | 功能说明 |
|---|---|---|
| 顶部连接栏 | 端口下拉框、波特率下拉框、连接/断开按钮、看门狗状态标签 | 串口连接管理及通信状态指示 |
| 继电器控制区 | CH1~CH4 按钮、全开/全断按钮、位掩码输入框+发送按钮 | 继电器状态翻转与批量控制 |
| DI指示区 | DI1~DI4 指示灯(Label)、状态文本(UPTIME/RESETS) | 数字量输入状态可视化及系统运行状态 |
| 只读信息区 | INFO/PARAM/STATS/FS 查询按钮、只读多行文本框 | 设备固件、参数、统计及故障安全信息查询 |
| 配置写Flash区 | ADDR/BAUD/FS_EN/FS_SEC 输入控件、应用/恢复出厂/PING按钮 | 设备参数持久化配置及连通性测试 |
| 日志终端区 | 只读多行文本框 (Consolas 9pt) | 实时显示带时间戳的收发报文 |
| 底部命令行/轮询 | 命令行文本框+发送按钮、轮询间隔数字框、自动轮询复选框 | 自定义指令透传及 STATUS 自动刷新配置 |
窗口规格: 760×640 像素,最小尺寸 780×680,固定对话框样式,居中显示。
4. 字段与状态管理
| 字段名称 | 类型 | 说明 |
|---|---|---|
_comm |
SerialComm |
串口通信管理器,负责底层收发与命令队列 |
_relayBtns |
Button[4] |
4路继电器 UI 按钮引用(索引 0...3 对应 CH1...CH4) |
_diLeds |
Label[4] |
4路 DI 指示灯 UI 引用 |
_relayState |
bool[4] |
继电器本地状态缓存,采用乐观更新,由 STATUS 轮询校正 |
_pollTimer |
Timer |
定时轮询 STATUS 命令的定时器(UI 线程触发) |
_watchdog |
Timer |
连接看门狗定时器,检测超时与触发重连 |
_lastResponse |
DateTime |
最后收到有效响应的时间戳 |
_lastPort |
string |
上次连接的端口名(用于自动重连) |
_lastBaud |
int |
上次连接的波特率 |
_watchdogRecovering |
bool |
防重连风暴标志,避免高频重连尝试 |
5. 核心功能设计
5.1 继电器控制
单路控制(OnRelayToggle):
- 检查连接状态,未连接则直接返回
- 翻转本地态
_relayState[idx] - 构建命令
Protocol.BuildRelayOn(ch)或Protocol.BuildRelayOff(ch) _comm.Enqueue(cmd, callback)入队发送- 回调中解析响应,成功则更新按钮 UI(文本+配色),失败则记录错误日志
全开/全关(BtnAllOn / BtnAllOff):
- 逐路(CH1~CH4)独立发送命令,每路独立回调更新本地态+按钮
- 确保 CH4 受控(最后一个发送,回调最后执行)
位掩码批量控制(BtnMaskSend_Click):
- 支持十进制(0-15)和十六进制(0x0-0xF)输入
TryParseMask解析并校验范围,非法输入弹出警告
5.2 DI指示与状态显示
UpdateDiLed动态切换 Label 背景色:高电平=浅绿(LightGreen),低电平=系统色(灰色)_lblStatus文本格式:状态:UPTIME {秒}s RESETS {次数},由 OnStatus 回调刷新
5.3 只读信息查询
| 按钮 | 命令 | 解析回调 | 显示内容 |
|---|---|---|---|
| INFO | Protocol.BuildInfo() |
OnInfo |
固件版本、MCU型号、运行秒数、复位次数、帧数、CRC错误数、Flash魔术字、Flash CRC |
| PARAM | Protocol.BuildParam() |
OnParam |
从机地址、波特率索引、波特率文本、通信使能 |
| STATS | Protocol.BuildStats() |
OnStats |
帧数、CRC错误、异常数、各继电器动作次数、寿命告警掩码、运行秒、运行小时 |
| FS | Protocol.BuildFailSafeQuery() |
OnFailSafe |
故障安全使能、超时时间、激活状态 |
5.4 Flash配置
参数应用(BtnApply_Click):
分批发送独立命令,各参数互不影响:
| 参数 | 范围 | 命令构建 |
|---|---|---|
| ADDR | 1~247 | Protocol.BuildAddr(addr) |
| BAUD | 0~4 | Protocol.BuildBaud(idx) |
| FS_EN | 0/1 | Protocol.BuildFailSafeEn(en) |
| FS_SEC | 1~254 | Protocol.BuildFailSafeSec(sec) |
恢复出厂(BtnFactory_Click):
- 弹出二次确认 MessageBox(不可逆操作警告)
- 用户确认后才发送
Protocol.BuildFactory()命令
5.5 日志终端
Log方法写入带完整时间戳(yyyy-MM-dd HH:mm:ss.fff)的报文- TX 日志前缀
TX>,RX 日志前缀RX< - 自动滚动到底部(
ScrollToCaret) - 日志框使用 Consolas 9pt 等宽字体
5.6 命令行透传
_txtCmd文本框支持输入任意命令,点击发送或按 Enter 键发送- 通过
Protocol.BuildRaw(cmd)构建原始命令透传给下位机
5.7 定时轮询
PollTimer_Tick:连接且_chkPoll.Checked为真时,按设定间隔(1~10秒,默认1秒)周期性发送 STATUS 命令- 轮询间隔通过
_numPoll数字框实时调整(ValueChanged事件)
6. 通信与线程模型
6.1 串口通信流程
UI线程 后台读线程
│ │
├─ 按钮点击 ──────────────────► │ _comm.Enqueue(cmd, callback)
│ │
│ ├─ 串口接收数据
│ ├─ Protocol.TryParse* 解析
│ ├─ 触发对应回调 OnXxx
│ │
│ ◄──── SafeUI(Invoke) ─────────┤ UI更新 + 日志写入
6.2 SafeUI 线程切换机制
csharp
private void SafeUI(Action action)
{
if (SelfTestHeadless) { action(); return; } // 无头模式同步直执
if (InvokeRequired)
{
Invoke(action); // 跨线程安全调用,切回UI线程
}
else
{
action(); // 已在UI线程,直接执行
}
}
设计要点:
- 所有响应回调(OnStatus、OnInfo 等)运行在后台读线程,UI 更新必须经 SafeUI
- 日志写入
Log()内部也包裹 SafeUI,确保线程安全 - SelfTestHeadless 模式下跳过 Invoke,同步直执,避免无消息泵环境下的死锁
6.3 事件订阅
SubscribeComm() 统一订阅串口通信三层事件:
| 事件 | 处理逻辑 |
|---|---|
CommandSent |
日志输出 TX 报文(前缀 TX> ) |
ResponseReceived |
更新时间戳 _lastResponse、清除恢复标志、更新看门狗UI、日志输出 RX 报文 |
ConnectionLost |
切换断开UI状态、更新看门狗UI、日志记录断线原因 |
7. 看门狗与自动重连
7.1 超时检测逻辑
Watchdog_Tick 每秒执行一次,检测流程:
- 未连接则直接返回
- 计算距上次响应的时间差
elapsed - 超时阈值 =
max(3.0, 轮询周期 × 2.0)------ 自适应轮询周期,避免慢轮询时误判 - 超时则标红看门狗标签,触发重连流程
7.2 重连策略
超时检测 → 设置 _watchdogRecovering = true → 断开连接 → 800ms延迟 → 尝试重连 → 成功则恢复轮询 / 失败则记录日志 → 释放Timer
防重连风暴设计:
_watchdogRecovering标志位:重连进行中禁止再次触发重连800ms延迟:给串口释放和底层驱动清理留出时间- 重连成功后重置
_watchdogRecovering = false和_lastResponse
7.3 状态指示
| 状态 | 标签文字 | 颜色 |
|---|---|---|
| 正常 | ● 正常 |
绿色 |
| 超时 | ● 超时 |
红色 |
| 未连接 | ● 未连接 |
灰色 |
8. 自助联调模式
通过启动参数 --selftest 激活,支持 CI/沙箱等无桌面环境自动化测试。
8.1 模式开关
| 属性 | 说明 |
|---|---|
SelfTestMode |
开关,隐藏窗口、不显示在任务栏 |
SelfTestPort |
目标串口(默认 COM3) |
SelfTestHeadless |
无桌面环境标志,跳过 Application.Run,SafeUI 同步直执 |
8.2 测试流程与断言设计
| 步骤 | 操作 | 断言条件 |
|---|---|---|
| 1. 连接板子 | 自动连接指定串口 @ 115200 | IsConnected 为真 |
| 2. STATUS握手 | 发送 STATUS 查询 | _lblStatus 匹配正则 UPTIME\s+\d+s(排除初始 --s 占位) |
| 3. PING握手 | 发送 PING 命令 | 响应包含 PONG |
| 4. INFO握手 | 发送 INFO 查询 | _txtInfo.Text 包含 FW: |
| 5. RELAY写握手 | 翻转 CH1 | _relayState[0] 与初始态不同 |
| 6. RELAY还原 | 再次翻转 CH1 | _relayState[0] 恢复初始态 |
| 7. STATUS复查 | 发送 STATUS 查询 | 板子回报的 CH1 与界面本地态一致(写-回读闭环) |
8.3 StWait 轮询等待
csharp
private async Task<bool> StWait(Func<bool> cond, int ms)
{
int w = 0;
while (!cond() && w < ms)
{
await Task.Delay(50).ConfigureAwait(false); // 关键:ConfigureAwait(false) 防挂起
w += 50;
}
return cond();
}
设计要点: 每 50ms 检查一次条件,最多等待指定毫秒。使用 ConfigureAwait(false) 确保续体投递到线程池而非 WinForms 同步上下文(无头环境下后者会导致永久挂起)。
8.4 报告输出
测试完成后输出 PASS/FAIL 汇总,写入 selftest_P02_时间戳.txt 报告文件到应用程序目录。
9. 对外依赖
| 依赖类/模块 | 说明 |
|---|---|
SerialComm |
串口通信管理器,提供 Connect/Disconnect/Enqueue/ListPorts 及事件订阅 |
Protocol |
协议解析器,提供 BuildRelayOn/BuildRelayOff/BuildStatus/BuildInfo 等命令构建方法及 TryParseStatus/TryParseInfo 等解析方法 |
System.Windows.Forms |
WinForms UI 框架(Form/Control/Timer/Button/Label/ComboBox/TextBox 等) |
System.IO.Ports |
串口枚举(SerialPort.GetPortNames)与底层通信 |
System.Threading.Tasks |
异步/await 支持(自助联调模式) |
10. 设计总结
| 设计维度 | 具体措施 |
|---|---|
| 纯代码布局 | 摆脱 Designer 依赖,便于版本控制与动态调整,DesignerCategory("Code") 标记 |
| 乐观更新+轮询校正 | 继电器点击即时响应(本地态乐观更新),后台 STATUS 轮询保证状态最终一致性 |
| 防重连风暴机制 | 标志位(_watchdogRecovering)+ 800ms 延迟双重保护,避免工业现场通信抖动导致的资源耗尽 |
| 自适应超时阈值 | 超时阈值 = max(3s, 轮询周期×2),兼容 1s~10s 轮询间隔,不误判 |
| 无头自助联调 | 支持 CI/CD 集成,ConfigureAwait(false) 避免无消息泵死锁,写-回读闭环断言确保协议栈可靠性 |
| 线程安全封装 | SafeUI 统一处理跨线程调用,兼顾 GUI 模式与 Headless 测试模式 |
| 参数范围钳位 | ADDR(1-247)、BAUD(0-4)、FS_SEC(1-254) 均做范围校验,非法值拒绝写入 |
| 恢复出厂保护 | 二次确认 MessageBox,防止误操作导致配置丢失 |
csharp
// ------------------------------------------------------------------------------
// MainForm.cs
// 功能:P02 上位机 WinForms 主窗口 ------ 继电器控制 / DI 指示 / 只读信息 /
// 配置写 Flash / 日志终端 / 定时轮询 STATUS。
// 数据流:按钮 -> _comm.Enqueue(cmd, OnXxx) -> 后台线程收响应 ->
// OnXxx 解析(Protocol.TryParse*) -> SafeUI 更新控件 + 日志。
// 线程:响应回调运行在后台读线程,所有 UI 更新经 SafeUI(Invoke) 切回 UI 线程。
// ------------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace P02HostPC
{
/// <summary>P02 上位机主窗口(手写布局,非设计器生成)。</summary>
[System.ComponentModel.DesignerCategory("Code")]
public partial class MainForm : Form
{
// ============================ 字段 ============================
#region 字段
/// <summary>串口通信管理器。</summary>
private readonly SerialComm _comm = new SerialComm();
/// <summary>4 路继电器按钮(索引 0..3 对应 CH1..CH4)。</summary>
private readonly Button[] _relayBtns = new Button[Protocol.CHANNEL_COUNT];
/// <summary>4 路 DI 指示灯(Label,亮=绿,灭=灰)。</summary>
private readonly Label[] _diLeds = new Label[Protocol.CHANNEL_COUNT];
/// <summary>继电器本地态(点击乐观更新,STATUS 轮询校正)。</summary>
private readonly bool[] _relayState = new bool[Protocol.CHANNEL_COUNT];
// 顶部连接栏
private ComboBox _cboPort;
private ComboBox _cboBaud;
private Button _btnConnect;
private Label _lblWatchdog;
// 继电器区
private Button _btnAllOn;
private Button _btnAllOff;
private TextBox _txtMask;
private Button _btnMaskSend;
// DI / 状态区
private Label _lblStatus;
// 只读信息区
private Button _btnInfo;
private Button _btnParam;
private Button _btnStats;
private Button _btnFs;
private TextBox _txtInfo;
// 配置区
private TextBox _txtAddr;
private ComboBox _cboBaudIdx;
private ComboBox _cboFsEn;
private TextBox _txtFsSec;
private Button _btnApply;
private Button _btnFactory;
private Button _btnPing;
// 日志与命令行
private TextBox _txtLog;
private TextBox _txtCmd;
private Button _btnSendCmd;
private NumericUpDown _numPoll;
private CheckBox _chkPoll;
/// <summary>定时轮询 STATUS 的定时器(UI 线程触发)。</summary>
private readonly Timer _pollTimer = new Timer();
/// <summary>连接看门狗 Timer(检测响应超时)。</summary>
private readonly Timer _watchdog = new Timer();
/// <summary>最后一次收到响应的时间戳。</summary>
private DateTime _lastResponse = DateTime.MinValue;
/// <summary>上次连接的端口名(用于自动重连)。</summary>
private string _lastPort = string.Empty;
/// <summary>上次连接的波特率。</summary>
private int _lastBaud;
/// <summary>看门狗恢复中标志(防重连风暴)。</summary>
private bool _watchdogRecovering;
#endregion
// ============================ 构造函数 ============================
#region 构造函数
/// <summary>初始化窗口、控件、事件订阅。</summary>
public MainForm()
{
BuildLayout();
BindEvents();
SubscribeComm();
// 初始填充端口列表与默认波特
RefreshPorts();
_cboBaud.SelectedItem = "115200";
_pollTimer.Interval = 1000;
_pollTimer.Tick += PollTimer_Tick;
_watchdog.Interval = 1000;
_watchdog.Tick += Watchdog_Tick;
_watchdog.Start();
if (SelfTestMode)
{
this.Opacity = 0;
this.ShowInTaskbar = false;
this.Load += async (s, e) => { await RunSelfTest(); };
}
}
#endregion
// ============================ 控件初始化 ============================
#region 控件初始化
/// <summary>手动创建并布局所有 WinForms 控件(等效于 Designer)。</summary>
private void BuildLayout()
{
this.Text = "P02 4-CH Relay Controller --- 上位机";
this.ClientSize = new Size(760, 640);
this.MinimumSize = new Size(780, 680);
this.FormBorderStyle = FormBorderStyle.FixedDialog;
this.MaximizeBox = false;
this.StartPosition = FormStartPosition.CenterScreen;
// --- 顶部连接栏 ---
Label lblPort = new Label { Text = "端口", Left = 12, Top = 16, Width = 36 };
_cboPort = new ComboBox { Left = 50, Top = 12, Width = 80, DropDownStyle = ComboBoxStyle.DropDownList };
Label lblBaud = new Label { Text = "波特", Left = 140, Top = 16, Width = 36 };
_cboBaud = new ComboBox { Left = 180, Top = 12, Width = 80, DropDownStyle = ComboBoxStyle.DropDownList };
_cboBaud.Items.AddRange(new object[] { "9600", "19200", "38400", "57600", "115200" });
_btnConnect = new Button { Text = "连接", Left = 270, Top = 11, Width = 80, Height = 26 };
_lblWatchdog = new Label { Text = "● 未连接", Left = 360, Top = 14, Width = 120,
ForeColor = Color.Gray, AutoSize = true };
// --- 继电器控制 ---
GroupBox gbRelay = new GroupBox { Text = "继电器控制", Left = 12, Top = 48, Width = 360, Height = 150 };
for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
{
int idx = i;
_relayBtns[i] = new Button
{
Text = $"CH{i + 1} OFF",
Left = 20 + i * 82,
Top = 22,
Width = 76,
Height = 34,
BackColor = SystemColors.Control,
Tag = idx
};
gbRelay.Controls.Add(_relayBtns[i]);
}
_btnAllOn = new Button { Text = "全开", Left = 20, Top = 70, Width = 80, Height = 28 };
_btnAllOff = new Button { Text = "全断", Left = 110, Top = 70, Width = 80, Height = 28 };
Label lblMask = new Label { Text = "位掩码", Left = 20, Top = 108, Width = 48 };
_txtMask = new TextBox { Left = 72, Top = 104, Width = 60, Height = 23 };
_btnMaskSend = new Button { Text = "发送", Left = 140, Top = 103, Width = 80, Height = 26 };
gbRelay.Controls.AddRange(new Control[] { _btnAllOn, _btnAllOff, lblMask, _txtMask, _btnMaskSend });
// --- DI / 状态 ---
GroupBox gbDi = new GroupBox { Text = "数字量输入 DI", Left = 384, Top = 48, Width = 360, Height = 150 };
for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
{
_diLeds[i] = new Label
{
Text = $"DI{i + 1}",
Left = 20 + i * 82,
Top = 24,
Width = 70,
Height = 40,
BackColor = SystemColors.Control,
TextAlign = ContentAlignment.MiddleCenter,
BorderStyle = BorderStyle.FixedSingle
};
gbDi.Controls.Add(_diLeds[i]);
}
_lblStatus = new Label { Text = "状态:UPTIME --s RESETS --", Left = 20, Top = 110, Width = 320 };
gbDi.Controls.Add(_lblStatus);
// --- 只读信息 ---
GroupBox gbInfo = new GroupBox { Text = "只读信息", Left = 12, Top = 206, Width = 360, Height = 180 };
_btnInfo = new Button { Text = "INFO", Left = 20, Top = 22, Width = 76, Height = 28 };
_btnParam = new Button { Text = "PARAM", Left = 102, Top = 22, Width = 76, Height = 28 };
_btnStats = new Button { Text = "STATS", Left = 184, Top = 22, Width = 76, Height = 28 };
_btnFs = new Button { Text = "FS", Left = 266, Top = 22, Width = 76, Height = 28 };
_txtInfo = new TextBox
{
Left = 20,
Top = 58,
Width = 322,
Height = 110,
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical
};
gbInfo.Controls.AddRange(new Control[] { _btnInfo, _btnParam, _btnStats, _btnFs, _txtInfo });
// --- 配置(写 Flash) ---
GroupBox gbConfig = new GroupBox { Text = "配置(写 Flash)", Left = 384, Top = 206, Width = 360, Height = 180 };
Label lblAddr = new Label { Text = "ADDR", Left = 20, Top = 26, Width = 44 };
_txtAddr = new TextBox { Left = 64, Top = 22, Width = 46, Height = 23 };
Label lblBaud2 = new Label { Text = "BAUD", Left = 120, Top = 26, Width = 44 };
_cboBaudIdx = new ComboBox { Left = 164, Top = 22, Width = 70, DropDownStyle = ComboBoxStyle.DropDownList };
_cboBaudIdx.Items.AddRange(new object[] { "0(9600)", "1(19200)", "2(38400)", "3(57600)", "4(115200)" });
_btnApply = new Button { Text = "应用", Left = 244, Top = 21, Width = 80, Height = 26 };
Label lblFsEn = new Label { Text = "FS_EN", Left = 20, Top = 60, Width = 48 };
_cboFsEn = new ComboBox { Left = 70, Top = 56, Width = 50, DropDownStyle = ComboBoxStyle.DropDownList };
_cboFsEn.Items.AddRange(new object[] { "0", "1" });
Label lblFsSec = new Label { Text = "FS_SEC", Left = 130, Top = 60, Width = 52 };
_txtFsSec = new TextBox { Left = 184, Top = 56, Width = 46, Height = 23 };
_btnFactory = new Button { Text = "恢复出厂", Left = 244, Top = 55, Width = 80, Height = 26 };
_btnPing = new Button { Text = "PING", Left = 20, Top = 92, Width = 80, Height = 28 };
gbConfig.Controls.AddRange(new Control[]
{
lblAddr, _txtAddr, lblBaud2, _cboBaudIdx, _btnApply,
lblFsEn, _cboFsEn, lblFsSec, _txtFsSec, _btnFactory, _btnPing
});
// --- 日志终端 ---
GroupBox gbLog = new GroupBox { Text = "日志终端", Left = 12, Top = 392, Width = 732, Height = 170 };
_txtLog = new TextBox
{
Left = 16,
Top = 20,
Width = 700,
Height = 138,
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
Font = new Font("Consolas", 9f)
};
gbLog.Controls.Add(_txtLog);
// --- 底部命令行 / 轮询 ---
Label lblCmd = new Label { Text = "命令行", Left = 12, Top = 578, Width = 48 };
_txtCmd = new TextBox { Left = 64, Top = 574, Width = 380, Height = 23 };
_btnSendCmd = new Button { Text = "发送", Left = 452, Top = 573, Width = 70, Height = 26 };
Label lblPoll = new Label { Text = "轮询", Left = 540, Top = 578, Width = 36 };
_numPoll = new NumericUpDown { Left = 580, Top = 575, Width = 46, Minimum = 1, Maximum = 10, Value = 1 };
_chkPoll = new CheckBox { Text = "自动", Left = 634, Top = 575, Width = 80, Checked = true };
// --- 汇总加入窗体 ---
this.Controls.AddRange(new Control[]
{
lblPort, _cboPort, lblBaud, _cboBaud, _btnConnect,
_lblWatchdog,
gbRelay, gbDi, gbInfo, gbConfig, gbLog,
lblCmd, _txtCmd, _btnSendCmd, lblPoll, _numPoll, _chkPoll
});
}
#endregion
// ============================ 事件绑定 ============================
#region 事件绑定
/// <summary>绑定所有按钮/控件事件。</summary>
private void BindEvents()
{
_btnConnect.Click += BtnConnect_Click;
for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
{
int ch = i + 1;
_relayBtns[i].Click += (s, e) => OnRelayToggle(ch);
}
_btnAllOn.Click += (s, e) =>
{
if (!_comm.IsConnected) return;
// 逐路发送 RELAY n ON(含回调握手更新本地态+按钮),确保 CH4 受控
for (int ch = 1; ch <= Protocol.CHANNEL_COUNT; ch++)
{
int idx = ch - 1;
_comm.Enqueue(Protocol.BuildRelayOn(ch), (raw) =>
{
if (Protocol.TryParseCommandResult(raw, out var r) && r.Success)
{
_relayState[idx] = true;
SafeUI(() => UpdateRelayButton(idx));
}
});
}
};
_btnAllOff.Click += (s, e) =>
{
if (!_comm.IsConnected) return;
// 逐路发送 RELAY n OFF(含回调握手更新本地态+按钮),确保 CH4 受控
for (int ch = 1; ch <= Protocol.CHANNEL_COUNT; ch++)
{
int idx = ch - 1;
_comm.Enqueue(Protocol.BuildRelayOff(ch), (raw) =>
{
if (Protocol.TryParseCommandResult(raw, out var r) && r.Success)
{
_relayState[idx] = false;
SafeUI(() => UpdateRelayButton(idx));
}
});
}
};
_btnMaskSend.Click += BtnMaskSend_Click;
_btnInfo.Click += (s, e) => _comm.Enqueue(Protocol.BuildInfo(), OnInfo);
_btnParam.Click += (s, e) => _comm.Enqueue(Protocol.BuildParam(), OnParam);
_btnStats.Click += (s, e) => _comm.Enqueue(Protocol.BuildStats(), OnStats);
_btnFs.Click += (s, e) => _comm.Enqueue(Protocol.BuildFailSafeQuery(), OnFailSafe);
_btnApply.Click += BtnApply_Click;
_btnFactory.Click += BtnFactory_Click;
_btnPing.Click += (s, e) => _comm.Enqueue(Protocol.BuildPing(), OnPing);
_btnSendCmd.Click += (s, e) => BtnSendCmd_Click();
_txtCmd.KeyDown += (s, e) =>
{
if (e.KeyCode == Keys.Enter)
{
BtnSendCmd_Click();
e.SuppressKeyPress = true;
}
};
_numPoll.ValueChanged += (s, e) => _pollTimer.Interval = (int)(_numPoll.Value * 1000);
}
/// <summary>订阅串口通信事件(日志 + 断线处理)。</summary>
private void SubscribeComm()
{
_comm.CommandSent += (cmd) => Log("TX> " + cmd);
_comm.ResponseReceived += (raw) =>
{
_lastResponse = DateTime.Now;
_watchdogRecovering = false;
SafeUI(() => UpdateWatchdogUi(true));
Log("RX< " + raw.Replace("\r\n", " | ").Replace("\n", " | "));
};
_comm.ConnectionLost += (msg) =>
{
SafeUI(() =>
{
SetConnectedUi(false);
UpdateWatchdogUi(false);
Log("[!] 连接丢失:" + msg);
});
};
}
#endregion
// ============================ 连接 ============================
#region 连接
/// <summary>连接/断开按钮。</summary>
private void BtnConnect_Click(object sender, EventArgs e)
{
if (_comm.IsConnected)
{
_comm.Disconnect();
SetConnectedUi(false);
UpdateWatchdogUi(false);
Log("[*] 已断开");
return;
}
string port = _cboPort.Text;
if (string.IsNullOrEmpty(port))
{
MessageBox.Show("请先选择串口", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
int baud = int.Parse(_cboBaud.Text, System.Globalization.CultureInfo.InvariantCulture);
bool ok = _comm.Connect(port, baud);
if (!ok)
{
MessageBox.Show($"无法打开 {port}(可能被占用或不存在)", "连接失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
SetConnectedUi(true);
_lastPort = port;
_lastBaud = baud;
_lastResponse = DateTime.Now;
UpdateWatchdogUi(true);
Log($"[*] 已连接 {port} @ {baud}");
// 连上先查一次状态
_comm.Enqueue(Protocol.BuildStatus(), OnStatus);
}
/// <summary>根据连接状态切换 UI(启用/禁用控件、按钮文案)。</summary>
private void SetConnectedUi(bool connected)
{
_btnConnect.Text = connected ? "断开" : "连接";
// 连接相关控件仅在连接后可用
foreach (Control c in new Control[]
{
_btnAllOn, _btnAllOff, _btnMaskSend, _btnInfo, _btnParam,
_btnStats, _btnFs, _btnApply, _btnFactory, _btnPing, _btnSendCmd
})
{
c.Enabled = connected;
}
for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
{
_relayBtns[i].Enabled = connected;
}
if (connected)
{
_pollTimer.Start();
}
else
{
_pollTimer.Stop();
}
}
/// <summary>刷新可用串口下拉列表。</summary>
private void RefreshPorts()
{
_cboPort.Items.Clear();
foreach (string p in SerialComm.ListPorts())
{
_cboPort.Items.Add(p);
}
if (_cboPort.Items.Count > 0)
{
_cboPort.SelectedIndex = 0;
}
}
#endregion
// ============================ 继电器控制 ============================
#region 继电器控制
/// <summary>单路继电器点击:翻转本地态并发送 ON/OFF。</summary>
private void OnRelayToggle(int ch)
{
if (!_comm.IsConnected)
{
return;
}
int idx = ch - 1;
bool turnOn = !_relayState[idx];
string cmd = turnOn ? Protocol.BuildRelayOn(ch) : Protocol.BuildRelayOff(ch);
int localIdx = idx;
_comm.Enqueue(cmd, (raw) =>
{
if (Protocol.TryParseCommandResult(raw, out var r) && r.Success)
{
_relayState[localIdx] = turnOn;
SafeUI(() => UpdateRelayButton(localIdx));
}
else
{
Log($"[ERR] CH{ch}: {r.Message}");
}
});
}
/// <summary>位掩码批量发送:本地校验 0-15 / 0x 前缀。</summary>
private void BtnMaskSend_Click(object sender, EventArgs e)
{
if (!_comm.IsConnected)
{
return;
}
string txt = _txtMask.Text.Trim();
if (!TryParseMask(txt, out int mask))
{
MessageBox.Show("位掩码须为 0-15 或 0x0-0xF", "输入错误", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
_comm.Enqueue(Protocol.BuildRelayAll(mask));
}
/// <summary>解析位掩码(支持十进制与 0x 十六进制)。</summary>
private static bool TryParseMask(string txt, out int mask)
{
mask = 0;
if (string.IsNullOrEmpty(txt))
{
return false;
}
string s = txt;
int @base = 10;
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
@base = 16;
s = s.Substring(2);
}
if (!int.TryParse(s, @base == 16 ? System.Globalization.NumberStyles.HexNumber : System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out mask))
{
return false;
}
return mask >= 0 && mask <= 0x0F;
}
#endregion
// ============================ 配置写 Flash ============================
#region 配置写 Flash
/// <summary>应用 ADDR / BAUD / FS_EN / FS_SEC 配置(分批发送)。</summary>
private void BtnApply_Click(object sender, EventArgs e)
{
if (!_comm.IsConnected)
{
return;
}
if (!string.IsNullOrWhiteSpace(_txtAddr.Text))
{
if (int.TryParse(_txtAddr.Text.Trim(), out int addr) && addr >= 1 && addr <= 247)
{
_comm.Enqueue(Protocol.BuildAddr(addr), OnSimpleResult);
}
else
{
Log("[ERR] ADDR 范围 1-247");
}
}
if (_cboBaudIdx.SelectedItem != null)
{
int idx = int.Parse(_cboBaudIdx.SelectedItem.ToString().Substring(0, 1), System.Globalization.CultureInfo.InvariantCulture);
_comm.Enqueue(Protocol.BuildBaud(idx), OnSimpleResult);
}
if (_cboFsEn.SelectedItem != null)
{
int en = int.Parse(_cboFsEn.SelectedItem.ToString(), System.Globalization.CultureInfo.InvariantCulture);
_comm.Enqueue(Protocol.BuildFailSafeEn(en), OnSimpleResult);
}
if (!string.IsNullOrWhiteSpace(_txtFsSec.Text))
{
if (int.TryParse(_txtFsSec.Text.Trim(), out int sec) && sec >= 1 && sec <= 254)
{
_comm.Enqueue(Protocol.BuildFailSafeSec(sec), OnSimpleResult);
}
else
{
Log("[ERR] FS_SEC 范围 1-254");
}
}
}
/// <summary>恢复出厂:二次确认(不可逆)。</summary>
private void BtnFactory_Click(object sender, EventArgs e)
{
if (!_comm.IsConnected)
{
return;
}
DialogResult dr = MessageBox.Show(
"将恢复出厂参数:Modbus 地址 / 波特 / 故障安全将被重置。\n确定继续?",
"恢复出厂确认",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning);
if (dr == DialogResult.Yes)
{
_comm.Enqueue(Protocol.BuildFactory(), OnSimpleResult);
}
}
/// <summary>命令行透传发送。</summary>
private void BtnSendCmd_Click()
{
if (!_comm.IsConnected)
{
return;
}
string cmd = _txtCmd.Text.Trim();
if (string.IsNullOrEmpty(cmd))
{
return;
}
_comm.Enqueue(Protocol.BuildRaw(cmd));
_txtCmd.Clear();
}
#endregion
// ============================ 响应回调 ============================
#region 响应回调(后台线程执行,UI 更新须 SafeUI)
/// <summary>STATUS 响应:刷新继电器按钮 + DI 指示灯 + 状态文本。</summary>
private void OnStatus(string raw)
{
if (!Protocol.TryParseStatus(raw, out var d))
{
return;
}
SafeUI(() =>
{
for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
{
_relayState[i] = d.Relays[i];
UpdateRelayButton(i);
UpdateDiLed(i, d.Di[i]);
}
_lblStatus.Text = $"状态:UPTIME {d.UptimeSec}s RESETS {d.Resets}";
});
}
/// <summary>INFO 响应:填充只读信息框。</summary>
private void OnInfo(string raw)
{
if (Protocol.TryParseInfo(raw, out var d))
{
SafeUI(() => _txtInfo.Text =
$"FW: {d.Fw} MCU: {d.Mcu}\r\n" +
$"Uptime: {d.UptimeSec}s Resets: {d.Resets}\r\n" +
$"Frames: {d.Frames} CRC_Err: {d.CrcErr}\r\n" +
$"Flash magic=0x{d.FlashMagic} crc=0x{d.FlashCrc}");
}
}
/// <summary>PARAM 响应:填充只读信息框。</summary>
private void OnParam(string raw)
{
if (Protocol.TryParseParam(raw, out var d))
{
SafeUI(() => _txtInfo.Text =
$"SlaveAddr={d.SlaveAddr}\r\nBaudIndex={d.BaudIndex}({d.BaudText})\r\nLinkEnable={d.LinkEnable}");
}
}
/// <summary>STATS 响应:填充只读信息框。</summary>
private void OnStats(string raw)
{
if (Protocol.TryParseStats(raw, out var d))
{
SafeUI(() =>
{
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Frames={d.Frames} CRC_Err={d.CrcErr} Excep={d.Excep}");
sb.Append("RelayAct: ");
for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
{
sb.Append($"R{i + 1}={d.RelayAct[i]} ");
}
sb.AppendLine();
sb.AppendLine($"LifeAlarmMask=0x{d.LifeAlarmMask:X2}");
sb.Append($"RunSec={d.RunSec} RunHours={d.RunHours}");
_txtInfo.Text = sb.ToString();
});
}
}
/// <summary>FS 响应:填充只读信息框。</summary>
private void OnFailSafe(string raw)
{
if (Protocol.TryParseFailSafe(raw, out var d))
{
SafeUI(() => _txtInfo.Text =
$"FailSafe en={d.En} timeout={d.TimeoutSec}s active={d.Active}");
}
}
/// <summary>PING 响应:校验 PONG。</summary>
private void OnPing(string raw)
{
bool pong = raw.Contains("PONG");
Log(pong ? "[OK] PING -> PONG (链路正常)" : "[!] PING 未收到 PONG");
}
/// <summary>通用写类命令结果。</summary>
private void OnSimpleResult(string raw)
{
if (Protocol.TryParseCommandResult(raw, out var r))
{
Log(r.Success ? $"[OK] {r.Message}" : $"[ERR] {r.Message}");
}
}
#endregion
// ============================ 定时轮询 ============================
#region 定时轮询
/// <summary>定时器:自动发 STATUS 刷新面板。</summary>
private void PollTimer_Tick(object sender, EventArgs e)
{
if (_comm.IsConnected && _chkPoll.Checked)
{
_comm.Enqueue(Protocol.BuildStatus(), OnStatus);
}
}
#endregion
// ============================ UI 辅助 ============================
#region UI 辅助
/// <summary>线程安全更新 UI(后台线程回调统一经此切回 UI 线程)。</summary>
private void SafeUI(Action action)
{
if (SelfTestHeadless) { action(); return; }
if (InvokeRequired)
{
Invoke(action);
}
else
{
action();
}
}
/// <summary>更新单路继电器按钮文本与配色。</summary>
private void UpdateRelayButton(int idx)
{
Button b = _relayBtns[idx];
if (_relayState[idx])
{
b.Text = $"CH{idx + 1} ON";
b.BackColor = Color.LightGreen;
}
else
{
b.Text = $"CH{idx + 1} OFF";
b.BackColor = SystemColors.Control;
}
}
/// <summary>更新单路 DI 指示灯配色(亮=绿,灭=灰)。</summary>
private void UpdateDiLed(int idx, bool high)
{
_diLeds[idx].BackColor = high ? Color.LightGreen : SystemColors.Control;
}
/// <summary>写日志(带完整时间戳 yyyy-MM-dd HH:mm:ss.fff,自动滚到底部)。</summary>
private void Log(string line)
{
SafeUI(() =>
{
_txtLog.AppendText($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {line}\r\n");
_txtLog.ScrollToCaret();
});
}
#endregion
#region 看门狗(连接状态指示)
/// <summary>每秒检查响应是否超时(>3s 无响应标红并尝试重连一次)。</summary>
private void Watchdog_Tick(object sender, EventArgs e)
{
if (!_comm.IsConnected)
{
return;
}
// 超时阈值自适应:≥ 轮询周期 × 2(最少 3s),
// 避免慢轮询(如 5s、10s)周期内被自家看门狗误判超时。
double elapsed = (DateTime.Now - _lastResponse).TotalSeconds;
double timeoutSec = Math.Max(3.0, _pollTimer.Interval / 1000.0 * 2.0);
if (elapsed > timeoutSec)
{
SafeUI(() => UpdateWatchdogUi(false));
// 尝试自动重连一次(防重连风暴)
if (!_watchdogRecovering && _lastPort.Length > 0)
{
_watchdogRecovering = true;
Log("[WARN] 响应超时,尝试重连...");
_comm.Disconnect();
SetConnectedUi(false);
// 延迟后重连(给串口释放时间)
System.Threading.Timer t = null;
t = new System.Threading.Timer(_ =>
{
if (_comm.Connect(_lastPort, _lastBaud))
{
_lastResponse = DateTime.Now;
SafeUI(() =>
{
SetConnectedUi(true);
UpdateWatchdogUi(true);
Log("[INFO] 自动重连成功");
_pollTimer.Start();
});
}
else
{
SafeUI(() => Log("[ERROR] 自动重连失败"));
}
t.Dispose();
}, null, 800, System.Threading.Timeout.Infinite);
}
}
}
/// <summary>更新看门狗指示标签。</summary>
private void UpdateWatchdogUi(bool healthy)
{
_lblWatchdog.ForeColor = healthy ? Color.Green : Color.Red;
_lblWatchdog.Text = healthy ? "● 正常" : "● 超时";
}
#endregion
#region 自助联调 (--selftest)
/// <summary>自助联调模式开关(由 Program 解析 --selftest 设置)。</summary>
public bool SelfTestMode { get; set; } = false;
/// <summary>自助联调目标串口(由 Program 解析 --selftest [port] 设置,默认 COM3)。</summary>
public string SelfTestPort { get; set; } = "COM3";
/// <summary>无桌面环境(CI/沙箱)自助联调:跳过 Application.Run 与窗口句柄,使 SafeUI 同步直执、绕过 BeginInvoke。</summary>
public bool SelfTestHeadless { get; set; } = false;
private readonly List<string> _stLines = new List<string>();
private int _stPass, _stFail;
private void StLog(string s) => _stLines.Add(s);
/// <summary>状态栏是否已被板子真实响应刷新(要求 "UPTIME <数字>s",排除初始占位 "--s")。</summary>
private bool StatusHasRealUptime()
{
return System.Text.RegularExpressions.Regex.IsMatch(_lblStatus.Text, @"UPTIME\s+\d+s");
}
/// <summary>把多行文本压成单行,便于写入报告。</summary>
private static string OneLine(string s)
{
return (s ?? string.Empty).Replace("\r\n", " / ").Replace("\n", " / ").Trim();
}
private void StCheck(string name, bool ok, string detail = "")
{
if (ok) _stPass++; else _stFail++;
StLog($"{(ok ? "[PASS]" : "[FAIL]")} {name}" + (detail.Length > 0 ? " :: " + detail : ""));
}
/// <summary>
/// 轮询等待条件成立(每 50ms 检查一次)。
/// 【关联指引】headless 下主线程被 GetAwaiter().GetResult() 阻塞,若 await 续体被投递回
/// WinForms 同步上下文(此时无消息泵)将永久挂起,故必须 ConfigureAwait(false) 走线程池。
/// </summary>
private async Task<bool> StWait(Func<bool> cond, int ms)
{
int w = 0;
while (!cond() && w < ms) { await Task.Delay(50).ConfigureAwait(false); w += 50; }
return cond();
}
/// <summary>自助联调主流程:连真板、跑 STATUS 查询与继电器写回读、断言界面由板子真实响应刷新。</summary>
public async Task RunSelfTest()
{
try
{
StLog($"=== P02 GUI SelfTest @ {DateTime.Now:HH:mm:ss} ===");
if (SelfTestHeadless) System.Threading.SynchronizationContext.SetSynchronizationContext(null);
if (!_cboPort.Items.Contains(SelfTestPort)) _cboPort.Items.Add(SelfTestPort);
_cboPort.SelectedItem = SelfTestPort;
string port = SelfTestPort;
StLog($"Port={port} Baud=115200");
bool ok = _comm.Connect(port, 115200);
StCheck("Connect to board", ok, $"IsConnected={_comm.IsConnected}");
if (!ok) return;
await StWait(() => _comm.IsConnected, 2000).ConfigureAwait(false);
// STATUS 全查询:OnStatus 刷新继电器按钮 / DI 指示灯 / 状态文本(握手驱动)
// 断言必须要求出现真实数字(初始占位文本为 "UPTIME --s",只查 Contains 会假 PASS)
_comm.Enqueue(Protocol.BuildStatus(), OnStatus);
await StWait(() => StatusHasRealUptime(), 3000).ConfigureAwait(false);
StCheck("STATUS handshake fills GUI (real uptime digits)", StatusHasRealUptime(),
$"lbl=\"{_lblStatus.Text}\"");
// PING 链路握手:断言收到板子 PONG
bool pong = false;
_comm.Enqueue(Protocol.BuildPing(), (raw) => { if (raw.Contains("PONG")) pong = true; });
await StWait(() => pong, 3000).ConfigureAwait(false);
StCheck("PING handshake (board replies PONG)", pong);
// INFO 查询握手:断言只读信息框被板子响应填充
_comm.Enqueue(Protocol.BuildInfo(), OnInfo);
await StWait(() => _txtInfo.Text.Contains("FW:"), 3000).ConfigureAwait(false);
StCheck("INFO handshake fills info box", _txtInfo.Text.Contains("FW:"),
$"info=\"{OneLine(_txtInfo.Text)}\"");
// 继电器写握手:读当前态 -> 翻转 -> 断言 GUI 被板子响应刷新 -> 还原
bool s0 = _relayState[0];
OnRelayToggle(1);
await StWait(() => _relayState[0] != s0, 3000).ConfigureAwait(false);
StCheck("RELAY write handshake (CH1 flips GUI)", _relayState[0] != s0,
$"btn=\"{_relayBtns[0].Text}\"");
OnRelayToggle(1); // 还原到初始态
await StWait(() => _relayState[0] == s0, 3000).ConfigureAwait(false);
StCheck("RELAY restore to original", _relayState[0] == s0,
$"btn=\"{_relayBtns[0].Text}\"");
// STATUS 复查:断言板子回报的 CH1 与界面本地态一致(写-回读闭环)
bool[] before = (bool[])_relayState.Clone();
_comm.Enqueue(Protocol.BuildStatus(), OnStatus);
await StWait(() => false, 800).ConfigureAwait(false); // 固定等待一帧回来
StCheck("STATUS re-read matches GUI relay state", _relayState[0] == before[0],
$"gui={_relayState[0]} board={before[0]}");
}
catch (Exception ex)
{
StLog($"[EXCEPTION] {ex}");
_stFail++;
}
finally
{
FinishSelfTest();
}
}
private void FinishSelfTest()
{
StLog($"=== SUMMARY PASS={_stPass} FAIL={_stFail} ===");
string ts = DateTime.Now.ToString("HHmmss");
string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, $"selftest_P02_{ts}.txt");
try { File.WriteAllLines(path, _stLines); StLog("REPORT=" + path); }
catch (Exception ex) { StLog("[WARN] report write failed: " + ex.Message); }
if (!SelfTestHeadless) { try { this.Close(); } catch { } }
}
#endregion
}
}