四轴运动控制系统 --- 博客四 Modbus TCP/RTU 通信、大端字节序、数据轮询与异常处理
项目名称: Four-Axis Motion Control System (WPF)
开发环境: .NET 10.0, WPF, NModbus4, System.IO.Ports
Git 仓库:
Upper_Machine_Personal/UpperMachine
目录
- [今日 Q&A 整理](#今日 Q&A 整理)
- 今日开发内容总结
- 项目核心概念
- 项目文件结构
- 完整代码(含逐行注释)
- 总结与下一步
1. 今日 Q&A 整理
Q1: ABCD 在 Modbus 中是大端还是小端?
结论:ABCD = 大端模式。
float 10.0 = 0x41100000 (32位 IEEE 754)
分成 4 个字节: A=0x41, B=0x10, C=0x00, D=0x00
Modbus 把 32 位 float 拆成两个 16 位寄存器:
寄存器地址0(低地址) = AB = 0x4110 ← 高 16 位 (大端: 低地址存高位)
寄存器地址1(高地址) = CD = 0x0000 ← 低 16 位
所以 ConvertPlcToFloat(regs[0], regs[1]) 正确
等价于: return (uint)regs[0] << 16 | regs[1]
Q2: "大端对应低地址存放高字节,值 1 存放为 0000 0001,数组 0 是低地址,所以把 0001 放进去了?"
答:混淆了两个概念------"16 位寄存器内部字节序"和"两个寄存器之间的顺序"。
- 16 位寄存器内部字节序 (Modbus 库自动处理,开发者从不需要关心):
- 大端时:低地址字节 = 高字节(0x00),高地址字节 = 低字节(0x01)
- 两个寄存器的顺序 (开发者需要处理):
- 大端时:数组0 = 低地址 = 高 16 位 → 左移 16 位
- 数组1 = 高地址 = 低 16 位 → 低位直接 OR
Q3: 低地址存放高位就是大端模式,高地址存放高位就是小端模式?
答:对,这是寄存器级别的理解。
大端: 数组[0]<<16 | 数组[1] ← 当前代码正确
小端: 数组[1]<<16 | 数组[0] ← 需要互换
Q4: Bit 层面最左边永远是高位,地址层面最左边就是低位?
答:对,两个不同的维度。
Bit 层面: 位置 0(最左) = MSB = 最高位(值最大)
地址层面: 位置 0(最左) = 最低地址 = 存的可能是高位(大端)或低位(小端)
Q5: 断开连接后 MessageBox 一直弹窗怎么修复?
答:轮询异常的 catch 块中缺少"只弹一次"的标志位控制。
原因:DispatcherTimer 每隔 200ms 触发一次 PollAllAxes,每次异常都弹 MessageBox,导致弹窗关不完。
修复方案:
- 新增
_hasShownPollingError轮询错误标志位 - 第一次轮询异常时:弹窗 + 停定时器 + 清理连接 + 标志位置
true - 后续异常:标志位
true→ 直接跳过,不再弹窗 - 连接成功时:标志重置为
false - 手动断开时:标志重置为
false
2. 今日开发内容总结
已完成
- 项目框架搭建(WPF, net10.0-windows, NModbus4, System.IO.Ports)
- Models 层:
CommConfig、PlcAddressMap、AxisData、AxisParam - Services 层:
ModbusServiceBase(抽象基类)、ModbusTcpService、ModbusRtuService - Helpers 层:
ModbusHelper(float ↔ ushort 转换) - ViewModels 层:
MainViewModel(连接管理 + DispatcherTimer 轮询) - View 层:
HomePage.xaml(4 轴数据卡片)、MainWindow.xaml(顶部连接面板) - Modbus 大端字节序理解与验证(ABCD 格式)
- 轮询异常只提示一次的修复(
_hasShownPollingError标志位) - TCP 连接测试通过(Modbus Slave 配合验证)
- RTU 连接测试通过
关键架构决策
- 使用
DispatcherTimer在 UI 线程进行轮询(简化多线程问题) async voidConnect 方法配合Task.Run避免 UI 阻塞_service?.Disconnect()等 null 条件调用确保安全- 轮询错误时自动停止定时器并清理连接资源
3. 项目核心概念
3.1 Modbus 大端字节序
32-bit float 在 Modbus 中的存储:
地址 n (低地址) = 高 16 位 (bits 31~16)
地址 n+1 (高地址) = 低 16 位 (bits 15~0)
C# 代码转换:
float → PLC: ConvertFloatToPlc(float) → (low, high) 元组
PLC → float: ConvertPlcToFloat(high, low) → float
3.2 轴地址映射
每轴占用 22 个寄存器 (11 个 float 参数 × 2 寄存器/float)
Axis 1: 寄存器 0 ~ 21
Axis 2: 寄存器 22 ~ 43
Axis 3: 寄存器 44 ~ 65
Axis 4: 寄存器 66 ~ 87
3.3 数据绑定链
PLC 寄存器 → ModbusService.ReadHoldingRegisters → ushort[]
→ ModbusHelper.ConvertPlcToFloat → AxisParam 属性 (INPC)
→ AxisData (组合 Status + Data)
→ MainViewModel (暴露 Axis1Data ~ Axis4Data)
→ XAML {Binding Axis1Data.Data.CurrentPos}
3.4 连接与轮询生命周期
Connect() 调用
├─ 已连接 → 断开分支: 停定时器 → 清理 service → IsConnected=false
└─ 未连接 → 连接分支:
├─ 创建 ModbusTcpService / ModbusRtuService
├─ Task.Run → _service.Connect() (后台线程)
├─ 成功 → 创建 DispatcherTimer → 启动轮询
└─ 失败 → catch → 打印日志 → IsConnected=false
PollAllAxes (定时器触发)
├─ _service==null || !IsConnected → return (安全守卫)
├─ 正常 → 读 4 轴寄存器 → 解析 float → 赋值 AxisData
└─ 异常 → 首次 → 弹窗 + 停定时器 + 清理
后续 → _hasShownPollingError=true → 跳过
4. 项目文件结构
UpperMachine/
├── App.xaml # 应用入口 + 全局颜色资源
├── App.xaml.cs # App 代码后置
├── MainWindow.xaml # 主窗口 Shell(顶栏连接面板 + Frame + 底栏)
├── MainWindow.xaml.cs # 主窗口代码后置(初始化 ViewModel + 导航)
│
├── Models/
│ ├── CommConfig.cs # 通信配置(TCP 参数 / RTU 参数 / 轮询间隔)
│ ├── PlcAddressMap.cs # PLC 寄存器地址映射(每轴 22 寄存器)
│ ├── AxisData.cs # 轴数据容器(Status 状态 + Data 参数)
│ └── AxisParam.cs # 单轴 12 个运动参数(带 INPC 通知)
│
├── ViewModels/
│ ├── MainViewModel.cs # 主视图模型(连接/轮询/数据管理)
│ └── RelayCommand.cs # ICommand 实现(按钮绑定桥接)
│
├── Services/
│ ├── ModbusServiceBase.cs # Modbus 抽象基类(5 个标准读写方法)
│ ├── ModbusTcpService.cs # TCP 协议实现(TcpClient + ModbusIpMaster)
│ └── ModbusRtuService.cs # RTU 协议实现(SerialPort + ModbusSerialMaster)
│
├── Helpers/
│ └── ModbusHelper.cs # 字节序转换工具(float ↔ ushort[])
│
└── View/
├── HomePage.xaml # 主页:2×2 轴数据卡片
└── HomePage.xaml.cs # 主页代码后置
5. 完整代码(含逐行注释)
5.1 App.xaml ------ 全局资源与主题颜色
xml
<!--===============================================================
App.xaml ------ 应用入口
定义 14 种全局颜色画刷,所有页面通过 {StaticResource ...} 引用
深色主题(深蓝/暗黑风格),统一项目视觉风格
===============================================================-->
<Application x:Class="UpperMachine.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:UpperMachine"
StartupUri="MainWindow.xaml">
<Application.Resources>
<!--
颜色画刷定义(Key=资源键名, Color=十六进制颜色值)
所有页面的背景/前景/边框都使用这些统一画刷
-->
<!-- 背景色:深蓝黑,用于窗口和页面的主背景 -->
<SolidColorBrush x:Key="BackgroundBrush" Color="#1B1B2F"/>
<!-- 面板色:深海军蓝,用于次级面板/模块的背景 -->
<SolidColorBrush x:Key="PanelBrush" Color="#162447"/>
<!-- 卡片色:中蓝,用于数据展示卡片的背景 -->
<SolidColorBrush x:Key="CardBrush" Color="#1F4068"/>
<!-- 顶栏色:更深的黑色,用于顶部导航栏和底部状态栏 -->
<SolidColorBrush x:Key="HeaderBrush" Color="#0F0F23"/>
<!-- 边框色:浅灰蓝,用于分隔线和边框描边 -->
<SolidColorBrush x:Key="BorderBrush" Color="#2D2D44"/>
<!-- 强调色:青色,用于标题圆点和高亮元素 -->
<SolidColorBrush x:Key="AccentBrush" Color="#00D2FF"/>
<!-- 文字色:浅灰白,用于主要文字内容 -->
<SolidColorBrush x:Key="TextBrush" Color="#E0E0E0"/>
<!-- 标签色:灰蓝色,用于辅助文字/单位/单位标签 -->
<SolidColorBrush x:Key="LabelBrush" Color="#90A4AE"/>
<!-- 绿色:已连接/正常运行指示 -->
<SolidColorBrush x:Key="GreenBrush" Color="#00E676"/>
<!-- 红色:断开/报警/严重错误指示 -->
<SolidColorBrush x:Key="RedBrush" Color="#FF1744"/>
<!-- 橙色:警告/负限位指示 -->
<SolidColorBrush x:Key="OrangeBrush" Color="#FF9100"/>
<!-- 黄色:正限位指示 -->
<SolidColorBrush x:Key="YellowBrush" Color="#FFD740"/>
<!-- 蓝色:回零完成/信息指示 -->
<SolidColorBrush x:Key="BlueBrush" Color="#448AFF"/>
<!-- 白色:输入框文字色 -->
<SolidColorBrush x:Key="WhiteBrush" Color="#FFFFFF"/>
</Application.Resources>
</Application>
5.2 App.xaml.cs ------ 应用代码后置(默认模板)
csharp
// App.xaml.cs ------ 应用入口代码后置
// 无需自定义逻辑,使用默认 StartupUri 启动 MainWindow
using System.Configuration;
using System.Data;
using System.Windows;
namespace UpperMachine
{
/// <summary>
/// Interaction logic for App.xaml
/// WPF 自动将 App.xaml 的 StartupUri="MainWindow.xaml"
/// 和本类的 public partial class App 连接
/// 启动时自动加载 MainWindow 作为主窗口
/// </summary>
public partial class App : Application
{
// 无额外代码,所有逻辑在 MainWindow 和 ViewModel 中
}
}
5.3 Models/CommConfig.cs ------ 通信参数配置
csharp
//===============================================================
// CommConfig.cs ------ 通信参数配置数据模型
//
// 功能:存储 TCP 和 RTU 两种连接方式的所有配置参数
// 实现 INotifyPropertyChanged,使 XAML 双向绑定生效
// 不包含通信逻辑,仅作为纯数据对象(DTO)
//
// 使用场景:
// MainWindow.xaml 的 TextBox 绑定 Config.IpAddress
// → 用户输入 IP → setter 触发 OnPropertyChanged()
// → WPF 引擎读取新值 → 界面实时更新
//===============================================================
// 引入 INotifyPropertyChanged 接口
using System.ComponentModel;
// 引入 Parity(校验位)和 StopBits(停止位)枚举类型
using System.IO.Ports;
// 引入 CallerMemberName 特性
using System.Runtime.CompilerServices;
namespace UpperMachine.Models
{
/// <summary>
/// 通信配置类 ------ 存储 TCP 和 RTU 两种连接方式的所有参数
/// 不包含通信逻辑,仅作为数据传输对象(DTO),与服务层解耦
/// </summary>
public class CommConfig:INotifyPropertyChanged
{
// ========== 私有字段(实现 INPC 需要字段 + 属性模式) ==========
// TCP 参数
private string _ipAddress = "192.168.1.100"; // 目标 PLC 的 IP 地址,默认 192.168.1.100
private int _tcpPort = 502; // TCP 端口号,Modbus 标准端口 502
// RTU(串口)参数
private string _portName = "COM1"; // 串口号,默认 COM1
private int _baudRate = 9600; // 波特率,默认 9600
private int _dataBits = 8; // 数据位,默认 8
private Parity _parity = Parity.None; // 校验位,默认 None(无校验)
private StopBits _stopBits = StopBits.One; // 停止位,默认 One(1 位停止位)
// ComboBox 下拉选项数组
private string[] _connectionTypes = ["TCP", "RTU"];
// =============== 轮询机制 ===============
// 轮询间隔时间(毫秒)
// 默认 200ms,即每 200ms 读取一次所有轴的数据
public int IntervalMs { get; set; } = 200;
// ========== TCP 参数 ==========
/// <summary>目标 PLC 的 IP 地址,默认值 192.168.1.100</summary>
public string IpAddress
{
get => _ipAddress; // 返回当前 IP 地址
set { _ipAddress = value; OnPropertyChanged(); } // 设置并通知 UI 刷新
}
/// <summary>TCP 端口号,Modbus TCP 标准端口为 502</summary>
public int TcpPort
{
get => _tcpPort; // 返回当前端口号
set { _tcpPort = value; OnPropertyChanged(); } // 设置并通知 UI
}
// ========== RTU(串口)参数 ==========
/// <summary>串口号,如 "COM1"、"COM3"</summary>
public string PortName
{
get => _portName; // 返回串口号
set { _portName = value; OnPropertyChanged(); } // 设置并通知 UI
}
/// <summary>波特率,常用值 9600 / 19200 / 38400 / 115200</summary>
public int BaudRate
{
get => _baudRate; // 返回波特率
set { _baudRate = value; OnPropertyChanged(); } // 设置并通知 UI
}
/// <summary>数据位,通常为 8</summary>
public int DataBits
{
get => _dataBits; // 返回数据位
set { _dataBits = value; OnPropertyChanged(); } // 设置并通知 UI
}
/// <summary>校验位枚举(None=无校验, Odd=奇校验, Even=偶校验)</summary>
public Parity Parity
{
get => _parity; // 返回校验位枚举值
set { _parity = value; OnPropertyChanged(); } // 设置并通知 UI
}
/// <summary>停止位枚举(One=1位, Two=2位)</summary>
public StopBits StopBits
{
get => _stopBits; // 返回停止位枚举值
set { _stopBits = value; OnPropertyChanged(); } // 设置并通知 UI
}
// ========== 通用参数(TCP 和 RTU 共用) ==========
/// <summary>Modbus 从站 ID(Slave ID),范围 1~247</summary>
public byte SlaveId { get; set; } = 1; // 默认 Slave ID = 1
/// <summary>
/// 通信超时时间(毫秒)
/// TCP:连接超时 + 读写超时
/// RTU:串口读写超时
/// </summary>
public int Timeout { get; set; } = 1000; // 默认 1000ms(1 秒)
/// <summary>
/// ComboBox 下拉选项数组
/// XAML 绑定: ItemsSource="{Binding Config.ConnectionTypes}"
/// 显示两个选项:"TCP" 和 "RTU"
/// </summary>
public string[] ConnectionTypes { get => _connectionTypes; }
// ========== INotifyPropertyChanged 实现 ==========
/// <summary>
/// 属性变更事件 ------ WPF 绑定引擎监听此事件
/// 当任意属性 setter 调用 OnPropertyChanged() 时,
/// WPF 自动重新读取绑定值,刷新 UI 显示
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// 触发属性变更通知(线程安全)
/// [CallerMemberName] 编译器自动填入调用方属性名
/// 例:IpAddress setter 中写 OnPropertyChanged(),
/// 编译器自动展开为 OnPropertyChanged("IpAddress")
/// </summary>
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
5.4 Models/PlcAddressMap.cs ------ PLC 寄存器地址映射
csharp
//===============================================================
// PlcAddressMap.cs ------ PLC 寄存器地址映射表
//
// 功能:定义 4 轴在 Modbus 保持寄存器中的地址分配方案
// 每个轴占用 22 个寄存器(11 个 float 参数 × 2 寄存器/float)
//
// 地址分配:
// Axis 1: 0 ~ 21 (每轴起始地址 0)
// Axis 2: 22 ~ 43 (每轴起始地址 22)
// Axis 3: 44 ~ 65 (每轴起始地址 44)
// Axis 4: 66 ~ 87 (每轴起始地址 66)
//
// 使用场景:
// PollAllAxes() 中通过 PerAxisStartAddress[i] 获取第 i 轴的起始地址
// 然后调用 ReadHoldingRegisters(startAddr, 22) 读取该轴的全部参数
//===============================================================
using System;
using System.Collections.Generic;
using System.Text;
namespace UpperMachine.Models
{
/// <summary>
/// PLC 寄存器地址映射表(静态常量 + 静态只读数组)
/// 所有成员 static,无需实例化,直接 PlcAddressMap.ParamPerAxis 访问
/// </summary>
public class PlcAddressMap
{
// 每个轴的参数数量:11 个 float
// CurrentPos, CurrentVel, CurrentTorque,
// AbsoPos, AbsoVel, AbsoAccel, AbsoDecel,
// RelPos, RelVel, RelAccel, RelDecel
public const int ParamPerAxis = 11;
// 每个轴占用寄存器数量 = 参数数量 × 2(每个 float 占 2 个寄存器)
// Modbus 寄存器为 16 位,float 为 32 位,需要连续 2 个寄存器存储
public const int RegisterPerAxisCount = 22;
// 4 个轴的起始寄存器地址数组
// PerAxisStartAddress[0] = 0 → Axis 1
// PerAxisStartAddress[1] = 22 → Axis 2
// PerAxisStartAddress[2] = 44 → Axis 3
// PerAxisStartAddress[3] = 66 → Axis 4
public static readonly int[] PerAxisStartAddress = new int[] { 0, 22, 44, 66 };
}
}
5.5 Models/AxisData.cs ------ 轴数据容器
csharp
//===============================================================
// AxisData.cs ------ 轴数据容器与状态枚举
//
// 功能:
// 1. AxisStatus 枚举:定义轴的 4 种运行状态
// 2. AxisData 类:组合状态(Status)和数据(Data)
//
// 使用场景:
// MainViewModel 暴露 4 个 AxisData 属性:
// Axis1Data, Axis2Data, Axis3Data, Axis4Data
// XAML 绑定:{Binding Axis1Data.Status} 和 {Binding Axis1Data.Data.CurrentPos}
//===============================================================
namespace UpperMachine.Models
{
/// <summary>
/// 轴运行状态枚举
/// 每个轴当前处于哪种工作阶段
/// XAML 绑定 TextBlock 时会自动调用 .ToString() 显示枚举名
/// </summary>
public enum AxisStatus
{
StandBy, // 待机状态(默认值),轴已就绪但未执行运动指令
Running, // 运行中,轴正在执行运动(位置/速度模式)
Stopped, // 已停止,轴完成运动后停止
Error // 错误/报警状态,轴出现故障需要处理
}
/// <summary>
/// 单轴的数据容器 ------ 组合了状态(Status)和参数(Data)
/// MainViewModel 暴露 4 个 AxisData 属性供 HomePage 绑定
///
/// 注意:本类不实现 INotifyPropertyChanged
/// AxisParam(Data 属性)内部实现了 INPC,参数变更会自动通知 UI
/// 如果以后需要 Status 变更时 UI 自动刷新,需加 INPC 实现
/// </summary>
internal class AxisData
{
/// <summary>
/// 轴运行状态
/// 可选值:StandBy / Running / Stopped / Error
/// XAML 绑定路径:{Binding Axis1Data.Status}
/// </summary>
public AxisStatus Status { get; set; }
/// <summary>
/// 轴实时参数(位置、速度、力矩等 12 个 float 值)
/// 内嵌 AxisParam 对象,支持嵌套属性变更通知
/// XAML 绑定路径:{Binding Axis1Data.Data.CurrentPos}
/// </summary>
public AxisParam Data { get; set; } = new AxisParam();
}
}
5.6 Models/AxisParam.cs ------ 单轴 12 个运动参数
csharp
//===============================================================
// AxisParam.cs ------ 单轴运动参数模型
//
// 功能:存储一个轴的全部 12 个 float 参数
// 实现 INotifyPropertyChanged 使 PLC 数据更新时 UI 自动刷新
//
// 参数分类:
// 实时数据(3个) → 当前位置/速度/力矩(PLC 实时反馈)
// 绝对运动(4个) → 目标位置/速度/加速度/减速度(从当前位置到指定值)
// 相对运动(4个) → 距离/速度/加速度/减速度(从当前位置移动指定量)
//
// 每个参数占 2 个 Modbus 寄存器(32-bit float)
// PollAllAxes() 中每两个寄存器解析成一个 float,赋值到对应属性
//===============================================================
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace UpperMachine.Models
{
/// <summary>
/// 单轴 12 个运动参数
/// 实现 INotifyPropertyChanged 以便 PLC 数据更新时 UI 自动刷新
/// 每 set 一个属性都会触发通知,WPF 绑定引擎收到通知后重新读取 UI
/// </summary>
internal class AxisParam : INotifyPropertyChanged
{
// ========== 实时数据(PLC 当前位置/速度/力矩) ==========
// 私有字段:当前位置(单位:mm 或度,取决于机械结构)
private float _currentPos;
// 私有字段:当前速度(单位:mm/s 或 deg/s)
private float _currentVel;
// 私有字段:当前力矩/扭矩(单位:Nm 或 %)
private float _currentTorque;
// ========== 绝对运动参数(从当前位置运动到指定位置) ==========
// 绝对定位目标位置(要移动到的终点坐标)
private float _absoPos;
// 绝对运动过程中的速度
private float _absoVel;
// 绝对运动过程中的加速度
private float _absoAccel;
// 绝对运动过程中的减速度
private float _absoDecel;
// ========== 相对运动参数(从当前位置运动指定距离) ==========
// 相对定位距离(要移动的长度,不是目标坐标)
private float _relPos;
// 相对运动过程中的速度
private float _relVel;
// 相对运动过程中的加速度
private float _relAccel;
// 相对运动过程中的减速度
private float _relDecel;
// ========== 公开属性(XAML 绑定目标) ==========
// 每个属性都是 字段+getter+setter 模式
// setter 中先更新字段值,再调用 OnPropertyChanged() 通知 UI
/// <summary>当前位置(PLC 实时反馈)</summary>
public float CurrentPos { get => _currentPos; set { _currentPos = value; OnPropertyChanged(); } }
/// <summary>当前速度(PLC 实时反馈)</summary>
public float CurrentVel { get => _currentVel; set { _currentVel = value; OnPropertyChanged(); } }
/// <summary>当前力矩(PLC 实时反馈)</summary>
public float CurrentTorque { get => _currentTorque; set { _currentTorque = value; OnPropertyChanged(); } }
/// <summary>绝对定位目标位置</summary>
public float AbsoPos { get => _absoPos; set { _absoPos = value; OnPropertyChanged(); } }
/// <summary>绝对运动速度</summary>
public float AbsoVel { get => _absoVel; set { _absoVel = value; OnPropertyChanged(); } }
/// <summary>绝对运动加速度</summary>
public float AbsoAccel { get => _absoAccel; set { _absoAccel = value; OnPropertyChanged(); } }
/// <summary>绝对运动减速度</summary>
public float AbsoDecel { get => _absoDecel; set { _absoDecel = value; OnPropertyChanged(); } }
/// <summary>相对定位距离</summary>
public float RelPos { get => _relPos; set { _relPos = value; OnPropertyChanged(); } }
/// <summary>相对运动速度</summary>
public float RelVel { get => _relVel; set { _relVel = value; OnPropertyChanged(); } }
/// <summary>相对运动加速度</summary>
public float RelAccel { get => _relAccel; set { _relAccel = value; OnPropertyChanged(); } }
/// <summary>相对运动减速度</summary>
public float RelDecel { get => _relDecel; set { _relDecel = value; OnPropertyChanged(); } }
// ========== INotifyPropertyChanged 实现 ==========
/// <summary>属性变更事件 ------ WPF 绑定引擎监听此事件</summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// 触发属性变更通知
/// [CallerMemberName] 编译器自动填入调用方属性名
/// 例如 CurrentPos setter 中调用 OnPropertyChanged()
/// 编译器自动展开为 OnPropertyChanged("CurrentPos")
/// 无需手动写字符串,减少拼写错误
/// </summary>
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
5.7 Helpers/ModbusHelper.cs ------ 字节序转换工具
csharp
//===============================================================
// ModbusHelper.cs ------ Modbus 字节序转换工具
//
// 功能:
// 1. ConvertPlcToFloat(ushort high, ushort low)
// → 将 PLC 读取的两个 16 位寄存器合并为 32 位 float
//
// 2. ConvertFloatToPlc(float v)
// → 将 C# float 拆分为两个 16 位值,用于写入 PLC
//
// 字节序说明(大端模式):
//
// Modbus 协议使用大端字节序(Big-Endian):
// 低地址寄存器存放 32 位数据的高 16 位
// 高地址寄存器存放 32 位数据的低 16 位
//
// PLC 显示 ABCD 格式时(如 Modbus Slave 工具):
// A=0x41, B=0x10, C=0x00, D=0x00 (float 10.0 = 0x41100000)
// 地址 n (低地址) = AB = 0x4110 ← 高 16 位
// 地址 n+1 (高地址) = CD = 0x0000 ← 低 16 位
//
// 验证:
// ConvertPlcToFloat(0x4110, 0x0000) = 10.0 ✅
//
// 小端对照:
// 如果 PLC 使用小端(CDAB 格式):
// 地址 n (低地址) = CD = 低位
// 地址 n+1 (高地址) = AB = 高位
// 此时需要 ConvertPlcToFloat(low, high) 调换参数顺序
//===============================================================
using System;
namespace UpperMachine.Helpers
{
/// <summary>
/// Modbus 工具类 ------ 提供 PLC 与上位机之间的数据类型转换
/// 所有方法静态,无需实例化,直接 ModbusHelper.ConvertPlcToFloat() 调用
/// </summary>
public class ModbusHelper
{
//===============================================================
// 读取 PLC 转换为上位机数据
//
// 大端对应:低地址存放高字节
// 例如 16 位整数值 1 在大端中的表示为:0000 0001
// 低地址字节 = 0x00(高位),高地址字节 = 0x01(低位)
//
// 小端对应:高地址存放高字节
// 例如 16 位整数值 1 在小端中的表示为:0001 0000
// 低地址字节 = 0x01(低位),高地址字节 = 0x00(高位)
//===============================================================
/// <summary>
/// PLC 寄存器 → C# float(大端模式)
/// </summary>
/// <param name="high">低地址寄存器值 = 32 位数据的高 16 位</param>
/// <param name="low">高地址寄存器值 = 32 位数据的低 16 位</param>
/// <returns>转换后的 float 值</returns>
public static float ConvertPlcToFloat(ushort high, ushort low)
{
// 将两个 16 位值合并为 32 位无符号整数
// high 左移 16 位 → 占据 bits 31~16
// low 直接 OR → 占据 bits 15~0
uint raw = (uint)(high << 16 | low);
// 将 32 位整数按 IEEE 754 标准重新解释为 float
// BitConverter.Int32BitsToSingle 直接内存 reinterpret,
// 不涉及字节序,只在 32 位整数与 float 之间做位级映射
return BitConverter.Int32BitsToSingle((int)raw);
}
//===============================================================
// 上位机数据转换 PLC 的字节序实现
//
// low 为低地址,存放高字节的高 16 位(大端模式)
// 返回值元组:(low, high)
// low ← 要写入低地址寄存器的值(高 16 位)
// high ← 要写入高地址寄存器的值(低 16 位)
//
// 写入示例:
// var (regLow, regHigh) = ConvertFloatToPlc(10.0f);
// WriteSingleRegister(address, regLow); // 写低地址
// WriteSingleRegister(address + 1, regHigh); // 写高地址
//===============================================================
/// <summary>
/// C# float → PLC 寄存器值(大端模式)
/// </summary>
/// <param name="v">要写入 PLC 的 float 值</param>
/// <returns>元组 (low, high):low=低地址寄存器值, high=高地址寄存器值</returns>
public static (ushort low, ushort high) ConvertFloatToPlc(float v)
{
// 将 float 按 IEEE 754 重新解释为 32 位有符号整数
int bits = BitConverter.SingleToInt32Bits(v);
// 高 16 位 → 低地址寄存器(大端:低地址存高位)
// 低 16 位 → 高地址寄存器
return (
(ushort)(bits >> 16), // 右移 16 位,取高 16 位
(ushort)(bits & 0xFFFF) // 取低 16 位
);
}
}
}
5.8 Services/ModbusServiceBase.cs ------ Modbus 抽象基类
csharp
//===============================================================
// ModbusServiceBase.cs ------ Modbus 通信服务抽象基类
//
// 设计模式:模板方法模式
// - Connect() / Disconnect() 为抽象方法,子类实现具体协议
// - ReadHoldingRegisters / WriteSingleRegister 等为公共方法
// 子类通过继承获得一致的读写接口
//
// 功能:
// 1. 持有 IModbusMaster 接口(TCP 和 RTU 共用)
// 2. 提供 5 个标准 Modbus 功能码:
// - 0x01 读取线圈 ReadCoils
// - 0x03 读保持寄存器 ReadHoldingRegisters ← 最常用
// - 0x04 读输入寄存器 ReadInputRegisters
// - 0x05 写单个线圈 WriteSingleCoil
// - 0x06 写单个寄存器 WriteSingleRegister
// 3. 实现 IDisposable 接口,支持 using 和手动释放
//===============================================================
// IModbusMaster 是 NModbus4 的核心接口
// TCP 的 ModbusIpMaster 和 RTU 的 ModbusSerialMaster 都实现此接口
using Modbus.Device;
// 引用 Models 命名空间,使用 CommConfig 配置类
using UpperMachine.Models;
namespace UpperMachine.Services
{
/// <summary>
/// Modbus 通信服务的抽象基类
/// 所有子类(TCP/RTU)共用的连接状态 + 5 个标准 Modbus 读写方法
/// 实现 IDisposable 以便 using 块或 ViewModel 释放资源
/// </summary>
public abstract class ModbusServiceBase : IDisposable
{
// ============= 受保护字段 =============
/// <summary>
/// NModbus4 的通信主站接口
/// TCP 子类赋值为 ModbusIpMaster,RTU 子类赋值为 ModbusSerialMaster
/// 两者都实现了 IModbusMaster,基类用这个类型统一操作
/// 问号 ? 表示可能为 null(未连接时)
///
/// protected 修饰:子类可以读写,外部只能通过基类的公共方法间接调用
/// </summary>
protected IModbusMaster? Master;
// ============= 公开属性 =============
/// <summary>当前是否已成功连接(子类可以用 protected set 修改)</summary>
public bool IsConnected { get; protected set; }
/// <summary>
/// 通信配置 ------ TCP/RTU 的所有连接参数
/// 公开字段,子类可以直接读写其中的 IpAddress / PortName / Timeout 等
/// </summary>
public CommConfig Config = new CommConfig();
// ============= 抽象方法(子类必须实现) =============
/// <summary>子类实现各自的连接逻辑,返回 true=连接成功 false=失败</summary>
public abstract bool Connect();
/// <summary>子类实现各自的断开逻辑</summary>
public abstract void Disconnect();
// ============= 私有辅助方法 =============
/// <summary>
/// 检查连接是否有效
/// Master != null 确保对象存在
/// IsConnected 确保连接状态为真
/// 两者缺一不可(断开后 Master 被置为 null)
/// </summary>
private bool _connected() => Master != null && IsConnected;
// ============= 公共读/写方法(Modbus 标准功能码) =============
/// <summary>
/// 读取保持寄存器(功能码 0x03)
/// 保持寄存器可读可写,断电不丢失,是最常用的寄存器类型
/// 我们的轴参数全部存储在保持寄存器中
/// </summary>
/// <param name="startAddress">起始地址,如 0</param>
/// <param name="count">读取数量</param>
/// <returns>ushort 数组,每个元素 16 位</returns>
public ushort[] ReadHoldingRegisters(ushort startAddress, ushort count)
{
// 未连接时直接抛异常,由调用方(PollAllAxes)的 catch 处理
if (!_connected()) throw new InvalidOperationException("未连接");
// 调用 NModbus4 的标准读取方法
// Config.SlaveId 从 CommConfig 读取,默认 1
return Master!.ReadHoldingRegisters(Config.SlaveId, startAddress, count);
}
/// <summary>
/// 读取输入寄存器(功能码 0x04)
/// 输入寄存器只读,通常用于传感器等输入数据
/// </summary>
public ushort[] ReadInputRegisters(ushort startAddress, ushort count)
{
if (!_connected()) throw new InvalidOperationException("未连接");
return Master!.ReadInputRegisters(Config.SlaveId, startAddress, count);
}
/// <summary>
/// 读取线圈状态(功能码 0x01)
/// 读取 DO/开关量输出,true=ON, false=OFF
/// </summary>
public bool[] ReadCoils(ushort startAddress, ushort count)
{
if (!_connected()) throw new InvalidOperationException("未连接");
return Master!.ReadCoils(Config.SlaveId, startAddress, count);
}
/// <summary>
/// 写入单个寄存器(功能码 0x06)
/// 用于向 PLC 写入 16 位整数值
/// 写入 float 时需要 ConvertFloatToPlc 先拆分再两次写入
/// </summary>
/// <param name="address">寄存器地址</param>
/// <param name="value">要写入的 16 位值</param>
public void WriteSingleRegister(ushort address, ushort value)
{
if (!_connected()) throw new InvalidOperationException("未连接");
try
{
Master!.WriteSingleRegister(Config.SlaveId, address, value);
}
catch (Exception e)
{
// 包装异常信息,方便调用方定位问题
throw new Exception($"写入失败: {e.Message}");
}
}
/// <summary>
/// 写入单个线圈(功能码 0x05)
/// 用于控制 PLC 的开关量输出
/// </summary>
public void WriteSingleCoil(ushort address, bool value)
{
if (!_connected()) throw new InvalidOperationException("未连接");
try
{
Master!.WriteSingleCoil(Config.SlaveId, address, value);
}
catch (Exception e)
{
throw new Exception($"写入失败: {e.Message}");
}
}
// ============= 资源释放 =============
/// <summary>
/// 实现 IDisposable.Dispose()
/// 供 using 块或 ViewModel 手动调用释放资源
/// 内部调用子类的 Disconnect() 关闭连接 + 释放 Master
/// GC.SuppressFinalize 告诉 GC 不需要再调用析构函数,提高性能
/// </summary>
public void Dispose()
{
Disconnect(); // 调用子类实现的断开逻辑
GC.SuppressFinalize(this); // 告诉 GC 不需要再调用析构函数
}
}
}
5.9 Services/ModbusTcpService.cs ------ TCP 协议实现
csharp
//===============================================================
// ModbusTcpService.cs ------ Modbus TCP 通信实现
//
// 通信流程:
// 1. new TcpClient() --- 创建 TCP 客户端对象
// 2. ConnectAsync() --- 异步连接 PLC(带可配置超时)
// 3. ModbusIpMaster.CreateIp(_tcpClient) --- 创建 Modbus 主站
// 4. 通过基类 ReadHoldingRegisters 等读取/写入数据
//
// 超时控制:
// 使用 CancellationTokenSource.CancelAfter(Config.Timeout)
// 实现可配置超时,避免 TcpClient 默认 20~30 秒不可控超时
//
// 断开流程:
// Close() → Dispose() → null → 释放 Master
//===============================================================
// ModbusIpMaster 是 NModbus4 提供的 TCP 主站创建器
using Modbus.Device;
// TcpClient 提供 TCP 网络连接
using System.Net.Sockets;
// Task 用于异步连接 + 超时控制
using System.Threading.Tasks;
namespace UpperMachine.Services
{
/// <summary>
/// Modbus TCP 通信实现
/// 通过 TcpClient 连接 PLC,使用 ModbusIpMaster 进行 Modbus 通信
/// 核心改进:使用 ConnectAsync + GetAwaiter().GetResult() 实现可配置超时
/// </summary>
internal class ModbusTcpService : ModbusServiceBase
{
/// <summary>底层 TCP 客户端,生命周期由 Connect / Disconnect 管理</summary>
private TcpClient? _tcpClient;
/// <summary>
/// 取消令牌源:用于实现连接超时控制
/// CancelAfter(ms) 在指定毫秒后自动触发取消
/// </summary>
private CancellationTokenSource? _cts;
/// <summary>
/// 建立 TCP 连接
/// </summary>
public override bool Connect()
{
try
{
// ---- 前置清理:取消上一次的令牌(如果有) ----
_cts?.Cancel();
_cts?.Dispose();
// ---- 第 1 步:创建超时取消令牌 ----
_cts = new CancellationTokenSource();
_cts.CancelAfter(Config.Timeout); // 超时时间从 Config.Timeout 读取
// ---- 第 2 步:创建 TcpClient(先不连接) ----
_tcpClient = new TcpClient();
// ---- 第 3 步:异步连接 + 超时控制 ----
// ConnectAsync 不会阻塞当前线程,返回 Task
// GetAwaiter().GetResult() = 同步等待异步结果
// 配合 CancellationToken,超时后抛出 OperationCanceledException
_tcpClient.ConnectAsync(Config.IpAddress, Config.TcpPort, _cts.Token)
.GetAwaiter().GetResult();
// ---- 第 4 步:设置读写超时 ----
// 读取/写入 PLC 数据的超时时间(毫秒)
_tcpClient.ReceiveTimeout = Config.Timeout;
_tcpClient.SendTimeout = Config.Timeout;
// ---- 第 5 步:创建 Modbus 主站 ----
// CreateIp 接收已连接的 TcpClient
// 返回 ModbusIpMaster(实现了 IModbusMaster 接口)
Master = ModbusIpMaster.CreateIp(_tcpClient);
// 更新连接状态
IsConnected = true;
return true;
}
catch (Exception ex)
{
// 连接失败:打印日志、清理资源、返回 false
Console.WriteLine($"TCP连接失败: {ex.Message}");
IsConnected = false;
// 清理 TCP 资源
_tcpClient?.Close();
_tcpClient = null;
// 清理令牌资源
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
return false;
}
}
/// <summary>
/// 断开 TCP 连接
/// 关闭 TcpClient → 释放资源 → 置为 null
/// </summary>
public override void Disconnect()
{
IsConnected = false; // 先改状态,防止断开过程中被误用
_tcpClient?.Close(); // 关闭 TCP 连接(发送 FIN 包)
_tcpClient?.Dispose(); // 释放托管资源
_tcpClient = null; // 解除引用,方便 GC 回收
// 取消令牌
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
// 释放 Modbus 主站
Master?.Dispose();
Master = null;
}
}
}
5.10 Services/ModbusRtuService.cs ------ RTU 协议实现
csharp
//===============================================================
// ModbusRtuService.cs ------ Modbus RTU(串口)通信实现
//
// 通信流程:
// 1. new SerialPort() --- 创建串口对象,传入 COM 口/波特率/校验/数据位/停止位
// 2. Open() --- 打开串口(占用硬件资源)
// 3. ModbusSerialMaster.CreateRtu(_serialPort) --- 创建 Modbus 主站
// 4. 通过基类 ReadHoldingRegisters 等读取/写入数据
//
// 串口参数详解:
// PortName: "COM1" ~ "COM256"(取决于系统可用串口)
// BaudRate: 9600 / 19200 / 38400 / 115200(需与 PLC 一致)
// Parity: None/Odd/Even(无校验/奇校验/偶校验,需与 PLC 一致)
// DataBits: 通常为 8
// StopBits: One/Two(1 位或 2 位停止位,需与 PLC 一致)
//
// 断开流程:
// Close() → Dispose() → null → 释放 Master
//===============================================================
// ModbusSerialMaster 是 NModbus4 提供的 RTU 主站工厂类
using Modbus.Device;
// SerialPort / Parity / StopBits 等串口相关类型
using System.IO.Ports;
namespace UpperMachine.Services
{
/// <summary>
/// Modbus RTU(串口)通信实现
/// 通过 SerialPort 连接 PLC,使用 ModbusSerialMaster 进行 Modbus 通信
/// 继承自 ModbusServiceBase,共享基类的通用读写方法
/// </summary>
internal class ModbusRtuService : ModbusServiceBase
{
/// <summary>底层的串口对象,生命周期由 Connect / Disconnect 管理</summary>
private SerialPort? _serialPort;
/// <summary>
/// 建立 RTU 连接
/// 步骤:创建 SerialPort → 打开串口 → 创建 ModbusSerialMaster → 更新状态
/// </summary>
public override bool Connect()
{
try
{
// ---- 第 1 步:创建串口对象 ----
// SerialPort 构造函数参数顺序:
// (端口名, 波特率, 校验位, 数据位, 停止位)
_serialPort = new SerialPort(
Config.PortName, // 如 "COM1"
Config.BaudRate, // 如 9600
Config.Parity, // 枚举,如 Parity.None
Config.DataBits, // 通常为 8
Config.StopBits // 枚举,如 StopBits.One
)
{
// 读写超时(毫秒),从 Config.Timeout 读取
ReadTimeout = Config.Timeout,
WriteTimeout = Config.Timeout
};
// ---- 第 2 步:打开串口 ----
// 如果端口不存在或已被占用,Open() 会抛出异常
// 常见的异常:UnauthorizedAccessException(端口被占用)
// IOException(端口不存在)
_serialPort.Open();
// ---- 第 3 步:创建 Modbus 主站 ----
// CreateRtu 接收一个已打开的 SerialPort 对象
// 返回 IModbusSerialMaster(继承 IModbusMaster)
Master = ModbusSerialMaster.CreateRtu(_serialPort);
IsConnected = true;
return true;
}
catch (Exception ex)
{
// 连接失败:打印日志、更新状态、返回 false
Console.WriteLine($"RTU连接失败: {ex.Message}");
IsConnected = false;
return false;
}
}
/// <summary>
/// 断开 RTU 连接
/// 关闭串口 → 释放资源 → 置为 null
/// 调用顺序:先关串口,再释放 Master
/// </summary>
public override void Disconnect()
{
IsConnected = false; // 先改状态,防止断开过程中被误用
_serialPort?.Close(); // 关闭串口(释放硬件资源)
_serialPort?.Dispose(); // 释放托管资源
_serialPort = null; // 解除引用,方便 GC 回收
Master?.Dispose(); // 释放 Modbus 主站
Master = null;
}
}
}
5.11 ViewModels/RelayCommand.cs ------ ICommand 实现
csharp
//===============================================================
// RelayCommand.cs ------ ICommand 桥接实现
//
// 为什么需要这个类?
// WPF 按钮的 Command 属性需要 ICommand 对象,不能直接传方法
// RelayCommand 包装了 ViewModel 中的方法,使按钮可以调用它
//
// 使用方式:
// 在 MainViewModel 中:
// ConnectionCommand = new RelayCommand(Connect);
// 在 XAML 中:
// <Button Command="{Binding ConnectionCommand}" />
// 点击按钮 → RelayCommand.Execute → MainViewModel.Connect()
//===============================================================
using System.Windows.Input;
namespace UpperMachine.ViewModels
{
/// <summary>
/// 简易 ICommand 实现 ------ 将按钮点击事件桥接到 ViewModel 的方法
///
/// WPF 按钮的 Command 属性需要 ICommand 对象,不能直接传方法
/// 这个包装类让 ViewModel 可以:new RelayCommand(Connect)
/// 告诉按钮:"点击我时执行 Connect() 方法"
/// </summary>
internal class RelayCommand : ICommand
{
/// <summary>存储要执行的方法(如 MainViewModel 的 Connect())</summary>
private readonly Action _execute;
/// <summary>
/// 事件:当 CanExecute 的返回值变化时触发
/// WPF 用它来刷新按钮的可用状态
/// 我们所有按钮始终可用,所以从不触发此事件
/// 但接口要求必须实现,所以用 #pragma 禁用警告
/// </summary>
#pragma warning disable CS0067 // 禁用"事件从未使用"的编译器警告
public event EventHandler? CanExecuteChanged;
#pragma warning restore CS0067
/// <summary>
/// 构造函数
/// </summary>
/// <param name="execute">要执行的无参方法,如 MainViewModel.Connect</param>
public RelayCommand(Action execute) => _execute = execute;
/// <summary>按钮是否可用,返回 true 表示始终可点击</summary>
public bool CanExecute(object? parameter) => true;
/// <summary>按钮被点击时执行此方法</summary>
public void Execute(object? parameter) => _execute();
}
}
5.12 ViewModels/MainViewModel.cs ------ 主视图模型(核心)
csharp
//===============================================================
// MainViewModel.cs ------ 主视图模型
//
// 这是整个应用的核心,负责:
// 1. 管理 Modbus 连接/断开(Connect 方法)
// 2. 定时轮询 PLC 数据(DispatcherTimer + PollAllAxes)
// 3. 暴露 4 轴数据给 XAML 绑定(Axis1Data ~ Axis4Data)
// 4. 处理轮询异常(一次弹窗,防止 MessageBox 轰炸)
//
// 设计模式:
// - INotifyPropertyChanged:属性变更时通知 WPF 引擎刷新 UI
// - RelayCommand:将按钮 Command 绑定到 Connect 方法
// - DispatcherTimer:UI 线程定时器,避免跨线程更新 UI
//
// 关键数据结构:
// CommConfig Config --- 通信参数(IP/端口/波特率/轮询间隔等)
// AxisData Axis1~4Data --- 4 轴的数据和状态
//
// 轮询流程:
// Connect 成功 → 创建 DispatcherTimer → 每 200ms 触发 PollAllAxes
// PollAllAxes → 读取 4 轴寄存器 → 解析 float → 更新 AxisData
// 异常 → 首次弹窗 + 停定时器 + 清理 → 后续静静等待重连
//===============================================================
// INotifyPropertyChanged 接口 ------ 属性变化时通知 WPF 引擎刷新 UI
using System.ComponentModel;
// Parity / StopBits 枚举 ------ 用于 RTU 串口参数
using System.IO.Ports;
// CallerMemberName ------ 自动填入调用属性名,省去手写字符串
using System.Runtime.CompilerServices;
// Task.Run ------ 后台线程连接,不阻塞 UI 线程
using System.Threading.Tasks;
// ICommand ------ 按钮绑定到方法所需的接口
using System.Windows.Input;
using System.Windows.Threading;
using UpperMachine.Helpers;
// 引用数据模型 CommConfig / AxisData
using UpperMachine.Models;
// 引用服务层 ModbusServiceBase / ModbusTcpService / ModbusRtuService
using UpperMachine.Services;
namespace UpperMachine.ViewModels
{
/// <summary>
/// 主 ViewModel ------ 管理连接 + 4 轴数据
/// 实现 INotifyPropertyChanged 让 UI 自动响应属性变化
/// 这是前后端交互的桥梁:XAML 绑定属性 → 属性通知 → UI 刷新
/// </summary>
class MainViewModel : INotifyPropertyChanged
{
// ========== 私有字段 ==========
/// <summary>当前选中的连接类型:"TCP" 或 "RTU"</summary>
private string _connectionType = "TCP";
/// <summary>
/// 当前活动的 Modbus 服务实例
/// Connect() 时创建,Disconnect() 时释放
/// ? 表示可能为 null(未连接时)
/// </summary>
private ModbusServiceBase? _service;
/// <summary>通信参数配置对象(IP/端口/波特率/轮询间隔等)</summary>
public CommConfig Config { get; } = new CommConfig();
/// <summary>连接状态(默认未连接)</summary>
private bool _isConnected = false;
// ===================== 轮询机制 ======================
/// <summary>
/// UI 线程定时器:每隔 IntervalMs 毫秒触发一次 PollAllAxes
/// 使用 DispatcherTimer 而非 System.Timers.Timer 的原因:
/// - DispatcherTimer 在 UI 线程触发 Tick 事件
/// - 可以直接更新 UI 绑定的属性,无需 Invoke/Dispatcher.BeginInvoke
/// - System.Timers.Timer 在工作线程触发,更新 UI 需要额外处理
/// </summary>
private DispatcherTimer? _pollingTimer;
/// <summary>
/// 轮询异常标志位
/// true = 已经弹过轮询异常提示,不要再弹
/// false = 还未弹过,下次异常时弹窗
///
/// 重置时机:
/// - 用户手动断开连接 → false(Connect 断开分支)
/// - 连接成功 → false(Connect 连接成功分支)
/// </summary>
private bool _hasShownPollingError;
// ========== 公开属性 ==========
/// <summary>
/// 连接类型
/// 设置时同时通知三个属性:自身 + IsTcpMode + IsRtuMode
/// 让 XAML 中的 DataTrigger 根据值切换 TCP/RTU 面板显隐
/// </summary>
public string ConnectionType
{
get => _connectionType;
set
{
_connectionType = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsTcpMode)); // 通知 TCP 面板显示/隐藏
OnPropertyChanged(nameof(IsRtuMode)); // 通知 RTU 面板显示/隐藏
}
}
/// <summary>
/// 连接状态 ------ UI 灯和文字直接绑定此值
/// true = 已连接(绿灯 + "Connected")
/// false = 未连接(红灯 + "DisConnect")
/// </summary>
public bool IsConnected
{
get => _isConnected;
set { _isConnected = value; OnPropertyChanged(); }
}
// ========== 计算属性 ==========
/// <summary>当前是 TCP 模式吗?XAML DataTrigger 绑定此值</summary>
public bool IsTcpMode => ConnectionType == "TCP";
/// <summary>当前是 RTU 模式吗?</summary>
public bool IsRtuMode => ConnectionType == "RTU";
// ========== ICommand ==========
/// <summary>Connect/DisConnect 按钮绑定的命令</summary>
public ICommand ConnectionCommand { get; }
// ========== 4 轴数据 ==========
/// <summary>轴 1 的状态 + 参数(绑定路径: Axis1Data.Status, Axis1Data.Data.CurrentPos)</summary>
public AxisData Axis1Data { get; set; } = new AxisData();
/// <summary>轴 2</summary>
public AxisData Axis2Data { get; set; } = new AxisData();
/// <summary>轴 3</summary>
public AxisData Axis3Data { get; set; } = new AxisData();
/// <summary>轴 4</summary>
public AxisData Axis4Data { get; set; } = new AxisData();
// ========== INotifyPropertyChanged ==========
/// <summary>属性变更事件 ------ WPF 绑定引擎监听此事件</summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// 触发属性变更通知
/// [CallerMemberName] 编译器自动填入调用属性名
/// 例:IpAddress setter 中写 OnPropertyChanged(),
/// 编译器自动展开为 OnPropertyChanged("IpAddress")
/// </summary>
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
// ========== 核心方法:连接/断开 ==========
/// <summary>
/// 连接/断开切换方法
/// async void 模式:方法签名 void 匹配 Action 委托(RelayCommand 要求)
/// 内部可用 await 异步执行,不阻塞 UI 线程
///
/// 注意:async void 的异常会直接崩溃,所以必须 try-catch
///
/// 功能分支:
/// 1. 已连接 → 断开:停定时器 → 清理 service → IsConnected=false
/// 2. 未连接 → 连接:创建 service → 异步连接 → 成功则启动轮询
/// </summary>
public async void Connect()
{
// ======== 情况 1:当前已连接 → 断开 ========
if (IsConnected)
{
// 重置轮询错误标志位,为下次连接做准备
_hasShownPollingError = false;
// 停止并清理轮询定时器
_pollingTimer?.Stop(); // 停止定时器触发
_pollingTimer = null; // 释放引用,方便 GC 回收
// 关闭 Modbus 连接
_service?.Disconnect(); // 调用子类的断开逻辑(TCP Close / RTU Close)
_service?.Dispose(); // 释放资源
_service = null; // 解除引用,方便 GC
// 更新 UI 状态
IsConnected = false;
return;
}
// ======== 情况 2:当前未连接 → 建立连接 ========
// 根据 ConnectionType 创建不同协议的服务实例
if (ConnectionType == "TCP")
{
// ---- TCP 分支 ----
var tcp = new ModbusTcpService();
// 从 "192.168.0.1:502" 格式中分离 IP 和端口号
// Split(':') → ["192.168.0.1", "502"]
tcp.Config.IpAddress = Config.IpAddress.Split(':')[0];
// 取端口号,如果没有则默认 502
int.TryParse(Config.IpAddress.Split(':').ElementAtOrDefault(1) ?? "502", out var port);
tcp.Config.TcpPort = port;
_service = tcp;
}
else
{
// ---- RTU 分支 ----
var rtu = new ModbusRtuService();
// 从前端输入框读取参数,逐项赋值给 Config
rtu.Config.PortName = Config.PortName; // 串口号,如 "COM1"
rtu.Config.BaudRate = Config.BaudRate; // 波特率,如 9600
rtu.Config.DataBits = Config.DataBits; // 数据位,如 8
rtu.Config.Parity = Config.Parity; // 校验位枚举
rtu.Config.StopBits = Config.StopBits; // 停止位枚举
_service = rtu;
}
// ======== 执行连接(异步!不阻塞 UI) ========
try
{
// Task.Run(() => _service.Connect())
// → 将连接操作扔到线程池执行,UI 线程不阻塞
//
// await
// → 等后台线程完成后,自动回到 UI 线程继续执行
// → 后续代码 IsConnected = ... 在 UI 线程运行
IsConnected = await Task.Run(() => _service.Connect());
// 连接成功,启动轮询定时器
if (IsConnected)
{
// 创建 DispatcherTimer,设置轮询间隔
_pollingTimer = new DispatcherTimer();
// IntervalMs 从 Config 读取,默认 200ms
_pollingTimer.Interval = TimeSpan.FromMilliseconds(Config.IntervalMs);
// 绑定 Tick 事件处理函数
_pollingTimer.Tick += PollAllAxes;
// 启动定时器,开始定时触发 PollAllAxes
_pollingTimer.Start();
// 重置轮询错误标志位,清除之前可能残留的"已弹窗"状态
_hasShownPollingError = false;
}
// 连接失败 → IsConnected = false → UI 显示红灯 + "DisConnect"
}
catch (Exception ex)
{
// 连接过程异常(如 IP 格式错误、网络不通、串口被占用等)
// 打印到控制台(调试时可见)
Console.WriteLine($"连接异常: {ex.Message}");
// 清理失败的 service
_service = null;
IsConnected = false;
}
}
/// <summary>
/// 轮询所有轴数据
/// 由 DispatcherTimer 定时触发(默认每 200ms 一次)
///
/// 执行流程:
/// 1. 检查 _service 和 IsConnected,任一无效应立即返回
/// 2. 对 4 个轴依次:
/// a. 根据 PlcAddressMap 获取起始地址
/// b. 读取 22 个寄存器(保存当前轴的全部参数)
/// c. 每 2 个寄存器解析为 1 个 float
/// d. 赋值到对应轴的 AxisParam 属性
/// 3. 异常时:首次弹窗 + 停定时器 + 清理连接
/// </summary>
private void PollAllAxes(object? sender, EventArgs e)
{
// 安全守卫:如果 service 为 null 或已断开,直接返回
// 防止在 Disconnect 之后定时器又触发导致异常
if (_service == null || !IsConnected) return;
try
{
// 将 4 个轴数据对象放入数组,方便循环处理
var axes = new[] { Axis1Data, Axis2Data, Axis3Data, Axis4Data };
// 依次处理 4 个轴
for (int i = 0; i < 4; i++)
{
// 根据 PlcAddressMap 获取当前轴的起始寄存器地址
// Axis 1: 0, Axis 2: 22, Axis 3: 44, Axis 4: 66
int baseAddress = PlcAddressMap.PerAxisStartAddress[i];
// 调用 Modbus 读取方法
// 参数1: 起始地址(如 0)
// 参数2: 读取长度(22 个寄存器)
var regs = _service.ReadHoldingRegisters(
(ushort)baseAddress,
(ushort)PlcAddressMap.RegisterPerAxisCount
);
// 获取当前轴的 AxisParam 对象
var data = axes[i].Data;
// ---- 解析 22 个寄存器为 11 个 float ----
// 每 2 个寄存器 → 1 个 float(大端模式)
// ConvertPlcToFloat(high, low)
// high = 低地址寄存器的值(32 位的高 16 位)
// low = 高地址寄存器的值(32 位的低 16 位)
data.CurrentPos = ModbusHelper.ConvertPlcToFloat(regs[0], regs[1]); // 当前位置
data.CurrentVel = ModbusHelper.ConvertPlcToFloat(regs[2], regs[3]); // 当前速度
data.CurrentTorque = ModbusHelper.ConvertPlcToFloat(regs[4], regs[5]); // 当前力矩
data.AbsoPos = ModbusHelper.ConvertPlcToFloat(regs[6], regs[7]); // 绝对目标位置
data.AbsoVel = ModbusHelper.ConvertPlcToFloat(regs[8], regs[9]); // 绝对速度
data.AbsoAccel = ModbusHelper.ConvertPlcToFloat(regs[10], regs[11]); // 绝对加速度
data.AbsoDecel = ModbusHelper.ConvertPlcToFloat(regs[12], regs[13]); // 绝对减速度
data.RelPos = ModbusHelper.ConvertPlcToFloat(regs[14], regs[15]); // 相对距离
data.RelVel = ModbusHelper.ConvertPlcToFloat(regs[16], regs[17]); // 相对速度
data.RelAccel = ModbusHelper.ConvertPlcToFloat(regs[18], regs[19]); // 相对加速度
data.RelDecel = ModbusHelper.ConvertPlcToFloat(regs[20], regs[21]); // 相对减速度
}
}
catch (Exception ex)
{
// ---- 轮询异常处理(核心:只弹一次 MessageBox) ----
// 使用 _hasShownPollingError 标志位控制弹窗次数
// 防止 PLC 断开后每 200ms 弹一次 MessageBox 的 Bug
if (!_hasShownPollingError)
{
// 标记已弹窗,后续异常不再弹
_hasShownPollingError = true;
// 弹窗提示用户
System.Windows.MessageBox.Show($"轮询异常: {ex.Message}");
// 停止轮询定时器(防止继续触发)
_pollingTimer?.Stop();
_pollingTimer = null;
// 清理 Modbus 连接资源
_service?.Disconnect();
_service = null;
// 更新 UI 连接状态
IsConnected = false;
}
// 如果 _hasShownPollingError 已经为 true,
// 则什么也不做------不弹窗、不操作,静静等待用户重新连接
}
}
// ========== 构造函数 ==========
/// <summary>
/// 构造函数:在 ViewModel 创建时执行
/// 将 ConnectionCommand 与 Connect() 方法绑定
///
/// XAML 中 <Button Command="{Binding ConnectionCommand}" />
/// 点击按钮 → RelayCommand.Execute → MainViewModel.Connect()
/// </summary>
public MainViewModel()
{
// 创建 RelayCommand,传入 Connect 方法引用
// 注意:这里不能加 (),Connect 不是调用而是引用
ConnectionCommand = new RelayCommand(Connect);
}
}
}
5.13 MainWindow.xaml ------ 主窗口 Shell
xml
<!--===============================================================
MainWindow.xaml ------ 主窗口 Shell
三行布局:
行 0 (高 60) :顶部栏 ------ 标题 + 连接配置面板
行 1 (高 *) :内容区 ------ Frame 页面容器
行 2 (高 50) :底部栏 ------ 4 轴状态一览
连接面板(行 0 右侧 StackPanel)包含:
ComboBox → 选择 TCP / RTU
TextBox → TCP: IP 地址 / RTU: COM + 波特率
Button → Connect / DisConnect(文字随状态切换)
Ellipse → 状态灯(红/绿)
TextBlock → "Connected" / "DisConnect"
===============================================================-->
<Window x:Class="UpperMachine.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:UpperMachine"
mc:Ignorable="d"
Title="四轴运动控制系统" Height="850" Width="1280"
MinHeight="700" MinWidth="1024"
WindowStartupLocation="CenterScreen"
Background="{StaticResource BackgroundBrush}"
FontFamily="Microsoft Himalaya"
>
<Grid>
<!-- 定义三行布局 -->
<Grid.RowDefinitions>
<RowDefinition Height="60"/> <!-- 行 0:顶部栏(固定高度) -->
<RowDefinition Height="*"/> <!-- 行 1:内容区(占满剩余空间) -->
<RowDefinition Height="50"/> <!-- 行 2:底部栏(固定高度) -->
</Grid.RowDefinitions>
<!--===============================================================
顶部栏(Grid.Row="0"):
深色背景 + 底部边框,包含标题和右侧连接面板
===============================================================-->
<Border Grid.Row="0"
Background="{StaticResource HeaderBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="0,0,0,1">
<Grid Margin="20,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <!-- 左:标题 -->
<ColumnDefinition Width="*"/> <!-- 中:留白 -->
<ColumnDefinition Width="Auto"/> <!-- 右:连接面板 -->
</Grid.ColumnDefinitions>
<!-- === 左侧区域:青色圆点 + 系统标题 === -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<!-- AccentBrush 青色圆点 -->
<Ellipse Width="14" Height="14"
Fill="{StaticResource AccentBrush}"
Margin="0,-5,12,0"/>
<!-- 系统名称 -->
<TextBlock Text="4-Axis Motion System" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource TextBrush}"/>
</StackPanel>
<!-- === 右侧区域:连接配置面板(水平排列所有控件) === -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!--
连接类型选择 ComboBox
ItemsSource:下拉数据源 → Config.ConnectionTypes (["TCP", "RTU"])
SelectedItem:选中项(双向绑定)→ ConnectionType
-->
<ComboBox ItemsSource="{Binding Config.ConnectionTypes}"
SelectedItem="{Binding ConnectionType}"
Width="90" Margin="0,-5,8,0"
VerticalAlignment="Center"
FontSize="22"
Background="Transparent"
Foreground="{StaticResource BorderBrush}"
BorderBrush="{StaticResource BorderBrush}">
<!-- 设置下拉列表选项的文字颜色和背景 -->
<ComboBox.ItemContainerStyle>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource BorderBrush}"/>
<Setter Property="Background" Value="Transparent"/>
</Style>
</ComboBox.ItemContainerStyle>
</ComboBox>
<!--
TCP/IP 地址输入框
双向绑定到 Config.IpAddress
DataTrigger:IsTcpMode=true 时显示,否则隐藏
-->
<TextBox Text="{Binding Config.IpAddress, UpdateSourceTrigger=PropertyChanged}"
Width="200" Margin="0,-4,8,0" Height="30"
VerticalContentAlignment="Bottom"
VerticalAlignment="Center"
FontSize="25"
Background="{StaticResource WhiteBrush}"
Foreground="{StaticResource BorderBrush}"
BorderThickness="0,0,0,1"
BorderBrush="{StaticResource BorderBrush}">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsTcpMode}" Value="true">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<!--
RTU 参数面板
包含:COM 口 / 波特率 / 8N1 标识
DataTrigger:IsRtuMode=true 时显示,否则隐藏
-->
<StackPanel Orientation="Horizontal" Margin="0,0,8,0">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsRtuMode}" Value="true">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<!-- COM 口名称输入框 -->
<TextBox Text="{Binding Config.PortName, UpdateSourceTrigger=PropertyChanged}"
Width="80" Margin="0,-4,4,0"
VerticalAlignment="Center"
VerticalContentAlignment="Bottom"
FontSize="24" Height="30"
Background="{StaticResource WhiteBrush}"
Foreground="{StaticResource BorderBrush}"
BorderThickness="0,0,0,1"
BorderBrush="{StaticResource BorderBrush}"/>
<!-- 分隔符 -->
<TextBlock Text=" / " FontSize="24"
Foreground="{StaticResource WhiteBrush}"
VerticalAlignment="Center" Margin="0,0,4,0"/>
<!-- 波特率输入框 -->
<TextBox Text="{Binding Config.BaudRate, UpdateSourceTrigger=PropertyChanged}"
Width="100" Margin="0,-4,4,0"
VerticalAlignment="Center"
FontSize="24" Height="30"
VerticalContentAlignment="Bottom"
Background="{StaticResource WhiteBrush}"
Foreground="{StaticResource BorderBrush}"
BorderThickness="0,0,0,1"
BorderBrush="{StaticResource BorderBrush}"/>
<!-- 8N1 参数标识(8数据位/无校验/1停止位) -->
<TextBlock Text=" 8N1" FontSize="20"
Foreground="{StaticResource WhiteBrush}"
VerticalAlignment="Center"/>
</StackPanel>
<!--
Connect / DisConnect 按钮
DataTrigger 切换按钮文字:
IsConnected=false → "Connect"
IsConnected=true → "DisConnect"
-->
<Button Command="{Binding ConnectionCommand}"
FontSize="20" Margin="0,-5,8,0"
VerticalAlignment="Center"
Foreground="{StaticResource TextBrush}"
Background="Transparent"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
Padding="12,4">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Content" Value="Connect"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Content" Value="DisConnect"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<!-- 连接状态灯(圆点) -->
<Ellipse Width="12" Height="12" Margin="0,-10,6,0" VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<!-- 默认红色(断开) -->
<Setter Property="Fill" Value="Red"/>
<Style.Triggers>
<!-- 已连接 → 绿色 -->
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Fill" Value="{StaticResource GreenBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<!-- 连接状态文字 -->
<TextBlock VerticalAlignment="Center" FontSize="24" Margin="0,-3,0,0"
Foreground="{StaticResource TextBrush}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="DisConnect"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Text" Value="Connected"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</Grid>
</Border>
<!--=== 行 1:内容区(Frame 页面容器) ===-->
<Frame x:Name="MainFrame" Grid.Row="1"
NavigationUIVisibility="Hidden" <!-- 隐藏导航 UI -->
Focusable="False"/> <!-- 不允许获取焦点 -->
<!--=== 行 2:底部状态栏(4 轴状态一览) ===-->
<Border Grid.Row="2" Background="{StaticResource HeaderBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="0,1,0,0"
Padding="15,0">
<!-- 水平居中显示 4 轴状态 -->
<Grid HorizontalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Axis 1 -->
<TextBlock Grid.Column="0" Foreground="{StaticResource LabelBrush}"
FontSize="28" VerticalAlignment="Center" Text="Axis1:"/>
<TextBlock Grid.Column="1" Foreground="{StaticResource LabelBrush}"
FontSize="28" VerticalAlignment="Center"
Text="{Binding Axis1Data.Status}"/>
<Ellipse Grid.Column="2" Width="8" Height="8"
Fill="{StaticResource GreenBrush}"
Margin="10,-5,80,0" VerticalAlignment="Center"/>
<!-- Axis 2 -->
<TextBlock Grid.Column="3" Foreground="{StaticResource LabelBrush}"
FontSize="28" VerticalAlignment="Center" Text="Axis2:"/>
<TextBlock Grid.Column="4" Foreground="{StaticResource LabelBrush}"
FontSize="28" VerticalAlignment="Center"
Text="{Binding Axis2Data.Status}"/>
<Ellipse Grid.Column="5" Width="8" Height="8"
Fill="{StaticResource GreenBrush}"
Margin="10,-5,80,0" VerticalAlignment="Center"/>
<!-- Axis 3 -->
<TextBlock Grid.Column="6" Foreground="{StaticResource LabelBrush}"
FontSize="28" VerticalAlignment="Center" Text="Axis3:"/>
<TextBlock Grid.Column="7" Foreground="{StaticResource LabelBrush}"
FontSize="28" VerticalAlignment="Center"
Text="{Binding Axis3Data.Status}"/>
<Ellipse Grid.Column="8" Width="8" Height="8"
Fill="{StaticResource GreenBrush}"
Margin="10,-5,80,0" VerticalAlignment="Center"/>
<!-- Axis 4 -->
<TextBlock Grid.Column="9" Foreground="{StaticResource LabelBrush}"
FontSize="28" VerticalAlignment="Center" Text="Axis4:"/>
<TextBlock Grid.Column="10" Foreground="{StaticResource LabelBrush}"
FontSize="28" VerticalAlignment="Center"
Text="{Binding Axis4Data.Status}"/>
<Ellipse Grid.Column="11" Width="8" Height="8"
Fill="{StaticResource GreenBrush}"
Margin="10,-5,8,0" VerticalAlignment="Center"/>
</Grid>
</Border>
</Grid>
</Window>
5.14 MainWindow.xaml.cs ------ 主窗口代码后置
csharp
//===============================================================
// MainWindow.xaml.cs ------ 主窗口代码后置
//
// 初始化流程:
// 1. InitializeComponent() --- WPF 解析 XAML,构建控件树
// 2. new MainViewModel() --- 创建视图模型(连接管理 + 数据)
// 3. DataContext = vm --- 设置数据上下文(XAML 绑定从此处寻找数据源)
// 4. new HomePage() --- 创建主页
// 5. homePage.DataContext = vm --- 主页共享同一个 ViewModel
// 6. MainFrame.Navigate(homePage) --- 导航到主页
//
// 注意:ViewModel 和 MainWindow 持有同一个实例
// 所有页面通过 MainWindow.DataContext 共享数据
//===============================================================
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using UpperMachine.View;
namespace UpperMachine
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// WPF 自动将 XAML 和本类通过 partial class 连接
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
// 解析 XAML,构建所有控件(必须第一步调用)
InitializeComponent();
// 创建共享视图模型实例(全局唯一)
var vm = new ViewModels.MainViewModel();
// 将 MainWindow 的 DataContext 设为 ViewModel
// 从此 XAML 中的 {Binding ...} 都从 vm 查找属性
DataContext = vm;
// 创建主页页面
var homePage = new HomePage();
// 主页也持有同一个 ViewModel 实例
homePage.DataContext = vm;
// 导航到主页(显示在 Grid.Row="1" 的 Frame 中)
MainFrame.Navigate(homePage);
}
}
}
5.15 View/HomePage.xaml ------ 主页数据卡片
xml
<!--===============================================================
HomePage.xaml ------ 主页(4 轴数据仪表盘)
2×2 网格布局,每个格子是一张轴数据卡片
每张卡片显示:标题 + 状态 + 当前位置 + 当前速度
绑定路径:
Axis1Data.Status → 轴 1 状态
Axis1Data.Data.CurrentPos → 轴 1 当前位置(带两位小数格式)
Axis1Data.Data.CurrentVel → 轴 1 当前速度
颜色资源:
CardBrush → 卡片背景色(深蓝中调)
BorderBrush → 卡片边框色(浅灰蓝)
AccentBrush → 标题圆点色(青色)
LabelBrush → 标签文字色(灰蓝)
TextBrush → 数值文字色(浅灰白)
===============================================================-->
<Page x:Class="UpperMachine.View.HomePage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="600" d:DesignWidth="800"
Title="HomePage"
Background="{StaticResource BackgroundBrush}">
<!-- 2×2 网格 + 右侧 200px 留白 -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/> <!-- 第一行:Axis 1 + Axis 2 -->
<RowDefinition Height="*"/> <!-- 第二行:Axis 3 + Axis 4 -->
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <!-- 左列:Axis 1 + Axis 3 -->
<ColumnDefinition Width="*"/> <!-- 右列:Axis 2 + Axis 4 -->
<ColumnDefinition Width="200"/> <!-- 右侧留白(预留操作面板) -->
</Grid.ColumnDefinitions>
<!--===============================================================
卡片模板:所有 4 张卡片结构完全相同
区别只在于 Grid.Row/Column 和绑定路径中的轴编号
===============================================================-->
<!-- === 卡片:Axis 1 === -->
<Border Grid.Row="0" Grid.Column="0"
Background="{StaticResource CardBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="10" Margin="15">
<StackPanel VerticalAlignment="Top" Margin="30">
<!-- 标题行:青色圆点 + Axis 1 -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,15">
<Ellipse Width="12" Height="12"
Fill="{StaticResource AccentBrush}"
Margin="0,0,10,0" VerticalAlignment="Center"/>
<TextBlock Text="Axis 1" FontSize="30" FontWeight="Bold"
Foreground="{StaticResource TextBrush}"/>
</StackPanel>
<!-- 状态:Status: Running / StandBy / ... -->
<TextBlock FontSize="30" Margin="0,0,0,8">
<Run Text="Status:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis1Data.Status}" Foreground="{StaticResource TextBrush}"/>
</TextBlock>
<!-- 当前位置:CurrentPos: 123.45 -->
<TextBlock FontSize="30" Margin="0,0,0,8">
<Run Text="CurrentPos:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis1Data.Data.CurrentPos, StringFormat=N2}"
Foreground="{StaticResource TextBrush}"/>
</TextBlock>
<!-- 当前速度:CurrentVel: 67.89 -->
<TextBlock FontSize="30">
<Run Text="CurrentVel:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis1Data.Data.CurrentVel, StringFormat=N2}"
Foreground="{StaticResource TextBrush}"/>
</TextBlock>
</StackPanel>
</Border>
<!-- === 卡片:Axis 2 === -->
<Border Grid.Row="0" Grid.Column="1"
Background="{StaticResource CardBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="10" Margin="15">
<StackPanel VerticalAlignment="Top" Margin="30">
<StackPanel Orientation="Horizontal" Margin="0,0,0,15">
<Ellipse Width="12" Height="12"
Fill="{StaticResource AccentBrush}"
Margin="0,0,10,0" VerticalAlignment="Center"/>
<TextBlock Text="Axis 2" FontSize="30" FontWeight="Bold"
Foreground="{StaticResource TextBrush}"/>
</StackPanel>
<TextBlock FontSize="30" Margin="0,0,0,8">
<Run Text="Status:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis2Data.Status}" Foreground="{StaticResource TextBrush}"/>
</TextBlock>
<TextBlock FontSize="30" Margin="0,0,0,8">
<Run Text="CurrentPos:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis2Data.Data.CurrentPos, StringFormat=N2}"
Foreground="{StaticResource TextBrush}"/>
</TextBlock>
<TextBlock FontSize="30">
<Run Text="CurrentVel:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis2Data.Data.CurrentVel, StringFormat=N2}"
Foreground="{StaticResource TextBrush}"/>
</TextBlock>
</StackPanel>
</Border>
<!-- === 卡片:Axis 3 === -->
<Border Grid.Row="1" Grid.Column="0"
Background="{StaticResource CardBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="10" Margin="15">
<StackPanel VerticalAlignment="Top" Margin="30">
<StackPanel Orientation="Horizontal" Margin="0,0,0,15">
<Ellipse Width="12" Height="12"
Fill="{StaticResource AccentBrush}"
Margin="0,0,10,0" VerticalAlignment="Center"/>
<TextBlock Text="Axis 3" FontSize="30" FontWeight="Bold"
Foreground="{StaticResource TextBrush}"/>
</StackPanel>
<TextBlock FontSize="30" Margin="0,0,0,8">
<Run Text="Status:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis3Data.Status}" Foreground="{StaticResource TextBrush}"/>
</TextBlock>
<TextBlock FontSize="30" Margin="0,0,0,8">
<Run Text="CurrentPos:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis3Data.Data.CurrentPos, StringFormat=N2}"
Foreground="{StaticResource TextBrush}"/>
</TextBlock>
<TextBlock FontSize="30">
<Run Text="CurrentVel:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis3Data.Data.CurrentVel, StringFormat=N2}"
Foreground="{StaticResource TextBrush}"/>
</TextBlock>
</StackPanel>
</Border>
<!-- === 卡片:Axis 4 === -->
<Border Grid.Row="1" Grid.Column="1"
Background="{StaticResource CardBrush}"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1"
CornerRadius="10" Margin="15">
<StackPanel VerticalAlignment="Top" Margin="30">
<StackPanel Orientation="Horizontal" Margin="0,0,0,15">
<Ellipse Width="12" Height="12"
Fill="{StaticResource AccentBrush}"
Margin="0,0,10,0" VerticalAlignment="Center"/>
<TextBlock Text="Axis 4" FontSize="30" FontWeight="Bold"
Foreground="{StaticResource TextBrush}"/>
</StackPanel>
<TextBlock FontSize="30" Margin="0,0,0,8">
<Run Text="Status:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis4Data.Status}" Foreground="{StaticResource TextBrush}"/>
</TextBlock>
<TextBlock FontSize="30" Margin="0,0,0,8">
<Run Text="CurrentPos:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis4Data.Data.CurrentPos, StringFormat=N2}"
Foreground="{StaticResource TextBrush}"/>
</TextBlock>
<TextBlock FontSize="30">
<Run Text="CurrentVel:" Foreground="{StaticResource LabelBrush}"/>
<Run Text="{Binding Axis4Data.Data.CurrentVel, StringFormat=N2}"
Foreground="{StaticResource TextBrush}"/>
</TextBlock>
</StackPanel>
</Border>
</Grid>
</Page>
5.16 View/HomePage.xaml.cs ------ 主页代码后置
csharp
//===============================================================
// HomePage.xaml.cs ------ 主页代码后置
//
// 当前仅执行 XAML 初始化
// DataContext 在 MainWindow.xaml.cs 中通过 Navigate 前设置
// 主页本身没有额外的代码逻辑
//===============================================================
using System;
using System.Collections.Generic;
using System.Text;
using System.Windows.Controls;
namespace UpperMachine.View
{
/// <summary>
/// HomePage 代码后置
/// 逻辑全部在 XAML 绑定和 ViewModel 中完成
/// 此处仅调用 InitializeComponent() 解析 XAML
/// </summary>
public partial class HomePage : Page
{
public HomePage()
{
// 解析 XAML,构建页面控件树
InitializeComponent();
}
}
}
6. 总结与下一步
今日核心收获
| 知识点 | 掌握程度 | 说明 |
|---|---|---|
| Modbus 大端字节序 | ✅ 理解 | ABCD = 大端,低地址存高位 |
| float ↔ 寄存器转换 | ✅ 掌握 | ConvertPlcToFloat(high, low) |
| DispatcherTimer 轮询 | ✅ 掌握 | UI 线程定时器,直接更新绑定 |
| 异常只弹一次模式 | ✅ 掌握 | _hasShownPollingError 标志位 |
| TCP/RTU 连接管理 | ✅ 掌握 | 抽象基类 + 多态实现 |
| WPF 数据绑定 | ✅ 掌握 | INPC + XAML Binding + DataTrigger |
下一步计划
- 添加
Running状态判断逻辑(位置/速度 ≠ 0 → Running) - 实现写入功能(
ConvertFloatToPlc+WriteSingleRegister) - 为
AxisData添加 INotifyPropertyChanged(Status 变更自动刷新) - 添加更多页面(手动调整 / 参数设置 / 实时监控 / 报警面板)
- 配置持久化(JSON 序列化保存/加载)
- 操作日志持久化(SQLite 或文件存储)