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
|
||||
public void onError(String message) {
|
||||
Log.w(TAG, "track: " + message);
|
||||
|
||||
@@ -31,6 +31,7 @@ public class LoraApp extends Application {
|
||||
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
|
||||
String deviceId = settingsRepository.getOrCreateDeviceId();
|
||||
trackRecorder = new TrackRecorder(
|
||||
this,
|
||||
serverApi,
|
||||
telemetryUploader,
|
||||
deviceId,
|
||||
@@ -103,6 +104,7 @@ public class LoraApp extends Application {
|
||||
}
|
||||
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
|
||||
trackRecorder = new TrackRecorder(
|
||||
this,
|
||||
serverApi,
|
||||
telemetryUploader,
|
||||
settingsRepository.getOrCreateDeviceId(),
|
||||
|
||||
@@ -114,6 +114,28 @@ public class ServerApi {
|
||||
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 {
|
||||
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@ public class LocationTracker {
|
||||
return;
|
||||
}
|
||||
LocationRequest request = new LocationRequest.Builder(
|
||||
Priority.PRIORITY_HIGH_ACCURACY, 10_000L
|
||||
Priority.PRIORITY_HIGH_ACCURACY, 1_000L
|
||||
)
|
||||
.setMinUpdateIntervalMillis(5_000L)
|
||||
.setMaxUpdateDelayMillis(15_000L)
|
||||
.setMinUpdateIntervalMillis(1_000L)
|
||||
.setMaxUpdateDelayMillis(2_000L)
|
||||
.setWaitForAccurateLocation(false)
|
||||
.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;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
import com.grigowashere.loratester.api.ServerApi;
|
||||
import com.grigowashere.loratester.location.GeoUtils;
|
||||
@@ -19,19 +21,26 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class TrackRecorder {
|
||||
|
||||
private static final String TAG = "TrackRecorder";
|
||||
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 {
|
||||
void onStateChanged(boolean recording, int pointCount, long trackId);
|
||||
|
||||
void onError(String message);
|
||||
|
||||
default void onSyncComplete(long trackId, int pointCount) {
|
||||
}
|
||||
|
||||
default void onPointRecorded(double lat, double lon) {
|
||||
}
|
||||
}
|
||||
@@ -39,6 +48,8 @@ public class TrackRecorder {
|
||||
private final ServerApi serverApi;
|
||||
private final TelemetryUploader uploader;
|
||||
private final NetworkMonitor networkMonitor;
|
||||
private final TrackPointQueue pendingQueue;
|
||||
private final Context appContext;
|
||||
private final String deviceId;
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
@@ -53,7 +64,9 @@ public class TrackRecorder {
|
||||
private volatile double lon = Double.NaN;
|
||||
private volatile double altitude = Double.NaN;
|
||||
private volatile long trackId = -1;
|
||||
private volatile double localStartedAt;
|
||||
private volatile boolean recording;
|
||||
private volatile boolean pendingSync;
|
||||
private final List<Map<String, Object>> buffer = new ArrayList<>();
|
||||
private int totalPoints;
|
||||
private ScheduledFuture<?> sampleTask;
|
||||
@@ -62,6 +75,7 @@ public class TrackRecorder {
|
||||
private Listener pairedListener;
|
||||
|
||||
public TrackRecorder(
|
||||
Context context,
|
||||
ServerApi serverApi,
|
||||
TelemetryUploader uploader,
|
||||
String deviceId,
|
||||
@@ -71,11 +85,14 @@ public class TrackRecorder {
|
||||
this.uploader = uploader;
|
||||
this.deviceId = deviceId;
|
||||
this.networkMonitor = networkMonitor;
|
||||
this.appContext = context.getApplicationContext();
|
||||
this.pendingQueue = new TrackPointQueue(this.appContext);
|
||||
networkMonitor.addListener(online -> {
|
||||
if (online && recording) {
|
||||
executor.execute(this::flushBuffer);
|
||||
if (online) {
|
||||
executor.execute(this::syncWhenOnline);
|
||||
}
|
||||
});
|
||||
restoreIfNeeded();
|
||||
}
|
||||
|
||||
public void setListener(Listener listener) {
|
||||
@@ -100,6 +117,10 @@ public class TrackRecorder {
|
||||
return recording;
|
||||
}
|
||||
|
||||
public boolean hasPendingSync() {
|
||||
return pendingSync;
|
||||
}
|
||||
|
||||
public int getPointCount() {
|
||||
return totalPoints;
|
||||
}
|
||||
@@ -108,30 +129,97 @@ public class TrackRecorder {
|
||||
return trackId;
|
||||
}
|
||||
|
||||
public int getPendingFlushCount() {
|
||||
synchronized (buffer) {
|
||||
return buffer.size();
|
||||
}
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (recording) {
|
||||
return;
|
||||
}
|
||||
if (!networkMonitor.isOnline()) {
|
||||
notifyError("Нужна сеть для начала трека");
|
||||
executor.execute(() -> {
|
||||
if (pendingSync) {
|
||||
syncWhenOnline();
|
||||
if (pendingSync) {
|
||||
notifyError(appContext.getString(R.string.track_sync_pending));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!networkMonitor.isOnline()) {
|
||||
startLocalRecording();
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
startOnlineRecording();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "start track on server failed", e);
|
||||
if (isReachabilityError(e)) {
|
||||
startLocalRecording();
|
||||
} else {
|
||||
notifyError(e.getMessage() != null ? e.getMessage() : "start failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Retry upload of a track stopped offline. */
|
||||
public void retryPendingSync() {
|
||||
executor.execute(this::syncWhenOnline);
|
||||
}
|
||||
|
||||
private void startOnlineRecording() throws Exception {
|
||||
long id = serverApi.startTrack(deviceId);
|
||||
synchronized (buffer) {
|
||||
buffer.clear();
|
||||
}
|
||||
totalPoints = 0;
|
||||
trackId = id;
|
||||
localStartedAt = System.currentTimeMillis() / 1000.0;
|
||||
recording = true;
|
||||
pendingSync = false;
|
||||
persistState(false);
|
||||
startTimers();
|
||||
notifyState();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "start track failed", e);
|
||||
notifyError(e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
private static boolean isReachabilityError(Throwable e) {
|
||||
while (e != null) {
|
||||
if (e instanceof java.net.UnknownHostException
|
||||
|| e instanceof java.net.ConnectException
|
||||
|| e instanceof java.net.SocketTimeoutException) {
|
||||
return true;
|
||||
}
|
||||
String msg = e.getMessage();
|
||||
if (msg != null) {
|
||||
String lower = msg.toLowerCase();
|
||||
if (lower.contains("unable to resolve host")
|
||||
|| lower.contains("failed to connect")
|
||||
|| lower.contains("timeout")
|
||||
|| lower.contains("econnrefused")
|
||||
|| lower.contains("network is unreachable")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
e = e.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void startLocalRecording() {
|
||||
synchronized (buffer) {
|
||||
buffer.clear();
|
||||
}
|
||||
totalPoints = 0;
|
||||
trackId = LOCAL_TRACK_ID;
|
||||
localStartedAt = System.currentTimeMillis() / 1000.0;
|
||||
recording = true;
|
||||
pendingSync = false;
|
||||
persistState(false);
|
||||
startTimers();
|
||||
notifyState();
|
||||
notifyError(appContext.getString(R.string.track_offline_started));
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
@@ -141,22 +229,146 @@ public class TrackRecorder {
|
||||
recording = false;
|
||||
stopTimers();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
flushBuffer();
|
||||
if (trackId > 0) {
|
||||
serverApi.finishTrack(trackId);
|
||||
boolean synced = completeTrackUpload(true);
|
||||
if (!synced) {
|
||||
pendingSync = true;
|
||||
persistState(true);
|
||||
notifyError(appContext.getString(R.string.track_sync_pending));
|
||||
} else {
|
||||
resetAfterSuccessfulSync(-1);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "stop track failed", e);
|
||||
notifyError(e.getMessage());
|
||||
} finally {
|
||||
trackId = -1;
|
||||
notifyState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
if (sampleTask != null || flushTask != null) {
|
||||
return;
|
||||
}
|
||||
sampleTask = scheduler.scheduleAtFixedRate(
|
||||
() -> executor.execute(this::samplePoint),
|
||||
SAMPLE_MS,
|
||||
@@ -210,12 +422,58 @@ public class TrackRecorder {
|
||||
}
|
||||
synchronized (buffer) {
|
||||
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++;
|
||||
persistState(false);
|
||||
notifyState();
|
||||
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) {
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) {
|
||||
@@ -227,27 +485,15 @@ public class TrackRecorder {
|
||||
});
|
||||
}
|
||||
|
||||
private void flushBuffer() {
|
||||
if (trackId < 0) {
|
||||
return;
|
||||
private void notifySyncComplete(long trackId, int pointCount) {
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) {
|
||||
listener.onSyncComplete(trackId, pointCount);
|
||||
}
|
||||
List<Map<String, Object>> batch;
|
||||
synchronized (buffer) {
|
||||
if (buffer.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
batch = new ArrayList<>(buffer);
|
||||
buffer.clear();
|
||||
}
|
||||
try {
|
||||
serverApi.addTrackPoints(trackId, batch);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "flush points failed", e);
|
||||
synchronized (buffer) {
|
||||
buffer.addAll(0, batch);
|
||||
}
|
||||
notifyError(e.getMessage());
|
||||
if (pairedListener != null) {
|
||||
pairedListener.onSyncComplete(trackId, pointCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 TextView mapStatus;
|
||||
private TextView mapDistance;
|
||||
private TextView mapRxQuality;
|
||||
private TextView mapTrackStatus;
|
||||
private TextView mapHillStatus;
|
||||
private TextView trackStatus;
|
||||
private ImageView iconServer;
|
||||
@@ -189,6 +191,8 @@ public class MapFragment extends Fragment {
|
||||
mapView = view.findViewById(R.id.mapView);
|
||||
mapStatus = view.findViewById(R.id.mapStatus);
|
||||
mapDistance = view.findViewById(R.id.mapDistance);
|
||||
mapRxQuality = view.findViewById(R.id.mapRxQuality);
|
||||
mapTrackStatus = view.findViewById(R.id.mapTrackStatus);
|
||||
mapHillStatus = view.findViewById(R.id.mapHillStatus);
|
||||
iconServer = view.findViewById(R.id.iconServer);
|
||||
iconLora = view.findViewById(R.id.iconLora);
|
||||
@@ -221,9 +225,29 @@ public class MapFragment extends Fragment {
|
||||
|
||||
networkOnline = networkMonitor != null && networkMonitor.isOnline();
|
||||
networkListener = online -> {
|
||||
boolean wasOnline = networkOnline;
|
||||
networkOnline = online;
|
||||
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) {
|
||||
@@ -465,6 +489,8 @@ public class MapFragment extends Fragment {
|
||||
heatmapActive = false;
|
||||
mapStatus = null;
|
||||
mapDistance = null;
|
||||
mapRxQuality = null;
|
||||
mapTrackStatus = null;
|
||||
trackStatus = null;
|
||||
btnTrack = null;
|
||||
btnPairedTrack = null;
|
||||
@@ -507,9 +533,7 @@ public class MapFragment extends Fragment {
|
||||
btnTrack.setActivated(recording);
|
||||
btnTrack.setContentDescription(getString(
|
||||
recording ? R.string.track_stop : R.string.track_start));
|
||||
if (trackStatus != null) {
|
||||
trackStatus.setText(getString(R.string.track_status, pointCount));
|
||||
}
|
||||
updateTrackStatusUi();
|
||||
if (recording && pointCount <= 1) {
|
||||
clearLiveTrackLayers();
|
||||
}
|
||||
@@ -523,9 +547,26 @@ public class MapFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
if (isAdded() && trackStatus != null) {
|
||||
if (!isAdded()) {
|
||||
return;
|
||||
}
|
||||
if (trackStatus != null) {
|
||||
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
|
||||
@@ -601,10 +642,49 @@ public class MapFragment extends Fragment {
|
||||
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() {
|
||||
if (trackRecorder.isRecording()) {
|
||||
trackRecorder.stop();
|
||||
} else {
|
||||
if (trackRecorder.hasPendingSync()) {
|
||||
trackRecorder.retryPendingSync();
|
||||
Toast.makeText(requireContext(), R.string.track_sync_pending, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
trackRecorder.start();
|
||||
}
|
||||
}
|
||||
@@ -1414,6 +1494,7 @@ public class MapFragment extends Fragment {
|
||||
));
|
||||
}
|
||||
updateGpsDistance();
|
||||
updateRxQuality();
|
||||
updateConnectionIcons(lastDevices, serverConnected);
|
||||
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() {
|
||||
if (mapDistance == null) {
|
||||
return;
|
||||
|
||||
@@ -159,24 +159,48 @@ public class RadioComparePanel extends LinearLayout {
|
||||
String peerId,
|
||||
Set<String> changedLocal,
|
||||
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 rx = peer;
|
||||
String txId = localId;
|
||||
String rxId = peerId;
|
||||
String txName = localDisplayName != null ? localDisplayName : localId;
|
||||
String rxName = peerDisplayName != null ? peerDisplayName : peerId;
|
||||
Set<String> chTx = changedLocal;
|
||||
Set<String> chRx = changedPeer;
|
||||
if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) {
|
||||
tx = peer;
|
||||
rx = local;
|
||||
txId = peerId;
|
||||
rxId = localId;
|
||||
txName = peerDisplayName != null ? peerDisplayName : peerId;
|
||||
rxName = localDisplayName != null ? localDisplayName : localId;
|
||||
chTx = changedPeer;
|
||||
chRx = changedLocal;
|
||||
}
|
||||
if (tx == null) tx = 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) {
|
||||
|
||||
@@ -23,11 +23,13 @@ import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
import com.grigowashere.loratester.api.DeviceInfo;
|
||||
import com.grigowashere.loratester.api.TelemetryHistoryItem;
|
||||
import com.grigowashere.loratester.location.GeoUtils;
|
||||
import com.grigowashere.loratester.model.RadioSnapshot;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -43,6 +45,7 @@ public class StatsFragment extends Fragment {
|
||||
private PeerStatsCache peerStatsCache;
|
||||
private TextView statsStatus;
|
||||
private TextView statsPeerWarning;
|
||||
private TextView statsDistance;
|
||||
private RadioComparePanel radioComparePanel;
|
||||
private RecyclerView statsHistoryList;
|
||||
private final HistoryAdapter historyAdapter = new HistoryAdapter();
|
||||
@@ -54,6 +57,10 @@ public class StatsFragment extends Fragment {
|
||||
private String cachedPeerId;
|
||||
private String cachedPeerError;
|
||||
private int cachedDeviceCount;
|
||||
private String cachedSelfDisplayName;
|
||||
private String cachedPeerDisplayName;
|
||||
private DeviceInfo cachedTxDev;
|
||||
private DeviceInfo cachedRxDev;
|
||||
|
||||
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) {
|
||||
statsStatus = view.findViewById(R.id.statsStatus);
|
||||
statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
|
||||
statsDistance = view.findViewById(R.id.statsDistance);
|
||||
radioComparePanel = view.findViewById(R.id.radioComparePanel);
|
||||
statsHistoryList = view.findViewById(R.id.statsHistoryList);
|
||||
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
@@ -157,6 +165,7 @@ public class StatsFragment extends Fragment {
|
||||
}
|
||||
statsStatus = null;
|
||||
statsPeerWarning = null;
|
||||
statsDistance = null;
|
||||
radioComparePanel = null;
|
||||
statsHistoryList = null;
|
||||
pollHelper = null;
|
||||
@@ -183,13 +192,17 @@ public class StatsFragment extends Fragment {
|
||||
String deviceId = uploader.getDeviceId();
|
||||
statsStatus.setText(getString(
|
||||
R.string.stats_status,
|
||||
deviceId,
|
||||
cachedSelfDisplayName != null ? cachedSelfDisplayName : deviceId,
|
||||
uploader.isTelnetConnected()
|
||||
? getString(R.string.connected) : getString(R.string.disconnected)
|
||||
));
|
||||
|
||||
executor.execute(() -> {
|
||||
List<TelemetryHistoryItem> history = null;
|
||||
DeviceInfo txDev = null;
|
||||
DeviceInfo rxDev = null;
|
||||
String selfName = deviceId;
|
||||
String peerName = cachedPeerId;
|
||||
try {
|
||||
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||
cachedDeviceCount = devices.size();
|
||||
@@ -200,12 +213,23 @@ public class StatsFragment extends Fragment {
|
||||
DeviceInfo self = null;
|
||||
DeviceInfo peerDev = null;
|
||||
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)) {
|
||||
self = d;
|
||||
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
|
||||
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();
|
||||
snapLocal = localStats != null
|
||||
@@ -264,10 +288,32 @@ public class StatsFragment extends Fragment {
|
||||
snapPeer,
|
||||
uploader.getDeviceId(),
|
||||
cachedPeerId,
|
||||
cachedSelfDisplayName,
|
||||
cachedPeerDisplayName,
|
||||
chLocal,
|
||||
chPeer
|
||||
);
|
||||
updateStatsDistance();
|
||||
prevLocal = snapLocal;
|
||||
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:textSize="9sp"
|
||||
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
|
||||
|
||||
@@ -24,6 +24,15 @@
|
||||
android:textSize="12sp"
|
||||
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
|
||||
android:id="@+id/radioComparePanel"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -37,6 +37,18 @@
|
||||
<string name="map_network_online">онлайн</string>
|
||||
<string name="map_network_offline">офлайн (кэш)</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="gps_waiting">GPS: ожидание фикса…</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_DB` | `./loratester.db` |
|
||||
| `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_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
|
||||
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
|
||||
@@ -40,14 +40,17 @@ docker compose up -d --build
|
||||
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`):
|
||||
|
||||
```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` внутри контейнера).
|
||||
|
||||
## Деплой (lora.grigowashere.ru)
|
||||
@@ -63,7 +66,8 @@ docker compose up -d --build
|
||||
cd /srv/storage/disk2/services/LoraTester/server
|
||||
pip install -r requirements.txt
|
||||
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
|
||||
```
|
||||
|
||||
@@ -95,11 +99,12 @@ curl http://127.0.0.1:7634/api/health
|
||||
|
||||
### Треки (запись с Android)
|
||||
|
||||
- `POST /api/tracks/sync` — `{device_id, track_id?, started_at?, points[], finish?}` — офлайн-догрузка точек и завершение трека
|
||||
- `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}/finish`
|
||||
- `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/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/commands/pending?device_id=` — Android, доставка + `delivered_at`
|
||||
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
-2
@@ -8,11 +8,20 @@ DATABASE_PATH = os.environ.get(
|
||||
HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
|
||||
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
|
||||
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
|
||||
ELEVATION_API_URL = os.environ.get(
|
||||
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "500000"))
|
||||
ELEVATION_OPENTOPO_URL = os.environ.get(
|
||||
"LORATESTER_ELEVATION_OPENTOPO_URL",
|
||||
"http://grigowashere.ru:5300/v1/srtm30",
|
||||
).rstrip("/")
|
||||
ELEVATION_FALLBACK_URL = os.environ.get(
|
||||
"LORATESTER_ELEVATION_FALLBACK_URL",
|
||||
os.environ.get(
|
||||
"LORATESTER_ELEVATION_URL",
|
||||
"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(
|
||||
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
|
||||
|
||||
@@ -10,8 +10,9 @@ from typing import Any, Optional
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
ELEVATION_API_URL,
|
||||
ELEVATION_CONNECT_TIMEOUT,
|
||||
ELEVATION_FALLBACK_URL,
|
||||
ELEVATION_OPENTOPO_URL,
|
||||
ELEVATION_PROBE_TTL_SEC,
|
||||
)
|
||||
|
||||
@@ -21,8 +22,11 @@ _BATCH_SIZE = 100
|
||||
_MAX_PROFILE_POINTS = 500
|
||||
_CACHE: dict[tuple[float, float], Optional[float]] = {}
|
||||
_probe_checked_at = 0.0
|
||||
_probe_ok = False
|
||||
_probe_error: Optional[str] = None
|
||||
_probe_opentopo_ok = False
|
||||
_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]:
|
||||
@@ -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))
|
||||
|
||||
|
||||
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
||||
"""Ping elevation service before batch requests (cached for TTL)."""
|
||||
global _probe_checked_at, _probe_ok, _probe_error
|
||||
def _probe_opentopodata(force: bool = False) -> dict[str, Any]:
|
||||
global _probe_checked_at, _probe_opentopo_ok, _probe_opentopo_error
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
@@ -53,35 +56,129 @@ def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
||||
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
||||
):
|
||||
return {
|
||||
"ok": _probe_ok,
|
||||
"url": ELEVATION_API_URL,
|
||||
"error": _probe_error,
|
||||
"ok": _probe_opentopo_ok,
|
||||
"url": ELEVATION_OPENTOPO_URL,
|
||||
"error": _probe_opentopo_error,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
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"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if "elevation" not in data:
|
||||
raise ValueError("response has no elevation field")
|
||||
_probe_checked_at = now
|
||||
_probe_ok = True
|
||||
_probe_error = None
|
||||
logger.info("elevation API ok: %s", ELEVATION_API_URL)
|
||||
_probe_fallback_ok = True
|
||||
_probe_fallback_error = None
|
||||
logger.info("elevation fallback ok: %s", ELEVATION_FALLBACK_URL)
|
||||
except Exception as e:
|
||||
_probe_checked_at = now
|
||||
_probe_ok = False
|
||||
_probe_error = str(e)
|
||||
logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e)
|
||||
_probe_fallback_ok = False
|
||||
_probe_fallback_error = str(e)
|
||||
logger.warning(
|
||||
"elevation fallback unreachable %s: %s", ELEVATION_FALLBACK_URL, e
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": _probe_ok,
|
||||
"url": ELEVATION_API_URL,
|
||||
"error": _probe_error,
|
||||
"ok": _probe_fallback_ok,
|
||||
"url": ELEVATION_FALLBACK_URL,
|
||||
"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_url": probe["url"],
|
||||
"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]
|
||||
) -> list[Optional[float]]:
|
||||
if not batch_lat:
|
||||
@@ -104,7 +231,7 @@ def _fetch_elevation_batch(
|
||||
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
|
||||
}
|
||||
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()
|
||||
data = r.json()
|
||||
elevations = data.get("elevation") or []
|
||||
@@ -112,15 +239,81 @@ def _fetch_elevation_batch(
|
||||
for j, elev in enumerate(elevations):
|
||||
if j >= len(batch_lat):
|
||||
break
|
||||
if elev is None:
|
||||
out.append(None)
|
||||
else:
|
||||
out.append(float(elev))
|
||||
out.append(None if elev is None else float(elev))
|
||||
while len(out) < len(batch_lat):
|
||||
out.append(None)
|
||||
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]:
|
||||
vals = fetch_elevations_batch([lat], [lon])
|
||||
return vals[0] if vals else None
|
||||
@@ -135,7 +328,7 @@ def fetch_elevations_batch(
|
||||
probe = probe_elevation_api()
|
||||
if not probe["ok"]:
|
||||
logger.warning(
|
||||
"skip elevation fetch: API unreachable (%s)",
|
||||
"skip elevation fetch: all providers unreachable (%s)",
|
||||
probe.get("error"),
|
||||
)
|
||||
return [None] * len(lats)
|
||||
@@ -159,14 +352,15 @@ def fetch_elevations_batch(
|
||||
batch_lat = pending_lat[start : start + _BATCH_SIZE]
|
||||
batch_lon = pending_lon[start : start + _BATCH_SIZE]
|
||||
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):
|
||||
lat = batch_lat[j]
|
||||
lon = batch_lon[j]
|
||||
_CACHE[_cache_key(lat, lon)] = val
|
||||
out[batch_i[j]] = val
|
||||
logger.info(
|
||||
"elevation ok: %s points, sample=%s",
|
||||
"elevation ok (%s): %s points, sample=%s",
|
||||
_last_fetch_source,
|
||||
len(batch_lat),
|
||||
batch_vals[0] if batch_vals else None,
|
||||
)
|
||||
@@ -178,7 +372,7 @@ def fetch_elevations_batch(
|
||||
)
|
||||
for j in range(len(batch_lat)):
|
||||
try:
|
||||
single = _fetch_elevation_batch(
|
||||
single = _fetch_batch_with_fallback(
|
||||
[batch_lat[j]], [batch_lon[j]]
|
||||
)
|
||||
val = single[0] if single else None
|
||||
@@ -329,7 +523,7 @@ def build_elevation_profile(
|
||||
"total_m": 0.0,
|
||||
"api_source": "elevation",
|
||||
"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]
|
||||
@@ -356,8 +550,8 @@ def build_elevation_profile(
|
||||
"min_elevation_m": min(elev_vals) if elev_vals else None,
|
||||
"max_elevation_m": max(elev_vals) if elev_vals else None,
|
||||
"points": profile,
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"api_source": _active_api_source(),
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
if not elev_vals:
|
||||
result["api_error"] = "elevation API returned no values"
|
||||
@@ -429,7 +623,7 @@ def build_elevation_grid(
|
||||
return {
|
||||
"ok": False,
|
||||
"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))
|
||||
@@ -481,8 +675,8 @@ def build_elevation_grid(
|
||||
"points": points,
|
||||
"min_delta_m": round(min(deltas), 1),
|
||||
"max_delta_m": round(max(deltas), 1),
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"api_source": _active_api_source(),
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
|
||||
@@ -499,7 +693,7 @@ def find_nearest_hill(
|
||||
return {
|
||||
"ok": False,
|
||||
"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))
|
||||
@@ -590,6 +784,6 @@ def find_nearest_hill(
|
||||
"candidates": len(candidates),
|
||||
"radius_m": radius_m,
|
||||
"step_m": step_m,
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"api_source": _active_api_source(),
|
||||
"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}
|
||||
|
||||
|
||||
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]]:
|
||||
limit = min(max(1, limit), 200)
|
||||
with _db() as conn:
|
||||
|
||||
@@ -10,11 +10,13 @@ services:
|
||||
environment:
|
||||
LORATESTER_DB: /data/loratester.db
|
||||
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_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
|
||||
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
|
||||
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:
|
||||
loratester-data:
|
||||
|
||||
+30
-1
@@ -72,6 +72,15 @@ class TrackPointsBody(BaseModel):
|
||||
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):
|
||||
from_device_id: str
|
||||
to_device_id: str
|
||||
@@ -193,6 +202,26 @@ def tracks_finish(
|
||||
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")
|
||||
def tracks_list(
|
||||
device_id: Optional[str] = None,
|
||||
@@ -379,7 +408,7 @@ def health():
|
||||
return {
|
||||
"ok": status["db_ok"],
|
||||
"ts": time.time(),
|
||||
"api_build": "2026-06-16g",
|
||||
"api_build": "2026-06-19a",
|
||||
**status,
|
||||
**elevation_status(),
|
||||
}
|
||||
|
||||
+200
-11
@@ -45,7 +45,17 @@
|
||||
}
|
||||
#elevationStatus { font-size: 0.7rem; color: #aaa; font-weight: 400; }
|
||||
#elevationCanvas { width: 100%; height: 130px; display: block; background: #0a0a14; border-radius: 4px; }
|
||||
#elevationCanvas.elev-probe { cursor: crosshair; }
|
||||
.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 {
|
||||
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; }
|
||||
.muted { color: #aaa; font-size: 0.75rem; }
|
||||
.legend { font-size: 0.75rem; color: #ccc; }
|
||||
.legend-tx { color: #e94560; }
|
||||
.legend-rx { color: #4fc3f7; }
|
||||
.legend-tx { color: #4fc3f7; }
|
||||
.legend-rx { color: #e94560; }
|
||||
#mapModal {
|
||||
display: none; position: fixed; z-index: 2000;
|
||||
min-width: 260px; max-width: 360px; max-height: 70vh; overflow: auto;
|
||||
@@ -320,6 +330,12 @@
|
||||
</div>
|
||||
<canvas id="elevationCanvas" width="800" height="130"></canvas>
|
||||
</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>
|
||||
</main>
|
||||
@@ -332,6 +348,7 @@
|
||||
</div>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/static/radio-ui.js"></script>
|
||||
<script src="/static/quality-viz.js"></script>
|
||||
<script>
|
||||
if (typeof RadioUI === 'undefined') {
|
||||
console.error('radio-ui.js not loaded — check /static/radio-ui.js');
|
||||
@@ -361,7 +378,7 @@
|
||||
{ position: 'topright', collapsed: true }
|
||||
).addTo(map);
|
||||
|
||||
const API_BUILD = '2026-06-16g';
|
||||
const API_BUILD = '2026-06-16i';
|
||||
|
||||
const markers = {};
|
||||
let selectedId = null;
|
||||
@@ -430,14 +447,22 @@
|
||||
let mapRulerPointsAuto = true;
|
||||
let mapRulerManualPoints = 100;
|
||||
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 CHAT_POLL_MS = 2500;
|
||||
const TRACKS_POLL_MS = 10000;
|
||||
const TELEMETRY_POLL_MS = 2000;
|
||||
|
||||
const TX_COLOR = '#e94560';
|
||||
const RX_COLOR = '#4fc3f7';
|
||||
const TX_COLOR = '#4fc3f7';
|
||||
const RX_COLOR = '#e94560';
|
||||
|
||||
map.on('zoomend moveend', () => {
|
||||
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() {
|
||||
clearTimeout(mapRulerLeaveTimer);
|
||||
mapRulerLeaveTimer = setTimeout(() => {
|
||||
@@ -1533,6 +1666,7 @@
|
||||
profile: elevProfileLink,
|
||||
label: 'рельеф TX↔RX',
|
||||
losLine: elevA != null && elevB != null ? { elevA, elevB } : null,
|
||||
cursor: timelineElevHoverDist,
|
||||
});
|
||||
return series;
|
||||
}
|
||||
@@ -1785,8 +1919,11 @@
|
||||
if (n > 0) {
|
||||
const src = profile.source === 'elevation' ? 'высоты'
|
||||
: 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() {
|
||||
@@ -1821,7 +1958,14 @@
|
||||
: ref?.source === 'server' ? 'сервер' : (ref?.source || 'данные');
|
||||
if (dualTracksActive && 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) {
|
||||
const nTx = elevationPointCount(elevProfileTx);
|
||||
const nRx = elevationPointCount(elevProfileRx);
|
||||
@@ -1835,6 +1979,7 @@
|
||||
setElevationStatus(err ? `ошибка: ${err}` : 'нет данных');
|
||||
}
|
||||
drawElevationChart();
|
||||
updateElevationProbeClass();
|
||||
requestAnimationFrame(() => drawElevationChart(
|
||||
singleTrackActive
|
||||
? { single: trackDistanceAtCursor(loadedSingleTrack, timelineCursor()) }
|
||||
@@ -2132,6 +2277,14 @@
|
||||
elevProfileSingle = null;
|
||||
elevProfileLink = null;
|
||||
elevProfileLinkKey = null;
|
||||
timelineElevHoverDist = null;
|
||||
timelineLinkTxPos = null;
|
||||
timelineLinkRxPos = null;
|
||||
timelineElevBaseStatus = '';
|
||||
clearTimelineElevCursorMarker();
|
||||
qualitySamplesCache = [];
|
||||
document.getElementById('qualityVizPanel')?.classList.remove('visible');
|
||||
updateElevationProbeClass();
|
||||
drawElevationChart();
|
||||
if (playTimer) {
|
||||
clearInterval(playTimer);
|
||||
@@ -2237,12 +2390,14 @@
|
||||
|
||||
if (txPos) {
|
||||
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);
|
||||
}
|
||||
if (rxPos) {
|
||||
const q = rxQualityFromMeta(rxPos.meta);
|
||||
const rxGhostColor = q != null ? (qualityColor(q) || RX_COLOR) : RX_COLOR;
|
||||
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);
|
||||
}
|
||||
if (txPos && rxPos) {
|
||||
@@ -2268,11 +2423,18 @@
|
||||
deviceDisplayName(loadedTxTrack?.device_id),
|
||||
deviceDisplayName(loadedRxTrack?.device_id)
|
||||
));
|
||||
timelineLinkTxPos = txPos || null;
|
||||
timelineLinkRxPos = rxPos || null;
|
||||
|
||||
if (txPos && rxPos) {
|
||||
scheduleLinkElevation(txPos, rxPos).then(() => drawElevationChart());
|
||||
scheduleLinkElevation(txPos, rxPos).then(() => {
|
||||
drawElevationChart();
|
||||
updateElevationProbeClass();
|
||||
});
|
||||
} else {
|
||||
drawElevationChart();
|
||||
}
|
||||
redrawQualityDistHighlight();
|
||||
}
|
||||
|
||||
function updateTimelineAtSingle(cursor, openModal) {
|
||||
@@ -2316,9 +2478,14 @@
|
||||
function setTimelineVisible(visible) {
|
||||
document.getElementById('trackTimeline').classList.toggle('visible', visible);
|
||||
document.getElementById('timelineStatsPanel').classList.toggle('visible', visible);
|
||||
document.getElementById('qualityVizPanel')?.classList.toggle(
|
||||
'visible', visible && dualTracksActive);
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
if (visible) drawElevationChart();
|
||||
if (visible) {
|
||||
drawElevationChart();
|
||||
if (dualTracksActive) rebuildQualityViz();
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
@@ -2419,6 +2586,7 @@
|
||||
setTimelineVisible(true);
|
||||
updateTimelineAt(timelineCursor());
|
||||
loadElevationProfiles();
|
||||
rebuildQualityViz();
|
||||
}
|
||||
|
||||
async function refreshTimelineTelemetry() {
|
||||
@@ -2595,6 +2763,7 @@
|
||||
updateTimelineAt(timelineCursor());
|
||||
}
|
||||
|
||||
rebuildQualityViz();
|
||||
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
|
||||
document.getElementById('trackInfo').textContent =
|
||||
`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() {
|
||||
const canvas = document.getElementById('mapRulerCanvas');
|
||||
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
|
||||
|
||||
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]})
|
||||
|
||||
|
||||
@@ -34,13 +44,20 @@ def test_probe_elevation_api_ok(monkeypatch):
|
||||
|
||||
assert status["ok"] is True
|
||||
assert status["error"] is None
|
||||
assert status["opentopodata_ok"] is True
|
||||
|
||||
|
||||
def test_fetch_skips_when_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"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])
|
||||
@@ -52,7 +69,13 @@ def test_build_profile_reports_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"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(
|
||||
@@ -76,7 +99,16 @@ def test_resample_track_path_count_even_spacing():
|
||||
|
||||
def test_build_profile_target_points(monkeypatch):
|
||||
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_elevations_batch",
|
||||
@@ -96,7 +128,13 @@ def test_find_nearest_hill_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
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)
|
||||
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):
|
||||
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):
|
||||
out = []
|
||||
@@ -125,7 +172,16 @@ def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
|
||||
|
||||
def test_build_elevation_grid_delta(monkeypatch):
|
||||
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):
|
||||
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):
|
||||
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,
|
||||
@@ -159,7 +224,16 @@ def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
|
||||
|
||||
def test_build_elevation_grid_limits_points(monkeypatch):
|
||||
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,
|
||||
@@ -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)
|
||||
cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step)
|
||||
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()
|
||||
|
||||
|
||||
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):
|
||||
storage.init_db()
|
||||
start = storage.start_track("android-12345678")
|
||||
|
||||
Reference in New Issue
Block a user