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"