Every Satellite Above Me in a Terminal

A small C program that reads a USB GNSS receiver and shows every satellite it can hear, across six constellations.

Contents
  1. What the receiver actually sends
  2. Parsing NMEA without getting fooled
    1. Checksums first
    2. Empty fields are real fields
    3. Coordinates aren’t decimal degrees
  3. Satellites in view: GSV
  4. Which satellites are used: GSA and the GN problem
  5. Reading the serial port
  6. The result
  7. What’s next
  8. Further reading

I bought a 7Semi L89 USB receiver, a small GNSS dongle that tracks GPS, GLONASS, Galileo, BeiDou, QZSS and NavIC (India’s own system, formerly IRNSS) at the same time. Plug it in and most software gives you one thing: a dot on a map.

I wanted the view underneath the dot. Which satellites can it hear right now, from which system, how strong is each signal and which ones is it actually using to work out where I am? So I wrote a small program in C that answers exactly that, with no dependencies and that builds on Linux, macOS and Windows.

What the receiver actually sends#

Open the serial port and the dongle streams plain text, a few lines per second, in a format called NMEA 0183. Every line is a sentence:

$GNGGA,083015.00,3249.60000,N,07721.00000,E,1,14,0.8,4980.5,M,-32.1,M,,*64

The first two letters after $ are the talker, which says which system the sentence is about. The next three are the sentence type. Everything after that is comma-separated fields and the two hex digits after * are a checksum.

Only three sentence types matter here:

Sentence What it carries
GGA The fix: time, latitude, longitude, altitude, fix quality, number of satellites used
GSV Satellites in view: for each one, its number, elevation, azimuth and signal strength
GSA Which satellites were used in the fix, plus dilution-of-precision values

The talker tells you the constellation:

Talker System
GP GPS (USA)
GL GLONASS (Russia)
GA Galileo (EU)
GB / BD BeiDou (China)
GQ QZSS (Japan)
GI NavIC (India)
GN Combined, from more than one system

That last one, GN, becomes a problem later.

Parsing NMEA without getting fooled#

Checksums first#

Serial lines pick up garbage, especially while a device is being plugged in. The checksum is the XOR of every character between $ and *, so any line that fails it gets thrown away before anything else looks at it:

c
/* XOR of every character between '$' and '*' must equal the hex after '*' */
static int checksum_ok(const char *line)
{
    const char *star, *p;
    unsigned char cs = 0;
    unsigned int given;

    if (line[0] != '$') return 0;
    star = strchr(line, '*');
    if (!star || strlen(star) < 3) return 0;
    for (p = line + 1; p < star; p++) cs ^= (unsigned char)*p;
    if (sscanf(star + 1, "%2x", &given) != 1) return 0;
    return cs == given;
}

The program counts good and bad lines and prints both, which turns out to be a useful health check for the cable and port.

Empty fields are real fields#

NMEA leaves fields empty when it has nothing to say. A satellite that’s in view but not being tracked has an empty signal-strength field and ,, is how a GGA sentence says “no differential correction”. The obvious tool for splitting on commas, strtok, quietly merges runs of commas, which shifts every field after an empty one into the wrong slot. So the splitter is hand-written and keeps empty fields:

c
/* Split on commas, keeping empty fields (strtok would skip them). */
static int split_fields(char *s, char *fields[], int max)
{
    int n = 0;
    fields[n++] = s;
    while (*s && n < max) {
        if (*s == ',') { *s = '\0'; fields[n++] = s + 1; }
        s++;
    }
    return n;
}

Coordinates aren’t decimal degrees#

3249.60000,N doesn’t mean 3249.6 degrees. NMEA packs latitude as ddmm.mmmm: 32 degrees and 49.6 minutes. Longitude is the same with three digits of degrees. Converting means splitting off the degrees and dividing the minutes by 60:

c
/* NMEA gives ddmm.mmmm; convert to decimal degrees. */
static double nmea_to_deg(const char *v, const char *hemi)
{
    double raw, d;
    int deg;
    if (!v || !*v) return 0.0;
    raw = atof(v);
    deg = (int)(raw / 100.0);
    d = deg + (raw - deg * 100.0) / 60.0;
    if (hemi && (*hemi == 'S' || *hemi == 'W')) d = -d;
    return d;
}

Satellites in view: GSV#

A GSV sentence describes up to four satellites, each as a block of four fields: number, elevation, azimuth and signal-to-noise ratio in dB-Hz. When more than four are in view, the receiver sends several GSV sentences in a row.

Newer receivers (NMEA 4.10 and later) add one more field at the end, a signal ID, saying which frequency band the numbers are for. A dual-band receiver can report the same GPS satellite twice, once on L1 and once on L5. There’s no version number in the sentence, so the parser works it out from the field count. After the four header fields, the rest should divide evenly into blocks of four. If there’s exactly one left over, it’s the signal ID:

c
static void parse_gsv(char *f[], int n, const char *talker)
{
    int remaining = n - 4, sig = -1, blocks, b;

    if (remaining < 0) return;
    if (remaining % 4 == 1) {                 /* trailing signal ID present */
        sig = (int)strtol(f[n - 1], NULL, 16);
        remaining--;
    }
    blocks = remaining / 4;
    ...

That’s why each satellite in the table is keyed on three things together: the talker, the satellite number and the signal ID. GPS 18 on L1 and GPS 18 on L5 are the same satellite but two separate signals and they get two rows.

Which satellites are used: GSA and the GN problem#

GSA lists up to twelve satellite numbers that went into the position fix. The catch is the talker. A multi-constellation receiver often sends GSA with the combined talker GN and satellite numbers overlap between systems: there’s a GPS 5 and a NavIC 5. With GN alone you can’t tell which one is meant.

NMEA 4.10 fixed this by adding a system ID as the last field of GSA. The parser uses it when it’s there and when it isn’t, it skips the sentence instead of guessing:

c
    if (n >= 19) {                            /* NMEA 4.10+ system ID */
        const char *t = talker_from_system_id(to_int(f[18]));
        if (t) sys = t;
    }
    if (!strcmp(sys, "GN")) return;           /* can't tell which system */

Marking a satellite as used when it wasn’t would be worse than not marking it at all.

One more quirk: older receivers call BeiDou BD instead of GB. The program rewrites BD to GB as soon as it reads the talker, so the rest of the code only ever sees one name.

Reading the serial port#

On Linux and macOS the dongle shows up as a serial device such as /dev/ttyACM0. The port is put into raw mode, so the terminal driver doesn’t try to interpret line endings or control characters and given a short read timeout:

c
    src->fd = open(name, O_RDONLY | O_NOCTTY);
    if (src->fd < 0) return 0;
    if (tcgetattr(src->fd, &tio) == 0) {
        cfmakeraw(&tio);
        cfsetispeed(&tio, speed);
        cfsetospeed(&tio, speed);
        tio.c_cc[VMIN]  = 0;
        tio.c_cc[VTIME] = 5;               /* 0.5 s read timeout */
        tcsetattr(src->fd, TCSANOW, &tio);
    }

VMIN = 0 and VTIME = 5 mean a read returns whatever has arrived within half a second, even if that’s nothing. That keeps the loop turning, so the screen still refreshes and stale satellites still expire when the receiver goes quiet.

On Windows the same port is COM3 or similar, opened with CreateFileA on \\.\COM3 and configured with SetCommState and SetCommTimeouts. Both paths sit behind one read_bytes() function and so does reading from a saved log file, which is how the program runs without the dongle plugged in.

Bytes arrive in arbitrary chunks, not whole lines, so the main loop assembles lines itself. It strips the \r before \n and throws away any line that grows past 256 characters, because a real NMEA sentence is never that long.

The result#

Refreshing once a second, it clears the screen and redraws a table sorted by system and satellite number. Satellites that haven’t been reported for five seconds drop off. Here it is on a short test log I use while developing, which includes one deliberately corrupted line:

shell
$ ./gnss_reader --file sample.nmea
GNSS reader | UTC 08:30:15 | sentences ok: 14, bad checksum: 1
Position: 32.82667, 77.35000   altitude 4980.5 m   satellites used: 14

System    PRN  Band  Elev  Azim  SNR Used  Signal
------------------------------------------------------------------------
Galileo     4   E5a    58   210   39    *  ###################
Galileo     4    E1    58   210   42    *  #####################
Galileo     9   E5a    40    88   34    *  #################
Galileo     9    E1    40    88   37    *  ##################
Galileo    11    E1    19   330   29       ##############
BeiDou     23     ?    66   130   43    *  #####################
BeiDou     28     ?    30   245   36    *  ##################
BeiDou     37     ?    17    60   -1
NavIC       2     ?    45   180   36    *  ##################
NavIC       5     ?    38   210   33    *  ################
GLONASS    70     ?    55    20   38    *  ###################
GLONASS    71     ?    33   301   35    *  #################
GLONASS    80     ?    12   150   22       ###########
GPS         5    L1    62    41   44    *  ######################
GPS         5    L5    62    41   40    *  ####################
GPS        12    L1    35   298   39    *  ###################
GPS        18    L1    71   190   46    *  #######################
GPS        18    L5    71   190   43    *  #####################
GPS        25    L1    22   112   33    *  ################
GPS        25    L5    22   112   30    *  ###############
GPS        29    L1    14   251   27       #############
GPS        31    L1    48    76   41    *  ####################

22 satellites listed, 21 with a usable signal

A few things worth reading in it:

  • The broken line was caught. 14 sentences passed and 1 failed the checksum, so it never touched the table.
  • Dual-band signals show up as pairs. GPS 5, 18 and 25 appear on both L1 and L5 and Galileo 4 and 9 on both E1 and E5a.
  • Signal strength tracks elevation. Satellites high in the sky (GPS 18 at 71°) come in strongest and ones near the horizon (GPS 29 at 14°, GLONASS 80 at 12°) are the weakest and aren’t used in the fix.
  • BeiDou 37 is listed but not tracked. The receiver knows where it should be but isn’t hearing it, which is what the -1 means.
  • The ? bands are systems whose signal IDs I haven’t mapped yet. The program only knows the IDs for GPS and Galileo so far.

Building and running it:

shell
gcc -Wall -O2 gnss_reader.c -o gnss_reader

./gnss_reader /dev/ttyACM0            # live, at the default 9600 baud
./gnss_reader /dev/ttyACM0 115200     # if your receiver runs faster
./gnss_reader --file sample.nmea      # replay a log, no hardware needed

If the port won’t open, something else probably has it, like gpsd or ModemManager on Linux, or u-center on Windows.

What’s next#

This is the first piece. What I want to build on top of it:

  • Map the remaining signal IDs, so BeiDou, GLONASS and NavIC bands show properly instead of ?.
  • A sky plot: elevation and azimuth are already there, so drawing where each satellite sits in the sky is a small step.
  • Watching NavIC specifically. It’s a regional system with only a handful of satellites, so how many I can see and how much they add to the fix is its own question.
  • Recording sessions to replay later and compare: indoors versus by a window versus outside and how long each takes to get a first fix.

I’ll write each of those up here as I build it.

Further reading#