From 8812cf9b40be25e3495643c628bfab6425bb6fbb Mon Sep 17 00:00:00 2001 From: grigo Date: Fri, 19 Jun 2026 11:09:20 +0300 Subject: [PATCH] offline track --- .../com/grigowashere/loratester/LoraApp.java | 2 + .../loratester/api/ServerApi.java | 22 ++ .../loratester/location/LocationTracker.java | 6 +- .../loratester/track/TrackPointQueue.java | 161 ++++++++++ .../loratester/track/TrackRecorder.java | 280 +++++++++++++++--- .../loratester/ui/DeviceNames.java | 25 ++ .../loratester/ui/MapFragment.java | 137 ++++++++- .../loratester/ui/RadioComparePanel.java | 34 ++- .../loratester/ui/StatsFragment.java | 48 ++- app/src/main/res/layout/fragment_map.xml | 18 ++ app/src/main/res/layout/fragment_stats.xml | 9 + app/src/main/res/values/strings.xml | 12 + .../loratester/DeviceNamesTest.java | 27 ++ .../loratester/TrackPointQueueTest.java | 45 +++ server/README.md | 3 +- .../core/__pycache__/config.cpython-313.pyc | Bin 1994 -> 1995 bytes .../core/__pycache__/storage.cpython-313.pyc | Bin 32798 -> 35439 bytes server/core/config.py | 2 +- server/core/storage.py | 73 +++++ server/docker-compose.yml | 2 +- server/fastapi_app.py | 31 +- .../test_schema.cpython-313-pytest-9.0.3.pyc | Bin 6862 -> 11815 bytes server/tests/test_schema.py | 44 +++ 23 files changed, 924 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/com/grigowashere/loratester/track/TrackPointQueue.java create mode 100644 app/src/main/java/com/grigowashere/loratester/ui/DeviceNames.java create mode 100644 app/src/test/java/com/grigowashere/loratester/DeviceNamesTest.java create mode 100644 app/src/test/java/com/grigowashere/loratester/TrackPointQueueTest.java 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 e8ebb665a40c216ab03b5cb87cd68370c5274792..0cac88364941889ebcfa1f2fd118016c4d6c28b1 100644 GIT binary patch delta 53 zcmX@bf1027GcPX}0}yzj9AS4+j7M delta 52 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 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 aea25cc5cf7f0723e515e2c3ac0c1fb2b8b42e98..16ae4bf83d86d597ee97fc01fb710728c6135b38 100644 GIT binary patch delta 3906 zcmc&$U2Gf25xygrzekD`Nr{xKAJVpdC`Gd5h(@GZG8Nm6Q_FU(6K$n(%FyCTrb8W- zy^~w1EvN(m+C0_HHK>8KfPf&V^%5Y_i=P6cD2l$c5hVo_4le4V2GX=Bs>CST`l&N} zB=r+J2n+;096!#^&d$xv&ds-fi2dqP)dRQN$-#B@KYdr<6mC?7_**yHe(4n*^&Aa~ zPQcJK9~IvJ-P;!1GOwF5%CdIVdX=wV`Pe$n^Rbm~`)Qj{4<2X)VZZjeXGr)4iiT4A zy(f0;0FLbl9SEHWEeKB`v>~)3L=kKNQ5*H6Jb=)n4SPHJr?naH3I62D2VRM9JOcVj z6F~WLfMAB-gC-lN4e_0a!(4(NwUno~|0gP<$cv_WE{3b%3Uk`eec_O3j`4;c}QyiE3 zo)+|XIjR%3K3?>VU(n9@JGGzHbVRJ>hl&1fQ#Jm*=yjvw&dN7-bc#5gUom6$$alC`>y*sBDXN_nh=O+FA~QyvNPcn>)C^ZiJ*~d zj!OvTRLE9H+;0;umbgD+S>Tl}Y7zf|n(BlbQn+tC+h>XqV@7Py-VMCNH)yZ=Bie^G z4(*~pVur+O=lyLFSJ|uB*iBeUtHdU;IZ;(gu!kksg9&aF26@>ENxlp5g-eOxSd#hD z21Zzt_di*Z^DGV1T=c+My?RPgC$A)>taL4+y|1!DO>z*l^WgAdSjcG`H` z7vjs@vPm~*Qz}4CR`4SThp7+#bozEv3-cV z$q-O7rAGOO){GLCb096$mSZwB<*=e2iboq&7^fJm6W-xO1p8*A%q@;UIg4u-NN=> z7vlNZ`K0lxz4SQF2=R(0Mo*x|g_@}xok^*^J&G4p;XRj8V@+SIteu5LUxAevhH?sa zbdkGDgdd-Kt?t|gso5l+A_*2q@E5k#NMGT|$ObucU(1H-^Y`6>qPuy`-Mne9zvr*L zId>~@`$WMtyb1EPTT{2w1=sN{i`n7avU9$gO^^RGkG;ye_{}XJ=ki@|d*j$DX}+0a zP!O7nq;#*5=ARrhMjPAWI@(rjP{^|dpx+d#uXhxL##Pd?6fi(&DU#B?Mp{;dMq{|K zLtaPgid97bHTvr+2w`+J%%HH>Ul{B=;Kv-|0B&?RY=}D%)c3Ve-JAK3$-zy*d%eCO zG^~>Lr4t4S?L|_$*GT)S&|nNVcF5~!U9qYNV8TB5T|LMSd&mg&Z-8%c+q5~Vjqr_6 zNUw3xERr&dc9TQA$idhlI{Ph*ZUqZGRW^&R-0=h%cpaECQ6YdF$4%I^A<|SUx)T;) zfvWMoZ5Dv3R{~AiCE_%d#J>wdm#4(4Kq+E%!dhzjwh8={*foK#$*xha_L;kew;>fo z%|HWVK>xl{Jbo6>smH|=VDZ#^ZSmMxJa7p4$ln|@i7ZQHVw3F&d+AI7x&8mfnP+hV z#RbpO=OC-Q3hGNp?fE!;9?a>t0Co}@Jp+m_a@i!6QmO=$#v0EKD$AF+4B4p6WhyJ} z1#e1D(_ALqmAX8cmZo}oOB8i5K3#lgcF_6nGx6!bd(ZuOVQdI^ZFHO_z{gnBY;cv6 zbzMdZbQ~s_mLg?Ed7U2udZAE{8P*VqdAIS6upKnoO>vQUcspr zgh#Li&EOixHYd<5S|SPLAH@Q624)mIA6NSMV>&3~0{3oZ4lHQT!`f6>*r=4!k-we+1rQ_s4qckv`P-iCtEv`Si+4l*bR ztwmzovDP)xx+*jo!M^dc(z5wPg!_Yv@HWfa}+lN9|fPDGQ4FCnB5 zCK1vAyAC)Qi`k~SJCgw0J<3taF0E@u%e|h8sm9|Li6X?Ifc%G zq-brSPD>u7ODh*c7kM8ZSEF^h{qkHUt7c?{c4&9%p0=_m2etp!y>K*!4o)KA0fp5z xMF&RX!S&@M^h_>2mz9R-8=%9lRXL}c~K8uoRf_wZzhd*=Pf42IehY+H}CuM=KFf*Q#E=rF)=FQoXcX6G0B@?!C6&=}N0Szt%_M`&P62 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")