diff --git a/app/src/main/java/com/grigowashere/loratester/LoraApp.java b/app/src/main/java/com/grigowashere/loratester/LoraApp.java index 9447149..a9d4a37 100644 --- a/app/src/main/java/com/grigowashere/loratester/LoraApp.java +++ b/app/src/main/java/com/grigowashere/loratester/LoraApp.java @@ -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(), diff --git a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java index 641c987..85bd9fd 100644 --- a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java +++ b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java @@ -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> points, + boolean finish + ) throws IOException { + Map 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 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 listTracks(String deviceId) throws IOException { return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST); } diff --git a/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java b/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java index 62bab5f..8853a7b 100644 --- a/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java +++ b/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java @@ -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(); diff --git a/app/src/main/java/com/grigowashere/loratester/track/TrackPointQueue.java b/app/src/main/java/com/grigowashere/loratester/track/TrackPointQueue.java new file mode 100644 index 0000000..53c348d --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/track/TrackPointQueue.java @@ -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() {}.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 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 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 toMap() { + Map 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> 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 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); + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java b/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java index 218fe84..37b8446 100644 --- a/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java +++ b/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java @@ -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> 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> 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> 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> 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> 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() { diff --git a/app/src/main/java/com/grigowashere/loratester/ui/DeviceNames.java b/app/src/main/java/com/grigowashere/loratester/ui/DeviceNames.java new file mode 100644 index 0000000..d902e92 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/DeviceNames.java @@ -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 "—"; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java index a5d1f3e..cd80ba9 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -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; diff --git a/app/src/main/java/com/grigowashere/loratester/ui/RadioComparePanel.java b/app/src/main/java/com/grigowashere/loratester/ui/RadioComparePanel.java index e0dc851..9064f4d 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/RadioComparePanel.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/RadioComparePanel.java @@ -159,24 +159,48 @@ public class RadioComparePanel extends LinearLayout { String peerId, Set changedLocal, Set 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 changedLocal, + Set 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 chTx = changedLocal; Set 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) { diff --git a/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java index e1827ec..20ce9a2 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java @@ -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 history = null; + DeviceInfo txDev = null; + DeviceInfo rxDev = null; + String selfName = deviceId; + String peerName = cachedPeerId; try { List 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); + } + } } diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index 9cac01f..10848a6 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -80,6 +80,24 @@ android:textColor="#00FF88" android:textSize="9sp" android:visibility="gone" /> + + + + + + онлайн офлайн (кэш) Нужна сеть для начала трека + Запись без сети — точки сохраняются на телефоне + Трек сохранён локально — отправится при появлении сети + Трек #%1$d загружен (%2$d точек) + Трекинг: %1$d точек + Трекинг: %1$d точек · буфер %2$d · офлайн + Сеть пропала — точки сохраняются на устройстве (буфер %1$d). Без ограничения по времени, до ~500 тыс. точек. + Сеть восстановлена — отправка буфера (%1$d точек) + Трек: %1$d точек + Трек: %1$d точек · буфер %2$d + RX Quality: %1$s%% + RX Quality: — + Расстояние TX↔RX: %1$s m В очереди: %1$d GPS: ожидание фикса… обновлено %1$s diff --git a/app/src/test/java/com/grigowashere/loratester/DeviceNamesTest.java b/app/src/test/java/com/grigowashere/loratester/DeviceNamesTest.java new file mode 100644 index 0000000..3cb4bab --- /dev/null +++ b/app/src/test/java/com/grigowashere/loratester/DeviceNamesTest.java @@ -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)); + } +} diff --git a/app/src/test/java/com/grigowashere/loratester/TrackPointQueueTest.java b/app/src/test/java/com/grigowashere/loratester/TrackPointQueueTest.java new file mode 100644 index 0000000..f420fb6 --- /dev/null +++ b/app/src/test/java/com/grigowashere/loratester/TrackPointQueueTest.java @@ -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 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 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()); + } +} diff --git a/server/README.md b/server/README.md index 945401e..05df859 100644 --- a/server/README.md +++ b/server/README.md @@ -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` diff --git a/server/core/__pycache__/config.cpython-313.pyc b/server/core/__pycache__/config.cpython-313.pyc index e8ebb66..0cac883 100644 Binary files a/server/core/__pycache__/config.cpython-313.pyc and b/server/core/__pycache__/config.cpython-313.pyc differ diff --git a/server/core/__pycache__/storage.cpython-313.pyc b/server/core/__pycache__/storage.cpython-313.pyc index ee3bfb7..27f31fb 100644 Binary files a/server/core/__pycache__/storage.cpython-313.pyc and b/server/core/__pycache__/storage.cpython-313.pyc differ diff --git a/server/core/config.py b/server/core/config.py index c2307cc..3b62e57 100644 --- a/server/core/config.py +++ b/server/core/config.py @@ -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", diff --git a/server/core/storage.py b/server/core/storage.py index 0c0645d..357b0b7 100644 --- a/server/core/storage.py +++ b/server/core/storage.py @@ -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: diff --git a/server/docker-compose.yml b/server/docker-compose.yml index 6447493..1c440fe 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -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: diff --git a/server/fastapi_app.py b/server/fastapi_app.py index eeb7a24..18bcb6c 100644 --- a/server/fastapi_app.py +++ b/server/fastapi_app.py @@ -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(), } diff --git a/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc b/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc index aea25cc..16ae4bf 100644 Binary files a/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc and b/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc differ diff --git a/server/tests/test_schema.py b/server/tests/test_schema.py index 5c8a9a9..dd4bc4a 100644 --- a/server/tests/test_schema.py +++ b/server/tests/test_schema.py @@ -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")