#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, “<”) + “<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();
}
}