From 94e2b772e8d701d6fb9f3a45a47816c249ae8287 Mon Sep 17 00:00:00 2001 From: grigo Date: Thu, 11 Jun 2026 09:09:28 +0300 Subject: [PATCH] added subproxy --- .../loratester/TelemetryUploader.java | 12 + .../loratester/api/NearestHillResult.java | 19 ++ .../loratester/api/ServerApi.java | 34 +++ .../loratester/ui/MapFragment.java | 222 +++++++++++++++++- app/src/main/res/drawable/ic_hill.xml | 10 + app/src/main/res/drawable/ic_link_lora.xml | 10 + app/src/main/res/drawable/ic_link_server.xml | 10 + app/src/main/res/layout/fragment_map.xml | 59 +++++ app/src/main/res/values/colors.xml | 3 + app/src/main/res/values/strings.xml | 10 + server/README.md | 1 + .../__pycache__/elevation.cpython-313.pyc | Bin 12629 -> 17198 bytes server/core/elevation.py | 126 ++++++++++ server/fastapi_app.py | 13 + server/flask_app.py | 14 ++ ...est_elevation.cpython-313-pytest-9.0.3.pyc | Bin 5771 -> 8768 bytes server/tests/test_elevation.py | 31 +++ 17 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/grigowashere/loratester/api/NearestHillResult.java create mode 100644 app/src/main/res/drawable/ic_hill.xml create mode 100644 app/src/main/res/drawable/ic_link_lora.xml create mode 100644 app/src/main/res/drawable/ic_link_server.xml diff --git a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java index 3d8dd2a..47df643 100644 --- a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java +++ b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java @@ -114,6 +114,18 @@ public class TelemetryUploader implements TelnetClient.Listener { } } + public boolean hasGpsFix() { + return GeoUtils.isValidCoordinate(lat, lon); + } + + public double getGpsLat() { + return lat; + } + + public double getGpsLon() { + return lon; + } + private Double validLat() { return GeoUtils.isValidCoordinate(lat, lon) ? lat : null; } diff --git a/app/src/main/java/com/grigowashere/loratester/api/NearestHillResult.java b/app/src/main/java/com/grigowashere/loratester/api/NearestHillResult.java new file mode 100644 index 0000000..dcb6176 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/NearestHillResult.java @@ -0,0 +1,19 @@ +package com.grigowashere.loratester.api; + +public class NearestHillResult { + public boolean ok; + public String error; + public HillPoint center; + public HillPoint hill; + public double radius_m; + public int candidates; + + public static class HillPoint { + public double lat; + public double lon; + public Double elevation_m; + public Double dist_m; + public Double prominence_m; + public Boolean is_local_max; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java index f8ec297..f1f9027 100644 --- a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java +++ b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java @@ -195,6 +195,40 @@ public class ServerApi { } } + @SuppressWarnings("unchecked") + public Map getHealth() throws IOException { + Request request = new Request.Builder() + .url(baseUrl + "/api/health") + .get() + .build(); + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) { + throw new IOException("HTTP " + response.code()); + } + return GSON.fromJson(response.body().string(), Map.class); + } + } + + public NearestHillResult findNearestHill(double lat, double lon, int radiusM) + throws IOException { + String path = "/api/elevation/nearest-hill?lat=" + + lat + + "&lon=" + + lon + + "&radius_m=" + + radiusM; + Request request = new Request.Builder() + .url(baseUrl + path) + .get() + .build(); + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) { + throw new IOException("HTTP " + response.code()); + } + return GSON.fromJson(response.body().string(), NearestHillResult.class); + } + } + @SuppressWarnings("unchecked") private Map postJsonMap(String path, Map body, boolean android) throws IOException { diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java index e882c81..23b07ab 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -1,6 +1,7 @@ package com.grigowashere.loratester.ui; import android.content.Context; +import android.graphics.PorterDuff; import android.os.Bundle; import android.view.LayoutInflater; import android.view.MotionEvent; @@ -9,20 +10,24 @@ import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.Button; +import android.widget.ImageView; import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import com.grigowashere.loratester.CommandPoller; import com.grigowashere.loratester.LoraApp; import com.grigowashere.loratester.PeerDevices; +import com.grigowashere.loratester.PeerStatsCache; import com.grigowashere.loratester.R; import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.api.DeviceInfo; +import com.grigowashere.loratester.api.NearestHillResult; import com.grigowashere.loratester.api.ServerApi; import com.grigowashere.loratester.api.TrackDetail; import com.grigowashere.loratester.api.TrackInfo; @@ -74,6 +79,9 @@ public class MapFragment extends Fragment { private static final int ARGB_TX = 0xFFE94560; private static final int ARGB_RX = 0xFF4FC3F7; private static final int ARGB_TRACK = 0xFF00FF88; + private static final int ARGB_HILL = 0xFFFFC107; + private static final int HILL_SEARCH_RADIUS_M = 5000; + private static final long LORA_STATS_FRESH_MS = 120_000; private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final DateFormat timeFormat = @@ -91,9 +99,13 @@ public class MapFragment extends Fragment { private TileCache tileCache; private TextView mapStatus; private TextView mapDistance; + private TextView mapHillStatus; private TextView trackStatus; + private ImageView iconServer; + private ImageView iconLora; private Button btnTrack; private Button btnPairedTrack; + private Button btnFindHill; private Button btnCenterMe; private Button btnCenterTx; private Button btnCenterRx; @@ -104,7 +116,9 @@ public class MapFragment extends Fragment { private boolean mapInitialized; private NetworkMonitor networkMonitor; private NetworkMonitor.Listener networkListener; + private PeerStatsCache peerStatsCache; private boolean networkOnline = true; + private boolean serverConnected; private boolean userMovedMap; private List lastDevices = new ArrayList<>(); private Polyline liveTrackPolyline; @@ -113,6 +127,9 @@ public class MapFragment extends Fragment { private Bitmap bitmapTx; private Bitmap bitmapRx; private Bitmap bitmapTrackPoint; + private Bitmap bitmapHill; + private Marker hillMarker; + private Polyline hillPathLine; private float touchDownX; private float touchDownY; @@ -124,6 +141,7 @@ public class MapFragment extends Fragment { trackRecorder = app.getTrackRecorder(); commandPoller = app.getCommandPoller(); networkMonitor = app.getNetworkMonitor(); + peerStatsCache = app.getPeerStatsCache(); } @Nullable @@ -141,9 +159,13 @@ public class MapFragment extends Fragment { mapView = view.findViewById(R.id.mapView); mapStatus = view.findViewById(R.id.mapStatus); mapDistance = view.findViewById(R.id.mapDistance); + mapHillStatus = view.findViewById(R.id.mapHillStatus); + iconServer = view.findViewById(R.id.iconServer); + iconLora = view.findViewById(R.id.iconLora); trackStatus = view.findViewById(R.id.trackStatus); btnTrack = view.findViewById(R.id.btnTrack); btnPairedTrack = view.findViewById(R.id.btnPairedTrack); + btnFindHill = view.findViewById(R.id.btnFindHill); btnCenterMe = view.findViewById(R.id.btnCenterMe); btnCenterTx = view.findViewById(R.id.btnCenterTx); btnCenterRx = view.findViewById(R.id.btnCenterRx); @@ -163,6 +185,11 @@ public class MapFragment extends Fragment { if (btnCenterBoth != null) { btnCenterBoth.setOnClickListener(v -> centerOnBoth()); } + if (btnFindHill != null) { + btnFindHill.setOnClickListener(v -> findNearestHill()); + } + + updateConnectionIcons(lastDevices, serverConnected); networkOnline = networkMonitor != null && networkMonitor.isOnline(); networkListener = online -> { @@ -257,6 +284,7 @@ public class MapFragment extends Fragment { bitmapTx = MapsforgeBitmaps.dot(ARGB_TX, 20); bitmapRx = MapsforgeBitmaps.dot(ARGB_RX, 20); bitmapTrackPoint = MapsforgeBitmaps.dot(ARGB_TRACK, 12); + bitmapHill = MapsforgeBitmaps.dot(ARGB_HILL, 22); mapInitialized = true; } @@ -306,6 +334,7 @@ public class MapFragment extends Fragment { removeAllDeviceMarkers(); clearTrackLayers(); clearLiveTrackLayers(); + clearHillLayers(); deviceMarkers.clear(); if (downloadLayer != null) { downloadLayer.onDestroy(); @@ -319,6 +348,11 @@ public class MapFragment extends Fragment { bitmapTx = null; bitmapRx = null; bitmapTrackPoint = null; + bitmapHill = null; + iconServer = null; + iconLora = null; + btnFindHill = null; + mapHillStatus = null; mapStatus = null; mapDistance = null; trackStatus = null; @@ -635,12 +669,17 @@ public class MapFragment extends Fragment { return; } requireActivity().runOnUiThread(() -> - runWhenMapReady(() -> updateMap(devices))); + runWhenMapReady(() -> { + serverConnected = true; + updateMap(devices); + })); } catch (Exception e) { + serverConnected = false; if (!isAdded() || !mapResumed) { return; } requireActivity().runOnUiThread(() -> { + updateConnectionIcons(lastDevices, false); if (mapStatus != null && pollHelper != null && pollHelper.canRun()) { mapStatus.setText(getString(R.string.map_error, e.getMessage())); } @@ -652,6 +691,186 @@ public class MapFragment extends Fragment { }); } + private void tintIcon(@Nullable ImageView view, int color) { + if (view == null) { + return; + } + view.setColorFilter(color, PorterDuff.Mode.SRC_IN); + } + + private void updateConnectionIcons(List devices, boolean serverOk) { + tintIcon( + iconServer, + ContextCompat.getColor(requireContext(), serverOk ? R.color.status_ok : R.color.status_off) + ); + tintIcon(iconLora, resolveLoraLinkColor(devices)); + } + + private int resolveLoraLinkColor(List devices) { + if (uploader == null || !uploader.isTelnetConnected()) { + return ContextCompat.getColor(requireContext(), R.color.status_off); + } + PeerDevices.Result peer = PeerDevices.resolve(devices, uploader.getDeviceId()); + if (!peer.bothOnline()) { + return ContextCompat.getColor(requireContext(), R.color.status_off); + } + if (hasLoraLinkData(devices, peer)) { + return ContextCompat.getColor(requireContext(), R.color.status_ok); + } + return ContextCompat.getColor(requireContext(), R.color.status_warn); + } + + private boolean hasLoraLinkData(List devices, PeerDevices.Result peer) { + if (peer.peerId != null) { + for (DeviceInfo d : devices) { + if (peer.peerId.equals(d.device_id)) { + if (d.rssi != null || d.range_m != null) { + return true; + } + } + } + } + StatsExtractor.ExtractedStats stats = uploader.getLastStats(); + if (stats != null && (stats.rssiDbm != null || stats.rssi != null || stats.rangeM != null)) { + return true; + } + if (peerStatsCache != null) { + PeerStatsCache.Snapshot snap = peerStatsCache.get(); + if (snap != null + && System.currentTimeMillis() - snap.atMs < LORA_STATS_FRESH_MS + && snap.rssi != null) { + return true; + } + } + return false; + } + + @Nullable + private LatLong resolveSelfPosition() { + if (uploader != null && uploader.hasGpsFix()) { + return new LatLong(uploader.getGpsLat(), uploader.getGpsLon()); + } + if (uploader != null) { + String myId = uploader.getDeviceId(); + for (DeviceInfo d : lastDevices) { + if (myId.equals(d.device_id) && GeoUtils.isValidCoordinate(d.lat, d.lon)) { + return new LatLong(d.lat, d.lon); + } + } + } + return null; + } + + private void findNearestHill() { + if (uploader == null || !isAdded()) { + return; + } + LatLong self = resolveSelfPosition(); + if (self == null) { + Toast.makeText(requireContext(), R.string.map_find_hill_no_gps, Toast.LENGTH_SHORT).show(); + return; + } + if (mapHillStatus != null) { + mapHillStatus.setVisibility(View.VISIBLE); + mapHillStatus.setText(R.string.map_find_hill_search); + } + if (btnFindHill != null) { + btnFindHill.setEnabled(false); + } + final double lat = self.latitude; + final double lon = self.longitude; + executor.execute(() -> { + try { + NearestHillResult result = uploader.getServerApi() + .findNearestHill(lat, lon, HILL_SEARCH_RADIUS_M); + if (!isAdded()) { + return; + } + requireActivity().runOnUiThread(() -> { + if (btnFindHill != null) { + btnFindHill.setEnabled(true); + } + if (result == null || !result.ok || result.hill == null) { + if (mapHillStatus != null) { + mapHillStatus.setVisibility(View.VISIBLE); + String err = result != null && result.error != null + ? result.error + : getString(R.string.map_find_hill_none); + mapHillStatus.setText(getString(R.string.map_find_hill_error, err)); + } + return; + } + showHillOnMap(lat, lon, result); + }); + } catch (Exception e) { + if (!isAdded()) { + return; + } + requireActivity().runOnUiThread(() -> { + if (btnFindHill != null) { + btnFindHill.setEnabled(true); + } + if (mapHillStatus != null) { + mapHillStatus.setVisibility(View.VISIBLE); + mapHillStatus.setText(getString(R.string.map_find_hill_error, e.getMessage())); + } + }); + } + }); + } + + private void showHillOnMap(double fromLat, double fromLon, NearestHillResult result) { + if (!isMapReady() || result.hill == null) { + return; + } + NearestHillResult.HillPoint hill = result.hill; + LatLong hillPos = new LatLong(hill.lat, hill.lon); + clearHillLayers(); + + hillMarker = new Marker(hillPos, bitmapHill, 0, 0); + mapView.getLayerManager().getLayers().add(hillMarker); + + List path = new ArrayList<>(); + path.add(new LatLong(fromLat, fromLon)); + path.add(hillPos); + hillPathLine = new Polyline( + MapsforgeBitmaps.linePaint(Color.RED, 3f), + AndroidGraphicFactory.INSTANCE + ); + hillPathLine.getLatLongs().addAll(path); + mapView.getLayerManager().getLayers().add(hillPathLine); + + double elev = hill.elevation_m != null ? hill.elevation_m : 0.0; + double gain = hill.prominence_m != null ? hill.prominence_m : 0.0; + double dist = hill.dist_m != null ? hill.dist_m : GeoUtils.haversineMeters(fromLat, fromLon, hill.lat, hill.lon); + if (mapHillStatus != null) { + mapHillStatus.setVisibility(View.VISIBLE); + mapHillStatus.setText(getString(R.string.map_find_hill_result, elev, gain, dist)); + } + + List bounds = new ArrayList<>(); + bounds.add(new LatLong(fromLat, fromLon)); + bounds.add(hillPos); + fitBoundsOnce(bounds, false, true); + mapView.invalidate(); + } + + private void clearHillLayers() { + if (mapView == null) { + hillMarker = null; + hillPathLine = null; + return; + } + if (hillMarker != null) { + mapView.getLayerManager().getLayers().remove(hillMarker); + } + if (hillPathLine != null) { + mapView.getLayerManager().getLayers().remove(hillPathLine); + } + hillMarker = null; + hillPathLine = null; + } + private void updateMap(List devices) { if (!isMapReady()) { return; @@ -707,6 +926,7 @@ public class MapFragment extends Fragment { )); } updateGpsDistance(); + updateConnectionIcons(lastDevices, serverConnected); if (!boundsPoints.isEmpty() && !userMovedMap && !MapSessionState.initialFitDone) { fitBoundsOnce(boundsPoints, onMap == 1, false); diff --git a/app/src/main/res/drawable/ic_hill.xml b/app/src/main/res/drawable/ic_hill.xml new file mode 100644 index 0000000..9788eb1 --- /dev/null +++ b/app/src/main/res/drawable/ic_hill.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_link_lora.xml b/app/src/main/res/drawable/ic_link_lora.xml new file mode 100644 index 0000000..ff21f8b --- /dev/null +++ b/app/src/main/res/drawable/ic_link_lora.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_link_server.xml b/app/src/main/res/drawable/ic_link_server.xml new file mode 100644 index 0000000..6877ae0 --- /dev/null +++ b/app/src/main/res/drawable/ic_link_server.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index 2740960..34359d7 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -25,6 +25,44 @@ android:orientation="vertical" android:padding="6dp"> + + + + + + + + + + + +