云计算百科
云计算领域专业知识百科平台

OPC UA 系列(一):标准全景与 Python 最小闭环——让第一条设备数据流动起来

配套项目:opcua-learning 配套资料:运行说明 · 实验记录

核心目标:理解 OPC UA 的信息模型、服务与通信之间的关系,使用 Python 创建一个模拟设备服务端,再用独立客户端进程连续读取它的量测值,最后通过故障实验区分连接失败与运行中断。

前置知识:能编写 Python 函数、处理异常,了解 IP 和端口。读过本仓库 IEC 104、DNP3、IEC 61850 系列会更容易理解应用场景,但不是必需条件。

实测基线:2026-09-05,Windows 11 x86_64、CPython 3.12.13、asyncua==2.0.1。本文附可运行代码、依赖锁文件和原始日志;UaExpert 互操作给出操作步骤,尚未实测。Linux、Python 3.13 和抓包验证不包含在本次结果中。

本系列共十篇,本文从 Python 最小通信闭环开始;篇目见系列目录。


1.1 从“读到了 230.6”开始理解问题

假设你在开发一个 Python 数据采集程序,设备返回一个浮点数 230.6。数值已经传过来了,但软件仍然无法安全地使用它:这是电压还是温度?单位是 V 还是 kV?属于哪台设备?值是什么时候产生的?设备掉线后留下的旧数值应该继续画成绿色吗?

如果所有含义都放在一份外部 Excel 点表里,采集程序当然也能工作。但设备扩容、模型修改和系统集成时,点表、驱动、数据库字段与界面就必须一起维护。任何一处更新遗漏,都可能出现“通信正常、业务含义错误”的情况。

OPC UA 把问题推进了一步:除了传递值,它还提供表达结构、类型和关系的共同基础,让客户端能通过服务发现和访问这些信息。规范将信息模型、消息模型、通信模型和一致性模型放在同一体系中。OPC UA Part 1,§4.1

不过,有建模能力不代表所有设备都暴露了完善模型。一个服务端如果只创建几千个含义不明的变量,客户端仍然需要外部约定。本系列会逐步把这些约定变成可审查的模型和工程配置;第一篇先验证通信链路和程序运行方式。

1.1.1 从 OPC Classic 到 OPC UA

OPC Classic 建立在 Microsoft COM/DCOM 技术之上,常见规范包括 DA(实时数据访问)、A&E(报警与事件)、HDA(历史数据访问)。这些接口让不同厂商的软件能够共享工业数据,但其技术基础与 Windows 体系紧密关联。OPC Foundation:Classic

OPC UA 中 UA 的全称是 Unified Architecture,统一架构。它是一套平台无关的工业信息交换体系,对应 IEC 62541 系列标准,统一考虑数据、事件、方法和安全等能力。OPC Foundation:Unified Architecture

这意味着 Python 开发者可以直接开发 OPC UA 应用。读一个变量不要求自己编写 COM 组件,也不要求先把业务逻辑改写成 C++。但如果项目面对的是旧 OPC DA 服务端,安装一个 OPC UA 库并不会使其自动变成 UA 设备:仍需已有 UA 接口或负责转换的桥接产品。

1.1.2 与已有电力协议如何衔接

本仓库已有系列为我们提供了不同的观察角度:

已有经验熟悉的工程问题迁移到 OPC UA 时关注什么
IEC 104 按点表解释地址、类型、品质与时标 先找到节点,再确认其数据类型、质量和时间
DNP3 区分当前数据、变化事件和控制操作 同样区分当前快照、通知、历史以及业务命令结果
IEC 61850 用模型表达设备功能,并通过通信服务访问 将模型、服务、通信映射分开理解,但不套用 LN/DO 等命名规则

这里讨论的是学习迁移,不是规范的一一等价。OPC UA 与这些协议可以在一个系统中协作,例如站端网关向下接入电力设备,向上提供 UA 数据服务。它不会仅凭“更统一”就自动替代现场所有通信方式。


1.2 三层结构与两种通信模型

1.2.1 先分清模型、服务和传输

面对一个新概念时,可以先判断它回答哪种问题:

层面问题本篇实例
信息模型 数据属于谁、叫什么、是什么类型? Station1 → Feeder1 → Voltage,值为 Double
服务 客户端如何找到和使用它? 读取命名空间表、定位节点、Read 变量值
通信映射 请求和结果怎样在线上传递? 本机 opc.tcp 连接,由 asyncua 处理编码和收发

#mermaid-svg-k2wLea3TtnvsEMjB{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-k2wLea3TtnvsEMjB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-k2wLea3TtnvsEMjB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-k2wLea3TtnvsEMjB .error-icon{fill:#552222;}#mermaid-svg-k2wLea3TtnvsEMjB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-k2wLea3TtnvsEMjB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-k2wLea3TtnvsEMjB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-k2wLea3TtnvsEMjB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-k2wLea3TtnvsEMjB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-k2wLea3TtnvsEMjB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-k2wLea3TtnvsEMjB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-k2wLea3TtnvsEMjB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-k2wLea3TtnvsEMjB .marker.cross{stroke:#333333;}#mermaid-svg-k2wLea3TtnvsEMjB svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-k2wLea3TtnvsEMjB p{margin:0;}#mermaid-svg-k2wLea3TtnvsEMjB .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-k2wLea3TtnvsEMjB .cluster-label text{fill:#333;}#mermaid-svg-k2wLea3TtnvsEMjB .cluster-label span{color:#333;}#mermaid-svg-k2wLea3TtnvsEMjB .cluster-label span p{background-color:transparent;}#mermaid-svg-k2wLea3TtnvsEMjB .label text,#mermaid-svg-k2wLea3TtnvsEMjB span{fill:#333;color:#333;}#mermaid-svg-k2wLea3TtnvsEMjB .node rect,#mermaid-svg-k2wLea3TtnvsEMjB .node circle,#mermaid-svg-k2wLea3TtnvsEMjB .node ellipse,#mermaid-svg-k2wLea3TtnvsEMjB .node polygon,#mermaid-svg-k2wLea3TtnvsEMjB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-k2wLea3TtnvsEMjB .rough-node .label text,#mermaid-svg-k2wLea3TtnvsEMjB .node .label text,#mermaid-svg-k2wLea3TtnvsEMjB .image-shape .label,#mermaid-svg-k2wLea3TtnvsEMjB .icon-shape .label{text-anchor:middle;}#mermaid-svg-k2wLea3TtnvsEMjB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-k2wLea3TtnvsEMjB .rough-node .label,#mermaid-svg-k2wLea3TtnvsEMjB .node .label,#mermaid-svg-k2wLea3TtnvsEMjB .image-shape .label,#mermaid-svg-k2wLea3TtnvsEMjB .icon-shape .label{text-align:center;}#mermaid-svg-k2wLea3TtnvsEMjB .node.clickable{cursor:pointer;}#mermaid-svg-k2wLea3TtnvsEMjB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-k2wLea3TtnvsEMjB .arrowheadPath{fill:#333333;}#mermaid-svg-k2wLea3TtnvsEMjB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-k2wLea3TtnvsEMjB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-k2wLea3TtnvsEMjB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-k2wLea3TtnvsEMjB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-k2wLea3TtnvsEMjB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-k2wLea3TtnvsEMjB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-k2wLea3TtnvsEMjB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-k2wLea3TtnvsEMjB .cluster text{fill:#333;}#mermaid-svg-k2wLea3TtnvsEMjB .cluster span{color:#333;}#mermaid-svg-k2wLea3TtnvsEMjB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-k2wLea3TtnvsEMjB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-k2wLea3TtnvsEMjB rect.text{fill:none;stroke-width:0;}#mermaid-svg-k2wLea3TtnvsEMjB .icon-shape,#mermaid-svg-k2wLea3TtnvsEMjB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-k2wLea3TtnvsEMjB .icon-shape p,#mermaid-svg-k2wLea3TtnvsEMjB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-k2wLea3TtnvsEMjB .icon-shape .label rect,#mermaid-svg-k2wLea3TtnvsEMjB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-k2wLea3TtnvsEMjB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-k2wLea3TtnvsEMjB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-k2wLea3TtnvsEMjB :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

Python 模拟量:230.0、230.1、230.2…

信息模型:Station1 / Feeder1 / Voltage

服务:Browse、Read、Write、Call、Subscription

本系列主线:UA TCP + UA Secure Conversation + UA Binary

Python 客户端 / 独立客户端

应用身份、用户身份、信任与权限

这张图是系列的阅读地图。本文只运行其中的模拟值与读取路径,后续依次加入模型、安全、订阅和工程能力。图中出现某项服务不代表本篇已验证它。

opc.tcp://127.0.0.1:4840/powerlab/ 是本例端点 URL:127.0.0.1 指当前计算机,4840 是配置的端口,/powerlab/ 是端点路径。这个路径不是节点路径,也不是打开后会显示网页的 HTTP 地址。

1.2.2 Client/Server 与 PubSub 分开理解

Client/Server 模型中,客户端使用服务器提供的服务;在本实验里,是客户端向服务端发起读取。模型中也有 Subscription,可以接收变化通知,因此“客户端/服务端”并不等于“只能定时轮询”。

PubSub 是另一套通信模型,使用 Publisher、Subscriber 和 DataSet 等概念,把数据集发送与接收者解耦。它不能与 C/S 中创建 Subscription 的操作混为一谈。OPC UA Part 14,§5.1

本系列内容使用方式何时展开
Read 轮询 客户端定时请求当前数据 本篇
C/S Subscription 建立监控项与订阅,通过 Publish 服务收取通知 第 4 篇
OPC UA PubSub 配置 Publisher/Subscriber 和数据集通信 第 8 篇

第一次编程选择轮询,是因为每次读取和输出的关系容易观察。它不意味着后续采集系统应该对全部测点逐点循环读。


1.3 写程序前需要的七个概念

概念在本实验中的含义容易混淆之处
Application 实现 UA 功能的应用程序 服务端应用身份不同于业务模型命名空间
Endpoint 客户端访问服务端的入口及相关连接配置 知道 URL 还需要匹配安全与用户身份策略
Server / Client 提供服务的一方与调用服务的一方 与“这个程序运行在服务器电脑上”无关
SecureChannel 通信层的通道上下文 名称包含 Secure 不代表选用 None 时仍有加密
Session 客户端与服务端之间的应用会话上下文 TCP 建立后还需要 UA 层的建立与激活过程
Node / NodeId 地址空间的节点与节点标识 显示名称不等于稳定地址
Subscription C/S 通知交付所使用的订阅对象 本篇尚未创建;不是 PubSub Subscriber

规范从服务器、地址空间和订阅实体三个方向解释这些对象的关系。OPC UA Part 1,§5.3

本例最值得提前养成的习惯是:保存 Namespace URI,连接后解析 Namespace Index。

我们定义的模型命名空间是 urn:example:powerlab,变量标识符是 Station1.Feeder1.Voltage。本次运行获得的节点字符串为:

ns=2;s=Station1.Feeder1.Voltage

ns=2 表示此服务器当前命名空间表中的索引,s= 表示字符串标识符。命名空间索引不是整个项目永久不变的业务编号。OPC UA Part 3,§5.2.2 NodeId

同样需要注意,标识符里的点号只是我们选择的命名约定。节点真正的父子关系由建模代码创建;仅仅把字符串命名为 A.B.C,不会自动生成三个层级。


1.4 建立可以复现的 Python 环境

1.4.1 库名、包名和版本

本系列使用 FreeOpcUa 项目的 opcua-asyncio,安装包名和导入名是 asyncua。本文锁定 2.0.1,该发布包要求 Python ≥3.10;系列示例选择 Python 3.12,并暂将项目范围限制为 3.12/3.13。PyPI 发布页

不要把旧教程中的 from opcua import Client 直接复制过来。两套库的导入名、异步调用和部分参数不同;本文的接口以实际安装的 2.0.1 包为准,而不是默认跟随 master 分支。

进入 examples 目录,执行:

uv sync –frozen –python 3.12

如果机器尚无该解释器,可以先执行 uv python install 3.12。随后所有命令都通过 uv run –frozen 使用项目虚拟环境,不依赖系统 python 恰好指向哪个版本。

这里有两个不同的“固定”:pyproject.toml 指定直接依赖 asyncua==2.0.1;uv.lock 还记录间接依赖及包哈希。文章代码不变而间接依赖变化,同样可能造成结果不同,所以复现时保留锁文件。uv sync 命令文档

本篇相关文件如下,均已提供:

examples/
├── pyproject.toml
├── uv.lock
├── .python-version
├── labs/
│ ├── l01_common.py # 模型标识、日志、参数检查
│ ├── l01_server.py # 量测模拟器与 UA Server
│ └── l01_client.py # 读取与阶段化错误记录
└── tests/
└── test_l01_processes.py # 两个独立进程之间的实际通信测试

1.4.2 只补充本篇需要的异步知识

async def 定义协程函数;调用后,需要 await 等待执行,或交给任务管理。asyncio.run() 创建并运行顶层事件循环。async with 则把资源进入和退出的异步操作放在一个明确范围内。

本篇服务端每次更新后使用 await asyncio.sleep(interval)。这段等待会让出执行机会,库可以继续处理连接与读取。若改成循环中的 time.sleep(),就会阻塞同一线程的事件循环,导致“服务端明明在打印数值,客户端却响应很慢”。Python 3.12:Coroutines and Tasks

不是所有方法都需要 await:例如 get_node() 构造本地节点对象,read_data_value() 才发起读取。本系列会结合实际调用解释,避免靠“它看起来像 I/O”来猜。


1.5 服务端:给模拟电压一个地址

1.5.1 本篇模型约定

我们先创建一个极小的地址空间:

#mermaid-svg-myC7LOzjwj1lWXvX{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-myC7LOzjwj1lWXvX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-myC7LOzjwj1lWXvX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-myC7LOzjwj1lWXvX .error-icon{fill:#552222;}#mermaid-svg-myC7LOzjwj1lWXvX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-myC7LOzjwj1lWXvX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-myC7LOzjwj1lWXvX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-myC7LOzjwj1lWXvX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-myC7LOzjwj1lWXvX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-myC7LOzjwj1lWXvX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-myC7LOzjwj1lWXvX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-myC7LOzjwj1lWXvX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-myC7LOzjwj1lWXvX .marker.cross{stroke:#333333;}#mermaid-svg-myC7LOzjwj1lWXvX svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-myC7LOzjwj1lWXvX p{margin:0;}#mermaid-svg-myC7LOzjwj1lWXvX .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-myC7LOzjwj1lWXvX .cluster-label text{fill:#333;}#mermaid-svg-myC7LOzjwj1lWXvX .cluster-label span{color:#333;}#mermaid-svg-myC7LOzjwj1lWXvX .cluster-label span p{background-color:transparent;}#mermaid-svg-myC7LOzjwj1lWXvX .label text,#mermaid-svg-myC7LOzjwj1lWXvX span{fill:#333;color:#333;}#mermaid-svg-myC7LOzjwj1lWXvX .node rect,#mermaid-svg-myC7LOzjwj1lWXvX .node circle,#mermaid-svg-myC7LOzjwj1lWXvX .node ellipse,#mermaid-svg-myC7LOzjwj1lWXvX .node polygon,#mermaid-svg-myC7LOzjwj1lWXvX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-myC7LOzjwj1lWXvX .rough-node .label text,#mermaid-svg-myC7LOzjwj1lWXvX .node .label text,#mermaid-svg-myC7LOzjwj1lWXvX .image-shape .label,#mermaid-svg-myC7LOzjwj1lWXvX .icon-shape .label{text-anchor:middle;}#mermaid-svg-myC7LOzjwj1lWXvX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-myC7LOzjwj1lWXvX .rough-node .label,#mermaid-svg-myC7LOzjwj1lWXvX .node .label,#mermaid-svg-myC7LOzjwj1lWXvX .image-shape .label,#mermaid-svg-myC7LOzjwj1lWXvX .icon-shape .label{text-align:center;}#mermaid-svg-myC7LOzjwj1lWXvX .node.clickable{cursor:pointer;}#mermaid-svg-myC7LOzjwj1lWXvX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-myC7LOzjwj1lWXvX .arrowheadPath{fill:#333333;}#mermaid-svg-myC7LOzjwj1lWXvX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-myC7LOzjwj1lWXvX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-myC7LOzjwj1lWXvX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-myC7LOzjwj1lWXvX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-myC7LOzjwj1lWXvX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-myC7LOzjwj1lWXvX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-myC7LOzjwj1lWXvX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-myC7LOzjwj1lWXvX .cluster text{fill:#333;}#mermaid-svg-myC7LOzjwj1lWXvX .cluster span{color:#333;}#mermaid-svg-myC7LOzjwj1lWXvX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-myC7LOzjwj1lWXvX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-myC7LOzjwj1lWXvX rect.text{fill:none;stroke-width:0;}#mermaid-svg-myC7LOzjwj1lWXvX .icon-shape,#mermaid-svg-myC7LOzjwj1lWXvX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-myC7LOzjwj1lWXvX .icon-shape p,#mermaid-svg-myC7LOzjwj1lWXvX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-myC7LOzjwj1lWXvX .icon-shape .label rect,#mermaid-svg-myC7LOzjwj1lWXvX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-myC7LOzjwj1lWXvX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-myC7LOzjwj1lWXvX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-myC7LOzjwj1lWXvX :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

Objects:标准入口

Station1:教学站点

Feeder1:教学馈线

Voltage:Double,模拟值约 230 V

这是教学模型,不是已经符合某个行业 Companion Specification 的标准设备。Station1/Feeder1 暂时使用基础 Object;Voltage 是基础变量。本篇把 V 作为实验约定,尚未配置 EngineeringUnits、范围或自定义设备类型。下一篇再把这些含义写进模型。

1.5.2 创建 Server 和节点

下面摘自 l01_server.py,参数由该文件的命令行入口传入;运行时请使用完整文件。

server = Server()
await server.init()
server.set_endpoint(endpoint)
server.set_server_name("PowerLab L01")
await server.set_application_uri("urn:example:powerlab:server")
server.set_security_policy([ua.SecurityPolicyType.NoSecurity])
server.set_identity_tokens([ua.AnonymousIdentityToken])

ns = await server.register_namespace(NAMESPACE_URI)
station = await server.nodes.objects.add_object(
ua.NodeId("Station1", ns), ua.QualifiedName("Station1", ns)
)
feeder = await station.add_object(
ua.NodeId("Station1.Feeder1", ns), ua.QualifiedName("Feeder1", ns)
)
voltage = await feeder.add_variable(
ua.NodeId(VOLTAGE_ID, ns),
ua.QualifiedName("Voltage", ns),
230.0,
ua.VariantType.Double,
)

这段代码里有几个不同职责:

  • server.init() 初始化标准地址空间与内部服务;模型创建在初始化之后进行。
  • set_application_uri() 设置应用标识;register_namespace() 注册本篇业务模型的命名空间,两者不互相替代。
  • ua.NodeId(…) 确定标识;ua.QualifiedName(…) 确定带命名空间的 BrowseName。
  • 初值 230.0 配合 ua.VariantType.Double,让本例的数据类型明确,而不是依赖 Python 数值类型的隐式推断。
  • 这里没有调用 set_writable()。服务端自己的更新程序仍可以改变值,但远程客户端不因此获得写量测的权限。模拟器生成的量测应由设备侧更新,客户端随意写入它并不是一个合理的默认接口。

    1.5.3 更新循环与清理

    async with server:
    emit("ready", endpoint=endpoint, namespace_index=ns,
    nodeid=voltage.nodeid.to_string())
    try:
    async with asyncio.timeout(duration or None):
    tick = 0
    while True:
    value = round(230.0 + (tick % 100) * 0.1, 1)
    await voltage.write_value(value, ua.VariantType.Double)
    emit("update", tick=tick, value=value)
    tick += 1
    await asyncio.sleep(interval)
    except TimeoutError:
    emit("duration_elapsed", seconds=duration)
    emit("stopped")

    模拟值每次增加 0.1,100 次后回到 230.0。采用确定性的序列,方便观察变化;它没有模拟真实电网的物理特性。

    async with server 进入后才打印 ready,所以测试程序可以据此启动客户端。–duration 为 0 时持续运行;大于 0 时,在开始监听后的指定时长结束更新循环,再退出 Server 上下文。这个计时范围不包括标准地址空间初始化耗时。

    手动按 Ctrl+C 时,顶层异步任务取消会经过上下文清理。强制杀进程或断电无法保证执行 Python 清理代码,不能把这两种结束方式混写。本次自动验证使用正常到时退出,并验证随后可以重新绑定同一端点。

    默认端点只监听 127.0.0.1,采用 None 安全策略和匿名身份。它用于本机实验;直接改成 0.0.0.0 并不能把这段程序变成适合网络部署的服务端,安全配置将在第 5 篇落实。


    1.6 客户端:按命名空间查找,再读取完整 DataValue

    下面是 l01_client.py 的关键调用:

    async with Client(url=endpoint, timeout=3.0, auto_reconnect=False) as client:
    ns = await client.get_namespace_index(namespace_uri)
    node = client.get_node(ua.NodeId(VOLTAGE_ID, ns))
    for sample in range(count):
    dv = await node.read_data_value()
    emit(
    "sample",
    sample=sample,
    nodeid=node.nodeid.to_string(),
    value=dv.Value.Value if dv.Value is not None else None,
    status=dv.StatusCode.name,
    source_timestamp=dv.SourceTimestamp.isoformat() if dv.SourceTimestamp else None,
    server_timestamp=dv.ServerTimestamp.isoformat() if dv.ServerTimestamp else None,
    )
    if sample + 1 < count:
    await asyncio.sleep(interval)

    完整文件还记录 connect、resolve_namespace、read、disconnect 四个应用阶段。异常会带阶段和异常类型输出,并返回退出码 1;达到读取次数并正常清理才输出 complete。

    1.6.1 为什么不只使用 read_value()

    只取数值适合查看最小效果,但会使读者过早把“工业数据”理解成一个 Python 浮点数。本例从第一篇就展示 DataValue 中的值、状态与时间:

    输出字段本例用途后续需要深化的边界
    value 模拟电压值 UA 类型与 Python 类型的转换
    status 观察 Good 等状态 坏质量可能导致库抛异常,不能假设总能打印一个 Bad 样本
    source_timestamp 观察源时间字段 本例来自模拟器/库的本地更新,不是实测设备采样时间
    server_timestamp 观察服务器时间字段 与 SourceTimestamp 相等不代表两者在规范上同义

    代码没有在异常后填入最后一次成功值来继续输出。如果服务已停止,继续打印旧值会制造“数据仍在正常更新”的错觉。

    1.6.2 为什么显式关闭自动重连

    实际安装包中,Client 构造参数已包含 auto_reconnect。这也说明只看旧 README 的能力条目不足以判断新版本行为。

    本篇显式设置 False:连接失败直接失败,读取期间断开则输出错误。这样读者能看到故障发生的位置。重连之后订阅是否重建、历史是否缺失、业务命令是否重复,是第 4 篇的问题,不能靠打开一个布尔选项就宣称全部解决。


    1.7 L01:两个进程之间的正常闭环

    1.7.1 手动运行

    在两个终端中进入同一个 examples 目录。终端 A 执行:

    uv run –frozen python -m labs.l01_server

    看到 "event": "ready" 后,终端 B 执行:

    uv run –frozen python -m labs.l01_client –count 6

    客户端读取六次后退出;服务端继续运行,方便再连接一次。实验拓扑如下:

    Python Client 进程

    Python Server 进程

    Python Client 进程

    Python Server 进程

    #mermaid-svg-xS1giAYnD1sosCNS{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xS1giAYnD1sosCNS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xS1giAYnD1sosCNS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xS1giAYnD1sosCNS .error-icon{fill:#552222;}#mermaid-svg-xS1giAYnD1sosCNS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xS1giAYnD1sosCNS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xS1giAYnD1sosCNS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xS1giAYnD1sosCNS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xS1giAYnD1sosCNS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xS1giAYnD1sosCNS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xS1giAYnD1sosCNS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xS1giAYnD1sosCNS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xS1giAYnD1sosCNS .marker.cross{stroke:#333333;}#mermaid-svg-xS1giAYnD1sosCNS svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xS1giAYnD1sosCNS p{margin:0;}#mermaid-svg-xS1giAYnD1sosCNS .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xS1giAYnD1sosCNS text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-xS1giAYnD1sosCNS .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-xS1giAYnD1sosCNS .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-xS1giAYnD1sosCNS .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-xS1giAYnD1sosCNS .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-xS1giAYnD1sosCNS #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-xS1giAYnD1sosCNS .sequenceNumber{fill:white;}#mermaid-svg-xS1giAYnD1sosCNS #sequencenumber{fill:#333;}#mermaid-svg-xS1giAYnD1sosCNS #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-xS1giAYnD1sosCNS .messageText{fill:#333;stroke:none;}#mermaid-svg-xS1giAYnD1sosCNS .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xS1giAYnD1sosCNS .labelText,#mermaid-svg-xS1giAYnD1sosCNS .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-xS1giAYnD1sosCNS .loopText,#mermaid-svg-xS1giAYnD1sosCNS .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-xS1giAYnD1sosCNS .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-xS1giAYnD1sosCNS .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-xS1giAYnD1sosCNS .noteText,#mermaid-svg-xS1giAYnD1sosCNS .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-xS1giAYnD1sosCNS .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xS1giAYnD1sosCNS .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xS1giAYnD1sosCNS .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xS1giAYnD1sosCNS .actorPopupMenu{position:absolute;}#mermaid-svg-xS1giAYnD1sosCNS .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-xS1giAYnD1sosCNS .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xS1giAYnD1sosCNS .actor-man circle,#mermaid-svg-xS1giAYnD1sosCNS line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-xS1giAYnD1sosCNS :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    输出 ready

    loop

    [连续读取]

    仍可接收下一次连接

    初始化模型并开始监听

    建立通道与会话(库处理)

    获取命名空间表

    URI 与当前索引

    更新模拟电压

    Read Voltage

    DataValue

    清理会话与连接

    这是概念时序图,不是抓包逐帧重建。真实更新和读取是独立调度的,图中的行序不表示每次更新都会紧接着被读取。

    1.7.2 本次实际结果

    自动测试为避免端口冲突使用动态回环端口,并将服务端更新周期设为 0.1 秒、客户端读取周期设为 0.2 秒。以下表格摘自原始 normal.client.log(实验说明):

    samplevaluestatusSourceTimestamp(UTC)
    0 230.6 Good 2026-09-05T13:13:21.004108+00:00
    1 230.8 Good 2026-09-05T13:13:21.213573+00:00
    2 231.0 Good 2026-09-05T13:13:21.409133+00:00
    3 231.2 Good 2026-09-05T13:13:21.615005+00:00
    4 231.4 Good 2026-09-05T13:13:21.817472+00:00
    5 231.6 Good 2026-09-05T13:13:22.018365+00:00

    首个读数不是初始值 230.0,因为服务端在客户端完成连接前已经更新数次。这是正常现象。测试断言的是“六个有效浮点读数且存在变化”,不会把某个首值或固定步长当成所有机器都必须满足的协议行为。

    日志还出现了下面的协商提示:

    Requested session timeout to be 3600000ms, got 600000ms instead

    客户端请求的会话超时与服务端接受的值不同。本次后续读取正常,不能仅因日志等级是 WARNING 就判断连接失败。超时协商值和生命周期将在第 3 篇结合报文解释。


    1.8 三个失败实验:故障发生在哪一段

    1.8.1 错误端口:还没有建立可用连接

    确认本机 4841 没有服务监听后执行:

    uv run –frozen python -m labs.l01_client –endpoint opc.tcp://127.0.0.1:4841/powerlab/ –count 1

    本次自动测试在另一个未监听的动态端口复现,得到 stage=connect、error_type=ConnectionRefusedError、退出码 1,没有任何 sample。

    这个错误发生在本例的连接入口,不是 NodeId 错误,也不是“电压为零”。不同操作系统可能给出不同错误号,验收应依据阶段和异常性质。若防火墙丢弃连接尝试,现象也可能是超时,而不是立即拒绝。

    1.8.2 错误命名空间:已经进入 UA 服务调用

    保持默认服务端运行,在另一个终端执行:

    uv run –frozen python -m labs.l01_client –namespace-uri urn:example:missing

    本次结果是 stage=resolve_namespace、error_type=ValueError,错误内容指出该 URI 不在列表中。

    这时继续排查防火墙就偏离了证据。客户端已获取命名空间信息,但配置的模型身份不匹配。ValueError 是本版本辅助方法查表产生的 Python 异常,不是服务端直接返回一个名为 ValueError 的 UA 状态码。

    1.8.3 读值期间服务端停止:不能把旧数值当新样本

    先启动一个 15 秒后自然退出的服务端:

    uv run –frozen python -m labs.l01_server –duration 15

    看到 ready 后,在另一个终端请求 40 次读取:

    uv run –frozen python -m labs.l01_client –count 40

    本次自动测试使用更短周期复现该场景:客户端先成功读到一段数据,随后输出:

    {"event": "error", "stage": "read", "error_type": "ConnectionError", "message": "client is disconnected"}

    客户端退出码为 1,没有输出 complete。这才是本例希望保留的事实:采集过程未按目标完成。

    具体异常可能受关闭方式及检测时机影响。这里验证的是服务端正常停止后的失败路径,不覆盖所有网络黑洞、断电或连接半开的情况。断开后清理 Session/Channel 的警告也保留在日志中,不将它们伪装成另一段成功会话。

    现象本程序阶段优先核对
    一开始就没有 sample connect 服务是否 ready、端点地址和监听端口
    能连接但找不到业务模型 resolve_namespace Namespace URI 是否与服务端一致
    先有值,随后失败 read Server/链路状态,再看数据服务错误
    值重复但没有异常 read 更新周期与读取周期;不能直接推断断线

    connect 是应用级阶段,内部仍包含多个 UA 建立步骤。本篇日志没有细分它们,第 3 篇再下钻到通道和 Session。


    1.9 独立客户端观察与验收边界

    1.9.1 UaExpert 操作路径(待实测)

    UaExpert 可作为独立参考客户端观察模型。厂商产品页 在装有该软件的本机上,可以按以下路径复验:

  • 保持默认 Python Server 运行并确认 ready。
  • 新增服务端地址 opc.tcp://127.0.0.1:4840/powerlab/。
  • 选择本例提供的 None 安全策略和匿名身份连接。
  • 展开 Objects → Station1 → Feeder1,找到 Voltage,检查 NodeId 与 Double 类型。
  • 将变量添加到数据访问视图,观察值、质量和时间;记录 UaExpert 版本与截图。
  • 界面名称可能随版本变化,判断目标以端点、节点标识和类型为准。本次环境未安装 UaExpert,没有执行这组步骤,因此不能声称已完成跨实现互操作。数据访问视图通常涉及监控/订阅行为,复验时也应与本篇 Python 轮询结果分开记录。

    1.9.2 自动验收已经覆盖什么

    在 examples 目录执行:

    uv run –frozen python -m pytest -q -rA
    uv run –frozen python -m ruff check –no-cache labs tests

    本次进程级测试 4 项通过,Ruff 检查通过。测试使用真实 UA 通信,不以模拟 Client 返回值代替服务交互。

    验收项结果
    两个独立 Python 进程建立连接,连续读取变化值 通过
    值为浮点、质量 Good、存在源时间 通过
    服务端自然退出后,可在同一端点重新启动 通过
    错误端口、错误 Namespace URI 有明确失败阶段 通过
    服务端停止导致读取失败,不继续伪造完成 通过
    UaExpert 独立客户端、Linux、Python 3.13 未测
    加密连接、权限体系、断线恢复、抓包字段分析 后续篇章验证

    这一结果证明本地入门闭环及指定故障路径成立,尚不代表真实设备互操作、长期稳定性或一致性认证。本篇已列出实测环境、运行命令及主要判定结果。


    1.10 下一步:让模型真正表达设备含义

    现在已经有一个可以运行和观察失败的起点:服务端提供稳定标识的模拟量,客户端按命名空间定位,读出值、状态和时间,并在失败时保留阶段信息。

    但当前模型还不知道电压单位和量程,也没有说明同类设备如何复用类型。第二篇《信息模型、数据语义与 NodeSet2》将以这三个节点为基础,加入类型、引用、工程单位、质量映射和模型文件,让“能读取”继续发展为“能正确理解并长期维护”。

    标准与实现资料已在相关段落提供公开来源。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » OPC UA 系列(一):标准全景与 Python 最小闭环——让第一条设备数据流动起来
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!