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):
- Detects IN/OUT endpoint addresses from the interface descriptor
- Detaches the kernel CDC-ACM driver to enable userspace access
- 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):
- Opens the USB device if not already connected
- Loops indefinitely calling
read_data() - Parses complete packets via
USBPacketParser - Emits
gpsReceived(GPSData)for each valid frame - Handles
USBErrorexceptions 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 positionDashboard._on_gps_received()— propagates fresh GPS data to the processing pipeline (lines 1689-1911 indashboard.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
STM32USBInterfaceclass. - 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
GPSDataWorkerallows integration with PyQt GUIs while maintaining responsive UIs. - Geographic processing pipelines consume
GPSDataobjects 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →