Skip to content

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. 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.

Protocol test results

Script keys

Header (defaults for every test):

  • name: script display name
  • timeout: 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 last
  • viz: visualizer names allowed in the debug screen's dropdown (empty = all)
  • send_fmt / expect_fmt: default inline format specs for TX / RX data
  • json_file: after /proto.run, write the results document to this file in test/ beside the script. A filename template: {name} (the config), {proto_name} (the script's stem), {datetime}, {date}, {time}. The debug screen and termapy --proto write one whether or not the key is set; there it overrides the proto_results_template config value

Per [[test]]:

  • name: test name
  • send: hex bytes (01 03 ...) or quoted text ('"AT\r"'); nothing is appended
  • expect: pattern to match (** = any byte); hex or quoted text
  • timeout: per-test override
  • setup / teardown: commands around this one test
  • viz: force one visualizer for this test in the debug screen
  • send_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.

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/.

Interactive proto debug screen

Multiple visualizers can be active at once via the checklist. Enable "Show viz string" to display the raw format spec above each table.

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.

"ID:H1 Temp:U2-3 Signed:I4-5 Status:H6"

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 = 200
  • U3-2 = bytes 3 then 2 = little-endian: C8 00 = 51200
  • I4-5 = big-endian signed: FF FE = -2
  • I5-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-5
  • B.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:

"Temp:U1-2 Humid:U3 MotorOn:B4.0 AlarmHi:B4.1 AlarmLo:B4.2 Mode:B4.5-7"

Real-world examples

Modbus RTU response (read 2 holding registers):

"Slave:H1 Func:H2 Len:U3 Reg0:U4-5 Reg1:U6-7 CRC:crc16-modbus"

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):

"Sync:H1-2 MsgID:U3 Lat:F4-7 Lon:F8-11 Alt:F12-15 Sats:U16 _:_17 CRC:crc8-maxim"

Sensor with string ID and padding:

"Serial:S1-8 _:_9-10 Temp:U11-12 Humid:U13-14 CRC:crc16-xmodem"