USB CDC Communication with STM32 for GPS Data Streaming: Implementing a Virtual COM Port in Python

The PLFM Radar project demonstrates USB CDC communication with STM32 microcontrollers by implementing a virtual COM port driver in Python that parses both text and binary GPS frames from a separate USB channel, independent of high-speed radar data.

The PLFM_RADAR repository by NawfalMotii79 showcases a production-ready architecture for streaming GPS and IMU data from an STM32F746xx microcontroller to a host PC. Unlike the radar data path—which uses a dedicated FT2232H/FT601 USB chip—the GPS telemetry travels over the STM32's built-in USB peripheral configured as a CDC (Communications Device Class) device. This design pattern appears in many robotics and remote sensing applications where multiple USB endpoints serve different data rates.

STM32 USB CDC Architecture in the PLFM Radar System

The firmware-to-host pipeline separates concerns across five distinct layers. Understanding this layering helps developers adapt the pattern to their own STM32 projects.

Layer Component Key File
Firmware (STM32) NMEA-style text frames or compact binary packets External MCU firmware
USB CDC Driver STM32USBInterface class hardware.py
Packet Parser USBPacketParser.parse_gps_data() processing.py
Background Worker GPSDataWorker QThread workers.py
GUI Consumer Dashboard signal handlers dashboard.py

The STM32 enumerates as a standard USB serial device using VID/PID pairs recognized by STM32USBInterface.list_devices() (lines 57-64 in hardware.py). This eliminates the need for custom drivers on Windows, Linux, or macOS.

USB CDC Driver Implementation in hardware.py

The STM32USBInterface class in 9_Firmware/9_3_GUI/v7/hardware.py wraps pyusb to expose a clean synchronous API for reading GPS byte streams.

Device Enumeration and Connection

from v7.hardware import STM32USBInterface

usb = STM32USBInterface()
devices = usb.list_devices()  # Returns list of (bus, address) tuples

if not devices:
    raise RuntimeError("No STM32 CDC device found")

usb.open_device(devices[0])   # Claims interface, detaches kernel driver

The open_device() method performs three critical setup steps (lines 9-44):

  1. Detects IN/OUT endpoint addresses from the interface descriptor
  2. Detaches the kernel CDC-ACM driver to enable userspace access
  3. Sets the device configuration and claims the interface

Reading Raw GPS Bytes

Once open, read_data(size=64, timeout=1000) pulls packets from the bulk IN endpoint:

while True:
    raw = usb.read_data()  # Returns bytes or None

    if raw:
        process_buffer(raw)

The 64-byte default matches the full-speed USB maximum packet size for bulk transfers—an efficient choice for 10-20 Hz GPS updates.

GPS Packet Parsing: Text and Binary Formats

The USBPacketParser class in processing.py handles two STM32 firmware output modes, switching automatically based on the frame prefix.

Text Format: NMEA-Style Frames

Text frames begin with b"GPS:" followed by comma-separated float values and a CRLF terminator:


GPS:34.052235,-118.243685,89.2,5.3\r\n

The parser splits on commas (lines 405-410):

from v7.processing import USBPacketParser

parser = USBPacketParser()
gps = parser.parse_gps_data(b"GPS:34.052235,-118.243685,89.2,5.3\r\n")

# Returns GPSData(latitude=34.052235, longitude=-118.243685, 

#                altitude=89.2, pitch=5.3)

Binary Format: Compact 30-Byte Packets

For bandwidth-constrained scenarios, the STM32 can emit binary frames prefixed with b"GPSB":

Offset Field Type
0-3 Magic b"GPSB" bytes
4-7 Latitude (float32) IEEE 754
8-11 Longitude (float32) IEEE 754
12-15 Altitude (float32) IEEE 754
16-19 Pitch (float32) IEEE 754
20-23 Reserved -
24-27 Timestamp (uint32) ms since boot
28-29 CRC16-CCITT -

Parsing logic at lines 418-426 unpacks with struct and validates the CRC.

Threaded Data Acquisition with GPSDataWorker

GUI applications must never block the main thread on USB I/O. The GPSDataWorker class in workers.py implements a producer-consumer pattern using Qt's signal-slot mechanism.

Running the Worker

from v7.workers import GPSDataWorker
from v7.models import GPSData

worker = GPSDataWorker(
    radar_position=GPSData(latitude=0, longitude=0, altitude=0, pitch=0)
)
worker.gpsReceived.connect(lambda gps: print(f"GPS update: {gps.latitude}, {gps.longitude}"))
worker.start()  # Launches QThread, begins polling loop

The worker's run() method (lines 241-251):

  1. Opens the USB device if not already connected
  2. Loops indefinitely calling read_data()
  3. Parses complete packets via USBPacketParser
  4. Emits gpsReceived(GPSData) for each valid frame
  5. Handles USBError exceptions with automatic reconnection

Integrating GPS Data with Radar Processing

GPS coordinates enable geographic registration of radar detections. The PLFM Radar project applies pitch correction and polar-to-geographic transformation using utilities in processing.py.

Pitch Compensation for Elevation Accuracy

from v7.processing import apply_pitch_correction

def get_true_elevation(raw_radar_elevation, gps):
    """Adjust radar depression angle for platform pitch."""
    return apply_pitch_correction(raw_radar_elevation, gps.pitch)

The apply_pitch_correction function accounts for antenna mounting angles when the STM32-mounted IMU reports platform attitude.

Full Coordinate Transformation

Radar targets in range-doppler space convert to latitude/longitude through:

  • polar_to_geographic() — converts (range, azimuth, elevation) relative to GPS position
  • Dashboard._on_gps_received() — propagates fresh GPS data to the processing pipeline (lines 1689-1911 in dashboard.py)

Standalone Usage Without the GUI

The modular design supports headless operation for logging or server deployments:

import time
from v7.hardware import STM32USBInterface
from v7.processing import USBPacketParser, GPSData

usb = STM32USBInterface()
usb.open_device(usb.list_devices()[0])

parser = USBPacketParser()

with open("gps_log.csv", "w") as f:
    f.write("timestamp,lat,lon,alt,pitch\n")
    
    while True:
        raw = usb.read_data(timeout=5000)
        if raw:
            gps: GPSData = parser.parse_gps_data(raw)
            if gps:
                f.write(f"{time.time()},{gps.latitude},{gps.longitude},"
                        f"{gps.altitude},{gps.pitch}\n")
                f.flush()

This pattern demonstrates USB CDC communication with STM32 microcontrollers in minimal Python scripts without Qt dependencies.

Key Files and Their Responsibilities

File Path Purpose
hardware.py 9_Firmware/9_3_GUI/v7/hardware.py STM32USBInterface — USB device enumeration, endpoint I/O
processing.py 9_Firmware/9_3_GUI/v7/processing.py USBPacketParser, coordinate transformations
workers.py 9_Firmware/9_3_GUI/v7/workers.py GPSDataWorker — threaded acquisition with Qt signals
models.py 9_Firmware/9_3_GUI/v7/models.py GPSData dataclass definition
dashboard.py 9_Firmware/9_3_GUI/v7/dashboard.py GUI signal consumers, status display
test_v7.py 9_Firmware/9_3_GUI/v7/test_v7.py Unit tests for parser text/binary roundtrips

Summary

  • USB CDC with STM32 enables driver-free virtual COM ports across all major operating systems, as implemented in the PLFM Radar project's STM32USBInterface class.
  • The architecture separates high-speed radar data (FTDI chip) from low-rate GPS telemetry (STM32 native USB) to prevent buffer underruns.
  • Dual-format parsing—both text and binary—accommodates debugging and production modes without code changes.
  • Thread-safe design via GPSDataWorker allows integration with PyQt GUIs while maintaining responsive UIs.
  • Geographic processing pipelines consume GPSData objects to correct radar detections for platform position and attitude.

Frequently Asked Questions

How do I identify the correct STM32 USB device if multiple serial ports are present?

The STM32USBInterface.list_devices() method filters by vendor and product identifiers hardcoded in hardware.py (lines 57-64). For custom hardware, modify the VID_PID_LIST constant or pass a custom filter to find_device():

custom = usb.find_device(custom_vid=0x0483, custom_pid=0x5740)

Why does USB CDC read return empty bytes instead of blocking?

Full-speed USB bulk endpoints return zero-length packets when no data is available. The PLFM implementation treats empty reads as non-fatal and continues polling. For blocking behavior, increase the timeout parameter in read_data() or use pyusb's synchronous API directly.

Can I use this code with CircuitPython or Arduino-based CDC devices?

Yes, with modifications. The STM32USBInterface class assumes standard CDC-ACM descriptors. For non-STM32 devices, verify that your firmware presents the correct interface class (0x02) and subclass (0x02) codes. The parsing layer in USBPacketParser is hardware-agnostic.

How is GPS synchronization maintained with radar data timestamps?

The GPSData dataclass includes a timestamp field populated from the STM32's internal millisecond counter. Cross-correlation with radar frames uses this field plus host-side arrival time interpolation, as documented in the RadarTarget merge logic within processing.py.

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 →