generated from Grigo/AndroidTemplate
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4842d4b29 | |||
| 8812cf9b40 | |||
| 4891933879 | |||
| f4ef87705c | |||
| 920a839197 |
@@ -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,32 +129,99 @@ 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()) {
|
|
||||||
notifyError("Нужна сеть для начала трека");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
if (pendingSync) {
|
||||||
long id = serverApi.startTrack(deviceId);
|
syncWhenOnline();
|
||||||
synchronized (buffer) {
|
if (pendingSync) {
|
||||||
buffer.clear();
|
notifyError(appContext.getString(R.string.track_sync_pending));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
totalPoints = 0;
|
}
|
||||||
trackId = id;
|
if (!networkMonitor.isOnline()) {
|
||||||
recording = true;
|
startLocalRecording();
|
||||||
startTimers();
|
return;
|
||||||
notifyState();
|
}
|
||||||
|
try {
|
||||||
|
startOnlineRecording();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "start track failed", e);
|
Log.w(TAG, "start track on server failed", e);
|
||||||
notifyError(e.getMessage());
|
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);
|
||||||
|
synchronized (buffer) {
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
totalPoints = 0;
|
||||||
|
trackId = id;
|
||||||
|
localStartedAt = System.currentTimeMillis() / 1000.0;
|
||||||
|
recording = true;
|
||||||
|
pendingSync = false;
|
||||||
|
persistState(false);
|
||||||
|
startTimers();
|
||||||
|
notifyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
if (!recording) {
|
if (!recording) {
|
||||||
return;
|
return;
|
||||||
@@ -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));
|
||||||
} catch (Exception e) {
|
} else {
|
||||||
Log.e(TAG, "stop track failed", e);
|
resetAfterSuccessfulSync(-1);
|
||||||
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;
|
|
||||||
synchronized (buffer) {
|
|
||||||
if (buffer.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
batch = new ArrayList<>(buffer);
|
if (pairedListener != null) {
|
||||||
buffer.clear();
|
pairedListener.onSyncComplete(trackId, pointCount);
|
||||||
}
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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.
+13
-4
@@ -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_URL",
|
"LORATESTER_ELEVATION_OPENTOPO_URL",
|
||||||
"http://192.168.1.109:8085/v1/elevation",
|
"http://grigowashere.ru:5300/v1/srtm30",
|
||||||
).rstrip("/")
|
).rstrip("/")
|
||||||
|
ELEVATION_FALLBACK_URL = os.environ.get(
|
||||||
|
"LORATESTER_ELEVATION_FALLBACK_URL",
|
||||||
|
os.environ.get(
|
||||||
|
"LORATESTER_ELEVATION_URL",
|
||||||
|
"http://192.168.1.109:8085/v1/elevation",
|
||||||
|
),
|
||||||
|
).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
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user