Skip to main content

A TUI serial terminal with ANSI color support, built on Textual and pyserial

Project description

termapy

Project Status: CI codecov ty license docs

Powered by: Textual pySerial zensical

Built with: python uv pytest coverage

Pronounced "ter-map-ee"

Runs on Windows, macOS, and Linux. A serial interface terminal like PuTTY or Tera Term, but it runs in your terminal, installs in seconds, works well with git and teams, and comes with scripting, protocol testing, and a plugin system built in.

Low time commitment: about 1 minute from scratch, or under 10 seconds if you already have uv installed.

termapy screenshot

Install and connect

uv is the preferred package manager: a clean install takes under 10 seconds, and subsequent updates are well under a second.

  1. Install Python package manager uv (skip if already installed) — < 1 minute:

    # Windows (PowerShell)
    powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
    
    # macOS / Linux
    curl -LsSf https://astral.sh/uv/install.sh | sh
    
  2. Install termapy — < 1 second:

    uv tool install -q termapy
    
  3. Run termapy — starts a simulated device, no hardware needed. You're typing commands in seconds:

    termapy --demo
    
  4. Remove termapy if you don't like it:

    uv tool uninstall termapy
    

For a plain-text terminal (no TUI), use CLI mode:

termapy --cli --demo

There's a lot more: scripting, binary protocol testing, every CRC algorithm in the reveng catalogue (62 of them, all verified against their check values in the test suite), custom buttons, plugins, and packet visualizers. Expand any section below.


First 60 seconds — connect, type, change settings
  1. Connect: click the port button in the title bar, pick your COM port, click the status button to connect (it turns green)
  2. Type: enter commands in the input box at the bottom and press Enter
  3. Change settings: click Cfg to edit port, baud rate, and other settings through the UI

Everything works through the UI. No config files to edit unless you want to.

Why not just use PuTTY? — what termapy adds

PuTTY works. So does minicom, screen, and CoolTerm. Use them if they do what you need. Here's where termapy goes further:

  • Runs anywhere Python does. Same tool on Windows, macOS, Linux. No GUI installer, no system dependencies.
  • Session logging and screenshots. Every session is logged. Ctrl+S saves an SVG screenshot you can paste into a report or email.
  • Scripting. Record a sequence of commands in a text file and replay it with one click. Add delays, prompts, and REPL commands.
  • Data capture. Capture serial text (timed) or binary data (by byte/record count) to files. Binary captures use the same format spec language as protocol testing to decode mixed-type records into CSV/TSV.
  • Binary protocol testing. Send raw hex, run scripted send/expect tests with pass/fail, decode Modbus and custom protocols with pluggable visualizers.
  • Plugin system. Add custom commands with a simple Python API. Drop a file in a folder, define a handler, done. Includes examples to get started.
  • Everything in one folder. Each device config gets its own subfolder with logs, screenshots, scripts, and plugins. Check it into git so the whole team has the same config.

See COMPARISON.md for a detailed feature comparison against RealTerm, CoolTerm, Tera Term, Docklight, and HTerm.

Who this is not for — save yourself some time
  • You just need a simple serial terminal. If you open PuTTY, type AT, see OK, and you're done, keep using PuTTY (or screen /dev/ttyUSB0 115200 on Linux/macOS). Termapy is built for people who hit the limits of simple terminals and need scripting, protocol testing, data capture, or a plugin system.
  • You don't want Python on your machine. Termapy is a Python app. uv makes installation isolated and fast (it manages its own Python, won't touch your system), but if "install Python" is a deal-breaker, a native app like CoolTerm or RealTerm is a better fit.
  • You need a GUI with menus and mouse-driven workflows. Termapy runs in your terminal. It has a TUI with buttons and dialogs, but it's keyboard-first and text-based. If you want drag-and-drop or a Windows-native look, try Tera Term or Docklight.
The basics — keyboard shortcuts, title bar, REPL commands

Keyboard shortcuts

Key Action
Ctrl+Q Quit (also closes any open dialog)
Ctrl+S Save SVG screenshot
Ctrl+T Save text screenshot
Ctrl+P Command palette
Up/Down Cycle through command history
Escape Clear input / exit history browsing
Right Accept type-ahead suggestion

Title bar

Button Action
? Open the help guide
# Toggle line numbers (green when active)
Cfg Open the config picker
Run Open the script picker
Center Click to edit the current config
Port Click to select a serial port
Status Click to connect/disconnect (red = disconnected, green = connected)

REPL commands

Type / to access built-in commands (the prefix is configurable). Type /help to list them all.

The most common ones:

Command Description
/help [cmd] List commands or show help for one
/port.list List available serial ports
/port.open {name} {baud} {mode} Connect with optional baud rate and mode (e.g. N81)
/port.info Show port status and parameters
/cfg [key [value]] Show or change in-memory config
/ss.svg [name] Save SVG screenshot
/cls Clear the terminal
/run <filename> Run a script file
/echo [on | off] Toggle command echo
/grep <pattern> Search scrollback
/exit Exit termapy
Full command list
Command Description
/help [cmd] List commands or show extended help for one
/help.dev <cmd> Show a command handler's Python docstring
/port [name] Open a port by name, or show subcommands
/port.list List available serial ports
/port.open {name} {baud} {mode} Connect with optional baud and mode (e.g. /port.open COM3 9600 N81)
/port.mode {baud} {mode} Show or set serial mode (e.g. /port.mode 9600 N81)
/port.close Disconnect from the serial port
/port.info Show port status, serial parameters, and hardware lines
/port.baud_rate {value} Show or set baud rate (hardware only)
/port.byte_size {value} Show or set data bits (hardware only)
/port.parity {value} Show or set parity (hardware only)
/port.stop_bits {value} Show or set stop bits (hardware only)
/port.flow_control {m} Show or set flow control: none, rtscts, xonxoff, manual
/port.dtr {0|1} Show or set DTR line
/port.rts {0|1} Show or set RTS line
/port.cts Show CTS state (read-only)
/port.dsr Show DSR state (read-only)
/port.ri Show RI state (read-only)
/port.cd Show CD state (read-only)
/port.break {ms} Send break signal (default 250ms)
/cfg [key [value]] Show config, show a key, or change in-memory value (with confirmation)
/cfg.auto <key> <value> Set an in-memory config key immediately (no confirmation)
/cfg.configs List all config files
/cfg.load <name> Switch to a different config by name
/ss.svg [name] Save SVG screenshot
/ss.txt [name] Save text screenshot
/ss.dir Show the screenshot folder
/cls Clear the terminal screen
/run <filename> {-v} Run a script file (-v/--verbose for per-line timing); nests up to 5 levels deep
/run.list List .run files in the run/ directory
/run.load <filename> Run a script file (same as /run)
/delay <duration> Wait for a duration (e.g. 500ms, 1.5s)
/confirm {message} Show Yes/Cancel dialog; Cancel stops a running script (see at_demo.run)
/stop Abort a running script
/seq Show sequence counters
/seq.reset Reset all sequence counters to zero
/print <text> Print a message to the terminal
/print.r <text> Print Rich markup text (e.g. [bold red]Warning![/])
/show <name> Show a file
/show.cfg Show the current config file
/echo [on | off] Toggle REPL command echo
/echo.quiet <on | off> Set echo on/off silently (for scripts and on_connect_cmd)
/edit <file> Edit a project file (run//proto/ path)
/edit.cfg Edit the current config file
/edit.log Open the session log in the system viewer
/edit.info Open the info report in the system viewer
/show_line_endings [on | off] Toggle visible \r \n markers for line-ending troubleshooting
/os <cmd> Run a shell command (10s timeout, requires os_cmd_enabled)
/grep <pattern> Search scrollback for regex matches (case-insensitive, skips own output)
/cfg.info {--display} Show project summary; --display opens full report in system viewer
/cfg.files Show project directory tree
/proto.send <hex> Send raw hex bytes and/or quoted text, display response as hex (see below)
/proto.run <file> Run a binary protocol test script (.pro) with pass/fail
/proto.list List .pro files in the proto/ directory
/proto.load <file> Run a protocol test script (same as /proto.run)
/proto.hex [on | off] Toggle hex display mode for serial I/O
/proto.crc.list {pat} List available CRC algorithms (optional glob filter)
/proto.crc.help <name> Show CRC algorithm parameters and description
/proto.crc.calc <n> {d} Compute CRC over hex bytes, text, or file; omit data to verify check string
/proto.status Show current protocol mode state
/var {name} List user variables, or show one by name
/var.set <NAME> <value> Set a user variable
/var.clear Clear all user variables
/env.list {pattern} List environment variables (all, by name, or glob)
/env.set <name> <value> Set a session-scoped environment variable
/env.reload Re-snapshot variables from the OS environment
/cap.text <f> ... Capture serial text to file for a timed duration
/cap.bin <f> ... Capture raw binary bytes to a file
/cap.struct <f> ... Capture binary data, decode with format spec to CSV
/cap.hex <f> ... Capture hex text lines, decode with format spec to CSV
/cap.stop Stop an active capture
/raw <text> Send text to serial with no variable expansion or transforms
/exit Exit termapy

Screenshots and logs are saved in the config's subfolder (termapy_cfg/<name>/).

Project files — config layout, version control, env vars, examples

On first run, termapy prompts for a config name and creates one with defaults. If one config exists it loads automatically; if multiple exist, a picker appears. You can edit the config file through the UI (Cfg button), or change in-memory settings for the current session with /cfg baud_rate 9600.

Everything termapy creates (configs, scripts, test files, plugins, logs) lives in one folder. Run termapy --demo and you'll see this structure:

termapy_cfg/
├── plugin/                             # global plugins (all configs)
└── demo/
    ├── demo.cfg                        # config file
    ├── demo.log                        # session log
    ├── .cmd_history.txt                # command history
    ├── ss/                             # screenshots
    ├── run/                            # script files for /run
    │   ├── at_demo.run
    │   ├── smoke_test.run
    │   └── status_check.run
    ├── plugin/                         # per-config plugins
    │   └── probe.py
    ├── cap/                            # data capture output files
    └── proto/                          # protocol test scripts
        ├── at_test.pro
        ├── bitfield_inline.pro
        └── modbus_inline.pro

Your own configs follow the same layout. Create one with CfgNew and termapy builds the folder structure automatically.

Version control

Because everything is in one folder, you can commit it to git alongside your firmware source. Point --cfg-dir at a folder in your repo:

termapy --cfg-dir ./termapy_cfg

Clone on another machine, run the same command, and all configs, scripts, and test files are ready to go.

Since COM port names differ between machines, use $(env.NAME) placeholders in your config so the same file works everywhere. Set a COMPORT environment variable on each machine, and reference it with a fallback:

{
    "port": "$(env.COMPORT|COM4)",
    "baud_rate": 115200,
    "auto_connect": true
}

On a machine with COMPORT=COM7, termapy connects to COM7. On a machine without COMPORT set, it falls back to COM4. The config file on disk keeps the raw $(env.COMPORT|COM4) template. It's expanded in memory at load time, so your checked-in config stays portable.

Environment variables work in any string config value, not just port:

{
    "port": "$(env.COMPORT|COM4)",
    "title": "$(env.DEVICE_NAME|Dev Board)",
    "log_file": "$(env.LOG_DIR|logs)/session.log"
}

You can also manage environment variables at runtime with REPL commands:

Command Description
/env.list {pattern} List variables (all, by name, or glob like COM*)
/env.set <name> <val> Set a session-scoped variable (in-memory only)
/env.reload Re-snapshot variables from the OS environment

Variables set with /env.set are available immediately for $(env.NAME) expansion in REPL commands but do not modify the OS environment or the config file.

User variables ($(NAME))

User variables let you define values once and reuse them across commands and scripts. This is especially useful when a test references the same address, register, or port in multiple places. Change it once at the top instead of everywhere.

Assign a variable by typing $(name) = value (no / prefix needed):

$(slave) = 01
$(reg) = 0064
$(count) = 05

Use variables in any command, REPL or serial:

/proto.send $(slave) 03 00 $(reg) 00 $(count)
/print Reading $(count) registers from $(slave) at $(reg)
AT+ADDR=$(slave)

A typical workflow is a setup script that configures a test, then a test script that uses the variables:

# setup_modbus.run — run this first to configure the test
$(SLAVE) = 01
$(BASE_REG) = 0064
$(NUM_REGS) = 05
/print Configured: slave=$(SLAVE) base=$(BASE_REG) count=$(NUM_REGS)
# test_registers.run — uses variables from setup
/proto.send $(SLAVE) 03 00 $(BASE_REG) 00 $(NUM_REGS)
/delay 500ms
/proto.send $(SLAVE) 06 00 $(BASE_REG) 04 D2

Run /run setup_modbus.run then /run test_registers.run. The variables persist across interactive /run calls.

Command Description
$(NAME) = value Set a variable (no / prefix needed)
/var List all defined variables
/var NAME Show one variable's value (or $(NAME))
/var.set NAME val Set a variable (explicit command form)
/var.clear Clear all variables

Scope: Variables persist for the interactive session. They are automatically cleared when a script is launched from the Scripts button or Run menu, but not when /run is typed interactively or called within a script. This lets you run a setup script to define variables, then run a test script that uses them. Use /var.clear to reset manually.

Naming: Variable names are case-sensitive ($(PORT) and $(port) are different variables). Names must start with a letter or underscore and contain only letters, digits, and underscores.

Built-in time variables:

Variable Set when Updates?
$(LAUNCH_DATETIME) App starts Never - frozen
$(SESSION_DATETIME) Script launched (Scripts button / Run menu) Per script launch
$(DATETIME) Every expansion Always current clock

Each group also has _DATE and _TIME variants (e.g. $(LAUNCH_DATE), $(SESSION_TIME)).

vs. environment variables: $(env.NAME) pulls from the OS environment and works in config files. $(NAME) is for user-defined session variables in commands and scripts. Both use the $(...) syntax. The env. prefix is required to access environment variables explicitly.

Escaping: Use \$ to prevent expansion of a single reference, or /raw to skip expansion for an entire line.

Add a .gitignore for session files you don't need to track:

# termapy_cfg — keep configs and scripts, ignore session files
termapy_cfg/*/*.log
termapy_cfg/*/.cmd_history.txt
termapy_cfg/*/ss/

To specify a config file directly:

termapy my_device.cfg

To override the config directory:

termapy --cfg-dir /path/to/configs

Config validation

Termapy validates config files on load and when saving from the editor. Invalid serial settings (baud rate, parity, data bits, stop bits, flow control, encoding) and unknown keys (typos) produce yellow warnings in the log window. Non-standard baud rates are flagged but allowed, since some hardware uses custom rates.

To validate a config from the command line without launching the UI:

termapy --check my_device.cfg

This prints a JSON result to stdout and exits:

{"status": "ok"}
{"status": "warn", "warnings": ["baud_rate: 115201 is not a standard rate (110, 300, ...)"]}

The --check flag is read-only. It never modifies the config file.

Config examples

When you create a new config, termapy writes a complete .cfg file with all defaults (~30 lines). Here are some of the settings you can change:

{
    "port": "COM4",
    "baud_rate": 115200,
    "auto_connect": true,
    "auto_reconnect": true,
    "title": "Sensor A",
    "border_color": "blue",
    "on_connect_cmd": "rev \n help dev"
}

Custom buttons

The demo project's "Info" button runs the /cfg.info command via a custom button:

{"enabled": true, "name": "Info", "command": "/cfg.info", "tooltip": "Project info"}

Custom Info button in the toolbar

Add toolbar buttons that send commands, run scripts, or chain multiple actions. Use \n to separate multiple commands:

{
    "custom_buttons": [
        {"enabled": true, "name": "Reset", "command": "ATZ", "tooltip": "Reset device"},
        {"enabled": true, "name": "Init", "command": "ATZ\\nAT+BAUD=115200\\n/sleep 500ms\\nAT+INFO", "tooltip": "Full init sequence"},
        {"enabled": true, "name": "Status", "command": "/run status_check.run", "tooltip": "Run status script"}
    ]
}

Hardware line control

Set flow_control to "manual" to get DTR, RTS, and Break buttons in the toolbar. This is useful for devices that use these lines for reset or bootloader entry:

{
    "port": "COM4",
    "baud_rate": 115200,
    "flow_control": "manual",
    "title": "Hardware Debug"
}
Full config reference
{
    "config_version": 12,
    "title": "",
    "border_color": "",
    "max_lines": 10000,
    "default_ui": "tui",
    "cmd_prefix": "/",
    "cli_prompt": "$(CFG)> ",
    "cli_echo_input": false,
    "cli_intellisense": true,
    "config_read_only": false,
    "os_cmd_enabled": false,
    "device_json_cmd": "",
    "port": "COM4",
    "baud_rate": 115200,
    "custom_baud": false,
    "byte_size": 8,
    "parity": "N",
    "stop_bits": 1,
    "flow_control": "none",
    "encoding": "utf-8",
    "cmd_delay_ms": 0,
    "auto_connect": false,
    "auto_reconnect": false,
    "on_connect_cmd": "",
    "line_ending": "\r",
    "send_bare_enter": false,
    "echo_input": false,
    "echo_input_fmt": "[purple]> {cmd}[/]",
    "log_file": "",
    "show_traceback": false,
    "proto_results_template": "{name}_results.json",
    "show_timestamps": false,
    "show_line_endings": false,
    "show_line_numbers": false,
    "hex_mode": false,
    "max_grep_lines": 100,
    "file_xfer_root": "",
    "custom_buttons": []
}
Field Default Description
config_version 5 Schema version (managed automatically by the migration system, do not edit)
port "" Serial port name -- auto-detected when only one port available (supports $(env.NAME|fallback))
baud_rate 115200 Baud rate -- non-standard rates require custom_baud
custom_baud false Allow any baud rate >= 300 (modern drivers support arbitrary rates; disable to catch typos)
byte_size 8 Data bits (5, 6, 7, 8)
parity "N" Parity: "N", "E", "O", "M", "S"
stop_bits 1 Stop bits (1, 1.5, 2)
flow_control "none" "none", "rtscts" (hardware), "xonxoff" (software), or "manual" (shows DTR/RTS/Break buttons)
encoding "utf-8" Character encoding for serial data. Common values: "utf-8", "latin-1", "ascii", "cp437"
cmd_delay_ms 0 Delay in milliseconds between commands in autoconnect sequences and multi-command input (cmd1 \n cmd2)
line_ending "\r" Appended to each command. "\r" CR, "\r\n" CRLF, "\n" LF
send_bare_enter false Send the line ending when Enter is pressed with no input (for "press enter to continue" prompts)
auto_connect false Connect to the port on startup
auto_reconnect false Retry every 2.5s if the port drops or fails to open (does not control startup)
on_connect_cmd "" Commands to send after connecting, separated by \n. Waits for idle between each
echo_input false Echo sent commands locally
echo_input_fmt "[purple]> {cmd}[/]" Rich markup format for echoed commands. {cmd} is replaced with the command text
log_file "" Session log path. If empty, uses <name>.log in the config's subfolder
show_timestamps false Prefix each line in the terminal display with [HH:MM:SS.mmm]
show_line_endings false Show dim \r and \n markers in serial output for line-ending debugging (see note below)
show_line_numbers false Show line numbers in serial output
hex_mode false Display serial I/O as hex bytes instead of text
max_grep_lines 100 Maximum number of matching lines shown by /grep
proto_frame_gap_ms 50 Silence gap (ms) to detect end of a binary protocol frame
title "" Title bar center text. Defaults to the config filename
border_color "" Title bar and output border color. Any CSS color name or hex value
max_lines 10000 Maximum lines in the scrollback buffer
cmd_prefix "/" Prefix for local REPL commands (e.g. /help, /cls)
config_read_only false Disable the Edit button in config/script/proto pickers (/cfg still changes in-memory values)
os_cmd_enabled false Enable the /os REPL command to run shell commands
show_traceback false Include full stack trace in serial exception output (for debugging)
custom_buttons [] Array of custom button objects (see Custom Buttons above)

Note on show_line_endings: This is a debug mode for troubleshooting line-ending mismatches (\r vs \n vs \r\n). When enabled, dim \r and \n markers appear inline in serial output before the characters are consumed by line splitting. Sent commands also show the configured line ending. Since the markers use ANSI escape sequences, they may interfere with device ANSI color output, so turn show_line_endings off when not actively debugging.

Scripting — automate command sequences with text files

Run menu / script picker dialog

Create text files with one command per line and run them from the Run button or with the /run or the Scripts button. IN the file ines starting with / are REPL commands, lines starting with # are comments and everything else is sent to the device.

# Quick status check
AT+STATUS
/delay 300ms
AT+TEMP
/delay 300ms

Scripts support delays (/delay 500ms), screen clearing (/cls), confirmation prompts (/confirm Reset device?), screenshots, and sequence counters with auto-increment for batch testing. See the demo scripts (at_demo.run, smoke_test.run) for examples.

Data capture — timed text capture, structured binary capture to CSV

Capture serial data to files without interrupting normal terminal display.

Text capture (timed, writes decoded text lines):

/cap.text log.txt timeout=3s cmd=AT+INFO              # capture 3 seconds of text
/cap.text session.txt timeout=10s mode=append          # append, just listen (no command)

Binary capture (raw bytes to file):

/cap.bin raw.bin bytes=256 cmd=read_all

Structured capture (binary data decoded via format spec to CSV):

# Single-type column — 50 big-endian unsigned 16-bit values
/cap.struct data.csv fmt=Val:U1-2 records=50 cmd=AT+BINDUMP u16 50

# Mixed-type record — string + u8 + u16 + u32 + float (little-endian)
/cap.struct mixed.csv fmt=Label:S1-10 Counter:U11 Val16:U13-12 Val32:U17-14 Temp:F21-18 records=20 cmd=AT+BINDUMP 20

# Tab-separated output with echo to terminal
/cap.struct log.tsv fmt=A:U1-2 B:F3-6 records=100 sep=tab echo=on cmd=read

The fmt= parameter uses the same format spec language as /proto, with type codes H (hex), U (unsigned), I (signed), S (string), F (float), B (bit) and 1-based byte ranges. Byte range order determines endianness: U1-2 = big-endian, U2-1 = little-endian. Named columns (Temp:U1-2) produce a CSV header row; unnamed columns (U1-2) omit it.

Format spec C type Meaning
U1 uint8_t 1 unsigned byte
U1-2 uint16_t 2-byte unsigned, big-endian
U2-1 uint16_t 2-byte unsigned, little-endian
U1-4 uint32_t 4-byte unsigned, big-endian
U1-8 uint64_t 8-byte unsigned, big-endian
I1 int8_t 1 signed byte
I1-2 int16_t 2-byte signed, big-endian
I1-4 int32_t 4-byte signed, big-endian
I1-8 int64_t 8-byte signed, big-endian
F1-4 float 4-byte IEEE 754 float
F1-8 double 8-byte IEEE 754 double
S1-10 char[10] 10-byte ASCII string
H1-4 4 bytes as hex (e.g. 0A1BFF03)

Auto-numbered filenames: use $(n000) for a 3-digit rotating sequence (000–999), tracked across sessions in a counter file.

/cap.text log_$(n000).txt timeout=3s cmd=AT+INFO          # log_000.txt, log_001.txt, ...
/cap.struct data_$(n00).csv fmt=V:U1-2 records=100 cmd=read

A progress bar and Stop button overlay the toolbar during capture. The Cap button opens the cap/ folder.

Binary protocol testing — hex send/receive, .pro test scripts, CRC

Send raw hex bytes and see the response:

/proto.send 01 03 00 00 00 01 84 0A
  TX: 01 03 00 00 00 01 84 0A
  RX: 01 03 02 00 07 F9 86
  (7 bytes, 12ms)

Mix hex and quoted text:

/proto.send "AT+RST\r\n"
/proto.send FF 00 "hello" 0D 0A

No line ending is appended; you send exactly the bytes you specify. Toggle /proto.hex to show all normal serial I/O as hex bytes.

Proto test scripts

Write .pro files (TOML format) for repeatable send/expect testing with pass/fail:

name = "Modbus Register Test"
frame_gap = "20ms"

[[test]]
name = "Read 1 register"
send = "01 03 00 00 00 01 84 0A"
expect = "01 03 02 00 07 F9 86"

[[test]]
name = "Write register 5 = 1234"
send = "01 06 00 05 04 D2 1B 56"
expect = "01 06 00 05 04 D2 1B 56"

Run with /proto.run <file> or from the proto debug screen, which adds repeat count, delay between runs, stop-on-error, scrolling results, and visualizer column data.

Inline format specs

Add send_fmt and expect_fmt to any test step to decode raw bytes into named columns. The proto debug screen displays the decoded values side by side with pass/fail highlighting, turning opaque hex into readable fields.

[[test]]
name = "Read 2 registers"
send = "01 03 00 00 00 02 C4 0B"
send_fmt = "Title:Modbus_TX Slave:H1 Func:H2 Addr:U3-4 Count:U5-6 CRC:crc16-modbus_le"
expect = "01 03 04 00 07 00 14 4B FD"
expect_fmt = "Title:Modbus_Response Slave:H1 Func:H2 Bytes:U3 R0:U4-5 R1:U6-7 CRC:crc16-modbus_le"

Each column is Name:TypeBytes where the type controls how bytes are displayed:

Type Description Example Display
H Hex (uppercase) H1 / H3-4 0A / 0A 2B
h Hex (lowercase) h1 0a
U Unsigned int (big-endian) U3-4 7
I Signed int (big-endian) I3-4 -1
S ASCII string S3-10 HELLO
B Bit field (integer) B4-5.0-2 3
b Bit field (binary string) b4-5.0-15 0000101000101011
F IEEE 754 float F3-6 3.14
_ Padding (skip bytes) _3-4 (hidden)
crc* CRC auto-check CRC:crc16-modbus_le OK / FAIL

Byte indices are 1-based. Ranges use - (e.g. U3-4 = bytes 3–4). Byte order is controlled by the index direction. This is how you handle big-endian vs little-endian protocols:

  • U3-4: big-endian (byte 3 is MSB, byte 4 is LSB)
  • U4-3: little-endian (byte 4 is LSB, byte 3 is MSB)
  • U5-8: 32-bit big-endian (4 bytes, MSB first)
  • U8-5: 32-bit little-endian (4 bytes, LSB first)

This works for all multi-byte types (U, I, H, F, B). CRC columns auto-compute and verify the checksum over the preceding bytes. Append _le or _be to the CRC algorithm name for the byte order of the checksum itself:

  • CRC:crc16-modbus_le: CRC-16/Modbus stored little-endian (low byte first, as Modbus RTU requires)
  • CRC:crc16-modbus_be: same algorithm but stored big-endian (high byte first)

The demo project includes two .pro files that exercise inline format specs: modbus_inline.pro (register reads/writes with Modbus decoding) and bitfield_inline.pro (bit field extraction and binary display). Run them from the Proto button in --demo mode.

Here is the test that generates the Modbus response shown below:

[[test]]
name = "Read 5 registers from addr 100"
send = "01 03 00 64 00 05 C4 16"
send_fmt = "Title:Modbus_TX Slave:H1 Func:H2 Addr:U3-4 Count:U5-6 CRC:crc16-modbus_le"
expect = "01 03 0A 05 1B 05 28 05 35 05 42 05 4F 8C 46"
expect_fmt = "Title:Modbus_Response Slave:H1 Func:H2 Bytes:U3 R0:U4-5 R1:U6-7 R2:U8-9 R3:U10-11 R4:U12-13 CRC:crc16-modbus_le"

Inline format spec — decoded Modbus columns in proto debug screen

Finally here is the serial log output of a protocol test:

================================================================================
[2026-03-12 22:28:14] Script: Demo AT Command Test | Tests: 4 | Repeat: 1
================================================================================
  [PASS]  AT basic
         TX:  41 54 0D
         EXP: 4F 4B 0D 0A
         RX:  4F 4B 0D 0A
         Time: 77ms
         [Hex] TX spec: Hex:h1-*
         [Hex] TX: Hex=41 54 0D
         [Hex] RX spec: Hex:h1-*
         [Hex] RX: Hex=4F 4B 0D 0A
  [PASS]  LED on
         TX:  41 54 2B 4C 45 44 20 6F 6E 0D
         EXP: 4F 4B 0D 0A
         RX:  4F 4B 0D 0A
         Time: 128ms
         [Hex] TX spec: Hex:h1-*
         [Hex] TX: Hex=41 54 2B 4C 45 44 20 6F 6E 0D
         [Hex] RX spec: Hex:h1-*
         [Hex] RX: Hex=4F 4B 0D 0A
  [PASS]  LED off
         TX:  41 54 2B 4C 45 44 20 6F 66 66 0D
         EXP: 4F 4B 0D 0A
         RX:  4F 4B 0D 0A
         Time: 99ms
         [Hex] TX spec: Hex:h1-*
         [Hex] TX: Hex=41 54 2B 4C 45 44 20 6F 66 66 0D
         [Hex] RX spec: Hex:h1-*
         [Hex] RX: Hex=4F 4B 0D 0A
  [PASS]  Unknown command
         TX:  49 4E 56 41 4C 49 44 0D
         EXP: 45 52 52 4F 52 3A 20 55 6E 6B 6E 6F 77 6E 20 63 6F 6D 6D 61 6E 64 20 27 49 4E 56 41 4C 49 44 27 0D 0A
         RX:  45 52 52 4F 52 3A 20 55 6E 6B 6E 6F 77 6E 20 63 6F 6D 6D 61 6E 64 20 27 49 4E 56 41 4C 49 44 27 0D 0A
         Time: 72ms
         [Hex] TX spec: Hex:h1-*
         [Hex] TX: Hex=49 4E 56 41 4C 49 44 0D
         [Hex] RX spec: Hex:h1-*
         [Hex] RX: Hex=45 52 52 4F 52 3A 20 55 6E 6B 6E 6F 77 6E 20 63 6F 6D 6D 61 6E 64 20 27 49 4E 56 41 4C 49 44 27 0D 0A
Summary: 4/4 PASS (4 tests)

CRC algorithms

Every CRC algorithm in the reveng catalogue is built in: 62 of them, with full parameterization (poly, init, refin, refout, xorout) and each one verified against its catalogue check value in the test suite. If you need a CRC and it has a name, termapy already has it, correctly. Browse with /proto.crc.list, inspect with /proto.crc.help <name>, compute with /proto.crc.calc. You can also generate standalone C, Python, or Rust source for any of them with /proto.crc.python, /proto.crc.c, /proto.crc.rust so you never have to port one by hand again.

Demo mode — simulated device for trying everything without hardware

termapy --demo launches a completely simulated COM port, no hardware needed. The simulated device (BASSOMATIC-77, the natural successor to Dan Aykroyd's '76) responds to AT commands, NMEA/GPS sentences, and binary Modbus RTU frames, so you can exercise every termapy feature: serial I/O, scripting, protocol testing, and plugins.

The demo exists for two reasons: people can evaluate termapy without owning hardware, and the test suite has a deterministic target it can drive end-to-end through the same code paths a real device would.

Note: The demo command sets (AT, NMEA, Modbus) are not validated protocol implementations. They simulate familiar interfaces so you can explore termapy's features without hardware.

ASCII commands

The device supports a full AT command set. Type commands and get responses just like a real device:

Command Description
AT Connection test (returns OK)
AT+PROD-ID Product identifier (returns BASSOMATIC-77)
AT+INFO Device info (version, uptime, free memory)
AT+TEMP Read temperature sensor
AT+LED on|off Control LED
AT+NAME? Query device name
AT+NAME=val Set device name (max 32 chars)
AT+BAUD? Query baud rate
AT+BAUD=val Set baud rate (9600, 19200, 38400, 57600, 115200)
AT+STATUS Device status (LED, uptime, connections)
AT+RESET Reset device (simulates boot sequence)
mem <addr> [len] Hex memory dump (deterministic, max 256 bytes)
help List all commands

GPS / NMEA commands

The device responds to standard NMEA queries and PMTK configuration commands. Position is fixed at the 50-yard line of Lumen Field, Seattle.

Command Description
$GPGGA Position fix (lat, lon, altitude, satellites)
$GPRMC Recommended minimum nav (pos, speed, date)
$GPGSA DOP and active satellites
$GPGSV Satellites in view (elevation, azimuth, SNR)
$PMTK220,1000 Set update rate (acknowledged, no effect)
$PMTK314,... Configure sentence output (acknowledged)

Binary protocol testing

The device also speaks Modbus RTU (binary), so you can try protocol test files and visualizers. Use /proto.send with hex bytes (CRC included):

/proto.send 01 03 00 00 00 01 84 0A       # read 1 register from addr 0
/proto.send 01 06 00 05 04 D2 1B 56       # write register 5 = 1234
/proto.send 01 03 00 05 00 01 94 0B       # read back register 5

Modbus RTU supports function 0x03 (read holding registers) and 0x06 (write single register) with CRC16 enforced.

Bundled scripts, tests, and plugins

The demo comes with everything wired up so you can try each feature:

  • Scripts: at_demo.run, smoke_test.run, status_check.run. Run via the Scripts button or /run.
  • Proto test files: at_test.pro, bitfield_inline.pro, modbus_inline.pro. Run via the Proto button for pass/fail results.
  • Plugins: /probe sends a command sequence and reports results; /cmd adds a custom shortcut.
CLI mode — plain-text terminal, no TUI

termapy --cli runs a plain-text serial terminal in your existing terminal window. No Textual UI, no mouse, just keyboard input and text output. The TUI is the intended way to use termapy, but the CLI exists for two cases: people who don't want a TUI (you know who you are), and pipelines that want to feed termapy's output into a file or another tool. The engine doesn't care which frontend it's running under; the TUI and CLI share the same ReplEngine, SerialEngine, plugins, scripts, and configs.

termapy --cli my_device              # interactive terminal
termapy --cli --demo                 # demo device, no hardware needed
termapy --cli smoke_test.run         # run a .run script and exit
termapy --cli my_device --no-color   # strip ANSI color codes

Passing a .run file to --cli automatically infers the config from the file's location and runs it. Passing a config name or path opens an interactive session.

Features:

  • Rich colored output (toggle with /color on|off or --no-color)
  • Command history shared with TUI (up/down arrows, persisted across sessions)
  • Tab completion for REPL commands
  • Script execution with /run (same scripts work in both TUI and CLI)
  • /delay with progress bar for waits over 3 seconds (Ctrl+C to cancel)
  • All /port, /cfg, /var, /env, /proto.crc, /edit commands work

TUI-only features (not available in CLI mode):

  • /ss.svg, /ss.txt - screenshots (prints "not supported" message)
  • /grep - scrollback search (no scrollback buffer in CLI)
  • /edit.cfg - opens in system editor instead of built-in config editor
  • Mouse interaction, modal dialogs, custom buttons

Exit: /exit, /quit, or Ctrl+C.

Extending termapy — plugins, subcommands, visualizers

Plugins

Every built-in command (/help, /cfg, /grep, all of them) is itself a plugin loaded from the same folder you'd drop your own into. If something was hard to build as a plugin, the API was wrong. Dogfooding all the way down.

Add custom REPL commands by dropping a .py file in a plugin folder. No classes to subclass, no registration:

# hello.py — drop into termapy_cfg/plugin/ or termapy_cfg/<config>/plugin/
from termapy.plugins import Command, PluginContext

def _handler(ctx: PluginContext, args: str):
    name = args.strip() or "world"
    ctx.write(f"Hello, {name}!")

# ── COMMAND (must be at end of file) ──────────────────────────────────────────
COMMAND = Command(
    name="hello",
    args="{name}",        # {braces} = optional, <angle> = required, "" = no args
    help="Say hello.",
    handler=_handler,
)

Plugin locations (loaded in order, later overrides earlier):

  1. Built-in: shipped with termapy, always available
  2. Global: termapy_cfg/plugin/*.py, shared across all configs
  3. Per-config: termapy_cfg/<name>/plugin/*.py, specific to one config
  4. App hooks: frontend-specific commands (/ss, /delay, /run, etc.)
Subcommands

Use sub_commands for related operations. Users invoke them with dot notation (/tool.run):

from termapy.plugins import Command

def _run(ctx, args):
    ctx.write(f"Running {args}...")

def _status(ctx, args):
    ctx.write("All good.")

# ── COMMAND (must be at end of file) ──────────────────────────────────────────
COMMAND = Command(
    name="tool",
    help="A tool with subcommands.",
    sub_commands={
        "run":    Command(args="<file>", help="Run a file.", handler=_run),
        "status": Command(help="Show status.", handler=_status),
    },
)

The user types /tool.run myfile or /tool.status.

PluginContext API

The ctx object passed to every handler:

Method / Attribute Description
ctx.write(text, color) Print to the terminal (color is optional)
ctx.write_markup(text) Print Rich markup text (e.g. [bold red]Warning![/])
ctx.cfg Current config dict (read-only access)
ctx.config_path Path to the current .cfg config file
ctx.port() The raw pyserial object, or None when disconnected
ctx.is_connected() Check if the serial port is open
ctx.log(prefix, text) Write to session log: ">" TX, "<" RX, "#" status
ctx.serial_write(data) Send bytes to the serial port (auto-logged as TX to session log)
ctx.serial_wait_idle() Wait until serial output settles
ctx.serial_read_raw() Read raw bytes with timeout framing (returns bytes)
ctx.serial_io() Context manager for exclusive serial I/O (with ctx.serial_io():)
ctx.ss_dir Screenshot directory (Path)
ctx.scripts_dir Scripts directory (Path)
ctx.confirm(message) Show Yes/Cancel dialog, return bool (scripts only)
ctx.notify(text) Show a toast notification
ctx.clear_screen() Clear the terminal output
ctx.save_screenshot(path) Save an SVG screenshot to a file
ctx.get_screen_text() Get terminal content as plain text
ctx.open_file(path) Open a file or folder in the system viewer/editor

There is also ctx.engine which exposes internal engine state (sequence counters, echo, config save, etc.). This is used by built-in commands and may change between versions, so external plugins should avoid it.

Example plugins

See examples/plugins/ for working examples:

  • hello.py: minimal greeting command
  • at_test.py: send AT commands over serial
  • timestamp.py: print the current date/time
  • ping.py: send a command and measure response time

A more complete example ships with --demo: the probe.py plugin demonstrates the drain → write → read → parse cycle for device interaction. Run /help probe or /help.dev probe to see its documentation.

Binary format specs

Embedded protocols send raw bytes. Format specs decode them into human-readable fields - so you see "Temp: 200" and "CRC: OK" instead of 00 C8 ... XX XX. Used in protocol testing (.pro files), data capture (/cap.struct, /cap.hex), and the proto debug screen.

A format spec is a one-line definition of your packet layout. Each field has a name, a type, and a byte range:

"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 using your spec, 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
Supported types
Code Meaning Example Output
H Hex bytes H1, H3-4 0A, 01FF
U Unsigned integer U1, U3-4 10, 256
I Signed integer I1, I3-4 -1, +127
S ASCII string S5-12 Hello...
F IEEE 754 float F1-4 3.14
B Bit field B1.3, B1-2.7-9 1, 5
_ Padding (hidden) _:_3-4 (skipped)
crc* CRC verify CRC:crc16m_le pass/fail

Integers support 1, 2, 3, 4, and 8 byte widths. Floats are 4-byte (F32) or 8-byte (F64).

Endianness

Byte order in the spec IS the endianness - no 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).

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_le"

Decodes: 01 03 04 00 C8 01 F4 XX XX -> 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 bit flags (status byte with packed bits):

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

Decodes byte 4 into individual bit fields: MotorOn=1, AlarmHi=0, AlarmLo=0, Mode=3

Simple checksum (not CRC - custom sum):

"Header:H1 Payload:H2-9 Sum:H10"

For non-standard checksums, add a CRC plugin (3 lines of Python):

NAME = "sum8"
WIDTH = 1

def compute(data: bytes) -> int:
    return sum(data) & 0xFF

Drop into builtins/crc/ or termapy_cfg/<name>/crc/.

CRC support

62 built-in algorithms covering CRC-8, CRC-16, CRC-32 families (Modbus, XMODEM, CCITT, USB, and more).

In format specs, CRC columns verify data integrity automatically:

  • CRC:crc16-modbus_le - little-endian Modbus CRC-16
  • CRC:crc16-xmodem_be - big-endian XMODEM CRC-16
  • CRC:crc8-maxim - 1-byte CRC (no endianness needed)

From the REPL:

  • /proto.crc.list - show all 62 algorithms
  • /proto.crc.help crc16-modbus - show parameters
  • /proto.crc.calc crc16-modbus 01 03 00 00 00 0A - compute CRC
Portability

Developed and tested on Windows. Basic usage verified on macOS (serial, ANSI rendering, screenshots). macOS support is alpha until further testing. Linux is exercised by GitHub Actions CI on every push (the full test suite, including the CLI gold-standard integration test, runs on Ubuntu against Python 3.11 through 3.14), but interactive use on Linux has not been hand-verified by the author.

Architecture — threading model

Textual runs on a single async event loop. Termapy uses @work(thread=True) for blocking operations, posting UI updates via call_from_thread().

Worker Lifetime Purpose
read_serial() Long-lived Reads serial data in a loop, posts lines to the RichLog
_auto_reconnect() Short-lived Retries serial connection every 2.5s until success
_run_lines() Short-lived Sends multiple commands with inter-command delay
_run_script() Short-lived Executes a .run script file line by line
_send_test() Short-lived Runs a single protocol test case (send/receive/match)
_run_cmds() Short-lived Sends setup/teardown commands for protocol tests

Only read_serial() is long-lived. At most two workers run concurrently: the serial reader plus one command/script/test worker.

Test coverage — 1505 tests, 67% overall

1505 tests across 32 test files. Run with uv run pytest.

Core logic (serial engine, capture, REPL, protocol, config):

Module Coverage Test file
defaults.py 97% test_defaults.py
scripting.py 97% test_scripting.py
migration.py 98% test_migration.py
plugins.py 94% test_plugins.py
capture.py 92% test_capture.py
serial_engine.py 93% test_serial_engine.py
serial_port.py 92% test_serial_port.py
protocol.py 89% test_protocol.py
config.py 87% test_app_config.py
port_control.py 80% test_port_control.py
repl.py 72% test_engine.py, test_repl_cfg.py
demo.py 73% test_demo.py
cli.py 53% test_cli.py

Built-in plugins: 15 of 18 plugins tested via mock PluginContext in test_builtins.py.

UI code: app.py (~3900 lines), proto_debug.py (~1150 lines), and dialogs.py (~1700 lines) are Textual UI and tested manually. The 67% overall figure reflects these large untested UI files. Core logic coverage is higher; the focus has been on extracting business logic into testable modules and keeping UI as thin delegation.

Continuous integration — GitHub Actions

All tests run automatically on push to main and on pull requests via GitHub Actions.

Job What it does
test Runs pytest across Python 3.11, 3.12, 3.13, and 3.14
coverage Runs pytest --cov on Python 3.14 and uploads to Codecov
audit Runs pip-audit to check for known vulnerabilities in dependencies

The CI badge at the top of this README reflects the current status of the test workflow. See .github/workflows/tests.yml for the full configuration.


Built with heavy use of Claude. For how that worked out, see On AI assistance.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

termapy-0.58.0.tar.gz (1.1 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

termapy-0.58.0-py3-none-any.whl (1.2 MB view details)

Uploaded Python 3

File details

Details for the file termapy-0.58.0.tar.gz.

File metadata

  • Download URL: termapy-0.58.0.tar.gz
  • Upload date:
  • Size: 1.1 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for termapy-0.58.0.tar.gz
Algorithm Hash digest
SHA256 67f4dbd8fb5038c011835503e0ca435e73ae6f9d314f2a733de62af4542b32fb
MD5 7d58c9c6e55dd9dfc0c7b58d79b03f7b
BLAKE2b-256 2423f4810b6929d712c70cb441c05ac7f609204fc0960cf6c72bf91f723cdb4b

See more details on using hashes here.

File details

Details for the file termapy-0.58.0-py3-none-any.whl.

File metadata

  • Download URL: termapy-0.58.0-py3-none-any.whl
  • Upload date:
  • Size: 1.2 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for termapy-0.58.0-py3-none-any.whl
Algorithm Hash digest
SHA256 697dbf3f24b8f5b5be963ea637ca9be8f88a58bd388edc514afc01fdce5b027f
MD5 6cbff64870e1afb1a2a2e03fe7a092fb
BLAKE2b-256 c6dfd9ce5db9b6f59bef2fe7969064439d18375009f85665c5828471c35a0ced

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page