WPF 上位机学习日记(六)— 按钮命令绑定 + 状态管理 + 状态颜色

WPF 上位机学习日记(六)--- 按钮命令绑定 + 状态管理 + 状态颜色


今日内容

今天主要做了 3 件事:

  1. 按钮绑定 ICommand --- Start/Stop/Pause/Emergency Stop 绑定到 ViewModel
  2. 状态管理 + INotifyPropertyChanged --- 按钮联动 4 轴状态,UI 自动刷新
  3. StatusToColorConverter --- 状态值自动映射到颜色(Running=绿, StandBy=灰 等)

一、遇到的坑 & 学到的知识

1. 新 .cs 文件添加后,XAML 找不到

问题: 新建 StatusToColorConverter.cs 后,XAML 报错 The name does not exist in the namespace

原因: WPF 的 XAML 设计器依赖编译后的程序集来解析代码中的类。新加 .cs 文件后需要先编译一次。

解决: dotnet build 之后再打开 XAML 文件,或者直接 dotnet run 运行。

2. App.xaml 命名空间声明

问题: 在 App.xaml 里引用 Helpers 文件夹下的类时,需要声明对应的 XML 命名空间。

复制代码
正确:
  xmlns:helpers="clr-namespace:UpperMachine.Helpers"
  <helpers:StatusToColorConverter x:Key="..."/>

错误:
  xmlns:local="clr-namespace:UpperMachine"    ← local 只到 UpperMachine
  <local:StatusToColorConverter .../>          ← 找不到!在 UpperMachine.Helpers 里

clr-namespace: 后面跟的是 C# 的命名空间路径,不是文件夹路径,但通常文件夹和命名空间一致。

3. 线圈 vs 寄存器

类型 地址前缀 功能码 写方法 数据
线圈(Coils) 0x 05 WriteSingleCoil(地址, true/false) 1 位 ON/OFF
保持寄存器(Holding Registers) 4x 06/16 WriteSingleRegister(地址, 数值) 16 位(0~65535)

它们地址空间独立------线圈地址 0 和寄存器地址 0 是两回事。

4. 按钮状态互锁

csharp 复制代码
// 状态机设计
Idle → Start → Running
Running → Stop → Idle
Running → Pause → Idle(Pause 后回 Idle)
Running → Emergency → Idle
任何状态 → Emergency → Idle(E-Stop 不检查状态)

Is_Running 字段 + 方法前置条件实现:

csharp 复制代码
if (Is_Running) return;    // Start 防重按
if (!Is_Running) return;   // Stop/Pause 只有运行时有效
// Emergency 不设条件------急停永远可用

5. Modbus Slave 同时配线圈和寄存器

使用 Modbus Slave(Witte)时,免费版只能选一种数据类型。选 Holding Registers 就看不到 Coils 配置。

所以:

  • 如果用线圈 → 在 Modbus Slave 里新建 Coils 表
  • 如果用寄存器 → 改回 WriteSingleRegister,地址 88~91
  • 不能两个同时用(免费版限制)

二、今天新增/修改的文件

文件 1: Models/PlcAddressMap.cs --- 新增线圈地址常量

csharp 复制代码
/*
 * 新增内容:
 * 4 个控制命令的线圈地址
 * 每个命令占一个独立的线圈位(0~3)
 * WriteSingleCoil(地址, true) 发脉冲
 */

// =========== 线圈地址 ============
// 开始
public static ushort Coil_Start = 0;
// 停止
public static ushort Coil_Stop = 1;
// 暂停
public static ushort Coil_Pause = 2;
// 急停
public static ushort Coil_EmergencyStop = 3;

文件 2: Models/AxisData.cs --- 加 INotifyPropertyChanged

csharp 复制代码
/*
 * 新增内容:
 * 1. AxisData 实现 INotifyPropertyChanged 接口
 * 2. Status 属性改为字段+属性模式,设值时触发 OnPropertyChanged
 * 3. 这样 XAML 绑定 {Binding Axis1Data.Status} 才能自动刷新
 */

internal class AxisData : INotifyPropertyChanged
{
    private AxisStatus _status;                    // 私有字段存真实值
    public AxisStatus Status                       // 公开属性供 XAML 绑定
    {
        get => _status;
        set { _status = value; OnPropertyChanged(); }  // 赋值时通知 WPF
    }

    public AxisParam Data { get; set; } = new AxisParam();  // 12 个 float 参数

    public event PropertyChangedEventHandler? PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

文件 3: Helpers/StatusToColorConverter.cs --- 新增状态→颜色转换器

csharp 复制代码
/*
 * 完整新增文件
 * 
 * 作用:
 *   把 AxisStatus 枚举转成对应的画刷颜色
 *   XAML 绑定:Foreground="{Binding Axis1Data.Status, Converter=...}"
 *   
 * 执行流程:
 *   Status = Running  → INPC 通知 → WPF 调 Convert(Running)
 *                     → switch 匹配 → 返回 StatusRunningBrush(绿色)
 *                     → UI 上状态文字变绿
 */

using System.Globalization;
using System.Windows;
using System.Windows.Data;
using UpperMachine.Models;

namespace UpperMachine.Helpers
{
    public class StatusToColorConverter : IValueConverter
    {
        // Convert:从数据源→UI 方向的转换
        // value:传进来的原始值(这里是 AxisStatus 枚举)
        // 返回值:赋给 Foreground 属性的画刷
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            // value is AxisStatus status
            //   → 判断 value 是不是 AxisStatus 类型
            //   → 是则赋给 status 变量,否则走最后 : 后面的兜底
            return value is AxisStatus status ? status switch
            {
                AxisStatus.Running => Application.Current.FindResource("StatusRunningBrush"),
                AxisStatus.StandBy => Application.Current.FindResource("StatusStandByBrush"),
                AxisStatus.Stopped => Application.Current.FindResource("StatusStoppedBrush"),
                AxisStatus.Error   => Application.Current.FindResource("StatusErrorBrush"),
                _ => Application.Current.FindResource("TextBrush")      // 兜底
            }
            : Application.Current.FindResource("TextBrush");              // 非 AxisStatus
        }

        // ConvertBack:UI→数据源 的逆转换(不需要,所以抛异常)
        public object ConvertBack(...) => throw new NotImplementedException();
    }
}

文件 4: App.xaml --- 注册转换器 + 新增颜色

xml 复制代码
<!-- 新增命名空间(第 5 行) -->
xmlns:helpers="clr-namespace:UpperMachine.Helpers"
<!-- ↑ 让 XAML 能找到 Helpers 文件夹下的类 -->

<!-- 在 Resources 里添加(第 10 行) -->
<helpers:StatusToColorConverter x:Key="StatusToColorConverter"/>
<!-- ↑ 创建转换器实例,取名为 "StatusToColorConverter" -->
<!--   所有 Page 都能通过 {StaticResource StatusToColorConverter} 引用它 -->

<!-- 新增 4 个状态颜色(第 29~32 行) -->
<SolidColorBrush x:Key="StatusStandByBrush"  Color="#90A4AE"/>   <!-- 灰色:待机 -->
<SolidColorBrush x:Key="StatusRunningBrush"  Color="#00E676"/>   <!-- 绿色:运行中 -->
<SolidColorBrush x:Key="StatusStoppedBrush"  Color="#E0E0E0"/>   <!-- 白色:已停止 -->
<SolidColorBrush x:Key="StatusErrorBrush"    Color="#FF1744"/>    <!-- 红色:错误/报警 -->

文件 5: MainViewModel.cs --- 4 个按钮命令 + 执行方法

csharp 复制代码
/*
 * 新增内容:
 * 1. 4 个 ICommand 属性(StartCommand, StopCommand, PauseCommand, EmergencyCommand)
 * 2. 4 个执行方法(ExecuteStart/Stop/Pause/Emergency)
 * 3. 构造函数注册 RelayCommand
 * 4. Is_Running 状态字段防止按钮重按
 * 5. 每个方法写对应线圈 + 设置 4 轴状态
 */

// === 私有字段 ===
private bool Is_Running;  // 运行状态锁

// === 公开属性(第 100~106 行) ===
public ICommand StartCommand { get; }
public ICommand StopCommand { get; }
public ICommand PauseCommand { get; }
public ICommand EmergencyCommand { get; }

// === 执行方法(第 228~274 行) ===

// Start:仅在 idle 状态可用
// 写线圈 0(Coil_Start)+ 设 4 轴为 Running
private void ExecuteStart()
{
    if (Is_Running) return;                      // 已在运行 → 忽略点击
    if (_service == null || !IsConnected) return; // 未连接 → 忽略
    _service.WriteSingleCoil(PlcAddressMap.Coil_Start, true);
    Is_Running = true;
    Axis1Data.Status = AxisStatus.Running;       // INPC 通知 UI 刷新
    Axis2Data.Status = AxisStatus.Running;
    Axis3Data.Status = AxisStatus.Running;
    Axis4Data.Status = AxisStatus.Running;
}

// Stop:仅在 running 状态可用
// 写线圈 1(Coil_Stop)+ 设 4 轴为 Stopped
private void ExecuteStop()
{
    if (!Is_Running) return;                     // 没运行 → 忽略
    if (_service == null || !IsConnected) return;
    _service.WriteSingleCoil(PlcAddressMap.Coil_Stop, true);
    Is_Running = false;
    Axis1Data.Status = AxisStatus.Stopped;       // UI 变 Stopped(白色)
    // ... 其他 3 轴同理
}

// Pause:仅在 running 状态可用
// 写线圈 2(Coil_Pause)+ 设 4 轴为 StandBy
private void ExecutePause()
{
    if (!Is_Running) return;
    if (_service == null || !IsConnected) return;
    _service.WriteSingleCoil(PlcAddressMap.Coil_Pause, true);
    Is_Running = false;
    Axis1Data.Status = AxisStatus.StandBy;       // UI 变 StandBy(灰色)
    // ... 其他 3 轴同理
}

// Emergency:任何状态都可用(不检查 Is_Running)
// 写线圈 3(Coil_EmergencyStop)+ 设 4 轴为 Error
private void ExecuteEmergency()
{
    if (_service == null || !IsConnected) return;
    _service.WriteSingleCoil(PlcAddressMap.Coil_EmergencyStop, true);
    Is_Running = false;
    Axis1Data.Status = AxisStatus.Error;         // UI 变 Error(红色)
    // ... 其他 3 轴同理
}

// === 构造函数(第 300~307 行) ===
public MainViewModel()
{
    ConnectionCommand = new RelayCommand(Connect);
    StartCommand = new RelayCommand(ExecuteStart);
    StopCommand = new RelayCommand(ExecuteStop);
    PauseCommand = new RelayCommand(ExecutePause);
    EmergencyCommand = new RelayCommand(ExecuteEmergency);
}

文件 6: HomePage.xaml --- 按钮加 Command + Status 加颜色

xml 复制代码
<!-- ===== 第 227~238 行:4 个按钮加 Command 绑定 ===== -->

<!-- Start 按钮:Command 绑定到 ViewModel 的 StartCommand -->
<Button Grid.Column="0" Command="{Binding StartCommand}"
        Content="Start" Background="{StaticResource GreenBrush}"
        Foreground="White" FontSize="20"
        BorderThickness="1" Height="45" Margin="0,0,5,0"/>

<!-- Stop 按钮 -->
<Button Grid.Column="1" Command="{Binding StopCommand}"
        Content="Stop" Background="{StaticResource RedBrush}" .../>

<!-- Pause 按钮 -->
<Button Grid.Column="2" Command="{Binding PauseCommand}"
        Content="Pause" .../>

<!-- Emergency Stop 按钮 -->
<Button Command="{Binding EmergencyCommand}"
        Content="⚠ Emergency Stop" .../>

<!-- ===== 第 39~42 行:Status 文字颜色随状态变化 ===== -->
<TextBlock FontSize="30" Margin="0,0,0,8">
    <!-- "Status:" 标签固定灰色 -->
    <Run Text="Status:" Foreground="{StaticResource LabelBrush}"/>
    <!-- 状态值通过 Converter 转颜色:
         Running→绿  StandBy→灰  Stopped→白  Error→红 -->
    <Run Text="{Binding Axis1Data.Status}"
         Foreground="{Binding Axis1Data.Status, Converter={StaticResource StatusToColorConverter}}"/>
</TextBlock>

三、今日踩坑汇总

# 问题 原因 解决
1 XAML 找不到新加的 Converter 类 没编译,设计器看不到 .cs 中的类 dotnet build
2 local: 命名空间找不到类 local 指向 UpperMachine,但类在 UpperMachine.Helpers xmlns:helpers="clr-namespace:UpperMachine.Helpers"
3 App.xaml 第 10 行用 local: 引 converter 手误,忘改成 helpers: local:helpers:
4 按钮互锁失效 Is_Running 写完就设回 false 保持 Is_Running = true 直到 Stop/Pause/E-Stop
5 AxisData.Status 赋值后 UI 不变 AxisData 没实现 INotifyPropertyChanged 加 INPC,Status 用属性+字段模式
6 Modbus Slave 不能同时配 Coils + Registers 免费版限制 根据实际 PLC 选一种

四、整体架构图

复制代码
用户点击 Start
  ↓
HomePage.xaml
  Button.Command="{Binding StartCommand}"
  ↓
MainViewModel.StartCommand (RelayCommand)
  ↓
ExecuteStart()
  ├── WriteSingleCoil(0, true)         ← 写 PLC 线圈
  ├── Is_Running = true                ← 锁住,防重按
  └── Axis1.Status = AxisStatus.Running ← INPC 通知 UI
        ↓
AxisData.Status setter
  → OnPropertyChanged()
    ↓
WPF 收到通知
  → 执行 StatusToColorConverter.Convert(Running)
    ↓
返回 StatusRunningBrush(绿色 #00E676)
  → 赋值给 Run.Foreground
    ↓
用户在 UI 上看到:
  Status: Running   ← 绿色文字

五、当前状态与下一步

已实现

功能 状态
Start/Stop/Pause/急停 绑定 ✅ 已完成
状态互锁(防重按) ✅ 已完成
Status INotifyPropertyChanged ✅ 已完成
Status→颜色自动转换 ✅ 已完成
线圈写入 PLC ✅ 已完成(需 Modbus Slave 配合测试)

下一阶段计划

  1. 创建子页面(ParamSettingsPage、CommConfigPage 等)
  2. Return Home / Manual Adjust 按钮绑定命令
  3. 导航按钮跳转页面
  4. 恢复轮询,完善数据读取
  5. 报警日志模块