5 Commits

Author SHA1 Message Date
Grigo d4842d4b29 offline track 2026-06-19 12:50:42 +03:00
Grigo 8812cf9b40 offline track 2026-06-19 11:09:20 +03:00
Grigo 4891933879 added opentopo 2026-06-17 13:03:11 +03:00
Grigo f4ef87705c fixed gistogramm 2026-06-17 11:22:15 +03:00
Grigo 920a839197 added gistogramm 2026-06-17 11:12:33 +03:00
30 changed files with 1653 additions and 136 deletions
@@ -67,6 +67,15 @@ public class CommandPoller {
} }
} }
@Override
public void onSyncComplete(long trackId, int pointCount) {
if (pendingAckSessionId > 0 && trackId > 0) {
long sid = pendingAckSessionId;
pendingAckSessionId = -1;
executor.execute(() -> ackSession(sid, trackId));
}
}
@Override @Override
public void onError(String message) { public void onError(String message) {
Log.w(TAG, "track: " + message); Log.w(TAG, "track: " + message);
@@ -31,6 +31,7 @@ public class LoraApp extends Application {
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl()); ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
String deviceId = settingsRepository.getOrCreateDeviceId(); String deviceId = settingsRepository.getOrCreateDeviceId();
trackRecorder = new TrackRecorder( trackRecorder = new TrackRecorder(
this,
serverApi, serverApi,
telemetryUploader, telemetryUploader,
deviceId, deviceId,
@@ -103,6 +104,7 @@ public class LoraApp extends Application {
} }
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl()); ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
trackRecorder = new TrackRecorder( trackRecorder = new TrackRecorder(
this,
serverApi, serverApi,
telemetryUploader, telemetryUploader,
settingsRepository.getOrCreateDeviceId(), settingsRepository.getOrCreateDeviceId(),
@@ -114,6 +114,28 @@ public class ServerApi {
postJson("/api/tracks/" + trackId + "/finish", new HashMap<>(), true); postJson("/api/tracks/" + trackId + "/finish", new HashMap<>(), true);
} }
public long syncTrack(
String deviceId,
Long trackId,
double startedAt,
List<Map<String, Object>> points,
boolean finish
) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("device_id", deviceId);
if (trackId != null && trackId > 0) {
body.put("track_id", trackId);
}
if (startedAt > 0) {
body.put("started_at", startedAt);
}
body.put("points", points != null ? points : List.of());
body.put("finish", finish);
Map<String, Object> resp = postJsonMap("/api/tracks/sync", body, true);
Number id = (Number) resp.get("track_id");
return id != null ? id.longValue() : (trackId != null ? trackId : -1);
}
public List<TrackInfo> listTracks(String deviceId) throws IOException { public List<TrackInfo> listTracks(String deviceId) throws IOException {
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST); return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
} }
@@ -32,10 +32,10 @@ public class LocationTracker {
return; return;
} }
LocationRequest request = new LocationRequest.Builder( LocationRequest request = new LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY, 10_000L Priority.PRIORITY_HIGH_ACCURACY, 1_000L
) )
.setMinUpdateIntervalMillis(5_000L) .setMinUpdateIntervalMillis(1_000L)
.setMaxUpdateDelayMillis(15_000L) .setMaxUpdateDelayMillis(2_000L)
.setWaitForAccurateLocation(false) .setWaitForAccurateLocation(false)
.build(); .build();
@@ -0,0 +1,161 @@
package com.grigowashere.loratester.track;
import android.content.Context;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Persists pending track points and recording state across network outages / app restarts. */
public class TrackPointQueue {
private static final String TAG = "TrackPointQueue";
private static final String FILE_NAME = "track_pending_points.json";
private static final Gson GSON = new Gson();
private static final Type SNAPSHOT_TYPE = new TypeToken<Snapshot>() {}.getType();
private final File queueFile;
public TrackPointQueue(Context context) {
queueFile = new File(context.getFilesDir(), FILE_NAME);
}
public static final class Snapshot {
public long trackId = -1;
public boolean recording;
/** Stopped locally; waiting for upload + finish. */
public boolean pendingSync;
public int totalPoints;
public double startedAt;
public String deviceId;
public List<PendingPoint> pending = new ArrayList<>();
}
public static final class PendingPoint {
public double ts;
public double lat;
public double lon;
public Double altitude_gps;
public Double rssi;
public String role;
public String meta;
static PendingPoint fromMap(Map<String, Object> point) {
PendingPoint p = new PendingPoint();
p.ts = toDouble(point.get("ts"));
p.lat = toDouble(point.get("lat"));
p.lon = toDouble(point.get("lon"));
Object alt = point.get("altitude_gps");
if (alt instanceof Number) {
p.altitude_gps = ((Number) alt).doubleValue();
}
Object rssi = point.get("rssi");
if (rssi instanceof Number) {
p.rssi = ((Number) rssi).doubleValue();
}
Object role = point.get("role");
if (role != null) {
p.role = String.valueOf(role);
}
Object meta = point.get("meta");
if (meta != null) {
p.meta = String.valueOf(meta);
}
return p;
}
Map<String, Object> toMap() {
Map<String, Object> point = new HashMap<>();
point.put("ts", ts);
point.put("lat", lat);
point.put("lon", lon);
if (altitude_gps != null) {
point.put("altitude_gps", altitude_gps);
}
if (rssi != null) {
point.put("rssi", rssi);
}
if (role != null) {
point.put("role", role);
}
if (meta != null) {
point.put("meta", meta);
}
return point;
}
private static double toDouble(Object value) {
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
return 0.0;
}
}
public synchronized Snapshot load() {
if (!queueFile.exists()) {
return null;
}
try (FileReader reader = new FileReader(queueFile)) {
Snapshot snap = GSON.fromJson(reader, SNAPSHOT_TYPE);
if (snap == null) {
return null;
}
if (snap.pending == null) {
snap.pending = new ArrayList<>();
}
return snap;
} catch (Exception e) {
Log.w(TAG, "load failed", e);
return null;
}
}
public synchronized void save(
String deviceId,
long trackId,
boolean recording,
boolean pendingSync,
int totalPoints,
double startedAt,
List<Map<String, Object>> pending
) {
Snapshot snap = new Snapshot();
snap.deviceId = deviceId;
snap.trackId = trackId;
snap.recording = recording;
snap.pendingSync = pendingSync;
snap.totalPoints = totalPoints;
snap.startedAt = startedAt;
snap.pending = new ArrayList<>();
if (pending != null) {
for (Map<String, Object> point : pending) {
snap.pending.add(PendingPoint.fromMap(point));
}
}
persist(snap);
}
public synchronized void clear() {
if (queueFile.exists() && !queueFile.delete()) {
Log.w(TAG, "clear failed");
}
}
private void persist(Snapshot snap) {
try (FileWriter writer = new FileWriter(queueFile)) {
GSON.toJson(snap, writer);
} catch (Exception e) {
Log.w(TAG, "persist failed", e);
}
}
}
@@ -1,9 +1,11 @@
package com.grigowashere.loratester.track; package com.grigowashere.loratester.track;
import android.content.Context;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.ServerApi; import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.location.GeoUtils;
@@ -19,19 +21,26 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public class TrackRecorder { public class TrackRecorder {
private static final String TAG = "TrackRecorder"; private static final String TAG = "TrackRecorder";
private static final long SAMPLE_MS = 1000; private static final long SAMPLE_MS = 1000;
private static final long FLUSH_MS = 30_000; private static final long FLUSH_MS = 10_000;
/** Local-only track before first server sync (offline start). */
private static final long LOCAL_TRACK_ID = 0;
/** No time limit — pending points stay on disk until track stop or successful upload. */
public static final int MAX_OFFLINE_BUFFER_POINTS = 500_000;
public interface Listener { public interface Listener {
void onStateChanged(boolean recording, int pointCount, long trackId); void onStateChanged(boolean recording, int pointCount, long trackId);
void onError(String message); void onError(String message);
default void onSyncComplete(long trackId, int pointCount) {
}
default void onPointRecorded(double lat, double lon) { default void onPointRecorded(double lat, double lon) {
} }
} }
@@ -39,6 +48,8 @@ public class TrackRecorder {
private final ServerApi serverApi; private final ServerApi serverApi;
private final TelemetryUploader uploader; private final TelemetryUploader uploader;
private final NetworkMonitor networkMonitor; private final NetworkMonitor networkMonitor;
private final TrackPointQueue pendingQueue;
private final Context appContext;
private final String deviceId; private final String deviceId;
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Handler mainHandler = new Handler(Looper.getMainLooper());
@@ -53,7 +64,9 @@ public class TrackRecorder {
private volatile double lon = Double.NaN; private volatile double lon = Double.NaN;
private volatile double altitude = Double.NaN; private volatile double altitude = Double.NaN;
private volatile long trackId = -1; private volatile long trackId = -1;
private volatile double localStartedAt;
private volatile boolean recording; private volatile boolean recording;
private volatile boolean pendingSync;
private final List<Map<String, Object>> buffer = new ArrayList<>(); private final List<Map<String, Object>> buffer = new ArrayList<>();
private int totalPoints; private int totalPoints;
private ScheduledFuture<?> sampleTask; private ScheduledFuture<?> sampleTask;
@@ -62,6 +75,7 @@ public class TrackRecorder {
private Listener pairedListener; private Listener pairedListener;
public TrackRecorder( public TrackRecorder(
Context context,
ServerApi serverApi, ServerApi serverApi,
TelemetryUploader uploader, TelemetryUploader uploader,
String deviceId, String deviceId,
@@ -71,11 +85,14 @@ public class TrackRecorder {
this.uploader = uploader; this.uploader = uploader;
this.deviceId = deviceId; this.deviceId = deviceId;
this.networkMonitor = networkMonitor; this.networkMonitor = networkMonitor;
this.appContext = context.getApplicationContext();
this.pendingQueue = new TrackPointQueue(this.appContext);
networkMonitor.addListener(online -> { networkMonitor.addListener(online -> {
if (online && recording) { if (online) {
executor.execute(this::flushBuffer); executor.execute(this::syncWhenOnline);
} }
}); });
restoreIfNeeded();
} }
public void setListener(Listener listener) { public void setListener(Listener listener) {
@@ -100,6 +117,10 @@ public class TrackRecorder {
return recording; return recording;
} }
public boolean hasPendingSync() {
return pendingSync;
}
public int getPointCount() { public int getPointCount() {
return totalPoints; return totalPoints;
} }
@@ -108,30 +129,97 @@ public class TrackRecorder {
return trackId; return trackId;
} }
public int getPendingFlushCount() {
synchronized (buffer) {
return buffer.size();
}
}
public void start() { public void start() {
if (recording) { if (recording) {
return; return;
} }
if (!networkMonitor.isOnline()) { executor.execute(() -> {
notifyError("Нужна сеть для начала трека"); if (pendingSync) {
syncWhenOnline();
if (pendingSync) {
notifyError(appContext.getString(R.string.track_sync_pending));
return;
}
}
if (!networkMonitor.isOnline()) {
startLocalRecording();
return; return;
} }
executor.execute(() -> {
try { try {
startOnlineRecording();
} catch (Exception e) {
Log.w(TAG, "start track on server failed", e);
if (isReachabilityError(e)) {
startLocalRecording();
} else {
notifyError(e.getMessage() != null ? e.getMessage() : "start failed");
}
}
});
}
/** Retry upload of a track stopped offline. */
public void retryPendingSync() {
executor.execute(this::syncWhenOnline);
}
private void startOnlineRecording() throws Exception {
long id = serverApi.startTrack(deviceId); long id = serverApi.startTrack(deviceId);
synchronized (buffer) { synchronized (buffer) {
buffer.clear(); buffer.clear();
} }
totalPoints = 0; totalPoints = 0;
trackId = id; trackId = id;
localStartedAt = System.currentTimeMillis() / 1000.0;
recording = true; recording = true;
pendingSync = false;
persistState(false);
startTimers(); startTimers();
notifyState(); notifyState();
} catch (Exception e) {
Log.e(TAG, "start track failed", e);
notifyError(e.getMessage());
} }
});
private static boolean isReachabilityError(Throwable e) {
while (e != null) {
if (e instanceof java.net.UnknownHostException
|| e instanceof java.net.ConnectException
|| e instanceof java.net.SocketTimeoutException) {
return true;
}
String msg = e.getMessage();
if (msg != null) {
String lower = msg.toLowerCase();
if (lower.contains("unable to resolve host")
|| lower.contains("failed to connect")
|| lower.contains("timeout")
|| lower.contains("econnrefused")
|| lower.contains("network is unreachable")) {
return true;
}
}
e = e.getCause();
}
return false;
}
private void startLocalRecording() {
synchronized (buffer) {
buffer.clear();
}
totalPoints = 0;
trackId = LOCAL_TRACK_ID;
localStartedAt = System.currentTimeMillis() / 1000.0;
recording = true;
pendingSync = false;
persistState(false);
startTimers();
notifyState();
notifyError(appContext.getString(R.string.track_offline_started));
} }
public void stop() { public void stop() {
@@ -141,22 +229,146 @@ public class TrackRecorder {
recording = false; recording = false;
stopTimers(); stopTimers();
executor.execute(() -> { executor.execute(() -> {
try { boolean synced = completeTrackUpload(true);
flushBuffer(); if (!synced) {
if (trackId > 0) { pendingSync = true;
serverApi.finishTrack(trackId); persistState(true);
notifyError(appContext.getString(R.string.track_sync_pending));
} else {
resetAfterSuccessfulSync(-1);
} }
} catch (Exception e) {
Log.e(TAG, "stop track failed", e);
notifyError(e.getMessage());
} finally {
trackId = -1;
notifyState(); notifyState();
}
}); });
} }
private void restoreIfNeeded() {
TrackPointQueue.Snapshot snap = pendingQueue.load();
if (snap == null) {
return;
}
if (snap.deviceId != null && !snap.deviceId.equals(deviceId)) {
return;
}
trackId = snap.trackId;
totalPoints = snap.totalPoints;
localStartedAt = snap.startedAt > 0 ? snap.startedAt : System.currentTimeMillis() / 1000.0;
pendingSync = snap.pendingSync;
synchronized (buffer) {
buffer.clear();
if (snap.pending != null) {
for (TrackPointQueue.PendingPoint point : snap.pending) {
buffer.add(point.toMap());
}
}
}
if (snap.recording) {
recording = true;
Log.i(TAG, "restored active track " + trackId + ", pending=" + buffer.size());
startTimers();
executor.execute(this::syncWhenOnline);
notifyState();
return;
}
if (snap.pendingSync) {
Log.i(TAG, "restored pending sync track " + trackId + ", points=" + buffer.size());
executor.execute(this::syncWhenOnline);
notifyState();
}
}
private void syncWhenOnline() {
if (!networkMonitor.isOnline()) {
return;
}
if (recording) {
if (trackId == LOCAL_TRACK_ID) {
promoteLocalTrackToServer();
} else if (trackId > 0) {
flushBuffer();
}
return;
}
if (pendingSync) {
if (completeTrackUpload(true)) {
long finishedId = trackId > 0 ? trackId : -1;
int count = totalPoints;
resetAfterSuccessfulSync(-1);
notifySyncComplete(finishedId, count);
notifyState();
}
}
}
private void promoteLocalTrackToServer() {
if (trackId != LOCAL_TRACK_ID || !recording) {
return;
}
try {
long id = serverApi.startTrack(deviceId);
trackId = id;
flushBuffer();
persistState(false);
notifyState();
Log.i(TAG, "promoted local track to server id " + id);
} catch (Exception e) {
Log.w(TAG, "promote local track failed", e);
}
}
private boolean completeTrackUpload(boolean finish) {
if (!networkMonitor.isOnline()) {
return false;
}
List<Map<String, Object>> batch;
synchronized (buffer) {
if (buffer.isEmpty() && !finish) {
return true;
}
batch = new ArrayList<>(buffer);
buffer.clear();
}
try {
Long serverTrackId = trackId > 0 ? trackId : null;
long resultId = serverApi.syncTrack(
deviceId,
serverTrackId,
localStartedAt,
batch,
finish
);
if (trackId <= 0) {
trackId = resultId;
}
persistState(false);
if (finish && batch.isEmpty()) {
return true;
}
return true;
} catch (Exception e) {
Log.e(TAG, "track sync failed", e);
synchronized (buffer) {
buffer.addAll(0, batch);
}
persistState(finish);
notifyError(e.getMessage());
return false;
}
}
private void resetAfterSuccessfulSync(long finishedTrackId) {
trackId = -1;
pendingSync = false;
recording = false;
synchronized (buffer) {
buffer.clear();
}
pendingQueue.clear();
}
private void startTimers() { private void startTimers() {
if (sampleTask != null || flushTask != null) {
return;
}
sampleTask = scheduler.scheduleAtFixedRate( sampleTask = scheduler.scheduleAtFixedRate(
() -> executor.execute(this::samplePoint), () -> executor.execute(this::samplePoint),
SAMPLE_MS, SAMPLE_MS,
@@ -210,12 +422,58 @@ public class TrackRecorder {
} }
synchronized (buffer) { synchronized (buffer) {
buffer.add(point); buffer.add(point);
if (buffer.size() > MAX_OFFLINE_BUFFER_POINTS) {
buffer.remove(0);
Log.w(TAG, "offline buffer trimmed at " + MAX_OFFLINE_BUFFER_POINTS);
}
} }
totalPoints++; totalPoints++;
persistState(false);
notifyState(); notifyState();
notifyPoint(lat, lon); notifyPoint(lat, lon);
} }
private void persistState(boolean markPendingSync) {
List<Map<String, Object>> copy;
synchronized (buffer) {
copy = new ArrayList<>(buffer);
}
pendingQueue.save(
deviceId,
trackId,
recording,
markPendingSync || pendingSync,
totalPoints,
localStartedAt,
copy
);
}
private void flushBuffer() {
if (!recording || trackId <= 0 || !networkMonitor.isOnline()) {
return;
}
List<Map<String, Object>> batch;
synchronized (buffer) {
if (buffer.isEmpty()) {
return;
}
batch = new ArrayList<>(buffer);
buffer.clear();
}
try {
serverApi.addTrackPoints(trackId, batch);
persistState(false);
} catch (Exception e) {
Log.e(TAG, "flush points failed", e);
synchronized (buffer) {
buffer.addAll(0, batch);
}
persistState(false);
notifyError(e.getMessage());
}
}
private void notifyPoint(double lat, double lon) { private void notifyPoint(double lat, double lon) {
mainHandler.post(() -> { mainHandler.post(() -> {
if (listener != null) { if (listener != null) {
@@ -227,27 +485,15 @@ public class TrackRecorder {
}); });
} }
private void flushBuffer() { private void notifySyncComplete(long trackId, int pointCount) {
if (trackId < 0) { mainHandler.post(() -> {
return; if (listener != null) {
listener.onSyncComplete(trackId, pointCount);
} }
List<Map<String, Object>> batch; if (pairedListener != null) {
synchronized (buffer) { pairedListener.onSyncComplete(trackId, pointCount);
if (buffer.isEmpty()) {
return;
}
batch = new ArrayList<>(buffer);
buffer.clear();
}
try {
serverApi.addTrackPoints(trackId, batch);
} catch (Exception e) {
Log.e(TAG, "flush points failed", e);
synchronized (buffer) {
buffer.addAll(0, batch);
}
notifyError(e.getMessage());
} }
});
} }
private void notifyState() { private void notifyState() {
@@ -0,0 +1,25 @@
package com.grigowashere.loratester.ui;
import com.grigowashere.loratester.api.DeviceInfo;
/** Human-readable device labels (matches web deviceDisplayName). */
public final class DeviceNames {
private DeviceNames() {
}
public static String displayName(DeviceInfo device, String fallbackId) {
if (device != null && device.label != null) {
String label = device.label.trim();
if (!label.isEmpty()
&& device.device_id != null
&& !label.equals(device.device_id)) {
return label;
}
}
if (fallbackId != null && !fallbackId.isEmpty()) {
return fallbackId;
}
return "";
}
}
@@ -111,6 +111,8 @@ 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 mapRxQuality;
private TextView mapTrackStatus;
private TextView mapHillStatus; private TextView mapHillStatus;
private TextView trackStatus; private TextView trackStatus;
private ImageView iconServer; private ImageView iconServer;
@@ -189,6 +191,8 @@ 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);
mapRxQuality = view.findViewById(R.id.mapRxQuality);
mapTrackStatus = view.findViewById(R.id.mapTrackStatus);
mapHillStatus = view.findViewById(R.id.mapHillStatus); mapHillStatus = view.findViewById(R.id.mapHillStatus);
iconServer = view.findViewById(R.id.iconServer); iconServer = view.findViewById(R.id.iconServer);
iconLora = view.findViewById(R.id.iconLora); iconLora = view.findViewById(R.id.iconLora);
@@ -221,9 +225,29 @@ public class MapFragment extends Fragment {
networkOnline = networkMonitor != null && networkMonitor.isOnline(); networkOnline = networkMonitor != null && networkMonitor.isOnline();
networkListener = online -> { networkListener = online -> {
boolean wasOnline = networkOnline;
networkOnline = online; networkOnline = online;
if (isAdded() && mapStatus != null) { if (isAdded() && mapStatus != null) {
requireActivity().runOnUiThread(this::updateNetworkStatusLine); requireActivity().runOnUiThread(() -> {
updateNetworkStatusLine();
updateTrackStatusUi();
});
}
if (isAdded() && trackRecorder != null && trackRecorder.isRecording()) {
int pending = trackRecorder.getPendingFlushCount();
if (!online && wasOnline) {
requireActivity().runOnUiThread(() -> Toast.makeText(
requireContext(),
getString(R.string.track_offline_toast, pending),
Toast.LENGTH_LONG
).show());
} else if (online && !wasOnline && pending > 0) {
requireActivity().runOnUiThread(() -> Toast.makeText(
requireContext(),
getString(R.string.track_online_toast, pending),
Toast.LENGTH_SHORT
).show());
}
} }
}; };
if (networkMonitor != null) { if (networkMonitor != null) {
@@ -465,6 +489,8 @@ public class MapFragment extends Fragment {
heatmapActive = false; heatmapActive = false;
mapStatus = null; mapStatus = null;
mapDistance = null; mapDistance = null;
mapRxQuality = null;
mapTrackStatus = null;
trackStatus = null; trackStatus = null;
btnTrack = null; btnTrack = null;
btnPairedTrack = null; btnPairedTrack = null;
@@ -507,9 +533,7 @@ public class MapFragment extends Fragment {
btnTrack.setActivated(recording); btnTrack.setActivated(recording);
btnTrack.setContentDescription(getString( btnTrack.setContentDescription(getString(
recording ? R.string.track_stop : R.string.track_start)); recording ? R.string.track_stop : R.string.track_start));
if (trackStatus != null) { updateTrackStatusUi();
trackStatus.setText(getString(R.string.track_status, pointCount));
}
if (recording && pointCount <= 1) { if (recording && pointCount <= 1) {
clearLiveTrackLayers(); clearLiveTrackLayers();
} }
@@ -523,9 +547,26 @@ public class MapFragment extends Fragment {
@Override @Override
public void onError(String message) { public void onError(String message) {
if (isAdded() && trackStatus != null) { if (!isAdded()) {
return;
}
if (trackStatus != null) {
trackStatus.setText(getString(R.string.track_error, message)); trackStatus.setText(getString(R.string.track_error, message));
} }
Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show();
}
@Override
public void onSyncComplete(long trackId, int pointCount) {
if (!isAdded()) {
return;
}
Toast.makeText(
requireContext(),
getString(R.string.track_sync_done, trackId, pointCount),
Toast.LENGTH_LONG
).show();
loadTrackList();
} }
@Override @Override
@@ -601,10 +642,49 @@ public class MapFragment extends Fragment {
liveTrackMarker = null; liveTrackMarker = null;
} }
private void updateTrackStatusUi() {
if (trackRecorder == null) {
return;
}
if (!trackRecorder.isRecording()) {
if (trackStatus != null) {
trackStatus.setText("");
}
if (mapTrackStatus != null) {
mapTrackStatus.setVisibility(View.GONE);
}
return;
}
int total = trackRecorder.getPointCount();
int pending = trackRecorder.getPendingFlushCount();
CharSequence line;
if (!networkOnline && pending > 0) {
line = getString(R.string.track_status_offline, total, pending);
} else {
line = getString(R.string.track_status_online, total);
}
if (trackStatus != null) {
trackStatus.setText(line);
}
if (mapTrackStatus != null) {
mapTrackStatus.setVisibility(View.VISIBLE);
if (!networkOnline && pending > 0) {
mapTrackStatus.setText(getString(R.string.map_track_status_offline, total, pending));
} else {
mapTrackStatus.setText(getString(R.string.map_track_status_online, total));
}
}
}
private void toggleTracking() { private void toggleTracking() {
if (trackRecorder.isRecording()) { if (trackRecorder.isRecording()) {
trackRecorder.stop(); trackRecorder.stop();
} else { } else {
if (trackRecorder.hasPendingSync()) {
trackRecorder.retryPendingSync();
Toast.makeText(requireContext(), R.string.track_sync_pending, Toast.LENGTH_SHORT).show();
return;
}
trackRecorder.start(); trackRecorder.start();
} }
} }
@@ -1414,6 +1494,7 @@ public class MapFragment extends Fragment {
)); ));
} }
updateGpsDistance(); updateGpsDistance();
updateRxQuality();
updateConnectionIcons(lastDevices, serverConnected); updateConnectionIcons(lastDevices, serverConnected);
checkHeatmapGpsFollow(); checkHeatmapGpsFollow();
@@ -1423,6 +1504,53 @@ public class MapFragment extends Fragment {
} }
} }
private void updateRxQuality() {
if (mapRxQuality == null) {
return;
}
Double quality = resolveRxQualityPercent();
if (quality != null) {
mapRxQuality.setVisibility(View.VISIBLE);
mapRxQuality.setText(getString(
R.string.map_rx_quality,
String.format(Locale.US, "%.0f", quality)));
} else {
mapRxQuality.setVisibility(View.VISIBLE);
mapRxQuality.setText(R.string.map_rx_quality_unknown);
}
}
private Double resolveRxQualityPercent() {
StatsExtractor.ExtractedStats localStats =
uploader != null ? uploader.getLastStats() : null;
if (localStats != null) {
RadioSnapshot localSnap = RadioSnapshot.fromExtracted(localStats);
if (StatsExtractor.ROLE_RX.equals(localSnap.role)
&& localSnap.rxQualityPercent != null) {
return localSnap.rxQualityPercent;
}
}
for (DeviceInfo d : lastDevices) {
if (!StatsExtractor.ROLE_RX.equals(d.role)) {
continue;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(d.meta, d.role, d.rssi);
if (snap.rxQualityPercent != null) {
return snap.rxQualityPercent;
}
}
if (peerStatsCache != null) {
PeerStatsCache.Snapshot push = peerStatsCache.get();
if (push != null && StatsExtractor.ROLE_RX.equals(push.role) && push.meta != null) {
RadioSnapshot snap = RadioSnapshot.fromMeta(push.meta, push.role, push.rssi);
if (snap.rxQualityPercent != null) {
return snap.rxQualityPercent;
}
}
}
return null;
}
private void updateGpsDistance() { private void updateGpsDistance() {
if (mapDistance == null) { if (mapDistance == null) {
return; return;
@@ -159,24 +159,48 @@ public class RadioComparePanel extends LinearLayout {
String peerId, String peerId,
Set<String> changedLocal, Set<String> changedLocal,
Set<String> changedPeer Set<String> changedPeer
) {
bindByRole(
panel,
local,
peer,
localId,
peerId,
localId,
peerId,
changedLocal,
changedPeer
);
}
public static void bindByRole(
RadioComparePanel panel,
RadioSnapshot local,
RadioSnapshot peer,
String localId,
String peerId,
String localDisplayName,
String peerDisplayName,
Set<String> changedLocal,
Set<String> changedPeer
) { ) {
RadioSnapshot tx = local; RadioSnapshot tx = local;
RadioSnapshot rx = peer; RadioSnapshot rx = peer;
String txId = localId; String txName = localDisplayName != null ? localDisplayName : localId;
String rxId = peerId; String rxName = peerDisplayName != null ? peerDisplayName : peerId;
Set<String> chTx = changedLocal; Set<String> chTx = changedLocal;
Set<String> chRx = changedPeer; Set<String> chRx = changedPeer;
if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) { if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) {
tx = peer; tx = peer;
rx = local; rx = local;
txId = peerId; txName = peerDisplayName != null ? peerDisplayName : peerId;
rxId = localId; rxName = localDisplayName != null ? localDisplayName : localId;
chTx = changedPeer; chTx = changedPeer;
chRx = changedLocal; chRx = changedLocal;
} }
if (tx == null) tx = RadioSnapshot.empty(); if (tx == null) tx = RadioSnapshot.empty();
if (rx == null) rx = RadioSnapshot.empty(); if (rx == null) rx = RadioSnapshot.empty();
panel.bind(tx, rx, txId, rxId, chTx, chRx); panel.bind(tx, rx, txName, rxName, chTx, chRx);
} }
private static String str(String v) { private static String str(String v) {
@@ -23,11 +23,13 @@ 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.TelemetryHistoryItem; import com.grigowashere.loratester.api.TelemetryHistoryItem;
import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.model.RadioSnapshot; import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.telnet.StatsExtractor;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -43,6 +45,7 @@ public class StatsFragment extends Fragment {
private PeerStatsCache peerStatsCache; private PeerStatsCache peerStatsCache;
private TextView statsStatus; private TextView statsStatus;
private TextView statsPeerWarning; private TextView statsPeerWarning;
private TextView statsDistance;
private RadioComparePanel radioComparePanel; private RadioComparePanel radioComparePanel;
private RecyclerView statsHistoryList; private RecyclerView statsHistoryList;
private final HistoryAdapter historyAdapter = new HistoryAdapter(); private final HistoryAdapter historyAdapter = new HistoryAdapter();
@@ -54,6 +57,10 @@ public class StatsFragment extends Fragment {
private String cachedPeerId; private String cachedPeerId;
private String cachedPeerError; private String cachedPeerError;
private int cachedDeviceCount; private int cachedDeviceCount;
private String cachedSelfDisplayName;
private String cachedPeerDisplayName;
private DeviceInfo cachedTxDev;
private DeviceInfo cachedRxDev;
private final TelemetryUploader.StatsListener statsListener = stats -> postRender(); private final TelemetryUploader.StatsListener statsListener = stats -> postRender();
@@ -80,6 +87,7 @@ public class StatsFragment extends Fragment {
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
statsStatus = view.findViewById(R.id.statsStatus); statsStatus = view.findViewById(R.id.statsStatus);
statsPeerWarning = view.findViewById(R.id.statsPeerWarning); statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
statsDistance = view.findViewById(R.id.statsDistance);
radioComparePanel = view.findViewById(R.id.radioComparePanel); radioComparePanel = view.findViewById(R.id.radioComparePanel);
statsHistoryList = view.findViewById(R.id.statsHistoryList); statsHistoryList = view.findViewById(R.id.statsHistoryList);
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext())); statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
@@ -157,6 +165,7 @@ public class StatsFragment extends Fragment {
} }
statsStatus = null; statsStatus = null;
statsPeerWarning = null; statsPeerWarning = null;
statsDistance = null;
radioComparePanel = null; radioComparePanel = null;
statsHistoryList = null; statsHistoryList = null;
pollHelper = null; pollHelper = null;
@@ -183,13 +192,17 @@ public class StatsFragment extends Fragment {
String deviceId = uploader.getDeviceId(); String deviceId = uploader.getDeviceId();
statsStatus.setText(getString( statsStatus.setText(getString(
R.string.stats_status, R.string.stats_status,
deviceId, cachedSelfDisplayName != null ? cachedSelfDisplayName : deviceId,
uploader.isTelnetConnected() uploader.isTelnetConnected()
? getString(R.string.connected) : getString(R.string.disconnected) ? getString(R.string.connected) : getString(R.string.disconnected)
)); ));
executor.execute(() -> { executor.execute(() -> {
List<TelemetryHistoryItem> history = null; List<TelemetryHistoryItem> history = null;
DeviceInfo txDev = null;
DeviceInfo rxDev = null;
String selfName = deviceId;
String peerName = cachedPeerId;
try { try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices(); List<DeviceInfo> devices = uploader.getServerApi().getDevices();
cachedDeviceCount = devices.size(); cachedDeviceCount = devices.size();
@@ -200,12 +213,23 @@ public class StatsFragment extends Fragment {
DeviceInfo self = null; DeviceInfo self = null;
DeviceInfo peerDev = null; DeviceInfo peerDev = null;
for (DeviceInfo d : devices) { for (DeviceInfo d : devices) {
if (StatsExtractor.ROLE_TX.equals(d.role)) {
txDev = d;
} else if (StatsExtractor.ROLE_RX.equals(d.role)) {
rxDev = d;
}
if (deviceId.equals(d.device_id)) { if (deviceId.equals(d.device_id)) {
self = d; self = d;
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) { } else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
peerDev = d; peerDev = d;
} }
} }
selfName = DeviceNames.displayName(self, deviceId);
peerName = DeviceNames.displayName(peerDev, peer.peerId);
cachedSelfDisplayName = selfName;
cachedPeerDisplayName = peerName;
cachedTxDev = txDev;
cachedRxDev = rxDev;
StatsExtractor.ExtractedStats localStats = uploader.getLastStats(); StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
snapLocal = localStats != null snapLocal = localStats != null
@@ -264,10 +288,32 @@ public class StatsFragment extends Fragment {
snapPeer, snapPeer,
uploader.getDeviceId(), uploader.getDeviceId(),
cachedPeerId, cachedPeerId,
cachedSelfDisplayName,
cachedPeerDisplayName,
chLocal, chLocal,
chPeer chPeer
); );
updateStatsDistance();
prevLocal = snapLocal; prevLocal = snapLocal;
prevPeer = snapPeer; prevPeer = snapPeer;
} }
private void updateStatsDistance() {
if (statsDistance == null) {
return;
}
DeviceInfo tx = cachedTxDev;
DeviceInfo rx = cachedRxDev;
if (tx != null && rx != null
&& GeoUtils.isValidCoordinate(tx.lat, tx.lon)
&& GeoUtils.isValidCoordinate(rx.lat, rx.lon)) {
double dist = GeoUtils.haversineMeters(tx.lat, tx.lon, rx.lat, rx.lon);
statsDistance.setVisibility(View.VISIBLE);
statsDistance.setText(getString(
R.string.stats_gps_distance,
String.format(Locale.US, "%.0f", dist)));
} else {
statsDistance.setVisibility(View.GONE);
}
}
} }
+18
View File
@@ -80,6 +80,24 @@
android:textColor="#00FF88" android:textColor="#00FF88"
android:textSize="9sp" android:textSize="9sp"
android:visibility="gone" /> android:visibility="gone" />
<TextView
android:id="@+id/mapRxQuality"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#4FC3F7"
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapTrackStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#FF9800"
android:textSize="9sp"
android:visibility="gone" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
@@ -24,6 +24,15 @@
android:textSize="12sp" android:textSize="12sp"
android:visibility="gone" /> android:visibility="gone" />
<TextView
android:id="@+id/statsDistance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#00FF88"
android:textSize="13sp"
android:visibility="gone" />
<com.grigowashere.loratester.ui.RadioComparePanel <com.grigowashere.loratester.ui.RadioComparePanel
android:id="@+id/radioComparePanel" android:id="@+id/radioComparePanel"
android:layout_width="match_parent" android:layout_width="match_parent"
+12
View File
@@ -37,6 +37,18 @@
<string name="map_network_online">онлайн</string> <string name="map_network_online">онлайн</string>
<string name="map_network_offline">офлайн (кэш)</string> <string name="map_network_offline">офлайн (кэш)</string>
<string name="track_need_network">Нужна сеть для начала трека</string> <string name="track_need_network">Нужна сеть для начала трека</string>
<string name="track_offline_started">Запись без сети — точки сохраняются на телефоне</string>
<string name="track_sync_pending">Трек сохранён локально — отправится при появлении сети</string>
<string name="track_sync_done">Трек #%1$d загружен (%2$d точек)</string>
<string name="track_status_online">Трекинг: %1$d точек</string>
<string name="track_status_offline">Трекинг: %1$d точек · буфер %2$d · офлайн</string>
<string name="track_offline_toast">Сеть пропала — точки сохраняются на устройстве (буфер %1$d). Без ограничения по времени, до ~500 тыс. точек.</string>
<string name="track_online_toast">Сеть восстановлена — отправка буфера (%1$d точек)</string>
<string name="map_track_status_online">Трек: %1$d точек</string>
<string name="map_track_status_offline">Трек: %1$d точек · буфер %2$d</string>
<string name="map_rx_quality">RX Quality: %1$s%%</string>
<string name="map_rx_quality_unknown">RX Quality: —</string>
<string name="stats_gps_distance">Расстояние TX↔RX: %1$s m</string>
<string name="upload_queue_pending">В очереди: %1$d</string> <string name="upload_queue_pending">В очереди: %1$d</string>
<string name="gps_waiting">GPS: ожидание фикса…</string> <string name="gps_waiting">GPS: ожидание фикса…</string>
<string name="stats_updated_at">обновлено %1$s</string> <string name="stats_updated_at">обновлено %1$s</string>
@@ -0,0 +1,27 @@
package com.grigowashere.loratester;
import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.ui.DeviceNames;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class DeviceNamesTest {
@Test
public void prefersLabelOverDeviceId() {
DeviceInfo d = new DeviceInfo();
d.device_id = "android-abc123";
d.label = "Pixel 7 TX";
assertEquals("Pixel 7 TX", DeviceNames.displayName(d, d.device_id));
}
@Test
public void fallsBackToDeviceId() {
DeviceInfo d = new DeviceInfo();
d.device_id = "android-abc123";
d.label = "android-abc123";
assertEquals("android-abc123", DeviceNames.displayName(d, d.device_id));
}
}
@@ -0,0 +1,45 @@
package com.grigowashere.loratester.track;
import org.junit.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class TrackPointQueueTest {
@Test
public void pendingPointRoundTrip() {
Map<String, Object> point = new HashMap<>();
point.put("ts", 1710000000.5);
point.put("lat", 59.93);
point.put("lon", 30.33);
point.put("altitude_gps", 11.0);
point.put("rssi", -90.0);
point.put("role", "RX");
point.put("meta", "{\"quality\":80}");
TrackPointQueue.PendingPoint pending = TrackPointQueue.PendingPoint.fromMap(point);
Map<String, Object> back = pending.toMap();
assertEquals(59.93, (Double) back.get("lat"), 1e-6);
assertEquals(30.33, (Double) back.get("lon"), 1e-6);
assertEquals(11.0, (Double) back.get("altitude_gps"), 1e-6);
assertEquals(-90.0, (Double) back.get("rssi"), 1e-6);
assertEquals("RX", back.get("role"));
assertEquals("{\"quality\":80}", back.get("meta"));
}
@Test
public void snapshotDefaults() {
TrackPointQueue.Snapshot snap = new TrackPointQueue.Snapshot();
assertEquals(-1, snap.trackId);
assertFalse(snap.recording);
assertEquals(0, snap.totalPoints);
assertTrue(snap.pending.isEmpty());
}
}
+12 -7
View File
@@ -22,7 +22,7 @@ python flask_app.py
| `LORATESTER_PORT` | `7634` | | `LORATESTER_PORT` | `7634` |
| `LORATESTER_DB` | `./loratester.db` | | `LORATESTER_DB` | `./loratester.db` |
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) | | `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) | | `LORATESTER_TRACK_POINTS_LIMIT` | `500000` (точек на один трек) |
| `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` | | `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` |
| `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) | | `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) | | `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
@@ -40,14 +40,17 @@ docker compose up -d --build
curl http://127.0.0.1:7634/api/health | jq curl http://127.0.0.1:7634/api/health | jq
``` ```
Ожидается `"elevation_ok": true` если локальный Open-Meteo доступен с хоста/контейнера. Ожидается `"elevation_ok": true` если OpenTopoData (основной) или Open-Meteo (fallback) доступны с хоста/контейнера.
Переопределить URL высот (`.env` рядом с `docker-compose.yml`): Переопределить URL высот (`.env` рядом с `docker-compose.yml`):
```env ```env
LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation LORATESTER_ELEVATION_OPENTOPO_URL=http://grigowashere.ru:5300/v1/srtm30
LORATESTER_ELEVATION_FALLBACK_URL=http://192.168.1.109:8085/v1/elevation
``` ```
`LORATESTER_ELEVATION_URL` — устаревший alias для fallback (Open-Meteo-compatible).
БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера). БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера).
## Деплой (lora.grigowashere.ru) ## Деплой (lora.grigowashere.ru)
@@ -63,7 +66,8 @@ docker compose up -d --build
cd /srv/storage/disk2/services/LoraTester/server cd /srv/storage/disk2/services/LoraTester/server
pip install -r requirements.txt pip install -r requirements.txt
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
export LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation export LORATESTER_ELEVATION_OPENTOPO_URL=http://grigowashere.ru:5300/v1/srtm30
export LORATESTER_ELEVATION_FALLBACK_URL=http://192.168.1.109:8085/v1/elevation
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634 uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
``` ```
@@ -95,11 +99,12 @@ curl http://127.0.0.1:7634/api/health
### Треки (запись с Android) ### Треки (запись с Android)
- `POST /api/tracks/sync``{device_id, track_id?, started_at?, points[], finish?}` — офлайн-догрузка точек и завершение трека
- `POST /api/tracks/start``{device_id}``{track_id}` - `POST /api/tracks/start``{device_id}``{track_id}`
- `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}` - `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
- `POST /api/tracks/{id}/finish` - `POST /api/tracks/{id}/finish`
- `GET /api/tracks?device_id=` - `GET /api/tracks?device_id=`
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через локальный Open-Meteo) - `GET /api/tracks/{id}` — метаданные + точки (высота terrain через OpenTopoData → Open-Meteo fallback)
### Команды (очередь на устройство) ### Команды (очередь на устройство)
@@ -110,9 +115,9 @@ 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}` → срез рельефа (OpenTopoData → 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/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность
- `GET /api/elevation/grid?lat=&lon=&radius_m=200&step_m=0` — сетка высот для хитмапы (100–500 m, step_m=0 авто) - `GET /api/elevation/grid?lat=&lon=&radius_m=200&step_m=0` — сетка высот для хитмапы (100–500 m, step_m=0 авто)
- `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.
Binary file not shown.
Binary file not shown.
+11 -2
View File
@@ -8,11 +8,20 @@ DATABASE_PATH = os.environ.get(
HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0") HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
PORT = int(os.environ.get("LORATESTER_PORT", "7634")) PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000")) TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000")) TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "500000"))
ELEVATION_API_URL = os.environ.get( ELEVATION_OPENTOPO_URL = os.environ.get(
"LORATESTER_ELEVATION_OPENTOPO_URL",
"http://grigowashere.ru:5300/v1/srtm30",
).rstrip("/")
ELEVATION_FALLBACK_URL = os.environ.get(
"LORATESTER_ELEVATION_FALLBACK_URL",
os.environ.get(
"LORATESTER_ELEVATION_URL", "LORATESTER_ELEVATION_URL",
"http://192.168.1.109:8085/v1/elevation", "http://192.168.1.109:8085/v1/elevation",
),
).rstrip("/") ).rstrip("/")
# Backward-compatible alias for Open-Meteo-compatible fallback API.
ELEVATION_API_URL = ELEVATION_FALLBACK_URL
ELEVATION_PROBE_TTL_SEC = float( ELEVATION_PROBE_TTL_SEC = float(
os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60") os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60")
) )
+235 -41
View File
@@ -1,4 +1,4 @@
"""Terrain elevation via self-hosted Open-Meteo-compatible API.""" """Terrain elevation: OpenTopoData primary, Open-Meteo-compatible fallback."""
from __future__ import annotations from __future__ import annotations
@@ -10,8 +10,9 @@ from typing import Any, Optional
import httpx import httpx
from .config import ( from .config import (
ELEVATION_API_URL,
ELEVATION_CONNECT_TIMEOUT, ELEVATION_CONNECT_TIMEOUT,
ELEVATION_FALLBACK_URL,
ELEVATION_OPENTOPO_URL,
ELEVATION_PROBE_TTL_SEC, ELEVATION_PROBE_TTL_SEC,
) )
@@ -21,8 +22,11 @@ _BATCH_SIZE = 100
_MAX_PROFILE_POINTS = 500 _MAX_PROFILE_POINTS = 500
_CACHE: dict[tuple[float, float], Optional[float]] = {} _CACHE: dict[tuple[float, float], Optional[float]] = {}
_probe_checked_at = 0.0 _probe_checked_at = 0.0
_probe_ok = False _probe_opentopo_ok = False
_probe_error: Optional[str] = None _probe_opentopo_error: Optional[str] = None
_probe_fallback_ok = False
_probe_fallback_error: Optional[str] = None
_last_fetch_source: Optional[str] = None
def _cache_key(lat: float, lon: float) -> tuple[float, float]: def _cache_key(lat: float, lon: float) -> tuple[float, float]:
@@ -42,9 +46,8 @@ def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def probe_elevation_api(force: bool = False) -> dict[str, Any]: def _probe_opentopodata(force: bool = False) -> dict[str, Any]:
"""Ping elevation service before batch requests (cached for TTL).""" global _probe_checked_at, _probe_opentopo_ok, _probe_opentopo_error
global _probe_checked_at, _probe_ok, _probe_error
now = time.monotonic() now = time.monotonic()
if ( if (
@@ -53,35 +56,129 @@ def probe_elevation_api(force: bool = False) -> dict[str, Any]:
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
): ):
return { return {
"ok": _probe_ok, "ok": _probe_opentopo_ok,
"url": ELEVATION_API_URL, "url": ELEVATION_OPENTOPO_URL,
"error": _probe_error, "error": _probe_opentopo_error,
} }
try: try:
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client: with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get( r = client.get(
ELEVATION_API_URL, ELEVATION_OPENTOPO_URL,
params={"locations": "0.000000,0.000000"},
)
r.raise_for_status()
data = r.json()
if data.get("status") != "OK":
raise ValueError(f"status={data.get('status')}")
results = data.get("results") or []
if not results or results[0].get("elevation") is None:
raise ValueError("response has no elevation values")
_probe_opentopo_ok = True
_probe_opentopo_error = None
logger.info("OpenTopoData ok: %s", ELEVATION_OPENTOPO_URL)
except Exception as e:
_probe_opentopo_ok = False
_probe_opentopo_error = str(e)
logger.warning(
"OpenTopoData unreachable %s: %s", ELEVATION_OPENTOPO_URL, e
)
return {
"ok": _probe_opentopo_ok,
"url": ELEVATION_OPENTOPO_URL,
"error": _probe_opentopo_error,
}
def _probe_fallback(force: bool = False) -> dict[str, Any]:
global _probe_checked_at, _probe_fallback_ok, _probe_fallback_error
now = time.monotonic()
if (
not force
and _probe_checked_at > 0
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
):
return {
"ok": _probe_fallback_ok,
"url": ELEVATION_FALLBACK_URL,
"error": _probe_fallback_error,
}
try:
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get(
ELEVATION_FALLBACK_URL,
params={"latitude": "0.000000", "longitude": "0.000000"}, params={"latitude": "0.000000", "longitude": "0.000000"},
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
if "elevation" not in data: if "elevation" not in data:
raise ValueError("response has no elevation field") raise ValueError("response has no elevation field")
_probe_checked_at = now _probe_fallback_ok = True
_probe_ok = True _probe_fallback_error = None
_probe_error = None logger.info("elevation fallback ok: %s", ELEVATION_FALLBACK_URL)
logger.info("elevation API ok: %s", ELEVATION_API_URL)
except Exception as e: except Exception as e:
_probe_checked_at = now _probe_fallback_ok = False
_probe_ok = False _probe_fallback_error = str(e)
_probe_error = str(e) logger.warning(
logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e) "elevation fallback unreachable %s: %s", ELEVATION_FALLBACK_URL, e
)
return { return {
"ok": _probe_ok, "ok": _probe_fallback_ok,
"url": ELEVATION_API_URL, "url": ELEVATION_FALLBACK_URL,
"error": _probe_error, "error": _probe_fallback_error,
}
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
"""Ping elevation providers before batch requests (cached for TTL)."""
global _probe_checked_at
now = time.monotonic()
if (
not force
and _probe_checked_at > 0
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
):
op = {
"ok": _probe_opentopo_ok,
"url": ELEVATION_OPENTOPO_URL,
"error": _probe_opentopo_error,
}
fb = {
"ok": _probe_fallback_ok,
"url": ELEVATION_FALLBACK_URL,
"error": _probe_fallback_error,
}
else:
op = _probe_opentopodata(force=True)
fb = _probe_fallback(force=True)
_probe_checked_at = now
ok = op["ok"] or fb["ok"]
if op["ok"]:
url = op["url"]
error = None
elif fb["ok"]:
url = fb["url"]
error = None
else:
url = ELEVATION_OPENTOPO_URL
error = f"opentopodata: {op['error']}; fallback: {fb['error']}"
return {
"ok": ok,
"url": url,
"error": error,
"opentopodata_ok": op["ok"],
"opentopodata_url": op["url"],
"opentopodata_error": op["error"],
"fallback_ok": fb["ok"],
"fallback_url": fb["url"],
"fallback_error": fb["error"],
} }
@@ -91,10 +188,40 @@ def elevation_status(force: bool = False) -> dict[str, Any]:
"elevation_ok": probe["ok"], "elevation_ok": probe["ok"],
"elevation_url": probe["url"], "elevation_url": probe["url"],
"elevation_error": probe["error"], "elevation_error": probe["error"],
"elevation_opentopodata_ok": probe.get("opentopodata_ok"),
"elevation_opentopodata_url": probe.get("opentopodata_url"),
"elevation_fallback_ok": probe.get("fallback_ok"),
"elevation_fallback_url": probe.get("fallback_url"),
} }
def _fetch_elevation_batch( def _fetch_opentopodata_batch(
batch_lat: list[float], batch_lon: list[float]
) -> list[Optional[float]]:
if not batch_lat:
return []
locations = "|".join(
f"{lat:.6f},{lon:.6f}" for lat, lon in zip(batch_lat, batch_lon)
)
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get(ELEVATION_OPENTOPO_URL, params={"locations": locations})
r.raise_for_status()
data = r.json()
if data.get("status") != "OK":
raise ValueError(f"OpenTopoData status={data.get('status')}")
results = data.get("results") or []
out: list[Optional[float]] = []
for j, item in enumerate(results):
if j >= len(batch_lat):
break
elev = item.get("elevation")
out.append(None if elev is None else float(elev))
while len(out) < len(batch_lat):
out.append(None)
return out
def _fetch_fallback_batch(
batch_lat: list[float], batch_lon: list[float] batch_lat: list[float], batch_lon: list[float]
) -> list[Optional[float]]: ) -> list[Optional[float]]:
if not batch_lat: if not batch_lat:
@@ -104,7 +231,7 @@ def _fetch_elevation_batch(
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon), "longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
} }
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client: with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get(ELEVATION_API_URL, params=params) r = client.get(ELEVATION_FALLBACK_URL, params=params)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
elevations = data.get("elevation") or [] elevations = data.get("elevation") or []
@@ -112,15 +239,81 @@ def _fetch_elevation_batch(
for j, elev in enumerate(elevations): for j, elev in enumerate(elevations):
if j >= len(batch_lat): if j >= len(batch_lat):
break break
if elev is None: out.append(None if elev is None else float(elev))
out.append(None)
else:
out.append(float(elev))
while len(out) < len(batch_lat): while len(out) < len(batch_lat):
out.append(None) out.append(None)
return out return out
def _fetch_batch_with_fallback(
batch_lat: list[float], batch_lon: list[float]
) -> list[Optional[float]]:
global _last_fetch_source
probe = probe_elevation_api()
op_ok = probe.get("opentopodata_ok", False)
fb_ok = probe.get("fallback_ok", False)
if not op_ok and not fb_ok:
return [None] * len(batch_lat)
out: list[Optional[float]] = [None] * len(batch_lat)
used_opentopo = False
used_fallback = False
if op_ok:
try:
out = _fetch_opentopodata_batch(batch_lat, batch_lon)
used_opentopo = any(v is not None for v in out)
except Exception as e:
logger.warning(
"OpenTopoData batch failed (%s points): %s", len(batch_lat), e
)
out = [None] * len(batch_lat)
missing_idx = [i for i, v in enumerate(out) if v is None]
if missing_idx and fb_ok:
miss_lat = [batch_lat[i] for i in missing_idx]
miss_lon = [batch_lon[i] for i in missing_idx]
try:
fb_vals = _fetch_fallback_batch(miss_lat, miss_lon)
for j, idx in enumerate(missing_idx):
out[idx] = fb_vals[j] if j < len(fb_vals) else None
if any(v is not None for v in fb_vals):
used_fallback = True
except Exception as e:
logger.warning(
"elevation fallback batch failed (%s points): %s",
len(miss_lat),
e,
)
if used_opentopo and used_fallback:
_last_fetch_source = "opentopodata+openmeteo"
elif used_opentopo:
_last_fetch_source = "opentopodata"
elif used_fallback:
_last_fetch_source = "openmeteo"
else:
_last_fetch_source = None
return out
def _active_elevation_url() -> str:
probe = probe_elevation_api()
if probe.get("opentopodata_ok"):
return ELEVATION_OPENTOPO_URL
if probe.get("fallback_ok"):
return ELEVATION_FALLBACK_URL
return ELEVATION_OPENTOPO_URL
def _active_api_source() -> str:
return _last_fetch_source or (
"opentopodata" if probe_elevation_api().get("opentopodata_ok") else "openmeteo"
)
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]: def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
vals = fetch_elevations_batch([lat], [lon]) vals = fetch_elevations_batch([lat], [lon])
return vals[0] if vals else None return vals[0] if vals else None
@@ -135,7 +328,7 @@ def fetch_elevations_batch(
probe = probe_elevation_api() probe = probe_elevation_api()
if not probe["ok"]: if not probe["ok"]:
logger.warning( logger.warning(
"skip elevation fetch: API unreachable (%s)", "skip elevation fetch: all providers unreachable (%s)",
probe.get("error"), probe.get("error"),
) )
return [None] * len(lats) return [None] * len(lats)
@@ -159,14 +352,15 @@ def fetch_elevations_batch(
batch_lat = pending_lat[start : start + _BATCH_SIZE] batch_lat = pending_lat[start : start + _BATCH_SIZE]
batch_lon = pending_lon[start : start + _BATCH_SIZE] batch_lon = pending_lon[start : start + _BATCH_SIZE]
try: try:
batch_vals = _fetch_elevation_batch(batch_lat, batch_lon) batch_vals = _fetch_batch_with_fallback(batch_lat, batch_lon)
for j, val in enumerate(batch_vals): for j, val in enumerate(batch_vals):
lat = batch_lat[j] lat = batch_lat[j]
lon = batch_lon[j] lon = batch_lon[j]
_CACHE[_cache_key(lat, lon)] = val _CACHE[_cache_key(lat, lon)] = val
out[batch_i[j]] = val out[batch_i[j]] = val
logger.info( logger.info(
"elevation ok: %s points, sample=%s", "elevation ok (%s): %s points, sample=%s",
_last_fetch_source,
len(batch_lat), len(batch_lat),
batch_vals[0] if batch_vals else None, batch_vals[0] if batch_vals else None,
) )
@@ -178,7 +372,7 @@ def fetch_elevations_batch(
) )
for j in range(len(batch_lat)): for j in range(len(batch_lat)):
try: try:
single = _fetch_elevation_batch( single = _fetch_batch_with_fallback(
[batch_lat[j]], [batch_lon[j]] [batch_lat[j]], [batch_lon[j]]
) )
val = single[0] if single else None val = single[0] if single else None
@@ -329,7 +523,7 @@ def build_elevation_profile(
"total_m": 0.0, "total_m": 0.0,
"api_source": "elevation", "api_source": "elevation",
"api_error": f"elevation API unreachable: {probe['error']}", "api_error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
lats = [s["lat"] for s in samples] lats = [s["lat"] for s in samples]
@@ -356,8 +550,8 @@ def build_elevation_profile(
"min_elevation_m": min(elev_vals) if elev_vals else None, "min_elevation_m": min(elev_vals) if elev_vals else None,
"max_elevation_m": max(elev_vals) if elev_vals else None, "max_elevation_m": max(elev_vals) if elev_vals else None,
"points": profile, "points": profile,
"api_source": "elevation", "api_source": _active_api_source(),
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
if not elev_vals: if not elev_vals:
result["api_error"] = "elevation API returned no values" result["api_error"] = "elevation API returned no values"
@@ -429,7 +623,7 @@ def build_elevation_grid(
return { return {
"ok": False, "ok": False,
"error": f"elevation API unreachable: {probe['error']}", "error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
radius_m = max(50.0, min(float(radius_m), 500.0)) radius_m = max(50.0, min(float(radius_m), 500.0))
@@ -481,8 +675,8 @@ def build_elevation_grid(
"points": points, "points": points,
"min_delta_m": round(min(deltas), 1), "min_delta_m": round(min(deltas), 1),
"max_delta_m": round(max(deltas), 1), "max_delta_m": round(max(deltas), 1),
"api_source": "elevation", "api_source": _active_api_source(),
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
@@ -499,7 +693,7 @@ def find_nearest_hill(
return { return {
"ok": False, "ok": False,
"error": f"elevation API unreachable: {probe['error']}", "error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
radius_m = max(500.0, min(float(radius_m), 15_000.0)) radius_m = max(500.0, min(float(radius_m), 15_000.0))
@@ -590,6 +784,6 @@ def find_nearest_hill(
"candidates": len(candidates), "candidates": len(candidates),
"radius_m": radius_m, "radius_m": radius_m,
"step_m": step_m, "step_m": step_m,
"api_source": "elevation", "api_source": _active_api_source(),
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
+73
View File
@@ -353,6 +353,79 @@ def finish_track(track_id: int) -> dict[str, Any]:
return {"ok": True, "track_id": track_id, "ended_at": ts, "point_count": count} return {"ok": True, "track_id": track_id, "ended_at": ts, "point_count": count}
def sync_track(
device_id: str,
points: list[dict[str, Any]],
track_id: Optional[int] = None,
started_at: Optional[float] = None,
finish: bool = False,
label: Optional[str] = None,
) -> dict[str, Any]:
"""Upload buffered points after offline recording; optionally create track and finish."""
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
points = points or []
if track_id is not None:
with _db() as conn:
track = conn.execute(
"SELECT id, device_id, ended_at FROM tracks WHERE id = ?",
(track_id,),
).fetchone()
if not track:
raise ValueError(f"track {track_id} not found")
if track["device_id"] != device_id:
raise ValueError("device_id does not match track owner")
if track["ended_at"] is not None:
raise ValueError(f"track {track_id} already finished")
else:
if not points and not finish:
raise ValueError("points required when creating a new track")
ts = float(started_at) if started_at is not None else time.time()
with _db() as conn:
cur = conn.execute(
"""
INSERT INTO tracks (device_id, started_at, label)
VALUES (?, ?, ?)
""",
(device_id, ts, label),
)
track_id = int(cur.lastrowid)
added = 0
batch_size = 100
for i in range(0, len(points), batch_size):
chunk = points[i : i + batch_size]
if not chunk:
continue
result = add_track_points(track_id, chunk)
added += int(result.get("added") or 0)
finished = False
ended_at = None
point_count = added
if finish:
fin = finish_track(track_id)
finished = True
ended_at = fin.get("ended_at")
point_count = int(fin.get("point_count") or 0)
else:
with _db() as conn:
point_count = conn.execute(
"SELECT COUNT(*) FROM track_points WHERE track_id = ?",
(track_id,),
).fetchone()[0]
return {
"ok": True,
"track_id": track_id,
"added": added,
"point_count": point_count,
"finished": finished,
"ended_at": ended_at,
}
def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]: def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
limit = min(max(1, limit), 200) limit = min(max(1, limit), 200)
with _db() as conn: with _db() as conn:
+3 -1
View File
@@ -10,11 +10,13 @@ services:
environment: environment:
LORATESTER_DB: /data/loratester.db LORATESTER_DB: /data/loratester.db
LORATESTER_PORT: "7634" LORATESTER_PORT: "7634"
LORATESTER_ELEVATION_OPENTOPO_URL: ${LORATESTER_ELEVATION_OPENTOPO_URL:-http://grigowashere.ru:5300/v1/srtm30}
LORATESTER_ELEVATION_FALLBACK_URL: ${LORATESTER_ELEVATION_FALLBACK_URL:-http://192.168.1.109:8085/v1/elevation}
LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation} LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation}
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60} LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8} LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000} LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000}
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-10000} LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-500000}
volumes: volumes:
loratester-data: loratester-data:
+30 -1
View File
@@ -72,6 +72,15 @@ class TrackPointsBody(BaseModel):
points: list[TrackPoint] = Field(default_factory=list) points: list[TrackPoint] = Field(default_factory=list)
class TrackSyncBody(BaseModel):
device_id: str
track_id: Optional[int] = None
started_at: Optional[float] = None
points: list[TrackPoint] = Field(default_factory=list)
finish: bool = False
label: Optional[str] = None
class CommandBody(BaseModel): class CommandBody(BaseModel):
from_device_id: str from_device_id: str
to_device_id: str to_device_id: str
@@ -193,6 +202,26 @@ def tracks_finish(
raise HTTPException(400, detail=str(e)) from e raise HTTPException(400, detail=str(e)) from e
@app.post("/api/tracks/sync")
def tracks_sync(
body: TrackSyncBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
points = [p.model_dump(exclude_none=True) for p in body.points]
return storage.sync_track(
body.device_id,
points,
track_id=body.track_id,
started_at=body.started_at,
finish=body.finish,
label=body.label,
)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/tracks") @app.get("/api/tracks")
def tracks_list( def tracks_list(
device_id: Optional[str] = None, device_id: Optional[str] = None,
@@ -379,7 +408,7 @@ def health():
return { return {
"ok": status["db_ok"], "ok": status["db_ok"],
"ts": time.time(), "ts": time.time(),
"api_build": "2026-06-16g", "api_build": "2026-06-19a",
**status, **status,
**elevation_status(), **elevation_status(),
} }
+200 -11
View File
@@ -45,7 +45,17 @@
} }
#elevationStatus { font-size: 0.7rem; color: #aaa; font-weight: 400; } #elevationStatus { font-size: 0.7rem; color: #aaa; font-weight: 400; }
#elevationCanvas { width: 100%; height: 130px; display: block; background: #0a0a14; border-radius: 4px; } #elevationCanvas { width: 100%; height: 130px; display: block; background: #0a0a14; border-radius: 4px; }
#elevationCanvas.elev-probe { cursor: crosshair; }
.elev-legend { font-size: 0.7rem; } .elev-legend { font-size: 0.7rem; }
#qualityVizPanel {
display: none; margin-top: 8px;
}
#qualityVizPanel.visible { display: block; }
.quality-viz-box {
background: #0f3460; border: 1px solid #444; border-radius: 6px; padding: 6px;
}
.quality-viz-title { font-size: 0.7rem; color: #aaa; margin-bottom: 4px; }
#qualityDistCanvas { width: 100%; height: 140px; display: block; background: #0a0a14; border-radius: 4px; }
#timelineStatsPanel { #timelineStatsPanel {
display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333; display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333;
} }
@@ -106,8 +116,8 @@
#pairedStatus { font-size: 0.75rem; color: #aaa; margin-top: 4px; } #pairedStatus { font-size: 0.75rem; color: #aaa; margin-top: 4px; }
.muted { color: #aaa; font-size: 0.75rem; } .muted { color: #aaa; font-size: 0.75rem; }
.legend { font-size: 0.75rem; color: #ccc; } .legend { font-size: 0.75rem; color: #ccc; }
.legend-tx { color: #e94560; } .legend-tx { color: #4fc3f7; }
.legend-rx { color: #4fc3f7; } .legend-rx { color: #e94560; }
#mapModal { #mapModal {
display: none; position: fixed; z-index: 2000; display: none; position: fixed; z-index: 2000;
min-width: 260px; max-width: 360px; max-height: 70vh; overflow: auto; min-width: 260px; max-width: 360px; max-height: 70vh; overflow: auto;
@@ -320,6 +330,12 @@
</div> </div>
<canvas id="elevationCanvas" width="800" height="130"></canvas> <canvas id="elevationCanvas" width="800" height="130"></canvas>
</div> </div>
<div id="qualityVizPanel">
<div class="quality-viz-box">
<div class="quality-viz-title">RX Quality vs расстояние TX↔RX</div>
<canvas id="qualityDistCanvas" width="800" height="140"></canvas>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
@@ -332,6 +348,7 @@
</div> </div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/static/radio-ui.js"></script> <script src="/static/radio-ui.js"></script>
<script src="/static/quality-viz.js"></script>
<script> <script>
if (typeof RadioUI === 'undefined') { if (typeof RadioUI === 'undefined') {
console.error('radio-ui.js not loaded — check /static/radio-ui.js'); console.error('radio-ui.js not loaded — check /static/radio-ui.js');
@@ -361,7 +378,7 @@
{ position: 'topright', collapsed: true } { position: 'topright', collapsed: true }
).addTo(map); ).addTo(map);
const API_BUILD = '2026-06-16g'; const API_BUILD = '2026-06-16i';
const markers = {}; const markers = {};
let selectedId = null; let selectedId = null;
@@ -430,14 +447,22 @@
let mapRulerPointsAuto = true; let mapRulerPointsAuto = true;
let mapRulerManualPoints = 100; let mapRulerManualPoints = 100;
let mapRulerReloadTimer = null; let mapRulerReloadTimer = null;
let timelineElevHoverDist = null;
let timelineElevCursorMarker = null;
let timelineElevBaseStatus = '';
let timelineLinkTxPos = null;
let timelineLinkRxPos = null;
let timelineElevLeaveTimer = null;
let timelineElevChartHover = false;
let qualitySamplesCache = [];
const DEVICE_POLL_MS = 1000; const DEVICE_POLL_MS = 1000;
const CHAT_POLL_MS = 2500; const CHAT_POLL_MS = 2500;
const TRACKS_POLL_MS = 10000; const TRACKS_POLL_MS = 10000;
const TELEMETRY_POLL_MS = 2000; const TELEMETRY_POLL_MS = 2000;
const TX_COLOR = '#e94560'; const TX_COLOR = '#4fc3f7';
const RX_COLOR = '#4fc3f7'; const RX_COLOR = '#e94560';
map.on('zoomend moveend', () => { map.on('zoomend moveend', () => {
if (!programmaticMove) userMovedMap = true; if (!programmaticMove) userMovedMap = true;
@@ -1092,6 +1117,114 @@
} }
} }
function clearTimelineElevCursorMarker() {
if (timelineElevCursorMarker) {
map.removeLayer(timelineElevCursorMarker);
timelineElevCursorMarker = null;
}
}
function updateTimelineElevCursorMarker(pt) {
clearTimelineElevCursorMarker();
timelineElevCursorMarker = L.circleMarker([pt.lat, pt.lon], {
radius: 8,
color: '#fff',
weight: 2,
fillColor: '#00ff88',
fillOpacity: 0.95,
interactive: false
}).addTo(map);
}
function setTimelineElevCursor(distM) {
if (!dualTracksActive || !timelineLinkTxPos || !timelineLinkRxPos
|| elevationPointCount(elevProfileLink) === 0) return;
const total = elevProfileLink.total_m
|| lineLengthM(timelineLinkTxPos, timelineLinkRxPos);
timelineElevHoverDist = Math.max(0, Math.min(distM, total));
const pt = latLonAtLineDist(
timelineLinkTxPos, timelineLinkRxPos, timelineElevHoverDist);
updateTimelineElevCursorMarker(pt);
const elev = elevationAtDist(elevProfileLink, timelineElevHoverDist);
if (elev != null) {
setElevationStatus(`${timelineElevHoverDist.toFixed(0)} m · ${elev.toFixed(1)} m`);
}
drawElevationChart();
}
function scheduleClearTimelineElevCursor() {
clearTimeout(timelineElevLeaveTimer);
timelineElevLeaveTimer = setTimeout(() => {
if (timelineElevChartHover) return;
timelineElevHoverDist = null;
clearTimelineElevCursorMarker();
setElevationStatus(timelineElevBaseStatus);
drawElevationChart();
}, 60);
}
function updateElevationProbeClass() {
const canvas = document.getElementById('elevationCanvas');
if (!canvas) return;
canvas.classList.toggle(
'elev-probe',
dualTracksActive && elevationPointCount(elevProfileLink) > 0
);
}
function buildQualitySamples() {
if (!dualTracksActive || !loadedTxTrack?.points?.length || !loadedRxTrack?.points?.length) {
return [];
}
const samples = [];
const steps = Math.min(
200,
Math.max(loadedTxTrack.points.length, loadedRxTrack.points.length, 2) - 1
);
for (let i = 0; i <= steps; i++) {
const cursor = timelineUseProgress
? { progress: i / steps }
: { t: overlapMin + (i / steps) * Math.max(overlapMax - overlapMin, 1e-6) };
const txPos = positionAtCursor(loadedTxTrack.points, cursor);
const rxPos = positionAtCursor(loadedRxTrack.points, cursor);
if (!txPos || !rxPos) continue;
const quality = rxQualityFromMeta(rxPos.meta);
if (quality == null) continue;
const distM = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
samples.push({ distM, quality });
}
return samples;
}
function currentTimelineLinkDist() {
if (!timelineLinkTxPos || !timelineLinkRxPos) return null;
return haversineM(
timelineLinkTxPos.lat, timelineLinkTxPos.lon,
timelineLinkRxPos.lat, timelineLinkRxPos.lon);
}
function redrawQualityDistHighlight() {
if (!dualTracksActive) return;
const distCanvas = document.getElementById('qualityDistCanvas');
if (!distCanvas) return;
QualityViz.drawQualityDistChart(
distCanvas, qualitySamplesCache, qualityColor, currentTimelineLinkDist());
}
function rebuildQualityViz() {
const panel = document.getElementById('qualityVizPanel');
if (!dualTracksActive) {
panel?.classList.remove('visible');
qualitySamplesCache = [];
return;
}
qualitySamplesCache = buildQualitySamples();
const distCanvas = document.getElementById('qualityDistCanvas');
QualityViz.drawQualityDistChart(
distCanvas, qualitySamplesCache, qualityColor, currentTimelineLinkDist());
panel?.classList.add('visible');
}
function scheduleClearMapRulerCursor() { function scheduleClearMapRulerCursor() {
clearTimeout(mapRulerLeaveTimer); clearTimeout(mapRulerLeaveTimer);
mapRulerLeaveTimer = setTimeout(() => { mapRulerLeaveTimer = setTimeout(() => {
@@ -1533,6 +1666,7 @@
profile: elevProfileLink, profile: elevProfileLink,
label: 'рельеф TX↔RX', label: 'рельеф TX↔RX',
losLine: elevA != null && elevB != null ? { elevA, elevB } : null, losLine: elevA != null && elevB != null ? { elevA, elevB } : null,
cursor: timelineElevHoverDist,
}); });
return series; return series;
} }
@@ -1785,8 +1919,11 @@
if (n > 0) { if (n > 0) {
const src = profile.source === 'elevation' ? 'высоты' const src = profile.source === 'elevation' ? 'высоты'
: profile.source === 'server' ? 'сервер' : (profile.source || 'данные'); : profile.source === 'server' ? 'сервер' : (profile.source || 'данные');
setElevationStatus(`срез TX↔RX · ${dist.toFixed(0)} m · ${src} · ${n} точек · оранжевая — прямая`); timelineElevBaseStatus =
`срез TX↔RX · ${dist.toFixed(0)} m · ${src} · ${n} точек · оранжевая — прямая`;
setElevationStatus(timelineElevBaseStatus);
} }
updateElevationProbeClass();
} }
async function loadElevationProfiles() { async function loadElevationProfiles() {
@@ -1821,7 +1958,14 @@
: ref?.source === 'server' ? 'сервер' : (ref?.source || 'данные'); : ref?.source === 'server' ? 'сервер' : (ref?.source || 'данные');
if (dualTracksActive && elevProfileLink) { if (dualTracksActive && elevProfileLink) {
const n = elevationPointCount(elevProfileLink); const n = elevationPointCount(elevProfileLink);
setElevationStatus(`срез TX↔RX · ${srcLabel} · ${n} точек · оранжевая — прямая`); const dist = timelineLinkTxPos && timelineLinkRxPos
? haversineM(
timelineLinkTxPos.lat, timelineLinkTxPos.lon,
timelineLinkRxPos.lat, timelineLinkRxPos.lon)
: (elevProfileLink.total_m || 0);
timelineElevBaseStatus =
`срез TX↔RX · ${dist.toFixed(0)} m · ${srcLabel} · ${n} точек · оранжевая — прямая`;
setElevationStatus(timelineElevBaseStatus);
} else if (dualTracksActive && elevProfileTx && elevProfileRx) { } else if (dualTracksActive && elevProfileTx && elevProfileRx) {
const nTx = elevationPointCount(elevProfileTx); const nTx = elevationPointCount(elevProfileTx);
const nRx = elevationPointCount(elevProfileRx); const nRx = elevationPointCount(elevProfileRx);
@@ -1835,6 +1979,7 @@
setElevationStatus(err ? `ошибка: ${err}` : 'нет данных'); setElevationStatus(err ? `ошибка: ${err}` : 'нет данных');
} }
drawElevationChart(); drawElevationChart();
updateElevationProbeClass();
requestAnimationFrame(() => drawElevationChart( requestAnimationFrame(() => drawElevationChart(
singleTrackActive singleTrackActive
? { single: trackDistanceAtCursor(loadedSingleTrack, timelineCursor()) } ? { single: trackDistanceAtCursor(loadedSingleTrack, timelineCursor()) }
@@ -2132,6 +2277,14 @@
elevProfileSingle = null; elevProfileSingle = null;
elevProfileLink = null; elevProfileLink = null;
elevProfileLinkKey = null; elevProfileLinkKey = null;
timelineElevHoverDist = null;
timelineLinkTxPos = null;
timelineLinkRxPos = null;
timelineElevBaseStatus = '';
clearTimelineElevCursorMarker();
qualitySamplesCache = [];
document.getElementById('qualityVizPanel')?.classList.remove('visible');
updateElevationProbeClass();
drawElevationChart(); drawElevationChart();
if (playTimer) { if (playTimer) {
clearInterval(playTimer); clearInterval(playTimer);
@@ -2237,12 +2390,14 @@
if (txPos) { if (txPos) {
ghostTx = L.circleMarker([txPos.lat, txPos.lon], { ghostTx = L.circleMarker([txPos.lat, txPos.lon], {
radius: 10, color: TX_COLOR, fillColor: TX_COLOR, fillOpacity: 0.9, weight: 3 radius: 10, color: '#fff', fillColor: TX_COLOR, fillOpacity: 0.95, weight: 3
}).addTo(map); }).addTo(map);
} }
if (rxPos) { if (rxPos) {
const q = rxQualityFromMeta(rxPos.meta);
const rxGhostColor = q != null ? (qualityColor(q) || RX_COLOR) : RX_COLOR;
ghostRx = L.circleMarker([rxPos.lat, rxPos.lon], { ghostRx = L.circleMarker([rxPos.lat, rxPos.lon], {
radius: 10, color: RX_COLOR, fillColor: RX_COLOR, fillOpacity: 0.9, weight: 3 radius: 10, color: '#fff', fillColor: rxGhostColor, fillOpacity: 0.95, weight: 3
}).addTo(map); }).addTo(map);
} }
if (txPos && rxPos) { if (txPos && rxPos) {
@@ -2268,11 +2423,18 @@
deviceDisplayName(loadedTxTrack?.device_id), deviceDisplayName(loadedTxTrack?.device_id),
deviceDisplayName(loadedRxTrack?.device_id) deviceDisplayName(loadedRxTrack?.device_id)
)); ));
timelineLinkTxPos = txPos || null;
timelineLinkRxPos = rxPos || null;
if (txPos && rxPos) { if (txPos && rxPos) {
scheduleLinkElevation(txPos, rxPos).then(() => drawElevationChart()); scheduleLinkElevation(txPos, rxPos).then(() => {
drawElevationChart();
updateElevationProbeClass();
});
} else { } else {
drawElevationChart(); drawElevationChart();
} }
redrawQualityDistHighlight();
} }
function updateTimelineAtSingle(cursor, openModal) { function updateTimelineAtSingle(cursor, openModal) {
@@ -2316,9 +2478,14 @@
function setTimelineVisible(visible) { function setTimelineVisible(visible) {
document.getElementById('trackTimeline').classList.toggle('visible', visible); document.getElementById('trackTimeline').classList.toggle('visible', visible);
document.getElementById('timelineStatsPanel').classList.toggle('visible', visible); document.getElementById('timelineStatsPanel').classList.toggle('visible', visible);
document.getElementById('qualityVizPanel')?.classList.toggle(
'visible', visible && dualTracksActive);
setTimeout(() => { setTimeout(() => {
map.invalidateSize(); map.invalidateSize();
if (visible) drawElevationChart(); if (visible) {
drawElevationChart();
if (dualTracksActive) rebuildQualityViz();
}
}, 80); }, 80);
} }
@@ -2419,6 +2586,7 @@
setTimelineVisible(true); setTimelineVisible(true);
updateTimelineAt(timelineCursor()); updateTimelineAt(timelineCursor());
loadElevationProfiles(); loadElevationProfiles();
rebuildQualityViz();
} }
async function refreshTimelineTelemetry() { async function refreshTimelineTelemetry() {
@@ -2595,6 +2763,7 @@
updateTimelineAt(timelineCursor()); updateTimelineAt(timelineCursor());
} }
rebuildQualityViz();
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : ''; const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
document.getElementById('trackInfo').textContent = document.getElementById('trackInfo').textContent =
`TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`; `TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`;
@@ -2689,6 +2858,26 @@
}); });
})(); })();
(function bindTimelineElevationProbe() {
const canvas = document.getElementById('elevationCanvas');
if (!canvas) return;
canvas.addEventListener('mousemove', e => {
if (!dualTracksActive || elevationPointCount(elevProfileLink) === 0) return;
const layout = canvas._elevLayout;
if (!layout || layout.plotW <= 0) return;
timelineElevChartHover = true;
clearTimeout(timelineElevLeaveTimer);
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const dist = ((x - layout.margin.l) / layout.plotW) * layout.maxDist;
setTimelineElevCursor(dist);
});
canvas.addEventListener('mouseleave', () => {
timelineElevChartHover = false;
scheduleClearTimelineElevCursor();
});
})();
(function bindMapRulerChartProbe() { (function bindMapRulerChartProbe() {
const canvas = document.getElementById('mapRulerCanvas'); const canvas = document.getElementById('mapRulerCanvas');
if (!canvas) return; if (!canvas) return;
+96
View File
@@ -0,0 +1,96 @@
/** RX quality vs distance chart for TX/RX track compare. */
(function (global) {
'use strict';
function drawQualityDistChart(canvas, samples, qualityColor, highlightDist) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.clientWidth || 280;
const h = canvas.clientHeight || 140;
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, w, h);
if (!samples?.length) {
ctx.fillStyle = '#888';
ctx.font = '11px system-ui';
ctx.fillText('нет данных качества', 12, h / 2);
return;
}
const dists = samples.map(s => s.distM);
const minD = Math.min(...dists);
const maxD = Math.max(...dists);
const span = Math.max(maxD - minD, 1);
const binCount = Math.min(20, Math.max(5, Math.ceil(span / 10)));
const binW = span / binCount;
const bins = Array.from({ length: binCount }, (_, i) => ({
min: minD + i * binW,
max: minD + (i + 1) * binW,
qualities: [],
}));
for (const s of samples) {
let idx = Math.floor((s.distM - minD) / binW);
if (idx >= binCount) idx = binCount - 1;
if (idx < 0) idx = 0;
bins[idx].qualities.push(s.quality);
}
const margin = { l: 36, r: 8, t: 16, b: 22 };
const plotW = w - margin.l - margin.r;
const plotH = h - margin.t - margin.b;
ctx.strokeStyle = '#333';
ctx.beginPath();
ctx.moveTo(margin.l, margin.t);
ctx.lineTo(margin.l, margin.t + plotH);
ctx.lineTo(margin.l + plotW, margin.t + plotH);
ctx.stroke();
ctx.fillStyle = '#888';
ctx.font = '9px system-ui';
ctx.fillText('0%', 2, margin.t + plotH);
ctx.fillText('100%', 2, margin.t + 8);
ctx.fillText(`${Math.round(minD)}m`, margin.l, h - 2);
ctx.fillText(`${Math.round(maxD)}m`, margin.l + plotW - 24, h - 2);
ctx.fillStyle = '#ccc';
ctx.font = '10px system-ui';
ctx.fillText('RX Quality vs расстояние', margin.l, margin.t - 4);
const barW = plotW / binCount * 0.75;
bins.forEach((b, i) => {
if (!b.qualities.length) return;
const avg = b.qualities.reduce((a, v) => a + v, 0) / b.qualities.length;
const cx = margin.l + (i + 0.5) / binCount * plotW;
const barH = (avg / 100) * plotH;
const x = cx - barW / 2;
const y = margin.t + plotH - barH;
const col = qualityColor ? qualityColor(avg) : '#888';
const highlight = highlightDist != null
&& highlightDist >= b.min && highlightDist < b.max;
ctx.fillStyle = col;
ctx.globalAlpha = highlight ? 1 : 0.75;
ctx.fillRect(x, y, barW, barH);
ctx.globalAlpha = 1;
if (highlight) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.strokeRect(x, y, barW, barH);
}
});
ctx.fillStyle = 'rgba(255,255,255,0.25)';
samples.forEach(s => {
const x = margin.l + ((s.distM - minD) / span) * plotW;
const y = margin.t + plotH - (s.quality / 100) * plotH;
ctx.beginPath();
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
ctx.fill();
});
}
global.QualityViz = {
drawQualityDistChart,
};
})(typeof window !== 'undefined' ? window : globalThis);
+110 -8
View File
@@ -23,6 +23,16 @@ class _FakeClient:
return False return False
def get(self, url, params=None): def get(self, url, params=None):
params = params or {}
if "locations" in params:
locs = params["locations"].split("|")
return _FakeResponse({
"status": "OK",
"results": [
{"elevation": 11.0 + i, "location": {"lat": 0, "lng": 0}}
for i, _ in enumerate(locs)
],
})
return _FakeResponse({"elevation": [152.0]}) return _FakeResponse({"elevation": [152.0]})
@@ -34,13 +44,20 @@ def test_probe_elevation_api_ok(monkeypatch):
assert status["ok"] is True assert status["ok"] is True
assert status["error"] is None assert status["error"] is None
assert status["opentopodata_ok"] is True
def test_fetch_skips_when_unreachable(monkeypatch): def test_fetch_skips_when_unreachable(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
"probe_elevation_api", "probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"}, lambda force=False: {
"ok": False,
"url": elev.ELEVATION_OPENTOPO_URL,
"error": "down",
"opentopodata_ok": False,
"fallback_ok": False,
},
) )
vals = elev.fetch_elevations_batch([55.75], [37.62]) vals = elev.fetch_elevations_batch([55.75], [37.62])
@@ -52,7 +69,13 @@ def test_build_profile_reports_unreachable(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
"probe_elevation_api", "probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"}, lambda force=False: {
"ok": False,
"url": elev.ELEVATION_OPENTOPO_URL,
"error": "down",
"opentopodata_ok": False,
"fallback_ok": False,
},
) )
profile = elev.build_elevation_profile( profile = elev.build_elevation_profile(
@@ -76,7 +99,16 @@ def test_resample_track_path_count_even_spacing():
def test_build_profile_target_points(monkeypatch): def test_build_profile_target_points(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0) monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None}) monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
"fetch_elevations_batch", "fetch_elevations_batch",
@@ -96,7 +128,13 @@ def test_find_nearest_hill_unreachable(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
"probe_elevation_api", "probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"}, lambda force=False: {
"ok": False,
"url": elev.ELEVATION_OPENTOPO_URL,
"error": "down",
"opentopodata_ok": False,
"fallback_ok": False,
},
) )
result = elev.find_nearest_hill(55.75, 37.62) result = elev.find_nearest_hill(55.75, 37.62)
assert result["ok"] is False assert result["ok"] is False
@@ -104,7 +142,16 @@ def test_find_nearest_hill_unreachable(monkeypatch):
def test_find_nearest_hill_picks_nearest_peak(monkeypatch): def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0) monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None}) monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
def fake_batch(lats, lons): def fake_batch(lats, lons):
out = [] out = []
@@ -125,7 +172,16 @@ def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
def test_build_elevation_grid_delta(monkeypatch): def test_build_elevation_grid_delta(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0) monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None}) monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
def fake_batch(lats, lons): def fake_batch(lats, lons):
return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(lats, lons)] return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(lats, lons)]
@@ -143,7 +199,16 @@ def test_build_elevation_grid_delta(monkeypatch):
def test_build_elevation_grid_fine_step_small_radius(monkeypatch): def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0) monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None}) monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 120.0) monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 120.0)
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
@@ -159,7 +224,16 @@ def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
def test_build_elevation_grid_limits_points(monkeypatch): def test_build_elevation_grid_limits_points(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0) monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None}) monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 50.0) monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 50.0)
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
@@ -170,3 +244,31 @@ def test_build_elevation_grid_limits_points(monkeypatch):
step = elev._resolve_grid_step(55.75, 37.62, 500.0, 5.0) step = elev._resolve_grid_step(55.75, 37.62, 500.0, 5.0)
cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step) cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step)
assert len(cells) <= elev._MAX_GRID_POINTS assert len(cells) <= elev._MAX_GRID_POINTS
def test_fetch_uses_fallback_when_opentopo_missing(monkeypatch):
monkeypatch.setattr(elev, "_CACHE", {})
monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
def fake_opentopo(batch_lat, batch_lon):
return [None] * len(batch_lat)
def fake_fallback(batch_lat, batch_lon):
return [42.0] * len(batch_lat)
monkeypatch.setattr(elev, "_fetch_opentopodata_batch", fake_opentopo)
monkeypatch.setattr(elev, "_fetch_fallback_batch", fake_fallback)
vals = elev.fetch_elevations_batch([55.75], [37.62])
assert vals == [42.0]
assert elev._last_fetch_source == "openmeteo"
+44
View File
@@ -45,6 +45,50 @@ def test_old_telemetry_without_meta_gets_migrated(temp_db):
conn.close() conn.close()
def test_sync_track_offline_upload(temp_db, monkeypatch):
storage.init_db()
monkeypatch.setattr(storage, "fetch_elevation_m", lambda lat, lon: 100.0)
start = storage.start_track("android-12345678")
tid = start["track_id"]
result = storage.sync_track(
"android-12345678",
[
{"ts": 1.0, "lat": 55.75, "lon": 37.62, "role": "TX"},
{"ts": 2.0, "lat": 55.751, "lon": 37.621, "role": "TX"},
],
track_id=tid,
finish=True,
)
assert result["added"] == 2
assert result["finished"] is True
assert result["point_count"] == 2
track = storage.get_track(tid)
assert len(track["points"]) == 2
assert track["ended_at"] is not None
def test_sync_track_create_offline(temp_db, monkeypatch):
storage.init_db()
monkeypatch.setattr(storage, "fetch_elevation_m", lambda lat, lon: 50.0)
result = storage.sync_track(
"android-abcdef01",
[
{"ts": 10.0, "lat": 59.93, "lon": 30.33, "role": "RX"},
],
track_id=None,
started_at=10.0,
finish=True,
)
assert result["track_id"] > 0
assert result["point_count"] == 1
track = storage.get_track(result["track_id"])
assert track["started_at"] == 10.0
def test_tracks_crud(temp_db): def test_tracks_crud(temp_db):
storage.init_db() storage.init_db()
start = storage.start_track("android-12345678") start = storage.start_track("android-12345678")