13 Commits

Author SHA1 Message Date
Grigo d4842d4b29 offline track 2026-06-19 12:50:42 +03:00
Grigo 8812cf9b40 offline track 2026-06-19 11:09:20 +03:00
Grigo 4891933879 added opentopo 2026-06-17 13:03:11 +03:00
Grigo f4ef87705c fixed gistogramm 2026-06-17 11:22:15 +03:00
Grigo 920a839197 added gistogramm 2026-06-17 11:12:33 +03:00
Grigo 40a1ccab1e added more fields 2026-06-16 12:53:43 +03:00
Grigo e71b6eed2f added sleepmode 2026-06-16 12:42:36 +03:00
Grigo dbef86d2c9 fix elevation 2026-06-16 12:04:23 +03:00
Grigo 6b34e75f35 fix elevation 2026-06-16 11:54:41 +03:00
Grigo 0e1fa15a2f fix 2026-06-16 11:42:23 +03:00
Grigo 64607def4a fix 2026-06-16 11:24:21 +03:00
Grigo 3399e81447 fix 2026-06-16 11:10:15 +03:00
Grigo 0571291b69 update 2026-06-16 10:36:18 +03:00
50 changed files with 3525 additions and 435 deletions
+10
View File
@@ -6,6 +6,12 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<application <application
@@ -30,6 +36,10 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".LoraForegroundService"
android:exported="false"
android:foregroundServiceType="location|dataSync" />
</application> </application>
</manifest> </manifest>
@@ -18,6 +18,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
public class CommandPoller { public class CommandPoller {
@@ -32,6 +34,11 @@ public class CommandPoller {
private final TrackRecorder trackRecorder; private final TrackRecorder trackRecorder;
private final PeerStatsCache peerStatsCache; private final PeerStatsCache peerStatsCache;
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "CommandPoller");
t.setDaemon(true);
return t;
});
private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final AtomicBoolean running = new AtomicBoolean(false); private final AtomicBoolean running = new AtomicBoolean(false);
@@ -60,6 +67,15 @@ public class CommandPoller {
} }
} }
@Override
public void onSyncComplete(long trackId, int pointCount) {
if (pendingAckSessionId > 0 && trackId > 0) {
long sid = pendingAckSessionId;
pendingAckSessionId = -1;
executor.execute(() -> ackSession(sid, trackId));
}
}
@Override @Override
public void onError(String message) { public void onError(String message) {
Log.w(TAG, "track: " + message); Log.w(TAG, "track: " + message);
@@ -75,35 +91,29 @@ public class CommandPoller {
if (!running.compareAndSet(false, true)) { if (!running.compareAndSet(false, true)) {
return; return;
} }
scheduleCommandPoll(); scheduler.scheduleWithFixedDelay(
schedulePairedPoll(); this::pollCommandsSafe, 0, COMMAND_POLL_MS, TimeUnit.MILLISECONDS);
scheduler.scheduleWithFixedDelay(
this::pollPairedSafe, 0, PAIRED_POLL_MS, TimeUnit.MILLISECONDS);
} }
public void stop() { public void stop() {
running.set(false); running.set(false);
} }
private void scheduleCommandPoll() { private void pollCommandsSafe() {
executor.execute(() -> { if (!running.get()) {
if (running.get()) { return;
}
pollCommands(); pollCommands();
} }
if (running.get()) {
mainHandler.postDelayed(this::scheduleCommandPoll, COMMAND_POLL_MS);
}
});
}
private void schedulePairedPoll() { private void pollPairedSafe() {
executor.execute(() -> { if (!running.get()) {
if (running.get()) { return;
}
pollPairedSession(); pollPairedSession();
} }
if (running.get()) {
mainHandler.postDelayed(this::schedulePairedPoll, PAIRED_POLL_MS);
}
});
}
private void pollCommands() { private void pollCommands() {
try { try {
@@ -181,7 +191,7 @@ public class CommandPoller {
} }
startedSessionId = session.id; startedSessionId = session.id;
pendingAckSessionId = session.id; pendingAckSessionId = session.id;
mainHandler.post(trackRecorder::start); trackRecorder.start();
} catch (Exception e) { } catch (Exception e) {
Log.w(TAG, "paired poll failed", e); Log.w(TAG, "paired poll failed", e);
} }
@@ -3,6 +3,7 @@ package com.grigowashere.loratester;
import android.app.Application; import android.app.Application;
import com.grigowashere.loratester.api.ServerApi; import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.location.LocationTracker;
import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.track.TrackRecorder; import com.grigowashere.loratester.track.TrackRecorder;
@@ -16,6 +17,7 @@ public class LoraApp extends Application {
private NetworkMonitor networkMonitor; private NetworkMonitor networkMonitor;
private PeerStatsCache peerStatsCache; private PeerStatsCache peerStatsCache;
private CommandPoller commandPoller; private CommandPoller commandPoller;
private LocationTracker locationTracker;
@Override @Override
public void onCreate() { public void onCreate() {
@@ -29,6 +31,7 @@ public class LoraApp extends Application {
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl()); ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
String deviceId = settingsRepository.getOrCreateDeviceId(); String deviceId = settingsRepository.getOrCreateDeviceId();
trackRecorder = new TrackRecorder( trackRecorder = new TrackRecorder(
this,
serverApi, serverApi,
telemetryUploader, telemetryUploader,
deviceId, deviceId,
@@ -42,6 +45,14 @@ public class LoraApp extends Application {
peerStatsCache peerStatsCache
); );
commandPoller.start(); commandPoller.start();
telemetryUploader.registerPresence();
if (networkMonitor != null) {
networkMonitor.addListener(online -> {
if (online) {
telemetryUploader.registerPresence();
}
});
}
} }
public NetworkMonitor getNetworkMonitor() { public NetworkMonitor getNetworkMonitor() {
@@ -68,6 +79,22 @@ public class LoraApp extends Application {
return commandPoller; return commandPoller;
} }
public synchronized void startLocationUpdates() {
if (locationTracker == null) {
locationTracker = new LocationTracker(this, (lat, lon, alt) -> {
telemetryUploader.updateLocation(lat, lon);
trackRecorder.updateLocation(lat, lon, alt);
});
}
locationTracker.start();
}
public synchronized void stopLocationUpdates() {
if (locationTracker != null) {
locationTracker.stop();
}
}
public void refreshTrackRecorder() { public void refreshTrackRecorder() {
if (commandPoller != null) { if (commandPoller != null) {
commandPoller.stop(); commandPoller.stop();
@@ -77,6 +104,7 @@ public class LoraApp extends Application {
} }
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl()); ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
trackRecorder = new TrackRecorder( trackRecorder = new TrackRecorder(
this,
serverApi, serverApi,
telemetryUploader, telemetryUploader,
settingsRepository.getOrCreateDeviceId(), settingsRepository.getOrCreateDeviceId(),
@@ -0,0 +1,178 @@
package com.grigowashere.loratester;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.PowerManager;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import com.grigowashere.loratester.track.TrackRecorder;
public class LoraForegroundService extends Service {
private static final String CHANNEL_ID = "lora_background";
private static final int NOTIFICATION_ID = 1;
private final Handler handler = new Handler(Looper.getMainLooper());
private PowerManager.WakeLock wakeLock;
private LoraApp app;
private final Runnable notificationTicker = new Runnable() {
@Override
public void run() {
updateNotification();
handler.postDelayed(this, 5000L);
}
};
public static void ensureRunning(Context context) {
Context appContext = context.getApplicationContext();
Intent intent = new Intent(appContext, LoraForegroundService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent);
} else {
appContext.startService(intent);
}
}
@Override
public void onCreate() {
super.onCreate();
app = (LoraApp) getApplication();
createNotificationChannel();
acquireWakeLock();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Notification notification = buildNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
| ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
);
} else {
startForeground(NOTIFICATION_ID, notification);
}
app.startLocationUpdates();
handler.removeCallbacks(notificationTicker);
handler.post(notificationTicker);
return START_STICKY;
}
@Override
public void onDestroy() {
handler.removeCallbacks(notificationTicker);
releaseWakeLock();
app.stopLocationUpdates();
super.onDestroy();
}
@Override
public void onTaskRemoved(Intent rootIntent) {
TelemetryUploader uploader = app.getTelemetryUploader();
if (uploader != null) {
uploader.stopTelnet();
}
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
super.onTaskRemoved(rootIntent);
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void acquireWakeLock() {
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
if (pm == null) {
return;
}
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "LoraTester::Background");
wakeLock.setReferenceCounted(false);
wakeLock.acquire();
}
private void releaseWakeLock() {
if (wakeLock != null && wakeLock.isHeld()) {
wakeLock.release();
}
wakeLock = null;
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription(getString(R.string.notification_channel_desc));
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm != null) {
nm.createNotificationChannel(channel);
}
}
private Notification buildNotification() {
Intent open = new Intent(this, MainActivity.class);
open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent pending = PendingIntent.getActivity(
this,
0,
open,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
TelemetryUploader uploader = app.getTelemetryUploader();
TrackRecorder recorder = app.getTrackRecorder();
SettingsRepository settings = app.getSettingsRepository();
boolean telnetOn = settings.isTelnetEnabled();
boolean telnetConnected = uploader != null && uploader.isTelnetConnected();
boolean recording = recorder != null && recorder.isRecording();
int points = recorder != null ? recorder.getPointCount() : 0;
String telnetLine = telnetOn
? getString(telnetConnected ? R.string.telnet_connected : R.string.telnet_disconnected)
: getString(R.string.telnet_disabled_short);
String trackLine = recording
? getString(R.string.notification_track_recording, points)
: getString(R.string.notification_track_idle);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_service)
.setContentTitle(getString(R.string.notification_title))
.setContentText(telnetLine + " · " + trackLine)
.setSubText(getString(R.string.notification_subtitle))
.setContentIntent(pending)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build();
}
private void updateNotification() {
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm != null) {
nm.notify(NOTIFICATION_ID, buildNotification());
}
}
}
@@ -1,8 +1,14 @@
package com.grigowashere.loratester; package com.grigowashere.loratester;
import android.Manifest; import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.PowerManager;
import android.provider.Settings;
import android.widget.Toast;
import androidx.activity.EdgeToEdge; import androidx.activity.EdgeToEdge;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
@@ -17,19 +23,30 @@ import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator; import com.google.android.material.tabs.TabLayoutMediator;
import com.grigowashere.loratester.location.LocationTracker;
import com.grigowashere.loratester.track.TrackRecorder;
import com.grigowashere.loratester.ui.MainPagerAdapter; import com.grigowashere.loratester.ui.MainPagerAdapter;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
private TelemetryUploader telemetryUploader; private LoraApp app;
private LocationTracker locationTracker; private SettingsRepository settings;
private boolean backgroundLocationRequested;
private final ActivityResultLauncher<String[]> locationPermissionLauncher = private final ActivityResultLauncher<String[]> locationPermissionLauncher =
registerForActivityResult( registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultContracts.RequestMultiplePermissions(),
result -> startLocationIfPermitted() result -> onForegroundLocationReady()
);
private final ActivityResultLauncher<String> backgroundLocationLauncher =
registerForActivityResult(
new ActivityResultContracts.RequestPermission(),
granted -> startBackgroundWork()
);
private final ActivityResultLauncher<String> notificationPermissionLauncher =
registerForActivityResult(
new ActivityResultContracts.RequestPermission(),
granted -> startBackgroundWork()
); );
@Override @Override
@@ -43,9 +60,8 @@ public class MainActivity extends AppCompatActivity {
return insets; return insets;
}); });
LoraApp app = (LoraApp) getApplication(); app = (LoraApp) getApplication();
telemetryUploader = app.getTelemetryUploader(); settings = app.getSettingsRepository();
SettingsRepository settings = app.getSettingsRepository();
ViewPager2 pager = findViewById(R.id.viewPager); ViewPager2 pager = findViewById(R.id.viewPager);
TabLayout tabs = findViewById(R.id.tabLayout); TabLayout tabs = findViewById(R.id.tabLayout);
@@ -62,41 +78,92 @@ public class MainActivity extends AppCompatActivity {
tab.setText(titleRes); tab.setText(titleRes);
}).attach(); }).attach();
TrackRecorder trackRecorder = app.getTrackRecorder(); requestStartupPermissions();
locationTracker = new LocationTracker(this, (lat, lon, alt) -> {
telemetryUploader.updateLocation(lat, lon);
trackRecorder.updateLocation(lat, lon, alt);
});
requestLocationPermission();
if (settings.isTelnetEnabled()) { if (settings.isTelnetEnabled()) {
telemetryUploader.startTelnet(); app.getTelemetryUploader().startTelnet();
} }
} }
private void requestLocationPermission() { private void requestStartupPermissions() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) if (hasForegroundLocation()) {
== PackageManager.PERMISSION_GRANTED) { onForegroundLocationReady();
startLocationIfPermitted(); return;
} else { }
locationPermissionLauncher.launch(new String[]{ locationPermissionLauncher.launch(new String[]{
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION Manifest.permission.ACCESS_COARSE_LOCATION
}); });
} }
private void onForegroundLocationReady() {
if (!hasForegroundLocation()) {
Toast.makeText(this, R.string.background_location_required, Toast.LENGTH_LONG).show();
return;
}
requestNotificationPermissionIfNeeded();
requestBackgroundLocationIfNeeded();
startBackgroundWork();
} }
private void startLocationIfPermitted() { private void requestNotificationPermissionIfNeeded() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return;
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) { == PackageManager.PERMISSION_GRANTED) {
locationTracker.start(); return;
}
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
}
private void requestBackgroundLocationIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return;
}
if (hasBackgroundLocation()) {
return;
}
if (backgroundLocationRequested) {
return;
}
backgroundLocationRequested = true;
if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_BACKGROUND_LOCATION)) {
Toast.makeText(this, R.string.background_location_rationale, Toast.LENGTH_LONG).show();
}
backgroundLocationLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION);
}
private void startBackgroundWork() {
LoraForegroundService.ensureRunning(this);
if (settings.isTelnetEnabled()) {
app.getTelemetryUploader().startTelnet();
} }
} }
@Override private boolean hasForegroundLocation() {
protected void onDestroy() { return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
locationTracker.stop(); == PackageManager.PERMISSION_GRANTED;
telemetryUploader.stopTelnet(); }
super.onDestroy();
private boolean hasBackgroundLocation() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return hasForegroundLocation();
}
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
== PackageManager.PERMISSION_GRANTED;
}
public static void openBatteryOptimizationSettings(@NonNull android.content.Context context) {
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + context.getPackageName()));
context.startActivity(intent);
}
public static boolean isIgnoringBatteryOptimizations(@NonNull android.content.Context context) {
PowerManager pm = (PowerManager) context.getSystemService(POWER_SERVICE);
if (pm == null) {
return true;
}
return pm.isIgnoringBatteryOptimizations(context.getPackageName());
} }
} }
@@ -13,6 +13,7 @@ public class SettingsRepository {
private static final String KEY_RANGE_REGEX = "range_regex"; private static final String KEY_RANGE_REGEX = "range_regex";
private static final String KEY_TELNET_ENABLED = "telnet_enabled"; private static final String KEY_TELNET_ENABLED = "telnet_enabled";
private static final String KEY_DEVICE_ID = "device_id"; private static final String KEY_DEVICE_ID = "device_id";
private static final String KEY_DEVICE_LABEL = "device_label";
public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru"; public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru";
private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634"; private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634";
@@ -106,4 +107,16 @@ public class SettingsRepository {
} }
return id; return id;
} }
public String getDeviceLabel() {
return prefs.getString(KEY_DEVICE_LABEL, null);
}
public void setDeviceLabel(String label) {
if (label == null) {
prefs.edit().remove(KEY_DEVICE_LABEL).apply();
} else {
prefs.edit().putString(KEY_DEVICE_LABEL, label.trim()).apply();
}
}
} }
@@ -1,6 +1,7 @@
package com.grigowashere.loratester; package com.grigowashere.loratester;
import android.content.Context; import android.content.Context;
import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
@@ -285,6 +286,7 @@ public class TelemetryUploader implements TelnetClient.Listener {
} }
TelemetryPayload payload = new TelemetryPayload( TelemetryPayload payload = new TelemetryPayload(
settings.getOrCreateDeviceId(), settings.getOrCreateDeviceId(),
phoneLabel(),
validLat(), validLat(),
validLon(), validLon(),
stats.rssi, stats.rssi,
@@ -297,6 +299,35 @@ public class TelemetryUploader implements TelnetClient.Listener {
uploadExecutor.execute(() -> uploadTelemetry(payload)); uploadExecutor.execute(() -> uploadTelemetry(payload));
} }
public void registerPresence() {
uploadExecutor.execute(() -> {
TelemetryPayload payload = new TelemetryPayload(
settings.getOrCreateDeviceId(),
phoneLabel(),
null,
null,
null,
null,
null,
null,
null,
System.currentTimeMillis() / 1000.0
);
uploadTelemetry(payload);
});
}
private String phoneLabel() {
String custom = settings.getDeviceLabel();
if (custom != null && !custom.isBlank()) {
return custom.trim();
}
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER : "";
String model = Build.MODEL != null ? Build.MODEL : "";
String label = (manufacturer + " " + model).trim();
return label.isEmpty() ? null : label;
}
private void uploadTelemetry(TelemetryPayload payload) { private void uploadTelemetry(TelemetryPayload payload) {
if (networkMonitor.isOnline()) { if (networkMonitor.isOnline()) {
try { try {
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
public class DeviceInfo { public class DeviceInfo {
public String device_id; public String device_id;
public String label;
public double last_seen; public double last_seen;
public Double lat; public Double lat;
public Double lon; public Double lon;
@@ -51,6 +51,9 @@ public class ServerApi {
public void postTelemetry(TelemetryPayload payload) throws IOException { public void postTelemetry(TelemetryPayload payload) throws IOException {
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("device_id", payload.deviceId); body.put("device_id", payload.deviceId);
if (payload.deviceLabel != null && !payload.deviceLabel.isBlank()) {
body.put("device_label", payload.deviceLabel);
}
if (payload.lat != null) body.put("lat", payload.lat); if (payload.lat != null) body.put("lat", payload.lat);
if (payload.lon != null) body.put("lon", payload.lon); if (payload.lon != null) body.put("lon", payload.lon);
if (payload.rssi != null) body.put("rssi", payload.rssi); if (payload.rssi != null) body.put("rssi", payload.rssi);
@@ -111,6 +114,28 @@ public class ServerApi {
postJson("/api/tracks/" + trackId + "/finish", new HashMap<>(), true); postJson("/api/tracks/" + trackId + "/finish", new HashMap<>(), true);
} }
public long syncTrack(
String deviceId,
Long trackId,
double startedAt,
List<Map<String, Object>> points,
boolean finish
) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("device_id", deviceId);
if (trackId != null && trackId > 0) {
body.put("track_id", trackId);
}
if (startedAt > 0) {
body.put("started_at", startedAt);
}
body.put("points", points != null ? points : List.of());
body.put("finish", finish);
Map<String, Object> resp = postJsonMap("/api/tracks/sync", body, true);
Number id = (Number) resp.get("track_id");
return id != null ? id.longValue() : (trackId != null ? trackId : -1);
}
public List<TrackInfo> listTracks(String deviceId) throws IOException { public List<TrackInfo> listTracks(String deviceId) throws IOException {
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST); return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
} }
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
public class TelemetryPayload { public class TelemetryPayload {
public final String deviceId; public final String deviceId;
public final String deviceLabel;
public final Double lat; public final Double lat;
public final Double lon; public final Double lon;
public final Double rssi; public final Double rssi;
@@ -22,8 +23,24 @@ public class TelemetryPayload {
String meta, String meta,
String role, String role,
Double ts Double ts
) {
this(deviceId, null, lat, lon, rssi, rangeM, rawFrame, meta, role, ts);
}
public TelemetryPayload(
String deviceId,
String deviceLabel,
Double lat,
Double lon,
Double rssi,
Double rangeM,
String rawFrame,
String meta,
String role,
Double ts
) { ) {
this.deviceId = deviceId; this.deviceId = deviceId;
this.deviceLabel = deviceLabel;
this.lat = lat; this.lat = lat;
this.lon = lon; this.lon = lon;
this.rssi = rssi; this.rssi = rssi;
@@ -32,8 +32,12 @@ public class LocationTracker {
return; return;
} }
LocationRequest request = new LocationRequest.Builder( LocationRequest request = new LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY, 10_000L Priority.PRIORITY_HIGH_ACCURACY, 1_000L
).setMinUpdateIntervalMillis(5_000L).build(); )
.setMinUpdateIntervalMillis(1_000L)
.setMaxUpdateDelayMillis(2_000L)
.setWaitForAccurateLocation(false)
.build();
callback = new LocationCallback() { callback = new LocationCallback() {
@Override @Override
@@ -0,0 +1,37 @@
package com.grigowashere.loratester.map;
import org.mapsforge.core.model.Tile;
import org.mapsforge.map.layer.download.tilesource.OnlineTileSource;
import java.net.MalformedURLException;
import java.net.URL;
/** Esri World Imagery — tile path is zoom/y/x (not OSM zoom/x/y). */
public final class EsriWorldImagery extends OnlineTileSource {
public static final EsriWorldImagery INSTANCE = new EsriWorldImagery();
private EsriWorldImagery() {
super(new String[]{"server.arcgisonline.com"}, 443);
setName("Esri.WorldImagery")
.setAlpha(false)
.setBaseUrl("/ArcGIS/rest/services/World_Imagery/MapServer/tile/")
.setExtension("png")
.setParallelRequestsLimit(4)
.setProtocol("https")
.setTileSize(256)
.setZoomLevelMax((byte) 18)
.setZoomLevelMin((byte) 0);
setUserAgent("LoraTester/1.0");
}
@Override
public URL getTileUrl(Tile tile) throws MalformedURLException {
StringBuilder path = new StringBuilder(48);
path.append(getBaseUrl());
path.append(tile.zoomLevel).append('/');
path.append(tile.tileY).append('/');
path.append(tile.tileX).append('.').append(getExtension());
return new URL(getProtocol(), getHostName(), 443, path.toString());
}
}
@@ -19,7 +19,7 @@ public final class RadioSnapshot {
public final String frame; public final String frame;
public final Double frequencyMhz; public final Double frequencyMhz;
public final Integer sf; public final Integer sf;
public final Integer bwKhz; public final Double bwKhz;
public final Double powerDbm; public final Double powerDbm;
public final Double rssiDbm; public final Double rssiDbm;
public final Double snrDb; public final Double snrDb;
@@ -30,6 +30,18 @@ public final class RadioSnapshot {
public final Double rxPktPerS; public final Double rxPktPerS;
public final Double perPercent; public final Double perPercent;
public final Double rxQualityPercent; public final Double rxQualityPercent;
public final String codeRate;
public final Integer preambleLength;
public final String lowDataRateOpt;
public final Boolean crcEnabled;
public final Integer payloadLengthBytes;
public final Double txTimeoutMs;
public final Integer packetReceive;
public final Integer packetTotal;
public final Integer packetError;
public final Integer crcError;
public final Integer preambleDetected;
public final Integer headerValid;
public final Map<String, String> extraFields; public final Map<String, String> extraFields;
public RadioSnapshot( public RadioSnapshot(
@@ -37,7 +49,7 @@ public final class RadioSnapshot {
String frame, String frame,
Double frequencyMhz, Double frequencyMhz,
Integer sf, Integer sf,
Integer bwKhz, Double bwKhz,
Double powerDbm, Double powerDbm,
Double rssiDbm, Double rssiDbm,
Double snrDb, Double snrDb,
@@ -48,6 +60,18 @@ public final class RadioSnapshot {
Double rxPktPerS, Double rxPktPerS,
Double perPercent, Double perPercent,
Double rxQualityPercent, Double rxQualityPercent,
String codeRate,
Integer preambleLength,
String lowDataRateOpt,
Boolean crcEnabled,
Integer payloadLengthBytes,
Double txTimeoutMs,
Integer packetReceive,
Integer packetTotal,
Integer packetError,
Integer crcError,
Integer preambleDetected,
Integer headerValid,
Map<String, String> extraFields Map<String, String> extraFields
) { ) {
this.role = role; this.role = role;
@@ -65,12 +89,26 @@ public final class RadioSnapshot {
this.rxPktPerS = rxPktPerS; this.rxPktPerS = rxPktPerS;
this.perPercent = perPercent; this.perPercent = perPercent;
this.rxQualityPercent = rxQualityPercent; this.rxQualityPercent = rxQualityPercent;
this.codeRate = codeRate;
this.preambleLength = preambleLength;
this.lowDataRateOpt = lowDataRateOpt;
this.crcEnabled = crcEnabled;
this.payloadLengthBytes = payloadLengthBytes;
this.txTimeoutMs = txTimeoutMs;
this.packetReceive = packetReceive;
this.packetTotal = packetTotal;
this.packetError = packetError;
this.crcError = crcError;
this.preambleDetected = preambleDetected;
this.headerValid = headerValid;
this.extraFields = extraFields != null ? extraFields : Map.of(); this.extraFields = extraFields != null ? extraFields : Map.of();
} }
public static RadioSnapshot empty() { public static RadioSnapshot empty() {
return new RadioSnapshot(null, null, null, null, null, null, null, null, return new RadioSnapshot(null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, Map.of()); null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
Map.of());
} }
public static RadioSnapshot fromMeta(String metaJson, String roleFallback, Double rssiFallback) { public static RadioSnapshot fromMeta(String metaJson, String roleFallback, Double rssiFallback) {
@@ -78,7 +116,9 @@ public final class RadioSnapshot {
RadioSnapshot snap = empty(); RadioSnapshot snap = empty();
if (roleFallback != null || rssiFallback != null) { if (roleFallback != null || rssiFallback != null) {
return new RadioSnapshot(roleFallback, null, null, null, null, null, return new RadioSnapshot(roleFallback, null, null, null, null, null,
rssiFallback, null, null, null, null, null, null, null, null, Map.of()); rssiFallback, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
Map.of());
} }
return snap; return snap;
} }
@@ -108,7 +148,7 @@ public final class RadioSnapshot {
text(o, "frame"), text(o, "frame"),
hzToMhz(lng(o, "frequency_hz")), hzToMhz(lng(o, "frequency_hz")),
integer(o, "spreading_factor"), integer(o, "spreading_factor"),
integer(o, "bandwidth_khz"), dbl(o, "bandwidth_khz"),
dbl(o, "power_dbm"), dbl(o, "power_dbm"),
rssi, rssi,
dbl(o, "snr_db"), dbl(o, "snr_db"),
@@ -119,11 +159,25 @@ public final class RadioSnapshot {
dbl(o, "rx_pkt_per_s"), dbl(o, "rx_pkt_per_s"),
dbl(o, "per_percent"), dbl(o, "per_percent"),
dbl(o, "rx_quality_percent"), dbl(o, "rx_quality_percent"),
text(o, "code_rate"),
integer(o, "preamble_length"),
text(o, "low_data_rate_opt"),
bool(o, "crc_enabled"),
integer(o, "payload_length_bytes"),
dbl(o, "tx_timeout_ms"),
integer(o, "packet_receive"),
integer(o, "packet_total"),
integer(o, "packet_error"),
integer(o, "crc_error"),
integer(o, "preamble_detected"),
integer(o, "header_valid"),
extra extra
); );
} catch (Exception ignored) { } catch (Exception ignored) {
return new RadioSnapshot(roleFallback, null, null, null, null, null, return new RadioSnapshot(roleFallback, null, null, null, null, null,
rssiFallback, null, null, null, null, null, null, null, null, Map.of()); rssiFallback, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
Map.of());
} }
} }
@@ -152,6 +206,14 @@ public final class RadioSnapshot {
cmp(changed, "sf", sf, prev.sf); cmp(changed, "sf", sf, prev.sf);
cmp(changed, "bw", bwKhz, prev.bwKhz); cmp(changed, "bw", bwKhz, prev.bwKhz);
cmp(changed, "power", powerDbm, prev.powerDbm); cmp(changed, "power", powerDbm, prev.powerDbm);
cmp(changed, "packetReceive", packetReceive, prev.packetReceive);
cmp(changed, "packetTotal", packetTotal, prev.packetTotal);
cmp(changed, "packetError", packetError, prev.packetError);
cmp(changed, "crcError", crcError, prev.crcError);
cmp(changed, "preambleDetected", preambleDetected, prev.preambleDetected);
cmp(changed, "headerValid", headerValid, prev.headerValid);
cmp(changed, "codeRate", codeRate, prev.codeRate);
cmp(changed, "crc", crcEnabled, prev.crcEnabled);
return changed; return changed;
} }
@@ -167,8 +229,12 @@ public final class RadioSnapshot {
|| n.contains("frequency") || n.equals("power") || n.equals("rssi") || n.contains("frequency") || n.equals("power") || n.equals("rssi")
|| n.equals("snr") || n.contains("spreading") || n.contains("bandwidth") || n.equals("snr") || n.contains("spreading") || n.contains("bandwidth")
|| n.equals("packet") || n.contains("packet number") || n.equals("payload") || n.equals("packet") || n.contains("packet number") || n.equals("payload")
|| n.contains("packet receive") || n.contains("packet total") || n.contains("packet error")
|| n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid")
|| n.contains("on air") || n.contains("tx speed") || n.contains("rx speed") || n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
|| n.equals("per") || n.contains("rx quality"); || n.equals("per") || n.contains("rx quality") || n.contains("tx timeout")
|| n.contains("code rate") || n.contains("preamble length")
|| n.contains("low data rate") || n.equals("crc") || n.contains("payload length");
} }
private static String text(JsonObject o, String key) { private static String text(JsonObject o, String key) {
@@ -186,6 +252,11 @@ public final class RadioSnapshot {
return e != null && e.isJsonPrimitive() ? e.getAsDouble() : null; return e != null && e.isJsonPrimitive() ? e.getAsDouble() : null;
} }
private static Boolean bool(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsBoolean() : null;
}
private static Long lng(JsonObject o, String key) { private static Long lng(JsonObject o, String key) {
JsonElement e = o.get(key); JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsLong() : null; return e != null && e.isJsonPrimitive() ? e.getAsLong() : null;
@@ -40,6 +40,12 @@ public final class LoraStatsFormatter {
appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed); appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed);
appendLine(sb, "Payload", s.payload, "payload", changed); appendLine(sb, "Payload", s.payload, "payload", changed);
appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed); appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed);
appendLine(sb, "Принято", fmtInt(s.packetReceive), "packetReceive", changed);
appendLine(sb, "Всего пакетов", fmtInt(s.packetTotal), "packetTotal", changed);
appendLine(sb, "Ошибки пакетов", fmtInt(s.packetError), "packetError", changed);
appendLine(sb, "CRC Error", fmtInt(s.crcError), "crcError", changed);
appendLine(sb, "Preamble Det.", fmtInt(s.preambleDetected), "preambleDetected", changed);
appendLine(sb, "Header Valid", fmtInt(s.headerValid), "headerValid", changed);
appendLine(sb, "TX Speed", fmtSuffix(s.txPktPerS, " pkt/s"), "txSpeed", changed); appendLine(sb, "TX Speed", fmtSuffix(s.txPktPerS, " pkt/s"), "txSpeed", changed);
appendLine(sb, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed); appendLine(sb, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed);
for (Map.Entry<String, String> e : s.extraFields.entrySet()) { for (Map.Entry<String, String> e : s.extraFields.entrySet()) {
@@ -58,8 +64,14 @@ public final class LoraStatsFormatter {
} }
appendLine(sb, "Частота", fmtSuffix(s.frequencyMhz, " MHz"), "frequency", changed); appendLine(sb, "Частота", fmtSuffix(s.frequencyMhz, " MHz"), "frequency", changed);
appendLine(sb, "SF", fmtInt(s.sf), "sf", changed); appendLine(sb, "SF", fmtInt(s.sf), "sf", changed);
appendLine(sb, "BW", fmtSuffix(s.bwKhz, " kHz"), "bw", changed); appendLine(sb, "BW", fmtBw(s.bwKhz), "bw", changed);
appendLine(sb, "Мощность TX", fmtDbm(s.powerDbm), "power", changed); appendLine(sb, "Мощность TX", fmtDbm(s.powerDbm), "power", changed);
appendLine(sb, "Code Rate", s.codeRate, "codeRate", changed);
appendLine(sb, "Preamble Len", fmtInt(s.preambleLength), "preambleLength", changed);
appendLine(sb, "Low DR Opt", s.lowDataRateOpt, "lowDataRateOpt", changed);
appendLine(sb, "CRC", fmtCrc(s.crcEnabled), "crc", changed);
appendLine(sb, "Payload len", fmtSuffix(s.payloadLengthBytes, " byte"), "payloadLength", changed);
appendLine(sb, "TX Timeout", fmtSuffix(s.txTimeoutMs, " ms"), "txTimeout", changed);
appendLine(sb, "On Air", fmtSuffix(s.onAirMs, " ms"), "onAir", changed); appendLine(sb, "On Air", fmtSuffix(s.onAirMs, " ms"), "onAir", changed);
return sb.toString().trim(); return sb.toString().trim();
} }
@@ -111,11 +123,22 @@ public final class LoraStatsFormatter {
return v != null ? String.valueOf(v) : null; return v != null ? String.valueOf(v) : null;
} }
private static String fmtSuffix(Double v, String suffix) { private static String fmtBw(Double v) {
return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null; return v != null ? String.format(Locale.US, "%.2f kHz", v) : null;
}
private static String fmtCrc(Boolean enabled) {
if (enabled == null) {
return null;
}
return enabled ? "On" : "Off";
} }
private static String fmtSuffix(Integer v, String suffix) { private static String fmtSuffix(Integer v, String suffix) {
return v != null ? v + suffix : null; return v != null ? v + suffix : null;
} }
private static String fmtSuffix(Double v, String suffix) {
return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null;
}
} }
@@ -25,9 +25,26 @@ public class StatsExtractor {
private static final Pattern SNR = Pattern.compile("SNR\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE); private static final Pattern SNR = Pattern.compile("SNR\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
private static final Pattern FREQUENCY = Pattern.compile("Frequency\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern FREQUENCY = Pattern.compile("Frequency\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern SPREADING = Pattern.compile("Spreading Factor\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern SPREADING = Pattern.compile("Spreading Factor\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET = Pattern.compile("Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern PACKET_TX = Pattern.compile("(?m)^\\s*Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET_NUMBER = Pattern.compile("Packet Number\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern PACKET_NUMBER = Pattern.compile("Packet Number\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET_RECEIVE = Pattern.compile("Packet Receive\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET_TOTAL = Pattern.compile("Packet Total\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET_ERROR = Pattern.compile("Packet Error\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern CRC_ERROR = Pattern.compile("CRC Error\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PREAMBLE_DETECTED = Pattern.compile(
"Preamble Detected\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern HEADER_VALID = Pattern.compile("Header Valid\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern CODE_RATE = Pattern.compile("Code Rate\\s*:\\s*(\\S+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PREAMBLE_LENGTH = Pattern.compile(
"Preamble Length\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern LOW_DATA_RATE = Pattern.compile(
"Low Data Rate Opt\\s*:\\s*(\\S+)", Pattern.CASE_INSENSITIVE);
private static final Pattern CRC = Pattern.compile("CRC\\s*:\\s*(On|Off)", Pattern.CASE_INSENSITIVE);
private static final Pattern PAYLOAD_LENGTH = Pattern.compile(
"Payload length\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern TX_TIMEOUT = Pattern.compile(
"TX Timeout\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PAYLOAD = Pattern.compile("Payload\\s*:\\s*(.+)", Pattern.CASE_INSENSITIVE); private static final Pattern PAYLOAD = Pattern.compile("Payload\\s*:\\s*(.+)", Pattern.CASE_INSENSITIVE);
private static final Pattern ON_AIR = Pattern.compile("On Air\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern ON_AIR = Pattern.compile("On Air\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
@@ -89,10 +106,10 @@ public class StatsExtractor {
putLong(meta, "frequency_hz", matchLong(FREQUENCY, normalized)); putLong(meta, "frequency_hz", matchLong(FREQUENCY, normalized));
putInt(meta, "spreading_factor", matchInt(SPREADING, normalized)); putInt(meta, "spreading_factor", matchInt(SPREADING, normalized));
putInt(meta, "bandwidth_khz", matchInt(BANDWIDTH, normalized)); putDouble(meta, "bandwidth_khz", matchDouble(BANDWIDTH, normalized));
Integer packet = matchInt(PACKET_NUMBER, normalized); Integer packet = matchInt(PACKET_NUMBER, normalized);
if (packet == null) { if (packet == null) {
packet = matchInt(PACKET, normalized); packet = matchInt(PACKET_TX, normalized);
} }
putInt(meta, "packet", packet); putInt(meta, "packet", packet);
putString(meta, "payload", matchString(PAYLOAD, normalized)); putString(meta, "payload", matchString(PAYLOAD, normalized));
@@ -101,11 +118,25 @@ public class StatsExtractor {
putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized)); putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized));
putDouble(meta, "per_percent", matchDouble(PER, normalized)); putDouble(meta, "per_percent", matchDouble(PER, normalized));
putDouble(meta, "rx_quality_percent", matchDouble(RX_QUALITY, normalized)); putDouble(meta, "rx_quality_percent", matchDouble(RX_QUALITY, normalized));
putString(meta, "code_rate", matchString(CODE_RATE, normalized));
putInt(meta, "preamble_length", matchInt(PREAMBLE_LENGTH, normalized));
putString(meta, "low_data_rate_opt", matchString(LOW_DATA_RATE, normalized));
putBool(meta, "crc_enabled", matchBool(CRC, normalized));
putInt(meta, "payload_length_bytes", matchInt(PAYLOAD_LENGTH, normalized));
putDouble(meta, "tx_timeout_ms", matchDouble(TX_TIMEOUT, normalized));
putInt(meta, "packet_receive", matchInt(PACKET_RECEIVE, normalized));
putInt(meta, "packet_total", matchInt(PACKET_TOTAL, normalized));
putInt(meta, "packet_error", matchInt(PACKET_ERROR, normalized));
putInt(meta, "crc_error", matchInt(CRC_ERROR, normalized));
putInt(meta, "preamble_detected", matchInt(PREAMBLE_DETECTED, normalized));
putInt(meta, "header_valid", matchInt(HEADER_VALID, normalized));
if (!fields.isEmpty()) { if (!fields.isEmpty()) {
meta.put("fields", fields); meta.put("fields", fields);
} }
meta.put("stats_at", System.currentTimeMillis() / 1000.0);
Double rangeM = matchDouble(rangePattern, normalized); Double rangeM = matchDouble(rangePattern, normalized);
Double displayDbm = rssiDbm != null ? rssiDbm : txPower; Double displayDbm = rssiDbm != null ? rssiDbm : txPower;
@@ -142,8 +173,12 @@ public class StatsExtractor {
return n.equals("frequency") || n.equals("power") || n.equals("rssi") return n.equals("frequency") || n.equals("power") || n.equals("rssi")
|| n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth") || n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth")
|| n.equals("packet") || n.contains("packet number") || n.equals("payload") || n.equals("packet") || n.contains("packet number") || n.equals("payload")
|| n.contains("packet receive") || n.contains("packet total") || n.contains("packet error")
|| n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid")
|| n.contains("on air") || n.contains("tx speed") || n.contains("rx speed") || n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
|| n.equals("per") || n.contains("rx quality"); || n.equals("per") || n.contains("rx quality") || n.contains("tx timeout")
|| n.contains("code rate") || n.contains("preamble length")
|| n.contains("low data rate") || n.equals("crc") || n.contains("payload length");
} }
private static ExtractedStats empty(String frame) { private static ExtractedStats empty(String frame) {
@@ -206,6 +241,20 @@ public class StatsExtractor {
} }
} }
private static void putBool(Map<String, Object> meta, String key, Boolean value) {
if (value != null) {
meta.put(key, value);
}
}
private static Boolean matchBool(Pattern pattern, String text) {
Matcher m = pattern.matcher(text);
if (!m.find()) {
return null;
}
return "on".equalsIgnoreCase(m.group(1).trim());
}
private static Double matchDouble(Pattern pattern, String text) { private static Double matchDouble(Pattern pattern, String text) {
Matcher m = pattern.matcher(text); Matcher m = pattern.matcher(text);
if (m.find()) { if (m.find()) {
@@ -0,0 +1,161 @@
package com.grigowashere.loratester.track;
import android.content.Context;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Persists pending track points and recording state across network outages / app restarts. */
public class TrackPointQueue {
private static final String TAG = "TrackPointQueue";
private static final String FILE_NAME = "track_pending_points.json";
private static final Gson GSON = new Gson();
private static final Type SNAPSHOT_TYPE = new TypeToken<Snapshot>() {}.getType();
private final File queueFile;
public TrackPointQueue(Context context) {
queueFile = new File(context.getFilesDir(), FILE_NAME);
}
public static final class Snapshot {
public long trackId = -1;
public boolean recording;
/** Stopped locally; waiting for upload + finish. */
public boolean pendingSync;
public int totalPoints;
public double startedAt;
public String deviceId;
public List<PendingPoint> pending = new ArrayList<>();
}
public static final class PendingPoint {
public double ts;
public double lat;
public double lon;
public Double altitude_gps;
public Double rssi;
public String role;
public String meta;
static PendingPoint fromMap(Map<String, Object> point) {
PendingPoint p = new PendingPoint();
p.ts = toDouble(point.get("ts"));
p.lat = toDouble(point.get("lat"));
p.lon = toDouble(point.get("lon"));
Object alt = point.get("altitude_gps");
if (alt instanceof Number) {
p.altitude_gps = ((Number) alt).doubleValue();
}
Object rssi = point.get("rssi");
if (rssi instanceof Number) {
p.rssi = ((Number) rssi).doubleValue();
}
Object role = point.get("role");
if (role != null) {
p.role = String.valueOf(role);
}
Object meta = point.get("meta");
if (meta != null) {
p.meta = String.valueOf(meta);
}
return p;
}
Map<String, Object> toMap() {
Map<String, Object> point = new HashMap<>();
point.put("ts", ts);
point.put("lat", lat);
point.put("lon", lon);
if (altitude_gps != null) {
point.put("altitude_gps", altitude_gps);
}
if (rssi != null) {
point.put("rssi", rssi);
}
if (role != null) {
point.put("role", role);
}
if (meta != null) {
point.put("meta", meta);
}
return point;
}
private static double toDouble(Object value) {
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
return 0.0;
}
}
public synchronized Snapshot load() {
if (!queueFile.exists()) {
return null;
}
try (FileReader reader = new FileReader(queueFile)) {
Snapshot snap = GSON.fromJson(reader, SNAPSHOT_TYPE);
if (snap == null) {
return null;
}
if (snap.pending == null) {
snap.pending = new ArrayList<>();
}
return snap;
} catch (Exception e) {
Log.w(TAG, "load failed", e);
return null;
}
}
public synchronized void save(
String deviceId,
long trackId,
boolean recording,
boolean pendingSync,
int totalPoints,
double startedAt,
List<Map<String, Object>> pending
) {
Snapshot snap = new Snapshot();
snap.deviceId = deviceId;
snap.trackId = trackId;
snap.recording = recording;
snap.pendingSync = pendingSync;
snap.totalPoints = totalPoints;
snap.startedAt = startedAt;
snap.pending = new ArrayList<>();
if (pending != null) {
for (Map<String, Object> point : pending) {
snap.pending.add(PendingPoint.fromMap(point));
}
}
persist(snap);
}
public synchronized void clear() {
if (queueFile.exists() && !queueFile.delete()) {
Log.w(TAG, "clear failed");
}
}
private void persist(Snapshot snap) {
try (FileWriter writer = new FileWriter(queueFile)) {
GSON.toJson(snap, writer);
} catch (Exception e) {
Log.w(TAG, "persist failed", e);
}
}
}
@@ -1,9 +1,11 @@
package com.grigowashere.loratester.track; package com.grigowashere.loratester.track;
import android.content.Context;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.ServerApi; import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.location.GeoUtils;
@@ -19,19 +21,26 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public class TrackRecorder { public class TrackRecorder {
private static final String TAG = "TrackRecorder"; private static final String TAG = "TrackRecorder";
private static final long SAMPLE_MS = 1000; private static final long SAMPLE_MS = 1000;
private static final long FLUSH_MS = 30_000; private static final long FLUSH_MS = 10_000;
/** Local-only track before first server sync (offline start). */
private static final long LOCAL_TRACK_ID = 0;
/** No time limit — pending points stay on disk until track stop or successful upload. */
public static final int MAX_OFFLINE_BUFFER_POINTS = 500_000;
public interface Listener { public interface Listener {
void onStateChanged(boolean recording, int pointCount, long trackId); void onStateChanged(boolean recording, int pointCount, long trackId);
void onError(String message); void onError(String message);
default void onSyncComplete(long trackId, int pointCount) {
}
default void onPointRecorded(double lat, double lon) { default void onPointRecorded(double lat, double lon) {
} }
} }
@@ -39,6 +48,8 @@ public class TrackRecorder {
private final ServerApi serverApi; private final ServerApi serverApi;
private final TelemetryUploader uploader; private final TelemetryUploader uploader;
private final NetworkMonitor networkMonitor; private final NetworkMonitor networkMonitor;
private final TrackPointQueue pendingQueue;
private final Context appContext;
private final String deviceId; private final String deviceId;
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Handler mainHandler = new Handler(Looper.getMainLooper());
@@ -53,7 +64,9 @@ public class TrackRecorder {
private volatile double lon = Double.NaN; private volatile double lon = Double.NaN;
private volatile double altitude = Double.NaN; private volatile double altitude = Double.NaN;
private volatile long trackId = -1; private volatile long trackId = -1;
private volatile double localStartedAt;
private volatile boolean recording; private volatile boolean recording;
private volatile boolean pendingSync;
private final List<Map<String, Object>> buffer = new ArrayList<>(); private final List<Map<String, Object>> buffer = new ArrayList<>();
private int totalPoints; private int totalPoints;
private ScheduledFuture<?> sampleTask; private ScheduledFuture<?> sampleTask;
@@ -62,6 +75,7 @@ public class TrackRecorder {
private Listener pairedListener; private Listener pairedListener;
public TrackRecorder( public TrackRecorder(
Context context,
ServerApi serverApi, ServerApi serverApi,
TelemetryUploader uploader, TelemetryUploader uploader,
String deviceId, String deviceId,
@@ -71,11 +85,14 @@ public class TrackRecorder {
this.uploader = uploader; this.uploader = uploader;
this.deviceId = deviceId; this.deviceId = deviceId;
this.networkMonitor = networkMonitor; this.networkMonitor = networkMonitor;
this.appContext = context.getApplicationContext();
this.pendingQueue = new TrackPointQueue(this.appContext);
networkMonitor.addListener(online -> { networkMonitor.addListener(online -> {
if (online && recording) { if (online) {
executor.execute(this::flushBuffer); executor.execute(this::syncWhenOnline);
} }
}); });
restoreIfNeeded();
} }
public void setListener(Listener listener) { public void setListener(Listener listener) {
@@ -100,6 +117,10 @@ public class TrackRecorder {
return recording; return recording;
} }
public boolean hasPendingSync() {
return pendingSync;
}
public int getPointCount() { public int getPointCount() {
return totalPoints; return totalPoints;
} }
@@ -108,30 +129,97 @@ public class TrackRecorder {
return trackId; return trackId;
} }
public int getPendingFlushCount() {
synchronized (buffer) {
return buffer.size();
}
}
public void start() { public void start() {
if (recording) { if (recording) {
return; return;
} }
if (!networkMonitor.isOnline()) { executor.execute(() -> {
notifyError("Нужна сеть для начала трека"); if (pendingSync) {
syncWhenOnline();
if (pendingSync) {
notifyError(appContext.getString(R.string.track_sync_pending));
return;
}
}
if (!networkMonitor.isOnline()) {
startLocalRecording();
return; return;
} }
executor.execute(() -> {
try { try {
startOnlineRecording();
} catch (Exception e) {
Log.w(TAG, "start track on server failed", e);
if (isReachabilityError(e)) {
startLocalRecording();
} else {
notifyError(e.getMessage() != null ? e.getMessage() : "start failed");
}
}
});
}
/** Retry upload of a track stopped offline. */
public void retryPendingSync() {
executor.execute(this::syncWhenOnline);
}
private void startOnlineRecording() throws Exception {
long id = serverApi.startTrack(deviceId); long id = serverApi.startTrack(deviceId);
synchronized (buffer) { synchronized (buffer) {
buffer.clear(); buffer.clear();
} }
totalPoints = 0; totalPoints = 0;
trackId = id; trackId = id;
localStartedAt = System.currentTimeMillis() / 1000.0;
recording = true; recording = true;
pendingSync = false;
persistState(false);
startTimers(); startTimers();
notifyState(); notifyState();
} catch (Exception e) {
Log.e(TAG, "start track failed", e);
notifyError(e.getMessage());
} }
});
private static boolean isReachabilityError(Throwable e) {
while (e != null) {
if (e instanceof java.net.UnknownHostException
|| e instanceof java.net.ConnectException
|| e instanceof java.net.SocketTimeoutException) {
return true;
}
String msg = e.getMessage();
if (msg != null) {
String lower = msg.toLowerCase();
if (lower.contains("unable to resolve host")
|| lower.contains("failed to connect")
|| lower.contains("timeout")
|| lower.contains("econnrefused")
|| lower.contains("network is unreachable")) {
return true;
}
}
e = e.getCause();
}
return false;
}
private void startLocalRecording() {
synchronized (buffer) {
buffer.clear();
}
totalPoints = 0;
trackId = LOCAL_TRACK_ID;
localStartedAt = System.currentTimeMillis() / 1000.0;
recording = true;
pendingSync = false;
persistState(false);
startTimers();
notifyState();
notifyError(appContext.getString(R.string.track_offline_started));
} }
public void stop() { public void stop() {
@@ -141,22 +229,146 @@ public class TrackRecorder {
recording = false; recording = false;
stopTimers(); stopTimers();
executor.execute(() -> { executor.execute(() -> {
try { boolean synced = completeTrackUpload(true);
flushBuffer(); if (!synced) {
if (trackId > 0) { pendingSync = true;
serverApi.finishTrack(trackId); persistState(true);
notifyError(appContext.getString(R.string.track_sync_pending));
} else {
resetAfterSuccessfulSync(-1);
} }
} catch (Exception e) {
Log.e(TAG, "stop track failed", e);
notifyError(e.getMessage());
} finally {
trackId = -1;
notifyState(); notifyState();
}
}); });
} }
private void restoreIfNeeded() {
TrackPointQueue.Snapshot snap = pendingQueue.load();
if (snap == null) {
return;
}
if (snap.deviceId != null && !snap.deviceId.equals(deviceId)) {
return;
}
trackId = snap.trackId;
totalPoints = snap.totalPoints;
localStartedAt = snap.startedAt > 0 ? snap.startedAt : System.currentTimeMillis() / 1000.0;
pendingSync = snap.pendingSync;
synchronized (buffer) {
buffer.clear();
if (snap.pending != null) {
for (TrackPointQueue.PendingPoint point : snap.pending) {
buffer.add(point.toMap());
}
}
}
if (snap.recording) {
recording = true;
Log.i(TAG, "restored active track " + trackId + ", pending=" + buffer.size());
startTimers();
executor.execute(this::syncWhenOnline);
notifyState();
return;
}
if (snap.pendingSync) {
Log.i(TAG, "restored pending sync track " + trackId + ", points=" + buffer.size());
executor.execute(this::syncWhenOnline);
notifyState();
}
}
private void syncWhenOnline() {
if (!networkMonitor.isOnline()) {
return;
}
if (recording) {
if (trackId == LOCAL_TRACK_ID) {
promoteLocalTrackToServer();
} else if (trackId > 0) {
flushBuffer();
}
return;
}
if (pendingSync) {
if (completeTrackUpload(true)) {
long finishedId = trackId > 0 ? trackId : -1;
int count = totalPoints;
resetAfterSuccessfulSync(-1);
notifySyncComplete(finishedId, count);
notifyState();
}
}
}
private void promoteLocalTrackToServer() {
if (trackId != LOCAL_TRACK_ID || !recording) {
return;
}
try {
long id = serverApi.startTrack(deviceId);
trackId = id;
flushBuffer();
persistState(false);
notifyState();
Log.i(TAG, "promoted local track to server id " + id);
} catch (Exception e) {
Log.w(TAG, "promote local track failed", e);
}
}
private boolean completeTrackUpload(boolean finish) {
if (!networkMonitor.isOnline()) {
return false;
}
List<Map<String, Object>> batch;
synchronized (buffer) {
if (buffer.isEmpty() && !finish) {
return true;
}
batch = new ArrayList<>(buffer);
buffer.clear();
}
try {
Long serverTrackId = trackId > 0 ? trackId : null;
long resultId = serverApi.syncTrack(
deviceId,
serverTrackId,
localStartedAt,
batch,
finish
);
if (trackId <= 0) {
trackId = resultId;
}
persistState(false);
if (finish && batch.isEmpty()) {
return true;
}
return true;
} catch (Exception e) {
Log.e(TAG, "track sync failed", e);
synchronized (buffer) {
buffer.addAll(0, batch);
}
persistState(finish);
notifyError(e.getMessage());
return false;
}
}
private void resetAfterSuccessfulSync(long finishedTrackId) {
trackId = -1;
pendingSync = false;
recording = false;
synchronized (buffer) {
buffer.clear();
}
pendingQueue.clear();
}
private void startTimers() { private void startTimers() {
if (sampleTask != null || flushTask != null) {
return;
}
sampleTask = scheduler.scheduleAtFixedRate( sampleTask = scheduler.scheduleAtFixedRate(
() -> executor.execute(this::samplePoint), () -> executor.execute(this::samplePoint),
SAMPLE_MS, SAMPLE_MS,
@@ -210,12 +422,58 @@ public class TrackRecorder {
} }
synchronized (buffer) { synchronized (buffer) {
buffer.add(point); buffer.add(point);
if (buffer.size() > MAX_OFFLINE_BUFFER_POINTS) {
buffer.remove(0);
Log.w(TAG, "offline buffer trimmed at " + MAX_OFFLINE_BUFFER_POINTS);
}
} }
totalPoints++; totalPoints++;
persistState(false);
notifyState(); notifyState();
notifyPoint(lat, lon); notifyPoint(lat, lon);
} }
private void persistState(boolean markPendingSync) {
List<Map<String, Object>> copy;
synchronized (buffer) {
copy = new ArrayList<>(buffer);
}
pendingQueue.save(
deviceId,
trackId,
recording,
markPendingSync || pendingSync,
totalPoints,
localStartedAt,
copy
);
}
private void flushBuffer() {
if (!recording || trackId <= 0 || !networkMonitor.isOnline()) {
return;
}
List<Map<String, Object>> batch;
synchronized (buffer) {
if (buffer.isEmpty()) {
return;
}
batch = new ArrayList<>(buffer);
buffer.clear();
}
try {
serverApi.addTrackPoints(trackId, batch);
persistState(false);
} catch (Exception e) {
Log.e(TAG, "flush points failed", e);
synchronized (buffer) {
buffer.addAll(0, batch);
}
persistState(false);
notifyError(e.getMessage());
}
}
private void notifyPoint(double lat, double lon) { private void notifyPoint(double lat, double lon) {
mainHandler.post(() -> { mainHandler.post(() -> {
if (listener != null) { if (listener != null) {
@@ -227,27 +485,15 @@ public class TrackRecorder {
}); });
} }
private void flushBuffer() { private void notifySyncComplete(long trackId, int pointCount) {
if (trackId < 0) { mainHandler.post(() -> {
return; if (listener != null) {
listener.onSyncComplete(trackId, pointCount);
} }
List<Map<String, Object>> batch; if (pairedListener != null) {
synchronized (buffer) { pairedListener.onSyncComplete(trackId, pointCount);
if (buffer.isEmpty()) {
return;
}
batch = new ArrayList<>(buffer);
buffer.clear();
}
try {
serverApi.addTrackPoints(trackId, batch);
} catch (Exception e) {
Log.e(TAG, "flush points failed", e);
synchronized (buffer) {
buffer.addAll(0, batch);
}
notifyError(e.getMessage());
} }
});
} }
private void notifyState() { private void notifyState() {
@@ -0,0 +1,25 @@
package com.grigowashere.loratester.ui;
import com.grigowashere.loratester.api.DeviceInfo;
/** Human-readable device labels (matches web deviceDisplayName). */
public final class DeviceNames {
private DeviceNames() {
}
public static String displayName(DeviceInfo device, String fallbackId) {
if (device != null && device.label != null) {
String label = device.label.trim();
if (!label.isEmpty()
&& device.device_id != null
&& !label.equals(device.device_id)) {
return label;
}
}
if (fallbackId != null && !fallbackId.isEmpty()) {
return fallbackId;
}
return "";
}
}
@@ -37,6 +37,8 @@ import com.grigowashere.loratester.api.TrackDetail;
import com.grigowashere.loratester.api.TrackInfo; import com.grigowashere.loratester.api.TrackInfo;
import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.map.EsriWorldImagery;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.track.TrackRecorder; import com.grigowashere.loratester.track.TrackRecorder;
@@ -51,6 +53,7 @@ import org.mapsforge.map.layer.Layer;
import org.mapsforge.map.layer.cache.TileCache; import org.mapsforge.map.layer.cache.TileCache;
import org.mapsforge.map.layer.download.TileDownloadLayer; import org.mapsforge.map.layer.download.TileDownloadLayer;
import org.mapsforge.map.layer.download.tilesource.OpenStreetMapMapnik; import org.mapsforge.map.layer.download.tilesource.OpenStreetMapMapnik;
import org.mapsforge.map.layer.download.tilesource.TileSource;
import org.mapsforge.map.layer.overlay.Marker; import org.mapsforge.map.layer.overlay.Marker;
import org.mapsforge.map.layer.overlay.Polyline; import org.mapsforge.map.layer.overlay.Polyline;
import org.mapsforge.map.model.MapViewPosition; import org.mapsforge.map.model.MapViewPosition;
@@ -108,6 +111,8 @@ public class MapFragment extends Fragment {
private TileCache tileCache; private TileCache tileCache;
private TextView mapStatus; private TextView mapStatus;
private TextView mapDistance; private TextView mapDistance;
private TextView mapRxQuality;
private TextView mapTrackStatus;
private TextView mapHillStatus; private TextView mapHillStatus;
private TextView trackStatus; private TextView trackStatus;
private ImageView iconServer; private ImageView iconServer;
@@ -122,6 +127,7 @@ public class MapFragment extends Fragment {
private MaterialButtonToggleGroup mapCenterMode; private MaterialButtonToggleGroup mapCenterMode;
private Spinner trackSpinner; private Spinner trackSpinner;
private Spinner mapHeatmapRadius; private Spinner mapHeatmapRadius;
private Spinner mapBasemap;
private TextView mapHeatmapStatus; private TextView mapHeatmapStatus;
private View mapHeatmapLegend; private View mapHeatmapLegend;
private List<TrackInfo> savedTracks = new ArrayList<>(); private List<TrackInfo> savedTracks = new ArrayList<>();
@@ -150,6 +156,8 @@ public class MapFragment extends Fragment {
private double lastHeatmapLat = Double.NaN; private double lastHeatmapLat = Double.NaN;
private double lastHeatmapLon = Double.NaN; private double lastHeatmapLon = Double.NaN;
private boolean suppressHeatmapSpinner; private boolean suppressHeatmapSpinner;
private boolean suppressBasemapSpinner;
private TileSource currentTileSource = OpenStreetMapMapnik.INSTANCE;
private Runnable heatmapReloadRunnable; private Runnable heatmapReloadRunnable;
private boolean suppressCenterToggle; private boolean suppressCenterToggle;
private boolean mapGestureActive; private boolean mapGestureActive;
@@ -183,6 +191,8 @@ public class MapFragment extends Fragment {
mapView = view.findViewById(R.id.mapView); mapView = view.findViewById(R.id.mapView);
mapStatus = view.findViewById(R.id.mapStatus); mapStatus = view.findViewById(R.id.mapStatus);
mapDistance = view.findViewById(R.id.mapDistance); mapDistance = view.findViewById(R.id.mapDistance);
mapRxQuality = view.findViewById(R.id.mapRxQuality);
mapTrackStatus = view.findViewById(R.id.mapTrackStatus);
mapHillStatus = view.findViewById(R.id.mapHillStatus); mapHillStatus = view.findViewById(R.id.mapHillStatus);
iconServer = view.findViewById(R.id.iconServer); iconServer = view.findViewById(R.id.iconServer);
iconLora = view.findViewById(R.id.iconLora); iconLora = view.findViewById(R.id.iconLora);
@@ -196,6 +206,7 @@ public class MapFragment extends Fragment {
mapToolDrawer = view.findViewById(R.id.mapToolDrawer); mapToolDrawer = view.findViewById(R.id.mapToolDrawer);
mapCenterMode = view.findViewById(R.id.mapCenterMode); mapCenterMode = view.findViewById(R.id.mapCenterMode);
mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius); mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius);
mapBasemap = view.findViewById(R.id.mapBasemap);
mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus); mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus);
mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend); mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend);
trackSpinner = view.findViewById(R.id.trackSpinner); trackSpinner = view.findViewById(R.id.trackSpinner);
@@ -208,14 +219,35 @@ public class MapFragment extends Fragment {
btnFindHill.setOnClickListener(v -> toggleHill()); btnFindHill.setOnClickListener(v -> toggleHill());
} }
setupHeatmapUi(); setupHeatmapUi();
setupBasemapUi();
updateConnectionIcons(lastDevices, serverConnected); updateConnectionIcons(lastDevices, serverConnected);
networkOnline = networkMonitor != null && networkMonitor.isOnline(); networkOnline = networkMonitor != null && networkMonitor.isOnline();
networkListener = online -> { networkListener = online -> {
boolean wasOnline = networkOnline;
networkOnline = online; networkOnline = online;
if (isAdded() && mapStatus != null) { if (isAdded() && mapStatus != null) {
requireActivity().runOnUiThread(this::updateNetworkStatusLine); requireActivity().runOnUiThread(() -> {
updateNetworkStatusLine();
updateTrackStatusUi();
});
}
if (isAdded() && trackRecorder != null && trackRecorder.isRecording()) {
int pending = trackRecorder.getPendingFlushCount();
if (!online && wasOnline) {
requireActivity().runOnUiThread(() -> Toast.makeText(
requireContext(),
getString(R.string.track_offline_toast, pending),
Toast.LENGTH_LONG
).show());
} else if (online && !wasOnline && pending > 0) {
requireActivity().runOnUiThread(() -> Toast.makeText(
requireContext(),
getString(R.string.track_online_toast, pending),
Toast.LENGTH_SHORT
).show());
}
} }
}; };
if (networkMonitor != null) { if (networkMonitor != null) {
@@ -347,18 +379,18 @@ public class MapFragment extends Fragment {
mapView.getModel().frameBufferModel.getOverdrawFactor() mapView.getModel().frameBufferModel.getOverdrawFactor()
); );
OpenStreetMapMapnik tileSource = OpenStreetMapMapnik.INSTANCE; OpenStreetMapMapnik.INSTANCE.setUserAgent("LoraTester/1.0");
tileSource.setUserAgent("LoraTester/1.0"); currentTileSource = OpenStreetMapMapnik.INSTANCE;
downloadLayer = new TileDownloadLayer( downloadLayer = new TileDownloadLayer(
tileCache, tileCache,
mapView.getModel().mapViewPosition, mapView.getModel().mapViewPosition,
tileSource, currentTileSource,
AndroidGraphicFactory.INSTANCE AndroidGraphicFactory.INSTANCE
); );
mapView.getLayerManager().getLayers().add(downloadLayer); mapView.getLayerManager().getLayers().add(downloadLayer);
mapView.setZoomLevelMin(tileSource.getZoomLevelMin()); mapView.setZoomLevelMin(currentTileSource.getZoomLevelMin());
mapView.setZoomLevelMax(tileSource.getZoomLevelMax()); mapView.setZoomLevelMax(currentTileSource.getZoomLevelMax());
downloadLayer.start(); downloadLayer.start();
MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition; MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition;
@@ -457,6 +489,8 @@ public class MapFragment extends Fragment {
heatmapActive = false; heatmapActive = false;
mapStatus = null; mapStatus = null;
mapDistance = null; mapDistance = null;
mapRxQuality = null;
mapTrackStatus = null;
trackStatus = null; trackStatus = null;
btnTrack = null; btnTrack = null;
btnPairedTrack = null; btnPairedTrack = null;
@@ -499,9 +533,7 @@ public class MapFragment extends Fragment {
btnTrack.setActivated(recording); btnTrack.setActivated(recording);
btnTrack.setContentDescription(getString( btnTrack.setContentDescription(getString(
recording ? R.string.track_stop : R.string.track_start)); recording ? R.string.track_stop : R.string.track_start));
if (trackStatus != null) { updateTrackStatusUi();
trackStatus.setText(getString(R.string.track_status, pointCount));
}
if (recording && pointCount <= 1) { if (recording && pointCount <= 1) {
clearLiveTrackLayers(); clearLiveTrackLayers();
} }
@@ -515,9 +547,26 @@ public class MapFragment extends Fragment {
@Override @Override
public void onError(String message) { public void onError(String message) {
if (isAdded() && trackStatus != null) { if (!isAdded()) {
return;
}
if (trackStatus != null) {
trackStatus.setText(getString(R.string.track_error, message)); trackStatus.setText(getString(R.string.track_error, message));
} }
Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show();
}
@Override
public void onSyncComplete(long trackId, int pointCount) {
if (!isAdded()) {
return;
}
Toast.makeText(
requireContext(),
getString(R.string.track_sync_done, trackId, pointCount),
Toast.LENGTH_LONG
).show();
loadTrackList();
} }
@Override @Override
@@ -593,10 +642,49 @@ public class MapFragment extends Fragment {
liveTrackMarker = null; liveTrackMarker = null;
} }
private void updateTrackStatusUi() {
if (trackRecorder == null) {
return;
}
if (!trackRecorder.isRecording()) {
if (trackStatus != null) {
trackStatus.setText("");
}
if (mapTrackStatus != null) {
mapTrackStatus.setVisibility(View.GONE);
}
return;
}
int total = trackRecorder.getPointCount();
int pending = trackRecorder.getPendingFlushCount();
CharSequence line;
if (!networkOnline && pending > 0) {
line = getString(R.string.track_status_offline, total, pending);
} else {
line = getString(R.string.track_status_online, total);
}
if (trackStatus != null) {
trackStatus.setText(line);
}
if (mapTrackStatus != null) {
mapTrackStatus.setVisibility(View.VISIBLE);
if (!networkOnline && pending > 0) {
mapTrackStatus.setText(getString(R.string.map_track_status_offline, total, pending));
} else {
mapTrackStatus.setText(getString(R.string.map_track_status_online, total));
}
}
}
private void toggleTracking() { private void toggleTracking() {
if (trackRecorder.isRecording()) { if (trackRecorder.isRecording()) {
trackRecorder.stop(); trackRecorder.stop();
} else { } else {
if (trackRecorder.hasPendingSync()) {
trackRecorder.retryPendingSync();
Toast.makeText(requireContext(), R.string.track_sync_pending, Toast.LENGTH_SHORT).show();
return;
}
trackRecorder.start(); trackRecorder.start();
} }
} }
@@ -707,29 +795,89 @@ public class MapFragment extends Fragment {
} }
clearTrackLayers(); clearTrackLayers();
List<LatLong> line = new ArrayList<>();
List<LatLong> boundsPoints = new ArrayList<>(); List<LatLong> boundsPoints = new ArrayList<>();
boolean colorByQuality = isRxTrack(detail);
int defaultColor = colorByQuality ? ARGB_RX : ARGB_TRACK;
for (TrackDetail.TrackPoint p : detail.points) { for (int i = 0; i < detail.points.size(); i++) {
TrackDetail.TrackPoint p = detail.points.get(i);
LatLong latLong = new LatLong(p.lat, p.lon); LatLong latLong = new LatLong(p.lat, p.lon);
line.add(latLong);
boundsPoints.add(latLong); boundsPoints.add(latLong);
Marker marker = new Marker(latLong, bitmapTrackPoint, 0, 0); int pointColor = trackPointColor(p.meta, colorByQuality, defaultColor);
Marker marker = new Marker(latLong, MapsforgeBitmaps.dot(pointColor, 12), 0, 0);
addTrackLayer(marker); addTrackLayer(marker);
}
if (line.size() >= 2) { if (i > 0) {
Polyline polyline = new Polyline( TrackDetail.TrackPoint prev = detail.points.get(i - 1);
MapsforgeBitmaps.linePaint(Color.GREEN, 4f), Double qa = rxQualityFromMeta(prev.meta);
Double qb = rxQualityFromMeta(p.meta);
Double q = qa != null && qb != null ? (qa + qb) / 2.0 : (qa != null ? qa : qb);
int segColor = colorByQuality && q != null ? qualityArgb(q) : defaultColor;
Polyline segment = new Polyline(
MapsforgeBitmaps.linePaint(segColor, 4f),
AndroidGraphicFactory.INSTANCE AndroidGraphicFactory.INSTANCE
); );
polyline.getLatLongs().addAll(line); segment.getLatLongs().add(new LatLong(prev.lat, prev.lon));
addTrackLayer(polyline); segment.getLatLongs().add(latLong);
addTrackLayer(segment);
}
} }
fitBoundsOnce(boundsPoints, detail.points.size() == 1, true); fitBoundsOnce(boundsPoints, detail.points.size() == 1, true);
} }
private static boolean isRxTrack(TrackDetail detail) {
if (detail.points == null) {
return false;
}
for (TrackDetail.TrackPoint p : detail.points) {
if (StatsExtractor.ROLE_RX.equals(p.role)) {
return true;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(p.meta, null, null);
if (StatsExtractor.ROLE_RX.equals(snap.role)) {
return true;
}
}
return false;
}
private static Double rxQualityFromMeta(String meta) {
if (meta == null || meta.isBlank()) {
return null;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(meta, null, null);
return snap.rxQualityPercent;
}
private static int qualityArgb(double pct) {
double p = Math.max(0.0, Math.min(100.0, pct));
int r;
int g;
if (p < 40.0) {
double t = p / 40.0;
r = 255;
g = (int) Math.round(140.0 * t);
} else if (p < 85.0) {
double t = (p - 40.0) / 45.0;
r = 255;
g = (int) Math.round(140.0 + 115.0 * t);
} else {
double t = (p - 85.0) / 15.0;
r = (int) Math.round(255.0 * (1.0 - t));
g = 255;
}
return 0xFF000000 | (r << 16) | (g << 8);
}
private static int trackPointColor(String meta, boolean colorByQuality, int fallback) {
if (!colorByQuality) {
return fallback;
}
Double q = rxQualityFromMeta(meta);
return q != null ? qualityArgb(q) : fallback;
}
private void addTrackLayer(Layer layer) { private void addTrackLayer(Layer layer) {
mapView.getLayerManager().getLayers().add(layer); mapView.getLayerManager().getLayers().add(layer);
trackLayers.add(layer); trackLayers.add(layer);
@@ -1015,6 +1163,68 @@ public class MapFragment extends Fragment {
hillPathLine = null; hillPathLine = null;
} }
private void setupBasemapUi() {
if (mapBasemap == null) {
return;
}
List<String> labels = List.of(
getString(R.string.map_layer_scheme),
getString(R.string.map_layer_satellite)
);
suppressBasemapSpinner = true;
mapBasemap.setAdapter(new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
labels
));
mapBasemap.setSelection(0, false);
suppressBasemapSpinner = false;
mapBasemap.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View v, int pos, long id) {
if (suppressBasemapSpinner) {
return;
}
TileSource next = pos == 1 ? EsriWorldImagery.INSTANCE : OpenStreetMapMapnik.INSTANCE;
if (next != currentTileSource) {
switchBasemap(next);
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
}
private void switchBasemap(TileSource tileSource) {
if (!isMapReady() || tileSource == null || tileSource == currentTileSource) {
return;
}
if (tileSource instanceof OpenStreetMapMapnik osm) {
osm.setUserAgent("LoraTester/1.0");
}
if (downloadLayer != null) {
downloadLayer.onPause();
mapView.getLayerManager().getLayers().remove(downloadLayer);
}
currentTileSource = tileSource;
downloadLayer = new TileDownloadLayer(
tileCache,
mapView.getModel().mapViewPosition,
currentTileSource,
AndroidGraphicFactory.INSTANCE
);
mapView.getLayerManager().getLayers().add(0, downloadLayer);
mapView.setZoomLevelMin(currentTileSource.getZoomLevelMin());
mapView.setZoomLevelMax(currentTileSource.getZoomLevelMax());
downloadLayer.start();
if (mapResumed) {
downloadLayer.onResume();
}
requestMapInvalidate();
}
private void setupHeatmapUi() { private void setupHeatmapUi() {
if (mapHeatmapRadius == null) { if (mapHeatmapRadius == null) {
return; return;
@@ -1284,6 +1494,7 @@ public class MapFragment extends Fragment {
)); ));
} }
updateGpsDistance(); updateGpsDistance();
updateRxQuality();
updateConnectionIcons(lastDevices, serverConnected); updateConnectionIcons(lastDevices, serverConnected);
checkHeatmapGpsFollow(); checkHeatmapGpsFollow();
@@ -1293,6 +1504,53 @@ public class MapFragment extends Fragment {
} }
} }
private void updateRxQuality() {
if (mapRxQuality == null) {
return;
}
Double quality = resolveRxQualityPercent();
if (quality != null) {
mapRxQuality.setVisibility(View.VISIBLE);
mapRxQuality.setText(getString(
R.string.map_rx_quality,
String.format(Locale.US, "%.0f", quality)));
} else {
mapRxQuality.setVisibility(View.VISIBLE);
mapRxQuality.setText(R.string.map_rx_quality_unknown);
}
}
private Double resolveRxQualityPercent() {
StatsExtractor.ExtractedStats localStats =
uploader != null ? uploader.getLastStats() : null;
if (localStats != null) {
RadioSnapshot localSnap = RadioSnapshot.fromExtracted(localStats);
if (StatsExtractor.ROLE_RX.equals(localSnap.role)
&& localSnap.rxQualityPercent != null) {
return localSnap.rxQualityPercent;
}
}
for (DeviceInfo d : lastDevices) {
if (!StatsExtractor.ROLE_RX.equals(d.role)) {
continue;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(d.meta, d.role, d.rssi);
if (snap.rxQualityPercent != null) {
return snap.rxQualityPercent;
}
}
if (peerStatsCache != null) {
PeerStatsCache.Snapshot push = peerStatsCache.get();
if (push != null && StatsExtractor.ROLE_RX.equals(push.role) && push.meta != null) {
RadioSnapshot snap = RadioSnapshot.fromMeta(push.meta, push.role, push.rssi);
if (snap.rxQualityPercent != null) {
return snap.rxQualityPercent;
}
}
}
return null;
}
private void updateGpsDistance() { private void updateGpsDistance() {
if (mapDistance == null) { if (mapDistance == null) {
return; return;
@@ -35,4 +35,12 @@ final class MapsforgeBitmaps {
paint.setStyle(org.mapsforge.core.graphics.Style.STROKE); paint.setStyle(org.mapsforge.core.graphics.Style.STROKE);
return paint; return paint;
} }
static org.mapsforge.core.graphics.Paint linePaint(int argb, float strokeWidth) {
int a = (argb >> 24) & 0xFF;
int r = (argb >> 16) & 0xFF;
int g = (argb >> 8) & 0xFF;
int b = argb & 0xFF;
return linePaint(AndroidGraphicFactory.INSTANCE.createColor(r, g, b, a), strokeWidth);
}
} }
@@ -92,14 +92,30 @@ public class RadioComparePanel extends LinearLayout {
addRow(table, "Пакет", fmtInt(tx.packet), fmtInt(rx.packet), "packet", changedTx, changedRx); addRow(table, "Пакет", fmtInt(tx.packet), fmtInt(rx.packet), "packet", changedTx, changedRx);
addRow(table, "Payload", str(tx.payload), str(rx.payload), "payload", changedTx, changedRx); addRow(table, "Payload", str(tx.payload), str(rx.payload), "payload", changedTx, changedRx);
addRow(table, "PER", fmtSuffix(tx.perPercent, " %"), fmtSuffix(rx.perPercent, " %"), "per", changedTx, changedRx); addRow(table, "PER", fmtSuffix(tx.perPercent, " %"), fmtSuffix(rx.perPercent, " %"), "per", changedTx, changedRx);
addRow(table, "Принято", fmtInt(tx.packetReceive), fmtInt(rx.packetReceive), "packetReceive", changedTx, changedRx);
addRow(table, "Всего", fmtInt(tx.packetTotal), fmtInt(rx.packetTotal), "packetTotal", changedTx, changedRx);
addRow(table, "Ошибки", fmtInt(tx.packetError), fmtInt(rx.packetError), "packetError", changedTx, changedRx);
addRow(table, "CRC err", fmtInt(tx.crcError), fmtInt(rx.crcError), "crcError", changedTx, changedRx);
addRow(table, "Preamble", fmtInt(tx.preambleDetected), fmtInt(rx.preambleDetected),
"preambleDetected", changedTx, changedRx);
addRow(table, "Header OK", fmtInt(tx.headerValid), fmtInt(rx.headerValid), "headerValid", changedTx, changedRx);
addRow(table, "TX spd", fmtSuffix(tx.txPktPerS, " p/s"), fmtSuffix(rx.txPktPerS, " p/s"), "txSpeed", changedTx, changedRx); addRow(table, "TX spd", fmtSuffix(tx.txPktPerS, " p/s"), fmtSuffix(rx.txPktPerS, " p/s"), "txSpeed", changedTx, changedRx);
addRow(table, "RX spd", fmtSuffix(tx.rxPktPerS, " p/s"), fmtSuffix(rx.rxPktPerS, " p/s"), "rxSpeed", changedTx, changedRx); addRow(table, "RX spd", fmtSuffix(tx.rxPktPerS, " p/s"), fmtSuffix(rx.rxPktPerS, " p/s"), "rxSpeed", changedTx, changedRx);
} else { } else {
addRow(table, "Роль", LoraStatsFormatter.roleLabel(tx.role), LoraStatsFormatter.roleLabel(rx.role), "role", changedTx, changedRx); addRow(table, "Роль", LoraStatsFormatter.roleLabel(tx.role), LoraStatsFormatter.roleLabel(rx.role), "role", changedTx, changedRx);
addRow(table, "Частота", fmtMhz(tx.frequencyMhz), fmtMhz(rx.frequencyMhz), "frequency", changedTx, changedRx); addRow(table, "Частота", fmtMhz(tx.frequencyMhz), fmtMhz(rx.frequencyMhz), "frequency", changedTx, changedRx);
addRow(table, "SF", fmtInt(tx.sf), fmtInt(rx.sf), "sf", changedTx, changedRx); addRow(table, "SF", fmtInt(tx.sf), fmtInt(rx.sf), "sf", changedTx, changedRx);
addRow(table, "BW", fmtSuffixInt(tx.bwKhz, " kHz"), fmtSuffixInt(rx.bwKhz, " kHz"), "bw", changedTx, changedRx); addRow(table, "BW", fmtBw(tx.bwKhz), fmtBw(rx.bwKhz), "bw", changedTx, changedRx);
addRow(table, "Мощн.", fmtDbm(tx.powerDbm), fmtDbm(rx.powerDbm), "power", changedTx, changedRx); addRow(table, "Мощн.", fmtDbm(tx.powerDbm), fmtDbm(rx.powerDbm), "power", changedTx, changedRx);
addRow(table, "Code Rate", str(tx.codeRate), str(rx.codeRate), "codeRate", changedTx, changedRx);
addRow(table, "Preamble", fmtInt(tx.preambleLength), fmtInt(rx.preambleLength), "preambleLength", changedTx, changedRx);
addRow(table, "LDR", str(tx.lowDataRateOpt), str(rx.lowDataRateOpt), "lowDataRateOpt", changedTx, changedRx);
addRow(table, "CRC", fmtCrc(tx.crcEnabled), fmtCrc(rx.crcEnabled), "crc", changedTx, changedRx);
addRow(table, "Payl.len", fmtSuffixInt(tx.payloadLengthBytes, " B"), fmtSuffixInt(rx.payloadLengthBytes, " B"),
"payloadLength", changedTx, changedRx);
addRow(table, "TX Timeout", fmtSuffix(tx.txTimeoutMs, " ms"), fmtSuffix(rx.txTimeoutMs, " ms"),
"txTimeout", changedTx, changedRx);
addRow(table, "On Air", fmtSuffix(tx.onAirMs, " ms"), fmtSuffix(rx.onAirMs, " ms"), "onAir", changedTx, changedRx);
} }
} }
@@ -143,24 +159,48 @@ public class RadioComparePanel extends LinearLayout {
String peerId, String peerId,
Set<String> changedLocal, Set<String> changedLocal,
Set<String> changedPeer Set<String> changedPeer
) {
bindByRole(
panel,
local,
peer,
localId,
peerId,
localId,
peerId,
changedLocal,
changedPeer
);
}
public static void bindByRole(
RadioComparePanel panel,
RadioSnapshot local,
RadioSnapshot peer,
String localId,
String peerId,
String localDisplayName,
String peerDisplayName,
Set<String> changedLocal,
Set<String> changedPeer
) { ) {
RadioSnapshot tx = local; RadioSnapshot tx = local;
RadioSnapshot rx = peer; RadioSnapshot rx = peer;
String txId = localId; String txName = localDisplayName != null ? localDisplayName : localId;
String rxId = peerId; String rxName = peerDisplayName != null ? peerDisplayName : peerId;
Set<String> chTx = changedLocal; Set<String> chTx = changedLocal;
Set<String> chRx = changedPeer; Set<String> chRx = changedPeer;
if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) { if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) {
tx = peer; tx = peer;
rx = local; rx = local;
txId = peerId; txName = peerDisplayName != null ? peerDisplayName : peerId;
rxId = localId; rxName = localDisplayName != null ? localDisplayName : localId;
chTx = changedPeer; chTx = changedPeer;
chRx = changedLocal; chRx = changedLocal;
} }
if (tx == null) tx = RadioSnapshot.empty(); if (tx == null) tx = RadioSnapshot.empty();
if (rx == null) rx = RadioSnapshot.empty(); if (rx == null) rx = RadioSnapshot.empty();
panel.bind(tx, rx, txId, rxId, chTx, chRx); panel.bind(tx, rx, txName, rxName, chTx, chRx);
} }
private static String str(String v) { private static String str(String v) {
@@ -183,6 +223,17 @@ public class RadioComparePanel extends LinearLayout {
return v != null ? v + suffix : ""; return v != null ? v + suffix : "";
} }
private static String fmtBw(Double v) {
return v != null ? String.format(Locale.US, "%.2f kHz", v) : "";
}
private static String fmtCrc(Boolean enabled) {
if (enabled == null) {
return "";
}
return enabled ? "On" : "Off";
}
private static String fmtSuffixInt(Integer v, String suffix) { private static String fmtSuffixInt(Integer v, String suffix) {
return v != null ? v + suffix : ""; return v != null ? v + suffix : "";
} }
@@ -15,6 +15,8 @@ import androidx.fragment.app.Fragment;
import com.google.android.material.switchmaterial.SwitchMaterial; import com.google.android.material.switchmaterial.SwitchMaterial;
import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputEditText;
import com.grigowashere.loratester.LoraApp; import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.LoraForegroundService;
import com.grigowashere.loratester.MainActivity;
import com.grigowashere.loratester.R; import com.grigowashere.loratester.R;
import com.grigowashere.loratester.SettingsRepository; import com.grigowashere.loratester.SettingsRepository;
import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.TelemetryUploader;
@@ -42,7 +44,9 @@ public class SettingsFragment extends Fragment {
TextInputEditText editPort = view.findViewById(R.id.editTelnetPort); TextInputEditText editPort = view.findViewById(R.id.editTelnetPort);
TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex); TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex);
TextInputEditText editRange = view.findViewById(R.id.editRangeRegex); TextInputEditText editRange = view.findViewById(R.id.editRangeRegex);
TextInputEditText editDeviceLabel = view.findViewById(R.id.editDeviceLabel);
SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet); SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet);
Button batteryBtn = view.findViewById(R.id.btnBatteryOptimization);
TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel); TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel);
Button save = view.findViewById(R.id.btnSaveSettings); Button save = view.findViewById(R.id.btnSaveSettings);
@@ -51,9 +55,21 @@ public class SettingsFragment extends Fragment {
editPort.setText(String.valueOf(settings.getTelnetPort())); editPort.setText(String.valueOf(settings.getTelnetPort()));
editRssi.setText(settings.getRssiRegex()); editRssi.setText(settings.getRssiRegex());
editRange.setText(settings.getRangeRegex()); editRange.setText(settings.getRangeRegex());
String savedLabel = settings.getDeviceLabel();
if (savedLabel != null) {
editDeviceLabel.setText(savedLabel);
}
switchTelnet.setChecked(settings.isTelnetEnabled()); switchTelnet.setChecked(settings.isTelnetEnabled());
deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId())); deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId()));
batteryBtn.setOnClickListener(v -> {
if (MainActivity.isIgnoringBatteryOptimizations(requireContext())) {
Toast.makeText(requireContext(), R.string.battery_optimization_done, Toast.LENGTH_SHORT).show();
return;
}
MainActivity.openBatteryOptimizationSettings(requireContext());
});
save.setOnClickListener(v -> { save.setOnClickListener(v -> {
settings.setServerUrl(textOf(editServer, SettingsRepository.DEFAULT_SERVER)); settings.setServerUrl(textOf(editServer, SettingsRepository.DEFAULT_SERVER));
settings.setTelnetHost(textOf(editHost, SettingsRepository.DEFAULT_TELNET_HOST)); settings.setTelnetHost(textOf(editHost, SettingsRepository.DEFAULT_TELNET_HOST));
@@ -64,13 +80,16 @@ public class SettingsFragment extends Fragment {
} }
settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX)); settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX));
settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX)); settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX));
settings.setDeviceLabel(textOf(editDeviceLabel, ""));
settings.setTelnetEnabled(switchTelnet.isChecked()); settings.setTelnetEnabled(switchTelnet.isChecked());
uploader.refreshApi(); uploader.refreshApi();
uploader.registerPresence();
if (switchTelnet.isChecked()) { if (switchTelnet.isChecked()) {
uploader.startTelnet(); uploader.startTelnet();
} else { } else {
uploader.stopTelnet(); uploader.stopTelnet();
} }
LoraForegroundService.ensureRunning(requireContext());
Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show(); Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show();
}); });
} }
@@ -23,11 +23,13 @@ import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo; import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.api.TelemetryHistoryItem; import com.grigowashere.loratester.api.TelemetryHistoryItem;
import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.model.RadioSnapshot; import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.telnet.StatsExtractor;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -43,6 +45,7 @@ public class StatsFragment extends Fragment {
private PeerStatsCache peerStatsCache; private PeerStatsCache peerStatsCache;
private TextView statsStatus; private TextView statsStatus;
private TextView statsPeerWarning; private TextView statsPeerWarning;
private TextView statsDistance;
private RadioComparePanel radioComparePanel; private RadioComparePanel radioComparePanel;
private RecyclerView statsHistoryList; private RecyclerView statsHistoryList;
private final HistoryAdapter historyAdapter = new HistoryAdapter(); private final HistoryAdapter historyAdapter = new HistoryAdapter();
@@ -54,6 +57,10 @@ public class StatsFragment extends Fragment {
private String cachedPeerId; private String cachedPeerId;
private String cachedPeerError; private String cachedPeerError;
private int cachedDeviceCount; private int cachedDeviceCount;
private String cachedSelfDisplayName;
private String cachedPeerDisplayName;
private DeviceInfo cachedTxDev;
private DeviceInfo cachedRxDev;
private final TelemetryUploader.StatsListener statsListener = stats -> postRender(); private final TelemetryUploader.StatsListener statsListener = stats -> postRender();
@@ -80,6 +87,7 @@ public class StatsFragment extends Fragment {
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
statsStatus = view.findViewById(R.id.statsStatus); statsStatus = view.findViewById(R.id.statsStatus);
statsPeerWarning = view.findViewById(R.id.statsPeerWarning); statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
statsDistance = view.findViewById(R.id.statsDistance);
radioComparePanel = view.findViewById(R.id.radioComparePanel); radioComparePanel = view.findViewById(R.id.radioComparePanel);
statsHistoryList = view.findViewById(R.id.statsHistoryList); statsHistoryList = view.findViewById(R.id.statsHistoryList);
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext())); statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
@@ -157,6 +165,7 @@ public class StatsFragment extends Fragment {
} }
statsStatus = null; statsStatus = null;
statsPeerWarning = null; statsPeerWarning = null;
statsDistance = null;
radioComparePanel = null; radioComparePanel = null;
statsHistoryList = null; statsHistoryList = null;
pollHelper = null; pollHelper = null;
@@ -183,13 +192,17 @@ public class StatsFragment extends Fragment {
String deviceId = uploader.getDeviceId(); String deviceId = uploader.getDeviceId();
statsStatus.setText(getString( statsStatus.setText(getString(
R.string.stats_status, R.string.stats_status,
deviceId, cachedSelfDisplayName != null ? cachedSelfDisplayName : deviceId,
uploader.isTelnetConnected() uploader.isTelnetConnected()
? getString(R.string.connected) : getString(R.string.disconnected) ? getString(R.string.connected) : getString(R.string.disconnected)
)); ));
executor.execute(() -> { executor.execute(() -> {
List<TelemetryHistoryItem> history = null; List<TelemetryHistoryItem> history = null;
DeviceInfo txDev = null;
DeviceInfo rxDev = null;
String selfName = deviceId;
String peerName = cachedPeerId;
try { try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices(); List<DeviceInfo> devices = uploader.getServerApi().getDevices();
cachedDeviceCount = devices.size(); cachedDeviceCount = devices.size();
@@ -200,12 +213,23 @@ public class StatsFragment extends Fragment {
DeviceInfo self = null; DeviceInfo self = null;
DeviceInfo peerDev = null; DeviceInfo peerDev = null;
for (DeviceInfo d : devices) { for (DeviceInfo d : devices) {
if (StatsExtractor.ROLE_TX.equals(d.role)) {
txDev = d;
} else if (StatsExtractor.ROLE_RX.equals(d.role)) {
rxDev = d;
}
if (deviceId.equals(d.device_id)) { if (deviceId.equals(d.device_id)) {
self = d; self = d;
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) { } else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
peerDev = d; peerDev = d;
} }
} }
selfName = DeviceNames.displayName(self, deviceId);
peerName = DeviceNames.displayName(peerDev, peer.peerId);
cachedSelfDisplayName = selfName;
cachedPeerDisplayName = peerName;
cachedTxDev = txDev;
cachedRxDev = rxDev;
StatsExtractor.ExtractedStats localStats = uploader.getLastStats(); StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
snapLocal = localStats != null snapLocal = localStats != null
@@ -264,10 +288,32 @@ public class StatsFragment extends Fragment {
snapPeer, snapPeer,
uploader.getDeviceId(), uploader.getDeviceId(),
cachedPeerId, cachedPeerId,
cachedSelfDisplayName,
cachedPeerDisplayName,
chLocal, chLocal,
chPeer chPeer
); );
updateStatsDistance();
prevLocal = snapLocal; prevLocal = snapLocal;
prevPeer = snapPeer; prevPeer = snapPeer;
} }
private void updateStatsDistance() {
if (statsDistance == null) {
return;
}
DeviceInfo tx = cachedTxDev;
DeviceInfo rx = cachedRxDev;
if (tx != null && rx != null
&& GeoUtils.isValidCoordinate(tx.lat, tx.lon)
&& GeoUtils.isValidCoordinate(rx.lat, rx.lon)) {
double dist = GeoUtils.haversineMeters(tx.lat, tx.lon, rx.lat, rx.lon);
statsDistance.setVisibility(View.VISIBLE);
statsDistance.setText(getString(
R.string.stats_gps_distance,
String.format(Locale.US, "%.0f", dist)));
} else {
statsDistance.setVisibility(View.GONE);
}
}
} }
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>
+32
View File
@@ -80,6 +80,24 @@
android:textColor="#00FF88" android:textColor="#00FF88"
android:textSize="9sp" android:textSize="9sp"
android:visibility="gone" /> android:visibility="gone" />
<TextView
android:id="@+id/mapRxQuality"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#4FC3F7"
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapTrackStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#FF9800"
android:textSize="9sp"
android:visibility="gone" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
@@ -222,6 +240,20 @@
android:textSize="10sp" /> android:textSize="10sp" />
</com.google.android.material.button.MaterialButtonToggleGroup> </com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/map_layer"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/mapBasemap"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -73,6 +73,19 @@
android:inputType="text" /> android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/device_display_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editDeviceLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchTelnet" android:id="@+id/switchTelnet"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -80,6 +93,20 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:text="@string/telnet_enabled" /> android:text="@string/telnet_enabled" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/battery_optimization_hint"
android:textSize="12sp" />
<Button
android:id="@+id/btnBatteryOptimization"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/battery_optimization" />
<TextView <TextView
android:id="@+id/deviceIdLabel" android:id="@+id/deviceIdLabel"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -24,6 +24,15 @@
android:textSize="12sp" android:textSize="12sp"
android:visibility="gone" /> android:visibility="gone" />
<TextView
android:id="@+id/statsDistance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#00FF88"
android:textSize="13sp"
android:visibility="gone" />
<com.grigowashere.loratester.ui.RadioComparePanel <com.grigowashere.loratester.ui.RadioComparePanel
android:id="@+id/radioComparePanel" android:id="@+id/radioComparePanel"
android:layout_width="match_parent" android:layout_width="match_parent"
+28
View File
@@ -17,6 +17,7 @@
<string name="range_regex">Range regex</string> <string name="range_regex">Range regex</string>
<string name="telnet_enabled">Подключить telnet</string> <string name="telnet_enabled">Подключить telnet</string>
<string name="device_id_label">ID устройства: %1$s</string> <string name="device_id_label">ID устройства: %1$s</string>
<string name="device_display_name">Имя на карте (realme, OPPO…)</string>
<string name="save">Сохранить</string> <string name="save">Сохранить</string>
<string name="saved">Сохранено</string> <string name="saved">Сохранено</string>
<string name="chat_hint">Сообщение…</string> <string name="chat_hint">Сообщение…</string>
@@ -36,6 +37,18 @@
<string name="map_network_online">онлайн</string> <string name="map_network_online">онлайн</string>
<string name="map_network_offline">офлайн (кэш)</string> <string name="map_network_offline">офлайн (кэш)</string>
<string name="track_need_network">Нужна сеть для начала трека</string> <string name="track_need_network">Нужна сеть для начала трека</string>
<string name="track_offline_started">Запись без сети — точки сохраняются на телефоне</string>
<string name="track_sync_pending">Трек сохранён локально — отправится при появлении сети</string>
<string name="track_sync_done">Трек #%1$d загружен (%2$d точек)</string>
<string name="track_status_online">Трекинг: %1$d точек</string>
<string name="track_status_offline">Трекинг: %1$d точек · буфер %2$d · офлайн</string>
<string name="track_offline_toast">Сеть пропала — точки сохраняются на устройстве (буфер %1$d). Без ограничения по времени, до ~500 тыс. точек.</string>
<string name="track_online_toast">Сеть восстановлена — отправка буфера (%1$d точек)</string>
<string name="map_track_status_online">Трек: %1$d точек</string>
<string name="map_track_status_offline">Трек: %1$d точек · буфер %2$d</string>
<string name="map_rx_quality">RX Quality: %1$s%%</string>
<string name="map_rx_quality_unknown">RX Quality: —</string>
<string name="stats_gps_distance">Расстояние TX↔RX: %1$s m</string>
<string name="upload_queue_pending">В очереди: %1$d</string> <string name="upload_queue_pending">В очереди: %1$d</string>
<string name="gps_waiting">GPS: ожидание фикса…</string> <string name="gps_waiting">GPS: ожидание фикса…</string>
<string name="stats_updated_at">обновлено %1$s</string> <string name="stats_updated_at">обновлено %1$s</string>
@@ -81,6 +94,9 @@
<string name="map_center_rx">RX</string> <string name="map_center_rx">RX</string>
<string name="map_center_both">Оба</string> <string name="map_center_both">Оба</string>
<string name="map_center_mode">Центр карты</string> <string name="map_center_mode">Центр карты</string>
<string name="map_layer">Слой карты</string>
<string name="map_layer_scheme">Схема</string>
<string name="map_layer_satellite">Спутник</string>
<string name="map_center_unavailable">Нет координат для выбранного режима</string> <string name="map_center_unavailable">Нет координат для выбранного режима</string>
<string name="map_tool_center">Центрировать карту</string> <string name="map_tool_center">Центрировать карту</string>
<string name="map_tool_track">Трекинг пути</string> <string name="map_tool_track">Трекинг пути</string>
@@ -109,4 +125,16 @@
<string name="map_heatmap_legend_level">уровень</string> <string name="map_heatmap_legend_level">уровень</string>
<string name="map_heatmap_legend_low">низина</string> <string name="map_heatmap_legend_low">низина</string>
<string name="chat_self_label">Вы</string> <string name="chat_self_label">Вы</string>
<string name="notification_channel_name">Фоновая работа</string>
<string name="notification_channel_desc">Telnet, GPS и запись трека при свёрнутом приложении</string>
<string name="notification_title">LoraTester активен</string>
<string name="notification_subtitle">Работа в фоне</string>
<string name="notification_track_recording">трек: %1$d точек</string>
<string name="notification_track_idle">трек: нет</string>
<string name="telnet_disabled_short">telnet: выкл</string>
<string name="background_location_rationale">Для GPS при выключенном экране разрешите геолокацию «Всегда»</string>
<string name="background_location_required">Нужен доступ к геолокации для трека и карты</string>
<string name="battery_optimization">Без ограничений батареи</string>
<string name="battery_optimization_hint">Рекомендуется на MIUI / ColorOS / EMUI для стабильного telnet</string>
<string name="battery_optimization_done">Ограничения батареи уже отключены</string>
</resources> </resources>
@@ -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));
}
}
@@ -54,11 +54,10 @@ public class LoraFrameExtractTest {
public void parsesAllLabeledLinesFromSendScreen() { public void parsesAllLabeledLinesFromSendScreen() {
StatsExtractor extractor = StatsExtractor.withDefaults(); StatsExtractor extractor = StatsExtractor.withDefaults();
StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND); StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND);
assertTrue(stats.metaJson.contains("\"fields\"")); assertTrue(stats.metaJson.contains("\"code_rate\":\"4/5\""));
assertTrue(stats.metaJson.contains("Frequency")); assertTrue(stats.metaJson.contains("\"spreading_factor\":12"));
assertTrue(stats.metaJson.contains("Spreading Factor")); assertTrue(stats.metaJson.contains("\"packet\":304"));
assertTrue(stats.metaJson.contains("Packet")); assertTrue(stats.metaJson.contains("Test TX!"));
assertTrue(stats.metaJson.contains("Payload"));
} }
@Test @Test
@@ -83,7 +82,8 @@ public class LoraFrameExtractTest {
assertEquals(StatsExtractor.ROLE_RX, stats.role); assertEquals(StatsExtractor.ROLE_RX, stats.role);
assertEquals(-78.0, stats.rssi, 0.01); assertEquals(-78.0, stats.rssi, 0.01);
assertEquals(10.5, stats.snrDb, 0.01); assertEquals(10.5, stats.snrDb, 0.01);
assertTrue(stats.metaJson.contains("\"fields\"")); assertTrue(stats.metaJson.contains("\"packet\":0"));
assertTrue(stats.metaJson.contains("\"rx_pkt_per_s\":0.45"));
} }
@Test @Test
@@ -96,6 +96,73 @@ public class LoraFrameExtractTest {
assertTrue(!stats.metaJson.contains("RX Quality")); assertTrue(!stats.metaJson.contains("RX Quality"));
} }
@Test
public void parsesRxCountersAndCrcErrors() {
StatsExtractor extractor = StatsExtractor.withDefaults();
String frame = """
RECEIVE
Frequency: 433500000 Hz
Power: 0 dBm
Spreading Factor: 5
Bandwidth: 7.81 kHz
Code Rate: 4/6
Preamble Length: 8
Low Data Rate Opt: Off
CRC: Off
Payload length: 32 byte
On Air: 427.14 ms, 2.34 pkt/c
Packet Number: 0
Payload: test
RSSI: -78
SNR: 10.5
RX Speed: 0.45 pkt/s, 120 bit/s
Packet Receive: 12
Packet Total: 100
Packet Error: 3
CRC Error: 2
PER: 3.00 %
Preamble Detected: 2
Header Valid: 1
RX Quality: 87 %
""";
StatsExtractor.ExtractedStats stats = extractor.extract(frame);
assertTrue(stats.metaJson.contains("\"crc_error\":2"));
assertTrue(stats.metaJson.contains("\"packet_error\":3"));
assertTrue(stats.metaJson.contains("\"packet_total\":100"));
assertTrue(stats.metaJson.contains("\"packet_receive\":12"));
assertTrue(stats.metaJson.contains("\"bandwidth_khz\":7.81"));
assertTrue(stats.metaJson.contains("\"crc_enabled\":false"));
assertTrue(stats.metaJson.contains("\"code_rate\":\"4/6\""));
}
@Test
public void parsesSendCrcAndConfig() {
StatsExtractor extractor = StatsExtractor.withDefaults();
String frame = """
SEND
Frequency: 433500000 Hz
Power: 0 dBm
Spreading Factor: 5
Bandwidth: 125 kHz
Code Rate: 4/5
Preamble Length: 8
Low Data Rate Opt: Off
CRC: On
Payload length: 32 byte
On Air: 23.10 ms, 43.28 pkt/c
Packet: 3816
Payload: Test TX!
TX Timeout: 0 ms
TX Speed: 32.06 pkt/s, 8206 bit/s
""";
StatsExtractor.ExtractedStats stats = extractor.extract(frame);
assertTrue(stats.metaJson.contains("\"packet\":3816"));
assertTrue(stats.metaJson.contains("\"crc_enabled\":true"));
assertTrue(stats.metaJson.contains("\"payload_length_bytes\":32"));
assertTrue(stats.metaJson.contains("\"tx_timeout_ms\":0"));
assertTrue(stats.metaJson.contains("\"preamble_length\":8"));
}
@Test @Test
public void splitsTwoFramesByReceiveHeaderWithoutEsc() { public void splitsTwoFramesByReceiveHeaderWithoutEsc() {
List<String> frames = new ArrayList<>(); List<String> frames = new ArrayList<>();
@@ -0,0 +1,45 @@
package com.grigowashere.loratester.track;
import org.junit.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class TrackPointQueueTest {
@Test
public void pendingPointRoundTrip() {
Map<String, Object> point = new HashMap<>();
point.put("ts", 1710000000.5);
point.put("lat", 59.93);
point.put("lon", 30.33);
point.put("altitude_gps", 11.0);
point.put("rssi", -90.0);
point.put("role", "RX");
point.put("meta", "{\"quality\":80}");
TrackPointQueue.PendingPoint pending = TrackPointQueue.PendingPoint.fromMap(point);
Map<String, Object> back = pending.toMap();
assertEquals(59.93, (Double) back.get("lat"), 1e-6);
assertEquals(30.33, (Double) back.get("lon"), 1e-6);
assertEquals(11.0, (Double) back.get("altitude_gps"), 1e-6);
assertEquals(-90.0, (Double) back.get("rssi"), 1e-6);
assertEquals("RX", back.get("role"));
assertEquals("{\"quality\":80}", back.get("meta"));
}
@Test
public void snapshotDefaults() {
TrackPointQueue.Snapshot snap = new TrackPointQueue.Snapshot();
assertEquals(-1, snap.trackId);
assertFalse(snap.recording);
assertEquals(0, snap.totalPoints);
assertTrue(snap.pending.isEmpty());
}
}
+12 -7
View File
@@ -22,7 +22,7 @@ python flask_app.py
| `LORATESTER_PORT` | `7634` | | `LORATESTER_PORT` | `7634` |
| `LORATESTER_DB` | `./loratester.db` | | `LORATESTER_DB` | `./loratester.db` |
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) | | `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) | | `LORATESTER_TRACK_POINTS_LIMIT` | `500000` (точек на один трек) |
| `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` | | `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` |
| `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) | | `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) | | `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
@@ -40,14 +40,17 @@ docker compose up -d --build
curl http://127.0.0.1:7634/api/health | jq curl http://127.0.0.1:7634/api/health | jq
``` ```
Ожидается `"elevation_ok": true` если локальный Open-Meteo доступен с хоста/контейнера. Ожидается `"elevation_ok": true` если OpenTopoData (основной) или Open-Meteo (fallback) доступны с хоста/контейнера.
Переопределить URL высот (`.env` рядом с `docker-compose.yml`): Переопределить URL высот (`.env` рядом с `docker-compose.yml`):
```env ```env
LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation LORATESTER_ELEVATION_OPENTOPO_URL=http://grigowashere.ru:5300/v1/srtm30
LORATESTER_ELEVATION_FALLBACK_URL=http://192.168.1.109:8085/v1/elevation
``` ```
`LORATESTER_ELEVATION_URL` — устаревший alias для fallback (Open-Meteo-compatible).
БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера). БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера).
## Деплой (lora.grigowashere.ru) ## Деплой (lora.grigowashere.ru)
@@ -63,7 +66,8 @@ docker compose up -d --build
cd /srv/storage/disk2/services/LoraTester/server cd /srv/storage/disk2/services/LoraTester/server
pip install -r requirements.txt pip install -r requirements.txt
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
export LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation export LORATESTER_ELEVATION_OPENTOPO_URL=http://grigowashere.ru:5300/v1/srtm30
export LORATESTER_ELEVATION_FALLBACK_URL=http://192.168.1.109:8085/v1/elevation
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634 uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
``` ```
@@ -95,11 +99,12 @@ curl http://127.0.0.1:7634/api/health
### Треки (запись с Android) ### Треки (запись с Android)
- `POST /api/tracks/sync``{device_id, track_id?, started_at?, points[], finish?}` — офлайн-догрузка точек и завершение трека
- `POST /api/tracks/start``{device_id}``{track_id}` - `POST /api/tracks/start``{device_id}``{track_id}`
- `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}` - `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
- `POST /api/tracks/{id}/finish` - `POST /api/tracks/{id}/finish`
- `GET /api/tracks?device_id=` - `GET /api/tracks?device_id=`
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через локальный Open-Meteo) - `GET /api/tracks/{id}` — метаданные + точки (высота terrain через OpenTopoData → Open-Meteo fallback)
### Команды (очередь на устройство) ### Команды (очередь на устройство)
@@ -110,9 +115,9 @@ curl http://127.0.0.1:7634/api/health
### Профиль высот (веб, треки) ### Профиль высот (веб, треки)
- `POST /api/elevation/profile``{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo) - `POST /api/elevation/profile``{points: [{lat, lon}], step_m?: 10}` → срез рельефа (OpenTopoData → Open-Meteo)
- `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку - `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку
- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность (прокси Open-Meteo) - `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность
- `GET /api/elevation/grid?lat=&lon=&radius_m=200&step_m=0` — сетка высот для хитмапы (100–500 m, step_m=0 авто) - `GET /api/elevation/grid?lat=&lon=&radius_m=200&step_m=0` — сетка высот для хитмапы (100–500 m, step_m=0 авто)
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at` - `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
- `GET /api/commands?to_device_id=&limit=` — история (веб) - `GET /api/commands?to_device_id=&limit=` — история (веб)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -2
View File
@@ -8,11 +8,20 @@ DATABASE_PATH = os.environ.get(
HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0") HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
PORT = int(os.environ.get("LORATESTER_PORT", "7634")) PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000")) TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000")) TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "500000"))
ELEVATION_API_URL = os.environ.get( ELEVATION_OPENTOPO_URL = os.environ.get(
"LORATESTER_ELEVATION_OPENTOPO_URL",
"http://grigowashere.ru:5300/v1/srtm30",
).rstrip("/")
ELEVATION_FALLBACK_URL = os.environ.get(
"LORATESTER_ELEVATION_FALLBACK_URL",
os.environ.get(
"LORATESTER_ELEVATION_URL", "LORATESTER_ELEVATION_URL",
"http://192.168.1.109:8085/v1/elevation", "http://192.168.1.109:8085/v1/elevation",
),
).rstrip("/") ).rstrip("/")
# Backward-compatible alias for Open-Meteo-compatible fallback API.
ELEVATION_API_URL = ELEVATION_FALLBACK_URL
ELEVATION_PROBE_TTL_SEC = float( ELEVATION_PROBE_TTL_SEC = float(
os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60") os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60")
) )
+235 -41
View File
@@ -1,4 +1,4 @@
"""Terrain elevation via self-hosted Open-Meteo-compatible API.""" """Terrain elevation: OpenTopoData primary, Open-Meteo-compatible fallback."""
from __future__ import annotations from __future__ import annotations
@@ -10,8 +10,9 @@ from typing import Any, Optional
import httpx import httpx
from .config import ( from .config import (
ELEVATION_API_URL,
ELEVATION_CONNECT_TIMEOUT, ELEVATION_CONNECT_TIMEOUT,
ELEVATION_FALLBACK_URL,
ELEVATION_OPENTOPO_URL,
ELEVATION_PROBE_TTL_SEC, ELEVATION_PROBE_TTL_SEC,
) )
@@ -21,8 +22,11 @@ _BATCH_SIZE = 100
_MAX_PROFILE_POINTS = 500 _MAX_PROFILE_POINTS = 500
_CACHE: dict[tuple[float, float], Optional[float]] = {} _CACHE: dict[tuple[float, float], Optional[float]] = {}
_probe_checked_at = 0.0 _probe_checked_at = 0.0
_probe_ok = False _probe_opentopo_ok = False
_probe_error: Optional[str] = None _probe_opentopo_error: Optional[str] = None
_probe_fallback_ok = False
_probe_fallback_error: Optional[str] = None
_last_fetch_source: Optional[str] = None
def _cache_key(lat: float, lon: float) -> tuple[float, float]: def _cache_key(lat: float, lon: float) -> tuple[float, float]:
@@ -42,9 +46,8 @@ def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def probe_elevation_api(force: bool = False) -> dict[str, Any]: def _probe_opentopodata(force: bool = False) -> dict[str, Any]:
"""Ping elevation service before batch requests (cached for TTL).""" global _probe_checked_at, _probe_opentopo_ok, _probe_opentopo_error
global _probe_checked_at, _probe_ok, _probe_error
now = time.monotonic() now = time.monotonic()
if ( if (
@@ -53,35 +56,129 @@ def probe_elevation_api(force: bool = False) -> dict[str, Any]:
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
): ):
return { return {
"ok": _probe_ok, "ok": _probe_opentopo_ok,
"url": ELEVATION_API_URL, "url": ELEVATION_OPENTOPO_URL,
"error": _probe_error, "error": _probe_opentopo_error,
} }
try: try:
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client: with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get( r = client.get(
ELEVATION_API_URL, ELEVATION_OPENTOPO_URL,
params={"locations": "0.000000,0.000000"},
)
r.raise_for_status()
data = r.json()
if data.get("status") != "OK":
raise ValueError(f"status={data.get('status')}")
results = data.get("results") or []
if not results or results[0].get("elevation") is None:
raise ValueError("response has no elevation values")
_probe_opentopo_ok = True
_probe_opentopo_error = None
logger.info("OpenTopoData ok: %s", ELEVATION_OPENTOPO_URL)
except Exception as e:
_probe_opentopo_ok = False
_probe_opentopo_error = str(e)
logger.warning(
"OpenTopoData unreachable %s: %s", ELEVATION_OPENTOPO_URL, e
)
return {
"ok": _probe_opentopo_ok,
"url": ELEVATION_OPENTOPO_URL,
"error": _probe_opentopo_error,
}
def _probe_fallback(force: bool = False) -> dict[str, Any]:
global _probe_checked_at, _probe_fallback_ok, _probe_fallback_error
now = time.monotonic()
if (
not force
and _probe_checked_at > 0
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
):
return {
"ok": _probe_fallback_ok,
"url": ELEVATION_FALLBACK_URL,
"error": _probe_fallback_error,
}
try:
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get(
ELEVATION_FALLBACK_URL,
params={"latitude": "0.000000", "longitude": "0.000000"}, params={"latitude": "0.000000", "longitude": "0.000000"},
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
if "elevation" not in data: if "elevation" not in data:
raise ValueError("response has no elevation field") raise ValueError("response has no elevation field")
_probe_checked_at = now _probe_fallback_ok = True
_probe_ok = True _probe_fallback_error = None
_probe_error = None logger.info("elevation fallback ok: %s", ELEVATION_FALLBACK_URL)
logger.info("elevation API ok: %s", ELEVATION_API_URL)
except Exception as e: except Exception as e:
_probe_checked_at = now _probe_fallback_ok = False
_probe_ok = False _probe_fallback_error = str(e)
_probe_error = str(e) logger.warning(
logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e) "elevation fallback unreachable %s: %s", ELEVATION_FALLBACK_URL, e
)
return { return {
"ok": _probe_ok, "ok": _probe_fallback_ok,
"url": ELEVATION_API_URL, "url": ELEVATION_FALLBACK_URL,
"error": _probe_error, "error": _probe_fallback_error,
}
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
"""Ping elevation providers before batch requests (cached for TTL)."""
global _probe_checked_at
now = time.monotonic()
if (
not force
and _probe_checked_at > 0
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
):
op = {
"ok": _probe_opentopo_ok,
"url": ELEVATION_OPENTOPO_URL,
"error": _probe_opentopo_error,
}
fb = {
"ok": _probe_fallback_ok,
"url": ELEVATION_FALLBACK_URL,
"error": _probe_fallback_error,
}
else:
op = _probe_opentopodata(force=True)
fb = _probe_fallback(force=True)
_probe_checked_at = now
ok = op["ok"] or fb["ok"]
if op["ok"]:
url = op["url"]
error = None
elif fb["ok"]:
url = fb["url"]
error = None
else:
url = ELEVATION_OPENTOPO_URL
error = f"opentopodata: {op['error']}; fallback: {fb['error']}"
return {
"ok": ok,
"url": url,
"error": error,
"opentopodata_ok": op["ok"],
"opentopodata_url": op["url"],
"opentopodata_error": op["error"],
"fallback_ok": fb["ok"],
"fallback_url": fb["url"],
"fallback_error": fb["error"],
} }
@@ -91,10 +188,40 @@ def elevation_status(force: bool = False) -> dict[str, Any]:
"elevation_ok": probe["ok"], "elevation_ok": probe["ok"],
"elevation_url": probe["url"], "elevation_url": probe["url"],
"elevation_error": probe["error"], "elevation_error": probe["error"],
"elevation_opentopodata_ok": probe.get("opentopodata_ok"),
"elevation_opentopodata_url": probe.get("opentopodata_url"),
"elevation_fallback_ok": probe.get("fallback_ok"),
"elevation_fallback_url": probe.get("fallback_url"),
} }
def _fetch_elevation_batch( def _fetch_opentopodata_batch(
batch_lat: list[float], batch_lon: list[float]
) -> list[Optional[float]]:
if not batch_lat:
return []
locations = "|".join(
f"{lat:.6f},{lon:.6f}" for lat, lon in zip(batch_lat, batch_lon)
)
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get(ELEVATION_OPENTOPO_URL, params={"locations": locations})
r.raise_for_status()
data = r.json()
if data.get("status") != "OK":
raise ValueError(f"OpenTopoData status={data.get('status')}")
results = data.get("results") or []
out: list[Optional[float]] = []
for j, item in enumerate(results):
if j >= len(batch_lat):
break
elev = item.get("elevation")
out.append(None if elev is None else float(elev))
while len(out) < len(batch_lat):
out.append(None)
return out
def _fetch_fallback_batch(
batch_lat: list[float], batch_lon: list[float] batch_lat: list[float], batch_lon: list[float]
) -> list[Optional[float]]: ) -> list[Optional[float]]:
if not batch_lat: if not batch_lat:
@@ -104,7 +231,7 @@ def _fetch_elevation_batch(
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon), "longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
} }
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client: with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get(ELEVATION_API_URL, params=params) r = client.get(ELEVATION_FALLBACK_URL, params=params)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
elevations = data.get("elevation") or [] elevations = data.get("elevation") or []
@@ -112,15 +239,81 @@ def _fetch_elevation_batch(
for j, elev in enumerate(elevations): for j, elev in enumerate(elevations):
if j >= len(batch_lat): if j >= len(batch_lat):
break break
if elev is None: out.append(None if elev is None else float(elev))
out.append(None)
else:
out.append(float(elev))
while len(out) < len(batch_lat): while len(out) < len(batch_lat):
out.append(None) out.append(None)
return out return out
def _fetch_batch_with_fallback(
batch_lat: list[float], batch_lon: list[float]
) -> list[Optional[float]]:
global _last_fetch_source
probe = probe_elevation_api()
op_ok = probe.get("opentopodata_ok", False)
fb_ok = probe.get("fallback_ok", False)
if not op_ok and not fb_ok:
return [None] * len(batch_lat)
out: list[Optional[float]] = [None] * len(batch_lat)
used_opentopo = False
used_fallback = False
if op_ok:
try:
out = _fetch_opentopodata_batch(batch_lat, batch_lon)
used_opentopo = any(v is not None for v in out)
except Exception as e:
logger.warning(
"OpenTopoData batch failed (%s points): %s", len(batch_lat), e
)
out = [None] * len(batch_lat)
missing_idx = [i for i, v in enumerate(out) if v is None]
if missing_idx and fb_ok:
miss_lat = [batch_lat[i] for i in missing_idx]
miss_lon = [batch_lon[i] for i in missing_idx]
try:
fb_vals = _fetch_fallback_batch(miss_lat, miss_lon)
for j, idx in enumerate(missing_idx):
out[idx] = fb_vals[j] if j < len(fb_vals) else None
if any(v is not None for v in fb_vals):
used_fallback = True
except Exception as e:
logger.warning(
"elevation fallback batch failed (%s points): %s",
len(miss_lat),
e,
)
if used_opentopo and used_fallback:
_last_fetch_source = "opentopodata+openmeteo"
elif used_opentopo:
_last_fetch_source = "opentopodata"
elif used_fallback:
_last_fetch_source = "openmeteo"
else:
_last_fetch_source = None
return out
def _active_elevation_url() -> str:
probe = probe_elevation_api()
if probe.get("opentopodata_ok"):
return ELEVATION_OPENTOPO_URL
if probe.get("fallback_ok"):
return ELEVATION_FALLBACK_URL
return ELEVATION_OPENTOPO_URL
def _active_api_source() -> str:
return _last_fetch_source or (
"opentopodata" if probe_elevation_api().get("opentopodata_ok") else "openmeteo"
)
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]: def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
vals = fetch_elevations_batch([lat], [lon]) vals = fetch_elevations_batch([lat], [lon])
return vals[0] if vals else None return vals[0] if vals else None
@@ -135,7 +328,7 @@ def fetch_elevations_batch(
probe = probe_elevation_api() probe = probe_elevation_api()
if not probe["ok"]: if not probe["ok"]:
logger.warning( logger.warning(
"skip elevation fetch: API unreachable (%s)", "skip elevation fetch: all providers unreachable (%s)",
probe.get("error"), probe.get("error"),
) )
return [None] * len(lats) return [None] * len(lats)
@@ -159,14 +352,15 @@ def fetch_elevations_batch(
batch_lat = pending_lat[start : start + _BATCH_SIZE] batch_lat = pending_lat[start : start + _BATCH_SIZE]
batch_lon = pending_lon[start : start + _BATCH_SIZE] batch_lon = pending_lon[start : start + _BATCH_SIZE]
try: try:
batch_vals = _fetch_elevation_batch(batch_lat, batch_lon) batch_vals = _fetch_batch_with_fallback(batch_lat, batch_lon)
for j, val in enumerate(batch_vals): for j, val in enumerate(batch_vals):
lat = batch_lat[j] lat = batch_lat[j]
lon = batch_lon[j] lon = batch_lon[j]
_CACHE[_cache_key(lat, lon)] = val _CACHE[_cache_key(lat, lon)] = val
out[batch_i[j]] = val out[batch_i[j]] = val
logger.info( logger.info(
"elevation ok: %s points, sample=%s", "elevation ok (%s): %s points, sample=%s",
_last_fetch_source,
len(batch_lat), len(batch_lat),
batch_vals[0] if batch_vals else None, batch_vals[0] if batch_vals else None,
) )
@@ -178,7 +372,7 @@ def fetch_elevations_batch(
) )
for j in range(len(batch_lat)): for j in range(len(batch_lat)):
try: try:
single = _fetch_elevation_batch( single = _fetch_batch_with_fallback(
[batch_lat[j]], [batch_lon[j]] [batch_lat[j]], [batch_lon[j]]
) )
val = single[0] if single else None val = single[0] if single else None
@@ -329,7 +523,7 @@ def build_elevation_profile(
"total_m": 0.0, "total_m": 0.0,
"api_source": "elevation", "api_source": "elevation",
"api_error": f"elevation API unreachable: {probe['error']}", "api_error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
lats = [s["lat"] for s in samples] lats = [s["lat"] for s in samples]
@@ -356,8 +550,8 @@ def build_elevation_profile(
"min_elevation_m": min(elev_vals) if elev_vals else None, "min_elevation_m": min(elev_vals) if elev_vals else None,
"max_elevation_m": max(elev_vals) if elev_vals else None, "max_elevation_m": max(elev_vals) if elev_vals else None,
"points": profile, "points": profile,
"api_source": "elevation", "api_source": _active_api_source(),
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
if not elev_vals: if not elev_vals:
result["api_error"] = "elevation API returned no values" result["api_error"] = "elevation API returned no values"
@@ -429,7 +623,7 @@ def build_elevation_grid(
return { return {
"ok": False, "ok": False,
"error": f"elevation API unreachable: {probe['error']}", "error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
radius_m = max(50.0, min(float(radius_m), 500.0)) radius_m = max(50.0, min(float(radius_m), 500.0))
@@ -481,8 +675,8 @@ def build_elevation_grid(
"points": points, "points": points,
"min_delta_m": round(min(deltas), 1), "min_delta_m": round(min(deltas), 1),
"max_delta_m": round(max(deltas), 1), "max_delta_m": round(max(deltas), 1),
"api_source": "elevation", "api_source": _active_api_source(),
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
@@ -499,7 +693,7 @@ def find_nearest_hill(
return { return {
"ok": False, "ok": False,
"error": f"elevation API unreachable: {probe['error']}", "error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
radius_m = max(500.0, min(float(radius_m), 15_000.0)) radius_m = max(500.0, min(float(radius_m), 15_000.0))
@@ -590,6 +784,6 @@ def find_nearest_hill(
"candidates": len(candidates), "candidates": len(candidates),
"radius_m": radius_m, "radius_m": radius_m,
"step_m": step_m, "step_m": step_m,
"api_source": "elevation", "api_source": _active_api_source(),
"elevation_url": ELEVATION_API_URL, "elevation_url": _active_elevation_url(),
} }
+1
View File
@@ -14,6 +14,7 @@ class TelemetryIn:
role: Optional[str] = None role: Optional[str] = None
ts: Optional[float] = None ts: Optional[float] = None
source: str = "android" source: str = "android"
device_label: Optional[str] = None
@dataclass @dataclass
+134 -13
View File
@@ -15,6 +15,8 @@ WEB_SENDER_ID = "web"
COMMAND_KINDS = frozenset({"at", "mode", "stats_push"}) COMMAND_KINDS = frozenset({"at", "mode", "stats_push"})
PAIRED_ONLINE_SEC = 30.0 PAIRED_ONLINE_SEC = 30.0
PAIRED_START_DELAY_SEC = 3.0 PAIRED_START_DELAY_SEC = 3.0
# Hide devices on map/UI after this many seconds without telemetry.
DEVICE_VISIBLE_SEC = 180.0
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -88,6 +90,19 @@ def record_telemetry(data: TelemetryIn) -> dict[str, Any]:
ts = data.ts if data.ts is not None else time.time() ts = data.ts if data.ts is not None else time.time()
lat, lon = _sanitize_coords(data.lat, data.lon) lat, lon = _sanitize_coords(data.lat, data.lon)
with _db() as conn: with _db() as conn:
phone_label = (data.device_label or "").strip()
if phone_label:
conn.execute(
"""
INSERT INTO devices (device_id, label, last_seen)
VALUES (?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET
last_seen = excluded.last_seen,
label = excluded.label
""",
(data.device_id, phone_label, ts),
)
else:
conn.execute( conn.execute(
""" """
INSERT INTO devices (device_id, label, last_seen) INSERT INTO devices (device_id, label, last_seen)
@@ -134,11 +149,30 @@ def _trim_telemetry(conn: sqlite3.Connection, device_id: str) -> None:
) )
def update_device_label(device_id: str, label: str) -> dict[str, Any]:
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
clean = (label or "").strip()
if not clean:
raise ValueError("label required")
ts = time.time()
with _db() as conn:
conn.execute(
"""
INSERT INTO devices (device_id, label, last_seen)
VALUES (?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET label = excluded.label
""",
(device_id, clean, ts),
)
return {"ok": True, "device_id": device_id, "label": clean}
def list_devices() -> list[dict[str, Any]]: def list_devices() -> list[dict[str, Any]]:
with _db() as conn: with _db() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT d.device_id, d.last_seen, SELECT d.device_id, d.label, d.last_seen,
t.lat, t.lon, t.rssi, t.range_m, t.raw_frame, t.meta, t.role, t.ts, t.source t.lat, t.lon, t.rssi, t.range_m, t.raw_frame, t.meta, t.role, t.ts, t.source
FROM devices d FROM devices d
INNER JOIN telemetry t ON t.id = ( INNER JOIN telemetry t ON t.id = (
@@ -150,8 +184,13 @@ def list_devices() -> list[dict[str, Any]]:
ORDER BY d.last_seen DESC ORDER BY d.last_seen DESC
""" """
).fetchall() ).fetchall()
cutoff = time.time() - DEVICE_VISIBLE_SEC
devices = [_row_to_device(r) for r in rows] devices = [_row_to_device(r) for r in rows]
return [d for d in devices if not _is_null_island(d)] return [
d
for d in devices
if not _is_null_island(d) and d.get("last_seen", 0) >= cutoff
]
def _is_null_island(device: dict[str, Any]) -> bool: def _is_null_island(device: dict[str, Any]) -> bool:
@@ -164,6 +203,7 @@ def _is_null_island(device: dict[str, Any]) -> bool:
def _row_to_device(row: sqlite3.Row) -> dict[str, Any]: def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
return { return {
"device_id": row["device_id"], "device_id": row["device_id"],
"label": row["label"] if "label" in row.keys() else None,
"last_seen": row["last_seen"], "last_seen": row["last_seen"],
"lat": row["lat"], "lat": row["lat"],
"lon": row["lon"], "lon": row["lon"],
@@ -313,6 +353,79 @@ def finish_track(track_id: int) -> dict[str, Any]:
return {"ok": True, "track_id": track_id, "ended_at": ts, "point_count": count} return {"ok": True, "track_id": track_id, "ended_at": ts, "point_count": count}
def sync_track(
device_id: str,
points: list[dict[str, Any]],
track_id: Optional[int] = None,
started_at: Optional[float] = None,
finish: bool = False,
label: Optional[str] = None,
) -> dict[str, Any]:
"""Upload buffered points after offline recording; optionally create track and finish."""
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
points = points or []
if track_id is not None:
with _db() as conn:
track = conn.execute(
"SELECT id, device_id, ended_at FROM tracks WHERE id = ?",
(track_id,),
).fetchone()
if not track:
raise ValueError(f"track {track_id} not found")
if track["device_id"] != device_id:
raise ValueError("device_id does not match track owner")
if track["ended_at"] is not None:
raise ValueError(f"track {track_id} already finished")
else:
if not points and not finish:
raise ValueError("points required when creating a new track")
ts = float(started_at) if started_at is not None else time.time()
with _db() as conn:
cur = conn.execute(
"""
INSERT INTO tracks (device_id, started_at, label)
VALUES (?, ?, ?)
""",
(device_id, ts, label),
)
track_id = int(cur.lastrowid)
added = 0
batch_size = 100
for i in range(0, len(points), batch_size):
chunk = points[i : i + batch_size]
if not chunk:
continue
result = add_track_points(track_id, chunk)
added += int(result.get("added") or 0)
finished = False
ended_at = None
point_count = added
if finish:
fin = finish_track(track_id)
finished = True
ended_at = fin.get("ended_at")
point_count = int(fin.get("point_count") or 0)
else:
with _db() as conn:
point_count = conn.execute(
"SELECT COUNT(*) FROM track_points WHERE track_id = ?",
(track_id,),
).fetchone()[0]
return {
"ok": True,
"track_id": track_id,
"added": added,
"point_count": point_count,
"finished": finished,
"ended_at": ended_at,
}
def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]: def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
limit = min(max(1, limit), 200) limit = min(max(1, limit), 200)
with _db() as conn: with _db() as conn:
@@ -321,13 +434,18 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
WHERE p.track_id = t.id AND p.role IS NOT NULL AND p.role != '' WHERE p.track_id = t.id AND p.role IS NOT NULL AND p.role != ''
ORDER BY p.ts DESC LIMIT 1) ORDER BY p.ts DESC LIMIT 1)
""" """
if device_id: track_cols = f"""
rows = conn.execute(
f"""
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label, SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
d.label AS device_label,
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count, (SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
{role_sub} AS role {role_sub} AS role
FROM tracks t FROM tracks t
LEFT JOIN devices d ON d.device_id = t.device_id
"""
if device_id:
rows = conn.execute(
f"""
{track_cols}
WHERE t.device_id = ? WHERE t.device_id = ?
ORDER BY t.started_at DESC ORDER BY t.started_at DESC
LIMIT ? LIMIT ?
@@ -337,10 +455,7 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
else: else:
rows = conn.execute( rows = conn.execute(
f""" f"""
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label, {track_cols}
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
{role_sub} AS role
FROM tracks t
ORDER BY t.started_at DESC ORDER BY t.started_at DESC
LIMIT ? LIMIT ?
""", """,
@@ -353,7 +468,11 @@ def get_track(track_id: int) -> dict[str, Any]:
with _db() as conn: with _db() as conn:
track = conn.execute( track = conn.execute(
""" """
SELECT id, device_id, started_at, ended_at, label FROM tracks WHERE id = ? SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
d.label AS device_label
FROM tracks t
LEFT JOIN devices d ON d.device_id = t.device_id
WHERE t.id = ?
""", """,
(track_id,), (track_id,),
).fetchone() ).fetchone()
@@ -394,9 +513,11 @@ def get_chat(since: float = 0.0, limit: int = 200) -> list[dict[str, Any]]:
with _db() as conn: with _db() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, device_id, text, ts FROM chat SELECT c.id, c.device_id, c.text, c.ts, d.label AS device_label
WHERE ts > ? FROM chat c
ORDER BY ts ASC LIMIT ? LEFT JOIN devices d ON d.device_id = c.device_id
WHERE c.ts > ?
ORDER BY c.ts ASC LIMIT ?
""", """,
(since, limit), (since, limit),
).fetchall() ).fetchall()
+5
View File
@@ -48,6 +48,10 @@ def merge_meta(body: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn: def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
meta, role = merge_meta(body) meta, role = merge_meta(body)
label = body.get("device_label") or body.get("label")
device_label = str(label).strip() if label else None
if device_label == "":
device_label = None
return TelemetryIn( return TelemetryIn(
device_id=str(body["device_id"]), device_id=str(body["device_id"]),
lat=_float_or_none(body.get("lat")), lat=_float_or_none(body.get("lat")),
@@ -58,4 +62,5 @@ def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
meta=meta, meta=meta,
role=role, role=role,
ts=_float_or_none(body.get("ts")), ts=_float_or_none(body.get("ts")),
device_label=device_label,
) )
+3 -1
View File
@@ -10,11 +10,13 @@ services:
environment: environment:
LORATESTER_DB: /data/loratester.db LORATESTER_DB: /data/loratester.db
LORATESTER_PORT: "7634" LORATESTER_PORT: "7634"
LORATESTER_ELEVATION_OPENTOPO_URL: ${LORATESTER_ELEVATION_OPENTOPO_URL:-http://grigowashere.ru:5300/v1/srtm30}
LORATESTER_ELEVATION_FALLBACK_URL: ${LORATESTER_ELEVATION_FALLBACK_URL:-http://192.168.1.109:8085/v1/elevation}
LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation} LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation}
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60} LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8} LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000} LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000}
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-10000} LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-500000}
volumes: volumes:
loratester-data: loratester-data:
+43
View File
@@ -31,6 +31,7 @@ storage.init_db()
class TelemetryBody(BaseModel): class TelemetryBody(BaseModel):
device_id: str device_id: str
device_label: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
rssi: Optional[float] = None rssi: Optional[float] = None
@@ -53,6 +54,10 @@ class TrackStartBody(BaseModel):
label: Optional[str] = None label: Optional[str] = None
class DeviceLabelBody(BaseModel):
label: str
class TrackPoint(BaseModel): class TrackPoint(BaseModel):
ts: Optional[float] = None ts: Optional[float] = None
lat: float lat: float
@@ -67,6 +72,15 @@ class TrackPointsBody(BaseModel):
points: list[TrackPoint] = Field(default_factory=list) points: list[TrackPoint] = Field(default_factory=list)
class TrackSyncBody(BaseModel):
device_id: str
track_id: Optional[int] = None
started_at: Optional[float] = None
points: list[TrackPoint] = Field(default_factory=list)
finish: bool = False
label: Optional[str] = None
class CommandBody(BaseModel): class CommandBody(BaseModel):
from_device_id: str from_device_id: str
to_device_id: str to_device_id: str
@@ -120,6 +134,14 @@ def get_devices():
return storage.list_devices() return storage.list_devices()
@app.patch("/api/devices/{device_id}/label")
def patch_device_label(device_id: str, body: DeviceLabelBody):
try:
return storage.update_device_label(device_id, body.label)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/telemetry") @app.get("/api/telemetry")
def get_telemetry_history( def get_telemetry_history(
device_id: Optional[str] = None, device_id: Optional[str] = None,
@@ -180,6 +202,26 @@ def tracks_finish(
raise HTTPException(400, detail=str(e)) from e raise HTTPException(400, detail=str(e)) from e
@app.post("/api/tracks/sync")
def tracks_sync(
body: TrackSyncBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
points = [p.model_dump(exclude_none=True) for p in body.points]
return storage.sync_track(
body.device_id,
points,
track_id=body.track_id,
started_at=body.started_at,
finish=body.finish,
label=body.label,
)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/tracks") @app.get("/api/tracks")
def tracks_list( def tracks_list(
device_id: Optional[str] = None, device_id: Optional[str] = None,
@@ -366,6 +408,7 @@ def health():
return { return {
"ok": status["db_ok"], "ok": status["db_ok"],
"ts": time.time(), "ts": time.time(),
"api_build": "2026-06-19a",
**status, **status,
**elevation_status(), **elevation_status(),
} }
+945 -158
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
/** RX quality vs distance chart for TX/RX track compare. */
(function (global) {
'use strict';
function drawQualityDistChart(canvas, samples, qualityColor, highlightDist) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.clientWidth || 280;
const h = canvas.clientHeight || 140;
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, w, h);
if (!samples?.length) {
ctx.fillStyle = '#888';
ctx.font = '11px system-ui';
ctx.fillText('нет данных качества', 12, h / 2);
return;
}
const dists = samples.map(s => s.distM);
const minD = Math.min(...dists);
const maxD = Math.max(...dists);
const span = Math.max(maxD - minD, 1);
const binCount = Math.min(20, Math.max(5, Math.ceil(span / 10)));
const binW = span / binCount;
const bins = Array.from({ length: binCount }, (_, i) => ({
min: minD + i * binW,
max: minD + (i + 1) * binW,
qualities: [],
}));
for (const s of samples) {
let idx = Math.floor((s.distM - minD) / binW);
if (idx >= binCount) idx = binCount - 1;
if (idx < 0) idx = 0;
bins[idx].qualities.push(s.quality);
}
const margin = { l: 36, r: 8, t: 16, b: 22 };
const plotW = w - margin.l - margin.r;
const plotH = h - margin.t - margin.b;
ctx.strokeStyle = '#333';
ctx.beginPath();
ctx.moveTo(margin.l, margin.t);
ctx.lineTo(margin.l, margin.t + plotH);
ctx.lineTo(margin.l + plotW, margin.t + plotH);
ctx.stroke();
ctx.fillStyle = '#888';
ctx.font = '9px system-ui';
ctx.fillText('0%', 2, margin.t + plotH);
ctx.fillText('100%', 2, margin.t + 8);
ctx.fillText(`${Math.round(minD)}m`, margin.l, h - 2);
ctx.fillText(`${Math.round(maxD)}m`, margin.l + plotW - 24, h - 2);
ctx.fillStyle = '#ccc';
ctx.font = '10px system-ui';
ctx.fillText('RX Quality vs расстояние', margin.l, margin.t - 4);
const barW = plotW / binCount * 0.75;
bins.forEach((b, i) => {
if (!b.qualities.length) return;
const avg = b.qualities.reduce((a, v) => a + v, 0) / b.qualities.length;
const cx = margin.l + (i + 0.5) / binCount * plotW;
const barH = (avg / 100) * plotH;
const x = cx - barW / 2;
const y = margin.t + plotH - barH;
const col = qualityColor ? qualityColor(avg) : '#888';
const highlight = highlightDist != null
&& highlightDist >= b.min && highlightDist < b.max;
ctx.fillStyle = col;
ctx.globalAlpha = highlight ? 1 : 0.75;
ctx.fillRect(x, y, barW, barH);
ctx.globalAlpha = 1;
if (highlight) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.strokeRect(x, y, barW, barH);
}
});
ctx.fillStyle = 'rgba(255,255,255,0.25)';
samples.forEach(s => {
const x = margin.l + ((s.distM - minD) / span) * plotW;
const y = margin.t + plotH - (s.quality / 100) * plotH;
ctx.beginPath();
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
ctx.fill();
});
}
global.QualityViz = {
drawQualityDistChart,
};
})(typeof window !== 'undefined' ? window : globalThis);
+68 -10
View File
@@ -5,7 +5,10 @@
const KNOWN_LABELS = new Set([ const KNOWN_LABELS = new Set([
'send', 'receive', 'frequency', 'power', 'rssi', 'snr', 'send', 'receive', 'frequency', 'power', 'rssi', 'snr',
'spreading factor', 'bandwidth', 'packet', 'packet number', 'payload', 'spreading factor', 'bandwidth', 'packet', 'packet number', 'payload',
'on air', 'tx speed', 'rx speed', 'per', 'rx quality' 'packet receive', 'packet total', 'packet error', 'crc error',
'preamble detected', 'header valid',
'on air', 'tx speed', 'rx speed', 'per', 'rx quality',
'code rate', 'preamble length', 'low data rate', 'crc', 'payload length', 'tx timeout'
]); ]);
function roleLabel(role) { function roleLabel(role) {
@@ -22,6 +25,15 @@
return false; return false;
} }
function fmtCrc(enabled) {
if (enabled == null) return '—';
return enabled ? 'On' : 'Off';
}
function fmtBw(khz) {
return khz != null ? `${Number(khz).toFixed(2)} kHz` : '—';
}
function parseRadioSnapshot(meta, roleFallback, rssiFallback) { function parseRadioSnapshot(meta, roleFallback, rssiFallback) {
const snap = { const snap = {
role: roleFallback || null, role: roleFallback || null,
@@ -39,6 +51,18 @@
rxPktPerS: null, rxPktPerS: null,
perPercent: null, perPercent: null,
rxQualityPercent: null, rxQualityPercent: null,
codeRate: null,
preambleLength: null,
lowDataRateOpt: null,
crcEnabled: null,
payloadLengthBytes: null,
txTimeoutMs: null,
packetReceive: null,
packetTotal: null,
packetError: null,
crcError: null,
preambleDetected: null,
headerValid: null,
extraFields: {} extraFields: {}
}; };
if (!meta) return snap; if (!meta) return snap;
@@ -61,6 +85,19 @@
if (o.rx_pkt_per_s != null) snap.rxPktPerS = Number(o.rx_pkt_per_s); if (o.rx_pkt_per_s != null) snap.rxPktPerS = Number(o.rx_pkt_per_s);
if (o.per_percent != null) snap.perPercent = Number(o.per_percent); if (o.per_percent != null) snap.perPercent = Number(o.per_percent);
if (o.rx_quality_percent != null) snap.rxQualityPercent = Number(o.rx_quality_percent); if (o.rx_quality_percent != null) snap.rxQualityPercent = Number(o.rx_quality_percent);
if (o.stats_at != null) snap.statsAt = Number(o.stats_at);
if (o.code_rate != null) snap.codeRate = String(o.code_rate);
if (o.preamble_length != null) snap.preambleLength = Number(o.preamble_length);
if (o.low_data_rate_opt != null) snap.lowDataRateOpt = String(o.low_data_rate_opt);
if (o.crc_enabled != null) snap.crcEnabled = Boolean(o.crc_enabled);
if (o.payload_length_bytes != null) snap.payloadLengthBytes = Number(o.payload_length_bytes);
if (o.tx_timeout_ms != null) snap.txTimeoutMs = Number(o.tx_timeout_ms);
if (o.packet_receive != null) snap.packetReceive = Number(o.packet_receive);
if (o.packet_total != null) snap.packetTotal = Number(o.packet_total);
if (o.packet_error != null) snap.packetError = Number(o.packet_error);
if (o.crc_error != null) snap.crcError = Number(o.crc_error);
if (o.preamble_detected != null) snap.preambleDetected = Number(o.preamble_detected);
if (o.header_valid != null) snap.headerValid = Number(o.header_valid);
if (o.fields && typeof o.fields === 'object') { if (o.fields && typeof o.fields === 'object') {
for (const [k, v] of Object.entries(o.fields)) { for (const [k, v] of Object.entries(o.fields)) {
if (!isKnownLabel(k)) snap.extraFields[k] = String(v); if (!isKnownLabel(k)) snap.extraFields[k] = String(v);
@@ -77,12 +114,17 @@
function diffSnapshots(a, b) { function diffSnapshots(a, b) {
const changed = new Set(); const changed = new Set();
if (!a || !b) return changed; if (!a || !b) return changed;
const keys = ['role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent', const keys = ['gps', 'packetTime', 'role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent',
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm']; 'packetReceive', 'packetTotal', 'packetError', 'crcError', 'preambleDetected', 'headerValid',
const map = { role: 'role', rssiDbm: 'rssi', snrDb: 'snr', rxQualityPercent: 'rxQuality', 'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm', 'codeRate', 'crcEnabled'];
packet: 'packet', const map = {
payload: 'payload', perPercent: 'per', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed', gps: 'gps', packetTime: 'packetTime', role: 'role', rssiDbm: 'rssi', snrDb: 'snr',
frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power' }; rxQualityPercent: 'rxQuality', packet: 'packet', payload: 'payload', perPercent: 'per',
packetReceive: 'packetReceive', packetTotal: 'packetTotal', packetError: 'packetError',
crcError: 'crcError', preambleDetected: 'preambleDetected', headerValid: 'headerValid',
txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed',
frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power', codeRate: 'codeRate', crcEnabled: 'crc'
};
for (const k of keys) { for (const k of keys) {
if (a[k] !== b[k] && !(a[k] == null && b[k] == null)) changed.add(map[k]); if (a[k] !== b[k] && !(a[k] == null && b[k] == null)) changed.add(map[k]);
} }
@@ -90,12 +132,20 @@
} }
const DYNAMIC_ROWS = [ const DYNAMIC_ROWS = [
{ key: 'gps', label: 'GPS', fmt: s => s.gps || '—' },
{ key: 'packetTime', label: 'Время пакета', fmt: s => s.packetTime || '—' },
{ key: 'rssi', label: 'RSSI', fmt: s => s.rssiDbm != null ? `${s.rssiDbm} dBm` : '—' }, { key: 'rssi', label: 'RSSI', fmt: s => s.rssiDbm != null ? `${s.rssiDbm} dBm` : '—' },
{ key: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' }, { key: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' },
{ key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' }, { key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' },
{ key: 'packet', label: 'Пакет', fmt: s => s.packet != null ? String(s.packet) : '—' }, { key: 'packet', label: 'Пакет', fmt: s => s.packet != null ? String(s.packet) : '—' },
{ key: 'payload', label: 'Payload', fmt: s => s.payload || '—' }, { key: 'payload', label: 'Payload', fmt: s => s.payload || '—' },
{ key: 'per', label: 'PER', fmt: s => s.perPercent != null ? `${s.perPercent} %` : '—' }, { key: 'per', label: 'PER', fmt: s => s.perPercent != null ? `${s.perPercent} %` : '—' },
{ key: 'packetReceive', label: 'Принято', fmt: s => s.packetReceive != null ? String(s.packetReceive) : '—' },
{ key: 'packetTotal', label: 'Всего', fmt: s => s.packetTotal != null ? String(s.packetTotal) : '—' },
{ key: 'packetError', label: 'Ошибки', fmt: s => s.packetError != null ? String(s.packetError) : '—' },
{ key: 'crcError', label: 'CRC err', fmt: s => s.crcError != null ? String(s.crcError) : '—' },
{ key: 'preambleDetected', label: 'Preamble', fmt: s => s.preambleDetected != null ? String(s.preambleDetected) : '—' },
{ key: 'headerValid', label: 'Header OK', fmt: s => s.headerValid != null ? String(s.headerValid) : '—' },
{ key: 'txSpeed', label: 'TX Speed', fmt: s => s.txPktPerS != null ? `${s.txPktPerS} pkt/s` : '—' }, { key: 'txSpeed', label: 'TX Speed', fmt: s => s.txPktPerS != null ? `${s.txPktPerS} pkt/s` : '—' },
{ key: 'rxSpeed', label: 'RX Speed', fmt: s => s.rxPktPerS != null ? `${s.rxPktPerS} pkt/s` : '—' } { key: 'rxSpeed', label: 'RX Speed', fmt: s => s.rxPktPerS != null ? `${s.rxPktPerS} pkt/s` : '—' }
]; ];
@@ -104,8 +154,14 @@
{ key: 'role', label: 'Роль', fmt: s => roleLabel(s.role) }, { key: 'role', label: 'Роль', fmt: s => roleLabel(s.role) },
{ key: 'frequency', label: 'Частота', fmt: s => s.frequencyMhz != null ? `${s.frequencyMhz.toFixed(3)} MHz` : '—' }, { key: 'frequency', label: 'Частота', fmt: s => s.frequencyMhz != null ? `${s.frequencyMhz.toFixed(3)} MHz` : '—' },
{ key: 'sf', label: 'SF', fmt: s => s.sf != null ? String(s.sf) : '—' }, { key: 'sf', label: 'SF', fmt: s => s.sf != null ? String(s.sf) : '—' },
{ key: 'bw', label: 'BW', fmt: s => s.bwKhz != null ? `${s.bwKhz} kHz` : '—' }, { key: 'bw', label: 'BW', fmt: s => fmtBw(s.bwKhz) },
{ key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' }, { key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' },
{ key: 'codeRate', label: 'Code Rate', fmt: s => s.codeRate || '—' },
{ key: 'preambleLength', label: 'Preamble', fmt: s => s.preambleLength != null ? String(s.preambleLength) : '—' },
{ key: 'lowDataRateOpt', label: 'LDR', fmt: s => s.lowDataRateOpt || '—' },
{ key: 'crc', label: 'CRC', fmt: s => fmtCrc(s.crcEnabled) },
{ key: 'payloadLength', label: 'Payl.len', fmt: s => s.payloadLengthBytes != null ? `${s.payloadLengthBytes} B` : '—' },
{ key: 'txTimeout', label: 'TX Timeout', fmt: s => s.txTimeoutMs != null ? `${s.txTimeoutMs} ms` : '—' },
{ key: 'onAir', label: 'On Air', fmt: s => s.onAirMs != null ? `${s.onAirMs} ms` : '—' } { key: 'onAir', label: 'On Air', fmt: s => s.onAirMs != null ? `${s.onAirMs} ms` : '—' }
]; ];
@@ -116,8 +172,10 @@
function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) { function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) {
let html = '<div class="radio-compare-grid">'; let html = '<div class="radio-compare-grid">';
html += `<div class="radio-compare-head"><span class="legend-tx">TX</span> ${escapeHtml(txId || '—')}`; html += '<div class="radio-compare-head">';
html += `<span class="legend-rx">RX</span> ${escapeHtml(rxId || '—')}</div>`; html += `<span><span class="legend-tx">TX</span> ${escapeHtml(txId || '—')}</span>`;
html += `<span><span class="legend-rx">RX</span> ${escapeHtml(rxId || '—')}</span>`;
html += '</div>';
for (const row of DYNAMIC_ROWS) { for (const row of DYNAMIC_ROWS) {
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : ''; const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : ''; const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
+110 -8
View File
@@ -23,6 +23,16 @@ class _FakeClient:
return False return False
def get(self, url, params=None): def get(self, url, params=None):
params = params or {}
if "locations" in params:
locs = params["locations"].split("|")
return _FakeResponse({
"status": "OK",
"results": [
{"elevation": 11.0 + i, "location": {"lat": 0, "lng": 0}}
for i, _ in enumerate(locs)
],
})
return _FakeResponse({"elevation": [152.0]}) return _FakeResponse({"elevation": [152.0]})
@@ -34,13 +44,20 @@ def test_probe_elevation_api_ok(monkeypatch):
assert status["ok"] is True assert status["ok"] is True
assert status["error"] is None assert status["error"] is None
assert status["opentopodata_ok"] is True
def test_fetch_skips_when_unreachable(monkeypatch): def test_fetch_skips_when_unreachable(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
"probe_elevation_api", "probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"}, lambda force=False: {
"ok": False,
"url": elev.ELEVATION_OPENTOPO_URL,
"error": "down",
"opentopodata_ok": False,
"fallback_ok": False,
},
) )
vals = elev.fetch_elevations_batch([55.75], [37.62]) vals = elev.fetch_elevations_batch([55.75], [37.62])
@@ -52,7 +69,13 @@ def test_build_profile_reports_unreachable(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
"probe_elevation_api", "probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"}, lambda force=False: {
"ok": False,
"url": elev.ELEVATION_OPENTOPO_URL,
"error": "down",
"opentopodata_ok": False,
"fallback_ok": False,
},
) )
profile = elev.build_elevation_profile( profile = elev.build_elevation_profile(
@@ -76,7 +99,16 @@ def test_resample_track_path_count_even_spacing():
def test_build_profile_target_points(monkeypatch): def test_build_profile_target_points(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0) monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None}) monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
"fetch_elevations_batch", "fetch_elevations_batch",
@@ -96,7 +128,13 @@ def test_find_nearest_hill_unreachable(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
"probe_elevation_api", "probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"}, lambda force=False: {
"ok": False,
"url": elev.ELEVATION_OPENTOPO_URL,
"error": "down",
"opentopodata_ok": False,
"fallback_ok": False,
},
) )
result = elev.find_nearest_hill(55.75, 37.62) result = elev.find_nearest_hill(55.75, 37.62)
assert result["ok"] is False assert result["ok"] is False
@@ -104,7 +142,16 @@ def test_find_nearest_hill_unreachable(monkeypatch):
def test_find_nearest_hill_picks_nearest_peak(monkeypatch): def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0) monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None}) monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
def fake_batch(lats, lons): def fake_batch(lats, lons):
out = [] out = []
@@ -125,7 +172,16 @@ def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
def test_build_elevation_grid_delta(monkeypatch): def test_build_elevation_grid_delta(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0) monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None}) monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
def fake_batch(lats, lons): def fake_batch(lats, lons):
return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(lats, lons)] return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(lats, lons)]
@@ -143,7 +199,16 @@ def test_build_elevation_grid_delta(monkeypatch):
def test_build_elevation_grid_fine_step_small_radius(monkeypatch): def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0) monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None}) monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 120.0) monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 120.0)
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
@@ -159,7 +224,16 @@ def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
def test_build_elevation_grid_limits_points(monkeypatch): def test_build_elevation_grid_limits_points(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0) monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None}) monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 50.0) monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 50.0)
monkeypatch.setattr( monkeypatch.setattr(
elev, elev,
@@ -170,3 +244,31 @@ def test_build_elevation_grid_limits_points(monkeypatch):
step = elev._resolve_grid_step(55.75, 37.62, 500.0, 5.0) step = elev._resolve_grid_step(55.75, 37.62, 500.0, 5.0)
cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step) cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step)
assert len(cells) <= elev._MAX_GRID_POINTS assert len(cells) <= elev._MAX_GRID_POINTS
def test_fetch_uses_fallback_when_opentopo_missing(monkeypatch):
monkeypatch.setattr(elev, "_CACHE", {})
monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {
"ok": True,
"error": None,
"opentopodata_ok": True,
"fallback_ok": True,
},
)
def fake_opentopo(batch_lat, batch_lon):
return [None] * len(batch_lat)
def fake_fallback(batch_lat, batch_lon):
return [42.0] * len(batch_lat)
monkeypatch.setattr(elev, "_fetch_opentopodata_batch", fake_opentopo)
monkeypatch.setattr(elev, "_fetch_fallback_batch", fake_fallback)
vals = elev.fetch_elevations_batch([55.75], [37.62])
assert vals == [42.0]
assert elev._last_fetch_source == "openmeteo"
+44
View File
@@ -45,6 +45,50 @@ def test_old_telemetry_without_meta_gets_migrated(temp_db):
conn.close() conn.close()
def test_sync_track_offline_upload(temp_db, monkeypatch):
storage.init_db()
monkeypatch.setattr(storage, "fetch_elevation_m", lambda lat, lon: 100.0)
start = storage.start_track("android-12345678")
tid = start["track_id"]
result = storage.sync_track(
"android-12345678",
[
{"ts": 1.0, "lat": 55.75, "lon": 37.62, "role": "TX"},
{"ts": 2.0, "lat": 55.751, "lon": 37.621, "role": "TX"},
],
track_id=tid,
finish=True,
)
assert result["added"] == 2
assert result["finished"] is True
assert result["point_count"] == 2
track = storage.get_track(tid)
assert len(track["points"]) == 2
assert track["ended_at"] is not None
def test_sync_track_create_offline(temp_db, monkeypatch):
storage.init_db()
monkeypatch.setattr(storage, "fetch_elevation_m", lambda lat, lon: 50.0)
result = storage.sync_track(
"android-abcdef01",
[
{"ts": 10.0, "lat": 59.93, "lon": 30.33, "role": "RX"},
],
track_id=None,
started_at=10.0,
finish=True,
)
assert result["track_id"] > 0
assert result["point_count"] == 1
track = storage.get_track(result["track_id"])
assert track["started_at"] == 10.0
def test_tracks_crud(temp_db): def test_tracks_crud(temp_db):
storage.init_db() storage.init_db()
start = storage.start_track("android-12345678") start = storage.start_track("android-12345678")