目录

  1. 工程概述
  2. 环境依赖
  3. 项目文件结构
  4. 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 包

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 引用命名空间

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

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

标准 WinForms 入口,无需修改。

6.2 窗体构造器

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。

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 证书标识每个应用的唯一身份。客户端需要自己的证书即使使用匿名登录。

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 端点发现

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

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 创建会话

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 事件让客户端决定是否信任。

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 — 创建树节点

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 同步读取

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 异步读取

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 结果解析

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 单节点写入

// 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() 方法:

// 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 单节点订阅完整流程

// ===== 启动订阅 =====
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 区分是哪个节点的数据变化:

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

13. 断线检测与断开

13.1 心跳检测

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 断开流程

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 — 证书验证弹窗

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);
    }
}
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐