USB Communication Protocol Between Python GUI and FPGA via FT2232H Explained

The PLFM_RADAR project uses a custom byte-stream protocol over an FTDI FT2232H USB 2.0 bridge to exchange 11-byte data packets, 26-byte status packets, and 4-byte configuration commands between an onboard FPGA and a Python/PyQt GUI, with all framing and parsing logic centralized in 9_Firmware/9_3_GUI/radar_protocol.py.

The NawfalMotii79/PLFM_RADAR repository implements the AERIS-10 radar system, where a single-board FPGA handles real-time signal processing and streams raw samples to a desktop application. The USB communication protocol between Python GUI and FPGA via FT2232H defines every header, footer, and payload structure required to keep the radar acquisition pipeline synchronized. Developers who want to extend the command set, integrate alternative hardware, or debug data corruption must understand the exact packet format used in radar_protocol.py and its accompanying worker threads.

Physical Layer – FT2232H Synchronous FIFO Bridge

The interface relies on an FTDI FT2232H dual-channel USB 2.0 Hi-Speed FIFO bridge (VID 0x0403, PID 0x6010). Channel A operates in 245 Synchronous FIFO mode, providing a deterministic byte stream with minimal latency between the FPGA fabric and the host PC.

In 9_Firmware/9_3_GUI/radar_protocol.py, the FT2232HConnection class wraps the low-level USB session. The constructor exposes a mock parameter so developers can instantiate either a real hardware driver or a pure-Python test double that exposes the same read and write interface.

from radar_protocol import FT2232HConnection

# Real hardware

ft = FT2232HConnection(mock=False)
ft.open(device_index=0)

# Mock for unit tests

ft_mock = FT2232HConnection(mock=True)
ft_mock.open()

According to the PLFM_RADAR source code, the FT2232HConnection.__init__ and open methods that handle this setup are found around lines 22–55 of radar_protocol.py.

Packet Format and Protocol Constants

The protocol defines three fixed-length packet types. Their layout constants are declared near the top of radar_protocol.py (lines 38–45).

  • Data packets (FPGA → Host): 11 bytes, starting with header 0xAA and ending with footer 0x55. The payload carries range Q (2 bytes), range I (2 bytes), Doppler I (2 bytes), Doppler Q (2 bytes), and a detection flag (1 byte).
  • Status packets (FPGA → Host): 26 bytes, starting with header 0xBB and ending with footer 0x55. The payload contains six 32-bit status words in big-endian format.
  • Command packets (Host → FPGA): 4 bytes with no header or footer. The byte layout is {opcode, addr, value_hi, value_lo} in big-endian order, matching the FPGA’s usb_cmd_opcode decoder.

The shared magic bytes and sizes are exposed as module-level constants:

HEADER_BYTE = 0xAA
STATUS_HEADER_BYTE = 0xBB
FOOTER_BYTE = 0x55
DATA_PACKET_SIZE = 11
STATUS_PACKET_SIZE = 26

Parsing Data Packets

RadarProtocol.parse_data_packet (lines 78–116) validates the header and footer, slices the unsigned 16-bit fields, and converts them to signed integers via the internal _to_signed16 helper. The returned dictionary contains keys such as range_i, range_q, doppler_i, doppler_q, detection, and frame_start.

raw = ft.read(11)  # read exactly one packet

sample = RadarProtocol.parse_data_packet(raw)

print(f"Range I/Q: ({sample['range_i']}, {sample['range_q']})")
print(f"Doppler I/Q: ({sample['doppler_i']}, {sample['doppler_q']})")
print(f"Detection flag: {sample['detection']}")

Parsing Status Packets

RadarProtocol.parse_status_packet (lines 118–162) unpacks the six big-endian 32-bit words and maps them into a StatusResponse dataclass. This structure exposes operational parameters such as radar_mode, stream_ctrl, cfar_threshold, agc_* settings, and self_test_* flags.

status = RadarProtocol.parse_status_packet(raw_bytes)
print("Current radar mode:", status.radar_mode)

Building FPGA Commands

RadarProtocol.build_command (lines 66–75) packs a 4-byte command word from an Opcode enum member, an address, and a 16-bit value. The FPGA firmware decodes this word in its usb_cmd_opcode state machine.

from radar_protocol import RadarProtocol, Opcode

# Set RADAR_MODE to 0x02 (e.g., operational state)

cmd_bytes = RadarProtocol.build_command(Opcode.RADAR_MODE, value=0x02)
ft.write(cmd_bytes)

High-Level Data Flow from Raw Bytes to GUI Frames

Because the FT2232H FIFO delivers a continuous byte stream, packet boundaries must be recovered in software. RadarProtocol.find_packet_boundaries (lines 64–93) scans the receive buffer for start markers 0xAA (data) or 0xBB (status) and yields complete, validated packets.

The acquisition pipeline then follows four stages:

  1. Read: RadarAcquisition (defined in 9_Firmware/9_3_GUI/v7/hardware.py) pulls raw bytes from the bridge via conn.read(size).
  2. Frame: find_packet_boundaries extracts valid packets, and RadarAcquisition._ingest_sample assembles data samples into a 64 × 32 range-Doppler frame represented by the RadarFrame class.
  3. Queue: Completed frames are placed on a Python queue.Queue with a small backlog buffer (typically maxsize=4).
  4. Display: RadarDataWorker (in 9_Firmware/9_3_GUI/v7/workers.py, lines 22–28) dequeues the frame and emits the Qt signal frameReady, which GUI_V7_PyQt.py routes to the waterfall widget and map view.

Status packets bypass the frame builder and are forwarded through the status_callback path, eventually surfacing in the UI panel via the statusReceived signal.

The following example shows the high-level acquisition thread in action:

import queue
from radar_protocol import FT2232HConnection, RadarAcquisition

conn = FT2232HConnection(mock=True)
conn.open()

frame_q = queue.Queue(maxsize=4)
acq = RadarAcquisition(
    connection=conn,
    frame_queue=frame_q,
    recorder=None,
    status_callback=lambda s: print("Status mode:", s.radar_mode)
)
acq.start()

frame = frame_q.get()  # blocks until a full frame is ready

print("Detections in frame:", frame.detection_count)
acq.stop()
acq.join()

For PyQt integration, RadarDataWorker hides the queue polling behind Qt signals:

from workers import RadarDataWorker
from radar_protocol import FT2232HConnection

conn = FT2232HConnection(mock=True)
conn.open()

worker = RadarDataWorker(connection=conn)
worker.frameReady.connect(lambda f: print("New frame, detections:", f.detection_count))
worker.statusReceived.connect(lambda s: print("FPGA mode:", s.radar_mode))
worker.start()

# ... later ...

worker.stop()
worker.wait()

Mock Mode for Hardware-Free Development

Both FT2232HConnection and the premium-board FT601Connection support a mock mode activated by mock=True. In this mode, _mock_read (lines 99–130) returns synthetic 11-byte data packets that mimic realistic target returns near range bin 20 and Doppler bin 8. This enables continuous integration tests and UI development without physical radar hardware attached.

conn = FT2232HConnection(mock=True)
conn.open()

synthetic = conn.read(4096)  # returns injectable test packets

The mock implementation exercises the full parsing pipeline—from boundary scanning in find_packet_boundaries to frame assembly in RadarAcquisition—ensuring protocol changes are validated before hardware deployment.

Extending the Protocol

The protocol is designed for symmetric extension on both the host and FPGA sides:

  • New opcodes: Add entries to the Opcode enum in radar_protocol.py and add matching decode cases in the FPGA firmware’s usb_cmd_opcode block.
  • New status fields: Update RadarProtocol.parse_status_packet and increase STATUS_PACKET_SIZE only after the FPGA serializer emits the extra words.

Because packet parsing is isolated inside RadarProtocol, GUI code in workers.py and GUI_V7_PyQt.py rarely needs changes when the underlying command set evolves.

Summary

  • The FT2232H bridge (VID 0x0403, PID 0x6010) provides a byte-stream USB 2.0 interface using 245 Synchronous FIFO mode on Channel A.
  • 9_Firmware/9_3_GUI/radar_protocol.py defines the complete USB communication protocol between Python GUI and FPGA via FT2232H, including classes for connection management, packet parsing, and command generation.
  • Data packets are 11 bytes (0xAA header, 0x55 footer), status packets are 26 bytes (0xBB header), and commands are 4 bytes big-endian.
  • RadarProtocol.find_packet_boundaries recovers packet alignment from the raw FIFO stream before parse_data_packet or parse_status_packet converts bytes into usable structures.
  • RadarAcquisition and RadarDataWorker orchestrate the flow from raw bytes to fully formed RadarFrame objects that the PyQt GUI consumes via Qt signals.
  • A built-in mock mode allows hardware-free unit testing and rapid UI iteration by synthesizing valid packets in pure Python.

Frequently Asked Questions

What USB bridge chip does PLFM_RADAR use for FPGA-to-host communication?

The project uses the FTDI FT2232H, a dual-channel USB 2.0 Hi-Speed FIFO bridge. Channel A is configured in 245 Synchronous FIFO mode to stream raw bytes between the FPGA signal-processing pipeline and the Python host application at high throughput with low latency.

How does the Python GUI recover individual packets from the continuous FT2232H byte stream?

The GUI calls conn.read(size) inside RadarAcquisition and passes the resulting buffer to RadarProtocol.find_packet_boundaries (lines 64–93). This scanner searches for the 0xAA data header or 0xBB status header, validates the trailing 0x55 footer, and returns only complete packets. Each valid packet is then routed to parse_data_packet or parse_status_packet accordingly.

Is it possible to develop and test the PLFM_RADAR interface without physical hardware?

Yes. The FT2232HConnection class supports mock=True, which activates a pure-Python _mock_read implementation (lines 99–130). In mock mode, the connection object synthesizes realistic 11-byte packets that pass through the same framing and parsing logic as real hardware, enabling automated tests and GUI development without an attached FT2232H board.

Which source files connect the low-level USB protocol to the PyQt GUI?

The core protocol lives in 9_Firmware/9_3_GUI/radar_protocol.py. 9_Firmware/9_3_GUI/v7/hardware.py provides the high-level RadarAcquisition façade that assembles frames. 9_Firmware/9_3_GUI/v7/workers.py contains RadarDataWorker, which polls the frame queue and emits Qt signals. Finally, 9_Firmware/9_3_GUI/GUI_V7_PyQt.py consumes those signals to update the waterfall, map, and status displays.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →