主题
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_listmode='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 是一张表。共用字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
mode | string | 'server' | 'client' 才是客户端,其它任何值(包括拼错)都当服务端 |
security | string | 'none' | 'sign' / 'signandencrypt',别的字符串一律当 none。当前固件只有 none 能用 |
user | string | 空 | 服务端=允许登录的用户名;客户端=登录对方用的用户名 |
password | string | 空 | 同上 |
服务端专用字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
port | number | 4840 | 监听端口。填 0 会创建失败并返回 nil |
ifname | string | 空 | 绑哪张网卡:'eth' / 'sta' / 'ap',空=自动(ETH→STA→AP 挑第一个有 IP 的) |
app_name | string | 'LPPLC' | ApplicationName,组态软件里显示的服务器名 |
app_uri | string | 'urn:linpan:lpplc' | ApplicationUri。开加密后必须和证书的 SAN 一致 |
ns_uri | string | 'urn:linpan:lpplc:ns1' | 本机自身那个命名空间的 URI,运行时下标是 ns=2 |
anonymous | boolean | true | 是否允许匿名登录。见下面的安全提醒 |
max_sessions | number | 2 | 最多几个并发会话,上限 4 |
pub_interval_min | number | 200 | 允许客户端协商到的最小发布周期(ms)。采样周期也按同一个下限卡,否则对方可以要 500ms 发布 + 5ms 采样,等于让固件每秒扫两百遍数据区 |
客户端专用字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
url | string | — | 必填,opc.tcp://主机[:端口]。留空会创建失败并返回 nil |
timeout | number | 3000 | 单次请求超时(ms) |
retries | number | 3 | 超时后重试几次 |
sub | boolean | true | 优先用 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.S16 | Int16 | 1 |
opcua.U16 | UInt16 | 1 |
opcua.S32 | Int32 | 2 |
opcua.U32 | UInt32 | 2 |
opcua.FLOAT | Float | 2 |
opcua.DOUBLE | Double | 4 |
opcua.STRING | String | 只能绑 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_NONE | None:报文不签名也不加密,只有用户名密码这一层 | 不用 |
opcua.SEC_SIGN | Sign:报文带签名,防篡改防冒充 | 要 —— 双方都得有证书 |
opcua.SEC_SIGNENC | SignAndEncrypt:签名 + 整个报文加密 | 要 —— 同上,还要对方信任本机的证书 |
这三个常量目前只是给「这台设备只支持 None」这件事一个显式写法,没有功能入口,两件事要分清楚:
creat的security字段只认字符串:'none'/'sign'/'signandencrypt'。把opcua.SEC_SIGN这种数字常量填进去不会被识别,效果和没写一样。- 就算填对了字符串
'sign',当前固件跑起来仍然是None:security这项在creat之后没有任何地方消费它,链路层建服务端 / 建客户端时都不带这个参数;open62541 那边也只编进了None一种策略,固件里没有证书加载入口(没有文件路径参数、也没有证书相关的 API)。
所以对客户的一句话是:能谈的只有 None。要接一台强制加密的 OPC UA 服务器,得让对方放开到 None,或者在中间放一台做协议转换的网关 —— 本机的客户端不会因为配了 'sign' 就连得上对方。反过来说,本机的服务端对任何客户端都是不加密的,这一点在公网 / 4G 场景下要按匿名那条 warning 一起评估。
错误码
| 值 | 名称 | 含义 |
|---|---|---|
0 | OK | 成功 |
8701 | TIMEOUT | 请求超时 |
8702 | CONNECT | 连不上 / 连接已断 |
8703 | SESSION | Session 失效 |
8704 | SECURITY | 证书或安全策略不匹配 |
8705 | AUTH | 用户名密码不对 |
8706 | NODEID | 地址文本非法(本机侧就没通过校验,没发出去) |
8707 | UNKNOWN_ID | 对方说没有这个节点 |
8708 | DATA_TYPE | 类型对不上 |
8709 | WRITE_DENIED | 对方不让写 |
8710 | OUT_OF_RANGE | 超量程 |
8711 | TOO_MANY | 点数或会话数超上限 |
8712 | PORT | 端口被占 / 网卡还没 IP |
8713 | ENCODING | 对方发了本机不支持的 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 变量节点,客户端就能读写它。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | id | string | 是 | — | 标识符本身,不带 ns=2;s= 前缀。见下面的命名规则 |
| 2 | mode | integer | 是 | — | opcua.FLOAT 等 |
| 3 | var | string/number/data | 是 | — | 绑哪个寄存器 |
| 4 | access | integer | 否 | opcua.READ | opcua.READ / opcua.READ_WRITE / opcua.WRITE_ONLY |
| 5 | name | string | 否 | 用 id 的最后一段 | DisplayName,可中文 |
| 6 | opt | table | 否 | — | { unit='degC', desc='...', min=0, max=100 } |
opt 的字段:
| 字段 | 说明 |
|---|---|
unit | 工程单位文本,如 'degC' / 'm3/h' |
desc | Description,可中文。不给就不上报这个属性 |
min / max | 量程。两个都给且 max>min 才算配了,只给一边按"没配"处理 |
标识符命名规则
固件会把 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() 之后也能调。
参数
| # | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| 1 | id | string | 是 | 当初 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 个。
参数
| # | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| 1 | id | string | 是 | 目录的标识符,规则与 add_node 的 id 完全一样(每段以字母或下划线开头、. 分层、最多 3 段、总长小于 64 字节)。'Boiler1'、'Plant1.Boiler1' 都行 |
| 2 | name | string | 否 | 目录显示名,可中文。不给就用 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))
endnode:add_poll
lua
node:add_poll(addr, mode, var, [opt]) --> 1 | 0客户端专用。加一个周期采集点:固件后台按周期去读对方,值自动落进本机寄存器。生产脚本用这个,不要用 read 轮询 —— read 是同步的,会阻塞调用它的 Lua 任务。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | addr | string | 是 | — | 对方的完整 NodeId,见下 |
| 2 | mode | integer | 是 | — | 数据类型 |
| 3 | var | string/number/data | 是 | — | 值落到哪个寄存器 |
| 4 | opt | table | 否 | — | 见下表 |
opt 的字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
interval | number | 1000 | 采集周期(ms) |
access | integer | opcua.READ | 配 opcua.READ_WRITE 时,改了本机寄存器下一轮会写给对方 |
deadband | number | 0 | 绝对死区。变化量小于它不回调、不落寄存器 |
fail_flag | string | 无 | 连续失败时置位的线圈,如 'M520'。恢复后自动复位 |
on_change | function | 无 | 值变化回调,签名 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 时填的完全一致。
参数
| # | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| 1 | addr | string | 是 | 当初 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。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | addr | string / string 数组 | 是 | — | 对方的完整 NodeId,写法同 add_poll。传数组就是批量读 |
| 2 | mode | integer | 否 | opcua.FLOAT | 这个参数读方向不看:对方回来什么数值类型都一律折成 double(Boolean 折成 0/1,Int64/UInt64 也折成 double),填与不填一样。写方向才见分晓,见 write |
返回值
{ errno = ..., data = { ... } }。data 的下标和输入顺序一一对应,errno 记的是第一个错误码。
| 现象 | errno | data 里对应位置 |
|---|---|---|
| 全部读到 | 0 | 各点的值 |
地址文本不合法(ns 超过 65535、s= 后面超过 63 字节、ns= 后面漏了分号) | 8706 | 0 |
| 数组里混进了非字符串元素 | 8706 | 0 |
| 对方说没有这个节点 | 8707 | 0 |
| 对方这个点的值折不成数值(字符串型、数组型 Variant) | 8713 | 0 |
Session 还没激活 / 连接已断 / 在服务端对象上调 read | 8702 | 0 |
一批里有某个失败时,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 是成功。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | addr | string | 是 | — | 对方的完整 NodeId,写法同 add_poll |
| 2 | value | number / boolean | 是 | — | 布尔按 1/0 发。其它类型(字符串、表)直接抛 Lua 错误 |
| 3 | mode | integer | 否 | 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 | 对方不让写(只读点) |
8702 | Session 还没激活 / 连接已断,或在服务端对象上调 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。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | id | string | 否 | 从 Objects 根开始 | 要展开的那个节点的完整 NodeId(写法同 add_poll)。传空串等同于不传。写错了返回 8706,不会去问对方 |
| 2 | max | number | 否 | 64 | 最多列几条。小于 1 按 1,超过 256 按 256(别让脚本一口气打几千帧) |
返回值
{ errno = ..., data = { ... } },data 里每一项:
| 字段 | 说明 |
|---|---|
id | 子节点的完整 NodeId,可以直接拿去 add_poll。例外:对方那个节点的标识符是 GUID 或 ByteString 型时,这一列是空串 —— 看到空串就知道这条粘不回点表 |
name | BrowseName |
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 没问题:还没起来,也就没有回调要收
endnode: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_err | open62541 的 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)注册事件回调。事件常量和签名见上面的事件一览。同一个事件重复注册会覆盖前一个。
参数
| # | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| 1 | event | integer | 是 | opcua.WRITE / opcua.SESSION / opcua.ONLINE / opcua.OFFLINE / opcua.ERROR 之一 |
| 2 | callback | function | 是 | 不是函数直接抛 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
endid 那一列可以直接抄进 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。

对应关系:
- 「服务端参数」=
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。