从零搭建工业控制系统(五):实时数据监控与UI更新

实时数据监控:1秒一次的生死线

这是「从零搭建工业控制系统」系列第5篇。上篇聊完Modbus通信,这篇说怎么把设备数据搬到屏幕上。


从"点一下刷一次"说起

最早做设备监控界面的时候,我写了个"刷新"按钮。操作员想看当前压力,点一下,发一次Modbus请求,显示出来。

后来被现场工程师骂了一顿。

“我盯着压力表看的时候,还得一直点按钮?万一我点慢了,压力超了不知道怎么办?”

确实。工业现场的数据监控必须是自动、持续、实时的。你不可能让人盯着手动刷新。所以定时轮询是必须的,但怎么轮询、轮询频率多少、数据怎么更新到UI,这里面有不少门道。


轮询方案选型

WPF里定时执行有三种选择:

方案线程适用场景
DispatcherTimerUI线程需要直接操作UI控件
System.Threading.Timer线程池后台数据采集
Task.Delay循环线程池需要精确控制间隔

我项目里选的是 System.Threading.Timer。原因很简单:数据采集是后台操作,不应该占用UI线程。采集完数据后通过 ObservableProperty 自动通知UI更新。

轮询数据流


压力监控服务:完整实现

项目里有个 ChamberPressureMonitorService,专门监控腔体压力。核心逻辑就是定时读寄存器、判断阈值、更新属性。

public partial class ChamberPressureMonitorService : ObservableObject, IDisposable
{
    private readonly IDeviceCmdService _deviceCmdService;
    private readonly Timer _monitorTimer;

    // 1秒轮询一次
    private const int MONITOR_INTERVAL_MS = 1000;

    [ObservableProperty]
    private double currentPressureMTorr;

    [ObservableProperty]
    private double pps1ThresholdMTorr;

    [ObservableProperty]
    private bool isPressureAboveThreshold;

    [ObservableProperty]
    private bool isDataValid;

    [ObservableProperty]
    private DateTime lastUpdateTime;

    [ObservableProperty]
    private bool isServiceRunning;

    public ChamberPressureMonitorService(IDeviceCmdService deviceCmdService)
    {
        _deviceCmdService = deviceCmdService;
        InitializePPS1Threshold();

        // 创建定时器,但不立即启动
        _monitorTimer = new Timer(OnMonitorTick, null, Timeout.Infinite, Timeout.Infinite);
    }
}

几个设计要点:

① 继承 ObservableObject — 这个服务本身就是数据源,属性变化时直接通知绑定的UI。不需要中间层转发。

Timeout.Infinite 初始化 — Timer创建后不立即跑。等设备连接成功后再调 StartMonitoring()

③ 监控间隔1秒 — 工业设备不需要太快。压力变化是秒级的,100ms轮询除了给设备添堵没别的好处。


启动和停止

public void StartMonitoring()
{
    if (IsServiceRunning)
    {
        _logger.Warn("压力监控服务已在运行");
        return;
    }

    // 立即执行一次,不用等第一个周期
    UpdatePressureData();

    _monitorTimer.Change(MONITOR_INTERVAL_MS, MONITOR_INTERVAL_MS);
    IsServiceRunning = true;
}

public void StopMonitoring()
{
    if (!IsServiceRunning) return;

    _monitorTimer.Change(Timeout.Infinite, Timeout.Infinite);
    IsServiceRunning = false;
}

启动时立即执行一次 — 这是个细节。如果不立即执行,启动后1秒内UI显示的都是初始值0。操作员看到0可能会以为设备坏了。


数据读取:从寄存器到属性

定时器回调里做的事很简单:找到对应寄存器,读值,更新属性。

private void UpdatePressureData()
{
    try
    {
        // 从压力真空计对应的模拟量输入寄存器读取(点位地址略)
        var pressureRegister = _deviceCmdService?.AnalogIOInputRegisters?
            .FirstOrDefault(ai => ai.IoCode == AnalogIOInputCodes.PressureGauge.ToIoCode());

        if (pressureRegister != null)
        {
            // 注意:用FlowValue不用Value
            // Value是原始寄存器值,FlowValue是转换后的物理量
            CurrentPressureMTorr = pressureRegister.FlowValue;

            // 阈值判断
            IsPressureAboveThreshold = CurrentPressureMTorr >= Pps1ThresholdMTorr;

            IsDataValid = true;
            LastUpdateTime = DateTime.Now;
        }
        else
        {
            HandleDataUnavailable();
        }
    }
    catch (Exception ex)
    {
        _logger.Error($"更新压力数据失败: {ex.Message}", ex);
        HandleDataUnavailable();
    }
}

这里有个我踩过:一开始我用的是 pressureRegister.Value,结果数值完全不对。后来才发现 Modbus 寄存器返回的是 ushort(0-65535),需要经过工程量转换才是实际压力值。FlowValue 才是转换后的。


数据不可用时的安全策略

设备断线、传感器故障、通信超时——这些情况都必须处理。我的做法是"安全默认":

private void HandleDataUnavailable()
{
    IsDataValid = false;
    LastUpdateTime = DateTime.Now;

    // 安全策略:数据不可用时认为压力未达标
    IsPressureAboveThreshold = false;

    // 重置为安全默认值
    CurrentPressureMTorr = 0;

    _logger.Warn("压力数据不可用,采用安全策略");
}

为什么设为0而不是保持上一个值? 因为我宁可让安全控制服务看到"压力不达标"从而禁止操作,也不能让它看到一个过时的"正常"值而放行。工业系统的安全原则是:不确定就不允许。


健康检查

光有定时器还不够。万一定时器在跑,但数据早就过期了呢?比如设备断线后Timer还在空转,IsDataValid虽然设了false,但外部怎么知道这个服务还"活着"?

public bool IsServiceHealthy()
{
    if (!IsServiceRunning) return false;

    // 超过10秒没更新认为不健康
    var timeSinceLastUpdate = DateTime.Now - LastUpdateTime;
    return timeSinceLastUpdate.TotalSeconds < 10 && IsDataValid;
}

10秒是10个轮询周期。正常情况下1秒更新一次,如果10秒没更新,要么通信断了,要么定时器卡死了。外部可以定期调这个方法判断服务状态。

监控服务生命周期


气体流量采集:事件驱动模式

压力监控是"服务自己持有数据,UI直接绑定"。气体流量采集用了另一种模式——事件驱动:

public class FlowDataCollectorService : IDisposable
{
    private Timer _collectionTimer;
    public int CollectionIntervalMs { get; set; } = 1000;

    // 数据采集完成事件
    public event EventHandler<FlowData> DataCollected;

    public void StartCollection(int intervalMs = 1000)
    {
        if (_isCollecting) return;

        CollectionIntervalMs = intervalMs;
        _collectionTimer = new Timer(CollectionTimerCallback, null, 0, intervalMs);
        _isCollecting = true;
    }

    private void CollectionTimerCallback(object state)
    {
        // 采集所有MFC流量数据
        var data = CollectFromAllChannels();
        DataCollected?.Invoke(this, data);
    }
}

区别在于:

  • 压力监控:数据存在Service的属性里,UI直接绑定 CurrentPressureMTorr
  • 流量采集:数据通过事件抛出去,订阅者自己决定怎么处理

两种模式各有适用场景。压力只有一个值,绑属性简单直接。流量有12路MFC,数据量大,用事件更灵活。

两种数据更新模式对比


UI更新:ObservableProperty的魔力

CommunityToolkit.Mvvm[ObservableProperty],数据更新到UI是自动的:

<!-- XAML绑定 -->
<TextBlock Text="{Binding CurrentPressureMTorr, StringFormat={}{0:F1} mTorr}" />
<Border Background="{Binding IsPressureAboveThreshold, Converter={StaticResource BoolToColorConverter}}" />
<TextBlock Text="{Binding LastUpdateTime, StringFormat={}{0:HH:mm:ss}}" />

C#里改了 CurrentPressureMTorr,UI上的数字立刻变。不需要手动调 Dispatcher.Invoke,不需要手动触发 PropertyChanged。源生成器帮你搞定了。

但有个线程问题要注意:System.Threading.Timer 的回调在线程池线程上跑,不是UI线程。ObservableProperty 内部会触发 PropertyChanged,WPF的绑定引擎会自动 marshal 到 UI 线程,所以大多数情况没问题。

但如果你的属性setter里有UI相关的逻辑(比如打开对话框),就必须手动切线程:

Application.Current?.Dispatcher?.BeginInvoke(new Action(() => 
{
    // UI相关操作
}));

多服务协调

实际项目里不止一个监控服务。我项目里有:

  • ChamberPressureMonitorService — 腔体压力,1秒
  • PumpPressureMonitorService — 泵口压力,1秒
  • FlowDataCollectorService — 气体流量,1秒
  • ChamberSafetyControlService — 安全控制,有自己的定时器

这些服务不能各跑各的,得有人协调。这个角色由 DeviceCmdService 担当——设备连接成功后启动监控,断开时停止监控。

// 设备连接成功后
_chamberPressureMonitor.StartMonitoring();
_rpsPressureMonitor.StartMonitoring();
_gasFlowCollector.StartCollection(1000);

// 设备断开时
_chamberPressureMonitor.StopMonitoring();
_rpsPressureMonitor.StopMonitoring();
_gasFlowCollector.StopCollection();

统一启停比让每个服务自己监听连接状态更可控。出了问题只看一个地方。

多服务协调


轮询踩坑清单

现象解决
用DispatcherTimerUI卡顿改用Threading.Timer
轮询间隔太短设备通信口打满1秒足够,最快500ms
不处理数据不可用断线后UI显示过期值设安全默认值+IsDataValid
不做健康检查服务假死不知道检查LastUpdateTime超时
用原始寄存器值数值不对用转换后的FlowValue
多服务各自监听启停不一致统一由协调者管理

本篇小结

知识点关键做法
定时轮询System.Threading.Timer + 1秒间隔
数据绑定[ObservableProperty] 驱动UI自动更新
安全策略数据不可用时设安全默认值
健康检查检查LastUpdateTime是否超时
两种模式属性绑定(单值) vs 事件驱动(批量数据)
多服务协调统一启停,由DeviceCmdService管理

实时监控的核心不是"快",而是"稳"。1秒一次的可靠更新,比100毫秒一次的偶尔丢包强得多。


下期预告

第6篇:配方数据结构设计

数据能读了,设备能控了。下一篇进入工艺执行——怎么把操作步骤写成配置文件,配方数据怎么组织,PPS参数怎么映射。

内容概要:本文研究了一种针对四机并联孤岛微电网的协同控制策略,通过集成DoS攻击模拟、分布式二次控制、下垂控制事件触发式负荷控制,旨在实现微电网在遭受网络安全威胁等异常工况下的电压频率恢复以及有功/无功功率的精确共享分配。基于Simulink平台构建了完整的微电网仿真系统,包含多台分布式发电单元(DG),采用下垂控制实现无需通信的功率自主分配,并引入分布式二次控制以补偿由下垂特性引起的电压和频率偏差,从而提升电能质量。为进一步降低通信负担并提高系统效率,设计了事件触发机制,仅在必要时刻进行信息交互。研究重点在于多时间尺度下的协同控制架构设计,全面验证了该策略在正常运行遭受DoS攻击等扰动情形下的稳定性、鲁棒性恢复能力。; 适合人群:具备电力系统、自动控制理论基础,熟悉Simulink/Matlab仿真工具,从事微电网、分布式能源、智能电网及相关领域研究的研究生、科研人员及工程技术人员。; 使用场景及目标:①研究孤岛微电网中电压频率恢复功率均分的多时间尺度协同控制机制;②分析DoS网络攻击对微电网控制性能的影响及其应对策略;③掌握事件触发控制在减少通信开销中的实际应用方法;④复现并拓展具备网络安全防护能力的高级微电网控制算法仿真模型。; 阅读建议:此资源以Simulink仿真实现为核心,建议读者结合现代控制理论网络安全知识,逐步搭建系统模型,重点关注控制器参数整定、事件触发阈值设计及DoS攻击注入方式,通过对比实验深入理解控制策略的有效性鲁棒性。
USB HID Usage Table内容概要:本文档是USB实施者论坛发布的《HID Usage Tables》版本1.7,定义了人机接口设备(HID)的标准用途表,用于统一USB设备主机之间的通信协议。文档详细列举了各类设备的Usage Page(如通用桌面设备、键盘、按钮、传感器等),并规范了每种Usage的类型、数据格式、单位及其在报告描述符中的应用方式。其中包括对静态/动态标志、数值、集合类型等控制类型的说明,以及针对LED、照明、显示器、电池系统、条码扫描器等多种设备的具体用法定义。文档还涵盖了多语言支持、厂商自定义用途、分辨率倍增器、向量数据处理等内容,旨在为设备制造商和开发者提供统一的交互标准。; 适合人群:从事嵌入式系统开发、USB设备研发、驱动程序设计及相关领域的工程师和技术人员,具备一定的硬件接口和协议基础知识;; 使用场景及目标:①为开发符合HID规范的USB设备提供标准化的Usage编码参考;②帮助系统软件识别和解析不同外设的功能,实现即插即用兼容性;③支持多类设备如键盘、鼠标、传感器、照明装置、医疗仪器等的数据上报控制指令交互;④指导厂商正确声明设备能力,避免误用或冲突的Usage定义; 阅读建议:本资源技术性强,建议结合USB HID规范文档一起阅读,重点关注各Usage Page的ID分配、Usage Type含义及实际应用场景,开发时应严格遵循命名类型约定,确保跨平台兼容性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值