Tracer Docs

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:

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.

  1. Settings → Scripting API
  2. Set Local WebSocket server to Enabled
  3. 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


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.