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

> Learn USB CDC communication with STM32 for GPS data streaming. Implement a virtual COM port in Python to parse GPS frames independently of radar data. Explore the PLFM Radar project.

- Repository: [NawfalMotii79/PLFM_RADAR](https://github.com/NawfalMotii79/PLFM_RADAR)
- Tags: how-to-guide
- Published: 2026-08-20

---

**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`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/hardware.py) |
| **Packet Parser** | `USBPacketParser.parse_gps_data()` | [`processing.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/processing.py) |
| **Background Worker** | `GPSDataWorker` QThread | [`workers.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/workers.py) |
| **GUI Consumer** | `Dashboard` signal handlers | [`dashboard.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/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`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/hardware.py)). This eliminates the need for custom drivers on Windows, Linux, or macOS.

## USB CDC Driver Implementation in [`hardware.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/hardware.py)

The `STM32USBInterface` class in [`9_Firmware/9_3_GUI/v7/hardware.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/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

```python
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:

```python
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`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/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):

```python
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`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/workers.py) implements a **producer-consumer pattern** using Qt's signal-slot mechanism.

### Running the Worker

```python
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`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/processing.py).

### Pitch Compensation for Elevation Accuracy

```python
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`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/dashboard.py))

## Standalone Usage Without the GUI

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

```python
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`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/hardware.py) | [`9_Firmware/9_3_GUI/v7/hardware.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/9_Firmware/9_3_GUI/v7/hardware.py) | `STM32USBInterface` — USB device enumeration, endpoint I/O |
| [`processing.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/processing.py) | [`9_Firmware/9_3_GUI/v7/processing.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/9_Firmware/9_3_GUI/v7/processing.py) | `USBPacketParser`, coordinate transformations |
| [`workers.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/workers.py) | [`9_Firmware/9_3_GUI/v7/workers.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/9_Firmware/9_3_GUI/v7/workers.py) | `GPSDataWorker` — threaded acquisition with Qt signals |
| [`models.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/models.py) | [`9_Firmware/9_3_GUI/v7/models.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/9_Firmware/9_3_GUI/v7/models.py) | `GPSData` dataclass definition |
| [`dashboard.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/dashboard.py) | [`9_Firmware/9_3_GUI/v7/dashboard.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/9_Firmware/9_3_GUI/v7/dashboard.py) | GUI signal consumers, status display |
| [`test_v7.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/test_v7.py) | [`9_Firmware/9_3_GUI/v7/test_v7.py`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/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`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/hardware.py) (lines 57-64). For custom hardware, modify the `VID_PID_LIST` constant or pass a custom filter to `find_device()`:

```python
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`](https://github.com/NawfalMotii79/PLFM_RADAR/blob/main/processing.py).