generated from Grigo/AndroidTemplate
offline track
This commit is contained in:
@@ -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,15 +129,21 @@ public class TrackRecorder {
|
||||
return trackId;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (recording) {
|
||||
return;
|
||||
public int getPendingFlushCount() {
|
||||
synchronized (buffer) {
|
||||
return buffer.size();
|
||||
}
|
||||
if (!networkMonitor.isOnline()) {
|
||||
notifyError("Нужна сеть для начала трека");
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (recording || pendingSync) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
if (!networkMonitor.isOnline()) {
|
||||
startLocalRecording();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long id = serverApi.startTrack(deviceId);
|
||||
synchronized (buffer) {
|
||||
@@ -124,7 +151,10 @@ public class TrackRecorder {
|
||||
}
|
||||
totalPoints = 0;
|
||||
trackId = id;
|
||||
localStartedAt = System.currentTimeMillis() / 1000.0;
|
||||
recording = true;
|
||||
pendingSync = false;
|
||||
persistState(false);
|
||||
startTimers();
|
||||
notifyState();
|
||||
} catch (Exception e) {
|
||||
@@ -134,6 +164,21 @@ public class TrackRecorder {
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!recording) {
|
||||
return;
|
||||
@@ -141,22 +186,145 @@ public class TrackRecorder {
|
||||
recording = false;
|
||||
stopTimers();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
flushBuffer();
|
||||
if (trackId > 0) {
|
||||
serverApi.finishTrack(trackId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "stop track failed", e);
|
||||
notifyError(e.getMessage());
|
||||
} finally {
|
||||
trackId = -1;
|
||||
notifyState();
|
||||
boolean synced = completeTrackUpload(true);
|
||||
if (!synced) {
|
||||
pendingSync = true;
|
||||
persistState(true);
|
||||
notifyError(appContext.getString(R.string.track_sync_pending));
|
||||
} else {
|
||||
resetAfterSuccessfulSync(-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);
|
||||
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 +378,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 +441,15 @@ public class TrackRecorder {
|
||||
});
|
||||
}
|
||||
|
||||
private void flushBuffer() {
|
||||
if (trackId < 0) {
|
||||
return;
|
||||
}
|
||||
List<Map<String, Object>> batch;
|
||||
synchronized (buffer) {
|
||||
if (buffer.isEmpty()) {
|
||||
return;
|
||||
private void notifySyncComplete(long trackId, int pointCount) {
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) {
|
||||
listener.onSyncComplete(trackId, pointCount);
|
||||
}
|
||||
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);
|
||||
if (pairedListener != null) {
|
||||
pairedListener.onSyncComplete(trackId, pointCount);
|
||||
}
|
||||
notifyError(e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,48 @@ 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()) {
|
||||
Toast.makeText(requireContext(), R.string.track_sync_pending, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
trackRecorder.start();
|
||||
}
|
||||
}
|
||||
@@ -1414,6 +1493,7 @@ public class MapFragment extends Fragment {
|
||||
));
|
||||
}
|
||||
updateGpsDistance();
|
||||
updateRxQuality();
|
||||
updateConnectionIcons(lastDevices, serverConnected);
|
||||
checkHeatmapGpsFollow();
|
||||
|
||||
@@ -1423,6 +1503,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());
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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 к сервису высот) |
|
||||
@@ -99,6 +99,7 @@ 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`
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -8,7 +8,7 @@ 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"))
|
||||
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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -16,7 +16,7 @@ services:
|
||||
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-16i",
|
||||
"api_build": "2026-06-19a",
|
||||
**status,
|
||||
**elevation_status(),
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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