Protocol testing¶
Automated send/expect test scripts for binary serial protocols. Each step sends data, waits for a response, and reports PASS/FAIL.
For interactive sending and CRC commands, see Serial Tools.
Protocol test scripts¶
Create .pro files in the per-config proto/ folder. A script is
TOML: a header of defaults, then one [[test]] table per send/expect
step. The Proto picker lists them newest first with size and age, and
/proto.rename renames one.
# example.pro
name = "Modbus smoke test"
timeout = "1000ms" # default expect timeout
frame_gap = "50ms" # silence that ends a frame
setup = ["/term.hex on"] # commands run before the first test
teardown = ["/term.hex off"]
[[test]]
name = "Read registers"
send = "01 03 00 00 00 0A C5 CD"
expect = "01 03 14 ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** **"
[[test]]
name = "Write register"
send = "01 06 00 01 00 03 98 0B"
expect = "01 06 00 01 00 03 98 0B"
timeout = "500ms" # per-test override
# Text protocols work too: quote the text, \r \n escapes are honored
[[test]]
name = "AT query"
send = '"AT+VERSION?\r"'
expect = '"V1." ** ** "\r"'
send_fmt = "Title:AT_Command Command:S1-*" # optional inline format specs
expect_fmt = "Title:AT_Response Response:S1-*" # (see the format spec language below)
Run with /proto.run example.pro. Each test reports PASS/FAIL, and the
command fails when any test fails (2 of 5 tests failed), so a .run
script or an agent can branch on it as termapy --proto does on its exit
code. Escape stops a run after the current test (the script-level teardown
is then skipped). /proto.run example.pro --json returns the results document
described under Headless runs instead of the prose,
which is also what an MCP client receives.
Script keys¶
Header (defaults for every test):
name: script display nametimeout: default expect timeout (default 1000ms)frame_gap: silence gap that ends a frame (default 50ms)strip_ansi: strip ANSI escape sequences from responses before matching (a TOML boolean,true/false; a quoted string is refused at load)quiet: hide setup/teardown output (a TOML boolean too)setup/teardown: lists of commands run before the first test / after the lastviz: visualizer names allowed in the debug screen's dropdown (empty = all)send_fmt/expect_fmt: default inline format specs for TX / RX datajson_file: after/proto.run, write the results document to this file intest/beside the script. A filename template:{name}(the config),{proto_name}(the script's stem),{datetime},{date},{time}. The debug screen andtermapy --protowrite one whether or not the key is set; there it overrides theproto_results_templateconfig value
Per [[test]]:
name: test namesend: hex bytes (01 03 ...) or quoted text ('"AT\r"'); nothing is appendedexpect: pattern to match (**= any byte); hex or quoted texttimeout: per-test overridesetup/teardown: commands around this one testviz: force one visualizer for this test in the debug screensend_fmt/expect_fmt: inline format specs for this test
Legacy flat format¶
Older scripts use a colon-keyed flat format: header directives
@name <text>, @timeout <dur>, @frame_gap <dur>, @strip_ansi {on|off}
(bare is on), @quiet, @loud, then label:, send:, expect:, timeout:,
delay:, flush:, cmd: steps. It is still parsed when a file is not
valid TOML, but JSON result output (json_file, --json, --proto)
needs the TOML form.
Headless runs¶
termapy --proto smoke.pro my_device # no TUI; exit 0 when every test passed
termapy --proto smoke.pro --demo # against the demo device
--proto runs one TOML script against the config's port and exits:
0 when every test passed, 1 when any failed, 2 on a config, script, or
port error. One summary line goes to stdout; the results document goes
to proto/test/ in the config's data folder, named by the
proto_results_template config value unless the script sets
json_file. The document has four keys: meta (script, config, port,
baud rate, encoding, timestamp), summary (total, passed,
failed, elapsed_ms), tests (one record per test that ran:
index, name, passed, elapsed_ms, send_hex, expect_hex,
actual_hex, the text forms, any send_fmt/expect_fmt, and error
on a timeout), and source (the script text). The same document is
what /proto.run --json returns and what the debug screen's JSON
checkbox writes.
The debug screen¶
/proto.debug <script> opens the screen the Proto button opens: the
script's tests on the left, and the controls for one run across the top.
Three lists, each worked the same way -- click a row, or arrow to it and
press Space. A row marked X is on, a blank row is off. Hovering a row
says what it is: an option's effect, a visualizer's description, or a
test's name and the bytes it sends.
| List | What it holds |
|---|---|
| Tests | The [[test]] sections of the script. Run works on the checked ones, and refuses if none are. |
| Visualizations | How each packet is decoded into columns. At least one stays checked; a test with its own viz adds that one whatever is checked here. |
| Options | Independent switches for the run (below). All four may be off, which is how the screen opens. |
The options:
- Stop on Error -- end the run at the first failing test instead of working through the rest.
- Show viz string -- print the raw format spec above each decoded table, so you can see how a visualizer read the bytes.
- Compact -- fewer blank lines per test, for a long run.
- JSON -- also write the results to the file named beside it.
Run settings, under the lists: Repeat runs the whole set that many
times, Delay (ms) waits between tests, and JSON file names the
results file. In that name {name} stands for the script's own name, so
the default {name}_results.json writes modbus_inline_results.json
for modbus_inline.pro. A relative name lands in the config's folder.
The file is only written when the JSON option is on.
The buttons: Setup and Teardown run the script's own setup and teardown blocks on their own (disabled when the script has none), Run runs the checked tests, Stop ends the run after the test in flight, Log opens the debug log in your system viewer, and Help opens this page.
Packet visualizers¶
The proto debug screen uses pluggable visualizers to decode packet bytes into
named columns. Built-in visualizers (Hex, Text, Modbus) ship with termapy. Add
your own by dropping a .py file into termapy_cfg/<config>/viz/.
Several can be active at once: check them in the Visualizations list, and each draws its own table under every test.
Selecting visualizers in .pro files:
Use viz in the script header to limit which visualizers appear in the dropdown.
Use viz in a [[test]] section to force that visualizer for the test:
viz = ["Modbus"] # header: only offer Modbus in the dropdown
[[test]]
name = "Read registers"
viz = "Modbus" # force Modbus view for this test
send = "01 03 00 00 00 01 84 0A"
expect = "01 03 02 00 07 F9 86"
Format spec language¶
Format specs decode raw bytes into named, typed fields. One line defines your
entire packet layout. Used in protocol testing (.pro files), data capture
(/cap.struct, /cap.hex), and the proto debug screen.
Syntax¶
Each field: Name:TypeByteRange. Fields separated by spaces.
Given the bytes 01 00 C8 FF FE 0A, this decodes to:
ID = 01 (byte 1 as hex)
Temp = 200 (bytes 2-3 as unsigned int, big-endian)
Signed = -2 (bytes 4-5 as signed int, big-endian)
Status = 0A (byte 6 as hex)
In protocol tests, termapy decodes both expected and actual bytes, then shows per-column pass/fail:
Expected: 01 00 C8 FF FE 0A -> ID:01 Temp:200 Signed:-2 Status:0A
Actual: 01 00 C9 FF FE 0A -> ID:01 Temp:201 Signed:-2 Status:0A
match MISMATCH match match
Type reference¶
| Code | Meaning | Example | Output |
|---|---|---|---|
H |
Hex bytes | H1, H3-4 |
0A, 01FF |
h |
Hex, per byte | h3-4 |
01 FF |
U |
Unsigned integer | U1, U3-4 |
10, 256 |
I |
Signed integer | I1, I3-4 |
-1, +127 |
S |
ASCII string | S1-5 |
Hello |
F |
IEEE 754 float | F1-4 |
3.14 |
B |
Bit field | B1.3, B1-2.7-9 |
1, 5 |
b |
Bit field, bits | b1.0-7 |
00000110 |
_ |
Padding (hidden) | _3-4 |
(skipped) |
crc* |
CRC verify | crc16-modbus |
pass/fail |
Lowercase shows the pieces: h spaces each byte (and a protocol diff marks
the mismatched bytes individually), b prints the bits as a binary string.
Only h and b have lowercase forms.
Integers support 1, 2, 3, 4, and 8 byte widths. Floats are 4-byte (F32) or
8-byte (F64). Byte indexing is 1-based: U0 is an error, not the first
byte. H7-* = wildcard to end of packet. A column that reaches past the
data shows ? rather than a value.
Endianness¶
Byte order in the spec IS the endianness - no separate flags needed:
U2-3= bytes 2 then 3 = big-endian:00 C8= 200U3-2= bytes 3 then 2 = little-endian:C8 00= 51200I4-5= big-endian signed:FF FE= -2I5-4= little-endian signed:FE FF= -257
You read the spec the same way you read the protocol datasheet. Modbus
devices are big-endian (U2-3), x86-based devices are little-endian (U3-2).
CRC fields don't take byte indices -- they use the algorithm's natural
wire order by default (low byte first for refout=True algorithms like
Modbus, USB, CRC-32; high byte first for refout=False like XMODEM,
CCITT-FALSE). Add _le or _be only when your protocol wires the CRC
opposite to that natural order: CRC:crc16-modbus_be would mean
"Modbus CRC, but the bytes go high-first on this particular wire."
The CRC covers every byte before it. When a protocol leaves bytes out (a
sync byte, say), give the range: CRC:crc16-modbus(2-7) computes over
bytes 2-7, 1-based and inclusive. In a protocol diff the CRC column is
match when the CRC the packet carries verifies over that range (and
agrees with the expect pattern wherever the pattern spells the CRC bytes
out instead of wildcarding them), mismatch otherwise.
Bit fields¶
Extract individual bits or bit ranges from bytes:
B4.0- bit 0 of byte 4 (LSB)B4.7- bit 7 of byte 4 (MSB)B4.5-7- bits 5-7 of byte 4 (3-bit value)B4-5.0-15- 16-bit range across bytes 4-5B.15- no byte range: bit 15 of a register word. Only a reader that knows the word binds it (the memory commands); in a packet spec it is an error
b takes the same references but prints the bits. An ascending range reads
like a datasheet, bit 7 leftmost (b4.0-7 on 06 gives 00000110); a
descending range reverses it, bit 0 leftmost (b4.7-0 gives 01100000).
Example: a status byte where each bit means something:
Real-world examples¶
Modbus RTU response (read 2 holding registers):
Decodes 01 03 04 00 C8 01 F4 XX XX to Slave:01 Func:03 Len:4
Reg0:200 Reg1:500 CRC:pass
GPS binary packet (mixed types):
Sensor with string ID and padding: