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。本次运行获得的节点字符串为:

text 复制代码
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 目录,执行:

console 复制代码
uv sync --frozen --python 3.12

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

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

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

text 复制代码
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,参数由该文件的命令行入口传入;运行时请使用完整文件。

python 复制代码
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,
)

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

  1. server.init() 初始化标准地址空间与内部服务;模型创建在初始化之后进行。
  2. set_application_uri() 设置应用标识;register_namespace() 注册本篇业务模型的命名空间,两者不互相替代。
  3. ua.NodeId(...) 确定标识;ua.QualifiedName(...) 确定带命名空间的 BrowseName。
  4. 初值 230.0 配合 ua.VariantType.Double,让本例的数据类型明确,而不是依赖 Python 数值类型的隐式推断。

这里没有调用 set_writable()。服务端自己的更新程序仍可以改变值,但远程客户端不因此获得写量测的权限。模拟器生成的量测应由设备侧更新,客户端随意写入它并不是一个合理的默认接口。

1.5.3 更新循环与清理

python 复制代码
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 的关键调用:

python 复制代码
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)

完整文件还记录 connectresolve_namespacereaddisconnect 四个应用阶段。异常会带阶段和异常类型输出,并返回退出码 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 执行:

console 复制代码
uv run --frozen python -m labs.l01_server

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

console 复制代码
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(实验说明)

sample value status SourceTimestamp(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,因为服务端在客户端完成连接前已经更新数次。这是正常现象。测试断言的是"六个有效浮点读数且存在变化",不会把某个首值或固定步长当成所有机器都必须满足的协议行为。

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

text 复制代码
Requested session timeout to be 3600000ms, got 600000ms instead

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


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

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

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

console 复制代码
uv run --frozen python -m labs.l01_client --endpoint opc.tcp://127.0.0.1:4841/powerlab/ --count 1

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

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

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

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

console 复制代码
uv run --frozen python -m labs.l01_client --namespace-uri urn:example:missing

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

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

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

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

console 复制代码
uv run --frozen python -m labs.l01_server --duration 15

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

console 复制代码
uv run --frozen python -m labs.l01_client --count 40

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

json 复制代码
{"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 可作为独立参考客户端观察模型。厂商产品页 在装有该软件的本机上,可以按以下路径复验:

  1. 保持默认 Python Server 运行并确认 ready。
  2. 新增服务端地址 opc.tcp://127.0.0.1:4840/powerlab/
  3. 选择本例提供的 None 安全策略和匿名身份连接。
  4. 展开 Objects → Station1 → Feeder1,找到 Voltage,检查 NodeId 与 Double 类型。
  5. 将变量添加到数据访问视图,观察值、质量和时间;记录 UaExpert 版本与截图。

界面名称可能随版本变化,判断目标以端点、节点标识和类型为准。本次环境未安装 UaExpert,没有执行这组步骤,因此不能声称已完成跨实现互操作。数据访问视图通常涉及监控/订阅行为,复验时也应与本篇 Python 轮询结果分开记录。

1.9.2 自动验收已经覆盖什么

在 examples 目录执行:

console 复制代码
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》将以这三个节点为基础,加入类型、引用、工程单位、质量映射和模型文件,让"能读取"继续发展为"能正确理解并长期维护"。

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

相关推荐
用户3721574261351 小时前
使用 Python 提取 Word 文档中的表格数据
python
有味道的男人1 小时前
得物详情 API|支持实时价格、成色、鉴别服务数据
windows·python·api
小溪学编程1 小时前
Java BufferedReader 详解:从基础用法到性能优化
java·python·性能优化
2601_962300471 小时前
基于Python与Tkinter的ADB Monkey自动化测试脚本开发
python·adb·tkinter·android测试·gui自动化
SamChan901 小时前
PDF翻译后的格式完整性校验:用Python自动比对译文与原文档的表格与段落结构
开发语言·python·ai·pdf·机器翻译
2601_962077982 小时前
Python是一门什么样的语言?
python·编程语言·解释器·编译型·解释型
用户019027581612 小时前
不用 SDK 也能取 A 股数据:AlphaFeed REST API 用 cURL/HTTP 直连教程
python
君顾12 小时前
外卖CPS系统开发实战指南:从架构设计到部署全流程解析
java·开发语言·外卖
旋生万物2 小时前
素数都排在等角螺线上?用螺旋数论给黎曼猜想画一张“几何画像“(附Python)
python·ai编程·数论·黎曼猜想·素数分布