『7x24小时有问必答』

前言

工业现场的数据采集与监控,说起来简单,做起来坑不少。串口超时、寄存器地址错位、设备突然离线、数据刷新太快页面卡死、历史数据堆积成山——每个问题都能让人折腾好几天。
这两年在折腾工业监控系统时,一直想搞一套既能快速落地、又不用被商业组态软件绑架的方案。.NET 8 的 Blazor Server 给了我一个新思路:用 C# 从头撸到尾,从设备驱动到前端展示,不用换语言,不用搞前后端分离那一套复杂工程。把思路和实现整理出来,希望能给同样在搞工业监控的同行一些参考。

项目介绍

一套基于 .NET 8 + Blazor Server 开发的轻量级工业 Web SCADA 系统,专门针对多泵站集中监控场景设计。整套系统涵盖了设备数据采集、实时监控、报警管理、历史存储和数据分析等核心环节。
系统默认配置了一套四泵站标准点表模板(71 个标签/站),涵盖液位、频率、运行状态、控制模式等关键参数。开箱即用,只要配好 Modbus TCP 设备地址,就能在浏览器里看到泵站的实时运转情况。

项目功能

实时监控
泵站总览 Dashboard:在线/离线统计、报警数量、各站液位概览
泵站详情页:液位实时曲线、泵组启停控制、运行状态指示灯
标签数据 100ms 级刷新,操作响应基本感觉不到延迟
报警管理
高低限报警、设备离线报警自动触发
报警列表支持按时间、级别、确认状态筛选
报警确认操作带操作人记录
历史数据
标签值定时归档(默认 10 秒一次)
支持按时间范围查询任意标签的历史曲线
数据导出为 CSV 格式
设备诊断
每个设备的通讯状态实时可见
提供 /api/status、/api/device-status 等诊断接口
采集日志结构化输出,排查问题不靠猜
多客户端推送
SignalR 实时广播,外部客户端可订阅数据
防抖机制避免高频推送压垮网络

项目特点

**全栈 C#**:从 Modbus 驱动到 Web 界面,一套技术栈从头写到尾,维护成本低,团队上手快
配置驱动:设备和点表全部走 JSON 配置,新增泵站只需复制一份模板文件改改 IP 地址
轻量存储:SQLite 作为历史库,部署省心,数据量不大时完全够用
采集与展示分离:后台服务负责轮询和计算,Blazor 组件只做渲染,各司其职
防抖推送:不管是页面更新还是 SignalR 广播,都做了 100ms 批量合并,避免高频刷新把浏览器搞崩
并行轮询:多设备并行采集,一个设备卡住不影响其他站的数据更新

项目技术

层级
技术选型
说明
前端框架
Blazor Server (InteractiveServer)
服务端渲染,实时数据通过 SignalR 推送
通讯协议
Modbus TCP (NModbus)
主流 PLC 和 RTU 都支持,兼容性好
实时推送
SignalR
WebSocket 长连接,延迟低
数据存储
EF Core + SQLite
轻量、免安装,适合工控机部署
日志
Serilog
结构化日志,方便接入 ELK 或 Seq
图表
ECharts (CDN)
液位趋势、历史曲线展示
DI 容器
.NET 8 原生 DI
不用第三方容器,够用
项目分层结构
IndustrialWebScada/

├── config/                                      # 配置目录

│    ├── devices.json                        # 设备列表

│    └── device-templates/                 # 点表模板

│          └── pump-station.json           # 71 标签标准模板

├── src/

│    ├── IndustrialWebScada.Core/      # 领域模型、接口、事件定义

│    ├── IndustrialWebScada.Application/   # 业务逻辑:TagValueStore、AlarmEngine

│    ├── IndustrialWebScada.Drivers/         # ModbusTcp、MQTT 驱动实现

│    ├── IndustrialWebScada.Infrastructure/  # EF Core + SQLite 持久化

│    ├── IndustrialWebScada.BackgroundServices/  # 采集、推送、报警、归档服务

│    └── IndustrialWebScada.Web/        # Blazor Server 主程序

│          ├── Components/Pages/           # Dashboard、PumpStationList 等页面

│          ├── Components/Shared/         # PumpStationCard、WaterLevelTank 等组件

│          └── Program.cs                     # 入口与 DI 配置

└── tests/

      └── IndustrialWebScada.Drivers.Tests/  # 驱动层单元测试

项目代码

数据采集服务(核心循环)
public  classDataCollectionService  :  BackgroundService

{

       privatereadonly  ITagValueStore _store;

       privatereadonly  IEnumerable<idevicedriver> _drivers;

       privatereadonly  ILogger<datacollectionservice> _logger;

       protected  override  async  Task  ExecuteAsync(CancellationToken stoppingToken)

      {

             while  (!stoppingToken.IsCancellationRequested)

            {

                   var  tasks = _drivers.Select(d => CollectDeviceAsync(d, stoppingToken));

                   await  Task.WhenAll(tasks);   // 并行采集,互不干扰

                  

                   await  Task.Delay(GetInterval(), stoppingToken);

            }

      }

       private  async  Task  CollectDeviceAsync(IDeviceDriver driver, CancellationToken ct)

      {

             try

            {

                   var  tags =  await  driver.ReadAllTagsAsync(ct);

                  _store.Update(tags, DateTime.UtcNow);

            }

             catch  (Exception ex)

            {

                  _logger.Warning(ex,  "采集失败 [{DeviceId}]", driver.DeviceId);

                  _store.MarkOffline(driver.DeviceId);

            }

      }

}

采集服务以 BackgroundService 形式跑在后台,每个设备独立并行执行。某个设备超时或断线不会拖累其它设备的数据更新。
防抖推送器(100ms 批量合并)
public  classTagValueNotifier  :  IDisposable

{

       privatereadonly  System.Timers.Timer _timer;

       privatereadonly  Dictionary<string, TagValue> _buffer =  new();

       privatereadonlyobject  _lock =  new();

       privatereadonlyint  _intervalMs =  100;

       publicevent  Action<ireadonlylist<tagvalue>> OnNotified;

       public  void  Push(TagValue tagValue)

      {

             lock  (_lock)

            {

                  _buffer[tagValue.TagId] = tagValue;   // 同一标签只保留最新值

            }

      }

       private  void  Flush()

      {

            List<tagvalue> snapshot;

             lock  (_lock)

            {

                   if  (_buffer.Count ==  0)  return;

                  snapshot = _buffer.Values.ToList();

                  _buffer.Clear();

            }

            OnNotified?.Invoke(snapshot);

      }

}

页面组件订阅这个 Notifier,收到批量数据后统一调用 StateHasChanged()。100ms 的窗口既能保证实时性,又避免了逐条刷新带来的频繁渲染开销。
Modbus 地址合并读取(减少报文往返)
public  async  Task<dictionary<[ span]string[="" span][="" span],="" [="" span]object[="" span]="">> ReadAllTagsAsync(CancellationToken ct)

{

       var  result =  new  Dictionary<string,  object>();

       var  mergedRequests = MergeAdjacentAddresses(_tagConfigs);

       foreach  (var  request  in  mergedRequests)

      {

             var  data = request.FunctionCode  switch

            {

                   2  =>  await  _modbusMaster.ReadInputsAsync(unitId, request.Start, request.Length),

                   3  =>  await  _modbusMaster.ReadHoldingRegistersAsync(unitId, request.Start, request.Length),

                   4  =>  await  _modbusMaster.ReadInputRegistersAsync(unitId, request.Start, request.Length),

                  _ =>  thrownew  NotSupportedException()

            };

             // 按原始点表拆分数据

            ParseAndFill(result, data, request);

      }

       return  result;

}

点表里 71 个标签,如果逐个读取需要发 71 次请求。这个合并逻辑会把地址连续的寄存器批量读取,读 16 个保持寄存器只需 1 次请求,效率直接拉满。

项目效果

主控台页面聚合了所有泵站的状态概览,右上角显示在线/离线数量和未确认报警数,中间区域以卡片形式展示各站的液位和运行泵数。
泵站详情页是使用频率最高的界面。左侧是液位动态水位图,中间是四台泵的运行状态灯和启停按钮,底部是最近 30 分钟的液位趋势。控制指令下发后,泵状态的变化在 1-2 秒内就能在界面上反映出来。
报警页面按时间倒序排列,未确认的报警高亮显示。点击确认按钮会记录操作人和确认时间,方便事后追溯。
历史查询页面支持选择时间范围和标签名称,曲线图用 ECharts 渲染,缩放和平移操作都很流畅。右侧提供 CSV 导出按钮,方便导出数据做进一步分析。

项目源码

GitHub:https://github.com/suwencjp/VibeCodingIndustrialWebScada

总结

回过头来看,这套系统最大的价值在于把工业采集和 Web 监控打通了,而且全程没离开 .NET 生态。当然,这套方案也有明显的局限。Blazor Server 依赖长连接,大规模部署时对服务器的 SignalR 连接数有压力。另外 SQLite 在高并发写入场景下性能一般,如果点位超过 5000 个,建议换成 PostgreSQL 或 InfluxDB。不过对于中小型泵站(几十到几百个标签)来说,这套架构足够皮实好用了。
如果你也在做类似的工业监控项目,欢迎交流,也欢迎给项目提 issue 或 PR。

关键词

最后
如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。也可以加入微信公众号[DotNet技术匠]  社区,与其他热爱技术的同行一起交流心得,共同成长!
作者:小码编匠
出处:gitee.com/smallcore/DotNetCore
声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!

END

推荐阅读

觉得有收获?不妨分享让更多人受益
关注「DotNet技术匠」,共同提升技术实力

收藏
点赞
分享
在看
</dictionary<[></tagvalue></ireadonlylist<tagvalue></datacollectionservice></idevicedriver>

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

上一主题上一主题         下一主题下一主题
QQ手机版小黑屋粤ICP备17165530号

关于我们·投诉举报· 用户帮助· 联系我们 · 本站服务 · 版权声明· 隐私政策 · 投搞指南

法律保护:PLC技术网,plcjs.com,plcjs.net等字样
Copyright 2010-2030. All rights reserved. 


微信公众号二维码 抖音二维码 百家号二维码 今日头条二维码哔哩哔哩二维码