Ultra Basic GPS Tester – NEO8M + ESP32 Wroom with CoG

    This is a really quick and basic GPS tester which evaluates a GPS module (in this case a Ublox-Neo8M and ESP32-Wroom. It has 3 core functions, evaluate the signal and reliability of the incoming signals, map the CoG or Course over Ground and establish what satellite is being used e.g.: GPS, BeiDou (BDS), Galileo or GLONASS. As the Neo8M has everything but GLONASS the messages are focused on Galileo and GPS. A sky Plot and other iterations from the basic acquisition are included to aid diagnosis and testing. 

  The code is light, basic, but allows a user to quickly get up and running. 

    Simply upload the code, look for “G5STO HUD-Setup” on your network or mobile phone. Apple IOS will connect to 192.168.4.1 initally and then open an web portal to assign a network. Just in case you get asked a password? it’s simply “12345678”.

 

Just a heads up….pun intended…..here is v 1.70 and update that parses BeiDou correctly and adds a Geiget counter sound, allows direct data entry to inject commands, spoofing and jamming detection which adds a basic heuristic engine, (Checking SNR and looking for exact matching SNR’s basically), object mapping, HDOP heatmap and lastly the SkyLine mapper shows a shadow to identify objects blocking the signal. For now the GLONASS detection is purged from the code as I am only using a Neo8M where a Neo8MN is required.

Click here to download the latest update.    Click Here >>> HUD_Display_Neo8M_N

Copy all the code between the astrix:

****************************************************************************************************************

// G5STO – HuD style GPS tester for Neo8M

#include <WiFi.h>
#include <WiFiManager.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <TinyGPSPlus.h>
#include <vector>
TinyGPSPlus gps;
HardwareSerial GPSserial(2);
AsyncWebServer server(80);
AsyncWebSocket ws(“/ws”);
struct SatInfo {
  int prn;
  int snr;
  int elevation;
  int azimuth;
  String system;
  unsigned long lastActive;
  unsigned long lastView;
};
std::vector<SatInfo> sats;
// ———- HUD HTML (PROGMEM) ———-
const char index_html[] PROGMEM = R”rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset=”UTF-8″>
<title>G5STO – GPS HuD</title>
<style>
  body { margin:0; background:#000; color:#0f0; font-family:Consolas, monospace; }
  .grid { display:grid; grid-template-columns:2fr 1fr; gap:10px; padding:10px; }
  .panel { border:1px solid #0f0; padding:10px; box-shadow:0 0 10px #0f0; }
  .title { font-size:18px; margin-bottom:5px; text-transform:uppercase; letter-spacing:2px; }
  #compass-container {
    position:relative; width:250px; height:250px; margin:0 auto;
    border:1px solid #0f0; border-radius:50%; box-shadow:0 0 15px #0f0;
  }
  #compass-ring { position:absolute; width:100%; height:100%; border-radius:50%; border:2px solid #0f0; }
  #compass-needle {
    position:absolute; width:2px; height:45%; background:#0f0;
    top:5%; left:50%; transform-origin:50% 90%; box-shadow:0 0 10px #0f0;
  }
  #compass-center {
    position:absolute; width:12px; height:12px; background:#000;
    border:2px solid #0f0; border-radius:50%; top:50%; left:50%;
    transform:translate(-50%, -50%);
  }
  #compass-label { position:absolute; bottom:10px; width:100%; text-align:center; font-size:16px; }
  .hud-row { display:flex; justify-content:space-between; margin-bottom:5px; }
  .hud-label { text-transform:uppercase; letter-spacing:1px; }
  .hud-value { font-weight:bold; }
  #console, #satbox {
    background:#010; padding:5px; font-size:12px; border:1px solid #0f0;
    overflow-y:auto;
  }
  #console { height:200px; }
  #satbox { height:150px; margin-top:5px; }
  #console::-webkit-scrollbar, #satbox::-webkit-scrollbar { width:6px; }
  #console::-webkit-scrollbar-thumb, #satbox::-webkit-scrollbar-thumb { background:#0f0; }
</style>
</head>
<body>
<div class=”grid”>
  <div class=”panel”>
    <div class=”title”>G5STO – Navigation style HuD GPS – tester</div>
    <div id=”compass-container”>
      <div id=”compass-ring”></div>
      <div id=”compass-needle”></div>
      <div id=”compass-center”></div>
      <div id=”compass-label”>COG: —°</div>
    </div>
    <div style=”text-align:center; margin-top:15px;”>
      <div class=”title”>Sky Plot</div>
      <canvas id=”skyplot” width=”250″ height=”250″ style=”background:#010; border-radius:50%; border:1px solid #0f0;”></canvas>
    </div>
    <div style=”margin-top:15px;”>
      <div style=”text-align:right; margin-bottom:5px;”>
        <label style=”font-size:12px; cursor:pointer;”>
          <input type=”checkbox” id=”unitToggle” onchange=”toggleUnits()”> MPH
        </label>
      </div>
      <div class=”hud-row”><div class=”hud-label” id=”speedLabel”>Speed (km/h)</div><div class=”hud-value” id=”speed”>—</div></div>
      <div class=”hud-row”><div class=”hud-label”>Altitude (m)</div><div class=”hud-value” id=”alt”>—</div></div>
      <div class=”hud-row”><div class=”hud-label”>Satellites</div><div class=”hud-value” id=”sats”>—</div></div>
      <div class=”hud-row”><div class=”hud-label”>HDOP</div><div class=”hud-value” id=”hdop”>—</div></div>
      <div class=”hud-row”><div class=”hud-label”>Lat</div><div class=”hud-value” id=”lat”>—</div></div>
      <div class=”hud-row”><div class=”hud-label”>Lng</div><div class=”hud-value” id=”lng”>—</div></div>
    </div>
  </div>
  <div class=”panel”>
    <div class=”title”>Packet Console</div>
    <div id=”console”></div>
    <div class=”title” style=”margin-top:10px;”>Satellite Status</div>
    <div id=”satbox”></div>
  </div>
</div>
<script>
let socket;
function connectWS() {
  socket = new WebSocket(“ws://” + location.host + “/ws”);
  socket.onopen = () => logLine(“[WS] Connected”);
  socket.onclose = () => {
    logLine(“[WS] Disconnected, retrying…”);
    setTimeout(connectWS, 2000);
  };
  socket.onmessage = event => {
    const data = event.data;
    if (data.startsWith(“{“)) {
      try {
        const j = JSON.parse(data);
        updateHUD(j);
      } catch (e) {
        logLine(“[JSON ERROR] ” + e);
      }
    } else if (data.startsWith(“[“)) {
      try {
        const list = JSON.parse(data);
        updateSatellites(list);
        drawSkyPlot(list);
      } catch (e) {
        logLine(“[SAT JSON ERROR] ” + e);
      }
    } else {
      logLine(data);
    }
  };
}
function logLine(t) {
  const c = document.getElementById(“console”);
  c.innerHTML += t.replace(/</g, “&lt;”) + “<br>”;
  c.scrollTop = c.scrollHeight;
}
function updateHUD(j) {
  let spd = j.speed;
  if (document.getElementById(“unitToggle”).checked) {
    spd *= 0.621371;
  }
  document.getElementById(“speed”).textContent = spd.toFixed(1);
  document.getElementById(“alt”).textContent   = j.alt.toFixed(1);
  document.getElementById(“sats”).textContent  = j.sats;
  document.getElementById(“hdop”).textContent  = j.hdop.toFixed(1);
  document.getElementById(“lat”).textContent   = j.lat.toFixed(6);
  document.getElementById(“lng”).textContent   = j.lng.toFixed(6);
  const needle = document.getElementById(“compass-needle”);
  const label  = document.getElementById(“compass-label”);
  needle.style.transform = “rotate(” + j.cog + “deg)”;
  label.textContent = “COG: ” + j.cog.toFixed(1) + “°”;
}
function toggleUnits() {
  const chk = document.getElementById(“unitToggle”).checked;
  document.getElementById(“speedLabel”).innerText = chk ? “Speed (mph)” : “Speed (km/h)”;
  localStorage.setItem(“useMPH”, chk);
}
function updateSatellites(list) {
  const box = document.getElementById(“satbox”);
  box.innerHTML = “”;
  list.forEach(s => {
    // Satellites active in the last 3.5 seconds will be marked as ‘Used’ and dark Green
    const isUsed = s.used;
    const color = isUsed ? “#0f0” : “#078007”;
    const usedText = isUsed ? ” (U)” : “”;
    box.innerHTML += `<span style=”color:${color}; white-space: pre;”>${s.system.padEnd(7)} PRN ${String(s.prn).padStart(3)} | El: ${String(s.elev).padStart(2)}° Az: ${String(s.azim).padStart(3)}° | SNR: ${String(s.snr).padStart(2)} ${usedText}</span><br>`;
  });
  box.scrollTop = box.scrollHeight;
}
function drawSkyPlot(satellites) {
  const canvas = document.getElementById(‘skyplot’);
  const ctx = canvas.getContext(‘2d’);
  const w = canvas.width;
  const h = canvas.height;
  const cx = w / 2;
  const cy = h / 2;
  const radius = w / 2 – 10;
  const sysColors = {
    “GPS”: “#00f”, “GLONASS”: “#f00”, “Galileo”: “#0f0”, “BeiDou”: “#f90”, “GNSS”: “#fff”
  };
  ctx.clearRect(0, 0, w, h);
  // Draw rings and lines
  ctx.strokeStyle = ‘#030’;
  ctx.beginPath(); ctx.arc(cx, cy, radius / 2, 0, 2 * Math.PI); ctx.stroke(); // 45 deg elevation
  ctx.beginPath(); ctx.arc(cx, cy, radius, 0, 2 * Math.PI); ctx.stroke();     // Horizon
  ctx.beginPath(); ctx.moveTo(cx – radius, cy); ctx.lineTo(cx + radius, cy); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(cx, cy – radius); ctx.lineTo(cx, cy + radius); ctx.stroke();
  ctx.fillStyle = ‘#070’;
  ctx.font = ’10px Consolas’;
  ctx.textAlign = ‘center’;
  ctx.fillText(‘N’, cx, cy – radius – 2);
  // Plot satellites
  satellites.forEach(s => {
    if (s.elev === null || s.azim === null) return;
    const r = radius * (90 – s.elev) / 90;
    const angle = (s.azim – 90) * Math.PI / 180; // Azimuth 0=N, rotate for canvas
    const x = cx + r * Math.cos(angle);
    const y = cy + r * Math.sin(angle);
    ctx.fillStyle = sysColors[s.system] || ‘#fff’;
    ctx.beginPath();
    ctx.arc(x, y, 5, 0, 2 * Math.PI);
    ctx.fill();
    if (s.used) {
      ctx.strokeStyle = ‘#ff0’;
      ctx.lineWidth = 2;
      ctx.stroke();
    }
  });
}
window.onload = () => {
  if (localStorage.getItem(“useMPH”) === “true”) {
    document.getElementById(“unitToggle”).checked = true;
    toggleUnits();
  }
  connectWS();
};
</script>
</body>
</html>
)rawliteral”;
// ———- WiFi ———-
void setupWiFi() {
  WiFi.mode(WIFI_STA);
  WiFiManager wm;
  wm.setAPCallback([](WiFiManager *wm) {
    Serial.println(“Entering config portal…”);
  });
  if (!wm.autoConnect(“G5STO HUD-Setup”, “12345678”)) {
    Serial.println(“WiFi failed, rebooting…”);
    delay(3000);
    ESP.restart();
  }
  Serial.print(“WiFi connected. IP: “);
  Serial.println(WiFi.localIP());
}
// ———- WebSocket Events ———-
void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
               AwsEventType type, void *arg, uint8_t *data, size_t len) {
  if (type == WS_EVT_CONNECT)
    Serial.printf(“WS client %u connected\n”, client->id());
  else if (type == WS_EVT_DISCONNECT)
    Serial.printf(“WS client %u disconnected\n”, client->id());
}
// ———- GSV Parsing ———-
void parseGSV(const String &line) {
  String sys;
  String talker = line.substring(1, 3);
  if (talker == “GP”) sys = “GPS”;
  else if (talker == “GL”) sys = “GLONASS”;
  else if (talker == “GA”) sys = “Galileo”;
  else if (talker == “BD” || talker == “GB”) sys = “BeiDou”;
  else if (talker == “GN”) sys = “GNSS”;
  else return; // Ignore unknown GSV talkers
  int idx = 0;
  int pStart = 0;
  int prn = 0, elev = 0, azim = 0; // Temp variables
  for (int i = 0; i < line.length(); i++) {
    if (line[i] == ‘,’ || line[i] == ‘*’) {
      String val = line.substring(pStart, i);
     
      // Satellite data starts at field 4, repeating every 4 fields
      if (idx >= 4) {
        int subField = (idx – 4) % 4;
        if (subField == 0) prn = val.toInt();
        else if (subField == 1) elev = val.toInt();
        else if (subField == 2) azim = val.toInt();
        else if (subField == 3) {
           int snr = val.toInt();
           if (prn > 0) {
             bool found = false;
             for (auto &s : sats) {
               if (s.prn == prn && s.system == sys) {
                 s.elevation = elev; s.azimuth = azim; s.snr = snr;
                 s.lastView = millis();
                 found = true; break;
               }
             }
             if (!found) {
               SatInfo s;
               s.prn = prn; s.elevation = elev; s.azimuth = azim; s.snr = snr;
               s.system = sys; s.lastActive = 0; s.lastView = millis();
               sats.push_back(s);
             }
           }
        }
      }
      idx++;
      pStart = i + 1;
    }
  }
}
// ———- GSA Parsing ———-
void parseGSA(const String &line) {
  String sys;
  String talker = line.substring(1, 3);
  if (talker == “GP”) sys = “GPS”;
  else if (talker == “GL”) sys = “GLONASS”;
  else if (talker == “GA”) sys = “Galileo”;
  else if (talker == “BD” || talker == “GB”) sys = “BeiDou”;
  else if (talker == “GN”) sys = “GNSS”; // Mixed message
  else return;
  int idx = 0;
  int pStart = 0;
  int activePRNs[12];
  int prnCount = 0;
  int systemId = 0;
  for (int i = 0; i < line.length(); i++) {
    if (line[i] == ‘,’ || line[i] == ‘*’) {
      String val = line.substring(pStart, i);
     
      // PRNs are in fields 3 to 14
      if (idx >= 3 && idx <= 14 && val.length() > 0) {
        if (prnCount < 12) activePRNs[prnCount++] = val.toInt();
      }
      // System ID in field 18 (NMEA 4.10)
      if (idx == 18 && val.length() > 0) {
        systemId = val.toInt();
      }
     
      idx++;
      pStart = i + 1;
    }
  }
  if (systemId == 1) sys = “GPS”;
  else if (systemId == 2) sys = “GLONASS”;
  else if (systemId == 3) sys = “Galileo”;
  else if (systemId == 4) sys = “BeiDou”;
  for (int i = 0; i < prnCount; i++) {
    int prn = activePRNs[i];
    for (auto &s : sats) {
      bool sysMatch = (sys == “GNSS”) ? true : (s.system == sys);
      if (s.prn == prn && sysMatch) {
        s.lastActive = millis();
        break; // Found satellite, move to next PRN in GSA message
      }
    }
  }
}
// ———- Setup ———-
void setup() {
  Serial.begin(115200);
  GPSserial.begin(9600, SERIAL_8N1, 16, 17);    //adjust this if using soemthing other than Wroom
  // Increase buffer to prevent overflow during heavy parsing
  GPSserial.setRxBufferSize(1024);
  setupWiFi();
  ws.onEvent(onWsEvent);
  server.addHandler(&ws);
  server.on(“/”, HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send_P(200, “text/html”, index_html);
  });
  server.begin();
  Serial.println(“Server started”);
}
// ———- Loop ———-
void loop() {
  static String nmeaLine = “”;
  static unsigned long lastNMEASend = 0;
  static unsigned long lastJsonSend = 0;
  static unsigned long lastSatSend  = 0;
  static unsigned long lastPing     = 0;
  // Pass commands from u-center (PC) to GPS module
  while (Serial.available()) {
    GPSserial.write(Serial.read());
  }
  while (GPSserial.available()) {
    char c = GPSserial.read();
    Serial.write(c); // Pass GPS data to u-center (PC)
    gps.encode(c);
    if (c == ‘\n’) {
      if (nmeaLine.startsWith(“$GPGSV”) ||
          nmeaLine.startsWith(“$GLGSV”) ||
          nmeaLine.startsWith(“$GAGSV”) ||
          nmeaLine.startsWith(“$BDGSV”) ||
          nmeaLine.startsWith(“$GNGSV”)) {
        parseGSV(nmeaLine);
      }
      if (nmeaLine.startsWith(“$GNGSA”) ||
          nmeaLine.startsWith(“$GPGSA”) ||
          nmeaLine.startsWith(“$GLGSA”) ||
          nmeaLine.startsWith(“$GAGSA”) ||
          nmeaLine.startsWith(“$BDGSA”)) {
        parseGSA(nmeaLine);
      }
      if (millis() – lastNMEASend > 250) {
        ws.textAll(nmeaLine);
        lastNMEASend = millis();
      }
      nmeaLine = “”;
    } else if (c != ‘\r’) {
      nmeaLine += c;
    }
  }
  if (millis() – lastJsonSend > 1000) {
    lastJsonSend = millis();
    if (gps.location.isValid()) {
      String json = “{“;
      json += “\”lat\”:”   + String(gps.location.lat(), 6) + “,”;
      json += “\”lng\”:”   + String(gps.location.lng(), 6) + “,”;
      json += “\”speed\”:” + String(gps.speed.kmph(), 1)   + “,”;
      json += “\”alt\”:”   + String(gps.altitude.meters(), 1) + “,”;
      json += “\”cog\”:”   + String(gps.course.deg(), 1)   + “,”;
      json += “\”sats\”:”  + String(gps.satellites.value()) + “,”;
      json += “\”hdop\”:”  + String(gps.hdop.hdop(), 1);
      json += “}”;
      ws.textAll(json);
    }
  }
  if (millis() – lastSatSend > 2000) {
    lastSatSend = millis();
    // Prune old satellites (not seen in 10 seconds)
    for (int i = sats.size() – 1; i >= 0; i–) {
      if (millis() – sats[i].lastView > 10000) sats.erase(sats.begin() + i);
    }
    if (!sats.empty()) {
      String satJson = “[“;
      for (size_t i = 0; i < sats.size(); i++) {
        satJson += “{“;
        satJson += “\”prn\”:” + String(sats[i].prn) + “,”;
        satJson += “\”elev\”:” + String(sats[i].elevation) + “,”;
        satJson += “\”azim\”:” + String(sats[i].azimuth) + “,”;
        satJson += “\”snr\”:” + String(sats[i].snr) + “,”;
        satJson += “\”system\”:\”” + sats[i].system + “\”,”;
        // Mark used if active in the last 3.5 seconds
        satJson += “\”used\”:” + String((millis() – sats[i].lastActive < 3500) ? “true” : “false”);
        satJson += “}”;
        if (i < sats.size() – 1) satJson += “,”;
      }
      satJson += “]”;
      ws.textAll(satJson);
    }
  }
  if (millis() – lastPing > 15000) {
    ws.pingAll();
    lastPing = millis();
  }
}
***********************************************************************************************************************