Skip to content

opcua — OPC UA ​

模块名:opcua

OPC UA(opc.tcp)的服务端和客户端。服务端把 PLC 寄存器做成 OPC UA 变量节点,组态软件(KEPServerEX、UaExpert、组态王、WinCC 等)连上来就能读写;客户端主动去读别人的 OPC UA 服务器,采到的值落进本机寄存器,之后梯形图、云端、组态画面都能直接用。

底层是 open62541 v1.4.15,MPL-2.0。

两种角色:一台设备可以同时是服务端和客户端 ​

服务端和客户端是两条完全独立的 TCP 关系,所以 opcua.creat() 要用 mode 指定角色,两种角色的方法也不通用:

  • mode='server':只能用 add_node / add_folder / session_list
  • mode='client':只能用 add_poll / read / write / browse

一台设备上两个都要,就建两个对象。

一台设备两种角色:服务端等别人来读,客户端主动去读别人

哪些平台能用 ​

ESP32-S3 机型(LP3232 / LP3288)和软 PLC 模拟器(Windows / Linux,2026-08-22 起)。普通 ESP32 的 1536K app 分区放不下 open62541,这是硬件层面的限制,不会随固件升级改变。

模拟器上和真机是同一份 open62541,只有底层网络不一样(真机走 lwIP,Windows 走 winsock,Linux 走原生 socket),Lua 接口完全相同 —— 服务端/客户端两种角色、订阅、会话事件都能在电脑上先调通再下真机。

其他机型上 opcua 这个全局表根本不存在,脚本里直接用会报 attempt to index a nil value。要写通用脚本就先判断一下:

lua
if opcua == nil then
    print('本机固件没有 OPC UA 模块')
    return
end

已支持 / 没支持 ​

能力状态
服务端:变量节点、目录树、Browse、Read、Write支持
服务端:Subscription / MonitoredItem(组态软件订阅变化)支持
服务端:匿名登录、用户名密码登录支持
客户端:连接、Read、Write、Browse、周期采集、断线自动重连支持
客户端:Subscription支持。建不起来自动退回周期 Read,stat().sub_active 如实报当前用的是哪条路
SecurityPolicy#None支持
Basic256Sha256 / 证书管理 / Sign / SignAndEncrypt没做。配了这两项客户端会连不上,不会自动降级成 None
Method 调用、历史数据、Alarm&Conditions、PubSub没做
服务端 stat().monitored_items(被订阅的点数)支持。Subscription 个数拿不到,只能给点数
服务端 session_list() 里的对方 IP拿不到,open62541 的会话属性里没有 remote-address,给的是 sessionName

函数一览 ​

函数角色作用
opcua.creat两者创建节点对象,mode 决定是服务端还是客户端
node:add_node服务端把一个 PLC 寄存器做成 OPC UA 变量节点
node:del_node服务端删掉一个变量节点
node:add_folder服务端给浏览树上的目录起显示名
node:session_list服务端列出当前连上来的会话,诊断用
node:add_poll客户端加一个周期采集点,值自动落寄存器
node:del_poll客户端删掉一个采集点
node:read客户端同步读一个或一批 NodeId
node:write客户端同步写一个 NodeId
node:browse客户端浏览对方有哪些节点,调试用
node:start两者启动协议栈任务
node:close两者停止并释放
node:is_online两者服务端=有没有会话;客户端=Session 是否激活
node:stat两者通讯质量统计
node:reg两者注册事件回调

对象方法用冒号写法(node:start())。

事件一览 ​

常量角色回调签名说明
opcua.WRITE服务端(id, value)客户端写了本机的某个节点
opcua.SESSION服务端(up, name, user)会话建立/断开。up 是布尔;name 是客户端自报的 sessionName(不是 IP,见注意事项);user 匿名时是空串
opcua.ONLINE两者()从离线转为在线
opcua.OFFLINE两者()从在线转为离线
opcua.ERROR两者(code, addr)读写失败。code 是本模块错误码,原始 UA 状态码去 stat().last_err 拿
opcua.CHANGE客户端(addr, value)采集点的值变了。不用 reg 注册,写在 add_poll 的 on_change 里

回调要靠消息泵,别用 system.sleep 当延时

上面这些回调都是靠固件的消息泵从消息队列里取出来分发的。主线程里的 system.sleep 只是停住线程、不推进泵,所以脚本主循环写成 while true do ... system.sleep(5000) end 的话这些回调一个都不会触发。

周期性的事情用 timer.start(ms, function() ... end),脚本 return 后固件自动接管消息泵。本页示例都是这个写法。


opcua.creat ​

lua
opcua.creat(cfg) --> node | nil

创建一个 OPC UA 节点对象。不会连接、也不会监听,要调 start() 才真的起来。

参数

cfg 是一张表。共用字段:

字段类型默认值说明
modestring'server''client' 才是客户端,其它任何值(包括拼错)都当服务端
securitystring'none''sign' / 'signandencrypt',别的字符串一律当 none。当前固件只有 none 能用
userstring空服务端=允许登录的用户名;客户端=登录对方用的用户名
passwordstring空同上

服务端专用字段:

字段类型默认值说明
portnumber4840监听端口。填 0 会创建失败并返回 nil
ifnamestring空绑哪张网卡:'eth' / 'sta' / 'ap',空=自动(ETH→STA→AP 挑第一个有 IP 的)
app_namestring'LPPLC'ApplicationName,组态软件里显示的服务器名
app_uristring'urn:linpan:lpplc'ApplicationUri。开加密后必须和证书的 SAN 一致
ns_uristring'urn:linpan:lpplc:ns1'本机自身那个命名空间的 URI,运行时下标是 ns=2
anonymousbooleantrue是否允许匿名登录。见下面的安全提醒
max_sessionsnumber2最多几个并发会话,上限 4
pub_interval_minnumber200允许客户端协商到的最小发布周期(ms)。采样周期也按同一个下限卡,否则对方可以要 500ms 发布 + 5ms 采样,等于让固件每秒扫两百遍数据区

客户端专用字段:

字段类型默认值说明
urlstring—必填,opc.tcp://主机[:端口]。留空会创建失败并返回 nil
timeoutnumber3000单次请求超时(ms)
retriesnumber3超时后重试几次
subbooleantrue优先用 Subscription(对方推变化)。建不起来自动退回按 interval 周期 Read,功能不打折,只是报文多

返回值

节点对象,参数不合法时返回 nil(客户端没给 url、服务端 port=0、内存不够)。

示例

lua
-- 服务端
srv = opcua.creat({ mode = 'server', port = 4840, ifname = 'eth',
                    app_name = '1号锅炉房', anonymous = false,
                    user = 'op', password = '8888' })

-- 客户端
cli = opcua.creat({ mode = 'client', url = 'opc.tcp://192.168.1.50:4840',
                    timeout = 3000, retries = 2 })

匿名 + 不加密等于把 PLC 寄存器公开

默认是允许匿名(产品决策:上手体验优先)。匿名 + security='none' 意味着任何能连到这个网段的人都能读写 PLC 的 D/M 寄存器 —— 而设备可能装在 4G 或公网侧。要收紧就写 anonymous=false 并给 user / password。

三档门槛:匿名 / 账号口令 / 加密(加密还没做)


数据类型与寄存器绑定 ​

add_node / add_poll 的 mode 参数用这些常量:

常量OPC UA 类型占几个 D 寄存器
opcua.BOOLEAN(别名 opcua.BITE)Boolean位区一个点
opcua.S16Int161
opcua.U16UInt161
opcua.S32Int322
opcua.U32UInt322
opcua.FLOATFloat2
opcua.DOUBLEDouble4
opcua.STRINGString只能绑 data.creat() 的变量,不能绑寄存器

BITE 是老脚本用的别名,新脚本建议写 opcua.BOOLEAN。

var 参数(绑到哪个寄存器)三种写法:

写法说明
字符串 'D100' / 'M10' / 'X0'推荐。区号 D/M/S/X/Y 加数字;X/Y 按八进制(不能出现 8、9);数值 ≤65535;M8000 以上自动转特殊线圈区
数字 100位类型当 M100,数值类型当 D100
data.creat() 返回的对象绑 Lua 侧变量。opcua.STRING 只能用这种

数值类型别绑位区

opcua.FLOAT 绑到 M10 不会报错,但会被降级成 0/1 —— 读出来永远只有 0 或 1。数值点请绑 D 区。

安全策略常量(SEC_*) ​

常量对应 OPC UA 的 SecurityPolicy要不要配证书
opcua.SEC_NONENone:报文不签名也不加密,只有用户名密码这一层不用
opcua.SEC_SIGNSign:报文带签名,防篡改防冒充要 —— 双方都得有证书
opcua.SEC_SIGNENCSignAndEncrypt:签名 + 整个报文加密要 —— 同上,还要对方信任本机的证书

这三个常量目前只是给「这台设备只支持 None」这件事一个显式写法,没有功能入口,两件事要分清楚:

  • creat 的 security 字段只认字符串:'none' / 'sign' / 'signandencrypt'。把 opcua.SEC_SIGN 这种数字常量填进去不会被识别,效果和没写一样。
  • 就算填对了字符串 'sign',当前固件跑起来仍然是 None:security 这项在 creat 之后没有任何地方消费它,链路层建服务端 / 建客户端时都不带这个参数;open62541 那边也只编进了 None 一种策略,固件里没有证书加载入口(没有文件路径参数、也没有证书相关的 API)。

所以对客户的一句话是:能谈的只有 None。要接一台强制加密的 OPC UA 服务器,得让对方放开到 None,或者在中间放一台做协议转换的网关 —— 本机的客户端不会因为配了 'sign' 就连得上对方。反过来说,本机的服务端对任何客户端都是不加密的,这一点在公网 / 4G 场景下要按匿名那条 warning 一起评估。

错误码 ​

值名称含义
0OK成功
8701TIMEOUT请求超时
8702CONNECT连不上 / 连接已断
8703SESSIONSession 失效
8704SECURITY证书或安全策略不匹配
8705AUTH用户名密码不对
8706NODEID地址文本非法(本机侧就没通过校验,没发出去)
8707UNKNOWN_ID对方说没有这个节点
8708DATA_TYPE类型对不上
8709WRITE_DENIED对方不让写
8710OUT_OF_RANGE超量程
8711TOO_MANY点数或会话数超上限
8712PORT端口被占 / 网卡还没 IP
8713ENCODING对方发了本机不支持的 Variant

排查现场问题看 stat().last_err,不是看这个表

这 13 个码是把上千个 UA_StatusCode 归类之后的结果,够用来做逻辑判断(比如"要不要重试"),但排查问题信息量不够。node:stat().last_err 里存的是 open62541 的 32 位原始状态码,比如 0x80AB0000 = BadInvalidArgument、0x800A0000 = BadInternalError,拿这个去查 OPC UA 规范的状态码表才能定位。


node:add_node ​

lua
node:add_node(id, mode, var, [access, name, opt]) --> 1 | 0

服务端专用。把一个 PLC 寄存器做成 OPC UA 变量节点,客户端就能读写它。

参数

#参数类型必填默认值说明
1idstring是—标识符本身,不带 ns=2;s= 前缀。见下面的命名规则
2modeinteger是—opcua.FLOAT 等
3varstring/number/data是—绑哪个寄存器
4accessinteger否opcua.READopcua.READ / opcua.READ_WRITE / opcua.WRITE_ONLY
5namestring否用 id 的最后一段DisplayName,可中文
6opttable否—{ unit='degC', desc='...', min=0, max=100 }

opt 的字段:

字段说明
unit工程单位文本,如 'degC' / 'm3/h'
descDescription,可中文。不给就不上报这个属性
min / max量程。两个都给且 max>min 才算配了,只给一边按"没配"处理

标识符命名规则

点地址三段:ns=2 / s= / 名字,名字里的点号就是目录分层

固件会把 id 拼成 ns=2;s=<id>。规则:

  • 每段首字符只能是字母或下划线,其余可以是字母、数字、下划线
  • 用 . 分层,最多 3 段(A.B.C),超了会被挂到根下并记日志
  • 总长小于 64 字节
  • 带 . 的层级会自动建目录,不用先 add_folder

所以 Boiler1.Temp 合法、1Temp 不合法(数字开头)、Boiler-1.Temp 不合法(有减号)、A.B.C.D 太深。

返回值

1 成功,0 失败:标识符不合法、寄存器写法不合法、点数超上限(256 个)、id 已存在。

示例

lua
srv:add_node('Boiler1.Temp', opcua.FLOAT, 'D100', opcua.READ, '供水温度',
             { unit = 'degC', desc = '1号锅炉供水温度', min = 0, max = 150 })
srv:add_node('Boiler1.Run', opcua.BOOLEAN, 'M10', opcua.READ_WRITE, '运行')

node:del_node ​

lua
node:del_node(id) --> 1 | 0

服务端专用。删掉一个变量节点。start() 之后也能调。

参数

#参数类型必填说明
1idstring是当初 add_node 用的那段标识符,要逐字符一致(不带 ns=2;s= 前缀)。传非字符串直接抛 Lua 错误

返回值

值说明
1删掉了
0什么都没删:点表里没有这个 id,或者节点已经在运行

add_node / del_node / add_folder 是一组,add_poll / del_poll 是一组,全都只认「start() 之前」这个时序:协议栈跑起来后这几句一律返回 0 并且不生效。原因是点表同时被 Lua 任务、协议栈任务和 PLC 扫描周期三方读,在线改会让另外两边用到已经释放的缓冲区。要换点表就 close() → 改 → start()。

示例

lua
-- 换点表的标准动作:先停,改完再起
srv:close()
srv:del_node('Boiler1.Flow')
srv:add_node('Boiler1.Humidity', opcua.FLOAT, 'D104', opcua.READ, '湿度')
srv:start()

node:add_folder ​

lua
node:add_folder(id, [name]) --> 1 | 0

服务端专用。给浏览树上的目录起显示名。

这个方法通常不需要调 —— add_node('Boiler1.Temp', ...) 会自动建出 Boiler1 目录。只有想让组态软件里显示中文目录名时才补一行:

lua
srv:add_folder('Boiler1', '1号锅炉')
srv:add_node('Boiler1.Temp', opcua.FLOAT, 'D100')

目录最多 32 个。

参数

#参数类型必填说明
1idstring是目录的标识符,规则与 add_node 的 id 完全一样(每段以字母或下划线开头、. 分层、最多 3 段、总长小于 64 字节)。'Boiler1'、'Plant1.Boiler1' 都行
2namestring否目录显示名,可中文。不给就用 id 的最后一段

返回值

值说明
1成功。同一个 id 再调一次是覆盖显示名,不会多出第二条
0失败:id 不合规则、目录表已满、或节点已经在运行(时序限制同 add_node)

还有两条是代码里就这么定的,配点表时会用到:

  • add_folder 只登记它自己这一条,不会顺手把父级目录也建出来 —— 父级目录是 add_node 时按 . 自动补的。所以 add_folder('A.B','甲乙') 只有在 A 也确实被某个点用出来时才看得到层次。
  • 先 add_folder 还是先 add_node 都不影响结果:自动补目录那一步会跳过已经存在的条目,所以中文显示名不会被冲掉;反过来先 add_node 后 add_folder,就是在覆盖自动建出来的那个英文名。

示例

lua
srv:add_folder('Plant1', '一号厂房')
srv:add_folder('Plant1.Boiler1', '1号锅炉')
srv:add_node('Plant1.Boiler1.Temp', opcua.FLOAT, 'D100', opcua.READ, '供水温度')

node:session_list ​

lua
node:session_list() --> { { name=..., user=..., items=..., up_ms=... }, ... }

服务端专用。列出当前连上来的会话,诊断用。

参数

无。

返回值

一张数组表;一个客户端都没连时就是空表 {}(不是 nil,ipairs 直接可用)。条数不会超过服务端能接受的会话数。

字段说明
name客户端自报的 sessionName。UaExpert 之类的工具会放主机名进去,识别度够用
user登录用户名,匿名时是空串
items这个会话订阅了几个点(MonitoredItem)
up_ms会话建立至今的毫秒数

这份表是快照:台账由协议栈任务每 10ms 从 open62541 里抄一轮,所以 up_ms 的精度就是这个粒度,别拿它当毫秒级计时器用。在客户端对象上调用不会报错,返回空表 —— 客户端没有"别人连本机"这回事。

这里没有对方 IP

OPC UA 的会话属性里只有 sessionName / clientUserId / clientDescription / localeIds,没有 remote-address —— 那是连接管理层的参数,不往 Session 上传。要拿 IP 得自己包一层 ConnectionManager,代价远大于收益,所以这里给的是 sessionName。

lua
for _, s in ipairs(srv:session_list()) do
    print(string.format('%s (%s) 订阅%d个点 已连接%d秒',
          s.name, (s.user == '' and '匿名' or s.user), s.items, s.up_ms // 1000))
end

node:add_poll ​

lua
node:add_poll(addr, mode, var, [opt]) --> 1 | 0

客户端专用。加一个周期采集点:固件后台按周期去读对方,值自动落进本机寄存器。生产脚本用这个,不要用 read 轮询 —— read 是同步的,会阻塞调用它的 Lua 任务。

采集点表:对方的点 → 本机的寄存器,掉线置故障标志

参数

#参数类型必填默认值说明
1addrstring是—对方的完整 NodeId,见下
2modeinteger是—数据类型
3varstring/number/data是—值落到哪个寄存器
4opttable否—见下表

opt 的字段:

字段类型默认值说明
intervalnumber1000采集周期(ms)
accessintegeropcua.READ配 opcua.READ_WRITE 时,改了本机寄存器下一轮会写给对方
deadbandnumber0绝对死区。变化量小于它不回调、不落寄存器
fail_flagstring无连续失败时置位的线圈,如 'M520'。恢复后自动复位
on_changefunction无值变化回调,签名 function(addr, value)。第一次读到值不触发

NodeId 怎么填

这里填的是对方定的完整 NodeId,不是本机服务端那套受限标识符。认这些形态:

写法说明
ns=2;s=Tank.Level字符串标识符。s= 后面不限字符集,带空格、中文、斜杠都行
ns=3;i=1001数字标识符
i=2258省略 ns= 就是 ns=0(OPC UA 标准命名空间)
ns=1;g=... / ns=1;b=...GUID / ByteString,按字符串处理

不知道对方有哪些点就先用 browse 扫一遍。

ns 下标不能猜

ns 是"命名空间数组里的第几个",同一个 URI 在不同服务器上下标可以不一样,甚至同一台服务器重启后也可能变。正规做法是先读 Server_NamespaceArray(i=2255)拿到 URI 列表,再按 URI 找下标。UaExpert 之类的工具会直接显示完整 NodeId,抄下来最省事。

本机自身的服务端是 ns=2,不是 ns=1 —— ns=1 被 ApplicationUri 占了。

值是"对方推过来"还是"本机按周期去问"

creat 时的 sub 决定优先走哪条路,interval 在两种方式下都有意义(订阅时它是协商发布周期的依据,退回周期读时它就是问的间隔)。

订阅是变化才推,周期读是到点就问一次

返回值

1 成功,0 失败:NodeId 不合法、寄存器不合法、点数超上限(128 个,所有客户端实例加起来)。

示例

lua
cli:add_poll('ns=2;s=Tank.Level', opcua.FLOAT, 'D400', {
    interval = 1000,
    deadband = 0.1,
    fail_flag = 'M520',
    on_change = function(addr, value)
        print('液位变了', addr, value)
    end,
})

-- 可回写点:改了 D410,下一轮就写给对方
cli:add_poll('ns=2;s=Tank.SetPoint', opcua.FLOAT, 'D410', {
    interval = 1000, access = opcua.READ_WRITE })

node:del_poll ​

lua
node:del_poll(addr) --> 1 | 0

客户端专用。删掉一个采集点,addr 要和 add_poll 时填的完全一致。

参数

#参数类型必填说明
1addrstring是当初 add_poll 传的那串 NodeId 原文。删点是拿整串文本去和登记时那份逐字符比对的:多一个空格、少写 ns=、i= 和 s= 换着写,都算另一个点,结果是返回 0 且什么都没删

返回值

值说明
1删掉了
0什么都没删:没有这个点,或者节点已经在运行(时序限制同 add_poll,要改点表先 close())

带 on_change 的点不要反复增删

del_poll 只把这一条从点表里抹掉,上一次传进来的 on_change 函数还留在 Lua 里不释放;对同一个 addr 再 add_poll 一次也是同样效果(旧的那份被丢掉但没回收)。一次性换点表无所谓,在长跑的循环里定时增删带回调的点,内存会一直往上爬。要整体换点表就 close() → 改 → start(),或者重启脚本。

示例

lua
cli:close()
cli:del_poll('ns=2;s=Tank.Level')
cli:start()

node:read ​

lua
node:read(addr, [mode])        --> { errno = 0, data = { value } }
node:read({addr1, addr2}, [mode]) --> { errno = 0, data = { v1, v2 } }

客户端专用。同步读一个或一批 NodeId。mode 默认 opcua.FLOAT。

参数

#参数类型必填默认值说明
1addrstring / string 数组是—对方的完整 NodeId,写法同 add_poll。传数组就是批量读
2modeinteger否opcua.FLOAT这个参数读方向不看:对方回来什么数值类型都一律折成 double(Boolean 折成 0/1,Int64/UInt64 也折成 double),填与不填一样。写方向才见分晓,见 write

返回值

{ errno = ..., data = { ... } }。data 的下标和输入顺序一一对应,errno 记的是第一个错误码。

现象errnodata 里对应位置
全部读到0各点的值
地址文本不合法(ns 超过 65535、s= 后面超过 63 字节、ns= 后面漏了分号)87060
数组里混进了非字符串元素87060
对方说没有这个节点87070
对方这个点的值折不成数值(字符串型、数组型 Variant)87130
Session 还没激活 / 连接已断 / 在服务端对象上调 read87020

一批里有某个失败时,data 里对应位置是 0,errno 记第一个错误码 —— 所以 errno ~= 0 时不能认为整批都没读到。

传数组是逐个发一次 Read,N 个地址就是 N 次串行往返,不是一帧批量读;一次别列几十个点。只有超时 / 连接 / 会话这三类错误会按 creat 的 retries 重发,地址错、类型错、权限不足一次就返回,不会白等 timeout × retries 那么久。

同步调用会阻塞

read / write / browse 都是同步请求:发出去等应答,期间调用它的 Lua 任务停住。只适合调试或者一次性的初始化,周期采集请用 add_poll。

示例

lua
local r = cli:read('ns=2;s=Tank.Level')
if r.errno == 0 then print('液位', r.data[1]) end

local r2 = cli:read({ 'ns=2;s=A', 'ns=2;s=B' }, opcua.S32)
print(r2.errno, r2.data[1], r2.data[2])

node:write ​

lua
node:write(addr, value, [mode]) --> errno

客户端专用。同步写一个 NodeId。value 可以是数字或布尔。返回错误码,0 是成功。

参数

#参数类型必填默认值说明
1addrstring是—对方的完整 NodeId,写法同 add_poll
2valuenumber / boolean是—布尔按 1/0 发。其它类型(字符串、表)直接抛 Lua 错误
3modeinteger否opcua.FLOAT写方向真正起作用的就是它:决定发出去的 Variant 类型。BOOLEAN→Boolean、S16→Int16、U16→UInt16、S32→Int32、U32→UInt32、FLOAT→Float、DOUBLE→Double

mode 填 opcua.STRING 不会写成字符串 —— 字符串不在转换分支里,会落到默认分支按 Float 发出去。想往对方写字符串目前做不到,add_poll 那侧同理。

返回值

一个错误码:

值说明
0对方接受了
8706地址文本非法,本机侧就没通过校验,报文根本没发出去
8708类型对不上:mode 和对方节点的数据类型不一致,对方顶回 BadTypeMismatch,原始码在 stat().last_err
8709对方不让写(只读点)
8702Session 还没激活 / 连接已断,或在服务端对象上调 write
8701请求超时(这一类才会按 retries 重发)

同步调用,会阻塞住调它的 Lua 任务;只有超时 / 连接 / 会话类错误才重试,类型错、权限不足一次就返回。

lua
local e = cli:write('ns=2;s=Tank.SetPoint', 32.5)
if e ~= 0 then print('写失败', e, string.format('0x%08X', cli:stat().last_err)) end
cli:write('ns=2;s=Pump.Run', true, opcua.BOOLEAN)

node:browse ​

lua
node:browse([id, max]) --> { errno = 0, data = { { id=, name=, class=, type_id= }, ... } }

客户端专用。列出某个节点下面有哪些子节点。id 不给就从 Objects 根开始,max 默认 64、上限 256。

参数

#参数类型必填默认值说明
1idstring否从 Objects 根开始要展开的那个节点的完整 NodeId(写法同 add_poll)。传空串等同于不传。写错了返回 8706,不会去问对方
2maxnumber否64最多列几条。小于 1 按 1,超过 256 按 256(别让脚本一口气打几千帧)

返回值

{ errno = ..., data = { ... } },data 里每一项:

字段说明
id子节点的完整 NodeId,可以直接拿去 add_poll。例外:对方那个节点的标识符是 GUID 或 ByteString 型时,这一列是空串 —— 看到空串就知道这条粘不回点表
nameBrowseName
class'Object'(还能往下展开)/ 'Variable'(能读值)/ 'Other'
type_id数据类型的数字 id,Variable 才有意义;对方给的类型定义不是数字形式时是 0

errno 的含义和 read 一样:8706 地址文本非法、8702 没连上或在服务端对象上调、8707 对方说没这个节点。中途某一条取失败时不会整体放弃:已经读到的照样在 data 里,errno 记下第一个错误码。

只做一层,不递归、不做 BrowseNext。要往下看就拿子节点的 id 再 browse 一次。

示例

lua
-- 扫一遍对方有什么
local function dump(id, depth)
    local r = cli:browse(id)
    if r.errno ~= 0 then print('browse 失败', r.errno); return end
    for _, o in ipairs(r.data) do
        print(string.rep('  ', depth) .. o.class .. ' ' .. o.name .. '  ' .. o.id)
        if o.class == 'Object' and depth < 2 then dump(o.id, depth + 1) end
    end
end
dump(nil, 0)

node:start ​

lua
node:start() --> 1 | 0

启动协议栈任务。服务端在这里绑端口开始监听;客户端不在这里连接 —— 连接是任务起来之后异步做的,所以 start() 返回 1 不代表已经连上,要看 is_online()。

返回 0 的原因:已经在跑、建任务失败(内部 RAM 不够)、服务端端口被占用或网卡还没 IP。

参数

无。

返回值(把上面那份列表说全)

值什么时候
1起来了。同一个对象重复调也返回 1(空操作,不会再起一个任务)
0服务端:这台设备上已经有另一个服务端对象在跑(端口独占,链路层的服务端实例也是单份,日志里会打「已有服务端实例在运行」)
服务端:anonymous=false 但 user / password 有一个是空 —— 这种配法谁都连不上,固件拒绝启动而不是偷偷退回匿名,日志里有原因
客户端:url 是空串
客户端实例数已经用满(creat 建不出客户端句柄)
两条链路通用:链路层起不来(端口被别的 TCP 服务占着、网卡还没拿到 IP)、协议栈任务建不出来(内存不够,日志里带剩余字节数)

服务端对象一台设备只能有一个,客户端对象可以同时有几个(各自一条独立的 TCP 连接)。所以「既被别人读、又去读别人」是一台设备上一个 server 加若干个 client,不是两个 server。

服务端要等网络连上再启动

网卡还没 IP 时 start() 如实返回 0,不会绑到 0.0.0.0 然后装作已经启动。脚本里可以失败后隔几秒重试:

lua
while srv:start() ~= 1 do
    print('OPC UA 启动失败,等网络...')
    system.sleep(5000)   -- 这里用 sleep 没问题:还没起来,也就没有回调要收
end

node:close ​

lua
node:close() --> 1 | 0

停止协议栈任务并断开所有连接。对象被 GC 时也会自动调,所以忘记 close 不会泄漏。

参数

无。

返回值

恒为 1 —— 节点本来就没在跑时它是空操作,不报错也不返回 0。

它有多慢:close() 是同步等协议任务自己退出来,等待上限由 creat 的 timeout 和 retries 算出来(timeout × (retries+1) + 500ms,两轮再加 1 秒,默认参数下最坏约 26 秒)。平时几毫秒就返回;只有客户端正卡在一次同步 read / write / browse 里时才会接近上限。所以别在周期任务里反复 close(),start() 之前那次重试等待用 system.sleep 是安全的(还没起来,没有回调要收)。

示例

lua
srv:close()                 -- 停下来改点表
srv:add_node('Boiler1.New', opcua.FLOAT, 'D150')
srv:start()                 -- 改完再起,统计计数器从这里重新开始清零

node:is_online ​

lua
node:is_online() --> boolean

服务端:当前有没有会话连着。客户端:Session 是否已激活。

服务端的判离线窗口是 60 秒(组态软件的轮询周期常见 10~30 秒,窗口太小会一直在 ONLINE/OFFLINE 之间抖);客户端直接看 Session 状态,不套时间窗。

参数

无。

返回值

布尔 true / false(不是 1/0,if node:is_online() then 直接可用)。节点没起来时恒为 false。

两个角色各看什么:

角色true 的判据掉线要多久才看得到
服务端当前至少有一条活动会话(stat().sessions > 0)对方断开或被回收的那一刻起,下一轮(协议任务 10ms 一轮)就是 false
客户端Session 已激活,能正常发请求连接一断、Session 状态一变就反映出来,不看时间窗

所以服务端的 is_online() 语义是"现在有没有人连着",不是"最近有没有人连过":组态软件关掉客户端、或者它的会话超时被服务端回收,这里立刻翻成 false。想拿"多久没人来读"当判据,去看 stat().last_rx_ms。

opcua.ONLINE / opcua.OFFLINE 两个事件就是上面这个值的跳变,判据和这里的表完全一致。

示例

lua
timer.start(1000, function()
    -- 客户端:连不上就把通讯故障灯点亮
    plc.m(500, not cli:is_online())
    -- 服务端:有没有人连着读我,看的就是会话数
    print('有人连着吗', tostring(srv:is_online()))
end)

node:stat ​

lua
node:stat() --> table

通讯质量统计。共有字段:

参数

无。

返回值

一张表,字段随角色不同(下面三张表列全)。计数器在每次 start() 时清零,所以它反映的是「这一次起来之后」的量 —— close() → start() 一遍,rx_msgs 这些都会从头计。

字段说明
rx_msgs进来的量。客户端角色 = 收到的有效应答数;服务端角色 = 被对方写进来的次数
tx_msgs出去的量。客户端角色 = 发出的请求数;服务端角色 = 值被取走的次数(对方 Read 和订阅采样都算,所以有人订阅时它会一直涨)
errors出错次数
last_erropen62541 的 32 位原始 UA_StatusCode,排查现场问题主要看这个
last_rx_ms距上次收到对方有效报文多久(ms)

服务端的收发为什么是"点级"的

open62541 不对外暴露报文级统计,能如实数到的最细粒度就是 DataSource 回调: 读回调 = 本机把一个点的值交出去,写回调 = 对方写进来一个点。所以服务端角色下 这两个字段数的是点次,不是报文数。一次读 10 个点的 ReadRequest 会让 tx_msgs 加 10。 (2026-08-22 之前这两个字段在服务端角色下恒为 0。)

服务端额外有:

字段说明
sessions当前会话数
monitored_items被订阅的点数(MonitoredItem 个数),不是 Subscription 个数。见下
ip本机 IP,调试时省得再查网卡

ip 这一项取不到本机 IP 时键本身不存在(不是空串),拼字符串前先判一下:local s = srv:stat(); print(s.ip or '还没拿到IP')。客户端角色下没有 ip / sessions / monitored_items 这三项,服务端角色下没有 reconnects / sub_active。

为什么叫 monitored_items 而不是 subscriptions

一个 Subscription 下面可以挂很多 MonitoredItem。open62541 的公开 API 里只有按点回调的 monitoredItemRegisterCallback,拿不到订阅个数(订阅个数在 ServerDiagnostics 节点里,那需要 UA_ENABLE_DIAGNOSTICS + NAMESPACE_ZERO >= REDUCED,本机固件编译时启用的是 MINIMAL,那些节点根本不存在)。报一个名字对不上的数比换个对的名字更坏,所以字段就叫 monitored_items。

判断"有没有人在订阅本机"用 monitored_items > 0 就够了。

客户端额外有:

字段说明
reconnects断线重连过几次
sub_active订阅是否真的建起来了。true 时这些点不再周期 Read,值靠对方推。判断依据:rx_msgs 在涨而 tx_msgs 几乎不动
lua
local s = cli:stat()
print(string.format('在线=%s 收=%d 发=%d 重连=%d 原始码=0x%08X',
      tostring(cli:is_online()), s.rx_msgs, s.tx_msgs, s.reconnects, s.last_err))

node:reg ​

lua
node:reg(event, callback)

注册事件回调。事件常量和签名见上面的事件一览。同一个事件重复注册会覆盖前一个。

参数

#参数类型必填说明
1eventinteger是opcua.WRITE / opcua.SESSION / opcua.ONLINE / opcua.OFFLINE / opcua.ERROR 之一
2callbackfunction是不是函数直接抛 Lua 错误

返回值

无(不返回任何东西,也没有反注册的接口)。同一个事件再 reg 一次就是覆盖,前一个函数从此不再被调用。

传上面五个之外的值(包括 opcua.CHANGE)不报错,但也永远不会被调用 —— 采集点的值变化只能写在 add_poll 的 on_change 里,一个点一个回调。

回调跑在 Lua 线程里(协议任务把消息投进队列,固件的消息泵取出来分发),所以里面可以放心调 plc.* / data.* / 其它协议模块的 API。回调里报错只会在日志打一行 ERR: 开头的内容,不会把协议栈带倒;但消息泵被堵住的时候它一次都不会触发,见本页开头那条 warning。

lua
srv:reg(opcua.WRITE, function(id, value)
    print('客户端写了', id, value)
end)
srv:reg(opcua.SESSION, function(up, name, user)
    print(name, (user == '' and '匿名' or user), up and '连上' or '断开')
end)
srv:reg(opcua.ERROR, function(code, addr)
    print('出错', addr, code, string.format('0x%08X', srv:stat().last_err))
end)

采集点的值变化不走 reg,写在 add_poll 的 on_change 里 —— 每个点一个回调,比一个全局回调再自己判断 addr 好用。


从零跑起来 ​

示例一:服务端 —— 让组态软件来读 PLC ​

服务端:寄存器变成一棵能浏览的树,组态软件按名字取值

lua
--=== OPC UA 服务端 =========================================
-- 作用:把 D100/D102/M10 做成 OPC UA 节点,组态软件连 4840 就能读写
-- 前提:网口已连、已拿到 IP
--==========================================================

if opcua == nil then print('本机固件没有 OPC UA 模块'); return end

srv = opcua.creat({
    mode = 'server',
    port = 4840,
    ifname = 'eth',              -- 改这里:走网口;WiFi 就写 'sta'
    app_name = '1号锅炉房',       -- 组态软件设备列表里显示的名字
    anonymous = true,            -- 上手先用匿名,正式投运请改 false
})

if srv == nil then print('创建失败:端口不能填 0'); return end

-- 目录显示名。不写也行,写了组态软件里看到的是中文
srv:add_folder('Boiler1', '1号锅炉')

-- 给组态软件看的:浮点占 D100+D101 两个寄存器
srv:add_node('Boiler1.Temp', opcua.FLOAT, 'D100', opcua.READ, '供水温度',
             { unit = 'degC', min = 0, max = 150 })
srv:add_node('Boiler1.Flow', opcua.FLOAT, 'D102', opcua.READ, '流量',
             { unit = 'm3/h' })

-- 让组态软件能控的:水泵启停
srv:add_node('Boiler1.Pump', opcua.BOOLEAN, 'M10', opcua.READ_WRITE, '水泵启停')

-- 对方写下来的时候会回调这里
srv:reg(opcua.WRITE, function(id, value)
    print('组态下发', id, value)
end)
srv:reg(opcua.SESSION, function(up, name, user)
    print(name, (user == '' and '匿名' or user), up and '连上' or '断开')
end)

while srv:start() ~= 1 do
    print('OPC UA 启动失败,等网络...')
    system.sleep(5000)
end
print('OPC UA 已启动 opc.tcp://' .. tostring(srv:stat().ip) .. ':4840')

-- 下面这段只是为了让你看到数据在动。真实项目里 D100 由梯形图或采集程序写。
-- 注意用定时器而不是 while + system.sleep:sleep 不泵消息队列,
-- 上面 reg 的回调会一次都不触发。
local t = 60.0
timer.start(2000, function()
    t = t + 0.5
    if t > 90.0 then t = 60.0 end
    plc.df(100, t)

    local s = srv:stat()
    print(string.format('会话=%d 收=%d 发=%d 温度=%.1f',
          s.sessions, s.rx_msgs, s.tx_msgs, t))
end)

跑起来之后应该看到:用 UaExpert 连 opc.tcp://<设备IP>:4840,Objects 下面出现 1号锅炉,展开有 供水温度(在 60~90 之间来回爬)、流量、水泵启停。把 水泵启停 写成 true,PLC 的 M10 会置位。节点的完整 NodeId 是 ns=2;s=Boiler1.Temp。

示例二:客户端 —— 去采一台别人的 OPC UA 服务器 ​

lua
--=== OPC UA 客户端 =========================================
-- 作用:每秒读对方的液位和水泵状态,落到 D400 / M406
--       另外把 D410 的设定值回写给对方
--==========================================================

if opcua == nil then print('本机固件没有 OPC UA 模块'); return end

cli = opcua.creat({
    mode = 'client',
    url = 'opc.tcp://192.168.1.50:4840',   -- 改这里
    timeout = 3000,
    retries = 2,
    -- user = 'op', password = '8888',      -- 对方要认证就填这两项
})

if cli == nil then print('创建失败:url 不能留空'); return end

-- 只读点:对方的液位 → 本机的 D400
-- 连续失败会把 M520 置位,恢复后自动复位
cli:add_poll('ns=2;s=Tank.Level', opcua.FLOAT, 'D400', {
    interval = 1000,
    deadband = 0.1,            -- 变化小于 0.1 不折腾
    fail_flag = 'M520',
    on_change = function(addr, value)
        print('液位变了', value)
    end,
})

-- 开关量:对方的水泵状态 → 本机的 M406
cli:add_poll('ns=2;s=Pump.Run', opcua.BOOLEAN, 'M406', { interval = 1000 })

-- 可回写点:本机改了 D410,下一轮就写给对方
cli:add_poll('ns=2;s=Tank.SetPoint', opcua.FLOAT, 'D410', {
    interval = 1000, access = opcua.READ_WRITE })

cli:reg(opcua.ONLINE,  function() print('连上了') end)
cli:reg(opcua.OFFLINE, function() print('断了') end)
cli:reg(opcua.ERROR, function(code, addr)
    print('读写失败', addr, code, string.format('0x%08X', cli:stat().last_err))
end)

if cli:start() ~= 1 then print('启动失败'); return end
print('OPC UA 客户端已启动,目标 192.168.1.50')

timer.start(5000, function()
    local s = cli:stat()
    print(string.format('在线=%s 液位=%.1f 水泵=%s 重连=%d',
          tostring(cli:is_online()), plc.df(400),
          tostring(plc.m(406)), s.reconnects))
end)

跑起来之后应该看到:D400 出现对方的液位并每秒刷新。把对方停掉,几秒后 M520 置位、is_online() 变 false、寄存器停在最后一个值(不会清零 —— 清零和"真的是 0"分不开,所以宁可冻住,用 fail_flag 判断数据新不新)。对方恢复后 reconnects 加 1,轮询自动继续。

不知道对方有哪些点?

先扫一遍。browse 是同步的,只在调试时用:

lua
cli = opcua.creat({ mode = 'client', url = 'opc.tcp://192.168.1.50:4840' })
cli:start()
system.sleep(2000)              -- 等它连上

local r = cli:browse()          -- 不给 id 就从 Objects 开始
for _, o in ipairs(r.data) do
    print(o.class, o.name, o.id)
    if o.class == 'Object' then
        local sub = cli:browse(o.id)
        for _, v in ipairs(sub.data) do
            print('    ' .. v.class .. ' ' .. v.name .. '  ' .. v.id)
        end
    end
end

id 那一列可以直接抄进 add_poll。

示例三:同时做服务端和客户端 ​

两个独立对象,互不影响。常见场景是"从别人的 OPC UA 服务器采数据,再作为服务端统一供给 BMS":

lua
if opcua == nil then return end

-- 客户端:去采现场那台
cli = opcua.creat({ mode = 'client', url = 'opc.tcp://192.168.1.50:4840' })
cli:add_poll('ns=2;s=Tank.Level', opcua.FLOAT, 'D400', { interval = 1000 })
cli:start()

-- 服务端:把采回来的值再供出去
srv = opcua.creat({ mode = 'server', port = 4840, ifname = 'eth' })
srv:add_node('Plant.TankLevel', opcua.FLOAT, 'D400', opcua.READ, '液位')
srv:start()

别让两个对象抢同一个端口

客户端连的是对方的 4840,服务端监听的是本机的 4840,两者不冲突。但如果本机同时开了别的 TCP 服务(tcp_server、modbus_tcp_server)用了 4840,谁先 bind 谁赢,另一个会静默起不来 —— start() 返回 0。

用上位机配,不写脚本 ​

DTU 功能页的 OPC UA 子标签可以把上面这些配置点出来,不用写 Lua。写下去之后固件自己按配置建节点,效果和脚本完全一样。完整界面说明见 DTU 配置式编程 · OPC UA。

上位机 → DTU 功能 → OPC UA:上面是服务端参数,下面「服务端节点」一行一个节点,Boiler1 目录下两个 Float 点

对应关系:

  • 「服务端参数」= opcua.creat({mode='server', ...}) 的各字段
  • 「浏览目录」= add_folder
  • 「服务端节点」= add_node,一行一个
  • 「客户端」每一项 = 一个 opcua.creat({mode='client', ...}) 实例
  • 客户端下面的「采集点」= add_poll

点表支持从 Excel 直接复制粘贴导入(服务端上限 256 个节点,手工点不现实)。

配置页和脚本不要同时配

配置页生成的是 config.lua,固件启动时先解释它、再跑 main.lua。两边都配 OPC UA 的话会建出两个服务端对象抢同一个端口。要写脚本就把配置页的开关关掉。

注意事项 ​

命名空间下标是 2,不是 1

  • 本机注册的 ns_uri 运行时下标是 ns=2 —— open62541 的 UA_ServerConfig_setMinimal 会先把 app_uri 注册成 ns=1。
  • 客户端(包括第三方组态软件)应该先读 Server_NamespaceArray(i=2255)按 URI 找下标,不要硬编码。本机自身的客户端方向也一样:对方的 ns 是对方定的。

服务端的点值有一个扫描周期的延迟

  • 协议栈任务只碰"影子副本",真正搬到 PLC 寄存器是在 PLC 扫描周期里批量做的。这是为了避免对 32 位/浮点寄存器做两次独立的 16 位访问而撕裂(寄存器全程无锁)。
  • 所以组态软件写下来的值,到 M10 真的变化最多晚一个扫描周期。反过来读也一样。
  • opcua.STRING 类型绕过影子机制直接读写 —— 它只能绑 data.creat() 的变量,不是 PLC 寄存器,不存在撕裂问题。

写方向的类型必须和对方节点一致

  • 读方向本机有意放宽:对方是 Int32 而点表写 opcua.FLOAT 也照收,值一律折成 double。
  • 写方向不放宽。 OPC UA 的 Write 是严格类型匹配的:对方节点是 Double、点表写 opcua.FLOAT,服务端会直接顶回 BadTypeMismatch(stat().last_err 里是 0x80740000)。
  • 症状是"读得到、写不进去"。用 browse 看一眼对方节点的类型,或直接问对方要点表,把 mode 改成一致的。不做静默转换是故意的 —— 悄悄换类型比报错更难查。

性能与资源

  • 协议任务优先级 5、钉在 core 0,和 Lua、云服务任务同级。任务栈在 PSRAM 里(10KB),因为内部 RAM 常年只剩几 KB。
  • 服务端节点上限 256、目录 32、会话 4;客户端采集点上限 128(所有实例加起来)。超了 add_* 返回 0,多出来的点被丢弃。
  • 客户端的 browse 结果缓存是全局的一份,browse 一次覆盖一次 —— 别指望上次的结果还在。
  • read / write / browse 都是同步阻塞调用,周期采集必须用 add_poll。

和其它模块的关系

  • 绑的寄存器和梯形图是同一份数据。梯形图也在写同一个 D100 的话,后写的赢 —— 谁写哪个寄存器要在项目里分清楚。
  • 和其它协议模块可以同时跑,互相不抢资源。但协议栈都吃 RAM,点表都配满时留意日志里有没有"任务创建失败"。

还没实现的部分

  • 服务端的 Subscription 个数(只能给被订阅的点数 monitored_items)
  • session_list() 里的对方 IP(给的是 sessionName)
  • Basic256Sha256、Sign / SignAndEncrypt、证书管理
  • Method 调用、历史数据、Alarm&Conditions、PubSub、Reverse Connect

例程 ​

每个例程都是可直接下载运行的完整脚本(仅 LP3232 / LP3288 / 软PLC模拟器支持)。creat 不会连接/监听,要 start() 才真起来。统一模板「场景 / 完整代码 / 要点 / 验证方法 / 注意事项」。

例程 1:启动一个 OPC UA 服务端(暴露 PLC 寄存器) ​

适用:LP3232 / LP3288 / 软PLC模拟器 | 依赖模块:opcua plc | 难度:★☆☆

场景:让组态软件通过 OPC UA 读写本机 D/M,且不让陌生人随便读写。

完整代码(可直接下载运行):

lua
-- 先把匿名关掉、设账号,避免把 PLC 寄存器公开到网段
srv = opcua.creat({ mode='server', port=4840, ifname='eth',
                    app_name='1号锅炉房', anonymous=false,
                    user='op', password='8888' })
-- 用 srv:add_node(...) 把 D/M 寄存器绑成节点(参数见本文「数据类型与寄存器绑定」)
srv:start()

要点:mode 拼错或不是 'server' 一律当服务端;port=0 会创建失败返回 nil;匿名 + security='none' 等于把 PLC 寄存器公开。

验证方法:用 UA 客户端连 opc.tcp://设备IP:4840,看到服务器 1号锅炉房。

注意事项:默认 anonymous=true 且 security=none,任何能连到这个网段的人都能读写 PLC 的 D/M;要收紧就写 anonymous=false 并给 user/password。


例程 2:客户端连对方的 OPC UA 服务器 ​

适用:同上 | 依赖模块:opcua | 难度:★☆☆

场景:本机当 OPC UA 客户端,去读另一台设备的节点。

完整代码(可直接下载运行):

lua
cli = opcua.creat({ mode='client', url='opc.tcp://192.168.1.50:4840',
                     timeout=3000, retries=2 })
-- 用 cli:add_poll(...) 订阅/轮询对方节点(参数见本文)
cli:start()

要点:客户端 url 必填,留空创建失败返回 nil;sub 默认 true 优先用 Subscription(对方推变化),建不起来自动退回周期 Read。

验证方法:连上后读到对方节点数据。

注意事项:mode 写错当成服务端会失败(url 字段被忽略);仅 ESP32-S3/模拟器支持。


例程 3:注意事项——平台判断与默认匿名公开 ​

适用:同上 | 依赖模块:opcua | 难度:★☆☆

完整代码(可直接下载运行):

lua
if type(opcua) == 'table' then
    srv = opcua.creat({ mode='server', port=4840, ifname='eth',
                        anonymous=false, user='op', password='8888' })
    srv:start()
end

要点:上架前 type(opcua) == 'table' 确认平台支持;匿名 + 不加密 = 把 PLC 寄存器公开到网段。

验证方法:支持平台能 start;不支持平台跳过。

注意事项:普通 ESP32 无此模块;上线前务必关匿名、设账号,否则 4G/公网侧任何人可读写 D/M。

LPPLC 工业物联网云平台 · 软硬件与云平台全栈自研