1. 系统定位:一台上位机为什么要同时接 OPC UA 和 MQTT
第一次在产线旁边盯这套东西跑起来,我没怎么关心旁边大屏上的 3D 模型炫不炫,我盯的是日志窗口里那一串 OPC UA 连接状态变化。原因很简单:上位机这类程序,最怕的不是功能少,而是看起来一切正常,实际数据早就停了。标题里写到 .NET9、C#、WPF3D、OPC UA、MQTT,这套词拆开看都不算新,但真正把它们组合成一个能放在产线机器旁边连续跑的项目,背后要考虑的事情比写代码多得多。
先说系统定位。这是一台设备状态采集上位机,核心工作是从产线上的 PLC、传感器网关、以及一些专用设备控制器里把运行数据拿出来,然后在 WPF 界面里用 3D 场景还原设备当前状态,比如某台机器人正在运行、某条输送线报警、某个工位的温度偏高。同时,这些数据还要往车间级平台或者云端转发,让 MES 大屏、质量追溯系统和远程运维端也能看到同一套数据。
1.1 别把 OPC UA 和 MQTT 放在同一个 PK 台上
很多人一看到“双协议”就想问:这两个协议到底哪个好?哪个更稳定?实际上它们在我这套系统里干的事完全不一样,非要二选一反而是坑。
OPC UA 解决的是“设备端到上位机”这一段的问题。它带安全模型,有证书校验,适合在工业控制网络内部使用。更重要的是,OPC UA 不只是把数据搬过来,它还有信息模型,能表达设备的结构、状态、历史数据、报警事件,而不是简单的一堆键值对。所以从 PLC、控制器这类设备采集数据时,用 OPC UA 更贴近工业语义。
MQTT 解决的是“上位机到云端/车间平台”这一段的问题。它是发布订阅模式,端口相对固定,协议头很小,边缘设备通常在内网或经过 NAT 转换,MQTT 可以让上位机主动维持到 Broker 的长连接,云端平台订阅主题就能拿数据,不用为每台上位机单独开公网端口。
这两者更像是接力关系,不是竞争关系。我把它们画成一条数据管线:设备 —— OPC UA —— 上位机边缘服务 —— MQTT —— 车间平台。上位机夹在中间,既要当 OPC UA 客户端,也要当 MQTT 客户端,这两个角色分别对应两个完全不同的上下文。
1.2 我从这套系统里划出的数据边界
最初我差点把所有采集到的点位全部通过 MQTT 上传,但后来实际一盘点就发现问题:产线上单台设备可能会有几百个 UA 节点,几十台设备就是成千上万个标签,MQTT 直接全量上传既不经济,也没有业务价值。
后来我把数据分成三类:
- 过程数据:比如电流、转速、温度、压力,这类数据是高频变化的,必须及时从 OPC UA 取出来,但只挑对生产分析有用的点位上报。
- 状态数据:比如设备待机、运行、报警、急停,这类数据变化频率不高,但每条状态变化都要可靠上报。
- 控制数据:比如下发启动、停止、复位指令,这类数据我尽量只走 OPC UA,不让 MQTT 参与,因为跨网下发指令一旦语义不明确,比采集不出数据更可怕。
OPC UA 订阅主要服务前两类,MQTT 上报主要服务第一类和第二类,控制类数据保留在 OPC UA 通道内,不去跨网络。
1.3 “工业级封装”到底封装的是什么
这个标题里的“工业级封装”不是指代码里用了多少个工厂模式、抽象类,而是指三层意思:
第一,程序不能因为一次网络抖动就退出。OPC UA 与 PLC 的连接可能因为交换机掉线、PLC 重启、防火墙空闲超时等原因中断,工业现场这些事都不是“极端情况”,而是日常。断线后要能自动重连,而且要恢复完整的数据订阅,不是简单的用 try/catch 包一圈。
第二,数据和 UI 必须解耦。协议回调线程池里的线程不能直接操作 WPF 控件,界面刷新频率也要有节流,否则数据稍微多一点,UI 线程就被打满。很多 WPF 上位机慢就慢在这里。
第三,点位配置不能靠写死代码。每次加一台设备就重新编译一次的项目没有资格叫工业级。点位映射、采集周期、上报开关、MQTT 主题,这些都要放到配置里。
有了这个边界之后,再去讨论 .NET9、WPF3D、OPC UA、MQTT 的技术细节才有意义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. .NET9 WPF 项目怎么把协议层做成一个能上线的结构
2.1 项目骨架与依赖注入
从我自己的经历来看,WPF 项目最大的通病是入口处太容易写成一锅粥。MainWindow 的构造函数里 new 一堆客户端,Startup 里启动连接,最后窗口关闭时忘了释放,日志和配置也散落各处。换成 .NET9 之后,我直接引入了 Microsoft.Extensions.Hosting,把 WPF 当成一个宿主应用来组织。
项目结构大致是这样:
TwinEdge.Domain:设备点位定义、采集模型、状态枚举、数据质量定义。TwinEdge.Protocols:OPC UA 客户端、MQTT 客户端、协议转换、连接管理。TwinEdge.Application:设备注册、数据总线、业务规则、点位映射配置。TwinEdge.Presentation:WPF UI、ViewModel、3D 场景、绑定模型。
App.xaml.cs 里不再直接 new MainWindow,而是用 Host 构建服务:
csharp复制protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var builder = Host.CreateApplicationBuilder();
builder.Services.AddSingleton<OpcUaConnectionManager>();
builder.Services.AddSingleton<MqttGatewayService>();
builder.Services.AddSingleton<IDeviceRegistry, DeviceRegistry>();
builder.Services.AddSingleton<MainWindow>();
_host = builder.Build();
var window = _host.Services.GetRequiredService<MainWindow>();
window.Show();
await Task.Run(_host.StartAsync);
}
这里有个我要特别提醒的细节:_host.StartAsync 不要阻塞 UI 线程,否则连接建立过程中的握手时间会让窗口白屏。我实际使用时会先用窗口先显示出来,再启动后台服务,这样用户能看到程序已经打开了,不是卡死。
OPC UA 和 MQTT 的服务生命周期里都注册成 Singleton,因为在整个程序运行周期内只需要一份连接实例。如果某个 ViewModel 里各自 new 一个 MQTT 客户端,那断线重连事件、消息分发、资源释放都会混乱。
2.2 数据总线:让协议层和 UI 层相互不认知
我踩过不少 WPF 上位机的坑,后来总结出一个硬性经验:协议层永远不要直接引用某个 ViewModel,UI 层也不要直接知道某个标签来自 OPC UA 还是来自某台设备的模拟器。两者之间需要一个公共的“数据总线”。
我用一个轻量级事件通道来做的,核心消息模型就一个记录:
csharp复制public record DeviceSample(
string TagKey,
DateTime Timestamp,
DeviceValueQuality Quality,
object? Value);
OPC UA 采集端只做一件事:从设备上收到数值后,把值解析成 DeviceSample,投递到数据总线。UI 层订阅自己关心的 TagKey,然后更新 3D 场景。MQTT 上报服务也订阅这条总线,把需要上报的数据打包发送到 Broker。
这个设计带来的好处非常明显:采集频率、UI 刷新频率、MQTT 上报频率三者可以各自独立调整。OPC UA 可以收到几十毫秒一个变化推送,UI 不必跟着四十毫秒刷一次,MQTT 也不会有多大道消息风暴。
2.3 点位映射配置化
点位这块我一开始图省事,把 UA 节点绑定写在代码里,后来当设备增加到几十台时再改代码已经来不及了。最终做法是在配置文件中用“设备组 + 点位名称 + UA 节点标识”来描述一个数据点:
json复制{
"EquipmentId": "Cell-03",
"Group": "Robot-1",
"Tags": [
{
"TagKey": "Robot1/Axis1Speed",
"NodeId": "ns=2;s=Cell03.Robot1.Axis1.Speed",
"DataType": "Double",
"SamplingIntervalMs": 200,
"ReportToMqtt": true,
"Unit": "rpm"
}
]
}
这个配置会在程序启动时加载成内存字典,TagKey 就是全局数据总线里的主键。OPC UA 服务根据这些配置创建订阅节点,MQTT 上报服务再根据 ReportToMqtt 字段决定哪些点位需要转发。加一台设备只是加一段配置,不需要改任何业务代码。
如果哪个节点需要做单位换算,我建议在配置里加 Scale 和 Offset 字段,不要想着在 ViewModel 里乘系数。工业数据通常要到采集源头就归一化,否则后期维护的人根本不知道这个系数藏在哪个 UI 层。
3. OPC UA 接入实战:证书、订阅和 Session 复活
3.1 从 SelectEndpoint 到 SecurityConfiguration
OPC UA 客户端和普通 TCP 客户端最大的差别,就是它默认要处理证书和端点安全。我早期在开发机上连接西门子 PLC 时一切正常,因为 OPC UA 服务器通常允许把开发机临时加入信任列表;但程序部署到产线工控机上时,工控机没有客户端证书,服务器直接拒绝连接。
OPC UA 的信任关系几乎是所有新手必经的坑。我用的官方 UA .NET SDK 里,会先定义一个 ApplicationConfiguration,里面描述当前应用的名字、证书存储位置、信任列表位置:
csharp复制var config = new ApplicationConfiguration
{
ApplicationName = "TwinEdgeMonitor/1.0",
ApplicationUri = "urn:TwinEdgeMonitor",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.X509Store,
StorePath = "CurrentUser\\UA_Machine\\pki",
SubjectName = "CN=TwinEdgeMonitor, O=Company"
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.X509Store,
StorePath = "CurrentUser\\UA_Machine\\pki\\trusted"
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.X509Store,
StorePath = "CurrentUser\\UA_Machine\\pki\\rejected"
}
},
TransportQuotas = new TransportQuotas
{
OperationTimeout = 30000
}
};
await config.Validate(ApplicationType.Client);
这里有个和实际生产环境强相关的问题:不是把所有证书往系统“受信任的根证书颁发机构”里一塞就完了。OPC UA 客户端要能访问自己的证书,同时要信任服务器证书;服务器如果配置了“拒绝所有未知客户端证书”,客户端证书没有加到服务器的信任列表里也一样连不上。
我建议任何准备做 OPC UA 上位机的人,第一周就把证书怎么生成、怎么导入、哪里能看到被拒绝的证书这件事弄清楚。否则后面凡是遇到连接被拒,你连从哪个方向排查都不知道。
连接创建时我会先列出服务器端点,选择安全策略,而不是直接默默连一个匿名端点。虽然很多 PLC 的模拟环境里 securityMode=None 能用,但现场控制网络里建议至少 SignAndEncrypt:
csharp复制var endpoints = await CoreClientUtils.GetEndpointsAsync(serverUrl, cancellationToken);
var endpoint = endpoints.FirstOrDefault(e => e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
?? endpoints.First();
Basic256Sha256 是目前比较主流的安全策略,很多旧设备可能只支持它不支持更高强度的策略。实际选端点时最好按服务器返回的列表顺序做匹配,优先挑加密签名都支持的,而不是盲目选第一个。
3.2 用订阅替代高频轮询
在 OPC UA 里写数据的正确姿势,是创建会话后创建一个或多个订阅,然后往订阅里添加监控项。添加监控项时可以指定采样间隔,服务器按采样间隔去设备上读值,值变化后通过订阅通知给客户端。这个模型比上位机每秒去读一百个节点高效得多。
示例代码大概是:
csharp复制var subscription = new Subscription(session.DefaultSubscription)
{
PublishingInterval = 200,
LifetimeCount = 300,
KeepAliveCount = 10
};
session.AddSubscription(subscription);
subscription.Create();
var monitoredItem = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = new NodeId("ns=2;s=Cell03.Robot1.Axis1.Speed"),
AttributeId = Attributes.Value,
SamplingInterval = 200,
QueueSize = 1,
DiscardOldest = true,
MonitoringMode = MonitoringMode.Reporting
};
monitoredItem.Notification += OnUaDataChanged;
subscription.AddItem(monitoredItem);
subscription.ApplyChanges();
通知事件里拿到的数据要特别注意两点。第一,要判断 StatusCode 是否为 Good,因为很多设备平时不产生新值,连接一瞬间会先把旧值带上来,如果你不检查质量就喂给画面,后面会看到一帧“看起来很正常的假数据”。第二,订阅并不是越快越好,尤其是 WPF 3D 场景里,把采样间隔设成 20ms 纯属给 CPU 找麻烦。普通状态展示用 200ms 足够了,确实需要监控主轴振动这类信号时,再单独把某一个节点降到 50ms。
很多初学者以为 UA 订阅的数据是“服务器实时推个个变化”,实际上服务器是按采样间隔检查然后按发布间隔发出去。两个间隔理解清楚,才能调出让 PLC 不抱怨、UI 也不卡的模式。
