A host-side validation harness for UART motor protocols. It is written in Python and does not get cross-compiled.
The host sits between the camera board and the PTZ motor board, with one USB-UART adapter on each board. uart-bridge:
- forwards bytes both ways;
- logs every byte with a timestamp;
- decodes frames live;
- can stand in for the camera (
inject); - compares captures (
diff).
The goal is to prove that our motor-control implementation drives the PTZ board the same way the stock firmware does.
camera board TX/RX ── /dev/ttyUSB0 ─┐
host: uart-bridge ── captures/*.jsonl
PTZ board TX/RX ── /dev/ttyUSB1 ─┘
uv run pytest # pty-based tests, no hardware needed
uv run uart-bridge bridge --note "idle" # forward + log; Ctrl-C to stop
uv run uart-bridge decode captures/<file>.jsonl # annotated timeline (--all: every frame)
uv run uart-bridge diff stock.jsonl ours.jsonl # exit 1 on divergence
uv run uart-bridge inject --replay stock.jsonl # host acts as camera, replays c2p + h2p (stop the bridge first)
uv run uart-bridge inject --frame a52e9eea2662efae --rate 20 --duration 2bridge --cam ptycreates a pseudo terminal in place of the camera port and prints its path once forwarding has started. A local program under test (for examplexm-uart-motors-host -d /dev/pts/N) then plays the camera against the real PTZ board, and every byte is logged.scripts/xm_uart_audit.py tooldoes this with a scripted key sequence.- Ports can also be pyserial URLs such as
socket://host:9000, for exampleinject --ptz socket://cam:9000 --replay stock.jsonlto replay a recording to another camera's lens board throughxm-uart -l. bridge --tee URLalso sends every whole camera frame to URL, logged asc2t, and logs what comes back ast2c. It never forwards those replies to the camera. The boot console and other junk are not teed.uart-bridge boards A [B]compares where two lens boards settled: A'sp2cagainst A'st2c, or against B'sp2c. It exits 1 on a mismatch beyond--tolerance.- TWIN.md is the full procedure for comparing two cameras' lens boards this way.
bridge --mute-camlogs the camera's bytes but forwards none of them, so the PTZ board hears nothing from the camera. That separates what the board does on its own from what the camera makes it do; it showed that the lens board re-homes by itself at power-up (seexm-uart/PROTOCOL.md, Power-up behaviour).- The bridge never leaves a partial frame on the PTZ wire. The XM board starts an 8-byte frame at any
A5/C5byte and has no inter-byte timeout (seexm-uart/PROTOCOL.md), so a stray byte silently eats the next command. Forwarding of camera bytes therefore starts only after a quiet gap on the camera line; bytes before it are logged as a mark, not forwarded. On exit, the bridge finishes forwarding the frame in flight. - While
bridgeruns, each line on stdin is stored in the log as a timestamped mark, for examplepan left pressed. A line of the form!<hex>is sent to the PTZ board as a probe and logged ash2p. A probe waits for the end of any camera frame in flight, so it never splices into one. If the camera stops mid-frame for 1 s, the probe goes out anyway and the stall is logged as a mark. For example, xm-uart's zoom-in then stop:( sleep 2; echo '!c50100200000215c'; sleep .5; echo '!c50100000000015c'; sleep 2 ) | uv run uart-bridge bridge --duration 6 bridgeandinjectopen the ports exclusively and set the FTDI latency timer to 1 ms through sysfs, using--latency(0 leaves it alone). The default is 16 ms, which makes every timestamp late by up to 16 ms.
Both FT232R adapters report the same serial number (A5069RR4), so /dev/serial/by-id shows only one of them. When ttyUSBn numbering matters, use the by-path names:
- camera:
/dev/serial/by-path/pci-0000:00:14.0-usb-0:3:1.0-port0 - PTZ board:
/dev/serial/by-path/pci-0000:00:14.0-usb-0:4:1.0-port0
The first line is a header: {"type":"header","version":1,"wall":...,"git":...,"mode":...}, followed by the ports, baud rates and your note. After that there is one record per read():
{"t": <ns since start>, "d": "c2p" | "p2c" | "h2p", "x": "<hex>"}
{"t": <ns since start>, "d": "mark", "note": "..."}
c2p is camera → PTZ board, p2c is PTZ board → camera, and h2p is a probe the host injected. Record boundaries are read boundaries, not frame boundaries.
The traffic is split into two streams: what the PTZ board received (c2p and h2p together, since a probe and a camera command reach the board the same way) and what it answered (p2c). Each stream is framed and run-length encoded by codec.key() into segments. The segment sequences are aligned with difflib, and the diff reports:
- insert / delete / replace: segments present in only one capture, including at either end.
- count: a segment repeats more than
--toleranceframes more or less often. - span: a segment lasts more than
--time-tolerancems longer or shorter. - gap: a segment starts more than
--time-tolerancems earlier or later after the previous one, which catches a command sent late.
A capture's first and last segment is cut by the capture itself, so its count and span are not compared.
By default an A5 frame is compared only as the class a5. Its bytes follow a per-second counter whose phase depends on when the camera booted, and the scramble is not decoded yet. The diff therefore checks that the stream is there and keeps its cadence, not what it carries. --strict-a5 compares bytes 0–6. Combine it with --ignore-edges for two captures whose counters run in step. Other frames (Pelco commands, PTZ replies) are always compared byte for byte.
The XM camera ↔ lens board protocol is specified in xm-uart/PROTOCOL.md, measured with this tool. codec.py decodes what that spec establishes. The recordings behind it are in xm-uart/captures/; e1-stock.jsonl is the stock firmware reference to diff an implementation against.
scripts/xm_uart_audit.py re-runs the experiments on a rig:
stock: the stock firmware, driven through DVRIP with python-dvr;accept: which frame variants the board acts on, and the partial-frame trap;tool <binary>: a program under test through the pty;focus: RTSP sharpness;focusdir: which focus bit moves focus nearer, by sweeping focus past near and far targets whose depth order is known from occlusion;restore/refocus: put the lens back afterwards;sync: drive two lenses (the second one through--openipc socket://…) into the wide end stop;twin: a live tee of a stock DVRIP session to a second board, with sharpness on both;focusoffset: where the sharpest focus is relative to a board's tracked focus.
Run uv run scripts/xm_uart_audit.py -h for the options.
scripts/xm_tracking.py measures the board's own zoom tracking on one or more
boards at once (--board NAME PTZ RTSP, repeatable): focus while and after a zoom,
where it settles against the sharpest point, combined and interleaved zoom/focus
frames, backlash, and whether the camera's A5 stream feeds an autofocus loop. The
results are in xm-uart/PROTOCOL.md, "Zoom tracking inside the board".