OPC UA 工业通信
大约 9 分钟约 2721 字
OPC UA 工业通信
简介
OPC UA(Open Platform Communications Unified Architecture)是工业自动化的标准通信协议,提供跨平台、高安全性的数据交换。它取代了传统的 OPC DA,支持复杂数据结构、历史数据访问、告警事件和 Method 调用。是工业 4.0 和智能制造的核心通信标准。
特点
.NET 客户端开发
安装 OPC UA 库
# 官方 OPC UA .NET 库
dotnet add package OPCFoundation.NetStandard.Opc.Ua基本客户端连接
/// <summary>
/// OPC UA 客户端 — 连接服务器并读写数据
/// </summary>
public class OpcUaClient : IDisposable
{
private Session? _session;
private SessionReconnectHandler? _reconnectHandler;
// 连接服务器
public async Task<bool> ConnectAsync(string serverUrl, string? username = null, string? password = null)
{
try
{
var application = new ApplicationInstance
{
ApplicationName = "OPC UA Client",
ApplicationType = ApplicationType.Client,
ConfigSectionName = "OpcUaClient"
};
var config = await application.LoadApplicationConfiguration(false);
await application.CheckApplicationInstanceCertificate(false, 0);
var endpoint = GetEndpoint(serverUrl);
var identity = !string.IsNullOrEmpty(username)
? new UserIdentity(username, password)
: new UserIdentity();
_session = await Session.Create(
config,
endpoint,
updateBeforeConnect: true,
sessionName: "OPC UA Session",
sessionTimeout: 30000,
identity,
preferredLocales: null);
// 自动重连
_reconnectHandler = new SessionReconnectHandler();
_reconnectHandler.BeginReconnect(_session, 5000, OnReconnectComplete);
_session.KeepAlive += OnKeepAlive;
_session.Notification += OnNotification;
return true;
}
catch (Exception ex)
{
Console.WriteLine($"连接失败:{ex.Message}");
return false;
}
}
private static EndpointDescription GetEndpoint(string serverUrl)
{
using var discovery = new DiscoveryClient(new Uri(serverUrl));
var endpoints = discovery.GetEndpoints(null);
var endpoint = endpoints.FirstOrDefault(e =>
e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
?? endpoints.First();
return endpoint;
}
// 读取节点值
public async Task<T?> ReadNodeAsync<T>(string nodeId)
{
if (_session == null) throw new InvalidOperationException("未连接");
var node = new NodeId(nodeId);
var value = await _session.ReadValueAsync(node);
if (value.StatusCode == StatusCodes.Good)
{
return (T?)value.Value;
}
return default;
}
// 写入节点值
public async Task<bool> WriteNodeAsync<T>(string nodeId, T value)
{
if (_session == null) throw new InvalidOperationException("未连接");
var node = new NodeId(nodeId);
var writeValue = new WriteValue
{
NodeId = node,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
var results = await _session.WriteAsync(null, new WriteValueCollection { writeValue });
return results[0].StatusCode == StatusCodes.Good;
}
// 批量读取
public async Task<Dictionary<string, object?>> ReadMultipleAsync(string[] nodeIds)
{
if (_session == null) throw new InvalidOperationException("未连接");
var nodesToRead = new ReadValueIdCollection();
foreach (var nodeId in nodeIds)
{
nodesToRead.Add(new ReadValueId
{
NodeId = new NodeId(nodeId),
AttributeId = Attributes.Value
});
}
var response = await _session.ReadAsync(null, 0, TimestampsToReturn.Both, nodesToRead);
var results = new Dictionary<string, object?>();
for (int i = 0; i < nodeIds.Length; i++)
{
results[nodeIds[i]] = response.Results[i].Value;
}
return results;
}
// 订阅节点变化
public Subscription Subscribe(string nodeId, Action<object?> onChanged, int publishingInterval = 1000)
{
if (_session == null) throw new InvalidOperationException("未连接");
var subscription = new Subscription(_session.DefaultSubscription)
{
PublishingInterval = publishingInterval,
KeepAliveCount = 10,
LifetimeCount = 100
};
var monitoredItem = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = new NodeId(nodeId),
AttributeId = Attributes.Value,
SamplingInterval = publishingInterval,
QueueSize = 10,
DiscardOldest = true
};
monitoredItem.Notification += (item, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
onChanged?.Invoke(notification.Value?.Value);
}
};
subscription.AddItem(monitoredItem);
_session.AddSubscription(subscription);
subscription.Create();
return subscription;
}
// 调用方法
public async Task<IList<object?>> CallMethodAsync(string objectId, string methodId, params object[] args)
{
if (_session == null) throw new InvalidOperationException("未连接");
var inputArgs = args.Select(a => new Variant(a)).ToArray();
var results = await _session.CallAsync(
new NodeId(objectId),
new NodeId(methodId),
inputArgs);
return results.Select(r => r.Value).ToList();
}
private void OnKeepAlive(Session session, KeepAliveEventArgs e)
{
if (e.Status != StatusCodes.Good)
{
Console.WriteLine($"连接异常:{e.Status}");
}
}
private void OnNotification(Session session, NotificationEventArgs e)
{
// 处理订阅通知
}
private void OnReconnectComplete(object? sender, EventArgs e)
{
_session = _reconnectHandler?.Session;
}
public void Dispose()
{
_reconnectHandler?.Dispose();
_session?.Close();
_session?.Dispose();
}
}使用示例
/// <summary>
/// OPC UA 客户端使用示例
/// </summary>
public class OpcUaExample
{
public async Task RunAsync()
{
using var client = new OpcUaClient();
// 连接
var connected = await client.ConnectAsync("opc.tcp://192.168.1.100:4840");
if (!connected) return;
// 读取单个节点
var temperature = await client.ReadNodeAsync<double>("ns=2;s=Temperature");
Console.WriteLine($"温度:{temperature}°C");
// 批量读取
var values = await client.ReadMultipleAsync(new[]
{
"ns=2;s=Temperature",
"ns=2;s=Pressure",
"ns=2;s=FlowRate"
});
foreach (var (nodeId, value) in values)
{
Console.WriteLine($"{nodeId} = {value}");
}
// 写入节点
await client.WriteNodeAsync("ns=2;s=SetPoint", 75.5);
// 订阅变化
client.Subscribe("ns=2;s=Temperature", value =>
{
Console.WriteLine($"温度变化:{value}");
}, publishingInterval: 500);
// 调用方法
var result = await client.CallMethodAsync(
"ns=2;s=Controller",
"ns=2;s=StartProcess",
"Recipe1", 100);
Console.WriteLine("按任意键退出...");
Console.ReadKey();
}
}OPC UA 地址空间
节点标识格式
ns=<namespace>;s=<string identifier> 字符串标识
ns=<namespace>;i=<numeric identifier> 数字标识
示例:
ns=2;s=Temperature 命名空间2的Temperature节点
ns=0;i=2258 Server.ServerStatus.CurrentTime
ns=3;s=PLC1.DB1.Temperature PLC变量浏览地址空间
/// <summary>
/// 浏览 OPC UA 服务器地址空间
/// </summary>
public async Task<List<NodeInfo>> BrowseAsync(Session session, string? startNodeId = null)
{
var nodeToBrowse = startNodeId != null
? new NodeId(startNodeId)
: ObjectIds.ObjectsFolder;
var browseDescription = new BrowseDescription
{
NodeId = nodeToBrowse,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable,
ResultMask = (uint)BrowseResultMask.All
};
var response = await session.BrowseAsync(null, null, new BrowseDescriptionCollection { browseDescription }, null);
return response.Results[0].References.Select(r => new NodeInfo
{
NodeId = r.NodeId.ToString(),
BrowseName = r.BrowseName.Name,
DisplayName = r.DisplayName.Text,
NodeClass = r.NodeClass
}).ToList();
}
public record NodeInfo(string NodeId, string BrowseName, string DisplayName, NodeClass NodeClass);批量订阅与数据变化过滤
/// <summary>
/// 批量订阅管理器 — 管理多个节点的订阅
/// </summary>
public class OpcUaSubscriptionManager : IDisposable
{
private Session? _session;
private readonly Dictionary<string, Subscription> _subscriptions = new();
private readonly Dictionary<string, MonitoredItem> _monitoredItems = new();
public void Initialize(Session session)
{
_session = session;
}
// 批量订阅多个节点
public Subscription SubscribeMultiple(
Dictionary<string, Action<string, object?>> nodeCallbacks,
int publishingInterval = 1000)
{
if (_session == null) throw new InvalidOperationException("未连接");
var subscription = new Subscription(_session.DefaultSubscription)
{
PublishingInterval = publishingInterval,
KeepAliveCount = 10,
LifetimeCount = 100,
PublishingEnabled = true
};
foreach (var (nodeId, callback) in nodeCallbacks)
{
var monitoredItem = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = new NodeId(nodeId),
AttributeId = Attributes.Value,
SamplingInterval = publishingInterval,
QueueSize = 10,
DiscardOldest = true
};
// 设置变化过滤器 — 只有值变化超过指定百分比才通知
monitoredItem.Filter = new DataChangeFilter
{
Trigger = DataChangeTrigger.StatusValue,
DeadbandType = (uint)DeadbandType.Percent,
DeadbandValue = 0.5 // 0.5% 变化才触发
};
var capturedNodeId = nodeId;
monitoredItem.Notification += (item, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
callback?.Invoke(capturedNodeId, notification.Value?.Value);
}
};
subscription.AddItem(monitoredItem);
_monitoredItems[nodeId] = monitoredItem;
}
_session.AddSubscription(subscription);
subscription.Create();
return subscription;
}
// 动态添加监控项
public void AddMonitoredItem(
Subscription subscription,
string nodeId,
Action<object?> onChanged,
int samplingInterval = 1000)
{
var monitoredItem = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = new NodeId(nodeId),
AttributeId = Attributes.Value,
SamplingInterval = samplingInterval
};
monitoredItem.Notification += (item, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
onChanged?.Invoke(notification.Value?.Value);
};
subscription.AddItem(monitoredItem);
subscription.ApplyChanges();
_monitoredItems[nodeId] = monitoredItem;
}
// 移除监控项
public void RemoveMonitoredItem(Subscription subscription, string nodeId)
{
if (_monitoredItems.TryGetValue(nodeId, out var item))
{
subscription.RemoveItem(item);
subscription.ApplyChanges();
_monitoredItems.Remove(nodeId);
}
}
public void Dispose()
{
foreach (var sub in _subscriptions.Values)
{
sub.Delete();
}
_subscriptions.Clear();
_monitoredItems.Clear();
}
}连接池与多服务器管理
/// <summary>
/// OPC UA 连接池 — 管理多个 PLC 的连接
/// </summary>
public class OpcUaConnectionPool : IDisposable
{
private readonly ConcurrentDictionary<string, OpcUaClient> _clients = new();
private readonly ILoggerFactory _loggerFactory;
public OpcUaConnectionPool(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
}
// 获取或创建连接
public async Task<OpcUaClient> GetOrCreateAsync(
string serverId, string serverUrl,
string? username = null, string? password = null)
{
if (_clients.TryGetValue(serverId, out var existingClient))
{
if (existingClient.IsConnected)
return existingClient;
_clients.TryRemove(serverId, out _);
existingClient.Dispose();
}
var client = new OpcUaClient(_loggerFactory.CreateLogger<OpcUaClient>());
var connected = await client.ConnectAsync(serverUrl, username, password);
if (!connected)
throw new InvalidOperationException($"无法连接 OPC UA 服务器: {serverUrl}");
_clients[serverId] = client;
return client;
}
// 移除连接
public void RemoveConnection(string serverId)
{
if (_clients.TryRemove(serverId, out var client))
client.Dispose();
}
// 获取所有连接状态
public Dictionary<string, bool> GetAllConnectionStatus()
{
return _clients.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.IsConnected);
}
public void Dispose()
{
foreach (var client in _clients.Values)
client.Dispose();
_clients.Clear();
}
}历史数据读取
/// <summary>
/// OPC UA 历史数据读取
/// </summary>
public class OpcUaHistoryReader
{
private readonly Session _session;
public OpcUaHistoryReader(Session session)
{
_session = session;
}
// 读取历史数据
public async Task<List<HistoryDataPoint>> ReadHistoryAsync(
string nodeId,
DateTime startTime,
DateTime endTime,
int maxValues = 1000)
{
var historyReadDetails = new ReadRawModifiedDetails(
startTime: startTime,
endTime: endTime,
numValuesPerNode: (uint)maxValues,
returnBounds: true,
isReadModified: false);
var nodesToRead = new HistoryReadValueIdCollection
{
new HistoryReadValueId
{
NodeId = new NodeId(nodeId),
AttributeId = Attributes.Value
}
};
var response = await _session.HistoryReadAsync(
null,
new ExtensionObject(historyReadDetails),
TimestampsToReturn.Both,
false,
nodesToRead,
null);
var results = new List<HistoryDataPoint>();
if (response?.Results != null && response.Results.Count > 0)
{
var result = response.Results[0];
if (result.HistoryData?.Body is HistoryData historyData)
{
foreach (var value in historyData.DataValues)
{
results.Add(new HistoryDataPoint
{
Timestamp = value.SourceTimestamp,
Value = value.Value,
StatusCode = value.StatusCode.Code
});
}
}
}
return results;
}
}
public record HistoryDataPoint
{
public DateTime Timestamp { get; init; }
public object? Value { get; init; }
public uint StatusCode { get; init; }
public bool IsGood => StatusCode == StatusCodes.Good;
}OPC UA 安全
安全模式
/// <summary>
/// OPC UA 安全配置
/// </summary>
// 安全模式
// None — 无加密(仅限开发环境)
// Sign — 签名
// SignAndEncrypt — 签名+加密(推荐生产环境)
// 用户认证方式
// Anonymous — 匿名
// Username/Password — 用户名密码
// Certificate — 证书认证
// 连接时指定安全
var identity = new UserIdentity("admin", "password123"); // 用户名密码
var identity = new UserIdentity(); // 匿名OPC UA vs Modbus vs MQTT
| 特性 | OPC UA | Modbus | MQTT |
|---|---|---|---|
| 架构 | 客户端/服务器 | 主/从 | 发布/订阅 |
| 安全 | 内置加密认证 | 无 | TLS |
| 数据模型 | 面向对象 | 寄存器地址 | 主题 |
| 发现 | 自动发现 | 手动配置 | Broker |
| 复杂度 | 高 | 低 | 中 |
| 适用 | 工厂级集成 | 设备级通信 | IoT 消息 |
优点
缺点
总结
OPC UA 是工业 4.0 的标准通信协议,适合工厂级别的系统集成。设备级通信用 Modbus,IoT 场景用 MQTT,工厂级集成用 OPC UA。.NET 开发推荐使用 OPC Foundation 官方库,掌握连接、读写、订阅和浏览四大核心操作。
关键知识点
- 先分清主题属于界面层、ViewModel 层、线程模型还是设备接入层。
- WPF 文章真正的价值在于把 UI、数据、命令、线程和资源关系讲清楚。
- 上位机场景里,稳定性和异常恢复常常比界面花哨更重要。
- 设备接入类主题通常同时涉及协议、线程、实时刷新和异常恢复。
项目落地视角
- 优先明确 DataContext、绑定路径、命令源和线程切换位置。
- 涉及设备时,补齐超时、重连、日志、告警和资源释放策略。
- 复杂控件最好提供最小可运行页面,便于后续复用和排障。
- 明确采集周期、重连策略、数据缓存和状态同步方式。
常见误区
- 把大量逻辑堆进 code-behind,导致页面难测难维护。
- 在后台线程直接操作 UI,或忽略 Dispatcher 切换。
- 只验证正常路径,不验证设备掉线、权限缺失和资源泄漏。
- 只验证通信成功,不验证断线、抖动和异常包。
进阶路线
- 继续向 MVVM 工具链、控件可复用性、性能优化和视觉树诊断深入。
- 把主题放回真实设备流程,思考启动、连接、采集、显示、告警和恢复。
- 沉淀成控件库、通信层和诊断工具,提高整套客户端的复用度。
- 继续补齐设备模拟、离线回放、现场诊断和配置中心能力。
适用场景
- 当你准备把《OPC UA 工业通信》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合桌面业务系统、监控看板、工控上位机和设备交互界面。
- 当系统需要同时处理复杂 UI、后台任务和硬件通信时,这类主题尤为关键。
落地建议
- 优先明确 View、ViewModel、服务层和设备层边界,避免代码隐藏过重。
- 涉及实时刷新时,提前设计 UI 线程切换、节流和资源释放。
- 对硬件通信、日志、告警和异常恢复建立标准流程。
排错清单
- 先检查 DataContext、绑定路径、INotifyPropertyChanged 和命令状态是否正常。
- 排查 Dispatcher 调用、死锁、后台线程直接操作 UI 的问题。
- 出现内存增长时,优先检查事件订阅、图像资源和窗口生命周期。
复盘问题
- 如果把《OPC UA 工业通信》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《OPC UA 工业通信》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《OPC UA 工业通信》最大的收益和代价分别是什么?
