Scripting API
Tracer can expose a small WebSocket server on your own machine. Connect to it from Python, Node, or anything else that speaks WebSocket, and your script can read the serial stream and write to the port while Tracer stays connected and keeps showing you everything.
Why not just use pyserial?
The reason this API exists is that serial ports are exclusive. One process owns the port — hard-locked on Windows, and messy enough on macOS and Linux that you should treat it as exclusive there too. So the moment you want a script talking to your device, you have to close your monitor. You give up the live view, the plot, the decoders, and the log, for as long as the script runs.
That is a bad trade on a bench, and it is exactly the trade this API removes. Tracer holds the port. Your script goes through Tracer.
What that gets you:
- Watch your automation run. The script sends the commands; you watch the responses land in the terminal, the plot, and the hex view, live, on the same port, at the same time.
- Every session is still recorded. The device's replies go to your vault as usual — timestamped JSONL you can search, annotate, and export afterwards. A failed test run leaves a full transcript behind without you doing anything.
- Unplugs don't kill your script. Tracer's hotplug reconnect owns the port, so a device that drops off USB and comes back is handled for you. A raw pyserial script just dies.
- No port configuration in your script. Baud rate, framing, and line endings are whatever you already set in the UI. Change them in the UI mid-run.
- No native dependencies. Any language with a WebSocket client works, with no serial bindings to compile — including a plain browser page, which cannot otherwise touch a serial port at all.
Reach for it when you are building a test rig, driving a long soak or a pass/fail sequence, or scripting a protocol you want to see while it happens. Reach for pyserial when the script is the whole job and nobody is watching.
Turning it on
The scripting API requires Pro. The server is off until you turn it on.
- Settings → Scripting API
- Set Local WebSocket server to Enabled
- Optionally change the port (default
7825, any value from 1024–65535, then press Apply)
The URL is shown with a Copy button: ws://127.0.0.1:7825
The server binds to 127.0.0.1 only. It is never reachable from your network.
When a script is connected, the Settings panel shows a live connected count.
Open the serial port in Tracer before you run your script. The API attaches
to ports Tracer already has open — it cannot open one for you, and
list_ports returns only ports that are currently open.
Quick start
pip install websockets
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:7825") as ws:
print(json.loads(await ws.recv())) # {"type": "welcome", ...}
# Which ports does Tracer have open?
await ws.send(json.dumps({"type": "list_ports"}))
ports = json.loads(await ws.recv())["ports"]
if not ports:
print("Open a port in Tracer first.")
return
port = ports[0]
# Start receiving, then send a command
await ws.send(json.dumps({"type": "subscribe", "port": port}))
await ws.send(json.dumps({"type": "send", "port": port, "data": "AT\r\n"}))
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "data":
print(msg["data"], end="")
elif msg["type"] == "error":
print("error:", msg["code"], msg["message"])
asyncio.run(main())
Watch the Tracer window while this runs. The AT command and the reply are
both there, in the terminal, in the log, in whatever view you have open.
Protocol
JSON text frames in both directions. Every message has a type.
Script → Tracer
| Message | Fields | Does |
|---|---|---|
list_ports |
— | Ask which ports Tracer currently has open |
subscribe |
port |
Start receiving data from that port |
unsubscribe |
port |
Stop receiving; discards anything buffered |
send |
port, data |
Write a string to the port |
send_bytes |
port, bytes |
Write raw bytes (array of 0–255) |
{ "type": "subscribe", "port": "cu.usbserial-1410" }
{ "type": "send", "port": "cu.usbserial-1410", "data": "reboot\r\n" }
{ "type": "send_bytes", "port": "cu.usbserial-1410", "bytes": [1, 3, 0, 5, 197, 202] }
Nothing is sent to the port for you — no line ending is appended. Put the \r\n
in data yourself.
Tracer → Script
| Message | Fields | Means |
|---|---|---|
welcome |
version |
Sent once on connect |
ports |
ports |
Reply to list_ports; names of currently open ports |
data |
port, data, hex, ts |
Serial data received from a subscribed port |
error |
code, message |
Something went wrong |
{ "type": "data", "port": "cu.usbserial-1410",
"data": "OK\r\n", "hex": "4F 4B 0D 0A", "ts": 1717171717171 }
data is the text form, hex the same bytes as uppercase space-separated hex —
use whichever suits your device. ts is a Unix timestamp in milliseconds,
taken from the first byte in the batch.
Error codes: INVALID_MESSAGE (the JSON did not parse or was not a known
message) and PORT_NOT_OPEN (Tracer has no open port by that name).
Reading responses
Incoming data is batched every 33 ms — a single data message holds
everything that arrived in that window, concatenated. This is what lets a script
keep up with a fast device instead of drowning in one message per chunk.
The consequence: data messages do not line up with your device's messages.
One data may hold several replies, and one reply may be split across two.
Accumulate into a buffer and look for your own delimiter rather than treating
each data message as a unit.
async def read_until(ws, port, terminator="\r\n", timeout=5.0):
"""Collect data messages until `terminator` shows up. Returns the buffer."""
buf = ""
async def pump():
nonlocal buf
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "data" and msg["port"] == port:
buf += msg["data"]
if terminator in buf:
return buf
elif msg["type"] == "error":
raise RuntimeError(f"{msg['code']}: {msg['message']}")
return await asyncio.wait_for(pump(), timeout)
Because of batching, gap-based framing is not recoverable from this API — if your protocol delimits messages by silence on the line rather than by bytes (Modbus RTU, for instance), use Tracer's own decoder views for that rather than reimplementing the framing in your script.
A pass/fail rig
The shape most test benches end up with — send a command, check the reply, score it, and leave a readable log behind.
import asyncio, json, sys, websockets
CHECKS = [
("version\r\n", "v2."),
("selftest\r\n", "PASS"),
("temp\r\n", "OK"),
]
async def main(port):
async with websockets.connect("ws://127.0.0.1:7825") as ws:
await ws.recv() # welcome
await ws.send(json.dumps({"type": "subscribe", "port": port}))
failures = 0
for command, expected in CHECKS:
await ws.send(json.dumps({"type": "send", "port": port, "data": command}))
try:
reply = await read_until(ws, port, "\r\n", timeout=3.0)
except asyncio.TimeoutError:
print(f"FAIL {command.strip():12} (timeout)"); failures += 1; continue
ok = expected in reply
print(f"{'PASS' if ok else 'FAIL'} {command.strip():12} {reply.strip()}")
failures += not ok
sys.exit(1 if failures else 0)
asyncio.run(main(sys.argv[1]))
Run it with the Tracer window visible and you can see each command go out and each reply come back as it happens. When it fails, the vault already has the session — open it, add a comment on the line that broke, export it.
From JavaScript
No dependency in the browser; npm install ws for Node.
const ws = new WebSocket("ws://127.0.0.1:7825");
ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" }));
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "ports" && msg.ports.length) {
const port = msg.ports[0];
ws.send(JSON.stringify({ type: "subscribe", port }));
ws.send(JSON.stringify({ type: "send", port, data: "status\r\n" }));
}
if (msg.type === "data") process.stdout.write(msg.data);
};
Limits worth knowing
- The port must already be open in Tracer. The API cannot open, close, or reconfigure a port, and it cannot see ports that are merely plugged in.
- Scripts receive, they don't observe. You get data arriving from the
device on ports you subscribed to. You do not see what was typed into Tracer's
own send box, and you do not get an echo of your own
sendcalls. - Sends are fire-and-forget. There is no acknowledgement and no request id.
If the port is not open you get a
PORT_NOT_OPENerror back; otherwise matching a reply to a command is your script's job. - A slow client loses data. If your script cannot keep up, Tracer drops batches rather than stalling the serial reader or the UI. Do your processing off the receive loop if it is expensive.
- One script is the common case, but several can connect at once. They each subscribe independently and all of them can write to the same port, so coordinate if you run more than one.
Security
The server binds to 127.0.0.1 and is not reachable from your network.
It is, however, reachable by anything running on your own machine, and it has no authentication. Turn it off when you are not using it — the toggle in Settings stops the server outright — and be aware that any local process, while it is on, can list your open ports and write to your hardware.