Writing plugins¶
Plugins are .py files that add REPL commands. Drop a file into a
plugin/ folder and it loads automatically. No compilation, no
registration, no restart.
Quick start: copy and modify¶
The fastest way to write a plugin is to copy an existing one:
- Copy
probe.pyfrom the demo plugins folder - Rename it to
your_plugin.py - Change the command name, help text, and handler logic
- Drop it into
termapy_cfg/plugin/(all configs) ortermapy_cfg/<config>/plugin/(one config)
How plugins work¶
When termapy starts, it scans the plugin/ folders for .py files.
Each file is imported and checked for a COMMAND object at module level.
If found, that command is registered in the REPL, and users can invoke it
by typing its name with the command prefix (e.g. /hello).
The COMMAND object tells termapy:
- name: what the user types to invoke it (
/name) - args: the argument syntax shown in
/help({optional}or<required>) - help: one-line description shown in
/help - handler: the Python function to call when the command runs
The handler function¶
The handler is where your plugin logic lives. It is called whenever
a user types your command in the REPL input, or when a .run script
contains your command. The handler receives two arguments:
- ctx (PluginContext): your interface to the terminal, serial port, config, and filesystem. This is the only API your plugin needs.
- args (str): everything the user typed after the command name.
For
/hello world, args is"world". For/hello, args is"".
The handler can do anything: print output, send commands to the serial device, read responses, write files, or chain other REPL commands.
Plugin file structure¶
A minimal plugin:
from termapy.plugins import Command, PluginContext
def _handler(ctx: PluginContext, args: str):
"""Called when the user types /hello."""
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
help="Say hello.",
handler=_handler,
)
The COMMAND object must be defined after all the functions it references.
Termapy looks for this specific name. If your file doesn't have a
COMMAND object, it is silently skipped.
Returning scriptable values¶
Handlers return CmdResult to indicate success or failure:
CmdResult.ok()-- success, no scriptable dataCmdResult.ok(value="...")-- success,valueis available to scriptsCmdResult.fail(msg="...")-- failure with error message
Set value= when your command produces data a script might capture.
Scripts run in quiet mode read the value field; without it they get nothing.
def _handler(ctx: PluginContext, args: str):
temp = read_temperature()
ctx.write(f"Temperature: {temp}C")
return CmdResult.ok(value=str(temp))
Examples that should set value=:
- Query commands (
/port.baud_ratereturns"115200") - State toggles (
/echo onreturns"on") - Computed values (
/proto.crc.calcreturns the CRC) - Ping timings, version strings, variable values
Examples that should not:
- Pure side-effect commands (
/cls,/edit,/cap.stop) - Commands that print multiple lines (
/cfg.configs,/help)
Dynamic help for runtime state¶
If your command owns runtime state that a user should see right on its
help page — a loaded file, an open connection, a count of cached items —
set long_help to a function instead of a string. The function takes
the PluginContext and returns a string. It's invoked at render time,
so whatever it reads from ctx.ns(...) or ctx.cfg is live.
def _dynamic_long_help(ctx):
target = ctx.ns("target_commands")
if target:
state = f"Currently loaded: {len(target)} device command(s)."
else:
state = "Currently loaded: none."
return f"""{state}
Sends a command to the device and parses the JSON response to include
command help. Use /include.reload to refresh."""
COMMAND = Command(
name="include",
help="Include device command help from JSON response.",
long_help=_dynamic_long_help, # a function, not a string
handler=_handler,
)
When the user runs /help include, the DESCRIPTION section calls this
function and the first line reflects the current state. No change to
the rendering path, no extra registration — the long_help field just
accepts either form.
Two caveats:
- Read ctx defensively. Use
ctx.ns("x").get("k", default)rather than indexing blindly. Help may be invoked at any moment, including before your plugin's state is populated. - Never raise. The renderer catches exceptions and substitutes
(dynamic help failed: <error>)so/helpnever crashes, but a noisy fallback is worse than a thoughtful default like"(not loaded)".
Reusable helpers (termapy.help_dynamic)¶
Most dynamic help lines fall into a handful of shapes, so the built-ins share a small helper module. Prefer these over hand-rolling — the output is green-on-default and uniform across every command.
from termapy.help_dynamic import (
state_line, # "Current <label> = <value>" in green
folder_line, # "<N> files in <folder>/" in green
port_status, # "Connected: COM3 @ 115200 8N1" or "Not connected"
cfg_status, # "Active cfg = demo (2 configs available)"
ns_count, # len(ctx.ns(name)), guards a missing ns
compose, # join non-empty parts with a blank line between
green, # wrap any text in green markup
)
def _long_help(ctx):
return compose(
folder_line(ctx, "run", noun="script"),
"Run a .run script from the run/ folder.",
)
compose drops empty parts, so a callable that returns "" for
"no state yet" collapses gracefully. For a single-value command
that needs no prose, you can pass the helper directly:
"baud_rate": Command(
help="Show or set baud rate.",
long_help=lambda ctx: state_line("baud rate", ctx.cfg.get("baud_rate")),
handler=_baud_handler,
),
Serial I/O pattern¶
Most plugins follow this pattern: send a command, read the response, do something with it.
def _handler(ctx: PluginContext, args: str):
if not ctx.is_connected():
ctx.write("Not connected.", "red")
return
encoding = ctx.cfg.get("encoding", "utf-8")
line_ending = ctx.cfg.get("line_ending", "\r")
with ctx.serial_io(): # suppress terminal, claim serial
ctx.serial_drain() # discard stale bytes
ctx.serial_write(f"YOUR_COMMAND{line_ending}".encode(encoding))
raw = ctx.serial_read_raw() # read response with timeout
text = raw.decode(encoding, errors="replace").strip()
ctx.write(text)
Key points:
serial_io()suppresses the normal terminal display during I/Oserial_drain()clears any leftover bytes before your commandserial_write()sends raw bytes; you add the line endingserial_read_raw()waits for a complete response (timeout-based framing)
PluginContext API reference¶
Output¶
| Method | Description |
|---|---|
ctx.write(text, color) |
Print to terminal. Color: "red", "green", "cyan", "dim", etc. |
ctx.write_markup(text) |
Print Rich markup (e.g. [bold red]Warning![/]) |
ctx.notify(text) |
Show a toast notification |
ctx.clear_screen() |
Clear the terminal |
Config¶
| Method | Description |
|---|---|
ctx.cfg |
Read-only config dict |
ctx.config_path |
Path to the .cfg file |
ctx.cfg.get("key", default) |
Read a config value |
Serial port¶
| Method | Description |
|---|---|
ctx.port() |
The raw pyserial object, or None when disconnected |
ctx.is_connected() |
True if the serial port is open |
ctx.serial_io() |
Context manager for exclusive serial access |
ctx.serial_drain() |
Discard stale bytes in the receive buffer |
ctx.serial_write(data) |
Send raw bytes (no line ending added) |
ctx.serial_read_raw() |
Read response bytes with timeout framing |
ctx.serial_wait_idle() |
Wait for ~400ms of silence |
Filesystem¶
| Method | Description |
|---|---|
ctx.ss_dir |
Screenshots directory (Path) |
ctx.scripts_dir |
Scripts directory (Path) |
ctx.proto_dir |
Protocol test scripts directory (Path) |
ctx.cap_dir |
Captures directory (Path) |
ctx.prof_dir |
Profile output directory (Path) |
Other¶
| Method | Description |
|---|---|
ctx.dispatch(cmd) |
Run a REPL or serial command |
ctx.confirm(message) |
Yes/Cancel dialog → bool (background thread only) |
ctx.open_file(path) |
Open a file or folder in the system viewer/editor |
ctx.log(prefix, text) |
Write to session log (">" TX, "<" RX, "#" status) |
Subcommands¶
Use sub_commands for related operations (e.g. /tool.run, /tool.status):
COMMAND = Command(
name="tool",
help="A tool with subcommands.",
sub_commands={
"run": Command(args="<file>", help="Run.", handler=_run),
"status": Command(help="Show status.", handler=_status),
},
)
Example plugins¶
The demo config ships with three plugins of increasing complexity:
- cmd.py: minimal. Wraps a single AT command in a custom name.
- probe.py: intermediate. Send/receive cycle with formatted output, good starting template.
- temp_plot.py: advanced. Repeated sampling, response parsing, ASCII sparkline visualization.
temp_plot.py is the best example for real-world plugin development. It shows:
- Checking connection before I/O
- Reading config for encoding and line ending
- Using
serial_io()for a multi-read loop - Parsing numeric values from device responses
- Handling edge cases (no data, invalid count)
- Rendering results with Rich markup
Run /temp_plot in demo mode to see it in action, then read the source.
Using AI coding tools¶
temp_plot.py was generated in one shot by Claude Code with full
project context. If you use an AI coding assistant with access to the
termapy source, describing what you want often produces a working
plugin on the first try. The key is that the AI can see probe.py,
the device protocol, and the PluginContext API all at once.
Without full project context, expect to iterate. The serial I/O timing and response parsing are device-specific and hard to get right from an API reference alone.
For more on how termapy itself was built with LLM tooling, see On AI assistance.