From 85de7a5ca926f7512471009b4198e16e94732025 Mon Sep 17 00:00:00 2001 From: grigo Date: Fri, 26 Jun 2026 10:29:26 +0300 Subject: [PATCH] fixed referrence --- .../com/grigowashere/loratester/LoraApp.java | 6 + .../loratester/ReferencePointRecorder.java | 7 +- .../ReferencePointSelectionRepository.java | 63 ++++ .../loratester/api/ReferencePointInfo.java | 18 ++ .../loratester/api/ServerApi.java | 23 ++ .../loratester/ui/MapFragment.java | 291 +++++++++++++++++- app/src/main/res/layout/fragment_map.xml | 38 +++ app/src/main/res/values/strings.xml | 8 + .../core/__pycache__/storage.cpython-313.pyc | Bin 55984 -> 59126 bytes server/core/storage.py | 131 +++++++- server/static/app.css | 7 + server/static/app.js | 198 ++++++++++-- server/static/index.html | 9 +- .../test_schema.cpython-313-pytest-9.0.3.pyc | Bin 20243 -> 21748 bytes server/tests/test_schema.py | 4 + 15 files changed, 764 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/grigowashere/loratester/ReferencePointSelectionRepository.java create mode 100644 app/src/main/java/com/grigowashere/loratester/api/ReferencePointInfo.java diff --git a/app/src/main/java/com/grigowashere/loratester/LoraApp.java b/app/src/main/java/com/grigowashere/loratester/LoraApp.java index ee75bd8..12ee85e 100644 --- a/app/src/main/java/com/grigowashere/loratester/LoraApp.java +++ b/app/src/main/java/com/grigowashere/loratester/LoraApp.java @@ -22,6 +22,7 @@ public class LoraApp extends Application { private LocationTracker locationTracker; private LinkLossAlerter linkLossAlerter; private MacroPresetRepository macroPresetRepository; + private ReferencePointSelectionRepository referencePointSelectionRepository; @Override public void onCreate() { @@ -29,6 +30,7 @@ public class LoraApp extends Application { AndroidGraphicFactory.createInstance(this); settingsRepository = new SettingsRepository(this); macroPresetRepository = new MacroPresetRepository(this); + referencePointSelectionRepository = new ReferencePointSelectionRepository(this); linkLossAlerter = new LinkLossAlerter(this, settingsRepository); networkMonitor = new NetworkMonitor(this); networkMonitor.start(); @@ -92,6 +94,10 @@ public class LoraApp extends Application { return macroPresetRepository; } + public ReferencePointSelectionRepository getReferencePointSelectionRepository() { + return referencePointSelectionRepository; + } + public PeerStatsCache getPeerStatsCache() { return peerStatsCache; } diff --git a/app/src/main/java/com/grigowashere/loratester/ReferencePointRecorder.java b/app/src/main/java/com/grigowashere/loratester/ReferencePointRecorder.java index 99b9a62..2e761ab 100644 --- a/app/src/main/java/com/grigowashere/loratester/ReferencePointRecorder.java +++ b/app/src/main/java/com/grigowashere/loratester/ReferencePointRecorder.java @@ -107,6 +107,11 @@ public class ReferencePointRecorder { notifyError("Нужна сеть для старта реперной точки"); return; } + StatsExtractor.ExtractedStats stats = uploader.getLastStats(); + if (!StatsExtractor.ROLE_RX.equals(stats != null ? stats.role : null)) { + notifyError("Реперная точка доступна только на устройстве RX"); + return; + } try { Double lat = validLat(); Double lon = validLon(); @@ -203,7 +208,7 @@ public class ReferencePointRecorder { if (lon != null) point.put("lon", lon); if (stats != null) { if (stats.rssi != null) point.put("rssi", stats.rssi); - if (stats.role != null) point.put("role", stats.role); + point.put("role", StatsExtractor.ROLE_RX); if (stats.metaJson != null) point.put("meta", stats.metaJson); } synchronized (buffer) { diff --git a/app/src/main/java/com/grigowashere/loratester/ReferencePointSelectionRepository.java b/app/src/main/java/com/grigowashere/loratester/ReferencePointSelectionRepository.java new file mode 100644 index 0000000..18f1807 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ReferencePointSelectionRepository.java @@ -0,0 +1,63 @@ +package com.grigowashere.loratester; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public class ReferencePointSelectionRepository { + + private static final String PREFS = "reference_point_selection"; + private static final String KEY_SELECTED = "selected_ids"; + private static final Gson GSON = new Gson(); + private static final Type SET_TYPE = new TypeToken>() {}.getType(); + + private final SharedPreferences prefs; + + public ReferencePointSelectionRepository(Context context) { + prefs = context.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + public Set getSelectedIds() { + String raw = prefs.getString(KEY_SELECTED, null); + if (raw == null || raw.isBlank()) { + return new LinkedHashSet<>(); + } + Set parsed = GSON.fromJson(raw, SET_TYPE); + return parsed != null ? parsed : new LinkedHashSet<>(); + } + + public void setSelectedIds(Set ids) { + LinkedHashSet copy = new LinkedHashSet<>(); + if (ids != null) { + copy.addAll(ids); + } + prefs.edit().putString(KEY_SELECTED, GSON.toJson(copy)).apply(); + } + + public void toggle(long sessionId) { + Set ids = getSelectedIds(); + if (ids.contains(sessionId)) { + ids.remove(sessionId); + } else { + ids.add(sessionId); + } + setSelectedIds(ids); + } + + public boolean isSelected(long sessionId) { + return getSelectedIds().contains(sessionId); + } + + public List selectedList() { + return new ArrayList<>(getSelectedIds()); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/ReferencePointInfo.java b/app/src/main/java/com/grigowashere/loratester/api/ReferencePointInfo.java new file mode 100644 index 0000000..50742f6 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/ReferencePointInfo.java @@ -0,0 +1,18 @@ +package com.grigowashere.loratester.api; + +import java.util.Map; + +public class ReferencePointInfo { + public long id; + public String device_id; + public String device_label; + public String label; + public Double lat; + public Double lon; + public double started_at; + public Double ended_at; + public String status; + public int sample_count; + public Map reference_context; + public Map stats; +} 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 5820566..593a3ca 100644 --- a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java +++ b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java @@ -32,6 +32,8 @@ public class ServerApi { private static final Type TRACK_LIST = new TypeToken>() {}.getType(); private static final Type COMMAND_LIST = new TypeToken>() {}.getType(); private static final Type MACRO_PRESET_LIST = new TypeToken>() {}.getType(); + private static final Type REFERENCE_POINT_LIST = + new TypeToken>() {}.getType(); private final String baseUrl; private final OkHttpClient client; @@ -248,6 +250,27 @@ public class ServerApi { postJson("/api/reference-points/" + sessionId + "/finish", new HashMap<>(), true); } + public List listReferencePoints(String deviceId) throws IOException { + String path = "/api/reference-points?limit=100"; + if (deviceId != null && !deviceId.isBlank()) { + path += "&device_id=" + deviceId; + } + return getJsonList(path, REFERENCE_POINT_LIST); + } + + public ReferencePointInfo getReferencePoint(long sessionId) throws IOException { + Request request = new Request.Builder() + .url(baseUrl + "/api/reference-points/" + sessionId) + .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(), ReferencePointInfo.class); + } + } + public List listMacroPresets() throws IOException { return getJsonList("/api/macro-presets", MACRO_PRESET_LIST); } 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 8139f1c..4d67e64 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -9,7 +9,6 @@ import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; -import android.view.View; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.ImageButton; @@ -18,6 +17,8 @@ import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; +import androidx.appcompat.app.AlertDialog; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; @@ -31,11 +32,13 @@ import com.grigowashere.loratester.LoraApp; import com.grigowashere.loratester.PeerDevices; import com.grigowashere.loratester.PeerStatsCache; import com.grigowashere.loratester.R; +import com.grigowashere.loratester.ReferencePointSelectionRepository; import com.grigowashere.loratester.ReferencePointRecorder; import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.api.DeviceInfo; import com.grigowashere.loratester.api.ElevationGridResult; import com.grigowashere.loratester.api.NearestHillResult; +import com.grigowashere.loratester.api.ReferencePointInfo; import com.grigowashere.loratester.api.ServerApi; import com.grigowashere.loratester.api.TrackDetail; import com.grigowashere.loratester.api.TrackInfo; @@ -68,6 +71,7 @@ import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -95,6 +99,8 @@ public class MapFragment extends Fragment { private static final int ARGB_TRACK = 0xFF00FF88; private static final int ARGB_SELF = 0xFF00FF88; private static final int ARGB_HILL = 0xFFFFC107; + private static final int ARGB_REF_RX = 0xFF00FF88; + private static final int ARGB_REF_TX = 0xFFFF9800; private static final int HILL_SEARCH_RADIUS_M = 5000; private static final long LORA_STATS_FRESH_MS = 120_000; private static final int HEATMAP_GPS_FOLLOW_M = 50; @@ -113,6 +119,7 @@ public class MapFragment extends Fragment { private LinkLossAlerter linkLossAlerter; private TrackRecorder trackRecorder; private ReferencePointRecorder referencePointRecorder; + private ReferencePointSelectionRepository referencePointSelectionRepository; private CommandPoller commandPoller; private MapView mapView; private TileDownloadLayer downloadLayer; @@ -124,6 +131,7 @@ public class MapFragment extends Fragment { private TextView mapHillStatus; private TextView trackStatus; private TextView referencePointStatus; + private TextView referencePointMapStatus; private ImageView iconServer; private ImageView iconLora; private ImageButton btnTrack; @@ -133,6 +141,9 @@ public class MapFragment extends Fragment { private ImageButton btnToolCenter; private ImageButton btnToolMore; private MaterialButton btnReferencePoint; + private MaterialButton btnReferenceSelect; + private MaterialButton btnReferenceShowMap; + private MaterialButton btnReferenceRepeat; private View mapToolDrawer; private MaterialButtonToggleGroup mapCenterMode; private Spinner trackSpinner; @@ -141,6 +152,8 @@ public class MapFragment extends Fragment { private TextView mapHeatmapStatus; private View mapHeatmapLegend; private List savedTracks = new ArrayList<>(); + private List savedReferencePoints = new ArrayList<>(); + private final List referencePointLayers = new ArrayList<>(); private boolean mapResumed; private boolean mapInitialized; private NetworkMonitor networkMonitor; @@ -158,6 +171,8 @@ public class MapFragment extends Fragment { private Bitmap bitmapTrackPoint; private Bitmap bitmapHill; private Bitmap bitmapSelf; + private Bitmap bitmapRefRx; + private Bitmap bitmapRefTx; private Marker selfMarker; private Marker hillMarker; private Polyline hillPathLine; @@ -187,6 +202,7 @@ public class MapFragment extends Fragment { linkLossAlerter = app.getLinkLossAlerter(); trackRecorder = app.getTrackRecorder(); referencePointRecorder = app.getReferencePointRecorder(); + referencePointSelectionRepository = app.getReferencePointSelectionRepository(); commandPoller = app.getCommandPoller(); networkMonitor = app.getNetworkMonitor(); peerStatsCache = app.getPeerStatsCache(); @@ -221,6 +237,10 @@ public class MapFragment extends Fragment { btnToolCenter = view.findViewById(R.id.btnToolCenter); btnToolMore = view.findViewById(R.id.btnToolMore); btnReferencePoint = view.findViewById(R.id.btnReferencePoint); + btnReferenceSelect = view.findViewById(R.id.btnReferenceSelect); + btnReferenceShowMap = view.findViewById(R.id.btnReferenceShowMap); + btnReferenceRepeat = view.findViewById(R.id.btnReferenceRepeat); + referencePointMapStatus = view.findViewById(R.id.referencePointMapStatus); mapToolDrawer = view.findViewById(R.id.mapToolDrawer); mapCenterMode = view.findViewById(R.id.mapCenterMode); mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius); @@ -281,6 +301,15 @@ public class MapFragment extends Fragment { if (btnReferencePoint != null) { btnReferencePoint.setOnClickListener(v -> toggleReferencePoint()); } + if (btnReferenceSelect != null) { + btnReferenceSelect.setOnClickListener(v -> openReferencePointSelectDialog()); + } + if (btnReferenceShowMap != null) { + btnReferenceShowMap.setOnClickListener(v -> showSelectedReferencePointsOnMap()); + } + if (btnReferenceRepeat != null) { + btnReferenceRepeat.setOnClickListener(v -> repeatSelectedReferencePoint()); + } setupTrackRecorderListener(); setupReferencePointRecorderListener(); setupTrackSpinnerListener(); @@ -429,6 +458,8 @@ public class MapFragment extends Fragment { bitmapTrackPoint = MapsforgeBitmaps.dot(ARGB_TRACK, 16); bitmapHill = MapsforgeBitmaps.dot(ARGB_HILL, 28); bitmapSelf = MapsforgeBitmaps.dot(ARGB_SELF, 24); + bitmapRefRx = MapsforgeBitmaps.dot(ARGB_REF_RX, 22); + bitmapRefTx = MapsforgeBitmaps.dot(ARGB_REF_TX, 22); mapInitialized = true; } @@ -451,6 +482,7 @@ public class MapFragment extends Fragment { setupReferencePointRecorderListener(); } loadTrackList(); + loadReferencePointList(); } @Override @@ -488,6 +520,7 @@ public class MapFragment extends Fragment { } removeAllDeviceMarkers(); removeSelfMarker(); + clearReferencePointLayers(); clearTrackLayers(); clearLiveTrackLayers(); clearHillLayers(); @@ -508,6 +541,8 @@ public class MapFragment extends Fragment { bitmapTrackPoint = null; bitmapHill = null; bitmapSelf = null; + bitmapRefRx = null; + bitmapRefTx = null; iconServer = null; iconLora = null; btnFindHill = null; @@ -515,6 +550,9 @@ public class MapFragment extends Fragment { btnToolCenter = null; btnToolMore = null; btnReferencePoint = null; + btnReferenceSelect = null; + btnReferenceShowMap = null; + btnReferenceRepeat = null; mapToolDrawer = null; mapCenterMode = null; mapHeatmapRadius = null; @@ -529,6 +567,7 @@ public class MapFragment extends Fragment { mapTrackStatus = null; trackStatus = null; referencePointStatus = null; + referencePointMapStatus = null; btnTrack = null; btnPairedTrack = null; trackSpinner = null; @@ -634,6 +673,9 @@ public class MapFragment extends Fragment { : R.string.reference_point_start); } updateReferencePointStatusUi(); + if (!recording) { + loadReferencePointList(); + } } @Override @@ -681,6 +723,253 @@ public class MapFragment extends Fragment { } } + private void loadReferencePointList() { + if (uploader == null || !mapResumed) { + return; + } + executor.execute(() -> { + try { + List points = uploader.getServerApi() + .listReferencePoints(uploader.getDeviceId()); + if (!isAdded()) { + return; + } + requireActivity().runOnUiThread(() -> { + savedReferencePoints = points != null ? points : new ArrayList<>(); + updateReferencePointMapStatus(); + }); + } catch (Exception e) { + if (!isAdded()) { + return; + } + requireActivity().runOnUiThread(() -> { + if (referencePointMapStatus != null) { + referencePointMapStatus.setText(e.getMessage()); + } + }); + } + }); + } + + private void updateReferencePointMapStatus() { + if (referencePointMapStatus == null || referencePointSelectionRepository == null) { + return; + } + int count = referencePointSelectionRepository.getSelectedIds().size(); + referencePointMapStatus.setText(getString(R.string.reference_point_map_status, count)); + } + + private void openReferencePointSelectDialog() { + if (!isAdded()) { + return; + } + if (savedReferencePoints.isEmpty()) { + Toast.makeText(requireContext(), R.string.reference_point_none, Toast.LENGTH_SHORT).show(); + loadReferencePointList(); + return; + } + String[] labels = new String[savedReferencePoints.size()]; + boolean[] checked = new boolean[savedReferencePoints.size()]; + Set selected = referencePointSelectionRepository.getSelectedIds(); + for (int i = 0; i < savedReferencePoints.size(); i++) { + ReferencePointInfo info = savedReferencePoints.get(i); + String title = info.label != null && !info.label.isBlank() + ? info.label + : ("Репер #" + info.id); + labels[i] = "#" + info.id + " · " + title; + checked[i] = selected.contains(info.id); + } + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.reference_point_select_title) + .setMultiChoiceItems(labels, checked, (dialog, which, isChecked) -> + checked[which] = isChecked) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + LinkedHashSet ids = new LinkedHashSet<>(); + for (int i = 0; i < checked.length; i++) { + if (checked[i]) { + ids.add(savedReferencePoints.get(i).id); + } + } + referencePointSelectionRepository.setSelectedIds(ids); + updateReferencePointMapStatus(); + showSelectedReferencePointsOnMap(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void showSelectedReferencePointsOnMap() { + if (mapView == null || referencePointSelectionRepository == null) { + return; + } + clearReferencePointLayers(); + Set selected = referencePointSelectionRepository.getSelectedIds(); + if (selected.isEmpty()) { + Toast.makeText(requireContext(), R.string.reference_point_repeat_no_selection, + Toast.LENGTH_SHORT).show(); + updateReferencePointMapStatus(); + return; + } + List bounds = new ArrayList<>(); + for (ReferencePointInfo info : savedReferencePoints) { + if (!selected.contains(info.id)) { + continue; + } + addReferencePointLayers(info, bounds); + } + if (!bounds.isEmpty()) { + centerOnPoints(bounds); + } + updateReferencePointMapStatus(); + requestMapInvalidate(); + } + + private void repeatSelectedReferencePoint() { + if (referencePointSelectionRepository == null) { + return; + } + List ids = referencePointSelectionRepository.selectedList(); + if (ids.isEmpty()) { + Toast.makeText(requireContext(), R.string.reference_point_repeat_no_selection, + Toast.LENGTH_SHORT).show(); + return; + } + ReferencePointInfo target = null; + for (ReferencePointInfo info : savedReferencePoints) { + if (info.id == ids.get(0)) { + target = info; + break; + } + } + if (target == null) { + Toast.makeText(requireContext(), R.string.reference_point_repeat_no_selection, + Toast.LENGTH_SHORT).show(); + return; + } + Map ctx = target.reference_context; + LatLong rx = positionFromContext(ctx, "rx_position", target.lat, target.lon); + LatLong tx = positionFromContext(ctx, "tx_position", null, null); + if (rx != null) { + List points = new ArrayList<>(); + points.add(rx); + if (tx != null) { + points.add(tx); + } + centerOnPoints(points); + showSelectedReferencePointsOnMap(); + } + Double distance = numberValue(ctx != null ? ctx.get("distance_m") : null); + String distText = distance != null + ? String.format(Locale.US, "%.0f", distance) + : "—"; + if (rx != null) { + Toast.makeText( + requireContext(), + getString( + R.string.reference_point_repeat_hint, + rx.latitude, + rx.longitude, + distText + ), + Toast.LENGTH_LONG + ).show(); + } + } + + private void addReferencePointLayers(ReferencePointInfo info, List bounds) { + Map ctx = info.reference_context; + LatLong rx = positionFromContext(ctx, "rx_position", info.lat, info.lon); + LatLong tx = positionFromContext(ctx, "tx_position", null, null); + if (rx == null) { + return; + } + bounds.add(rx); + Marker rxMarker = new Marker(rx, bitmapRefRx, 0, 0); + referencePointLayers.add(rxMarker); + mapView.getLayerManager().getLayers().add(rxMarker); + if (tx != null) { + bounds.add(tx); + Marker txMarker = new Marker(tx, bitmapRefTx, 0, 0); + referencePointLayers.add(txMarker); + mapView.getLayerManager().getLayers().add(txMarker); + Polyline line = new Polyline( + MapsforgeBitmaps.linePaint(ARGB_REF_TX, 3f), + AndroidGraphicFactory.INSTANCE + ); + line.getLatLongs().add(rx); + line.getLatLongs().add(tx); + referencePointLayers.add(line); + mapView.getLayerManager().getLayers().add(line); + } + } + + private void clearReferencePointLayers() { + if (mapView == null) { + referencePointLayers.clear(); + return; + } + for (Layer layer : referencePointLayers) { + mapView.getLayerManager().getLayers().remove(layer); + } + referencePointLayers.clear(); + } + + private LatLong positionFromContext( + Map ctx, + String key, + Double fallbackLat, + Double fallbackLon + ) { + Map pos = mapValue(ctx != null ? ctx.get(key) : null); + Double lat = pos != null ? numberValue(pos.get("lat")) : fallbackLat; + Double lon = pos != null ? numberValue(pos.get("lon")) : fallbackLon; + if (lat == null || lon == null || !GeoUtils.isValidCoordinate(lat, lon)) { + return null; + } + return new LatLong(lat, lon); + } + + @SuppressWarnings("unchecked") + private static Map mapValue(Object value) { + if (value instanceof Map map) { + return (Map) map; + } + return null; + } + + private static Double numberValue(Object value) { + if (value instanceof Number number) { + return number.doubleValue(); + } + return null; + } + + private void centerOnPoints(List points) { + if (mapView == null || points == null || points.isEmpty()) { + return; + } + if (points.size() == 1) { + mapView.getModel().mapViewPosition.setCenter(points.get(0)); + mapView.getModel().mapViewPosition.setZoomLevel((byte) Math.max( + mapView.getModel().mapViewPosition.getZoomLevel(), 15 + )); + return; + } + double minLat = points.get(0).latitude; + double maxLat = minLat; + double minLon = points.get(0).longitude; + double maxLon = minLon; + for (LatLong p : points) { + minLat = Math.min(minLat, p.latitude); + maxLat = Math.max(maxLat, p.latitude); + minLon = Math.min(minLon, p.longitude); + maxLon = Math.max(maxLon, p.longitude); + } + LatLong center = new LatLong((minLat + maxLat) / 2.0, (minLon + maxLon) / 2.0); + mapView.getModel().mapViewPosition.setCenter(center); + mapView.getModel().mapViewPosition.setZoomLevel((byte) 14); + } + private void appendLiveTrackPoint(double lat, double lon) { if (!isMapReady() || !GeoUtils.isValidCoordinate(lat, lon)) { return; diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index db1d7e3..2e89a99 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -304,6 +304,44 @@ android:textColor="#4FC3F7" android:textSize="9sp" /> + + + + + + + + Репер %1$s Репер #%1$d: %2$d samples · буфер %3$d Репер: %1$s + Выбрать реперы + Показать на карте + Повторить измерение + Реперные точки (RX) + Нет сохранённых реперных точек + Реперов на карте: %1$d + RX: %1$.6f, %2$.6f · дальность %3$s м + Выберите реперную точку Сохранённые треки — нет треков — Это устройство diff --git a/server/core/__pycache__/storage.cpython-313.pyc b/server/core/__pycache__/storage.cpython-313.pyc index 16107303e1bebfcb908c020f517d171121cb60e6..5d1908ace3bbb0ba342149a5cc885712fc370109 100644 GIT binary patch delta 7243 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 j#bDkT7PG%3DB$bx dict[str, Any]: return {key: data[key] for key in _TX_PARAM_KEYS if data.get(key) is not None} -def _reference_context(rows: list[dict[str, Any]]) -> dict[str, Any]: +def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + from math import asin, cos, radians, sin, sqrt + + r = 6_371_000.0 + dlat = radians(lat2 - lat1) + dlon = radians(lon2 - lon1) + a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2 + return 2 * r * asin(sqrt(a)) + + +def _position_from_row(row: dict[str, Any]) -> Optional[dict[str, Any]]: + lat = row.get("lat") + lon = row.get("lon") + if lat is None or lon is None: + return None + return { + "device_id": row.get("device_id"), + "lat": lat, + "lon": lon, + "ts": row.get("ts"), + } + + +def _reference_context( + rows: list[dict[str, Any]], + *, + session_device_id: Optional[str] = None, + session_lat: Optional[float] = None, + session_lon: Optional[float] = None, +) -> dict[str, Any]: + """Build measurement context. Session device is always RX (reference receiver).""" tx_params = None tx_device = None + tx_position = None rx_position = None roles: set[str] = set() devices: set[str] = set() + + if ( + session_device_id + and session_lat is not None + and session_lon is not None + ): + rx_position = { + "device_id": session_device_id, + "lat": session_lat, + "lon": session_lon, + } + for row in sorted(rows, key=lambda r: float(r.get("ts") or 0)): role = _role_from_row(row) if role: @@ -594,20 +637,51 @@ def _reference_context(rows: list[dict[str, Any]]) -> dict[str, Any]: if device_id: devices.add(str(device_id)) params = _tx_params_from_meta(row.get("meta")) - if params and (role == "TX" or tx_params is None): + if params and role == "TX": tx_params = params tx_device = device_id - if role == "RX" and row.get("lat") is not None and row.get("lon") is not None: - rx_position = { - "device_id": device_id, - "lat": row.get("lat"), - "lon": row.get("lon"), - "ts": row.get("ts"), - } + pos = _position_from_row(row) + if role == "TX" and pos is not None: + tx_position = pos + if ( + role == "RX" + and pos is not None + and ( + rx_position is None + or ( + session_device_id + and str(device_id) == str(session_device_id) + ) + ) + ): + rx_position = pos + + distance_m = None + if ( + rx_position + and tx_position + and rx_position.get("lat") is not None + and rx_position.get("lon") is not None + and tx_position.get("lat") is not None + and tx_position.get("lon") is not None + ): + distance_m = round( + _haversine_m( + float(rx_position["lat"]), + float(rx_position["lon"]), + float(tx_position["lat"]), + float(tx_position["lon"]), + ), + 1, + ) + return { - "tx_params": tx_params, - "tx_device_id": tx_device, + "rx_device_id": session_device_id or (rx_position or {}).get("device_id"), "rx_position": rx_position, + "tx_device_id": tx_device or (tx_position or {}).get("device_id"), + "tx_position": tx_position, + "tx_params": tx_params, + "distance_m": distance_m, "roles": sorted(roles), "devices": sorted(devices), } @@ -633,6 +707,8 @@ def start_reference_point_session( raise ValueError(f"invalid device_id '{device_id}'") lat, lon = _sanitize_coords(lat, lon) ts = time.time() + meta_dict: dict[str, Any] = dict(meta) if isinstance(meta, dict) else {} + meta_dict.setdefault("rx_role", "RX") with _db() as conn: cur = conn.execute( """ @@ -640,7 +716,7 @@ def start_reference_point_session( (device_id, label, lat, lon, started_at, status, meta) VALUES (?, ?, ?, ?, ?, 'recording', ?) """, - (device_id, label, lat, lon, ts, _json_or_none(meta)), + (device_id, label, lat, lon, ts, _json_or_none(meta_dict)), ) session_id = cur.lastrowid return { @@ -685,7 +761,7 @@ def add_reference_point_samples( lat, lon, sample.get("rssi"), - sample.get("role"), + "RX", _json_or_none(sample.get("meta")), ), ) @@ -698,7 +774,7 @@ def finish_reference_point_session(session_id: int) -> dict[str, Any]: with _db() as conn: session = conn.execute( """ - SELECT id, device_id, started_at, meta + SELECT id, device_id, lat, lon, started_at, meta FROM reference_point_sessions WHERE id = ? """, (session_id,), @@ -728,7 +804,10 @@ def finish_reference_point_session(session_id: int) -> dict[str, Any]: if not isinstance(meta, dict): meta = {} meta["reference_context"] = _reference_context( - [dict(r) for r in sample_rows] + [dict(r) for r in telemetry_rows] + [dict(r) for r in sample_rows] + [dict(r) for r in telemetry_rows], + session_device_id=session["device_id"], + session_lat=session["lat"], + session_lon=session["lon"], ) cur = conn.execute( """ @@ -776,7 +855,22 @@ def list_reference_point_sessions( base + " ORDER BY s.started_at DESC LIMIT ?", (limit,), ).fetchall() - return [dict(r) for r in rows] + sessions = [] + for row in rows: + item = dict(row) + meta = _json_load(item.get("meta")) + if isinstance(meta, dict) and isinstance(meta.get("reference_context"), dict): + item["reference_context"] = meta["reference_context"] + else: + item["reference_context"] = _reference_context( + [], + session_device_id=item.get("device_id"), + session_lat=item.get("lat"), + session_lon=item.get("lon"), + ) + item.pop("meta", None) + sessions.append(item) + return sessions def get_reference_point_session(session_id: int) -> dict[str, Any]: @@ -832,7 +926,10 @@ def get_reference_point_session(session_id: int) -> dict[str, Any]: {"device_id": result["device_id"], **sample} for sample in samples ] - + [dict(r) for r in telemetry_rows] + + [dict(r) for r in telemetry_rows], + session_device_id=result["device_id"], + session_lat=result.get("lat"), + session_lon=result.get("lon"), ) result["reference_context"] = context return result diff --git a/server/static/app.css b/server/static/app.css index 3cea5c2..d1b47c3 100644 --- a/server/static/app.css +++ b/server/static/app.css @@ -122,6 +122,13 @@ aside { .preset-row.applied .preset-check { color: #00ff88; } .preset-title { font-weight: 600; } .preset-desc { color: #aaa; font-size: 0.7rem; margin-top: 2px; } +.reference-point-list { display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow: auto; margin: 6px 0; } +.reference-point-row { + display: grid; grid-template-columns: 18px 1fr; gap: 6px; align-items: start; + padding: 6px; background: #0a0a14; border: 1px solid #333; border-radius: 4px; + cursor: pointer; font-size: 0.8rem; +} +.reference-point-row:hover, .reference-point-row.selected { border-color: #00ff88; background: #102018; } .preset-actions { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; } .preset-actions button, .preset-modal-actions button { flex: 1; padding: 5px 8px; font-size: 0.75rem; border: none; border-radius: 4px; diff --git a/server/static/app.js b/server/static/app.js index cb3d587..c528de7 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -68,7 +68,9 @@ let macroPresets = []; let selectedMacroPresetId = ''; let appliedMacroPresetId = ''; let referencePointSessions = []; -let referencePointMarker = null; +let selectedReferencePointIds = new Set(JSON.parse(localStorage.getItem('selectedReferencePointIds') || '[]')); +let referencePointMapLayers = new Map(); +let referencePointDetailId = ''; const deviceLabelCache = {}; let timelineSpanMs = 1000; let timelineUseProgress = false; @@ -907,6 +909,164 @@ function formatReferenceRxPosition(pos) { return `RX координаты: ${device}${Number(pos.lat).toFixed(6)}, ${Number(pos.lon).toFixed(6)}`; } +function formatReferenceTxPosition(pos) { + if (!pos || pos.lat == null || pos.lon == null) return 'TX координаты: —'; + const device = pos.device_id ? `${deviceDisplayName(pos)} · ` : ''; + return `TX координаты: ${device}${Number(pos.lat).toFixed(6)}, ${Number(pos.lon).toFixed(6)}`; +} + +function formatReferenceDistance(ctx) { + if (!ctx || ctx.distance_m == null) return 'Дальность TX↔RX: —'; + return `Дальность TX↔RX: ${Number(ctx.distance_m).toFixed(0)} м`; +} + +function normalizeTxParamsForForm(params) { + if (!params) return {}; + const p = { ...params }; + if (p.spreading_factor != null && p.sf == null) p.sf = p.spreading_factor; + if (p.bandwidth_khz != null && p.bw_khz == null) p.bw_khz = p.bandwidth_khz; + if (p.preamble_length != null && p.preamble == null) p.preamble = p.preamble_length; + if (p.tx_timeout_ms != null && p.send_timeout_ms == null) p.send_timeout_ms = p.tx_timeout_ms; + if (p.crc_enabled != null && p.crc == null) p.crc = p.crc_enabled; + return p; +} + +function saveSelectedReferencePointIds() { + localStorage.setItem('selectedReferencePointIds', JSON.stringify([...selectedReferencePointIds])); +} + +function referenceSessionById(id) { + return referencePointSessions.find(s => String(s.id) === String(id)) || null; +} + +function renderReferencePointList() { + const list = document.getElementById('referencePointList'); + if (!list) return; + if (!referencePointSessions.length) { + list.innerHTML = '
Реперных точек пока нет
'; + return; + } + list.innerHTML = ''; + referencePointSessions.forEach(session => { + const row = document.createElement('label'); + row.className = 'reference-point-row'; + const checked = selectedReferencePointIds.has(String(session.id)); + if (checked) row.classList.add('selected'); + const ctx = session.reference_context || {}; + const dist = ctx.distance_m != null ? ` · ${Number(ctx.distance_m).toFixed(0)} м` : ''; + row.innerHTML = ` + ${escapeHtml(session.label || 'Репер #' + session.id)} + #${session.id} · RX${dist}`; + const checkbox = row.querySelector('input'); + checkbox.addEventListener('change', () => { + const sid = String(session.id); + if (checkbox.checked) selectedReferencePointIds.add(sid); + else selectedReferencePointIds.delete(sid); + saveSelectedReferencePointIds(); + renderReferencePointList(); + if (referencePointDetailId === sid) showReferencePoint(sid).catch(() => {}); + }); + row.addEventListener('click', e => { + if (e.target === checkbox) return; + showReferencePoint(session.id).catch(err => { + const summary = document.getElementById('referencePointSummary'); + if (summary) summary.textContent = err.message || String(err); + }); + }); + list.appendChild(row); + }); +} + +function clearReferencePointMapLayers() { + referencePointMapLayers.forEach(layers => { + if (layers.rx) map.removeLayer(layers.rx); + if (layers.tx) map.removeLayer(layers.tx); + if (layers.line) map.removeLayer(layers.line); + }); + referencePointMapLayers.clear(); +} + +function showReferencePointsOnMap() { + clearReferencePointMapLayers(); + const ids = [...selectedReferencePointIds]; + if (!ids.length) { + const summary = document.getElementById('referencePointSummary'); + if (summary) summary.textContent = 'Выберите реперные точки'; + return; + } + const bounds = []; + ids.forEach(id => { + const session = referenceSessionById(id); + if (!session) return; + const ctx = session.reference_context || {}; + const rxPos = ctx.rx_position || (session.lat != null ? { lat: session.lat, lon: session.lon } : null); + const txPos = ctx.tx_position || null; + if (!rxPos || rxPos.lat == null || rxPos.lon == null) return; + const rxLatLng = [Number(rxPos.lat), Number(rxPos.lon)]; + bounds.push(rxLatLng); + const rxMarker = L.circleMarker(rxLatLng, { + radius: 9, + color: '#00ff88', + weight: 2, + fillColor: '#00ff88', + fillOpacity: 0.25 + }).addTo(map).bindPopup(`RX репер #${session.id}`); + let txMarker = null; + let line = null; + if (txPos && txPos.lat != null && txPos.lon != null) { + const txLatLng = [Number(txPos.lat), Number(txPos.lon)]; + bounds.push(txLatLng); + txMarker = L.circleMarker(txLatLng, { + radius: 8, + color: '#ff9800', + weight: 2, + fillColor: '#ff9800', + fillOpacity: 0.2 + }).addTo(map).bindPopup(`TX #${session.id}`); + line = L.polyline([rxLatLng, txLatLng], { + color: '#ff9800', + weight: 2, + dashArray: '6 4' + }).addTo(map); + const dist = ctx.distance_m != null ? `${Number(ctx.distance_m).toFixed(0)} м` : ''; + if (dist) line.bindTooltip(dist, { permanent: false, direction: 'center' }); + } + referencePointMapLayers.set(String(session.id), { rx: rxMarker, tx: txMarker, line }); + }); + if (bounds.length) { + map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 }); + } +} + +async function repeatSelectedReferencePoint() { + const ids = [...selectedReferencePointIds]; + if (!ids.length) { + const summary = document.getElementById('referencePointSummary'); + if (summary) summary.textContent = 'Выберите реперную точку'; + return; + } + const session = referenceSessionById(ids[0]); + if (!session) return; + const detailRes = await fetch(`/api/reference-points/${session.id}`, { cache: 'no-store' }); + if (!detailRes.ok) throw new Error('reference point ' + detailRes.status); + const data = await detailRes.json(); + const ctx = data.reference_context || {}; + applyMacroParams(normalizeTxParamsForForm(ctx.tx_params)); + showReferencePointsOnMap(); + const rx = ctx.rx_position || (data.lat != null ? { lat: data.lat, lon: data.lon } : null); + if (rx && rx.lat != null && rx.lon != null) { + map.setView([Number(rx.lat), Number(rx.lon)], Math.max(map.getZoom(), 15)); + } + const summary = document.getElementById('referencePointSummary'); + if (summary) { + summary.innerHTML = `
Повтор измерения: ${escapeHtml(data.label || 'Репер #' + data.id)}
+
${escapeHtml(formatReferenceTxParams(ctx.tx_params))}
+
${escapeHtml(formatReferenceRxPosition(ctx.rx_position))}
+
${escapeHtml(formatReferenceTxPosition(ctx.tx_position))}
+
${escapeHtml(formatReferenceDistance(ctx))}
`; + } +} + function drawReferencePointChart(samples) { const canvas = document.getElementById('referencePointChart'); if (!canvas) return; @@ -987,6 +1147,7 @@ function updateReferencePointSelect() { sel.appendChild(opt); }); if (prev) sel.value = prev; + renderReferencePointList(); } async function loadReferencePoints() { @@ -998,13 +1159,10 @@ async function loadReferencePoints() { async function showReferencePoint(sessionId) { const summary = document.getElementById('referencePointSummary'); + referencePointDetailId = sessionId ? String(sessionId) : ''; if (!sessionId) { if (summary) summary.textContent = 'Выберите сессию'; drawReferencePointChart([]); - if (referencePointMarker) { - map.removeLayer(referencePointMarker); - referencePointMarker = null; - } return; } const res = await fetch(`/api/reference-points/${sessionId}`, { cache: 'no-store' }); @@ -1013,28 +1171,20 @@ async function showReferencePoint(sessionId) { const stats = data.stats || {}; const ctx = data.reference_context || {}; if (summary) { - summary.innerHTML = `
${escapeHtml(data.label || 'Реперная точка')} #${data.id}
+ summary.innerHTML = `
${escapeHtml(data.label || 'Реперная точка')} #${data.id} · RX
${escapeHtml(deviceDisplayName(data))} · samples: ${data.sample_count || 0} · ${escapeHtml(data.status || '')}
Контекст
${escapeHtml(formatReferenceTxParams(ctx.tx_params))}
${escapeHtml(formatReferenceRxPosition(ctx.rx_position))}
-
Роли: ${escapeHtml((ctx.roles || []).join(', ') || '—')}
+
${escapeHtml(formatReferenceTxPosition(ctx.tx_position))}
+
${escapeHtml(formatReferenceDistance(ctx))}
${metricCard('PER', stats.per_percent)} ${metricCard('RX Quality', stats.rx_quality_percent)}`; } drawReferencePointChart(data.samples || []); - if (data.lat != null && data.lon != null) { - const latlng = [Number(data.lat), Number(data.lon)]; - if (referencePointMarker) map.removeLayer(referencePointMarker); - referencePointMarker = L.circleMarker(latlng, { - radius: 9, - color: '#00ff88', - weight: 2, - fillColor: '#00ff88', - fillOpacity: 0.25 - }).addTo(map).bindPopup(`Репер #${data.id}`); - map.setView(latlng, Math.max(map.getZoom(), 15)); + if (selectedReferencePointIds.has(String(sessionId))) { + showReferencePointsOnMap(); } } @@ -4119,6 +4269,18 @@ document.getElementById('btnReferenceRefresh')?.addEventListener('click', () => document.getElementById('referencePointSummary').textContent = e.message || String(e); }); }); +document.getElementById('btnReferenceShowMap')?.addEventListener('click', () => { + try { + showReferencePointsOnMap(); + } catch (e) { + document.getElementById('referencePointSummary').textContent = e.message || String(e); + } +}); +document.getElementById('btnReferenceRepeat')?.addEventListener('click', () => { + repeatSelectedReferencePoint().catch(err => { + document.getElementById('referencePointSummary').textContent = err.message || String(err); + }); +}); document.getElementById('referencePointSelect')?.addEventListener('change', e => { showReferencePoint(e.target.value).catch(err => { document.getElementById('referencePointSummary').textContent = err.message || String(err); diff --git a/server/static/index.html b/server/static/index.html index aefd6a6..dfc07e8 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -201,9 +201,14 @@
-

Реперные точки

+

Реперные точки (RX)

- +
+
+ + +
+
Нет данных
diff --git a/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc b/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc index fd7a843301b8deb90b7f4155fee8290288d88d3d..5a4db5013657e6df9eb348f8bed02c8198c76f92 100644 GIT binary patch delta 890 zcmZ9KTSyd97{~qEv$N_+*IYHcL>I+shw`@5Uh)d+dLt6`F-%l&W!+h4b~UI_BakTS z;p-tH%7=m$iKrp;!9WV4haUDKZ0uW)89muU^wjxw*G(D@zxjXv?>}c|&N=uLenp^o zuc)XnPh9@`_SvN5a~#(Is{B1Iqt?GgN4>w+8DR9DUd z;yEmlt4dWN&K788;--}lG<-F140L>0=Y$pGOWiXwTt~m%#g;`EPxe>g2fH2S@Ta{E zRFvi8Qji{-$IJ4mDxGAA&`k&vdI$@6Pi}x!V@nQ$Y15jA^B`kMVUgiKr*Irs3al+= zksE0u2H1#XOgJo%HJh`hC1X0WWu7`ZW*!1O(%4{8#v})fT)=P6?euK>87E~tw{f_o z$>K_9+#>T4Jov)ZE!JY!^+f#MsykkOU%WVF3M!2PPUU+gef0wjcqUzUNe)q)hpvqW zRoxfzsex4Clur*$1SY4)=d@Xh_z8yyHwjCa^lU>AA6=Z`>%yRA(I1-)-s)hc1nb2+ z9r}`YyCTX2V%(c#*%4>nC~wP@I|p=vg1J;q2>UB9joRvC{PfGiF|I^e*XC#jyerAF zBhI>_T*;I>`+7;#35w@pIU(%-r*&^alnKPRJIS&m&fMu~soXiB6BNv)azaEjr&x$d z5D@2Stkh~;>72=jHT=|9E4E^{ZUtQu(cO>QvZ2-L8Gc T=a;TpI{`dCG}ztys3%$=;QEzgz*(vv?qKwC+w5X3A35iTI2 z7(}>&h?2=Y+WL&moA+p!GBV|HPj+w%+}xro%gvI`plLk$y^RadQ6{!?fc9Lpy(HQN z)N+ftz|;Upn47+o1ewr1d52vxTRVv9GdbA)0At={5r+)!r9goaATBm#+1&16!oqch z#jVI3D3HuJIY3Bcvz=Q82jiT{%l(vrW}o&8sZ>1t5X- zAb+!M1Tl*zZwwFQGaGmDmjbku@~W)J~#QqlIw b=^?U=hbFg%B=FlXx-)*RV-#l81G@|WUUYe8 diff --git a/server/tests/test_schema.py b/server/tests/test_schema.py index 3a22364..ab62d77 100644 --- a/server/tests/test_schema.py +++ b/server/tests/test_schema.py @@ -174,6 +174,10 @@ def test_reference_point_session_stats(temp_db): assert session["reference_context"]["tx_params"]["spreading_factor"] == 7 assert session["reference_context"]["rx_position"]["device_id"] == "android-12345678" assert session["reference_context"]["rx_position"]["lat"] == 55.75 + assert session["reference_context"]["tx_position"]["device_id"] == "android-11111111" + assert session["reference_context"]["tx_position"]["lat"] == 55.751 + assert session["reference_context"]["distance_m"] is not None + assert session["reference_context"]["distance_m"] > 0 def test_macro_presets_crud(temp_db):