generated from Grigo/AndroidTemplate
added subproxy
This commit is contained in:
@@ -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() {
|
private Double validLat() {
|
||||||
return GeoUtils.isValidCoordinate(lat, lon) ? lat : null;
|
return GeoUtils.isValidCoordinate(lat, lon) ? lat : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,6 +195,40 @@ public class ServerApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> 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")
|
@SuppressWarnings("unchecked")
|
||||||
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
|
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.grigowashere.loratester.ui;
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.graphics.PorterDuff;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.MotionEvent;
|
import android.view.MotionEvent;
|
||||||
@@ -9,20 +10,24 @@ import android.view.ViewGroup;
|
|||||||
import android.widget.AdapterView;
|
import android.widget.AdapterView;
|
||||||
import android.widget.ArrayAdapter;
|
import android.widget.ArrayAdapter;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
|
import android.widget.ImageView;
|
||||||
import android.widget.Spinner;
|
import android.widget.Spinner;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
|
|
||||||
import com.grigowashere.loratester.CommandPoller;
|
import com.grigowashere.loratester.CommandPoller;
|
||||||
import com.grigowashere.loratester.LoraApp;
|
import com.grigowashere.loratester.LoraApp;
|
||||||
import com.grigowashere.loratester.PeerDevices;
|
import com.grigowashere.loratester.PeerDevices;
|
||||||
|
import com.grigowashere.loratester.PeerStatsCache;
|
||||||
import com.grigowashere.loratester.R;
|
import com.grigowashere.loratester.R;
|
||||||
import com.grigowashere.loratester.TelemetryUploader;
|
import com.grigowashere.loratester.TelemetryUploader;
|
||||||
import com.grigowashere.loratester.api.DeviceInfo;
|
import com.grigowashere.loratester.api.DeviceInfo;
|
||||||
|
import com.grigowashere.loratester.api.NearestHillResult;
|
||||||
import com.grigowashere.loratester.api.ServerApi;
|
import com.grigowashere.loratester.api.ServerApi;
|
||||||
import com.grigowashere.loratester.api.TrackDetail;
|
import com.grigowashere.loratester.api.TrackDetail;
|
||||||
import com.grigowashere.loratester.api.TrackInfo;
|
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_TX = 0xFFE94560;
|
||||||
private static final int ARGB_RX = 0xFF4FC3F7;
|
private static final int ARGB_RX = 0xFF4FC3F7;
|
||||||
private static final int ARGB_TRACK = 0xFF00FF88;
|
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 ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
private final DateFormat timeFormat =
|
private final DateFormat timeFormat =
|
||||||
@@ -91,9 +99,13 @@ public class MapFragment extends Fragment {
|
|||||||
private TileCache tileCache;
|
private TileCache tileCache;
|
||||||
private TextView mapStatus;
|
private TextView mapStatus;
|
||||||
private TextView mapDistance;
|
private TextView mapDistance;
|
||||||
|
private TextView mapHillStatus;
|
||||||
private TextView trackStatus;
|
private TextView trackStatus;
|
||||||
|
private ImageView iconServer;
|
||||||
|
private ImageView iconLora;
|
||||||
private Button btnTrack;
|
private Button btnTrack;
|
||||||
private Button btnPairedTrack;
|
private Button btnPairedTrack;
|
||||||
|
private Button btnFindHill;
|
||||||
private Button btnCenterMe;
|
private Button btnCenterMe;
|
||||||
private Button btnCenterTx;
|
private Button btnCenterTx;
|
||||||
private Button btnCenterRx;
|
private Button btnCenterRx;
|
||||||
@@ -104,7 +116,9 @@ public class MapFragment extends Fragment {
|
|||||||
private boolean mapInitialized;
|
private boolean mapInitialized;
|
||||||
private NetworkMonitor networkMonitor;
|
private NetworkMonitor networkMonitor;
|
||||||
private NetworkMonitor.Listener networkListener;
|
private NetworkMonitor.Listener networkListener;
|
||||||
|
private PeerStatsCache peerStatsCache;
|
||||||
private boolean networkOnline = true;
|
private boolean networkOnline = true;
|
||||||
|
private boolean serverConnected;
|
||||||
private boolean userMovedMap;
|
private boolean userMovedMap;
|
||||||
private List<DeviceInfo> lastDevices = new ArrayList<>();
|
private List<DeviceInfo> lastDevices = new ArrayList<>();
|
||||||
private Polyline liveTrackPolyline;
|
private Polyline liveTrackPolyline;
|
||||||
@@ -113,6 +127,9 @@ public class MapFragment extends Fragment {
|
|||||||
private Bitmap bitmapTx;
|
private Bitmap bitmapTx;
|
||||||
private Bitmap bitmapRx;
|
private Bitmap bitmapRx;
|
||||||
private Bitmap bitmapTrackPoint;
|
private Bitmap bitmapTrackPoint;
|
||||||
|
private Bitmap bitmapHill;
|
||||||
|
private Marker hillMarker;
|
||||||
|
private Polyline hillPathLine;
|
||||||
private float touchDownX;
|
private float touchDownX;
|
||||||
private float touchDownY;
|
private float touchDownY;
|
||||||
|
|
||||||
@@ -124,6 +141,7 @@ public class MapFragment extends Fragment {
|
|||||||
trackRecorder = app.getTrackRecorder();
|
trackRecorder = app.getTrackRecorder();
|
||||||
commandPoller = app.getCommandPoller();
|
commandPoller = app.getCommandPoller();
|
||||||
networkMonitor = app.getNetworkMonitor();
|
networkMonitor = app.getNetworkMonitor();
|
||||||
|
peerStatsCache = app.getPeerStatsCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
@@ -141,9 +159,13 @@ public class MapFragment extends Fragment {
|
|||||||
mapView = view.findViewById(R.id.mapView);
|
mapView = view.findViewById(R.id.mapView);
|
||||||
mapStatus = view.findViewById(R.id.mapStatus);
|
mapStatus = view.findViewById(R.id.mapStatus);
|
||||||
mapDistance = view.findViewById(R.id.mapDistance);
|
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);
|
trackStatus = view.findViewById(R.id.trackStatus);
|
||||||
btnTrack = view.findViewById(R.id.btnTrack);
|
btnTrack = view.findViewById(R.id.btnTrack);
|
||||||
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
|
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
|
||||||
|
btnFindHill = view.findViewById(R.id.btnFindHill);
|
||||||
btnCenterMe = view.findViewById(R.id.btnCenterMe);
|
btnCenterMe = view.findViewById(R.id.btnCenterMe);
|
||||||
btnCenterTx = view.findViewById(R.id.btnCenterTx);
|
btnCenterTx = view.findViewById(R.id.btnCenterTx);
|
||||||
btnCenterRx = view.findViewById(R.id.btnCenterRx);
|
btnCenterRx = view.findViewById(R.id.btnCenterRx);
|
||||||
@@ -163,6 +185,11 @@ public class MapFragment extends Fragment {
|
|||||||
if (btnCenterBoth != null) {
|
if (btnCenterBoth != null) {
|
||||||
btnCenterBoth.setOnClickListener(v -> centerOnBoth());
|
btnCenterBoth.setOnClickListener(v -> centerOnBoth());
|
||||||
}
|
}
|
||||||
|
if (btnFindHill != null) {
|
||||||
|
btnFindHill.setOnClickListener(v -> findNearestHill());
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConnectionIcons(lastDevices, serverConnected);
|
||||||
|
|
||||||
networkOnline = networkMonitor != null && networkMonitor.isOnline();
|
networkOnline = networkMonitor != null && networkMonitor.isOnline();
|
||||||
networkListener = online -> {
|
networkListener = online -> {
|
||||||
@@ -257,6 +284,7 @@ public class MapFragment extends Fragment {
|
|||||||
bitmapTx = MapsforgeBitmaps.dot(ARGB_TX, 20);
|
bitmapTx = MapsforgeBitmaps.dot(ARGB_TX, 20);
|
||||||
bitmapRx = MapsforgeBitmaps.dot(ARGB_RX, 20);
|
bitmapRx = MapsforgeBitmaps.dot(ARGB_RX, 20);
|
||||||
bitmapTrackPoint = MapsforgeBitmaps.dot(ARGB_TRACK, 12);
|
bitmapTrackPoint = MapsforgeBitmaps.dot(ARGB_TRACK, 12);
|
||||||
|
bitmapHill = MapsforgeBitmaps.dot(ARGB_HILL, 22);
|
||||||
|
|
||||||
mapInitialized = true;
|
mapInitialized = true;
|
||||||
}
|
}
|
||||||
@@ -306,6 +334,7 @@ public class MapFragment extends Fragment {
|
|||||||
removeAllDeviceMarkers();
|
removeAllDeviceMarkers();
|
||||||
clearTrackLayers();
|
clearTrackLayers();
|
||||||
clearLiveTrackLayers();
|
clearLiveTrackLayers();
|
||||||
|
clearHillLayers();
|
||||||
deviceMarkers.clear();
|
deviceMarkers.clear();
|
||||||
if (downloadLayer != null) {
|
if (downloadLayer != null) {
|
||||||
downloadLayer.onDestroy();
|
downloadLayer.onDestroy();
|
||||||
@@ -319,6 +348,11 @@ public class MapFragment extends Fragment {
|
|||||||
bitmapTx = null;
|
bitmapTx = null;
|
||||||
bitmapRx = null;
|
bitmapRx = null;
|
||||||
bitmapTrackPoint = null;
|
bitmapTrackPoint = null;
|
||||||
|
bitmapHill = null;
|
||||||
|
iconServer = null;
|
||||||
|
iconLora = null;
|
||||||
|
btnFindHill = null;
|
||||||
|
mapHillStatus = null;
|
||||||
mapStatus = null;
|
mapStatus = null;
|
||||||
mapDistance = null;
|
mapDistance = null;
|
||||||
trackStatus = null;
|
trackStatus = null;
|
||||||
@@ -635,12 +669,17 @@ public class MapFragment extends Fragment {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requireActivity().runOnUiThread(() ->
|
requireActivity().runOnUiThread(() ->
|
||||||
runWhenMapReady(() -> updateMap(devices)));
|
runWhenMapReady(() -> {
|
||||||
|
serverConnected = true;
|
||||||
|
updateMap(devices);
|
||||||
|
}));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
serverConnected = false;
|
||||||
if (!isAdded() || !mapResumed) {
|
if (!isAdded() || !mapResumed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requireActivity().runOnUiThread(() -> {
|
requireActivity().runOnUiThread(() -> {
|
||||||
|
updateConnectionIcons(lastDevices, false);
|
||||||
if (mapStatus != null && pollHelper != null && pollHelper.canRun()) {
|
if (mapStatus != null && pollHelper != null && pollHelper.canRun()) {
|
||||||
mapStatus.setText(getString(R.string.map_error, e.getMessage()));
|
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<DeviceInfo> 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<DeviceInfo> 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<DeviceInfo> 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<LatLong> 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<LatLong> 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<DeviceInfo> devices) {
|
private void updateMap(List<DeviceInfo> devices) {
|
||||||
if (!isMapReady()) {
|
if (!isMapReady()) {
|
||||||
return;
|
return;
|
||||||
@@ -707,6 +926,7 @@ public class MapFragment extends Fragment {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
updateGpsDistance();
|
updateGpsDistance();
|
||||||
|
updateConnectionIcons(lastDevices, serverConnected);
|
||||||
|
|
||||||
if (!boundsPoints.isEmpty() && !userMovedMap && !MapSessionState.initialFitDone) {
|
if (!boundsPoints.isEmpty() && !userMovedMap && !MapSessionState.initialFitDone) {
|
||||||
fitBoundsOnce(boundsPoints, onMap == 1, false);
|
fitBoundsOnce(boundsPoints, onMap == 1, false);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M14,6l-3.75,5 2.75,3.5L9,18H3l8.5,-10.5L14,6zM17.5,10.5L14,15h6l-2.5,-4.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,3C7.03,3 3,7.03 3,12h2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7h2c0,-4.97 -4.03,-9 -9,-9zM12,7c-2.76,0 -5,2.24 -5,5h2c0,-1.66 1.34,-3 3,-3s3,1.34 3,3h2c0,-2.76 -2.24,-5 -5,-5zM12,11c-0.55,0 -1,0.45 -1,1h2c0,-0.55 -0.45,-1 -1,-1zM4.5,14.5L2,17l2.5,2.5 1.4,-1.4L4.8,17l1.1,-1.1 -1.4,-1.4zM19.5,14.5l-1.4,1.4 1.1,1.1 -1.1,1.1 1.4,1.4L22,17l-2.5,-2.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M4,18h16v2H4v-2zM6,15h2v2H6v-2zM16,15h2v2h-2v-2zM8,12h8v2H8v-2zM10,9h4v2h-4V9zM12,6c-2.2,0 -4,1.8 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2h2c0,-2.2 -1.8,-4 -4,-4z" />
|
||||||
|
</vector>
|
||||||
@@ -25,6 +25,44 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="6dp">
|
android:padding="6dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iconServer"
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:contentDescription="@string/status_server"
|
||||||
|
android:src="@drawable/ic_link_server" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:text="@string/status_server_short"
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iconLora"
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:contentDescription="@string/status_lora"
|
||||||
|
android:src="@drawable/ic_link_lora" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:text="@string/status_lora_short"
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/mapStatus"
|
android:id="@+id/mapStatus"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -91,6 +129,27 @@
|
|||||||
android:textSize="10sp" />
|
android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnFindHill"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:drawableStart="@drawable/ic_hill"
|
||||||
|
android:drawablePadding="6dp"
|
||||||
|
android:minHeight="34dp"
|
||||||
|
android:text="@string/map_find_hill"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/mapHillStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textColor="#FFC107"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/mapLegend"
|
android:id="@+id/mapLegend"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -2,4 +2,7 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
<color name="status_ok">#FF00FF88</color>
|
||||||
|
<color name="status_warn">#FFFFC107</color>
|
||||||
|
<color name="status_off">#FF888888</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -81,5 +81,15 @@
|
|||||||
<string name="map_center_rx">RX</string>
|
<string name="map_center_rx">RX</string>
|
||||||
<string name="map_center_both">Оба</string>
|
<string name="map_center_both">Оба</string>
|
||||||
<string name="map_gps_distance">GPS между устройствами: %1$s m</string>
|
<string name="map_gps_distance">GPS между устройствами: %1$s m</string>
|
||||||
|
<string name="status_server">Связь с сервером</string>
|
||||||
|
<string name="status_server_short">сервер</string>
|
||||||
|
<string name="status_lora">Связь LoRa с другим устройством</string>
|
||||||
|
<string name="status_lora_short">LoRa</string>
|
||||||
|
<string name="map_find_hill">Ближайшая возвышенность</string>
|
||||||
|
<string name="map_find_hill_search">Поиск возвышенности…</string>
|
||||||
|
<string name="map_find_hill_result">Возвышенность: %1$.0f m · +%2$.0f m · %3$.0f m</string>
|
||||||
|
<string name="map_find_hill_none">В радиусе 5 км возвышенностей не найдено</string>
|
||||||
|
<string name="map_find_hill_no_gps">Нужен GPS для поиска</string>
|
||||||
|
<string name="map_find_hill_error">Ошибка: %1$s</string>
|
||||||
<string name="chat_self_label">Вы</string>
|
<string name="chat_self_label">Вы</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ curl http://127.0.0.1:7634/api/health
|
|||||||
|
|
||||||
- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo)
|
- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo)
|
||||||
- `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку
|
- `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку
|
||||||
|
- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность (прокси Open-Meteo)
|
||||||
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
|
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
|
||||||
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -307,3 +307,129 @@ def build_elevation_profile(
|
|||||||
if not elev_vals:
|
if not elev_vals:
|
||||||
result["api_error"] = "elevation API returned no values"
|
result["api_error"] = "elevation API returned no values"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _offset_m(lat: float, lon: float, north_m: float, east_m: float) -> tuple[float, float]:
|
||||||
|
dlat = north_m / 111_320.0
|
||||||
|
dlon = east_m / (111_320.0 * max(math.cos(math.radians(lat)), 1e-6))
|
||||||
|
return lat + dlat, lon + dlon
|
||||||
|
|
||||||
|
|
||||||
|
def find_nearest_hill(
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
radius_m: float = 5000.0,
|
||||||
|
step_m: float = 300.0,
|
||||||
|
min_prominence_m: float = 8.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Find nearest local elevation maximum around a point."""
|
||||||
|
probe = probe_elevation_api()
|
||||||
|
if not probe["ok"]:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"elevation API unreachable: {probe['error']}",
|
||||||
|
"elevation_url": ELEVATION_API_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
radius_m = max(500.0, min(float(radius_m), 15_000.0))
|
||||||
|
step_m = max(100.0, min(float(step_m), 500.0))
|
||||||
|
min_prominence_m = max(3.0, min(float(min_prominence_m), 100.0))
|
||||||
|
|
||||||
|
center_elev = fetch_elevation_m(lat, lon)
|
||||||
|
if center_elev is None:
|
||||||
|
return {"ok": False, "error": "no elevation at center"}
|
||||||
|
|
||||||
|
steps = int(radius_m / step_m)
|
||||||
|
grid_cells: list[tuple[int, int, float, float, float]] = []
|
||||||
|
for i in range(-steps, steps + 1):
|
||||||
|
for j in range(-steps, steps + 1):
|
||||||
|
north = i * step_m
|
||||||
|
east = j * step_m
|
||||||
|
dist = math.hypot(north, east)
|
||||||
|
if dist > radius_m:
|
||||||
|
continue
|
||||||
|
la, lo = _offset_m(lat, lon, north, east)
|
||||||
|
grid_cells.append((i, j, la, lo, dist))
|
||||||
|
|
||||||
|
if not grid_cells:
|
||||||
|
return {"ok": False, "error": "empty search grid"}
|
||||||
|
|
||||||
|
lats = [c[2] for c in grid_cells]
|
||||||
|
lons = [c[3] for c in grid_cells]
|
||||||
|
elevations = fetch_elevations_batch(lats, lons)
|
||||||
|
|
||||||
|
grid: dict[tuple[int, int], dict[str, Any]] = {}
|
||||||
|
for (i, j, la, lo, dist), elev in zip(grid_cells, elevations):
|
||||||
|
grid[(i, j)] = {
|
||||||
|
"lat": round(la, 6),
|
||||||
|
"lon": round(lo, 6),
|
||||||
|
"dist_m": round(dist, 1),
|
||||||
|
"elevation_m": elev,
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_local_max(i: int, j: int, elev: float) -> bool:
|
||||||
|
for di in (-1, 0, 1):
|
||||||
|
for dj in (-1, 0, 1):
|
||||||
|
if di == 0 and dj == 0:
|
||||||
|
continue
|
||||||
|
n = grid.get((i + di, j + dj))
|
||||||
|
if n and n["elevation_m"] is not None and n["elevation_m"] >= elev:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
for (i, j), cell in grid.items():
|
||||||
|
elev = cell.get("elevation_m")
|
||||||
|
if elev is None:
|
||||||
|
continue
|
||||||
|
prominence = float(elev) - center_elev
|
||||||
|
if prominence < min_prominence_m:
|
||||||
|
continue
|
||||||
|
if is_local_max(i, j, float(elev)):
|
||||||
|
candidates.append({**cell, "prominence_m": round(prominence, 1)})
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
best = None
|
||||||
|
for cell in grid.values():
|
||||||
|
elev = cell.get("elevation_m")
|
||||||
|
if elev is None:
|
||||||
|
continue
|
||||||
|
prominence = float(elev) - center_elev
|
||||||
|
if prominence < min_prominence_m * 0.5:
|
||||||
|
continue
|
||||||
|
if best is None or cell["dist_m"] < best["dist_m"]:
|
||||||
|
best = {
|
||||||
|
**cell,
|
||||||
|
"prominence_m": round(prominence, 1),
|
||||||
|
"is_local_max": False,
|
||||||
|
}
|
||||||
|
if best is None:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "no hill found in radius",
|
||||||
|
"center": {
|
||||||
|
"lat": round(lat, 6),
|
||||||
|
"lon": round(lon, 6),
|
||||||
|
"elevation_m": center_elev,
|
||||||
|
},
|
||||||
|
"radius_m": radius_m,
|
||||||
|
}
|
||||||
|
hill = best
|
||||||
|
else:
|
||||||
|
candidates.sort(key=lambda c: c["dist_m"])
|
||||||
|
hill = {**candidates[0], "is_local_max": True}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"center": {
|
||||||
|
"lat": round(lat, 6),
|
||||||
|
"lon": round(lon, 6),
|
||||||
|
"elevation_m": center_elev,
|
||||||
|
},
|
||||||
|
"hill": hill,
|
||||||
|
"candidates": len(candidates),
|
||||||
|
"radius_m": radius_m,
|
||||||
|
"step_m": step_m,
|
||||||
|
"api_source": "elevation",
|
||||||
|
"elevation_url": ELEVATION_API_URL,
|
||||||
|
}
|
||||||
|
|||||||
@@ -331,6 +331,19 @@ def track_elevation_profile(
|
|||||||
return build_elevation_profile(track.get("points") or [], step_m)
|
return build_elevation_profile(track.get("points") or [], step_m)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/elevation/nearest-hill")
|
||||||
|
def elevation_nearest_hill(
|
||||||
|
lat: float = Query(..., ge=-90.0, le=90.0),
|
||||||
|
lon: float = Query(..., ge=-180.0, le=180.0),
|
||||||
|
radius_m: float = Query(5000.0, ge=500.0, le=15000.0),
|
||||||
|
step_m: float = Query(300.0, ge=100.0, le=500.0),
|
||||||
|
min_prominence_m: float = Query(8.0, ge=3.0, le=100.0),
|
||||||
|
):
|
||||||
|
from core.elevation import find_nearest_hill
|
||||||
|
|
||||||
|
return find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
from core.elevation import elevation_status
|
from core.elevation import elevation_status
|
||||||
|
|||||||
@@ -264,6 +264,20 @@ def track_elevation_profile(track_id: int):
|
|||||||
return jsonify(build_elevation_profile(points, step_m or 10.0))
|
return jsonify(build_elevation_profile(points, step_m or 10.0))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/elevation/nearest-hill")
|
||||||
|
def elevation_nearest_hill():
|
||||||
|
from core.elevation import find_nearest_hill
|
||||||
|
|
||||||
|
lat = request.args.get("lat", type=float)
|
||||||
|
lon = request.args.get("lon", type=float)
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return jsonify({"ok": False, "error": "lat and lon required"}), 400
|
||||||
|
radius_m = request.args.get("radius_m", 5000, type=float)
|
||||||
|
step_m = request.args.get("step_m", 300, type=float)
|
||||||
|
min_prominence_m = request.args.get("min_prominence_m", 8, type=float)
|
||||||
|
return jsonify(find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
from core.elevation import elevation_status
|
from core.elevation import elevation_status
|
||||||
|
|||||||
Binary file not shown.
@@ -62,3 +62,34 @@ def test_build_profile_reports_unreachable(monkeypatch):
|
|||||||
|
|
||||||
assert profile["points"] == []
|
assert profile["points"] == []
|
||||||
assert "unreachable" in profile["api_error"]
|
assert "unreachable" in profile["api_error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_nearest_hill_unreachable(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
elev,
|
||||||
|
"probe_elevation_api",
|
||||||
|
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||||
|
)
|
||||||
|
result = elev.find_nearest_hill(55.75, 37.62)
|
||||||
|
assert result["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
|
||||||
|
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||||
|
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||||
|
|
||||||
|
def fake_batch(lats, lons):
|
||||||
|
out = []
|
||||||
|
for la, lo in zip(lats, lons):
|
||||||
|
if abs(la - 55.75) < 1e-4 and abs(lo - 37.62) < 1e-4:
|
||||||
|
out.append(100.0)
|
||||||
|
elif la > 55.75:
|
||||||
|
out.append(130.0)
|
||||||
|
else:
|
||||||
|
out.append(95.0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
monkeypatch.setattr(elev, "fetch_elevations_batch", fake_batch)
|
||||||
|
result = elev.find_nearest_hill(55.75, 37.62, radius_m=2000, step_m=300, min_prominence_m=8)
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["hill"]["elevation_m"] >= 120.0
|
||||||
|
|||||||
Reference in New Issue
Block a user