diff --git a/data_embed/aprs-symbols-24-0.png b/data_embed/aprs-symbols-24-0.png
new file mode 100644
index 0000000..92bf114
Binary files /dev/null and b/data_embed/aprs-symbols-24-0.png differ
diff --git a/data_embed/aprs-symbols-24-1.png b/data_embed/aprs-symbols-24-1.png
new file mode 100644
index 0000000..b83d4b1
Binary files /dev/null and b/data_embed/aprs-symbols-24-1.png differ
diff --git a/data_embed/script.js b/data_embed/script.js
index 90faeef..2f54d32 100644
--- a/data_embed/script.js
+++ b/data_embed/script.js
@@ -490,7 +490,6 @@ function toggleWiFiAutoAPFields() {
if (autoAPConfig) autoAPConfig.style.display = isEnabled ? 'block' : 'none';
}
-
document.querySelector(".new button").addEventListener("click", function () {
const networksContainer = document.querySelector(".list-networks");
@@ -659,10 +658,240 @@ function setMapMessage(text) {
}
}
+
+/*
+ * APRS symbol renderer for Stations Map
+ *
+ * s.symbol contains APRS table + symbol code, for example:
+ * "/>" Primary table
+ * "\\>" Alternate table
+ * "L&" Alternate table with overlay
+ *
+ * Sprite layout:
+ * aprs-symbols-24-0.png = Primary table
+ * aprs-symbols-24-1.png = Alternate table
+ */
+
+function getAprsSymbolIcon(symbol, callsign = "") {
+
+ if (!symbol || String(symbol).length < 2) {
+ return null;
+ }
+
+ const value = String(symbol);
+
+ let table = value.charAt(0);
+ let code = value.charAt(1);
+ let overlay = null;
+
+ /*
+ * Standard APRS:
+ * "/" = Primary
+ * "\\" = Alternate
+ *
+ * A-Z / 0-9 / a-j in table position are overlays
+ * for the Alternate table.
+ */
+ if (
+ table !== "/" &&
+ table !== "\\"
+ ) {
+ overlay = table;
+ table = "\\";
+ }
+
+ const ascii = code.charCodeAt(0);
+
+ /*
+ * APRS printable symbol codes are ASCII 33..126.
+ */
+ if (
+ ascii < 33 ||
+ ascii > 126
+ ) {
+ return null;
+ }
+
+ const index = ascii - 33;
+
+ /*
+ * Sprite sheet contains 16 columns.
+ * Each cell is 24 x 24 px.
+ */
+ const columns = 16;
+
+ const col = index % columns;
+ const row = Math.floor(index / columns);
+
+ const sprite =
+ table === "/"
+ ? "/aprs-symbols-24-0.png"
+ : "/aprs-symbols-24-1.png";
+
+ const x = -(col * 24);
+ const y = -(row * 24);
+
+ let overlayHtml = "";
+
+ if (overlay) {
+
+ let displayOverlay = overlay;
+
+ /*
+ * APRS compressed numeric overlay:
+ * a-j -> 0-9
+ */
+ if (
+ overlay >= "a" &&
+ overlay <= "j"
+ ) {
+ displayOverlay =
+ String(
+ overlay.charCodeAt(0) -
+ "a".charCodeAt(0)
+ );
+ }
+
+ overlayHtml = `
+
+ ${displayOverlay}
+
+ `;
+ }
+
+ return L.divIcon({
+ className: "",
+ html: `
+
+ ${overlayHtml}
+
+ ${callsign}
+
+ `,
+ iconSize: [24, 24],
+ iconAnchor: [12, 12],
+ popupAnchor: [0, -12]
+ });
+}
+
+
+let mapStationsCache = [];
+let mapRouteLayer = null;
+
+function getUsedDigipeaters(path) {
+ if (!path) return [];
+
+ return path
+ .split(",")
+ .map((x) => x.trim())
+ .filter((x) => x.endsWith("*"))
+ .map((x) => x.replace(/\*+$/, ""))
+ .filter((x) =>
+ x &&
+ !x.startsWith("WIDE") &&
+ !x.startsWith("TRACE") &&
+ !x.startsWith("TCPIP") &&
+ !x.startsWith("TCPXX") &&
+ !x.startsWith("qA")
+ );
+}
+
+
+function describeMapPath(station) {
+ const digis = getUsedDigipeaters(station.path);
+
+ if (!digis.length) {
+ return "Direct";
+ }
+
+ return "Via: " + digis.join(" → ");
+}
+
+
+function clearMapRoute() {
+ if (mapRouteLayer && mapInstance) {
+ mapInstance.removeLayer(mapRouteLayer);
+ }
+ mapRouteLayer = null;
+}
+
+
+function drawStationRoute(station) {
+ if (!mapInstance || !iGateLatLng) return;
+
+ clearMapRoute();
+
+ const points = [
+ L.latLng(station.lat, station.lon)
+ ];
+
+ const digis = getUsedDigipeaters(station.path);
+
+ digis.forEach((digiCall) => {
+ const digi = mapStationsCache.find((s) =>
+ String(s.callsign || "").toUpperCase() === digiCall.toUpperCase()
+ );
+
+ if (digi && !(digi.lat === 0 && digi.lon === 0)) {
+ points.push(L.latLng(digi.lat, digi.lon));
+ }
+ });
+
+ points.push(iGateLatLng);
+
+ mapRouteLayer = L.polyline(points, {
+ color: "#e53935",
+ weight: 3,
+ opacity: 0.9
+ }).addTo(mapInstance);
+}
+
+
function loadMapStations(stations) {
if (!mapMarkers) return;
+ mapStationsCache = stations || [];
+
mapMarkers.clearLayers();
+ clearMapRoute();
let drawn = 0;
(stations || []).forEach((s) => {
@@ -671,15 +900,52 @@ function loadMapStations(stations) {
const popup = `${s.callsign}`
+ (s.lastHeard ? `
Last: ${s.lastHeard}` : "")
+ `
RSSI ${s.RSSI} / SNR ${s.SNR}`
- + `
Packets: ${s.count}`;
+ + `
Packets: ${s.count}`
+ + `
${describeMapPath(s)}`
+ + (s.path ? `
Path: ${s.path}` : "");
+
+ const aprsIcon =
+ getAprsSymbolIcon(s.symbol, s.callsign);
+
+ if (aprsIcon) {
+
+ const marker = L.marker(
+ [s.lat, s.lon],
+ {
+ icon: aprsIcon
+ }
+ )
+ .bindPopup(popup)
+ .addTo(mapMarkers);
+
+ marker.on("mouseover", function () {
+ drawStationRoute(s);
+ });
+
+ marker.on("mouseout", function () {
+ clearMapRoute();
+ });
+
+ } else { // Fallback: station with missing or invalid APRS symbol
+ const marker = L.circleMarker([s.lat, s.lon], {
+ radius: 6,
+ color: "#1d6fe0",
+ weight: 2,
+ fillColor: "#3b8cff",
+ fillOpacity: 0.9
+ })
+ .bindPopup(popup)
+ .addTo(mapMarkers);
+
+ marker.on("mouseover", function () {
+ drawStationRoute(s);
+ });
+
+ marker.on("mouseout", function () {
+ clearMapRoute();
+ });
+ }
- L.circleMarker([s.lat, s.lon], {
- radius: 6,
- color: "#1d6fe0", // borde
- weight: 2,
- fillColor: "#3b8cff", // relleno
- fillOpacity: 0.9
- }).bindPopup(popup).addTo(mapMarkers);
drawn++;
});
diff --git a/include/map_utils.h b/include/map_utils.h
index 07d2f1c..027ab5b 100644
--- a/include/map_utils.h
+++ b/include/map_utils.h
@@ -32,12 +32,13 @@ struct MapStation {
float snr; // ultimo SNR
uint16_t count; // nro de paquetes oidos de esa estacion
char lastHeard[10]; // "HH:MM:SS" si hay NTP valido, "" si no
+ char path[96]; // last path received
uint32_t lastHeardMillis; // millis() -> ordenar / expirar (no se muestra)
};
namespace MAP_Utils {
- void upsert(const String& callsign, float latitude, float longitude, const String& symbol, int rssi, float snr);
+ void upsert(const String& callsign, float latitude, float longitude, const String& path, const String& symbol, int rssi, float snr);
String getStationsJson();
}
diff --git a/platformio.ini b/platformio.ini
index 4499fa8..b4bf4b5 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -29,6 +29,8 @@ board_build.embed_files =
data_embed/leaflet.css.gz
data_embed/leaflet.js.gz
data_embed/favicon.png.gz
+ data_embed/aprs-symbols-24-0.png.gz
+ data_embed/aprs-symbols-24-1.png.gz
extra_scripts =
pre:tools/compress.py
debug_tool = esp-prog
\ No newline at end of file
diff --git a/src/LoRa_APRS_iGate.cpp b/src/LoRa_APRS_iGate.cpp
index eb501f8..8a23b27 100644
--- a/src/LoRa_APRS_iGate.cpp
+++ b/src/LoRa_APRS_iGate.cpp
@@ -68,8 +68,8 @@ ___________________________________________________________________*/
#endif
-String versionDate = "2026-08-16";
-String versionNumber = "4.0.0";
+String versionDate = "2026-08-24";
+String versionNumber = "4.0.1";
Configuration Config;
WiFiClient aprsIsClient;
WiFiClient mqttClient;
diff --git a/src/lora_utils.cpp b/src/lora_utils.cpp
index 1f603be..5ce7d91 100644
--- a/src/lora_utils.cpp
+++ b/src/lora_utils.cpp
@@ -291,8 +291,8 @@ namespace LoRa_Utils {
receivedPackets.push_back(receivedPacket);
APRSPacket aprsPacket = APRSPacketLib::processReceivedPacket(packet.substring(3), rssi, snr, freqError);
- if (aprsPacket.type == 0 || aprsPacket.type == 4) { // 0 = GPS, 4 = Mic-E (los que traen posición)
- MAP_Utils::upsert(aprsPacket.sender, aprsPacket.latitude, aprsPacket.longitude, aprsPacket.overlay + aprsPacket.symbol, aprsPacket.rssi, aprsPacket.snr);
+ if (aprsPacket.type == 0 || aprsPacket.type == 4) { // 0 = GPS, 4 = Mic-E (only ones with position)
+ MAP_Utils::upsert(aprsPacket.sender, aprsPacket.latitude, aprsPacket.longitude, aprsPacket.path, aprsPacket.overlay + aprsPacket.symbol, aprsPacket.rssi, aprsPacket.snr);
}
}
diff --git a/src/map_utils.cpp b/src/map_utils.cpp
index 762534f..fc27a89 100644
--- a/src/map_utils.cpp
+++ b/src/map_utils.cpp
@@ -29,9 +29,7 @@ std::vector mapStations;
namespace MAP_Utils {
- // Copia la hora NTP "HH:MM:SS" si es valida; si no hay red (NTP devuelve
- // un texto de fallback) deja el campo vacio para no guardar hora falsa.
- void writeFormatedTime(char* dst, size_t size) {
+ void writeFormatedTime(char* dst, size_t size) { // gets NTP time ("HH:MM:SS") and writes it to dst if valid
String t = NTP_Utils::getFormatedTime();
if (t.length() == 8 && t.charAt(2) == ':') {
t.toCharArray(dst, size);
@@ -40,10 +38,8 @@ namespace MAP_Utils {
}
}
- // Inserta una estacion nueva o actualiza la existente (dedup por callsign).
- // Llamar solo cuando el parser ya decodifico una posicion valida.
- void upsert(const String& callsign, float latitude, float longitude, const String& symbol, int rssi, float snr) {
- for (auto &s : mapStations) { // 1) ya existe -> actualizar
+ void upsert(const String& callsign, float latitude, float longitude, const String& path, const String& symbol, int rssi, float snr) { // Upsert station only with valid position with callsign dedup.
+ for (auto &s : mapStations) { // 1) update
if (callsign.equals(s.callsign)) {
s.latitude = latitude;
s.longitude = longitude;
@@ -51,13 +47,14 @@ namespace MAP_Utils {
s.snr = snr;
s.count++;
symbol.toCharArray(s.symbol, sizeof(s.symbol));
+ path.toCharArray(s.path, sizeof(s.path));
writeFormatedTime(s.lastHeard, sizeof(s.lastHeard));
s.lastHeardMillis = millis();
return;
}
}
- if (mapStations.size() >= MAX_MAP_STATIONS) { // 2) llena -> descartar la mas vieja
+ if (mapStations.size() >= MAX_MAP_STATIONS) { // 2) full? delete oldest one
size_t oldest = 0;
for (size_t i = 1; i < mapStations.size(); i++) {
if (mapStations[i].lastHeardMillis < mapStations[oldest].lastHeardMillis) {
@@ -67,9 +64,10 @@ namespace MAP_Utils {
mapStations.erase(mapStations.begin() + oldest);
}
- MapStation st = {}; // 3) insertar nueva
+ MapStation st = {}; // 3) insert new
callsign.toCharArray(st.callsign, sizeof(st.callsign));
symbol.toCharArray(st.symbol, sizeof(st.symbol));
+ path.toCharArray(st.path, sizeof(st.path));
st.latitude = latitude;
st.longitude = longitude;
st.rssi = rssi;
@@ -102,6 +100,7 @@ namespace MAP_Utils {
data[i]["SNR"] = mapStations[i].snr;
data[i]["count"] = mapStations[i].count;
data[i]["lastHeard"] = mapStations[i].lastHeard;
+ data[i]["path"] = mapStations[i].path;
}
String buffer;
diff --git a/src/web_utils.cpp b/src/web_utils.cpp
index 5604038..ec81b87 100644
--- a/src/web_utils.cpp
+++ b/src/web_utils.cpp
@@ -62,6 +62,14 @@ extern const unsigned char favicon_data[] asm("_binary_data_embed_favicon_png_gz
extern const unsigned char favicon_data_end[] asm("_binary_data_embed_favicon_png_gz_end");
extern const size_t favicon_data_len = favicon_data_end - favicon_data;
+extern const unsigned char aprs_symbols_24_0_data[] asm("_binary_data_embed_aprs_symbols_24_0_png_gz_start");
+extern const unsigned char aprs_symbols_24_0_data_end[] asm("_binary_data_embed_aprs_symbols_24_0_png_gz_end");
+extern const size_t aprs_symbols_24_0_data_len = aprs_symbols_24_0_data_end - aprs_symbols_24_0_data;
+
+extern const unsigned char aprs_symbols_24_1_data[] asm("_binary_data_embed_aprs_symbols_24_1_png_gz_start");
+extern const unsigned char aprs_symbols_24_1_data_end[] asm("_binary_data_embed_aprs_symbols_24_1_png_gz_end");
+extern const size_t aprs_symbols_24_1_data_len = aprs_symbols_24_1_data_end - aprs_symbols_24_1_data;
+
namespace WEB_Utils {
@@ -92,6 +100,30 @@ namespace WEB_Utils {
request->send(response);
}
+ void handleAprsSymbols24Primary(AsyncWebServerRequest *request) {
+ AsyncWebServerResponse *response = request->beginResponse(
+ 200,
+ "image/png",
+ (const uint8_t*)aprs_symbols_24_0_data,
+ aprs_symbols_24_0_data_len
+ );
+ response->addHeader("Content-Encoding", "gzip");
+ response->addHeader("Cache-Control", "max-age=3600");
+ request->send(response);
+ }
+
+ void handleAprsSymbols24Alternate(AsyncWebServerRequest *request) {
+ AsyncWebServerResponse *response = request->beginResponse(
+ 200,
+ "image/png",
+ (const uint8_t*)aprs_symbols_24_1_data,
+ aprs_symbols_24_1_data_len
+ );
+ response->addHeader("Content-Encoding", "gzip");
+ response->addHeader("Cache-Control", "max-age=3600");
+ request->send(response);
+ }
+
void handleReadConfiguration(AsyncWebServerRequest *request) {
if(Config.webadmin.active && !request->authenticate(Config.webadmin.username.c_str(), Config.webadmin.password.c_str()))
return request->requestAuthentication();
@@ -401,6 +433,8 @@ namespace WEB_Utils {
server.on("/leaflet.css", HTTP_GET, handleLeafletStyle);
server.on("/leaflet.js", HTTP_GET, handleLeafletScript);
server.on("/favicon.png", HTTP_GET, handleFavicon);
+ server.on("/aprs-symbols-24-0.png", HTTP_GET, handleAprsSymbols24Primary);
+ server.on("/aprs-symbols-24-1.png", HTTP_GET, handleAprsSymbols24Alternate);
OTA_Utils::setup(&server); // Include OTA Updater for WebServer
diff --git a/tools/compress.py b/tools/compress.py
index 0d91d17..fe75697 100644
--- a/tools/compress.py
+++ b/tools/compress.py
@@ -29,6 +29,8 @@ files = [
'data_embed/leaflet.js',
'data_embed/leaflet.css',
'data_embed/favicon.png',
+ 'data_embed/aprs-symbols-24-0.png',
+ 'data_embed/aprs-symbols-24-1.png',
]
string_to_find_str = "String"