自动化

脚本自动化(Lua)

序列表达不了的流程交给脚本:变量、条件、循环、计算和二进制解析。

本页内容

脚本自动化(Lua)

脚本是一小段 Lua 5.4 程序,在一个连接上发送、等待、判断和循环。另外两种自动化工具做不到的地方,由它接手:

  • 序列不用编程就能发送、等待并检查回复,但它记不住变量,不能按回复的内容走不同的分支,也不会计算;
  • 自动应答规则能回复请求,但回复的永远是那几个固定的字节。

流程里需要变量、条件、循环、计算,或者要把二进制回复拆开来看时,就该用脚本:反复发一条命令直到模块注册上网络,读出一个计数并和上一次比较,按一张数值表拼出报文,或者扮演一台「回复取决于请求」的设备。

脚本就是普通的顺序代码——先 sendLine("AT"),再 expect("OK"),再 sleep(500)——等待期间软件照常响应。即使写出死循环,也能随时停止。

工具 → 脚本… 打开脚本窗口。它是独立窗口,脚本运行时可以和主窗口并排放着。

脚本窗口。顶部一行是「运行于:1: TCP/UDP」和运行、停止、检查三个按钮,状态显示“AT 指令回归测试”通过,用时 0.32 秒。左侧脚本库里是「AT 指令回归测试」示例,下面是新建、复制、重命名、删除、示例、导入、导出按钮。编辑器带行号和语法高亮,显示脚本的前 27 行:edit here 参数区里的 TIMEOUT_MS、ROUNDS 和列出 AT、AT+GMR、AT+CSQ 的 CHECKS 表,以及打印汇总的 onStop 函数。下方输出区依次是开始行、signal quality 23、summary: 9 passed, 0 failed (passed) 和结论「通过,用时 0.32 秒」。最下面一行是「SPU Free:本次启动还可运行脚本 2/3 次(所有端口合计),单次最长 5 分钟」
「AT 指令回归测试」示例运行之后:左侧是脚本库,右侧是编辑器,下方输出区里是脚本打印的汇总和结论。Free 下最底下一行显示还剩几次运行。

脚本窗口

  • 运行于选择脚本收发数据所用的连接。在你手动选过之前,它跟随主窗口里获得焦点的那个连接。
  • 运行(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 没有这些限制。

这篇文档是否有帮助?