脚本自动化(Lua)
脚本是一小段 Lua 5.4 程序,在一个连接上发送、等待、判断和循环。另外两种自动化工具做不到的地方,由它接手:
流程里需要变量、条件、循环、计算,或者要把二进制回复拆开来看时,就该用脚本:反复发一条命令直到模块注册上网络,读出一个计数并和上一次比较,按一张数值表拼出报文,或者扮演一台「回复取决于请求」的设备。
脚本就是普通的顺序代码——先 sendLine("AT"),再 expect("OK"),再 sleep(500)——等待期间软件照常响应。即使写出死循环,也能随时停止。
工具 → 脚本… 打开脚本窗口。它是独立窗口,脚本运行时可以和主窗口并排放着。
脚本窗口
- 运行于选择脚本收发数据所用的连接。在你手动选过之前,它跟随主窗口里获得焦点的那个连接。
- 运行(
Ctrl+R)在该连接上启动选中的脚本;停止结束它;检查只查语法错误,不运行,也不发送任何字节。 - 左侧列表是脚本库:新建、复制、重命名...(或双击脚本)、删除、示例、导入...、导出...。新建的脚本是一段很短的 AT 检查,可以直接运行。
- 编辑器带行号,对 Lua 和 SPU 的函数做语法高亮,并标出上一次检查或运行出错的那一行。Tab 键输入四个空格。修改随输入进入脚本库,没有「保存」按钮。
- 输出区显示脚本打印的内容、错误,以及每次运行的结论。右键可以全部复制、另存为...文本文件或清空。它保留最近 5,000 行。
- 按钮旁边的一行说明脚本此刻在做什么,例如
正在运行“模块检查” 00:12 · expect "OK" (1000 毫秒)。
示例把三个可以直接运行的脚本之一加入脚本库;前两个脚本的开头有一段「edit here」参数区:
| 示例 | 演示了什么 |
|---|---|
| AT 指令回归测试 | 把一组 AT 指令发若干轮,逐条检查回复并打印汇总——中途停止也照样打印 |
| Modbus RTU 从站 | 按寄存器表应答 FC03 读与 FC06 写,校验并追加 CRC |
| 收到 PING 回 PONG | 最简单的 onReceive:一台会应答的设备 |
第一个脚本
-- 查询模块的信号质量,最多五次。
for attempt = 1, 5 do
sendLine("AT+CSQ")
local match, rssi = tryExpectRegex([[\+CSQ:\s*(\d+),]], 1000)
if rssi and tonumber(rssi) ~= 99 then
print("signal quality " .. rssi .. " after " .. attempt .. " attempt(s)")
return
end
sleep(2000)
end
fail("the module never reported a signal")
一次运行有三种结局,输出区和连接的接收区都会写明是哪一种:
- 通过——脚本运行到末尾(或执行了 return);
- 失败——某个
expect超时、调用了fail()、发送被拒绝,或者代码出错;会给出行号; - 已停止——你点了「停止」、连接被关闭或断开、文件传输接管了连接,或者到了 Free 的时长上限。
发送
在 Lua 里,字符串就是一串字节,发送函数发到线路上的正是这些字节。"\r\n"、"\x01\x03" 和 string.pack 的结果都按原样发出。
| 函数 | 发送什么 |
|---|---|
send(bytes) |
原样发送这些字节 |
sendLine(text [, eol]) |
text 加上 eol;不写 eol 时是 "\r\n"。发送框的行尾、校验和编码设置对它不起作用 |
sendHex("01 03 00 00 00 0A") |
十六进制字节,每字节两位;写错一个字符会报错,而不是悄悄发出 00 |
sendText(text) |
等同于把这段文字打进发送框再点发送:连接的发送编码、行尾和追加校验全部生效 |
sendCommand(name) |
命令库里的命令,名字写作 "组/命令";全库只有一条命令叫这个名字时,只写 "命令" 也行。按命令自己的行尾和校验发送 |
连接没有接受这次发送时,运行在这一行以失败结束。开启「显示发送」时,脚本发出的数据和你自己发送的一样显示在接收区里。
脚本本身的文字是 UTF-8。连接使用别的编码时,ASCII 以外的文字要先经过 encode(),或者用 sendText:send(encode("温度") .. "\r\n")。
等待回复
expect 看哪些数据,由两条规则决定,与序列的「期望回复」步骤相同:
- 只看脚本上一次发送之后收到的数据。 每次发送都会清掉此前收到的内容:回复是对上一条命令的回答,前面某条命令留下的
OK不该让后面的检查通过。 - 匹配会用掉到它结束为止的全部字节。 连续两个
expect("OK")需要设备回两次OK。
分几次到达的回复同样能匹配——数据够了就匹配。所以正则表达式要以「你要的那一段之后的内容」收尾,比如数字后面的逗号或行尾:以 \d+ 结尾的模式,第一位数字到达时就已经满足了。脚本保留最近的 64 KB 数据。
| 函数 | 等待什么 | 返回 |
|---|---|---|
expect(text [, ms]) |
一模一样的字节 | match, text |
expectHex("01 03 ?? ??" [, ms]) |
十六进制字节;?? 表示任意字节 |
match, text |
expectRegex(pattern [, ms]) |
Perl 兼容的正则表达式,按字节匹配 | match, capture1, …, text |
expectAny({"OK", "ERROR"} [, ms]) |
列表里最先结束的那一个 | match, index, text |
text 永远是最后一个返回值:从收到的数据开头到匹配结束的全部内容——整段回复,而不只是你要找的那一小段。index 是它在列表里的位置,从 1 开始。
超时以毫秒为单位,必须是整数,不写时为 1000;0 表示不限时,一直等到数据到达或运行被停止。
到时间仍没有匹配,expect 就让运行以失败结束,消息形如 等待 "OK" 超时(1000 毫秒),并给出行号。测试要的正是这个:没有回复,就不算通过。想自己决定怎么办,就用 tryExpect、tryExpectHex、tryExpectRegex 和 tryExpectAny:超时时它们返回 nil,脚本继续往下跑。
正则表达式请写成长字符串 [[\+CSQ:\s*(\d+),]],这样 Lua 不会去动里面的反斜杠。模式本身也是字节:其中的 "\xff" 就是字节 0xFF。
只读取、不判断:
| 函数 | 返回 |
|---|---|
read(n [, ms]) |
恰好 n 个字节(1 到 65536);超时返回 nil |
readLine([ms]) |
下一行,不含行尾;超时返回 nil,已收到的半行留给下一次读取 |
readAll() |
已经收到的全部内容,立即返回、不等待——可能是空字符串 |
clearRx() |
无返回值;丢弃已经收到的内容 |
想在发送时不清掉已收到的内容——比如还没读第一条命令的回复就要发第二条——给任何一个发送函数的最后一个参数传 {keepRx = true}:send("\x06", {keepRx = true})。
二进制报文与校验
Lua 自带的 string.pack 和 string.unpack 用来拼装和拆解二进制报文;> 表示大端,< 表示小端,B 是一个字节,I2、I4 是相应字节数的无符号整数。
-- 从 Modbus 从站 1 的地址 0 起读 2 个保持寄存器。
local request = string.pack(">BBI2I2", 1, 3, 0, 2)
send(appendChecksum(request, "crc16modbus"))
local head = expectHex("01 03 04", 500) -- 从站、功能码、字节数
local body = read(6, 500) -- 4 个数据字节和 CRC
if not body or not verifyChecksum(head .. body, "crc16modbus") then
fail("no valid reply")
end
local a, b = string.unpack(">I2I2", body)
print(("registers: %d %d"):format(a, b))
| 函数 | 作用 |
|---|---|
checksum(name, bytes) |
校验值,整数 |
appendChecksum(bytes, name [, order]) |
追加了校验的字节 |
verifyChecksum(frame, name [, order]) |
报文末尾的字节正是其余部分的校验时返回 true |
toHex(bytes) |
"01 03 00 0A"——用来打印报文 |
fromHex("01 03 00 0A") |
对应的字节 |
regex(pattern, text) |
match, capture1, … 或 nil:对手头已有的字符串使用同样的正则表达式 |
encode(text)/decode(bytes) |
在脚本的 UTF-8 文字与连接的发送/接收编码之间转换 |
order 是 "le"(低字节在前,默认值,与 Modbus 和发送框一致)或 "be"(高字节在前)。校验占多少字节由它的位宽决定:CRC-16 两个字节,CRC-32 四个字节。
校验名称不区分大小写,-、/、_ 和空格可有可无,"CRC-16/CCITT-FALSE" 与 "crc16ccittfalse" 是同一个:
| 位宽 | 名称 |
|---|---|
| 累加与异或 | sum8 xor8 |
| 8 位及以下 | crc4itu crc5epc crc5itu crc5usb crc6cdma2000a crc6cdma2000b crc6itu crc7 crc8 crc8ebu crc8maxim crc8wcdma |
| 10 到 15 位 | crc10 crc10cdma2000 crc11 crc12cdma2000 crc12dect crc12umts crc13bbc crc15 crc15mpt1327 |
| 16 位 | crc16arc crc16buypass crc16ccittfalse crc16cdma2000 crc16cms crc16dectr crc16dectx crc16dnp crc16genibus crc16kermit crc16maxim crc16modbus crc16t10dif crc16usb crc16x25 crc16xmodem |
| 17 到 30 位 | crc17can crc21can crc24 crc24flexraya crc24flexrayb crc30 |
| 32 位 | crc32 crc32bzip2 crc32c crc32mpeg2 crc32posix crc32q |
| 40 与 64 位 | crc40gsm crc64 |
最高位为 1 的 64 位校验值会打印成负数,用 ("%016X"):format(value) 格式化即可。
扮演设备
onReceive(fn) 把收到的每一段数据都交给你的函数,脚本于是可以像设备一样应答——而且回复可以根据请求算出来,这是自动应答规则做不到的。
local pending = ""
onReceive(function(bytes)
pending = pending .. bytes -- 一条消息可能分几段到达
while true do
local first, last, id = pending:find("GET (%d+)\r\n")
if not first then
break
end
send(("VALUE %s %d\r\n"):format(id, tonumber(id) * 10))
pending = pending:sub(last + 1) -- 留下请求之后的内容
end
end)
idle() -- 一直应答,直到被停止
- 函数收到
bytes,第二个参数是数据来自哪个对端:TCP 服务端的客户端或 UDP 的发送方;串口和 TCP 客户端下是空字符串。 - 函数里可以发送、打印和计算,但不能等待:在里面调用
expect、read、sleep、idle会报错。它发送数据时,不会清掉脚本主体正在等待的内容。 idle()一直等到运行被停止,只靠应答工作的脚本靠它保持运行。onReceive(nil)取消这个函数。- 数据按连接交付的分段到达:一条请求可能分成两段,一段里也可能有两条请求。要像上面那样先攒起来,取走每一条完整的消息,余下的留着。
- 如果连接同时启用了自动应答规则,输出区会警告一次:对方的一次请求可能收到两个回复。
onStop(fn) 在运行结束时调用一次你的函数,不管是怎么结束的。它收到两个参数:结局——"passed"、"failed" 或 "stopped"——和原因;即使被「停止」也必须打印的汇总,就写在这里。里面可以 print 和 log,不能发送也不能等待:硬要这么做,只会让这个函数到此为止,并在输出区留一条警告,运行的结论不变。
其他函数与可用的 Lua
| 函数 | 作用 |
|---|---|
sleep(ms) |
等待;整数毫秒,最长一天 |
print(...) |
在输出区写一行 |
log(...) |
写到输出区,同时在连接的接收区按时间顺序插入一行 |
fail(message) |
以失败结束运行,原因是你给的消息;外面包一层 pcall 也拦不住 |
now() |
本次运行开始以来的毫秒数 |
timestamp() |
当前时间,格式 HH:mm:ss.zzz |
port.name、port.mode |
连接的标签和类型:"serial"、"tcp-client"、"tcp-server"、"udp-client"、"udp-server"、"udp-group"、"rfc2217-client" 或 "live-relay" |
接收区每秒最多接收 20 行 log;写得更快时,其余的只出现在输出区。接收区暂停时不添加这些行,输出区里仍然齐全。
语言是标准的 Lua 5.4,带 string、table、math、utf8 四个库,以及 pcall、error、assert、pairs、ipairs、select、tonumber、tostring、type、setmetatable 这些基本函数。有两点与别处的 Lua 不同:
- 沙盒。 没有
io、os、coroutine、debug这几个库,也没有require、load、dofile、loadfile:脚本不能读写文件、访问网络、启动程序,也不能加载别的代码。它唯一的出口是运行它的那个连接。别人发给你的脚本动不了你的电脑——但运行之前仍请读一遍,因为它确实会向你的设备发送数据。 - 严格的全局变量。 读取一个从未赋过值的变量会报错
variable 'x' is not defined,而不是悄悄得到nil:拼错的名字、或者 Python 习惯的True,会让运行立即停下,而不是不声不响地走错分支。变量请用local声明,或者先赋值再读取。
脚本可用 32 MB 内存。一直循环、从不等待的脚本也不会让窗口失去响应,照样可以停止。
运行时
- 一个连接同一时刻只运行一个脚本;不同的连接可以各跑各的,这时输出区的每一行前面会标出所属的连接。同一个连接上,脚本与序列不能同时运行。
- 运行会先打开未打开的连接,与「发送」一样。TCP 客户端是在后台连接的,所以第一次点运行只会发起连接,并提示连上之后再运行一次。有语法错误的脚本不会打开连接,也不发送任何数据。
- 关闭连接、断链或开始文件传输会停止脚本,并记下原因。它不会背着你把连接重新打开。
- 连接的接收区记录开始、每一行
log()和结论,例如脚本“模块检查”失败于第 12 行,用时 3.42 秒:等待 "OK" 超时(1000 毫秒)。 - 脚本运行期间关闭脚本窗口,会问你是停止并关闭还是保持运行。保持运行时脚本继续跑,再次打开 工具 → 脚本… 就能看到它并停止。
运行失败时,输出区用红色显示出错的行——点击它跳到编辑器里的那一行——如果错误出在你自己写的函数里,还会列出它是从哪几行调用的。最常见的错误会在下面附一行中文提示:中文输入法打出的全角逗号或引号、把 ~= 写成 !=、把 elseif 写成 elif、+=、拿 # 当注释、少了 end、函数名拼错(「你是不是想写 sendLine?」),或者 tryExpect 超时返回的 nil。
保存与分享
- 随工程保存:保存的
.spu工程包含脚本库,打开工程即恢复。没有打开工程时,脚本库保存在本机。 - 脚本文件(
.lua):导出... 把选中的脚本存为 UTF-8 编码的.lua文件;导入... 把一个.lua文件加入脚本库,以文件名命名。导入的脚本只会显示出来,不会自己运行:先读一遍,再点运行。 - 脚本库最多容纳 64 个脚本,每个不超过 256 KB。
- 诊断包不包含脚本:脚本是代码,里面常常写着设备的密码。
Free 与 SPU Pro
编写、编辑和检查脚本、示例、导入导出,以及随工程保存,在 Free 里完整可用。运行有额度:
- 每次启动 SPU 可运行 3 次脚本,所有连接合计;脚本窗口里显示还剩几次。
- 单次运行最长 5 分钟。 到时间前 30 秒输出区会提醒,之后运行以「已停止」结束。
- 30 秒内失败或被停止的运行不计次,每次启动最多 3 次——第 3 行的一个笔误不该用掉三次里的一次。有语法错误的脚本、或者连接打不开的运行,从不计次。
SPU Pro 没有这些限制。