Automation

Script automation (Lua)

For the flows a sequence cannot express: variables, conditions, loops, arithmetic and binary parsing.

On this page

Script automation (Lua)

A script is a short Lua 5.4 program that sends, waits, checks and loops on one connection. It picks up where the other two automation tools stop:

  • a sequence sends, waits and checks replies without any programming, but it cannot keep a variable, branch on what came back or do arithmetic;
  • an auto reply rule answers a request, but always with the same fixed bytes.

Reach for a script when the flow needs a variable, a condition, a loop, a calculation, or a binary reply taken apart: retry a command until the module registers, read a counter and compare it with the last one, build a frame from a table of values, or play a device whose answer depends on the request.

A script is written as plain sequential code — sendLine("AT"), then expect("OK"), then sleep(500) — and the application stays responsive while it waits. Even an endless loop can be stopped.

Tools → Scripts… opens the script window. It is a window of its own, so it can sit beside the main window while a script runs.

The Scripts window. The run bar at the top reads Run on: 1: TCP/UDP, with Run, Stop and Check buttons and the status "AT command regression" passed in 0.32 s. The library on the left holds the AT command regression example, with New, Duplicate, Rename, Delete, Examples, Import and Export below it. The editor shows the first 27 numbered lines of the script with syntax colouring: its edit-here block with TIMEOUT_MS, ROUNDS and the CHECKS table of AT, AT+GMR and AT+CSQ, then the onStop function that prints the summary. The Output below lists the start line, signal quality 23, summary: 9 passed, 0 failed (passed) and the verdict passed in 0.32 s. The bottom line reads SPU Free: 2 of 3 script runs left this launch (all ports together), up to 5 minutes each
The AT command regression example after a run: the library on the left, the editor, and the Output with the script's summary and the verdict. In Free the bottom line counts the runs left.

The script window

  • Run on picks the connection the script sends to and reads from. Until you pick one by hand, it follows the connection that has the focus in the main window.
  • Run (Ctrl+R) starts the selected script on that connection; Stop ends it; Check looks for syntax errors without running anything or sending a byte.
  • The list on the left is the script library: New, Duplicate, Rename… (or double-click a script), Delete, Examples, Import… and Export…. A new script starts as a short AT check you can run at once.
  • The editor has line numbers, syntax colouring for Lua and for the SPU functions, and marks the line the last check or run failed on. Tab inserts four spaces. Edits go into the library as you type; there is no Save button.
  • Output shows what the script prints, any error, and the verdict of each run. Right-click it to Copy All, Save As… a text file, or Clear. It keeps the latest 5,000 lines.
  • The line beside the buttons says what the run is doing right now — for example Running "Modem check" 00:12 · expect "OK" (1000 ms).

Examples adds one of three working scripts to the library; the first two keep their settings in an "edit here" block at the top:

Example What it shows
AT command regression Sends a list of AT commands for several rounds, checks each reply, and prints a summary — even when stopped half way
Modbus RTU slave Answers FC03 reads and FC06 writes from a register table, with the CRC checked and appended
Answer PING with PONG The shortest onReceive: a device that answers

A first script

-- Ask the module for its signal quality, up to five times.
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")

A run ends in one of three ways, and both the Output and the connection's receive view say which:

  • passed — the script ran to its end (or returned);
  • failed — an expect timed out, fail() was called, a send was refused, or the code raised an error; the line number is given;
  • stopped — you pressed Stop, the connection closed or was lost, a file transfer took the connection over, or Free's time limit was reached.

Sending

In Lua a string is a sequence of bytes, and that is what the send functions put on the wire. "\r\n", "\x01\x03" and the result of string.pack go out exactly as written.

Function What it sends
send(bytes) The bytes as they are
sendLine(text [, eol]) text followed by eol, which is "\r\n" unless you give another. The send box's line ending, checksum and encoding settings play no part
sendHex("01 03 00 00 00 0A") Hex bytes, two digits each; a typo is an error rather than a silent 00
sendText(text) The text as if typed into the send box and sent: the connection's send encoding, line ending and appended checksum all apply
sendCommand(name) A command from the command library, named "Group/Name" or just "Name" when only one command has that name; it goes out with the command's own line ending and checksum

A send the connection does not accept fails the run at that line. With Display Send on, what a script sends shows in the receive view like anything you send yourself.

The script's own text is UTF-8. On a connection that uses another encoding, pass text outside ASCII through encode() first, or use sendText: send(encode("温度") .. "\r\n").

Waiting for replies

Two rules decide which data an expect looks at. They are the rules a sequence's Expect step follows:

  • Only what arrived since the script last sent. Every send clears what was received before it: a reply answers the command before it, and an OK left from an earlier command must not pass a later check.
  • A match uses up everything up to its end. Two expect("OK") in a row need the device to answer OK twice.

A reply that arrives in pieces still matches, as soon as enough of it is there. For a regular expression that means ending the pattern on whatever closes the part you want — the comma after a number, the line ending — because a pattern that ends in \d+ is satisfied by the first digit to arrive. The script keeps the latest 64 KB.

Function Waits for Returns
expect(text [, ms]) The exact bytes match, text
expectHex("01 03 ?? ??" [, ms]) Hex bytes; ?? is any byte match, text
expectRegex(pattern [, ms]) A Perl-compatible regular expression, matched against the bytes match, capture1, …, text
expectAny({"OK", "ERROR"} [, ms]) Whichever of the texts ends first match, index, text

text is always the last value: everything from the start of the received data to the end of the match — the whole reply, not only the part you asked for. index is the position in the list, starting at 1.

The timeout is in whole milliseconds, 1000 when left out; 0 waits without limit, until the data arrives or the run is stopped.

When nothing matches in time, expect fails the run with a message such as timeout waiting for "OK" (1000 ms) and the line number. That is what a test wants: no reply, no pass. To decide for yourself, use the tryExpect, tryExpectHex, tryExpectRegex and tryExpectAny forms: they return nil on a timeout and the script carries on.

Write a regular expression as a long string, [[\+CSQ:\s*(\d+),]], so that Lua leaves its backslashes alone. A pattern is bytes too: "\xff" in it means the byte 0xFF.

To read rather than check:

Function Returns
read(n [, ms]) Exactly n bytes (1 to 65536), or nil on a timeout
readLine([ms]) The next line without its line ending, or nil on a timeout; a half line stays for the next read
readAll() Whatever has arrived, at once, without waiting — possibly an empty string
clearRx() Nothing; discards what has arrived

To send without clearing what has arrived — a second command before reading the answer to the first — pass {keepRx = true} as the last argument of any send function: send("\x06", {keepRx = true}).

Binary frames and checksums

Lua's own string.pack and string.unpack build and take apart binary frames; > means big-endian, < little-endian, B a byte, I2 and I4 unsigned integers of that many bytes.

-- Read 2 holding registers from Modbus slave 1, starting at address 0.
local request = string.pack(">BBI2I2", 1, 3, 0, 2)
send(appendChecksum(request, "crc16modbus"))

local head = expectHex("01 03 04", 500)          -- slave, function, byte count
local body = read(6, 500)                        -- 4 data bytes and the 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))
Function What it does
checksum(name, bytes) The checksum as an integer
appendChecksum(bytes, name [, order]) The bytes with the checksum added
verifyChecksum(frame, name [, order]) true when the frame's last bytes are the checksum of the rest
toHex(bytes) "01 03 00 0A" — for printing a frame
fromHex("01 03 00 0A") The bytes
regex(pattern, text) match, capture1, … or nil: the same regular expressions on a string you already have
encode(text) / decode(bytes) Converts between the script's UTF-8 text and the connection's send / receive encoding

order is "le", low byte first — the default, and what Modbus and the send box use — or "be", high byte first. A checksum takes as many bytes as its width needs: two for a CRC-16, four for a CRC-32.

The names, written in any case and with or without -, /, _ and spaces, so that "CRC-16/CCITT-FALSE" and "crc16ccittfalse" are the same:

Width Names
Sums sum8 xor8
Up to 8 bits crc4itu crc5epc crc5itu crc5usb crc6cdma2000a crc6cdma2000b crc6itu crc7 crc8 crc8ebu crc8maxim crc8wcdma
10 to 15 bits crc10 crc10cdma2000 crc11 crc12cdma2000 crc12dect crc12umts crc13bbc crc15 crc15mpt1327
16 bits crc16arc crc16buypass crc16ccittfalse crc16cdma2000 crc16cms crc16dectr crc16dectx crc16dnp crc16genibus crc16kermit crc16maxim crc16modbus crc16t10dif crc16usb crc16x25 crc16xmodem
17 to 30 bits crc17can crc21can crc24 crc24flexraya crc24flexrayb crc30
32 bits crc32 crc32bzip2 crc32c crc32mpeg2 crc32posix crc32q
40 and 64 bits crc40gsm crc64

A 64-bit checksum whose top bit is set prints as a negative number; format it with ("%016X"):format(value).

Playing the device

onReceive(fn) hands every piece of received data to your function as it arrives, so a script can answer like a device — with an answer worked out from the request, which an auto reply rule cannot do.

local pending = ""

onReceive(function(bytes)
  pending = pending .. bytes                     -- a message can arrive in pieces
  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)              -- keep what follows the request
  end
end)

idle()                                           -- answer until stopped
  • The function receives bytes and, as a second argument, the peer they came from: the client on a TCP server or the sender on UDP, and an empty string on a serial port or a TCP client.
  • Inside it you can send, print and compute, but not wait: expect, read, sleep and idle are errors there. Its sends do not clear what the main part of the script is waiting for.
  • idle() waits until the run is stopped; it is how a script that only answers stays alive. onReceive(nil) removes the function.
  • Data arrives in the pieces the connection delivers: one request may come in two pieces, and two requests in one. Collect the pieces, take out each whole message and keep the rest, as above.
  • If the connection also has auto reply rules switched on, the Output warns once: the other side may get two answers to one request.

onStop(fn) runs your function once when the run ends, however it ends. It is called with how the run ended — "passed", "failed" or "stopped" — and the reason, and is the place to print a summary that must appear even after a Stop. It can print and log, but not send or wait: trying to only ends that function with a warning in the Output, and the verdict stands.

Other functions and the Lua you get

Function What it does
sleep(ms) Waits; whole milliseconds, up to a day
print(...) Writes a line to the Output
log(...) Writes to the Output and also adds a line to the connection's receive view, in time order with the data
fail(message) Ends the run as failed, with your message; a pcall around it does not catch it
now() Milliseconds since the run started
timestamp() The time of day as HH:mm:ss.zzz
port.name, port.mode The connection's label and kind: "serial", "tcp-client", "tcp-server", "udp-client", "udp-server", "udp-group", "rfc2217-client" or "live-relay"

The receive view takes up to 20 log lines a second; a faster script's remaining lines are in the Output only. While the receive view is paused it adds none, and the Output still has them all.

The language is standard Lua 5.4 with its string, table, math and utf8 libraries, pcall, error, assert, pairs, ipairs, select, tonumber, tostring, type and setmetatable. Two things differ from Lua elsewhere:

  • A sandbox. There is no io, os, coroutine or debug library and no require, load, dofile or loadfile: a script cannot read or write files, reach the network, start a program or load other code. Its only way out is the connection it runs on. A script someone sends you can do nothing to your computer — but read it before you run it, because it does send to your device.
  • Strict globals. Reading a variable that was never assigned is an error, variable 'x' is not defined, instead of a silent nil: a misspelt name or Python's True stops the run at once rather than quietly taking the wrong branch. Declare variables with local, or assign them before reading them.

A script has 32 MB of memory. One that loops without ever waiting still leaves the window responsive and can be stopped.

While it runs

  • One script per connection at a time; different connections can each run their own, and the Output then marks each line with its connection. A script and a sequence cannot run on the same connection together.
  • Run opens a closed connection first, as Send does. A TCP client connects in the background, so the first Run only starts the connection and asks you to run again once it is up. A script with a syntax error opens and sends nothing.
  • Closing the connection, losing the link or starting a file transfer stops the script and records why. It never reopens the connection behind your back.
  • The connection's receive view notes the start, each log() line and the verdict, for example Script "Modem check" failed at line 12 after 3.42 s: timeout waiting for "OK" (1000 ms).
  • Closing the script window while a script runs asks whether to Stop and Close or Keep Running. Kept running, the script carries on; open Tools → Scripts… again to see it and stop it.

When a run fails, the Output shows the line in red — click it to jump there in the editor — and, for a function of your own, the lines it was called from. The mistakes people make most come with a hint under the error: a full-width comma or quote typed with a Chinese input method, != for ~=, elif for elseif, +=, # used as a comment, a missing end, a misspelt function name ("did you mean sendLine?"), or a nil from a tryExpect that timed out.

Saving and sharing

  • With the project: a saved .spu project includes the script library and restores it when opened. With no project open, the library is kept on this computer.
  • Script files (.lua): Export… saves the selected script as a UTF-8 .lua file; Import… adds one to the library under the file's name. An imported script is shown, never run by itself: read it, then press Run.
  • A library holds up to 64 scripts of up to 256 KB each.
  • The diagnostic pack leaves scripts out: they are code, and often hold a device's password.

Free and SPU Pro

Writing, editing and checking scripts, the examples, import and export, and saving them with the project are complete in Free. Running is metered:

  • 3 script runs each time SPU starts, all connections together; the script window shows how many are left.
  • Up to 5 minutes per run. The Output warns 30 seconds before, and the run then ends as stopped.
  • A run that fails or is stopped within 30 seconds does not count, up to 3 times each time SPU starts — a typo on line 3 should not cost one of three runs. A script with a syntax error, or one whose connection cannot be opened, never counts.

SPU Pro has none of these limits.

Was this document helpful?