配套项目 :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.1;uv.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,
)
这段代码里有几个不同职责:
server.init()初始化标准地址空间与内部服务;模型创建在初始化之后进行。set_application_uri()设置应用标识;register_namespace()注册本篇业务模型的命名空间,两者不互相替代。ua.NodeId(...)确定标识;ua.QualifiedName(...)确定带命名空间的 BrowseName。- 初值
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)
完整文件还记录 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 执行:
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=connect、error_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_namespace、error_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 可作为独立参考客户端观察模型。厂商产品页 在装有该软件的本机上,可以按以下路径复验:
- 保持默认 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 目录执行:
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》将以这三个节点为基础,加入类型、引用、工程单位、质量映射和模型文件,让"能读取"继续发展为"能正确理解并长期维护"。
标准与实现资料已在相关段落提供公开来源。