基于OPC UA基金会:设计OPC UA 客户端-技术文档

目录

  1. 工程概述
  2. 环境依赖
  3. 项目文件结构
  4. [UI 界面布局](#UI 界面布局)
  5. 核心概念
  6. 初始化流程
  7. 连接建立
  8. 证书验证
  9. 地址空间浏览
  10. 单节点读写
  11. 批量读取
  12. 数据订阅
  13. 断线检测与断开
  14. 完整代码清单

1. 工程概述

本项目是一个基于 OPC Foundation .NET Standard SDK 的 OPC UA 客户端 WinForms 程序。支持以下功能:

  • 连接/断开 OPC UA 服务器(匿名 + 用户名密码认证)
  • 浏览服务器地址空间(TreeView 树形展示,支持递归展开全部节点)
  • 单节点同步/异步读取
  • 单节点同步/异步写入
  • 批量读取
  • 单节点订阅(数据变化自动推送)
  • 批量订阅
  • 服务器证书验证弹窗

目标框架 : .NET Framework 4.8

SDK 版本: OPCFoundation.NetStandard.Opc.Ua v1.5.376.244


2. 环境依赖

2.1 NuGet 包

powershell 复制代码
Install-Package OPCFoundation.NetStandard.Opc.Ua.Client -Version 1.5.376.244
Install-Package OPCFoundation.NetStandard.Opc.Ua.Configuration -Version 1.5.376.244
Install-Package OPCFoundation.NetStandard.Opc.Ua.Security.Certificates -Version 1.5.376.244

Client 包会自动拉取 CoreBouncyCastleNewtonsoft.Json 等依赖。

2.2 引用命名空间

csharp 复制代码
using Opc.Ua;                  // OPC UA 基础类型(NodeId, DataValue, StatusCode 等)
using Opc.Ua.Client;            // 客户端 API(Session, Subscription, MonitoredItem)
using Opc.Ua.Configuration;     // 应用配置(ApplicationInstance, ApplicationConfiguration)
using System.Security.Cryptography.X509Certificates;  // Windows 证书存储操作

3. 项目文件结构

复制代码
OPC_UA_Claude/
├── Program.cs                    // 程序入口
├── OPC_UA_Client.cs             // ★ 主窗体(所有 OPC UA 逻辑)★
├── OPC_UA_Client.Designer.cs    // 窗体控件声明(VS 生成)
├── FormCertClient.cs            // 服务器证书验证弹窗
└── FormCertClient.Designer.cs   // 证书弹窗控件声明

只需要关注两个 .cs 文件:OPC_UA_Client.cs(业务逻辑)和 FormCertClient.cs(证书弹窗)。


4. UI 界面布局

复制代码
┌──────────────────────────────────────────────────────────────┐
│ 连接设置                                                      │
│ URL: [opc.tcp://127.0.0.1:4841]  [连接] [断开] ● 已连接/未连接│
│ [✓ 匿名登录]  用户名: [___]  密码: [___]  [显示密码]          │
├────────────────────┬─────────────────────────────────────────┤
│ 地址空间浏览        │ [单节点操作] [批量操作]                  │
│                    │ ┌─ 读取节点 ─────────────────────────┐  │
│   TreeView         │ │ NodeId: [________] [读取]          │  │
│   带类型前缀:       │ │ [异步读取] [订阅数据变化]           │  │
│   [V] 变量         │ │ 值: [___]  时间: [___]             │  │
│   [O] 对象/文件夹   │ │ 状态: [___]                      │  │
│   [M] 方法         │ ├──────────────────────────────────┤  │
│                    │ │ 写入节点                          │  │
│                    │ │ NodeId: [________] [写入]          │  │
│                    │ │ 值: [___] 类型: [Int16 ▼]         │  │
│                    │ │ [异步写入]                         │  │
│                    │ │ 状态: [___]                       │  │
│                    │ └──────────────────────────────────┘  │
│ NodeId: [___]      │                                       │
│ [刷新][展开][折叠]   │                                       │
├────────────────────┴───────────────────────────────────────┤
│ 结果列表                                                    │
│ ┌─ NodeId ─┬─ Value ─┬─ DataType ─┬─ Status ─┬─ Timestamp┐│
│ │          │         │            │          │           ││
│ └──────────┴─────────┴────────────┴──────────┴───────────┘│
└────────────────────────────────────────────────────────────┘

5. 核心概念

5.1 OPC UA 连接分层模型

复制代码
┌─────────────────────────────────────┐
│ Session(会话层)                    │  用户身份认证、会话管理
│   ├── KeepAlive(心跳)              │  检测连接状态
│   ├── Read / Write / Browse         │  数据访问
│   └── Subscription(订阅)           │  数据变化推送
├─────────────────────────────────────┤
│ SecureChannel(安全通道层)           │  加密、签名、证书交换
├─────────────────────────────────────┤
│ TCP 连接(传输层)                    │  opc.tcp://host:port
└─────────────────────────────────────┘

5.2 关键对象说明

对象 类型 说明
m_session Session OPC UA 会话,所有操作(读/写/浏览/订阅)都通过它进行
m_configuration ApplicationConfiguration 应用配置,包含证书、安全策略、传输限制
NodeId NodeId 节点的唯一标识,如 ns=2;s=Temperature
DataValue DataValue 节点的值,包含 Value、StatusCode、Timestamp
Subscription Subscription 订阅容器,管理一组 MonitoredItem
MonitoredItem MonitoredItem 监控项,指定要监控的节点和属性

5.3 NodeId 格式

复制代码
ns=命名空间索引;标识符类型=标识符值

示例:
  ns=0;i=85        → 命名空间0,数字型NodeId 85(Objects 文件夹)
  ns=2;s=Temperature  → 命名空间2,字符串型NodeId "Temperature"
  ns=4;s=GVL_Camera.ChipCamera_Trig  → 命名空间4,字符串型NodeId

6. 初始化流程

6.1 入口 Program.cs

csharp 复制代码
[STAThread]
static void Main()
{
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);
    Application.Run(new OPC_UA_Client());  // 启动主窗体
}

标准 WinForms 入口,无需修改。

6.2 窗体构造器

csharp 复制代码
public OPC_UA_Client()
{
    InitializeComponent();
    WriteType_cB.SelectedIndex = 0; // 写入类型下拉框默认选 Boolean
}

7. 连接建立

连接建立是整个客户端的核心流程,分为 4 个步骤:

复制代码
用户点击 [连接]
    │
    ├── 步骤1: BuildConfiguration()
    │     ├── 创建 ApplicationConfiguration(应用名、URI、安全策略)
    │     ├── 查找或创建 X.509 自签名证书(标识客户端身份)
    │     ├── 注册证书验证事件(连接时服务端返回证书触发)
    │     └── 验证配置合法性
    │
    ├── 步骤2: 发现端点
    │     ├── 匿名登录 → 直接用 URL 创建 EndpointDescription
    │     └── 用户名密码 → DiscoveryClient.GetEndpoints() → 选最安全的
    │
    ├── 步骤3: 创建 ConfiguredEndpoint
    │     └── 将端点描述 + 配置封装为 ConfiguredEndpoint
    │
    ├── 步骤4: 创建用户身份
    │     ├── 匿名 → AnonymousIdentityToken
    │     └── 用户名密码 → UserIdentity(name, password)
    │
    └── 步骤5: Session.Create()
          ├── TCP 连接
          ├── OpenSecureChannel(交换证书建立加密通道)
          ├── CreateSession(创建会话)
          └── ActivateSession(激活会话,身份认证)

7.1 BuildConfiguration() --- 构建应用配置

这是连接前必须执行的方法。它在后台线程运行以避免阻塞 UI。

csharp 复制代码
private void BuildConfiguration()
{
    string appName = "OPC_UA_Claude_Client";  // 应用名称(可自定义)

    // 1. 创建 ApplicationConfiguration 对象(纯代码构建,不需要 XML 配置文件)
    m_configuration = new ApplicationConfiguration
    {
        ApplicationName = appName,
        ApplicationUri = "urn:MyOPCUAClient",     // 唯一标识(可自定义)
        ApplicationType = ApplicationType.Client, // 标记为客户端
        ProductUri = "OPC_UA_Claude_Client_1.0",

        // 2. 安全配置:证书存放路径
        SecurityConfiguration = new SecurityConfiguration
        {
            AutoAcceptUntrustedCertificates = false,  // 不自动接受证书
            RejectSHA1SignedCertificates = false,

            // 客户端自己的证书:存在 Windows 证书管理器的"个人"存储
            ApplicationCertificate = new CertificateIdentifier
            {
                StoreType = CertificateStoreType.X509Store,
                StorePath = "CurrentUser\\My",      // Windows 证书管理器
                SubjectName = appName
            },

            // 受信任的服务端证书存放位置
            TrustedPeerCertificates = new CertificateTrustList
            {
                StoreType = CertificateStoreType.X509Store,
                StorePath = "CurrentUser\\Root"      // 受信任的根证书
            }
        },

        // 3. 传输配额:限制消息大小和超时
        TransportQuotas = new TransportQuotas
        {
            OperationTimeout = 360000,          // 操作超时(毫秒)
            SecurityTokenLifetime = 86400000,   // 安全令牌有效期(毫秒)
            MaxStringLength = 67108864,         // 最大字符串长度
            MaxByteStringLength = 16777216      // 最大字节串长度
        },

        // 4. 客户端行为配置
        ClientConfiguration = new ClientConfiguration
        {
            DefaultSessionTimeout = 360000      // 默认会话超时(毫秒)
        }
    };

    // 5. 查找或创建客户端证书
    var certFindTask = m_configuration.SecurityConfiguration
        .ApplicationCertificate.Find(true);
    if (certFindTask.Result == null)
    {
        // 没有就创建新的自签名 RSA 2048 位证书
        CreateCertificateAndAddToStore(
            m_configuration.ApplicationUri, appName,
            "X509Store", "CurrentUser\\My");
    }

    // 6. 注册证书验证事件:连接服务器时会触发
    var certValidator = new CertificateValidator();
    certValidator.CertificateValidation += CertificateValidator_CertificateValidation;
    m_configuration.CertificateValidator = certValidator;

    // 7. 验证配置
    m_configuration.Validate(ApplicationType.Client);
}

7.2 创建自签名证书

OPC UA 使用 X.509 证书标识每个应用的唯一身份。客户端需要自己的证书即使使用匿名登录。

csharp 复制代码
private void CreateCertificateAndAddToStore(
    string applicationUri, string applicationName,
    string storeType, string storePath)
{
    // 收集本机 IP 和主机名(作为证书的 DNS 条目)
    var host = Dns.GetHostEntry(Dns.GetHostName());
    var localIps = new List<string>();
    foreach (var ip in host.AddressList)
    {
        if (ip.AddressFamily == AddressFamily.InterNetwork)
            localIps.Add(ip.ToString());
    }
    localIps.Add(Dns.GetHostName());

    // 创建自签名证书:RSA 2048 位,SHA256,有效期 24 个月
    ushort keySize = 2048;
    ushort lifeTimeMonths = 24;
    ushort hashSizeBits = 256;

    var builder = CertificateFactory.CreateCertificate(
        applicationUri, applicationName, null, localIps);

    X509Certificate2 cert = builder
        .SetNotBefore(DateTime.Now)
        .SetNotAfter(DateTime.Now.AddMonths(lifeTimeMonths))
        .SetHashAlgorithm(X509Utils.GetRSAHashAlgorithmName(hashSizeBits))
        .SetRSAKeySize(keySize)
        .CreateForRSA();

    cert.FriendlyName = applicationName;
    cert.AddToStore(storeType, storePath, null);
}

迁移要点

  • SubjectName 必须唯一,不同应用不能重名
  • 第一次运行需要管理员权限(写入 Windows 证书存储需要)
  • 证书有效期为 24 个月,过期需重新生成

7.3 端点发现

当使用用户名密码连接时,需要先发现服务器支持哪些端点:

csharp 复制代码
private EndpointDescription SelectEndpoint(Uri discoveryUrl, bool useSecurity)
{
    var discConfig = EndpointConfiguration.Create();
    discConfig.OperationTimeout = 5000;  // 发现超时 5 秒

    EndpointDescription bestEndpoint = null;

    // 创建 DiscoveryClient 连接服务器获取端点列表
    using (var discoveryClient = DiscoveryClient.Create(discoveryUrl, discConfig))
    {
        var endpoints = discoveryClient.GetEndpoints(null);

        foreach (var ep in endpoints)
        {
            // 遍历选择安全等级最高的端点
            if (bestEndpoint == null)
                bestEndpoint = ep;
            else if (ep.SecurityMode > bestEndpoint.SecurityMode ||
                (ep.SecurityMode == bestEndpoint.SecurityMode &&
                 ep.SecurityLevel > bestEndpoint.SecurityLevel))
            {
                bestEndpoint = ep;
            }
        }
    }

    return bestEndpoint;
}

7.4 创建会话

csharp 复制代码
m_session = await Session.Create(
    configuration: m_configuration,       // 应用配置(含证书)
    endpoint: configuredEndpoint,         // 端点信息
    updateBeforeConnect: true,            // 连接前更新端点
    checkDomain: false,                   // 不检查证书域名
    sessionName: m_configuration.ApplicationName,
    sessionTimeout: 300000,               // 会话超时 5 分钟(300秒)
    identity: userIdentity,               // 用户身份
    preferredLocales: new string[] { "zh-CN" });

// 设置心跳间隔(10 秒发一次 Ping)
m_session.KeepAliveInterval = 10000;
m_session.KeepAlive += Session_KeepAlive;

关键参数说明

  • sessionTimeout: 服务端在此时间内未收到任何请求会关闭会话。心跳会自动发送请求防止超时
  • KeepAliveInterval: 心跳间隔。值太小增加网络负担,太大会导致断线检测延迟
  • updateBeforeConnect: true 表示连接前重新获取服务器端点信息

8. 证书验证

连接时服务端返回其证书。SDK 触发 CertificateValidation 事件让客户端决定是否信任。

csharp 复制代码
private void CertificateValidator_CertificateValidation(
    CertificateValidator validator, CertificateValidationEventArgs e)
{
    // SDK 会连续触发两次验证事件,用计数器区分
    if (m_certStep == 0)
    {
        // 步骤1: 先查 Windows 受信任存储,已有则直接通过
        X509Store store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
        store.Open(OpenFlags.ReadOnly);
        var found = store.Certificates.Find(
            X509FindType.FindByThumbprint, e.Certificate.Thumbprint, true);
        store.Close();

        if (found.Count > 0)
        {
            e.Accept = true;  // 已信任,直接通过
            return;
        }

        // 步骤2: 未知证书 → 弹出证书详情窗口让用户确认
        using (var certForm = new FormCertClient())
        {
            certForm.LoadCertificate(e.Certificate);  // 加载证书信息
            DialogResult result = certForm.ShowDialog(this);

            if (result == DialogResult.OK)
            {
                e.Accept = true;
                if (certForm.AlwaysTrust)
                {
                    // 用户勾选"始终信任"→ 存入 Windows 受信任根存储
                    using (var trustStore = new X509Store(
                        StoreName.Root, StoreLocation.CurrentUser))
                    {
                        trustStore.Open(OpenFlags.ReadWrite);
                        trustStore.Add(e.Certificate);
                    }
                }
            }
            else
                e.Accept = false;
        }
        m_certStep++;
    }
    else
    {
        // 第二次验证:SDK 内部重复触发,直接自动接受
        e.Accept = true;
        m_certStep = 0;
    }
}

迁移要点

  • 必须添加 certStep 计数器机制(SDK 会重复触发验证事件)
  • 弹窗操作必须通过 InvokeRequired 检查确保在 UI 线程执行
  • 信任证书存入 CurrentUser\Root 后,下次连接不会再弹窗

9. 地址空间浏览

浏览 OPC UA 地址空间类似于浏览文件夹树:

复制代码
Root
  ├── Views
  ├── Objects
  │     ├── Server
  │     └── My Variables
  │           ├── Temperature
  │           ├── Pressure
  │           └── Status
  └── Types

9.1 浏览原理

复制代码
session.Browse(nodeId, direction, referenceType, includeSubtypes, nodeClassMask)
    ↓
返回 ReferenceDescriptionCollection(子节点/父节点的引用描述)
    ↓
如果 continuationPoint ≠ null → 继续调用 session.BrowseNext() 获取后续分页

关键参数

参数 含义
nodeId ObjectIds.RootFolder 从根目录开始
direction BrowseDirection.Forward 向下找子节点
referenceTypeId ReferenceTypeIds.HierarchicalReferences 只遍历层级引用
includeSubtypes true 包含子类型(Organizes, HasComponent 等)
nodeClassMask `Variable Object

9.2 BrowseAllRecursive --- 递归加载全部节点

展开按钮点击后的流程:

复制代码
[展开按钮]
  ├── 阶段1(后台线程): BrowseAllRecursive()
  │     递归浏览所有节点 → 存入 Dictionary<父NodeId, 子节点列表>
  │     只递归 Object 节点(Folder/对象),跳过 Variable 和 Method
  │     上限 10 层
  │
  └── 阶段2(UI线程): BuildChildTree()
        从 Dictionary 构建 TreeNode → 添加到 TreeView

关键技术点

  • 分两阶段避免跨线程操作 UI 控件(后台浏览 + UI 构建)
  • Dictionary<string, ReferenceDescriptionCollection> 存储中间数据
  • key 为空字符串 "" 时表示根目录

9.3 CreateTreeNode --- 创建树节点

csharp 复制代码
private TreeNode CreateTreeNode(ReferenceDescription refDesc)
{
    // ExpandedNodeId → NodeId → 字符串(如 "ns=2;s=Temperature")
    string id = ExpandedNodeId.ToNodeId(
        refDesc.NodeId, m_session.NamespaceUris).ToString();

    string name = refDesc.DisplayName?.Text ?? id;

    // 不同类型用不同前缀标记
    string prefix = refDesc.NodeClass == NodeClass.Variable ? "[V] " :
                    refDesc.NodeClass == NodeClass.Method ? "[M] " : "[O] ";

    var node = new TreeNode(prefix + name);
    node.Tag = id;   // ★ 核心:把 NodeId 字符串存在 Tag 里,后续读写时取出
    return node;
}

迁移要点

  • Tag 属性是关键设计:点击树节点时从 Tag 取 NodeId 自动填入读/写输入框
  • 类型前缀 [V] [O] [M] 帮助用户区分节点类型

10. 单节点读写

10.1 同步读取

csharp 复制代码
private (string value, string type, string status, string timestamp)
    ReadNodeSync(NodeId nodeId)
{
    // 1. 构造读取请求:指定 NodeId 和要读的属性(Value)
    var readValueId = new ReadValueId
    {
        NodeId = nodeId,
        AttributeId = Attributes.Value
    };
    var readValues = new ReadValueIdCollection { readValueId };

    // 2. 发送同步读取请求
    m_session.Read(
        null,                             // requestHeader
        0,                                // 最大年龄(0=拿最新值)
        TimestampsToReturn.Both,          // 同时返回源时间戳和服务器时间戳
        readValues,                       // 读取列表
        out DataValueCollection results,  // 输出:结果集合
        out DiagnosticInfoCollection _);  // 输出:诊断信息

    // 3. 解析结果
    return ParseDataValue(results[0]);
}

10.2 异步读取

csharp 复制代码
private async Task<(...)> ReadNodeAsync(NodeId nodeId)
{
    // 1. 构造相同请求
    var readValues = new ReadValueIdCollection {
        new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
    };

    // 2. 发送异步读取请求
    var response = await m_session.ReadAsync(
        null, 0, TimestampsToReturn.Both,
        readValues, CancellationToken.None);

    // 3. 解析响应
    return ParseDataValue(response.Results[0]);
}

10.3 结果解析

csharp 复制代码
private (string value, string type, string status, string timestamp)
    ParseDataValue(DataValue dataValue)
{
    // 值 → 字符串(数组特殊处理)
    string strValue;
    if (dataValue.Value is Array arr)
        strValue = ArrayToString(arr);      // [1, 2, 3] → "1, 2, 3"
    else if (dataValue.Value == null)
        strValue = "null";
    else
        strValue = dataValue.Value.ToString();

    // 数据类型
    string strType = dataValue.WrappedValue.TypeInfo?
        .BuiltInType.ToString() ?? "Unknown";

    // 状态码(Good / BadXxx)
    string strStatus = StatusCode.LookupSymbolicId(
        dataValue.StatusCode.Code);

    // 时间戳(UTC → 本地时间)
    DateTime localTime = dataValue.ServerTimestamp.ToLocalTime();
    string strTime = localTime.ToString("yyyy-MM-dd HH:mm:ss");

    return (strValue, strType, strStatus, strTime);
}

10.4 单节点写入

csharp 复制代码
// 1. 类型转换
TypeCode typeCode = MapTypeCode(typeStr);  // "Int32" → TypeCode.Int32

// 布尔值特殊处理
object convertedValue;
if (typeCode == TypeCode.Boolean)
    convertedValue = valueStr == "1" ||
        valueStr.Equals("true", StringComparison.OrdinalIgnoreCase);
else
    convertedValue = Convert.ChangeType(valueStr, typeCode);

// 2. 构造写入请求
DataValue dataValue = new DataValue { Value = convertedValue };
WriteValue writeValue = new WriteValue
{
    NodeId = nodeId,
    AttributeId = Attributes.Value,
    Value = dataValue
};
var writeValues = new WriteValueCollection { writeValue };

// 3. 同步写入(异步类似)
StatusCodeCollection statusCodes = await Task.Run(() =>
{
    m_session.Write(null, writeValues,
        out StatusCodeCollection sc, out DiagnosticInfoCollection _);
    return sc;
});

// 4. 判断结果
uint code = statusCodes[0].Code;
// code == 0 → Good → 写入成功
// code != 0 → 失败 → LookupSymbolicId(code) 查状态名

11. 批量读取

一次请求读取多个节点,使用 session.ReadValues() 方法:

csharp 复制代码
// 1. 从多行文本框解析 NodeId 列表
string[] lines = BatchReadNodes_tB.Lines
    .Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();

var nodeIds = new NodeIdCollection();
foreach (string line in lines)
    nodeIds.Add(new NodeId(line));

// 2. 批量读取(后台线程)
var results = await Task.Run(() =>
{
    m_session.ReadValues(
        nodeIds,
        out DataValueCollection values,
        out IList<ServiceResult> _);
    return values;
});

// 3. 逐条解析并添加到结果列表
for (int i = 0; i < results.Count; i++)
{
    if (StatusCode.IsGood(results[i].StatusCode))
    {
        var parsed = ParseDataValue(results[i]);
        AddResultToList(lines[i], parsed.value, ...);
    }
}

12. 数据订阅

12.1 订阅原理

复制代码
Subscription(订阅容器)
  ├── PublishingInterval = 500ms    // 检查间隔
  ├── PublishingEnabled = true      // 启用发布
  │
  └── MonitoredItem(监控项) × N
        ├── StartNodeId              // 要监控的节点
        ├── AttributeId = Value     // 监控值属性
        ├── SamplingInterval = 100ms // 采样间隔
        └── Notification 事件        // 值变化时触发

12.2 单节点订阅完整流程

csharp 复制代码
// ===== 启动订阅 =====
private void StartSubscription(string nodeIdStr)
{
    NodeId nodeId = new NodeId(nodeIdStr);

    // 1. 基于默认模板创建订阅
    m_singleSub = new Subscription(m_session.DefaultSubscription)
    {
        PublishingInterval = 500,       // 每 500ms 检查一次变化
        PublishingEnabled = true
    };

    // 2. 创建监控项
    m_singleMonitoredItem = new MonitoredItem
    {
        StartNodeId = nodeId,
        AttributeId = Attributes.Value,
        SamplingInterval = 100,         // 每 100ms 采样一次
        DisplayName = nodeIdStr
    };

    // 3. 绑定通知回调 + 注册到订阅 + 向服务器注册
    m_singleMonitoredItem.Notification += OnSingleMonitoredItemChanged;
    m_singleSub.AddItem(m_singleMonitoredItem);
    m_session.AddSubscription(m_singleSub);
    m_singleSub.Create();              // ← 向服务器发送注册请求
}

// ===== 停止订阅 =====
private void StopSubscription()
{
    m_singleMonitoredItem.Notification -= OnSingleMonitoredItemChanged;
    m_singleSub.Delete(true);           // ← 通知服务器删除订阅
    m_session.RemoveSubscription(m_singleSub);
    m_singleSub.Dispose();
    m_singleSub = null;
}

// ===== 回调处理(后台线程 → BeginInvoke 封送到 UI 线程)=====
private void OnSingleMonitoredItemChanged(MonitoredItem item,
    MonitoredItemNotificationEventArgs e)
{
    var notification = e.NotificationValue as MonitoredItemNotification;
    if (notification == null) return;

    var parsed = ParseDataValue(notification.Value);

    // ★ 关键:后台线程不能直接操作 UI 控件,必须用 BeginInvoke
    if (this.InvokeRequired)
        this.BeginInvoke(new Action(() => UpdateSubscriptionUI(parsed)));
    else
        UpdateSubscriptionUI(parsed);
}

迁移要点

  • 回调在后台线程 触发,更新 UI 必须用 Control.BeginInvoke()
  • SamplingInterval 控制采样频率,PublishingInterval 控制推送频率
  • 断开连接前必须先停止订阅,否则可能抛出异常

12.3 批量订阅

与单节点订阅的区别:一个 Subscription 包含多个 MonitoredItem,共享 500ms 发布间隔。

回调通过 item.DisplayName 区分是哪个节点的数据变化:

csharp 复制代码
private void OnBatchMonitoredItemChanged(MonitoredItem item,
    MonitoredItemNotificationEventArgs e)
{
    string nodeId = item.DisplayName;  // ← 通过 DisplayName 识别节点
    // ... 解析并更新 UI
}

13. 断线检测与断开

13.1 心跳检测

csharp 复制代码
private void Session_KeepAlive(object sender, KeepAliveEventArgs e)
{
    // 心跳返回 Bad 状态 → 连接已断开
    if (e.Status != null && ServiceResult.IsBad(e.Status))
    {
        this.BeginInvoke(new Action(() =>
        {
            StatusPic_pB.BackColor = Color.Red;
            Status_lb.Text = $"已断开: {StatusCode.LookupSymbolicId(e.Status.StatusCode.Code)}";
            SetConnectedState(false);
        }));
    }
}

13.2 断开流程

csharp 复制代码
private async Task DisconnectSession()
{
    if (m_session != null && !m_session.Disposed)
    {
        // 1. 先停止所有订阅
        StopSubscription();
        StopBatchSubscription();

        // 2. 取消心跳
        m_session.KeepAlive -= Session_KeepAlive;

        // 3. 关闭并释放会话
        await Task.Run(() =>
        {
            m_session.Close();    // 向服务器发送关闭请求
            m_session.Dispose();  // 释放资源
        });
        m_session = null;
    }
}

14. 完整代码清单

FormCertClient.cs --- 证书验证弹窗

csharp 复制代码
public partial class FormCertClient : Form
{
    // 用户是否勾选了"始终信任此证书"
    public bool AlwaysTrust => StoreCert_cB.Checked;

    // 解析证书并展示到 DataGridView
    public void LoadCertificate(X509Certificate2 cert)
    {
        Cert_dGV.Rows.Clear();
        Cert_dGV.Rows.Add("主题", cert.Subject);
        Cert_dGV.Rows.Add("颁发者", cert.Issuer);
        Cert_dGV.Rows.Add("有效期", $"{cert.NotBefore:yyyy-MM-dd} ~ {cert.NotAfter:yyyy-MM-dd}");
        Cert_dGV.Rows.Add("指纹", cert.Thumbprint);
        Cert_dGV.Rows.Add("签名算法", cert.SignatureAlgorithm?.FriendlyName);
    }
}
相关推荐
HMS工业网络15 天前
CRIMSON OPC UA客户端与WINCC SCADA OPC UA服务器通信
运维·服务器·客户端·opc ua
星野云联AIoT技术洞察24 天前
Brownfield 到 Cloud:老旧工业设备接入现代 IoT 平台的现实路径
mqtt·modbus·opc ua·工业iot·brownfield·协议适配·工业上云
图扑软件25 天前
50ms 级实时数字孪生|汽车先进制造车间工艺流程
3d·数据采集·webgl·数字孪生·可视化·opc ua·汽车制造
埃和智能1 个月前
快速实现PLC设备、智能仪表的数据转换OPC UA服务端标签(含客户端测试)
数据采集·modbus·opc ua·opc网关·plc通讯·数据标签·ua 服务端
星野云联AIoT技术洞察1 个月前
A2A、MCP、OPC UA、Modbus:Agentic IoT 控制平面的分层设计
modbus·opc ua·mcp·a2a·工业 iot·agentic iot·控制平面
星野云联AIoT技术洞察1 个月前
工业 IoT 协议适配层应该怎么设计:Modbus、OPC UA、MQTT 与 HTTP 如何统一
mqtt·数据建模·modbus·opc ua·http api·协议适配层·工业 iot
星野云联AIoT技术洞察2 个月前
OPC UA、MQTT、Modbus 应该如何分层:工业 IoT 接入架构新思路
mqtt·modbus·opc ua·边缘网关·设备接入·协议分层·工业iot
慧都小妮子2 个月前
PLC数据采集怎么做?三种主流采集方案对比:OPC Server / 网关 / SDK
opc ua·kepware·kepserver·opc da·takebishi·dxpserver·opc server
慧都小妮子5 个月前
汽车制造的设备数据采集:Kepware 与 Takebishi 在总装线的应用对比
opc ua·kepware·kepserver·takebishi·dxpserver·设备数据采集软件·opc server