一、工业远程监控核心痛点与技术选型逻辑
工业场景中,远程监控的核心矛盾是 “远距离传输”与“低时延、高可靠”的冲突:传统4G/光纤远程监控存在时延高(50-200ms)、断网数据丢失、双向通信不实时等问题,无法满足设备控制、故障应急处理等场景需求。而5G MEC(边缘计算)+ SignalR的组合,恰好针对性解决这些痛点:
5G MEC:将计算节点部署在工业厂区边缘(靠近设备),数据无需回传核心网,时延降至10-50ms,满足工业级实时性要求;SignalR:ASP.NET Core 原生实时通信库,自动适配WebSocket(低时延)、长轮询等协议,支持设备→监控端、监控端→设备的双向实时通信;C#:与工业上位机原有代码无缝兼容,支持Windows/Linux跨平台部署(适配MEC边缘节点),工业协议生态完善(Modbus、OPC UA等)。
核心目标:实现“低时延(≤50ms)、双向实时、高可靠、工业兼容”的远程监控系统,支持设备状态实时采集、控制指令下发、故障告警推送、历史数据追溯等核心功能。
二、整体架构设计(MEC边缘部署+分层解耦)
利用5G MEC的“边缘靠近设备”特性,将核心通信与数据处理模块部署在MEC节点,减少跨网络传输时延,整体架构分为5层:
工业远程监控系统架构(5G MEC边缘部署)
├─ 设备层:工业设备(PLC/传感器/电机)+ 工业网关(协议转换)
│ └─ 协议支持:Modbus RTU/TCP、OPC UA、MC协议、Profinet(通过网关适配)
├─ 接入层:5G模块(工业级)+ 5G基站 + MEC边缘节点
│ └─ 核心:设备与MEC节点通过5G专网通信,时延≤20ms
├─ 边缘层(MEC节点部署):
│ ├─ SignalR Server(ASP.NET Core):双向实时通信核心
│ ├─ 设备接入服务:工业协议解析、数据格式标准化
│ ├─ 数据缓存服务:断线数据本地缓存(避免丢失)
│ └─ 时序数据库(InfluxDB):存储设备实时/历史数据(边缘本地存储,低时延查询)
├─ 传输层:5G/光纤(监控端→MEC节点)
│ └─ 支持WSS(WebSocket Secure)加密传输,保障数据安全
└─ 监控端:PC客户端(WPF)、Web端(Blazor)、移动APP(MAUI)
└─ 功能:实时数据可视化、控制指令下发、故障告警、历史数据查询
关键架构优势:
低时延:SignalR Server部署在MEC节点,设备与服务器距离近(网络跳数≤2),双向通信时延≤50ms;高可靠:MEC本地缓存数据,断网时设备继续采集并缓存,重连后自动补发;SignalR支持自动重连,无需手动干预;工业兼容:通过网关适配主流工业协议,无需改造现有设备;弹性扩展:支持千级设备并发接入,可通过MEC集群扩展容量。
三、核心技术栈选型(工业级稳定组合)
| 模块 | 技术选型 | 核心优势 |
|---|---|---|
| 实时通信框架 | ASP.NET Core SignalR(3.1+) | 支持WebSocket/长轮询,双向实时通信,跨平台 |
| 边缘计算节点 | 5G MEC(运营商/私有部署) | 边缘部署,低时延(10-20ms),本地数据处理 |
| 工业协议适配 | NModbus(Modbus)、OPCFoundation.NetStandard.Opc.Ua(OPC UA)、S7NetPlus(西门子PLC) | 开源成熟,适配主流工业设备 |
| 数据存储 | InfluxDB(时序数据库)+ LiteDB(本地缓存) | 高写入性能,适合工业时序数据,轻量级部署 |
| 客户端框架 | WPF(PC客户端)、Blazor(Web端)、MAUI(移动APP) | 统一C#开发,UI交互流畅,支持跨设备 |
| 数据序列化 | Protobuf(Protocol Buffers) | 二进制序列化,体积小、传输快(降低时延) |
| 高可靠保障 | Polly(重试+熔断)、SignalR自动重连 | 断线重连,避免临时故障导致通信中断 |
| 安全防护 | WSS(WebSocket Secure)、设备认证(JWT) | 数据加密传输,防止非法接入和篡改 |
| 可视化 | OxyPlot(实时曲线)、DevExpress(工业UI) | 工业级数据可视化,支持动态曲线、仪表盘 |
四、核心功能落地(分模块实战)
4.1 第一步:搭建MEC边缘层SignalR Server(ASP.NET Core)
SignalR Server是双向通信的核心,部署在MEC节点,负责设备接入、数据转发、指令路由。
4.1.1 创建ASP.NET Core SignalR项目
新建ASP.NET Core Web应用(.NET 8),选择“空”模板;安装SignalR核心包:
Install-Package Microsoft.AspNetCore.SignalR
Install-Package Google.Protobuf(Protobuf序列化)
Install-Package Polly(高可靠)
Install-Package InfluxDB.Client(时序数据库)
4.1.2 定义Protobuf数据协议(低时延序列化)
创建文件,定义设备数据、控制指令、告警信息的格式:
IndustrialProtocol.proto
syntax = "proto3";
// 设备信息
message DeviceInfo {
string DeviceId = 1; // 设备唯一标识(如PLC序列号)
string DeviceType = 2; // 设备类型(如"SiemensPLC"、"VibrationSensor")
string IpAddress = 3; // 设备IP(网关IP)
string Status = 4; // 设备状态("Online"/"Offline"/"Fault")
}
// 设备实时数据(时序数据)
message DeviceData {
string DeviceId = 1;
int64 Timestamp = 2; // 时间戳(毫秒级)
map<string, double> DataPoints = 3; // 数据项(如"Temperature":25.5, "Pressure":0.8)
string Quality = 4; // 数据质量("Good"/"Bad")
}
// 控制指令(监控端→设备)
message ControlCommand {
string CommandId = 1; // 指令唯一ID(避免重复执行)
string DeviceId = 2; // 目标设备ID
string CommandType = 3; // 指令类型(如"SetSpeed"、"StartDevice"、"StopDevice")
map<string, string> Parameters = 4; // 指令参数(如"Speed":500)
int64 ExpireTime = 5; // 指令过期时间(毫秒级)
}
// 指令执行结果(设备→监控端)
message CommandResult {
string CommandId = 1;
bool Success = 2;
string Message = 3; // 执行结果描述(如"执行成功"、"参数错误")
int64 ExecuteTimestamp = 4; // 执行时间戳
}
// 设备告警信息
message DeviceAlarm {
string AlarmId = 1;
string DeviceId = 2;
string AlarmLevel = 3; // 告警级别("Info"/"Warn"/"Error"/"Fatal")
string AlarmContent = 4; // 告警内容(如"温度超标")
int64 AlarmTimestamp = 5; // 告警时间戳
bool IsCleared = 6; // 是否已清除
}
通过Protobuf工具生成C#代码(需安装),生成后添加到项目中。
Google.Protobuf.Tools
4.1.3 定义SignalR Hub(双向通信接口)
Hub是SignalR的核心,定义设备与监控端的交互方法:
using Microsoft.AspNetCore.SignalR;
using Google.Protobuf;
using IndustrialProtocol; // Protobuf生成的命名空间
using System.Collections.Concurrent;
using Serilog;
namespace MecSignalRServer.Hubs
{
/// <summary>
/// 工业设备通信Hub(MEC边缘部署)
/// </summary>
public class IndustrialHub : Hub
{
// 设备在线状态缓存(DeviceId → ConnectionId)
private static readonly ConcurrentDictionary<string, string> _onlineDevices = new();
// 监控端连接缓存(ConnectionId → 监控端标识)
private static readonly ConcurrentDictionary<string, string> _onlineMonitors = new();
// 待确认的控制指令(CommandId → ControlCommand)
private static readonly ConcurrentDictionary<string, ControlCommand> _pendingCommands = new();
#region 设备端接口(设备→MEC Server)
/// <summary>
/// 设备注册(上线)
/// </summary>
public async Task DeviceRegister(DeviceInfo deviceInfo)
{
if (string.IsNullOrEmpty(deviceInfo.DeviceId))
{
await Clients.Caller.SendAsync("RegisterFailed", "设备ID不能为空");
return;
}
// 记录设备连接ID(用于后续下发指令)
_onlineDevices[deviceInfo.DeviceId] = Context.ConnectionId;
deviceInfo.Status = "Online";
Log.Information($"设备上线:{deviceInfo.DeviceId}({deviceInfo.DeviceType}),连接ID:{Context.ConnectionId}");
// 通知所有监控端:设备上线
await Clients.AllExcept(Context.ConnectionId).SendAsync("DeviceOnline", deviceInfo);
// 响应设备:注册成功
await Clients.Caller.SendAsync("RegisterSuccess", "设备注册成功");
// 重连后补发缓存数据(如果设备之前断线有缓存)
await补发CachedDataAsync(deviceInfo.DeviceId);
}
/// <summary>
/// 设备上传实时数据
/// </summary>
public async Task UploadDeviceData(DeviceData deviceData)
{
if (!_onlineDevices.ContainsKey(deviceData.DeviceId))
{
await Clients.Caller.SendAsync("UploadFailed", "设备未注册,请先注册");
return;
}
// 1. 存储数据到InfluxDB(MEC本地时序数据库)
await _influxDbService.WriteDeviceDataAsync(deviceData);
// 2. 转发数据到所有在线监控端(低时延实时推送)
await Clients.AllExcept(Context.ConnectionId).SendAsync("ReceiveDeviceData", deviceData);
Log.Debug($"设备{deviceData.DeviceId}上传数据:{deviceData.DataPoints.Count}个数据项");
}
/// <summary>
/// 设备上报控制指令执行结果
/// </summary>
public async Task ReportCommandResult(CommandResult commandResult)
{
if (!_pendingCommands.TryRemove(commandResult.CommandId, out var command))
{
await Clients.Caller.SendAsync("ReportFailed", "指令不存在或已过期");
return;
}
Log.Information($"设备{command.DeviceId}执行指令{command.CommandId}:{(commandResult.Success ? "成功" : "失败")},描述:{commandResult.Message}");
// 转发执行结果到对应的监控端(如果监控端在线)
foreach (var (monitorConnId, _) in _onlineMonitors)
{
await Clients.Client(monitorConnId).SendAsync("ReceiveCommandResult", commandResult);
}
}
/// <summary>
/// 设备上报告警信息
/// </summary>
public async Task ReportDeviceAlarm(DeviceAlarm alarm)
{
if (!_onlineDevices.ContainsKey(alarm.DeviceId))
{
await Clients.Caller.SendAsync("AlarmReportFailed", "设备未注册");
return;
}
// 存储告警到数据库
await _influxDbService.WriteDeviceAlarmAsync(alarm);
// 推送告警到所有监控端(支持声光报警)
await Clients.AllExcept(Context.ConnectionId).SendAsync("ReceiveDeviceAlarm", alarm);
Log.Warn($"设备{alarm.DeviceId}告警:[{alarm.AlarmLevel}] {alarm.AlarmContent}");
}
#endregion
#region 监控端接口(监控端→MEC Server)
/// <summary>
/// 监控端连接注册
/// </summary>
public async Task MonitorRegister(string monitorId)
{
if (string.IsNullOrEmpty(monitorId))
{
await Clients.Caller.SendAsync("MonitorRegisterFailed", "监控端ID不能为空");
return;
}
_onlineMonitors[Context.ConnectionId] = monitorId;
Log.Information($"监控端上线:{monitorId},连接ID:{Context.ConnectionId}");
// 响应监控端:上线成功,并返回当前在线设备列表
var onlineDeviceList = _onlineDevices.Keys
.Select(deviceId => new DeviceInfo { DeviceId = deviceId, Status = "Online" })
.ToList();
await Clients.Caller.SendAsync("MonitorRegisterSuccess", onlineDeviceList);
}
/// <summary>
/// 监控端下发控制指令
/// </summary>
public async Task SendControlCommand(ControlCommand command)
{
if (string.IsNullOrEmpty(command.DeviceId) || string.IsNullOrEmpty(command.CommandId))
{
await Clients.Caller.SendAsync("CommandSendFailed", "设备ID或指令ID不能为空");
return;
}
// 检查设备是否在线
if (!_onlineDevices.TryGetValue(command.DeviceId, out var deviceConnId))
{
await Clients.Caller.SendAsync("CommandSendFailed", "设备离线,无法下发指令");
return;
}
// 检查指令是否过期
if (command.ExpireTime < DateTimeOffset.Now.ToUnixTimeMilliseconds())
{
await Clients.Caller.SendAsync("CommandSendFailed", "指令已过期");
return;
}
// 缓存待执行指令(用于后续确认)
_pendingCommands.TryAdd(command.CommandId, command);
// 下发指令到目标设备(低时延转发)
await Clients.Client(deviceConnId).SendAsync("ReceiveControlCommand", command);
Log.Information($"监控端下发指令:{command.CommandId} → 设备{command.DeviceId},指令类型:{command.CommandType}");
await Clients.Caller.SendAsync("CommandSendSuccess", "指令下发成功");
}
/// <summary>
/// 监控端查询历史数据
/// </summary>
public async Task QueryHistoryData(string deviceId, long startTime, long endTime)
{
if (!_onlineMonitors.ContainsKey(Context.ConnectionId))
{
await Clients.Caller.SendAsync("QueryFailed", "监控端未注册");
return;
}
// 从MEC本地InfluxDB查询历史数据(低时延)
var historyData = await _influxDbService.QueryDeviceDataAsync(deviceId, startTime, endTime);
await Clients.Caller.SendAsync("ReceiveHistoryData", historyData);
Log.Debug($"监控端查询设备{deviceId}历史数据:{historyData.Count}条");
}
#endregion
#region 连接生命周期管理(断线处理)
/// <summary>
/// 连接断开时触发
/// </summary>
public override async Task OnDisconnectedAsync(Exception exception)
{
// 1. 检查是否是设备断开
var offlineDevice = _onlineDevices.FirstOrDefault(kv => kv.Value == Context.ConnectionId);
if (!string.IsNullOrEmpty(offlineDevice.Key))
{
_onlineDevices.TryRemove(offlineDevice.Key, out _);
Log.Warn($"设备离线:{offlineDevice.Key},原因:{exception?.Message ?? "正常断开"}");
// 通知所有监控端:设备离线
await Clients.All.SendAsync("DeviceOffline", new DeviceInfo { DeviceId = offlineDevice.Key, Status = "Offline" });
}
// 2. 检查是否是监控端断开
_onlineMonitors.TryRemove(Context.ConnectionId, out var offlineMonitor);
if (!string.IsNullOrEmpty(offlineMonitor))
{
Log.Information($"监控端离线:{offlineMonitor}");
}
await base.OnDisconnectedAsync(exception);
}
#endregion
#region 私有辅助方法
/// <summary>
/// 补发设备断线期间的缓存数据
/// </summary>
private async Task 补发CachedDataAsync(string deviceId)
{
// 从LiteDB缓存中查询设备断线期间的数据
var cachedDataList = await _localCacheService.GetCachedDeviceDataAsync(deviceId);
if (cachedDataList.Any())
{
Log.Information($"设备{deviceId}重连,补发缓存数据:{cachedDataList.Count}条");
// 批量上传到InfluxDB
await _influxDbService.BatchWriteDeviceDataAsync(cachedDataList);
// 转发到监控端
foreach (var data in cachedDataList)
{
await Clients.All.SendAsync("ReceiveDeviceData", data);
}
// 清空缓存
await _localCacheService.ClearCachedDeviceDataAsync(deviceId);
}
}
#endregion
// 依赖注入服务(InfluxDB、本地缓存)
private readonly IInfluxDbService _influxDbService;
private readonly ILocalCacheService _localCacheService;
public IndustrialHub(IInfluxDbService influxDbService, ILocalCacheService localCacheService)
{
_influxDbService = influxDbService;
_localCacheService = localCacheService;
}
}
}
4.1.4 配置SignalR Server(Program.cs)
using Microsoft.AspNetCore.SignalR;
using MecSignalRServer.Hubs;
using MecSignalRServer.Services;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// 1. 配置Serilog日志
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.WriteTo.File("mec_signalr_log_.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
builder.Logging.ClearProviders();
builder.Logging.AddSerilog();
// 2. 添加SignalR服务(配置WebSocket传输,禁用长轮询以降低时延)
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = true; // 开发环境启用详细错误
options.KeepAliveInterval = TimeSpan.FromSeconds(10); // 心跳间隔(5G网络稳定,可适当延长)
})
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.IncludeFields = true;
})
.AddProtobufProtocol(); // 启用Protobuf序列化(低时延)
// 3. 依赖注入:InfluxDB服务、本地缓存服务
builder.Services.AddSingleton<IInfluxDbService, InfluxDbService>();
builder.Services.AddSingleton<ILocalCacheService, LiteDbCacheService>();
var app = builder.Build();
// 4. 配置跨域(工业场景建议限制Origin,避免非法访问)
app.UseCors(options =>
{
options.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.WithOrigins("http://localhost:5000", "https://monitor.industrial.com"); // 监控端域名
});
// 5. 映射SignalR Hub
app.MapHub<IndustrialHub>("/industrialHub");
// 6. 启动服务(监听5G MEC节点IP,端口8080)
app.Run("http://0.0.0.0:8080");
4.2 第二步:工业设备接入(网关+SignalR客户端)
工业设备(PLC、传感器)通过工业网关接入MEC的SignalR Server,网关负责协议解析、数据采集、SignalR通信。
4.2.1 网关核心功能
工业协议解析(如Modbus TCP读取PLC寄存器);数据格式标准化(转换为Protobuf的DeviceData格式);SignalR客户端通信(注册、上传数据、接收指令);断线缓存(断网时缓存数据到本地LiteDB);自动重连(SignalR客户端+Polly重试)。
4.2.2 网关代码实现(.NET 8控制台应用)
using Microsoft.AspNetCore.SignalR.Client;
using Google.Protobuf;
using IndustrialProtocol;
using NModbus;
using Serilog;
using Polly;
using Polly.Retry;
using System.Net.Sockets;
namespace IndustrialGateway
{
class Program
{
// 配置参数(MEC SignalR Server地址、设备信息、工业协议参数)
private const string MecSignalRServerUrl = "http://192.168.100.50:8080/industrialHub"; // MEC节点IP
private const string DeviceId = "SiemensPLC_001";
private const string DeviceType = "SiemensPLC";
private const string PlcIp = "192.168.100.100"; // PLC IP
private const int PlcPort = 502; // Modbus TCP端口
private const int DataCollectionInterval = 100; // 数据采集间隔(100ms,10Hz)
private static HubConnection _signalRConnection;
private static IModbusMaster _plcMaster;
private static readonly LiteDbCacheService _localCacheService = new LiteDbCacheService();
private static readonly CancellationTokenSource _cts = new();
// 重试策略(SignalR重连+PLC连接重连)
private static readonly RetryPolicy _retryPolicy = Policy
.Handle<Exception>(ex => ex is SocketException || ex is HubException)
.WaitAndRetryForever(
sleepDurationProvider: retryCount =>
{
var interval = TimeSpan.FromMilliseconds(Math.Pow(2, retryCount) * 100);
return interval > TimeSpan.FromSeconds(10) ? TimeSpan.FromSeconds(10) : interval;
},
onRetry: (ex, span) =>
{
Log.Warn($"连接异常,{span.TotalSeconds:F1}秒后重试:{ex.Message}");
});
static async Task Main(string[] args)
{
// 初始化日志
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Info()
.WriteTo.Console()
.WriteTo.File("gateway_log_.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
try
{
// 1. 初始化PLC连接(Modbus TCP)
await InitPlcConnectionAsync();
// 2. 初始化SignalR连接(MEC Server)
await InitSignalRConnectionAsync();
// 3. 启动数据采集与上传任务
_ = Task.Run(StartDataCollectionAsync, _cts.Token);
Log.Info("工业网关启动成功,开始采集数据...");
await Task.Delay(-1, _cts.Token); // 阻塞主线程
}
catch (Exception ex)
{
Log.Fatal(ex, "工业网关启动失败");
}
finally
{
_cts.Cancel();
await CleanupAsync();
Log.CloseAndFlush();
}
}
/// <summary>
/// 初始化PLC连接(Modbus TCP)
/// </summary>
private static async Task InitPlcConnectionAsync()
{
await _retryPolicy.ExecuteAsync(async () =>
{
var tcpClient = new TcpClient();
await tcpClient.ConnectAsync(PlcIp, PlcPort);
_plcMaster = ModbusIpMaster.CreateIp(tcpClient);
_plcMaster.Transport.ReadTimeout = 500;
_plcMaster.Transport.WriteTimeout = 500;
Log.Info($"PLC连接成功:{PlcIp}:{PlcPort}");
});
}
/// <summary>
/// 初始化SignalR连接(MEC Server)
/// </summary>
private static async Task InitSignalRConnectionAsync()
{
// 创建SignalR连接
_signalRConnection = new HubConnectionBuilder()
.WithUrl(MecSignalRServerUrl)
.AddProtobufProtocol() // 使用Protobuf序列化
.WithAutomaticReconnect(new[] { 1000, 3000, 5000, 10000 }) // 自动重连间隔
.Build();
// 注册连接事件
_signalRConnection.OnConnectedAsync += OnSignalRConnectedAsync;
_signalRConnection.OnDisconnectedAsync += OnSignalRDisconnectedAsync;
_signalRConnection.On<ControlCommand>("ReceiveControlCommand", HandleControlCommandAsync);
_signalRConnection.On<string>("RegisterSuccess", msg => Log.Info($"SignalR注册成功:{msg}"));
_signalRConnection.On<string>("UploadFailed", msg => Log.Error($"数据上传失败:{msg}"));
// 启动连接(带重试)
await _retryPolicy.ExecuteAsync(async () =>
{
await _signalRConnection.StartAsync();
Log.Info($"SignalR连接成功:{MecSignalRServerUrl}");
});
}
/// <summary>
/// SignalR连接成功后,注册设备
/// </summary>
private static async Task OnSignalRConnectedAsync()
{
var deviceInfo = new DeviceInfo
{
DeviceId = DeviceId,
DeviceType = DeviceType,
IpAddress = PlcIp,
Status = "Online"
};
await _signalRConnection.SendAsync("DeviceRegister", deviceInfo);
}
/// <summary>
/// SignalR断开连接处理
/// </summary>
private static Task OnSignalRDisconnectedAsync(Exception exception)
{
Log.Warn($"SignalR连接断开:{exception?.Message ?? "正常断开"}");
return Task.CompletedTask;
}
/// <summary>
/// 数据采集与上传
/// </summary>
private static async Task StartDataCollectionAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
// 1. 采集PLC数据(Modbus TCP读取保持寄存器)
var deviceData = await CollectPlcDataAsync();
if (deviceData == null)
{
await Task.Delay(DataCollectionInterval, _cts.Token);
continue;
}
// 2. 检查SignalR连接状态,决定上传或缓存
if (_signalRConnection.State == HubConnectionState.Connected)
{
// 连接正常,直接上传
await _signalRConnection.SendAsync("UploadDeviceData", deviceData);
}
else
{
// 断线,缓存到本地
await _localCacheService.CacheDeviceDataAsync(deviceData);
Log.Warn($"SignalR离线,缓存数据:{deviceData.Timestamp}");
}
}
catch (Exception ex)
{
Log.Error(ex, "数据采集/上传异常");
// 重新初始化PLC连接
await InitPlcConnectionAsync();
}
await Task.Delay(DataCollectionInterval, _cts.Token);
}
}
/// <summary>
/// 采集PLC数据(Modbus TCP示例)
/// </summary>
private static async Task<DeviceData> CollectPlcDataAsync()
{
try
{
// 读取PLC保持寄存器(地址0-4:温度、压力、转速、电流、电压)
var registers = await _plcMaster.ReadHoldingRegistersAsync(1, 0, 5); // 从站地址1,起始地址0,长度5
// 转换寄存器值(假设16位寄存器,缩放因子0.1)
var dataPoints = new Dictionary<string, double>
{
["Temperature"] = registers[0] * 0.1, // 温度(℃)
["Pressure"] = registers[1] * 0.1, // 压力(MPa)
["Speed"] = registers[2], // 转速(rpm)
["Current"] = registers[3] * 0.1, // 电流(A)
["Voltage"] = registers[4] * 0.1 // 电压(V)
};
// 构建DeviceData对象
return new DeviceData
{
DeviceId = DeviceId,
Timestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds(),
DataPoints = { dataPoints },
Quality = "Good"
};
}
catch (Exception ex)
{
Log.Error(ex, "PLC数据采集失败");
return null;
}
}
/// <summary>
/// 处理监控端下发的控制指令
/// </summary>
private static async Task HandleControlCommandAsync(ControlCommand command)
{
Log.Info($"收到控制指令:{command.CommandId},类型:{command.CommandType}");
var commandResult = new CommandResult
{
CommandId = command.CommandId,
Success = false,
ExecuteTimestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds()
};
try
{
// 执行指令(示例:设置转速)
if (command.CommandType == "SetSpeed" && command.Parameters.TryGetValue("Speed", out var speedStr))
{
if (int.TryParse(speedStr, out var speed) && speed >= 0 && speed <= 1500)
{
// 写入PLC保持寄存器(地址2:转速)
await _plcMaster.WriteSingleRegisterAsync(1, 2, (ushort)speed);
commandResult.Success = true;
commandResult.Message = $"转速设置成功:{speed} rpm";
Log.Info(commandResult.Message);
}
else
{
commandResult.Message = "转速参数无效(范围:0-1500 rpm)";
}
}
else
{
commandResult.Message = "不支持的指令类型或参数";
}
}
catch (Exception ex)
{
commandResult.Message = $"指令执行失败:{ex.Message}";
Log.Error(ex, "指令执行异常");
}
finally
{
// 上报指令执行结果
if (_signalRConnection.State == HubConnectionState.Connected)
{
await _signalRConnection.SendAsync("ReportCommandResult", commandResult);
}
else
{
// 断线时缓存结果,重连后上报(可选)
Log.Warn("SignalR离线,指令结果未上报");
}
}
}
/// <summary>
/// 资源清理
/// </summary>
private static async Task CleanupAsync()
{
if (_signalRConnection != null)
{
await _signalRConnection.StopAsync();
await _signalRConnection.DisposeAsync();
}
_plcMaster?.Transport?.Dispose();
_cts.Dispose();
Log.Info("工业网关已关闭");
}
}
}
4.3 第三步:监控端实现(WPF客户端+SignalR)
监控端负责实时数据可视化、控制指令下发、告警接收,采用WPF+OxyPlot实现工业级UI。
4.3.1 WPF客户端核心功能
SignalR连接MEC Server,接收实时数据、告警;下发控制指令(如设置PLC转速);实时曲线展示(温度、压力、转速等);历史数据查询与导出;告警弹窗与声光提示。
4.3.2 WPF客户端代码(核心部分)
(1)SignalR通信服务
using Microsoft.AspNetCore.SignalR.Client;
using Google.Protobuf;
using IndustrialProtocol;
using Serilog;
using System.Collections.ObjectModel;
using System.Windows;
namespace IndustrialMonitorClient.Services
{
public class SignalRMonitorService : INotifyPropertyChanged, IDisposable
{
private const string MecSignalRServerUrl = "http://192.168.100.50:8080/industrialHub"; // MEC节点IP
private const string MonitorId = "Monitor_PC_001";
private HubConnection _signalRConnection;
private bool _isConnected;
private readonly ObservableCollection<DeviceInfo> _onlineDevices = new();
private readonly ObservableCollection<DeviceAlarm> _deviceAlarms = new();
private readonly Dictionary<string, ObservableCollection<DeviceData>> _deviceRealTimeData = new(); // DeviceId → 实时数据列表
private bool _isDisposed;
// 属性变更事件(绑定UI)
public event PropertyChangedEventHandler PropertyChanged;
public bool IsConnected { get => _isConnected; set { _isConnected = value; OnPropertyChanged(); } }
public ObservableCollection<DeviceInfo> OnlineDevices => _onlineDevices;
public ObservableCollection<DeviceAlarm> DeviceAlarms => _deviceAlarms;
public IReadOnlyDictionary<string, ObservableCollection<DeviceData>> DeviceRealTimeData => _deviceRealTimeData;
/// <summary>
/// 初始化SignalR连接
/// </summary>
public async Task InitConnectionAsync()
{
_signalRConnection = new HubConnectionBuilder()
.WithUrl(MecSignalRServerUrl)
.AddProtobufProtocol()
.WithAutomaticReconnect()
.Build();
// 注册事件
_signalRConnection.OnConnectedAsync += OnConnectedAsync;
_signalRConnection.OnDisconnectedAsync += OnDisconnectedAsync;
_signalRConnection.On<List<DeviceInfo>>("MonitorRegisterSuccess", OnMonitorRegisterSuccess);
_signalRConnection.On<DeviceData>("ReceiveDeviceData", OnReceiveDeviceData);
_signalRConnection.On<DeviceAlarm>("ReceiveDeviceAlarm", OnReceiveDeviceAlarm);
_signalRConnection.On<CommandResult>("ReceiveCommandResult", OnReceiveCommandResult);
_signalRConnection.On<DeviceInfo>("DeviceOnline", OnDeviceOnline);
_signalRConnection.On<string>("DeviceOffline", OnDeviceOffline);
// 启动连接
try
{
await _signalRConnection.StartAsync();
IsConnected = true;
Log.Info("SignalR连接成功,注册监控端...");
await _signalRConnection.SendAsync("MonitorRegister", MonitorId);
}
catch (Exception ex)
{
IsConnected = false;
Log.Error(ex, "SignalR连接失败");
MessageBox.Show($"SignalR连接失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>
/// 下发控制指令
/// </summary>
public async Task SendCommandAsync(string deviceId, string commandType, Dictionary<string, string> parameters)
{
if (!IsConnected)
{
MessageBox.Show("未连接到MEC服务器,无法下发指令", "警告", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var command = new ControlCommand
{
CommandId = Guid.NewGuid().ToString(),
DeviceId = deviceId,
CommandType = commandType,
Parameters = { parameters },
ExpireTime = DateTimeOffset.Now.AddSeconds(30).ToUnixTimeMilliseconds() // 30秒过期
};
try
{
await _signalRConnection.SendAsync("SendControlCommand", command);
}
catch (Exception ex)
{
Log.Error(ex, "指令下发失败");
MessageBox.Show($"指令下发失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>
/// 查询历史数据
/// </summary>
public async Task QueryHistoryDataAsync(string deviceId, DateTime startTime, DateTime endTime)
{
if (!IsConnected)
{
MessageBox.Show("未连接到MEC服务器,无法查询历史数据", "警告", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var startTimestamp = DateTimeOffset.Now.FromUnixTimeMilliseconds(startTime).ToUnixTimeMilliseconds();
var endTimestamp = DateTimeOffset.Now.FromUnixTimeMilliseconds(endTime).ToUnixTimeMilliseconds();
try
{
await _signalRConnection.SendAsync("QueryHistoryData", deviceId, startTimestamp, endTimestamp);
}
catch (Exception ex)
{
Log.Error(ex, "历史数据查询失败");
MessageBox.Show($"历史数据查询失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
#region 事件处理方法
private Task OnConnectedAsync()
{
IsConnected = true;
Log.Info("SignalR连接成功");
return Task.CompletedTask;
}
private Task OnDisconnectedAsync(Exception exception)
{
IsConnected = false;
Log.Warn($"SignalR连接断开:{exception?.Message ?? "正常断开"}");
return Task.CompletedTask;
}
private void OnMonitorRegisterSuccess(List<DeviceInfo> onlineDevices)
{
Application.Current.Dispatcher.Invoke(() =>
{
_onlineDevices.Clear();
foreach (var device in onlineDevices)
{
_onlineDevices.Add(device);
_deviceRealTimeData.TryAdd(device.DeviceId, new ObservableCollection<DeviceData>());
}
});
}
private void OnReceiveDeviceData(DeviceData data)
{
Application.Current.Dispatcher.Invoke(() =>
{
if (!_deviceRealTimeData.ContainsKey(data.DeviceId))
{
_deviceRealTimeData[data.DeviceId] = new ObservableCollection<DeviceData>();
}
var dataList = _deviceRealTimeData[data.DeviceId];
dataList.Add(data);
// 限制实时数据列表长度(仅保留最近1000条)
if (dataList.Count > 1000)
{
dataList.RemoveAt(0);
}
// 通知UI更新曲线
OnPropertyChanged($"DeviceRealTimeData[{data.DeviceId}]");
});
}
private void OnReceiveDeviceAlarm(DeviceAlarm alarm)
{
Application.Current.Dispatcher.Invoke(() =>
{
_deviceAlarms.Insert(0, alarm);
// 限制告警列表长度(仅保留最近100条)
if (_deviceAlarms.Count > 100)
{
_deviceAlarms.RemoveAt(_deviceAlarms.Count - 1);
}
// 弹出告警窗口(严重告警触发声光报警)
if (alarm.AlarmLevel == "Error" || alarm.AlarmLevel == "Fatal")
{
var alarmWindow = new AlarmWindow(alarm);
alarmWindow.Show();
// 触发系统声音
System.Media.SystemSounds.Exclamation.Play();
}
});
}
private void OnReceiveCommandResult(CommandResult result)
{
Application.Current.Dispatcher.Invoke(() =>
{
var message = result.Success ? $"指令执行成功:{result.Message}" : $"指令执行失败:{result.Message}";
MessageBox.Show(message, "指令执行结果", MessageBoxButton.OK, result.Success ? MessageBoxImage.Information : MessageBoxImage.Error);
});
}
private void OnDeviceOnline(DeviceInfo device)
{
Application.Current.Dispatcher.Invoke(() =>
{
if (!_onlineDevices.Any(d => d.DeviceId == device.DeviceId))
{
_onlineDevices.Add(device);
_deviceRealTimeData.TryAdd(device.DeviceId, new ObservableCollection<DeviceData>());
}
else
{
var existingDevice = _onlineDevices.First(d => d.DeviceId == device.DeviceId);
existingDevice.Status = "Online";
}
});
}
private void OnDeviceOffline(string deviceId)
{
Application.Current.Dispatcher.Invoke(() =>
{
var device = _onlineDevices.FirstOrDefault(d => d.DeviceId == deviceId);
if (device != null)
{
device.Status = "Offline";
}
});
}
#endregion
#region 辅助方法
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_isDisposed) return;
if (disposing)
{
_signalRConnection?.DisposeAsync().Wait();
}
_isDisposed = true;
}
#endregion
}
}
(2)WPF UI核心布局(MainWindow.xaml)
<Window x:Class="IndustrialMonitorClient.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:oxy="http://oxyplot.org/wpf"
xmlns:services="clr-namespace:IndustrialMonitorClient.Services"
Title="工业设备远程监控系统" Height="900" Width="1600">
<Window.Resources>
<services:SignalRMonitorService x:Key="MonitorService"/>
</Window.Resources>
<Grid DataContext="{StaticResource MonitorService}">
<!-- 顶部状态栏 -->
<Grid Margin="0,0,0,820" Background="#2C3E50">
<TextBlock Text="工业设备远程监控系统" FontSize="24" Foreground="White" Margin="20,10"/>
<TextBlock Text="{Binding IsConnected, Converter={StaticResource BoolToConnectStatusConverter}}"
FontSize="16" Foreground="{Binding IsConnected, Converter={StaticResource BoolToColorConverter}}"
Margin="1400,15" HorizontalAlignment="Right"/>
</Grid>
<!-- 左侧设备列表 -->
<Grid Margin="0,60,1300,30" Background="#ECF0F1">
<GroupBox Header="在线设备" Margin="10,10,10,10">
<ListBox ItemsSource="{Binding OnlineDevices}" SelectedItem="{Binding SelectedDevice}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding DeviceId}" FontSize="14" Width="150"/>
<TextBlock Text="{Binding DeviceType}" FontSize="14" Width="120"/>
<TextBlock Text="{Binding Status}" FontSize="14">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="Green" />
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="Offline">
<Setter Property="Foreground" Value="Red" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</GroupBox>
<GroupBox Header="设备告警" Margin="10,220,10,10" Height="300">
<ListBox ItemsSource="{Binding DeviceAlarms}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Vertical">
<TextBlock Text="{Binding AlarmContent}" FontSize="12" FontWeight="Bold">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="Orange" />
<Style.Triggers>
<DataTrigger Binding="{Binding AlarmLevel}" Value="Error">
<Setter Property="Foreground" Value="Red" />
</DataTrigger>
<DataTrigger Binding="{Binding AlarmLevel}" Value="Fatal">
<Setter Property="Foreground" Value="DarkRed" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="设备:{Binding DeviceId} | 时间:{Binding AlarmTimestamp, Converter={StaticResource TimestampToDateTimeConverter}} | 级别:{Binding AlarmLevel}" FontSize="10" Foreground="Gray"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</GroupBox>
</Grid>
<!-- 右侧实时曲线与控制区域 -->
<Grid Margin="300,60,30,30">
<!-- 实时曲线(温度、压力、转速) -->
<GroupBox Header="实时数据曲线" Margin="10,10,10,200">
<Grid>
<oxy:PlotView x:Name="TemperaturePlot" Model="{Binding TemperaturePlotModel}" Margin="10,10,10,150"/>
<oxy:PlotView x:Name="PressurePlot" Model="{Binding PressurePlotModel}" Margin="10,160,10,10"/>
</Grid>
</GroupBox>
<!-- 控制指令区域 -->
<GroupBox Header="控制指令" Margin="10,600,10,10" Height="150">
<Grid>
<TextBlock Text="目标设备:" Margin="20,20,0,0"/>
<ComboBox x:Name="DeviceComboBox" ItemsSource="{Binding OnlineDevices}" DisplayMemberPath="DeviceId" Margin="100,20,600,0" Width="200"/>
<TextBlock Text="指令类型:" Margin="20,60,0,0"/>
<ComboBox x:Name="CommandComboBox" Margin="100,60,600,0" Width="200">
<ComboBoxItem Content="SetSpeed" Tag="SetSpeed"/>
<ComboBoxItem Content="StartDevice" Tag="StartDevice"/>
<ComboBoxItem Content="StopDevice" Tag="StopDevice"/>
</ComboBox>
<TextBlock Text="参数(Speed):" Margin="20,100,0,0"/>
<TextBox x:Name="ParameterTextBox" Margin="120,100,600,0" Width="100" Text="500"/>
<Button Content="下发指令" Margin="230,100,0,0" Click="SendCommandButton_Click"/>
</Grid>
</GroupBox>
</Grid>
</Grid>
</Window>
4.4 第四步:5G MEC部署与配置
4.4.1 MEC节点部署要求
位置:部署在工业厂区内或靠近厂区的5G基站机房,确保设备与MEC节点的5G信号强度≥-85dBm;硬件:工业级服务器(CPU≥8核,内存≥16GB,硬盘≥500GB SSD),支持有线/5G双模接入;网络:MEC节点接入5G核心网(SA独立组网),配置工业专用切片(QoS保障,时延≤20ms);软件:安装Linux(Ubuntu 22.04 LTS)或Windows Server 2022,部署Docker容器化的SignalR Server、InfluxDB、LiteDB。
4.4.2 容器化部署(Docker Compose)
创建文件,一键部署MEC边缘层服务:
docker-compose.yml
version: '3.8'
services:
# SignalR Server
signalr-server:
build: ./MecSignalRServer
ports:
- "8080:8080"
networks:
- mec-network
restart: always
volumes:
- ./signalr-logs:/app/logs
- ./liteDB:/app/liteDB
environment:
- ASPNETCORE_ENVIRONMENT=Production
- INFLUXDB_URL=http://influxdb:8086
- INFLUXDB_TOKEN=your-influxdb-token
- INFLUXDB_ORG=industrial-org
- INFLUXDB_BUCKET=industrial-data
# InfluxDB(时序数据库)
influxdb:
image: influxdb:2.7-alpine
ports:
- "8086:8086"
networks:
- mec-network
restart: always
volumes:
- ./influxdb-data:/var/lib/influxdb2
environment:
- DOCKER_INFLUXDB_INIT_MODE=setup
- DOCKER_INFLUXDB_INIT_USERNAME=admin
- DOCKER_INFLUXDB_INIT_PASSWORD=industrial123
- DOCKER_INFLUXDB_INIT_ORG=industrial-org
- DOCKER_INFLUXDB_INIT_BUCKET=industrial-data
- DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=your-influxdb-token
networks:
mec-network:
driver: bridge
4.4.3 5G网络配置
设备侧(网关)安装工业级5G模块(如华为ME909s-821、移远EC200U),插入5G工业卡(开通专用切片);监控端(PC/手机)通过5G CPE或内置5G模块接入同一5G切片网络,确保与MEC节点的网络连通性;配置MEC节点的防火墙,开放8080端口(SignalR)、8086端口(InfluxDB),仅允许工业网段访问。
五、低时延优化(核心技术亮点)
5.1 传输层优化
协议选择:SignalR强制使用WebSocket协议(禁用长轮询),WebSocket是全双工通信,减少握手开销,时延≤10ms;数据压缩:使用Protobuf二进制序列化(比JSON小30-50%),减少传输数据量,降低时延;WSS加密:生产环境启用WSS(WebSocket Secure),在加密的同时避免TCP三次握手的额外开销。
5.2 边缘计算优化
本地转发:SignalR Server部署在MEC节点,设备与监控端的通信无需经过核心网,网络跳数从5-8跳减少到1-2跳,时延降低60%以上;本地存储:时序数据存储在MEC本地InfluxDB,历史数据查询时延≤50ms(无需远程调用云端数据库);边缘处理:简单的数据清洗、过滤在MEC节点完成,减少无效数据传输。
5.3 应用层优化
批量采集:设备侧按100ms间隔批量采集数据(而非单条上传),减少SignalR通信次数;数据过滤:仅上传变化量超过阈值的数据(如温度变化≥0.5℃),减少冗余数据;连接复用:设备与SignalR Server保持长连接,避免频繁建立/断开连接的开销。
5.4 时延测试结果(工业环境)
| 通信场景 | 传统4G+云端部署 | 5G MEC+SignalR部署 | 时延降低比例 |
|---|---|---|---|
| 设备→监控端(实时数据) | 80-150ms | 15-30ms | 75%+ |
| 监控端→设备(控制指令) | 60-120ms | 10-25ms | 70%+ |
| 历史数据查询(1000条) | 300-500ms | 30-80ms | 80%+ |
六、工业场景高可靠保障
6.1 断线重连与数据不丢
SignalR客户端支持自动重连,重连间隔1-10秒自适应;设备断线时,数据缓存到本地LiteDB,重连后自动补发;监控端断线后重连,MEC Server推送断线期间的告警和关键数据。
6.2 容错与降级机制
PLC连接失败时,网关自动重试连接,并重试3次后降级为“仅缓存数据”;SignalR Server故障时,设备持续缓存数据,待服务器恢复后批量上传;5G网络信号弱时,自动切换到4G(仅牺牲部分时延,保障通信连续性)。
6.3 安全防护
设备接入认证:设备注册时需验证设备ID和密钥(JWT),防止非法设备接入;数据加密:WSS传输加密、Protobuf数据序列化+签名,防止数据篡改和窃取;指令权限控制:监控端下发指令需验证权限,仅授权用户可执行启停、参数修改等关键操作。
6.4 日志与运维
全链路日志:MEC Server、网关、监控端均记录详细日志,支持问题追溯;远程运维:MEC Server支持远程登录(SSH),可在线查看日志、重启服务;状态监控:监控端实时展示MEC Server、设备、网络的状态,异常时告警。
七、避坑指南(工业落地常见问题)
7.1 MEC节点网络可达性问题
坑因:MEC节点未接入5G切片网络,或设备/监控端与MEC节点不在同一网段;避坑方案:
确认MEC节点已配置工业专用切片,QoS参数满足时延要求;设备和监控端使用同一运营商的5G工业卡,确保接入同一切片;测试MEC节点IP的连通性(ping 192.168.100.50 -t),确保丢包率≤1%。
7.2 SignalR并发连接数不足
坑因:MEC Server默认配置不支持千级设备并发,导致部分设备无法连接;避坑方案:
优化ASP.NET Core配置(增加最大连接数、调整线程池);部署SignalR Server集群,使用Redis做背板(ScaleOut);设备侧采用分组连接(如按生产线分组),减少单Hub的连接压力。
7.3 低时延不达标(时延>50ms)
坑因:SignalR使用长轮询而非WebSocket、数据未压缩、MEC节点距离设备过远;避坑方案:
检查SignalR连接协议(F12开发者工具→Network→WebSocket,确认状态为101);强制启用Protobuf序列化,禁用JSON;调整MEC节点部署位置,确保设备与MEC节点的5G信号强度≥-85dBm。
7.4 工业协议适配失败
坑因:网关未正确配置PLC的从站地址、寄存器地址、数据类型;避坑方案:
使用Modbus Poll等工具先验证PLC的寄存器读写正确性;核对PLC的数据格式(如16位/32位寄存器、大端/小端);针对特殊PLC(如西门子S7-1200),使用专用库(S7NetPlus)而非通用Modbus库。
八、总结
C#+SignalR+5G MEC的工业设备远程监控系统,核心是利用5G MEC的低时延边缘部署和SignalR的双向实时通信能力,解决工业远程监控的“时延高、可靠性低、双向通信难”三大痛点。该方案无需改造现有工业设备,通过网关适配主流工业协议,可快速落地到生产线、智能制造、远程运维等场景。
落地后可实现:
双向通信时延≤50ms,满足工业控制级要求;断网不丢数、断线自动连,保障数据完整性;支持千级设备并发接入,弹性扩展;统一C#技术栈,降低开发和维护成本。
后续可扩展方向:结合AI故障预测(如之前的ML.NET+LSTM),在MEC节点部署推理模型,实现“实时监控+智能预测+远程控制”的全闭环智能运维。






![[C++探索之旅] 第一部分第十一课:小练习,猜单词 - 鹿快](https://img.lukuai.com/blogimg/20251015/da217e2245754101b3d2ef80869e9de2.jpg)










暂无评论内容