一、核心场景与价值
在智能制造场景中,常需解决「近端采集」与「远端管控」的协同问题:
Linux 边缘节点(如 Ubuntu 工控板、树莓派)部署在设备旁,负责低时延采集传感器/PLC 数据、执行本地边缘计算;Windows 工控机作为中心管控端,负责实时数据可视化、远程控制指令下发、历史数据存储;两者基于 .NET 8 实现跨平台数据互通,无需跨语言适配,兼顾工业场景的「低时延(≤50ms)」与「高可靠(断线重连、数据缓存)」。
本文提供完整落地方案:从架构设计、代码实现到部署测试,覆盖 Modbus 数据采集、SignalR 实时通信、跨平台适配核心功能,可直接应用于产线监控、设备运维等工业场景。
二、整体架构设计
2.1 角色分工
| 节点类型 | 核心职责 | 技术形态 |
|---|---|---|
| Linux 边缘节点 | 1. Modbus TCP/RTU 采集设备数据;2. 边缘数据过滤/缓存;3. 接收 Windows 指令并下发设备;4. 实时上传数据到 Windows | .NET 8 控制台应用(轻量化,无 UI) |
| Windows 工控机 | 1. 实时展示边缘节点上传的数据(表格+曲线);2. 下发远程控制指令;3. 存储历史数据;4. 管理边缘节点连接 | .NET 8 + Avalonia UI(跨平台桌面) |
| 数据互通核心 | SignalR 实时通信(双向推送:数据上传+指令下发) | ASP.NET Core SignalR(.NET 8) |
| 设备层 | PLC/传感器(支持 Modbus TCP/RTU 协议) | 西门子 S7-1200、欧姆龙 CP1H 等 |
2.2 数据流向
设备层(PLC/传感器)→ Linux 边缘节点(Modbus 采集+边缘缓存)→ SignalR 通信 → Windows 工控机(UI 展示+指令下发)→ SignalR 通信 → Linux 边缘节点(指令执行+结果反馈)
2.3 技术栈选型(工业级稳定组合)
| 模块 | 技术选型 | 核心优势 |
|---|---|---|
| 基础框架 | .NET 8(LTS) | 跨平台兼容性最优,性能提升 30%+,支持 Trim 优化 |
| 实时通信 | ASP.NET Core SignalR | 自动适配 WebSocket,双向通信零开发,时延≤50ms |
| 工业协议 | NModbus(Modbus TCP/RTU) | 跨平台无依赖,适配绝大多数工业设备 |
| Windows UI | Avalonia 11 | 开源免费,API 接近 WPF,工业界面适配性强 |
| 数据存储 | SQLite(本地缓存)+ InfluxDB(时序数据) | 跨平台文件型数据库,轻量化无部署成本 |
| 部署方式 | 自包含发布(Windows/Linux) | 无需安装 .NET Runtime,简化现场运维 |
三、环境准备
3.1 开发环境
开发工具:Visual Studio 2022(17.10+);框架:.NET 8 SDK(下载地址);测试环境:
Windows 工控机:Windows 10/11 x64;Linux 边缘节点:Ubuntu 22.04 x64/树莓派 4B(Linux ARM64);设备:PLC/传感器(或 Modbus 模拟器,如 Modbus Slave)。
3.2 依赖库安装(NuGet)
| 项目类型 | 依赖库名称 | 安装命令 |
|---|---|---|
| 共享类库 | 无(仅数据模型) | – |
| Linux 边缘节点 | NModbus、Microsoft.AspNetCore.SignalR.Client | |
| Windows 工控机 | Avalonia、OxyPlot.Avalonia、Microsoft.AspNetCore.SignalR.Server | |
四、Step1:创建共享类库(统一数据格式)
创建 .NET 8 类库项目 ,定义跨节点的数据模型,确保 Windows 与 Linux 数据互通格式一致:
IndustrialShared
using System;
namespace IndustrialShared
{
/// <summary>
/// 设备实时数据(Linux 采集后上传 Windows)
/// </summary>
public class DeviceData
{
/// <summary>
/// 边缘节点ID(区分多个边缘节点)
/// </summary>
public string NodeId { get; set; }
/// <summary>
/// 设备ID(如 PLC-001、SENSOR-002)
/// </summary>
public string DeviceId { get; set; }
/// <summary>
/// 采集时间戳(边缘节点本地时间)
/// </summary>
public DateTime CollectTime { get; set; }
/// <summary>
/// 温度(℃)
/// </summary>
public float Temperature { get; set; }
/// <summary>
/// 压力(MPa)
/// </summary>
public float Pressure { get; set; }
/// <summary>
/// 设备状态(0=离线,1=正常,2=告警)
/// </summary>
public int DeviceStatus { get; set; }
}
/// <summary>
/// 控制指令(Windows 下发给 Linux 边缘节点)
/// </summary>
public class ControlCommand
{
/// <summary>
/// 指令ID(唯一标识,用于匹配响应)
/// </summary>
public string CommandId { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>
/// 目标边缘节点ID(空表示广播所有节点)
/// </summary>
public string TargetNodeId { get; set; }
/// <summary>
/// 目标设备ID
/// </summary>
public string TargetDeviceId { get; set; }
/// <summary>
/// 指令类型(Start/Stop/SetParam)
/// </summary>
public string CommandType { get; set; }
/// <summary>
/// 指令参数(JSON格式,如 {"PressureSet":0.8})
/// </summary>
public string CommandParams { get; set; }
}
/// <summary>
/// 指令执行结果(Linux 反馈给 Windows)
/// </summary>
public class CommandResponse
{
/// <summary>
/// 对应指令ID
/// </summary>
public string CommandId { get; set; }
/// <summary>
/// 执行结果(0=成功,1=失败)
/// </summary>
public int ResultCode { get; set; }
/// <summary>
/// 结果描述
/// </summary>
public string ResultDesc { get; set; }
/// <summary>
/// 边缘节点ID
/// </summary>
public string NodeId { get; set; }
/// <summary>
/// 响应时间
/// </summary>
public DateTime ResponseTime { get; set; } = DateTime.Now;
}
/// <summary>
/// 边缘节点状态(Linux 上报给 Windows)
/// </summary>
public class NodeStatus
{
public string NodeId { get; set; }
public string NodeName { get; set; }
public string IpAddress { get; set; }
public bool IsOnline { get; set; }
public DateTime LastHeartbeat { get; set; } = DateTime.Now;
}
}
五、Step2:Linux 边缘节点开发(数据采集+通信)
创建 .NET 8 控制台项目 ,核心功能:Modbus 数据采集、SignalR 连接 Windows 工控机、指令接收与执行。
LinuxEdgeNode
5.1 核心配置与全局变量
using Microsoft.AspNetCore.SignalR.Client;
using IndustrialShared;
using NModbus;
using System.IO.Ports;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace LinuxEdgeNode
{
class Program
{
// 配置参数(可改为配置文件读取)
private const string WindowsSignalRUrl = "http://192.168.1.100:5000/industrialHub"; // Windows 工控机 SignalR 地址
private const string NodeId = "EDGE-001"; // 边缘节点唯一ID
private const string NodeName = "车间A边缘采集节点";
private const string ModbusType = "TCP"; // TCP/RTU
private const string ModbusIp = "192.168.1.200"; // Modbus TCP 设备IP
private const int ModbusTcpPort = 502; // Modbus TCP 端口
private const string ModbusRtuPort = "/dev/ttyUSB0"; // Linux Modbus RTU 串口
private const int ModbusBaudRate = 9600; // RTU 波特率
private const int CollectInterval = 1000; // 数据采集间隔(1s)
private const int HeartbeatInterval = 5000; // 心跳上报间隔(5s)
// 核心对象
private static HubConnection _signalRConnection;
private static IModbusMaster _modbusMaster;
private static CancellationTokenSource _cts = new();
private static bool _isConnectedToWindows = false;
private static List<DeviceData> _cacheData = new(100); // 本地数据缓存(断线时存储)
static async Task Main(string[] args)
{
Console.WriteLine("Linux 边缘节点启动中...");
try
{
// 1. 初始化 Modbus 连接(采集设备数据)
InitModbus();
// 2. 初始化 SignalR 连接(连接 Windows 工控机)
await InitSignalRClient();
// 3. 启动数据采集任务
_ = Task.Run(CollectAndUploadDataAsync, _cts.Token);
// 4. 启动心跳上报任务
_ = Task.Run(ReportHeartbeatAsync, _cts.Token);
Console.WriteLine("边缘节点启动成功,按 Enter 退出...");
Console.ReadLine();
}
catch (Exception ex)
{
Console.WriteLine($"边缘节点启动失败:{ex.Message}");
Console.ReadLine();
}
finally
{
// 释放资源
_cts.Cancel();
_modbusMaster?.Dispose();
await _signalRConnection?.DisposeAsync();
}
}
}
}
5.2 Modbus 数据采集(跨协议适配 TCP/RTU)
/// <summary>
/// 初始化 Modbus 连接(支持 TCP/RTU)
/// </summary>
private static void InitModbus()
{
try
{
var factory = new ModbusFactory();
if (ModbusType.Equals("TCP", StringComparison.OrdinalIgnoreCase))
{
// Modbus TCP 连接
var tcpClient = new TcpClient(ModbusIp, ModbusTcpPort);
_modbusMaster = factory.CreateIpMaster(tcpClient);
Console.WriteLine($"Modbus TCP 连接成功:{ModbusIp}:{ModbusTcpPort}");
}
else
{
// Modbus RTU 连接(Linux 串口)
var serialPort = new SerialPort(ModbusRtuPort, ModbusBaudRate, Parity.None, 8, StopBits.One)
{
ReadTimeout = 500,
WriteTimeout = 500,
DtrEnable = true
};
serialPort.Open();
_modbusMaster = factory.CreateRtuMaster(serialPort);
Console.WriteLine($"Modbus RTU 连接成功:{ModbusRtuPort}(波特率:{ModbusBaudRate})");
}
_modbusMaster.Transport.ReadTimeout = 1000;
_modbusMaster.Transport.WriteTimeout = 1000;
}
catch (Exception ex)
{
throw new Exception($"Modbus 初始化失败:{ex.Message}");
}
}
/// <summary>
/// 采集设备数据并上传 Windows 工控机
/// </summary>
private static async Task CollectAndUploadDataAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
// 1. 采集 Modbus 数据
var deviceData = await CollectModbusDataAsync();
if (deviceData == null)
{
await Task.Delay(CollectInterval, _cts.Token);
continue;
}
// 2. 本地缓存(断线时使用)
_cacheData.Add(deviceData);
if (_cacheData.Count > 100) _cacheData.RemoveAt(0);
// 3. 上传 Windows 工控机(已连接则实时上传,未连接则缓存)
if (_isConnectedToWindows)
{
// 先补发缓存数据(断线重连后)
if (_cacheData.Count > 1)
{
await _signalRConnection.SendAsync("UploadBatchDeviceData", _cacheData);
Console.WriteLine($"补发缓存数据 {_cacheData.Count} 条");
_cacheData.Clear();
}
// 上传当前数据
await _signalRConnection.SendAsync("UploadDeviceData", deviceData);
Console.WriteLine($"上传数据:设备 {deviceData.DeviceId},温度 {deviceData.Temperature}℃,压力 {deviceData.Pressure}MPa");
}
else
{
Console.WriteLine($"未连接 Windows 工控机,缓存数据:{deviceData.CollectTime:HH:mm:ss}");
}
}
catch (Exception ex)
{
Console.WriteLine($"数据采集/上传失败:{ex.Message}");
}
finally
{
await Task.Delay(CollectInterval, _cts.Token);
}
}
}
/// <summary>
/// 读取 Modbus 设备数据(示例:温度=寄存器0,压力=寄存器2,状态=线圈0)
/// </summary>
private static async Task<DeviceData> CollectModbusDataAsync()
{
try
{
// 读取保持寄存器(温度、压力,浮点数=2个16位寄存器)
ushort[] tempRegs = await Task.Run(() => _modbusMaster.ReadHoldingRegisters(slaveAddress: 1, startAddress: 0, numberOfPoints: 2));
ushort[] pressRegs = await Task.Run(() => _modbusMaster.ReadHoldingRegisters(slaveAddress: 1, startAddress: 2, numberOfPoints: 2));
// 读取线圈(设备状态:通=正常,断=告警)
bool[] statusCoils = await Task.Run(() => _modbusMaster.ReadCoils(slaveAddress: 1, startAddress: 0, numberOfPoints: 1));
// 数据转换
float temperature = BitConverter.ToSingle(BitConverter.GetBytes((uint)(tempRegs[0] << 16 | tempRegs[1])), 0);
float pressure = BitConverter.ToSingle(BitConverter.GetBytes((uint)(pressRegs[0] << 16 | pressRegs[1])), 0);
int deviceStatus = statusCoils[0] ? 1 : 2;
return new DeviceData
{
NodeId = NodeId,
DeviceId = "PLC-001",
CollectTime = DateTime.Now,
Temperature = (float)Math.Round(temperature, 2),
Pressure = (float)Math.Round(pressure, 2),
DeviceStatus = deviceStatus
};
}
catch (Exception ex)
{
Console.WriteLine($"Modbus 数据读取失败:{ex.Message}");
return null;
}
}
5.3 SignalR 通信(连接 Windows+指令处理)
/// <summary>
/// 初始化 SignalR 客户端(连接 Windows 工控机)
/// </summary>
private static async Task InitSignalRClient()
{
_signalRConnection = new HubConnectionBuilder()
.WithUrl(WindowsSignalRUrl, options =>
{
options.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets; // 强制使用 WebSocket,低时延
})
.WithAutomaticReconnect(new[] { 1000, 3000, 5000 }) // 断线重连间隔
.Build();
// 注册接收控制指令的回调(Windows 下发指令)
_signalRConnection.On<ControlCommand>("ReceiveControlCommand", async (command) =>
{
await HandleControlCommandAsync(command);
});
// 连接状态回调
_signalRConnection.StateChanged += (state) =>
{
_isConnectedToWindows = state.NewState == HubConnectionState.Connected;
Console.WriteLine($"SignalR 状态变更:{state.OldState} → {state.NewState}");
if (_isConnectedToWindows)
{
// 连接成功后上报节点状态
_ = ReportNodeStatusAsync(true);
}
};
// 启动连接
await _signalRConnection.StartAsync();
Console.WriteLine($"SignalR 连接 Windows 工控机成功:{WindowsSignalRUrl}");
}
/// <summary>
/// 处理 Windows 下发的控制指令
/// </summary>
private static async Task HandleControlCommandAsync(ControlCommand command)
{
var response = new CommandResponse
{
CommandId = command.CommandId,
NodeId = NodeId,
ResultCode = 0,
ResultDesc = "执行成功"
};
try
{
// 过滤非目标节点指令
if (!string.IsNullOrEmpty(command.TargetNodeId) && command.TargetNodeId != NodeId)
return;
Console.WriteLine($"收到指令:{command.CommandType},目标设备:{command.TargetDeviceId},参数:{command.CommandParams}");
// 解析指令并执行 Modbus 写操作
switch (command.CommandType.ToUpper())
{
case "START":
// 启动设备:写线圈1为通
await Task.Run(() => _modbusMaster.WriteSingleCoil(1, 1, true));
break;
case "STOP":
// 停止设备:写线圈1为断
await Task.Run(() => _modbusMaster.WriteSingleCoil(1, 1, false));
break;
case "SETPARAM":
// 调整压力设定值:写寄存器4为浮点数
var param = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, float>>(command.CommandParams);
if (!param.ContainsKey("PressureSet"))
{
response.ResultCode = 1;
response.ResultDesc = "参数缺失:PressureSet";
break;
}
float setPoint = param["PressureSet"];
byte[] setBytes = BitConverter.GetBytes(setPoint);
ushort[] regs = new ushort[]
{
BitConverter.ToUInt16(setBytes, 2),
BitConverter.ToUInt16(setBytes, 0)
};
await Task.Run(() => _modbusMaster.WriteMultipleRegisters(1, 4, regs));
break;
default:
response.ResultCode = 1;
response.ResultDesc = $"不支持的指令类型:{command.CommandType}";
break;
}
}
catch (Exception ex)
{
response.ResultCode = 1;
response.ResultDesc = $"执行失败:{ex.Message}";
}
finally
{
// 反馈执行结果给 Windows
if (_isConnectedToWindows)
{
await _signalRConnection.SendAsync("ReportCommandResponse", response);
}
Console.WriteLine($"指令响应:{response.ResultDesc}");
}
}
/// <summary>
/// 上报节点状态给 Windows
/// </summary>
private static async Task ReportNodeStatusAsync(bool isOnline)
{
try
{
var status = new NodeStatus
{
NodeId = NodeId,
NodeName = NodeName,
IpAddress = GetLocalIpAddress(),
IsOnline = isOnline
};
await _signalRConnection.SendAsync("ReportNodeStatus", status);
}
catch (Exception ex)
{
Console.WriteLine($"节点状态上报失败:{ex.Message}");
}
}
/// <summary>
/// 心跳上报(维持连接状态)
/// </summary>
private static async Task ReportHeartbeatAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
if (_isConnectedToWindows)
{
await ReportNodeStatusAsync(true);
}
await Task.Delay(HeartbeatInterval, _cts.Token);
}
}
/// <summary>
/// 获取 Linux 边缘节点本地 IP
/// </summary>
private static string GetLocalIpAddress()
{
var host = Dns.GetHostEntry(Dns.GetHostName());
foreach (var ip in host.AddressList)
{
if (ip.AddressFamily == AddressFamily.InterNetwork)
{
return ip.ToString();
}
}
return "未知IP";
}
六、Step3:Windows 工控机开发(UI+SignalR 服务)
创建 .NET 8 + Avalonia 项目 ,核心功能:SignalR 服务端、数据可视化、指令下发、节点管理。
WindowsIndustrialPC
6.1 项目初始化与 UI 设计
6.1.1 创建 Avalonia 项目
安装 Avalonia 模板:;新建「Avalonia MVVM Application」,框架选择 .NET 8;配置跨平台目标(仅保留 Windows,聚焦工控机场景):
dotnet new install Avalonia.Templates
<!-- WindowsIndustrialPC.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<UseAvalonia>true</UseAvalonia>
<OutputType>WinExe</OutputType>
</PropertyGroup>
</Project>
6.1.2 UI 布局(MainWindow.xaml)
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:oxy="clr-namespace:OxyPlot.Avalonia;assembly=OxyPlot.Avalonia"
xmlns:dg="clr-namespace:Avalonia.Controls.DataGrid;assembly=Avalonia.Controls.DataGrid"
x:Class="WindowsIndustrialPC.MainWindow"
Title="跨平台工业监控系统(Windows 工控机)" Width="1200" Height="800" Background="#F5F5F5">
<Grid RowSpacing="15" ColumnSpacing="15" Padding="10">
<!-- 顶部:SignalR 服务配置 + 边缘节点列表 -->
<Grid Grid.Row="0" ColumnSpacing="15" Background="White" Padding="10" CornerRadius="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- SignalR 服务控制 -->
<VerticalStackLayout Grid.Column="0" Spacing="10">
<Label Text="SignalR 服务状态:" FontAttributes="Bold"/>
<Label x:Name="LblSignalRStatus" Text="未启动" Foreground="Red"/>
<Button x:Name="BtnStartSignalR" Text="启动 SignalR 服务" Background="#27AE60" TextColor="White" Clicked="BtnStartSignalR_Clicked"/>
<Label Text="服务地址:http://localhost:5000/industrialHub"/>
</VerticalStackLayout>
<!-- 边缘节点列表 -->
<VerticalStackLayout Grid.Column="1" Spacing="5">
<Label Text="在线边缘节点" FontAttributes="Bold"/>
<dg:DataGrid x:Name="DgNodeList" Height="120" ColumnSpacing="1" RowSpacing="1">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="节点ID" Binding="{Binding NodeId}" Width="Auto"/>
<dg:DataGridTextColumn Header="节点名称" Binding="{Binding NodeName}" Width="*"/>
<dg:DataGridTextColumn Header="IP地址" Binding="{Binding IpAddress}" Width="Auto"/>
<dg:DataGridTextColumn Header="最后心跳" Binding="{Binding LastHeartbeat, StringFormat='HH:mm:ss'}" Width="Auto"/>
</dg:DataGrid.Columns>
</dg:DataGrid>
</VerticalStackLayout>
</Grid>
<!-- 中间:数据可视化(表格+曲线) -->
<Grid Grid.Row="1" ColumnSpacing="15">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 实时数据表格 -->
<Frame Grid.Column="0" Background="White" CornerRadius="8" Padding="5">
<VerticalStackLayout Spacing="5">
<Label Text="设备实时数据" FontAttributes="Bold"/>
<dg:DataGrid x:Name="DgDeviceData" Height="300" ColumnSpacing="1" RowSpacing="1">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="边缘节点" Binding="{Binding NodeId}" Width="Auto"/>
<dg:DataGridTextColumn Header="设备ID" Binding="{Binding DeviceId}" Width="Auto"/>
<dg:DataGridTextColumn Header="采集时间" Binding="{Binding CollectTime, StringFormat='HH:mm:ss.fff'}" Width="Auto"/>
<dg:DataGridTextColumn Header="温度(℃)" Binding="{Binding Temperature}" Width="Auto"/>
<dg:DataGridTextColumn Header="压力(MPa)" Binding="{Binding Pressure}" Width="Auto"/>
<dg:DataGridTextColumn Header="状态" Binding="{Binding DeviceStatus, Converter={StaticResource StatusConverter}}" Width="Auto"/>
</dg:DataGrid.Columns>
</dg:DataGrid>
</VerticalStackLayout>
</Frame>
<!-- 趋势曲线 -->
<Frame Grid.Column="1" Background="White" CornerRadius="8" Padding="5">
<VerticalStackLayout Spacing="5">
<Label Text="温度/压力趋势" FontAttributes="Bold"/>
<oxy:PlotView x:Name="PlotTrend" Model="{Binding PlotModel}" Height="300"/>
</VerticalStackLayout>
</Frame>
</Grid>
<!-- 底部:控制指令区域 + 日志 -->
<Grid Grid.Row="2" ColumnSpacing="15">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 控制指令 -->
<Frame Grid.Column="0" Background="White" CornerRadius="8" Padding="10" WidthRequest="400">
<VerticalStackLayout Spacing="10">
<Label Text="远程控制" FontAttributes="Bold"/>
<Grid ColumnSpacing="5" RowSpacing="5">
<Label Grid.Row="0" Grid.Column="0" Text="目标节点:" VerticalOptions="Center"/>
<ComboBox x:Name="CboTargetNode" Grid.Row="0" Grid.Column="1" Width="200"/>
</Grid>
<Grid ColumnSpacing="5" RowSpacing="5">
<Label Grid.Row="1" Grid.Column="0" Text="目标设备:" VerticalOptions="Center"/>
<TextBox x:Name="TxtTargetDevice" Grid.Row="1" Grid.Column="1" Width="200" Text="PLC-001"/>
</Grid>
<Grid ColumnSpacing="5" RowSpacing="5">
<Label Grid.Row="2" Grid.Column="0" Text="指令类型:" VerticalOptions="Center"/>
<ComboBox x:Name="CboCommandType" Grid.Row="2" Grid.Column="1" Width="200">
<ComboBox.Items>
<ComboBoxItem Content="Start(启动)"/>
<ComboBoxItem Content="Stop(停止)"/>
<ComboBoxItem Content="SetParam(调整参数)"/>
</ComboBox.Items>
<ComboBox.SelectedIndex>0</ComboBox.SelectedIndex>
</ComboBox>
</Grid>
<Grid ColumnSpacing="5" RowSpacing="5">
<Label Grid.Row="3" Grid.Column="0" Text="指令参数:" VerticalOptions="Center"/>
<TextBox x:Name="TxtCommandParams" Grid.Row="3" Grid.Column="1" Width="200" Text="{"PressureSet":0.8}"/>
</Grid>
<Button x:Name="BtnSendCommand" Grid.Row="4" Grid.ColumnSpan="2" Text="发送指令" Background="#3498DB" TextColor="White" Clicked="BtnSendCommand_Clicked"/>
</VerticalStackLayout>
</Frame>
<!-- 操作日志 -->
<Frame Grid.Column="1" Background="White" CornerRadius="8" Padding="5">
<VerticalStackLayout Spacing="5">
<Label Text="操作日志" FontAttributes="Bold"/>
<TextBox x:Name="TxtLog" Height="150" IsReadOnly="True" Background="#F8F9FA" TextWrapping="Wrap"/>
</VerticalStackLayout>
</Frame>
</Grid>
</Grid>
</Window>
6.2 SignalR 服务端初始化(Windows 作为服务端)
在 中集成 SignalR 服务,监听 Linux 边缘节点连接:
MainWindow.xaml.cs
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using IndustrialShared;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.SignalR;
using OxyPlot;
using OxyPlot.Axes;
using OxyPlot.Series;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
namespace WindowsIndustrialPC
{
public partial class MainWindow : Window
{
// 核心对象
private IWebHost _signalRHost;
private bool _isSignalRStarted = false;
private HubConnectionManager _hubConnections;
// 数据模型
public ObservableCollection<NodeStatus> NodeStatusList { get; set; } = new();
public ObservableCollection<DeviceData> DeviceDataList { get; set; } = new();
public PlotModel PlotModel { get; set; } = new();
// 曲线系列
private LineSeries _tempSeries;
private LineSeries _pressSeries;
private int _dataPointIndex = 0;
public MainWindow()
{
InitializeComponent();
DataContext = this;
// 初始化UI数据绑定
DgNodeList.ItemsSource = NodeStatusList;
DgDeviceData.ItemsSource = DeviceDataList;
// 初始化趋势曲线
InitPlotModel();
// 注册状态转换器(0=离线,1=正常,2=告警)
Resources["StatusConverter"] = new StatusConverter();
}
/// <summary>
/// 初始化趋势曲线
/// </summary>
private void InitPlotModel()
{
PlotModel.Title = "温度/压力实时趋势";
PlotModel.Axes.Add(new LinearAxis { Title = "数据点", Minimum = 0, Maximum = 100 });
PlotModel.Axes.Add(new LinearAxis { Title = "数值", Position = AxisPosition.Left });
_tempSeries = new LineSeries { Title = "温度(℃)", Color = OxyColors.Red, StrokeThickness = 2 };
_pressSeries = new LineSeries { Title = "压力(MPa)", Color = OxyColors.Blue, StrokeThickness = 2 };
PlotModel.Series.Add(_tempSeries);
PlotModel.Series.Add(_pressSeries);
}
/// <summary>
/// 启动 SignalR 服务
/// </summary>
private async void BtnStartSignalR_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_isSignalRStarted)
{
await StopSignalRServer();
BtnStartSignalR.Text = "启动 SignalR 服务";
LblSignalRStatus.Text = "未启动";
LblSignalRStatus.Foreground = Avalonia.Media.Brushes.Red;
_isSignalRStarted = false;
Log("SignalR 服务已停止");
return;
}
try
{
// 构建 SignalR 服务
_signalRHost = new WebHostBuilder()
.UseKestrel()
.UseUrls("http://*:5000") // 监听所有网卡,允许 Linux 边缘节点访问
.ConfigureServices(services =>
{
services.AddSignalR(options =>
{
options.CompressionOptions = Microsoft.AspNetCore.SignalR.CompressionOptions.All; // 数据压缩
});
_hubConnections = new HubConnectionManager(); // 管理边缘节点连接
})
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapHub<IndustrialHub>("/industrialHub"); // 注册 Hub 端点
});
})
.Build();
// 启动服务
await _signalRHost.StartAsync();
_isSignalRStarted = true;
BtnStartSignalR.Text = "停止 SignalR 服务";
LblSignalRStatus.Text = "运行中";
LblSignalRStatus.Foreground = Avalonia.Media.Brushes.Green;
Log("SignalR 服务启动成功,地址:http://localhost:5000/industrialHub");
// 注册 Hub 事件(接收边缘节点数据/状态)
IndustrialHub.DeviceDataReceived += IndustrialHub_DeviceDataReceived;
IndustrialHub.BatchDeviceDataReceived += IndustrialHub_BatchDeviceDataReceived;
IndustrialHub.NodeStatusReported += IndustrialHub_NodeStatusReported;
IndustrialHub.CommandResponseReceived += IndustrialHub_CommandResponseReceived;
}
catch (Exception ex)
{
Log($"SignalR 服务启动失败:{ex.Message}");
}
}
/// <summary>
/// 停止 SignalR 服务
/// </summary>
private async Task StopSignalRServer()
{
if (_signalRHost != null)
{
await _signalRHost.StopAsync();
_signalRHost.Dispose();
}
}
/// <summary>
/// 日志输出
/// </summary>
private void Log(string message)
{
Dispatcher.Post(() =>
{
TxtLog.AppendText($"[{DateTime.Now:HH:mm:ss.fff}] {message}
");
TxtLog.ScrollToEnd();
});
}
}
/// <summary>
/// 状态转换器(数字→文字)
/// </summary>
public class StatusConverter : Avalonia.Data.Converters.IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
return value switch
{
0 => "离线",
1 => "正常",
2 => "告警",
_ => "未知"
};
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
6.3 SignalR Hub 实现(数据路由核心)
创建 ,处理 Windows 与 Linux 之间的消息路由:
IndustrialHub.cs
using Microsoft.AspNetCore.SignalR;
using IndustrialShared;
using System.Collections.Concurrent;
namespace WindowsIndustrialPC
{
/// <summary>
/// SignalR 核心 Hub(Windows 服务端)
/// </summary>
public class IndustrialHub : Hub
{
// 边缘节点连接映射(NodeId → ConnectionId)
private static readonly ConcurrentDictionary<string, string> _nodeConnections = new();
// 事件定义(通知主窗口更新UI)
public static event Action<DeviceData> DeviceDataReceived;
public static event Action<List<DeviceData>> BatchDeviceDataReceived;
public static event Action<NodeStatus> NodeStatusReported;
public static event Action<CommandResponse> CommandResponseReceived;
/// <summary>
/// 边缘节点上报设备数据(单条)
/// </summary>
public Task UploadDeviceData(DeviceData data)
{
DeviceDataReceived?.Invoke(data);
return Task.CompletedTask;
}
/// <summary>
/// 边缘节点上报设备数据(批量补发)
/// </summary>
public Task UploadBatchDeviceData(List<DeviceData> dataList)
{
BatchDeviceDataReceived?.Invoke(dataList);
return Task.CompletedTask;
}
/// <summary>
/// 边缘节点上报状态
/// </summary>
public Task ReportNodeStatus(NodeStatus status)
{
// 记录节点连接
_nodeConnections[status.NodeId] = Context.ConnectionId;
NodeStatusReported?.Invoke(status);
return Task.CompletedTask;
}
/// <summary>
/// 边缘节点反馈指令执行结果
/// </summary>
public Task ReportCommandResponse(CommandResponse response)
{
CommandResponseReceived?.Invoke(response);
return Task.CompletedTask;
}
/// <summary>
/// Windows 工控机下发控制指令
/// </summary>
public async Task SendControlCommand(ControlCommand command)
{
if (string.IsNullOrEmpty(command.TargetNodeId))
{
// 广播所有边缘节点
await Clients.All.SendAsync("ReceiveControlCommand", command);
}
else if (_nodeConnections.TryGetValue(command.TargetNodeId, out string connectionId))
{
// 定向发送给目标节点
await Clients.Client(connectionId).SendAsync("ReceiveControlCommand", command);
}
else
{
// 目标节点离线
CommandResponseReceived?.Invoke(new CommandResponse
{
CommandId = command.CommandId,
ResultCode = 1,
ResultDesc = $"目标节点 {command.TargetNodeId} 离线",
NodeId = "System"
});
}
}
/// <summary>
/// 边缘节点断开连接
/// </summary>
public override Task OnDisconnectedAsync(Exception exception)
{
// 移除离线节点
var offlineNode = _nodeConnections.FirstOrDefault(kv => kv.Value == Context.ConnectionId);
if (!string.IsNullOrEmpty(offlineNode.Key))
{
_nodeConnections.Remove(offlineNode.Key, out _);
NodeStatusReported?.Invoke(new NodeStatus
{
NodeId = offlineNode.Key,
IsOnline = false,
LastHeartbeat = DateTime.Now
});
}
return base.OnDisconnectedAsync(exception);
}
}
}
6.4 数据处理与指令下发
在 中实现 Hub 事件回调,更新 UI 并处理指令下发:
MainWindow.xaml.cs
/// <summary>
/// 接收边缘节点单条设备数据
/// </summary>
private void IndustrialHub_DeviceDataReceived(DeviceData data)
{
Dispatcher.Post(() =>
{
// 更新数据表格
DeviceDataList.Add(data);
if (DeviceDataList.Count > 20) DeviceDataList.RemoveAt(0);
// 更新趋势曲线
UpdatePlot(data);
Log($"收到数据:节点 {data.NodeId} → 设备 {data.DeviceId},温度 {data.Temperature}℃");
});
}
/// <summary>
/// 接收边缘节点批量设备数据(断线补发)
/// </summary>
private void IndustrialHub_BatchDeviceDataReceived(List<DeviceData> dataList)
{
Dispatcher.Post(() =>
{
foreach (var data in dataList)
{
DeviceDataList.Add(data);
if (DeviceDataList.Count > 20) DeviceDataList.RemoveAt(0);
UpdatePlot(data);
}
Log($"收到批量数据 {dataList.Count} 条");
});
}
/// <summary>
/// 接收边缘节点状态上报
/// </summary>
private void IndustrialHub_NodeStatusReported(NodeStatus status)
{
Dispatcher.Post(() =>
{
var existingNode = NodeStatusList.FirstOrDefault(n => n.NodeId == status.NodeId);
if (existingNode != null)
{
// 更新现有节点状态
existingNode.NodeName = status.NodeName;
existingNode.IpAddress = status.IpAddress;
existingNode.IsOnline = status.IsOnline;
existingNode.LastHeartbeat = status.LastHeartbeat;
}
else if (status.IsOnline)
{
// 添加新在线节点
NodeStatusList.Add(status);
}
else
{
// 移除离线节点
NodeStatusList.Remove(existingNode);
}
// 更新目标节点下拉框
CboTargetNode.Items.Clear();
CboTargetNode.Items.Add("所有节点");
foreach (var node in NodeStatusList)
{
CboTargetNode.Items.Add(node.NodeId);
}
CboTargetNode.SelectedIndex = 0;
Log($"{(status.IsOnline ? "节点上线" : "节点离线")}:{status.NodeId}({status.IpAddress})");
});
}
/// <summary>
/// 接收指令执行结果
/// </summary>
private void IndustrialHub_CommandResponseReceived(CommandResponse response)
{
Dispatcher.Post(() =>
{
string result = response.ResultCode == 0 ? "成功" : "失败";
Log($"指令 {response.CommandId} 执行{result}(节点 {response.NodeId}):{response.ResultDesc}");
});
}
/// <summary>
/// 更新趋势曲线
/// </summary>
private void UpdatePlot(DeviceData data)
{
_tempSeries.Points.Add(new DataPoint(_dataPointIndex, data.Temperature));
_pressSeries.Points.Add(new DataPoint(_dataPointIndex, data.Pressure));
// 只保留最近100个数据点
if (_tempSeries.Points.Count > 100)
{
_tempSeries.Points.RemoveAt(0);
_pressSeries.Points.RemoveAt(0);
PlotModel.Axes[0].Minimum = _dataPointIndex - 99;
PlotModel.Axes[0].Maximum = _dataPointIndex;
}
_dataPointIndex++;
PlotModel.InvalidatePlot(true);
}
/// <summary>
/// 发送控制指令
/// </summary>
private async void BtnSendCommand_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (!_isSignalRStarted)
{
Log("请先启动 SignalR 服务");
return;
}
if (CboTargetNode.SelectedItem == null)
{
Log("请选择目标节点");
return;
}
try
{
string targetNodeId = CboTargetNode.SelectedItem.ToString() == "所有节点" ? "" : CboTargetNode.SelectedItem.ToString();
string targetDeviceId = TxtTargetDevice.Text.Trim();
string commandType = CboCommandType.SelectedItem.ToString().Split('(')[0];
string commandParams = TxtCommandParams.Text.Trim();
// 验证参数
if (string.IsNullOrEmpty(targetDeviceId))
{
Log("目标设备ID不能为空");
return;
}
if (commandType == "SetParam" && string.IsNullOrEmpty(commandParams))
{
Log("调整参数指令必须输入JSON格式参数");
return;
}
// 构造指令
var command = new ControlCommand
{
TargetNodeId = targetNodeId,
TargetDeviceId = targetDeviceId,
CommandType = commandType,
CommandParams = commandParams
};
// 发送指令(通过 SignalR 下发给边缘节点)
var hubContext = _signalRHost.Services.GetRequiredService<IHubContext<IndustrialHub>>();
await hubContext.Clients.All.SendAsync("ReceiveControlCommand", command);
Log($"发送指令:{commandType} → 节点 {targetNodeId ?? "所有"},设备 {targetDeviceId},参数 {commandParams}");
}
catch (Exception ex)
{
Log($"指令发送失败:{ex.Message}");
}
}
七、Step4:跨平台发布与部署
7.1 发布 Linux 边缘节点
命令行发布(支持 x64/ARM64):
# 发布到 Linux x64(如 Ubuntu 22.04 x64)
dotnet publish -c Release -r linux-x64 --self-contained true -o ./publish/linux-x64
# 发布到 Linux ARM64(如树莓派 4B)
dotnet publish -c Release -r linux-arm64 --self-contained true -o ./publish/linux-arm64
部署到 Linux 设备:
通过 SFTP 复制发布文件到 ;授予执行权限:
/opt/EdgeNode;配置串口权限(RTU 模式):
chmod +x /opt/EdgeNode/LinuxEdgeNode(永久权限,需重启);后台运行:
sudo usermod -aG dialout $USER。
nohup /opt/EdgeNode/LinuxEdgeNode > edge.log 2>&1 &
7.2 发布 Windows 工控机
右键项目 → 「发布」→ 选择「文件夹」→ 目标框架 ;部署模式选择「自包含」,目标运行时「win-x64」;点击「发布」,将生成的文件复制到 Windows 工控机;双击
net8.0-windows10.0.19041.0 运行(无需安装 .NET Runtime)。
WindowsIndustrialPC.exe
7.3 网络配置
确保 Windows 工控机与 Linux 边缘节点在同一局域网(或通过 5G MEC 打通网络);Windows 工控机关闭防火墙,或开放 5000 端口(SignalR 服务端口);修改 Linux 边缘节点的 为 Windows 工控机的局域网 IP(如
WindowsSignalRUrl)。
http://192.168.1.100:5000/industrialHub
八、测试验证(工业场景核心用例)
| 测试项 | 操作步骤 | 预期结果 |
|---|---|---|
| SignalR 服务启动 | Windows 工控机点击「启动 SignalR 服务」 | 日志显示启动成功,状态为「运行中」 |
| 边缘节点连接 | 启动 Linux 边缘节点 | Windows 工控机节点列表显示「EDGE-001」,IP 正确 |
| 数据采集与上传 | 启动 Modbus 设备/模拟器 | Windows 表格实时更新温度/压力,曲线动态滚动 |
| 断线重连与数据补发 | 断开 Linux 网络→恢复网络 | Windows 接收断线期间的缓存数据,无数据丢失 |
| 远程控制指令下发 | Windows 选择节点→发送 Start/SetParam 指令 | Linux 日志显示指令执行成功,设备执行对应操作 |
| 稳定性测试 | 连续运行 24 小时 | 无崩溃、无数据丢包,CPU 占用 < 8% |
九、工业场景优化(低时延+高可靠)
9.1 低时延优化
SignalR 强制 WebSocket:禁用长轮询,仅使用 WebSocket 传输,时延≤50ms;数据压缩:启用 SignalR 自动压缩(已在代码中配置),减少网络传输量;采集间隔优化:根据设备响应速度调整 (最小支持 200ms 采集间隔)。
CollectInterval
9.2 高可靠优化
断线缓存:Linux 边缘节点缓存最近 100 条数据,重连后自动补发;心跳检测:5s 一次心跳,Windows 自动移除离线节点;指令幂等性:控制指令携带唯一 ,Linux 端重复接收时仅执行一次;异常重试:Modbus 读取失败时自动重试,避免单次故障导致数据中断。
CommandId
9.3 资源优化(Linux 边缘节点)
Trim 优化:发布时启用 ,减小程序体积(适合嵌入式设备);内存限制:通过代码限制缓存数据量,内存占用≤64MB;日志轮转:集成 Serilog 实现日志轮转,避免日志文件过大。
--trim-mode partial
十、扩展方向(工业场景进阶)
多边缘节点管理:支持 100+ 边缘节点同时连接,Windows 端按节点分组展示数据;协议扩展:添加 OPC UA 协议(使用 ),适配多厂商设备;历史数据存储:集成 InfluxDB 存储时序数据,支持数据导出、历史趋势查询;国产化适配:Windows 工控机适配 Windows 10 国产化版,Linux 边缘节点适配麒麟/统信系统;边缘 AI 分析:Linux 边缘节点集成 ML.NET 模型,本地检测设备异常(如温度超标),实时告警。
OPCFoundation.NetStandard.Opc.Ua
十一、总结
本方案基于 .NET 8 实现 Windows 工控机与 Linux 边缘节点的跨平台数据互通,核心优势在于「一次开发、双端部署」,复用 C# 工业生态(Modbus/SignalR),无需跨语言适配。通过边缘节点近端采集降低时延,Windows 工控机集中管控提升运维效率,完美契合智能制造的「分布式采集+集中式管控」需求。
关键落地要点:
采用 SignalR 作为双向通信核心,兼顾低时延与易用性;针对性解决 Linux 串口权限、跨平台路径、断线重连等工业场景痛点;自包含发布模式简化现场部署,降低运维成本。
该方案可直接应用于汽车制造、新能源、智能仓储等工业场景,如需适配实际设备,仅需修改 Modbus 寄存器地址、指令逻辑即可快速落地。
















暂无评论内容