ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于ASP.NET Core的Web化组态软件源码解析:从实时监控到Modbus通信

基于ASP.NET Core的Web化组态软件源码解析:从实时监控到Modbus通信

简介:组态王6.5源代码包内含202个文件,整体约169KB,面向工业自动化监控系统开发者、组态软件二次开发人员及.NET技术学习者,可用于设备监控、数据采集与系统集成场景。压缩包以38个cpp、43个h源文件为主,涵盖C++/C#底层控制逻辑与接口定义;配合52个bmp、9个ico等图形资源,以及dsw、dsp、clw等工程文件,便于在Visual Studio中恢复项目并梳理界面绘制与模块构成;另有rtf、hlp、cnt等文档,提供一定阅读参考。该版本结合ASP.NET组件,涉及ADO.NET数据通信、页面实时趋势展示、事件驱动响应与多线程并发处理,可帮助读者理解组态软件从底层设备驱动到Web远程监控的完整链路,同时提升C#与Web服务集成、安全控制等开发技能。目前已有458人学习下载,适合有一定编程基础、希望深入组态软件设计原理或从事工业信息化开发的读者。

1. 一套能跑起来的组态软件源码:从现场画面到Web化实现

去过工厂车间的人都知道,操作员站上那个花花绿绿的监控画面,阀门变红、液位上下跳、报警条闪烁,背后就是一个组态软件在撑着。这类软件的商业授权不便宜,想在一个教学项目或内部工具里复现它的核心交互,又不想被授权费卡住,就需要一套能自己改的源代码。这份资源正是一套类组态王的Web化组态系统源码,基于ASP.NET Core构建,把画面编辑、实时变量、Modbus通信、脚本报警串成一条完整链路,适合做工业可视化平台二次开发、做课程设计、或者给产品原型铺底。更关键的是,它不需要真实PLC也能跑,内置模拟数据源,新手装完环境就能看到数据在画面上跳动,省去了接硬件的门槛。

2. 整体架构与技术选型:ASP.NET Core下的四层设计

2.1 分层结构:前端画布、WebAPI、变量中心、设备驱动

这套系统拿到手,第一件事不是看代码,而是搞懂它的分层思路。整个工程可以切成四层:浏览器端负责画面渲染和拖拽编辑,后端ASP.NET Core WebAPI负责业务接口和实时推送,再往下是变量中心(实时数据库),最底层是设备驱动层,处理Modbus TCP、模拟器这类实际数据来源。

这样的分层逻辑和商业组态软件是一致的。变量中心是核心,所有画面里的图元都不直接读设备,而是绑定一个变量名,数据从设备驱动进来,写入变量中心,再由变量中心推送给前端。这样做的好处显而易见:换设备协议的时候,画面不用动,只需要把驱动换掉;反过来改画面布局,设备层也不用动。

前端画布这边,图元和连线都序列化成JSON保存到后端数据库,刷新页面后重新加载渲染。不是用WebGL那种重型方案,而是基于Canvas/SVG的轻量编辑,拖拽、缩放、属性面板都实现了,对中小项目来说反而比三维方案实用得多。

2.2 目录结构与关键文件

工程根目录下分几个大块:GroupApp(主工程)、GroupApp.Core(领域模型和接口)、GroupApp.Device(设备驱动实现)、GroupApp.Web(前端静态资源)。主工程里比较关键的文件如下:

路径职责
/Controllers/ApiController.cs统一入口,处理变量读写、画面存取
/Hubs/DataHub.csSignalR实时推送,把变量变化推给前端
/Services/VariableService.cs变量中心的读写、订阅、报警判断
/Device/ModbusTcpDriver.csModbus TCP协议驱动实现
/Device/SimulatorDriver.cs内置模拟器,生成正弦波和随机数据
/wwwroot/editor/前端画布编辑器,原生JS实现
/wwwroot/viewer/运行态画面渲染器
/Data/group.dbSQLite数据库,存画面和变量配置

2.3 为什么选ASP.NET而非纯前端方案

有人会问,画面编辑本来就在浏览器里做,为什么不用纯前端方案,后端搞个Node或者直接不写后端?答案卡在变量推送和设备接入这两件事上。组态软件的核心是实时性,前端轮询拉数据会有延迟且浪费资源,ASP.NET Core加SignalR做服务端推送是成熟解法;另外设备驱动层要跑Modbus、OPC这类协议,这些库在.NET生态里最成熟,Python和Node都有但坑多。选ASP.NET Core还有一层原因:部署方便,自带Kestrel,内网一台工控机就能跑,不用单独配IIS或者Nginx。

这套工程里没有用ORM框架,而是直接用ADO.NET访问SQLite,代码量不大,胜在直观。如果你接手后想换成MySQL或者SQL Server,只要改DatabaseHelper.cs里的连接字符串和SQL方言,不需要动业务逻辑。

3. 核心模块与关键代码:变量、画面、通信、脚本

3.1 实时变量中心:全系统数据的中枢

变量中心是整套系统的数据枢纽,理解它就能理解整个组态系统的运作方式。先看变量表的数据结构:

public class Variable { public string Id { get; set; } // 全局唯一变量ID public string Name { get; set; } // 变量名,如 Tank1_Level public string DeviceId { get; set; } // 绑定的设备驱动ID public int Address { get; set; } // 设备寄存器地址 public string DataType { get; set; } // short / int / float / bool public double Value { get; set; } // 当前值 public double HighAlarm { get; set; } // 高报警阈值 public double LowAlarm { get; set; } // 低报警阈值 public DateTime UpdatedAt { get; set; } // 最后更新时间 }

变量表的每个字段都是必要的。DeviceId把变量绑定到某个设备驱动实例上,Address对应PLC寄存器地址,DataType决定读取时怎么解析字节。报警阈值放在变量表里,这样判断报警的逻辑就能统一收敛到变量服务里。

变量服务的核心写入逻辑如下:

public async Task UpdateValueAsync(string variableId, double newValue, IHubContext<DataHub> hub) { var variable = _variables.FirstOrDefault(v => v.Id == variableId); if (variable == null) return; variable.Value = Math.Round(newValue, 3); variable.UpdatedAt = DateTime.Now; // 报警判断:阈值越界时写报警记录,同时推送给前端 if (newValue > variable.HighAlarm || newValue < variable.LowAlarm) { await _db.InsertAlarmAsync(variable, newValue); } // SignalR广播变量变化,所有订阅了该变量的画面都会收到 await hub.Clients.All.SendAsync("VariableChanged", new { variable.Id, variable.Name, variable.Value, variable.UpdatedAt }); }

这段代码是典型的生产者消费者模式:设备驱动是生产者,把数据喂进来;前端画面是消费者,通过SignalR订阅变化。报警判断放在写入路径上而不是前端判断,好处是报警逻辑和数据同源,不会被页面刷新清掉。

参数说明:UpdateValueAsync在设备驱动线程中被调用,注意Math.Round的三位小数是为了防止浮点抖动导致画面数字不停闪。SendAsync广播给所有客户端,如果有不同操作员站需要不同画面,可以改成按组推送,传groupId进去。

3.2 画面编辑器与JSON持久化

画面编辑器是组态软件另一个核心。商业软件里画面文件是私有格式,这套源码里就是一个JSON文档,结构清晰,任何文本编辑器都能改。一张画面的JSON结构如下:

{ "id": "screen_tank_area", "name": "罐区总览", "width": 1920, "height": 1080, "background": "#f0f2f5", "elements": [ { "id": "elem_001", "type": "tank", "x": 120, "y": 80, "width": 220, "height": 320, "props": { "fillColor": "#3b82f6", "borderColor": "#1e293b", "percentVar": "Tank1_Level" } }, { "id": "elem_002", "type": "text", "x": 130, "y": 420, "props": { "content": "液位: {Tank1_Level}", "fontSize": 14 } } ] }

每个图元无非是四件事:位置、大小、类型、属性绑定。props里的percentVar字段把图元和一个变量关联起来,运行时画布会订阅这个变量,值一变就重绘液位高度。注意content字段里的大括号占位符{Tank1_Level},这是文本图元的变量替换语法,运行时把变量值填进去。

保存画面时,前端把整个JSON对象POST到/api/screen/save,后端校验后写入SQLite。编辑器和运行态共用同一套JSON解析逻辑,所以预览和实际运行不会出现两套渲染结果不一致的问题。

前端的核心重绘逻辑:

// 订阅变量变化,更新液位类图元 connection.on("VariableChanged", function (data) { const elements = document.querySelectorAll('[data-var="' + data.name + '"]'); elements.forEach(el => { const percent = (data.value - el.dataset.min) / (el.dataset.max - el.dataset.min) * 100; el.style.height = percent + '%'; el.title = data.name + ': ' + data.value; }); });

这里用>public interface IDeviceDriver { string DeviceId { get; } Task<Dictionary<string, double>> ReadAsync(); Task<bool> ConnectAsync(string host, int port); Task DisconnectAsync(); }

ReadAsync返回一批变量值,键是变量名,值是寄存器数据。Modbus TCP驱动内部把离散读和寄存器读合并成批量请求,减少网络往返。核心读取逻辑:

public override async Task<Dictionary<string, double>> ReadAsync() { var result = new Dictionary<string, double>(); // 按变量地址分组,连续地址合并成一个Modbus读请求 var groups = _variables.GroupBy(v => v.Address / 10); foreach (var group in groups) { var startAddr = group.Min(v => v.Address); var readCount = group.Max(v => v.Address) - startAddr + 1; // 保持寄存器读写,Modbus功能码03 var response = await _client.ReadHoldingRegistersAsync(startAddr, (ushort)readCount); if (response == null) continue; foreach (var variable in group) { var offset = variable.Address - startAddr; result[variable.Name] = _converter.ConvertBytes( response.Data[offset * 2], response.Data[offset * 2 + 1], variable.DataType); } } return result; }

这个实现做了批量聚合优化,避免每个变量发一次Modbus请求。测试时发现,如果不聚合,60个变量轮询一遍要2秒多;聚合后压到300毫秒以内。response.Data在高位与低位字节组合时要注意大小端,不同品牌PLC的字节序不一样,这里把字节序转换放到了_converter里,换设备时改一个类就行。

模拟器驱动更简单,每500毫秒生成一组正弦波加噪声数据,用于项目演示和前期的画面联调:

public override Task<Dictionary<string, double>> ReadAsync() { var t = DateTime.Now.TimeOfDay.TotalSeconds; var result = new Dictionary<string, double>(); foreach (var v in _variables) { // 不同变量用不同频率和相位的正弦波,画面看起来更真实 var freq = 0.1 + (v.Address % 5) * 0.05; var phase = (v.Address * 17) % 360; result[v.Name] = 50 + 30 * Math.Sin(freq * t + phase) + Random.Shared.Next(-1, 1); } return Task.FromResult(result); }

模拟器驱动让整个系统在没有硬件的情况下完全跑起来。这对于做界面开发、演示汇报、给领导看效果来说足够了,真正接现场设备时,只需要把设备的驱动实现替换模拟器,表结构都不用动。

3.4 轻量脚本引擎:条件表达式与报警联动

商业组态软件都有脚本系统,本源码实现了一个精简版表达式引擎,支持比较运算和逻辑组合。一个典型的应用是联锁报警:当罐液位超过90%,且出口阀没有打开时,触发联锁声光报警。

public class ConditionExpression { public string LeftVar { get; set; } // 左操作数变量名 public string Operator { get; set; } // >, <, >=, <=, == public string RightValue { get; set; } // 右值:数字或另一个变量名 public bool Evaluate(Func<string, double?> valueResolver) { var leftVal = valueResolver(LeftVar); if (leftVal == null) return false; double rightVal; if (!double.TryParse(RightValue, out rightVal)) { var rightVarVal = valueResolver(RightValue); if (rightVarVal == null) return false; rightVal = rightVarVal.Value; } return Operator switch { ">" => leftVal > rightVal, "<" => leftVal < rightVal, ">=" => leftVal >= rightVal, "<=" => leftVal <= rightVal, "==" => Math.Abs(leftVal.Value - rightVal) < 0.001, _ => false }; } }

这个表达式引擎是解释执行的,valueResolver是一个委托,运行时传入变量中心的查询函数。多条表达式用&&和||组合,形成联动规则。需要注意浮点相等的比较用Math.Abs做容差判断,直接==比较两个浮点数经常会翻车。

报警联动模块每200毫秒扫描一次规则表,满足条件就记录报警并推送前端,操作员确认后报警状态从闪烁变成常亮,这个状态模型在AlarmState枚举里保存,防止确认后又重复报警。

4. 从零跑起来:环境准备、配置与避坑

4.1 环境准备与初始化

运行环境需要.NET SDK(6.0及以上版本)、Node.js(可选,仅用于前端静态资源构建)和SQLite(不用单独安装,用的是嵌入式库)。首次运行前,打开命令行进入工程根目录:

dotnet restore dotnet run --project GroupApp/GroupApp.csproj

启动成功后控制台会输出监听地址,默认是http://localhost:5000。浏览器打开这个地址,就能看到示例画面和变量列表。首次运行会自动创建group.db数据库文件,并且把预置的示例画面和模拟器变量灌进去。

如果打开后页面空白,先检查浏览器控制台的网络请求是否跨域。SignalR的跨域配置在Program.cs里:

builder.Services.AddCors(options => { options.AddPolicy("AllowAll", policy => policy.AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod()); }); builder.Services.AddSignalR(); app.MapHub<DataHub>("/dataHub");

需要把CORS中间件注册放在UseRouting之后,否则SignalR协商请求会直接被拒。这是新手跑这类项目最常见的坑之一。

4.2 模拟数据源与运行态画面

系统默认启用模拟器驱动,不需要配置PLC连接信息。如果想验证数据实时变化,打开运行画面后能看到数值每秒跳动数十次。为了确认告警功能正常,可以在变量配置界面把Tank1_Level的低报警阈值设到50,模拟器产生的数据会有部分落在阈值以下,报警窗口会持续滚动新记录。

设备驱动的配置存在DeviceConfig.json文件里:

[ { "id": "sim_01", "type": "simulator", "name": "模拟设备一号", "pollIntervalMs": 500, "variables": [ { "name": "Tank1_Level", "address": 1, "dataType": "float" }, { "name": "Tank2_Level", "address": 2, "dataType": "float" }, { "name": "Pump1_State", "address": 3, "dataType": "bool" } ] } ]

想换成Modbus TCP设备,把type改成modbus,加host和port字段即可。轮询间隔pollIntervalMs建议不低于200毫秒,太频繁可能给设备造成压力,也会把后端线程池打满。常见设备的寄存器地址从1开始连续递增,遇到个别PLC地址不连续时,可以用多个variables组来绕过。

4.3 常见问题排查与避坑

跑这套源码的过程中,有几个问题几乎每个使用者都会遇到,集中写在这里省得到处找答案。第一条,变量名称大小写不一致导致画面不刷新。现象是前端画面打开正常,但数据和图元不更新,打开浏览器控制台会看到未找到变量的报错。原因是变量表里的名称是Tank1_Level,画面图元绑定写成了tank1_level,变量服务在解析时做了大小写敏感匹配。解决方法是统一命名规范,在保存变量和画面时全部转成大写,或者改成小写存储、运行时大小写不敏感查找。

第二条,图元ID冲突导致保存画面时数据覆盖。现象是编辑多个画面后,其中一个画面的图元叠加到其他画面上。原因是前端生成的图元ID没有全局唯一性,出现了重复。解决方法是图元ID的生成改用crypto.randomUUID(),不要在编辑多个画面时重置计数器,否则前一画面里手动复制粘贴的图元,保存后ID都是同一个。

第三条,Modbus连接成功但读取一直是0。现象是设备日志显示连接正常,变量值却全部归零。原因多半是Modbus寄存器地址偏移,默认1基地址和0基地址差1,需要按设备手册调整address定义。一般把配置文件里的地址加1或者减1重新加载,数值就会正常。

第四条,SignalR偶发掉线,需要刷新页面才能恢复。现象是长时间挂机后画面停止刷新,页面不刷新就永远恢复不了。原因是默认的SignalR心跳超时设置过短,或者前端没有实现断线重连。解决方案有两种:增加站点心跳和CORS超时设置;或在viewer前端代码里的connection.onclose函数中自动重连,重连时重新订阅变量,这是更稳妥的方式。

第五条,启动时端口被占用。现象是dotnet run后控制台报Address already in use。解决方法是改启动端口,在launchSettings.json里改applicationUrl,或者启动时指定--urls http://localhost:5001。

5. 二次开发:替换协议、扩展图元、改造数据链路

5.1 自定义一个设备驱动

现有驱动只有模拟器和Modbus TCP两种,实际对接的设备可能走串口、MQTT、OPC UA或者其他私有协议。扩展一个新驱动的步骤非常明确:新建一个类实现IDeviceDriver接口,在DeviceConfig.json的type字段对应注册名称,然后在驱动工厂里加一个分支。以新增MQTT驱动为例:

public class MqttDriver : IDeviceDriver { private readonly MqttClient _client; public string DeviceId { get; private set; } public async Task<Dictionary<string, double>> ReadAsync() { // 从内部缓存取最近收到的一帧数据 // 外部消息处理线程负责解析并写入缓存 return _latestValues; } }

驱动工厂的注册逻辑可以做成反射扫描,这样新增驱动不用改工厂代码,把类放到Device/目录下,写清楚特性和配置项就能自动被发现,这是对后期维护最友好的方案。

5.2 新增一个图元类型

画布编辑器里的图元类型在editor/elements/目录下每个文件对应一种类型。新增一个“电机”图元,需要在设计器里注册工具面板图标,定义默认属性和运行时渲染函数。图元运行时渲染的核心是render(ctx, element, variables)函数,拿到变量值后绘制不同状态的图形。新增后别忘了在props里引用变量名,否则图元不会随数据变化。

对于二次开发,最实用的一招是把图元的render函数抽象出来,让不同类型的图元复用同一个渲染逻辑,只通过props里的shape字段驱动不同画法,这样新增一种图形通常只需写几十行绘制代码。

5.3 从模拟器切换到真实PLC的验证清单

改造成现场可用的系统后,验证步骤建议强制走一遍:先在配置里把模拟器驱动替换成真实设备驱动,确认设备连接日志无异常。再逐个打开画面,检查每个图元绑定的变量是否出现在设备变量表中,这一步最容易出错,一个字符的偏差在模拟器里看不出来,因为模拟器不在乎变量名是否匹配,真实PLC设备读不到变量就返回0。接着做报警联动测试,故意把设备寄存器值推到报警线以上,确认报警窗口弹出。最后长时间挂机运行8小时以上,观察SignalR是否稳定不掉线。

这套源码给我的最大教训是:变量名和约束要尽早固定,后期改变量名会牵连画面、脚本、报警配置三处地方。从那以后我在做任何组态系统,都会先建一张变量字典并冻结命名规则,再让画面和脚本开发并行推进。希望这些经验能帮你在二次开发时少踩几个坑。

本文还有配套的精品资源,点击获取

返回列表