DEV Community

UAV GNSS
UAV GNSS

Posted on

Debugging RTK GNSS integration: ports, baud rates and protocol direction (ArduPilot / PX4)

We build Septentrio-powered GNSS receivers at UAV GNSS — so the message "I wired it up, the port is open, and the autopilot still says no fix" is the one we get most often. In our experience it is almost never the receiver. It is a port, baud, or protocol-direction mismatch, and all three look identical from the outside.

Here is the checklist we walk integrators through, plus a small script that tells you which of those failure modes you are actually in instead of guessing.

1. Count the serial ports before you plan the build

The port count is a property of the receiver, not of the connector you plug in. Counted from the official interface maps:

Receiver GNSS engine Serial ports Other links
HB10 AsteRx-m3 Pro+ 3x UART (LVTTL, 115200 bps - 4 Mbps) USB-C, microSD, 1PPS out, EVENT in
HB59 AsteRx-m3 Pro+ 3x UART (LVTTL) 100M Ethernet, USB-C, microSD, 1PPS, EVENT
HB6 / HB6 Pro mosaic-X5 2x UART USB (4G on Pro)
EV322 / HB51 / HB52 mosaic-G5 2x UART (TTL) + I2C PPS, EVENT
HB3 (machine-control box) AsteRx-m3 Pro+ SER M12 CAN (PWR) M12 x2, Ethernet M12, 4G LTE, UHF, WiFi/BT

Two things worth internalising immediately:

These UARTs are LVTTL (3.3 V logic) at the module. They are not RS-232, RS-422 or RS-485. Feeding a true RS-232 source straight in will not work and can damage the port — use the matching level shifter on the carrier board, or an external transceiver if you genuinely need RS-232.

There is no native CAN/J1939 on the GNSS chip. CAN exists at product level (the HB3 above); on modules it is an external gateway/transceiver job. If a datasheet skim told you otherwise, you were reading the box, not the chip.

2. One port per job — the three-stream rule

Serial links usually fail because two logical jobs were pushed onto one port. Budget a port per job:

Port Job Protocol Typical rate
UART 1 Autopilot position/status NMEA 0183 out 5-10 Hz
UART 2 Corrections in (+ optional NMEA mirror out) RTCM v3 / CMR in as broadcast
UART 3 (HB10/HB59) Raw logging, ROS driver, radio SBF out 1 Hz, up to 100 Hz bursts

At absolute minimum, keep corrections on their own port. On a single-port build you have to multiplex RTCM input and NMEA output on the same UART — and a misconfiguration there looks exactly like a dead correction source, so you will spend an afternoon replacing cables for a config problem.

Dual-port wiring example:

Septentrio receiver                Flight controller (Pixhawk class)
-------------------                ---------------------------------
UART1 TX  ---------------------->  GPS1 RX
UART1 RX  <----------------------  GPS1 TX
GND       -----------------------  GND
5V        -----------------------  5V (dedicated UART 5V, not the servo rail)

Septentrio receiver                NTRIP / telemetry link
-------------------                ----------------------
UART2 RX  <--- RTCM v3 corrections ---  radio or companion TX
UART2 TX  --- NMEA mirror (opt.)  --->  radio or companion RX
Enter fullscreen mode Exit fullscreen mode

3. Baud rate: match exactly, and know the headroom

  • 115200 bps is the safe default, and what the parameter tables below assume (GPS_BAUD_RATE=9 on ArduPilot, GPS_1_BAUD=115200 on PX4).
  • AsteRx-m3 Pro+ UARTs on HB10/HB59 support up to 4 Mbps. That headroom exists for raw multi-constellation SBF logging — full-band data will not fit in 115200.
  • Change baud on one side at a time and re-verify. Changing both ends blind is how two working ports become one broken link.
  • A baud mismatch typically shows as garbage or framing errors rather than a clean "no data" — which is exactly why it gets misdiagnosed as a dead receiver.

4. Protocol direction — the silent failure

This is the one that catches experienced people:

Protocol Direction Carries Goes to
NMEA 0183 receiver -> autopilot GGA/RMC position + status GPS port
SBF receiver -> logger/computer PVTGeodetic, AttEuler, observables SD card, ROS driver, post-processing
RTCM v2/v3 base -> rover (IN) corrections rover's correction port
CMR v2.0 / CMR+ base -> rover (IN) corrections rover's correction port

NMEA and SBF are outputs you enable. RTCM/CMR is an input the rover must be told to accept. A receiver happily streaming perfect NMEA while ignoring its RTCM input will sit in Single forever with a healthy-looking data link — no error, no warning, nothing in a log to point at.

5. A script that names the fault

Guessing is what costs the afternoon, so here is a small checker. It reads the port for a few seconds and classifies what it sees: no bytes at all, bytes but nothing valid, binary SBF only, or NMEA with fix type, satellite count and age of differential corrections parsed straight out of GGA (fields 6, 7 and 13).

#!/usr/bin/env python3
"""Prove what a GNSS receiver is actually sending on a serial port.

Usage:
    python gnss_stream_check.py /dev/ttyUSB0 115200
"""
import sys
import time


def parse_nmea(data: bytes) -> dict:
    """Pure parser: summarise an NMEA byte stream (no serial port needed)."""
    out = {"bytes": len(data), "lines": 0, "gga": 0, "rmc": 0,
           "fix": None, "sats": None, "age": None, "sbf_syncs": data.count(b"\xfa\xfa")}
    for raw in data.split(b"\n"):
        line = raw.strip()
        if not line.startswith(b"$"):
            continue
        out["lines"] += 1
        kind = line[3:6]
        if kind == b"GGA":
            out["gga"] += 1
            f = line.split(b",")
            if out["fix"] is None and len(f) > 13:
                try:
                    out["fix"] = int(f[6]) if f[6] else 0        # 0 none, 1 GPS, 2 DGPS, 4 RTK fixed, 5 RTK float
                    out["sats"] = int(f[7]) if f[7] else 0
                    out["age"] = float(f[13]) if f[13] else None  # age of differential corrections (s)
                except ValueError:
                    pass
        elif kind == b"RMC":
            out["rmc"] += 1
    return out


def read_port(port: str, baud: int, seconds: float) -> bytes:
    import serial  # pyserial, only needed for live capture
    ser = serial.Serial(port, baud, timeout=1)
    buf = b""
    t0 = time.time()
    while time.time() - t0 < seconds:
        buf += ser.read(4096)
    ser.close()
    return buf


FIX_NAMES = {0: "no fix", 1: "GPS (single)", 2: "DGPS", 4: "RTK fixed", 5: "RTK float"}


def report(r: dict) -> None:
    print(f"bytes={r['bytes']} lines={r['lines']} GGA={r['gga']} RMC={r['rmc']} "
          f"SBF_syncs={r['sbf_syncs']}")
    if r["bytes"] == 0:
        print("-> No bytes at all: TX/RX swapped, wrong port, baud mismatch, "
              "or receiver output disabled on this port.")
    elif r["lines"] == 0 and r["sbf_syncs"] == 0:
        print("-> Bytes but no valid NMEA or SBF: almost always a baud mismatch.")
    elif r["gga"] == 0 and r["sbf_syncs"] > 0:
        print("-> Binary SBF only on this port. Fine for logging, but the autopilot "
              "needs NMEA on its port.")
    if r["fix"] is not None:

        print(f"-> fix={r['fix']} ({FIX_NAMES.get(r['fix'], '?')}) sats={r['sats']} "
              f"correction_age={r['age']}s")
        if r["fix"] in (0, 1) and r["age"] in (None, 0.0):
            print("   Never RTK: corrections are not reaching the receiver "
                  "(check RTCM is wired IN, not OUT).")
        elif r["age"] is not None and r["age"] > 10:
            print("   Corrections are arriving but stale (>10 s): link/caster problem, "
                  "not receiver.")


if __name__ == "__main__":
    port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB0"
    baud = int(sys.argv[2]) if len(sys.argv) > 2 else 115200
    report(parse_nmea(read_port(port, baud, 5)))
Enter fullscreen mode Exit fullscreen mode

The classifier has four outcomes. Exercised against synthetic NMEA and SBF streams, they print like this:

# healthy stream: GGA quality 4 = RTK fixed, fresh corrections
bytes=149 lines=2 GGA=1 RMC=1 SBF_syncs=0
-> fix=4 (RTK fixed) sats=18 correction_age=1.2s

# bytes arriving, but nothing valid -> baud mismatch
bytes=200 lines=0 GGA=0 RMC=0 SBF_syncs=0
-> Bytes but no valid NMEA or SBF: almost always a baud mismatch.

# receiver configured for raw logging only
bytes=56 lines=0 GGA=0 RMC=0 SBF_syncs=2
-> Binary SBF only on this port. Fine for logging, but the autopilot needs NMEA on its port.

# NMEA fine, but corrections never arrive
-> fix=1 (GPS (single)) sats=9 correction_age=None
   Never RTK: corrections are not reaching the receiver (check RTCM is wired IN, not OUT).
Enter fullscreen mode Exit fullscreen mode

That last branch is the important one. NMEA GGA fix quality is 4 = RTK fixed, 5 = RTK float, 1 = single, 0 = no fix. And a correction_age above ~10 s means corrections are arriving but are stale — a link or caster problem, not a receiver problem. Two numbers, and the whole "is it the radio or the receiver" argument is over.

6. Autopilot parameters

ArduPilot:

Parameter Value Notes
GPS_TYPE 9 Septentrio
GPS_BAUD_RATE 9 115200
GPS_RATE_MS 100 10 Hz
GPS_AUTO_CONFIG 1 Receiver configured on every boot
GPS2_TYPE / GPS2_BAUD_RATE 9 / 9 Second receiver or dual-antenna heading

PX4:

Parameter Value Notes
GPS_1_GNSS_ID 1 Septentrio
GPS_1_CONFIG TELEM2 Serial port
GPS_1_BAUD 115200 Must match receiver
GPS_1_PROTOCOL 14 Septentrio SBF/NMEA (autodetect usually works)

One driver per port. Do not point two autopilot drivers — or an autopilot plus a companion logging script — at the same UART. They fight over the stream and both look intermittent.

7. Fault isolation table

Symptom Likely cause
No data at all TX/RX swapped, wrong port, baud mismatch
Garbage / framing errors Baud or protocol mismatch on that port
3D fix but never RTK Fixed Corrections not arriving, or RTCM directed OUT instead of IN
Fixed -> float, recovers when you move Multipath (canopy, buildings, vehicle body) — antenna placement
Fix lost near power lines, fences, 4G sites In-band RF interference
Data dies at high output rate Baud too low for the stream set — reduce blocks or raise baud
Two receivers conflict / intermittent position Two drivers on one UART, or mismatched GPS2_*
Resets to defaults after power cycle Configuration not stored as boot config

8. Why a perfectly configured port can still lose the fix

A port can be flawless and the fix still drops, because the receiver's front end is being desensitised. Power lines, electric fences, 4G/5G sites and deliberate jamming all put energy in band. AIM+ on our receivers rejects roughly 40-60 dB of in-band interference versus ~25 dB typical for consumer GNSS modules, and it runs continuously with no configuration.

The tell is the shape of the failure: if C/N0 sags across many satellites at once and the drop correlates with position rather than sky view, your link is fine and the RF environment is not. If only low-elevation satellites fade and it recovers as soon as you clear the trees, that is multipath — an antenna placement problem, not a receiver one.


The full port/baud/protocol guide plus an SBF log parser and a synthetic SBF generator (handy for testing a pipeline without driving to a field) live in the repo: septentrio-gnss-integration-guide.

More on the hardware: GNSS receivers | AIM+ resilient GNSS | integration guides

How do you allocate your ports? Do you keep corrections on a dedicated UART, or multiplex RTCM and NMEA on one link — and does your correction age stay steady through a whole pass?

Top comments (0)