generated from Grigo/AndroidTemplate
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4842d4b29 | |||
| 8812cf9b40 | |||
| 4891933879 | |||
| f4ef87705c | |||
| 920a839197 | |||
| 40a1ccab1e | |||
| e71b6eed2f | |||
| dbef86d2c9 | |||
| 6b34e75f35 | |||
| 0e1fa15a2f | |||
| 64607def4a | |||
| 3399e81447 | |||
| 0571291b69 |
@@ -6,6 +6,12 @@
|
||||
<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_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" />
|
||||
|
||||
<application
|
||||
@@ -30,6 +36,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".LoraForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location|dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -18,6 +18,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class CommandPoller {
|
||||
@@ -32,6 +34,11 @@ public class CommandPoller {
|
||||
private final TrackRecorder trackRecorder;
|
||||
private final PeerStatsCache peerStatsCache;
|
||||
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 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
|
||||
public void onError(String message) {
|
||||
Log.w(TAG, "track: " + message);
|
||||
@@ -75,34 +91,28 @@ public class CommandPoller {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
scheduleCommandPoll();
|
||||
schedulePairedPoll();
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::pollCommandsSafe, 0, COMMAND_POLL_MS, TimeUnit.MILLISECONDS);
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::pollPairedSafe, 0, PAIRED_POLL_MS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running.set(false);
|
||||
}
|
||||
|
||||
private void scheduleCommandPoll() {
|
||||
executor.execute(() -> {
|
||||
if (running.get()) {
|
||||
pollCommands();
|
||||
}
|
||||
if (running.get()) {
|
||||
mainHandler.postDelayed(this::scheduleCommandPoll, COMMAND_POLL_MS);
|
||||
}
|
||||
});
|
||||
private void pollCommandsSafe() {
|
||||
if (!running.get()) {
|
||||
return;
|
||||
}
|
||||
pollCommands();
|
||||
}
|
||||
|
||||
private void schedulePairedPoll() {
|
||||
executor.execute(() -> {
|
||||
if (running.get()) {
|
||||
pollPairedSession();
|
||||
}
|
||||
if (running.get()) {
|
||||
mainHandler.postDelayed(this::schedulePairedPoll, PAIRED_POLL_MS);
|
||||
}
|
||||
});
|
||||
private void pollPairedSafe() {
|
||||
if (!running.get()) {
|
||||
return;
|
||||
}
|
||||
pollPairedSession();
|
||||
}
|
||||
|
||||
private void pollCommands() {
|
||||
@@ -181,7 +191,7 @@ public class CommandPoller {
|
||||
}
|
||||
startedSessionId = session.id;
|
||||
pendingAckSessionId = session.id;
|
||||
mainHandler.post(trackRecorder::start);
|
||||
trackRecorder.start();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "paired poll failed", e);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.grigowashere.loratester;
|
||||
import android.app.Application;
|
||||
|
||||
import com.grigowashere.loratester.api.ServerApi;
|
||||
import com.grigowashere.loratester.location.LocationTracker;
|
||||
import com.grigowashere.loratester.net.NetworkMonitor;
|
||||
import com.grigowashere.loratester.track.TrackRecorder;
|
||||
|
||||
@@ -16,6 +17,7 @@ public class LoraApp extends Application {
|
||||
private NetworkMonitor networkMonitor;
|
||||
private PeerStatsCache peerStatsCache;
|
||||
private CommandPoller commandPoller;
|
||||
private LocationTracker locationTracker;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
@@ -29,6 +31,7 @@ public class LoraApp extends Application {
|
||||
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
|
||||
String deviceId = settingsRepository.getOrCreateDeviceId();
|
||||
trackRecorder = new TrackRecorder(
|
||||
this,
|
||||
serverApi,
|
||||
telemetryUploader,
|
||||
deviceId,
|
||||
@@ -42,6 +45,14 @@ public class LoraApp extends Application {
|
||||
peerStatsCache
|
||||
);
|
||||
commandPoller.start();
|
||||
telemetryUploader.registerPresence();
|
||||
if (networkMonitor != null) {
|
||||
networkMonitor.addListener(online -> {
|
||||
if (online) {
|
||||
telemetryUploader.registerPresence();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public NetworkMonitor getNetworkMonitor() {
|
||||
@@ -68,6 +79,22 @@ public class LoraApp extends Application {
|
||||
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() {
|
||||
if (commandPoller != null) {
|
||||
commandPoller.stop();
|
||||
@@ -77,6 +104,7 @@ public class LoraApp extends Application {
|
||||
}
|
||||
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
|
||||
trackRecorder = new TrackRecorder(
|
||||
this,
|
||||
serverApi,
|
||||
telemetryUploader,
|
||||
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;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.PowerManager;
|
||||
import android.provider.Settings;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
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.TabLayoutMediator;
|
||||
import com.grigowashere.loratester.location.LocationTracker;
|
||||
import com.grigowashere.loratester.track.TrackRecorder;
|
||||
import com.grigowashere.loratester.ui.MainPagerAdapter;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private TelemetryUploader telemetryUploader;
|
||||
private LocationTracker locationTracker;
|
||||
private LoraApp app;
|
||||
private SettingsRepository settings;
|
||||
private boolean backgroundLocationRequested;
|
||||
|
||||
private final ActivityResultLauncher<String[]> locationPermissionLauncher =
|
||||
registerForActivityResult(
|
||||
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
|
||||
@@ -43,9 +60,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
return insets;
|
||||
});
|
||||
|
||||
LoraApp app = (LoraApp) getApplication();
|
||||
telemetryUploader = app.getTelemetryUploader();
|
||||
SettingsRepository settings = app.getSettingsRepository();
|
||||
app = (LoraApp) getApplication();
|
||||
settings = app.getSettingsRepository();
|
||||
|
||||
ViewPager2 pager = findViewById(R.id.viewPager);
|
||||
TabLayout tabs = findViewById(R.id.tabLayout);
|
||||
@@ -62,41 +78,92 @@ public class MainActivity extends AppCompatActivity {
|
||||
tab.setText(titleRes);
|
||||
}).attach();
|
||||
|
||||
TrackRecorder trackRecorder = app.getTrackRecorder();
|
||||
locationTracker = new LocationTracker(this, (lat, lon, alt) -> {
|
||||
telemetryUploader.updateLocation(lat, lon);
|
||||
trackRecorder.updateLocation(lat, lon, alt);
|
||||
});
|
||||
|
||||
requestLocationPermission();
|
||||
requestStartupPermissions();
|
||||
if (settings.isTelnetEnabled()) {
|
||||
telemetryUploader.startTelnet();
|
||||
app.getTelemetryUploader().startTelnet();
|
||||
}
|
||||
}
|
||||
|
||||
private void requestLocationPermission() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
private void requestStartupPermissions() {
|
||||
if (hasForegroundLocation()) {
|
||||
onForegroundLocationReady();
|
||||
return;
|
||||
}
|
||||
locationPermissionLauncher.launch(new String[]{
|
||||
Manifest.permission.ACCESS_FINE_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 requestNotificationPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
return;
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
startLocationIfPermitted();
|
||||
} else {
|
||||
locationPermissionLauncher.launch(new String[]{
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private void startLocationIfPermitted() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
locationTracker.start();
|
||||
}
|
||||
private boolean hasForegroundLocation() {
|
||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
locationTracker.stop();
|
||||
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_TELNET_ENABLED = "telnet_enabled";
|
||||
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";
|
||||
private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634";
|
||||
@@ -106,4 +107,16 @@ public class SettingsRepository {
|
||||
}
|
||||
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;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
@@ -285,6 +286,7 @@ public class TelemetryUploader implements TelnetClient.Listener {
|
||||
}
|
||||
TelemetryPayload payload = new TelemetryPayload(
|
||||
settings.getOrCreateDeviceId(),
|
||||
phoneLabel(),
|
||||
validLat(),
|
||||
validLon(),
|
||||
stats.rssi,
|
||||
@@ -297,6 +299,35 @@ public class TelemetryUploader implements TelnetClient.Listener {
|
||||
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) {
|
||||
if (networkMonitor.isOnline()) {
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
|
||||
|
||||
public class DeviceInfo {
|
||||
public String device_id;
|
||||
public String label;
|
||||
public double last_seen;
|
||||
public Double lat;
|
||||
public Double lon;
|
||||
|
||||
@@ -51,6 +51,9 @@ public class ServerApi {
|
||||
public void postTelemetry(TelemetryPayload payload) throws IOException {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
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.lon != null) body.put("lon", payload.lon);
|
||||
if (payload.rssi != null) body.put("rssi", payload.rssi);
|
||||
@@ -111,6 +114,28 @@ public class ServerApi {
|
||||
postJson("/api/tracks/" + trackId + "/finish", new HashMap<>(), true);
|
||||
}
|
||||
|
||||
public long syncTrack(
|
||||
String deviceId,
|
||||
Long trackId,
|
||||
double startedAt,
|
||||
List<Map<String, Object>> points,
|
||||
boolean finish
|
||||
) throws IOException {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("device_id", deviceId);
|
||||
if (trackId != null && trackId > 0) {
|
||||
body.put("track_id", trackId);
|
||||
}
|
||||
if (startedAt > 0) {
|
||||
body.put("started_at", startedAt);
|
||||
}
|
||||
body.put("points", points != null ? points : List.of());
|
||||
body.put("finish", finish);
|
||||
Map<String, Object> resp = postJsonMap("/api/tracks/sync", body, true);
|
||||
Number id = (Number) resp.get("track_id");
|
||||
return id != null ? id.longValue() : (trackId != null ? trackId : -1);
|
||||
}
|
||||
|
||||
public List<TrackInfo> listTracks(String deviceId) throws IOException {
|
||||
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
|
||||
|
||||
public class TelemetryPayload {
|
||||
public final String deviceId;
|
||||
public final String deviceLabel;
|
||||
public final Double lat;
|
||||
public final Double lon;
|
||||
public final Double rssi;
|
||||
@@ -22,8 +23,24 @@ public class TelemetryPayload {
|
||||
String meta,
|
||||
String role,
|
||||
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.deviceLabel = deviceLabel;
|
||||
this.lat = lat;
|
||||
this.lon = lon;
|
||||
this.rssi = rssi;
|
||||
|
||||
@@ -32,8 +32,12 @@ public class LocationTracker {
|
||||
return;
|
||||
}
|
||||
LocationRequest request = new LocationRequest.Builder(
|
||||
Priority.PRIORITY_HIGH_ACCURACY, 10_000L
|
||||
).setMinUpdateIntervalMillis(5_000L).build();
|
||||
Priority.PRIORITY_HIGH_ACCURACY, 1_000L
|
||||
)
|
||||
.setMinUpdateIntervalMillis(1_000L)
|
||||
.setMaxUpdateDelayMillis(2_000L)
|
||||
.setWaitForAccurateLocation(false)
|
||||
.build();
|
||||
|
||||
callback = new LocationCallback() {
|
||||
@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 Double frequencyMhz;
|
||||
public final Integer sf;
|
||||
public final Integer bwKhz;
|
||||
public final Double bwKhz;
|
||||
public final Double powerDbm;
|
||||
public final Double rssiDbm;
|
||||
public final Double snrDb;
|
||||
@@ -30,6 +30,18 @@ public final class RadioSnapshot {
|
||||
public final Double rxPktPerS;
|
||||
public final Double perPercent;
|
||||
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 RadioSnapshot(
|
||||
@@ -37,7 +49,7 @@ public final class RadioSnapshot {
|
||||
String frame,
|
||||
Double frequencyMhz,
|
||||
Integer sf,
|
||||
Integer bwKhz,
|
||||
Double bwKhz,
|
||||
Double powerDbm,
|
||||
Double rssiDbm,
|
||||
Double snrDb,
|
||||
@@ -48,6 +60,18 @@ public final class RadioSnapshot {
|
||||
Double rxPktPerS,
|
||||
Double perPercent,
|
||||
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
|
||||
) {
|
||||
this.role = role;
|
||||
@@ -65,12 +89,26 @@ public final class RadioSnapshot {
|
||||
this.rxPktPerS = rxPktPerS;
|
||||
this.perPercent = perPercent;
|
||||
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();
|
||||
}
|
||||
|
||||
public static RadioSnapshot empty() {
|
||||
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) {
|
||||
@@ -78,7 +116,9 @@ public final class RadioSnapshot {
|
||||
RadioSnapshot snap = empty();
|
||||
if (roleFallback != null || rssiFallback != 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;
|
||||
}
|
||||
@@ -108,7 +148,7 @@ public final class RadioSnapshot {
|
||||
text(o, "frame"),
|
||||
hzToMhz(lng(o, "frequency_hz")),
|
||||
integer(o, "spreading_factor"),
|
||||
integer(o, "bandwidth_khz"),
|
||||
dbl(o, "bandwidth_khz"),
|
||||
dbl(o, "power_dbm"),
|
||||
rssi,
|
||||
dbl(o, "snr_db"),
|
||||
@@ -119,11 +159,25 @@ public final class RadioSnapshot {
|
||||
dbl(o, "rx_pkt_per_s"),
|
||||
dbl(o, "per_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
|
||||
);
|
||||
} catch (Exception ignored) {
|
||||
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, "bw", bwKhz, prev.bwKhz);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -167,8 +229,12 @@ public final class RadioSnapshot {
|
||||
|| n.contains("frequency") || n.equals("power") || n.equals("rssi")
|
||||
|| n.equals("snr") || n.contains("spreading") || n.contains("bandwidth")
|
||||
|| 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.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) {
|
||||
@@ -186,6 +252,11 @@ public final class RadioSnapshot {
|
||||
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) {
|
||||
JsonElement e = o.get(key);
|
||||
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, "Payload", s.payload, "payload", 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, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed);
|
||||
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, "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, "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);
|
||||
return sb.toString().trim();
|
||||
}
|
||||
@@ -111,11 +123,22 @@ public final class LoraStatsFormatter {
|
||||
return v != null ? String.valueOf(v) : null;
|
||||
}
|
||||
|
||||
private static String fmtSuffix(Double v, String suffix) {
|
||||
return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null;
|
||||
private static String fmtBw(Double v) {
|
||||
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) {
|
||||
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 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 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 BANDWIDTH = Pattern.compile("Bandwidth\\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_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 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);
|
||||
@@ -89,10 +106,10 @@ public class StatsExtractor {
|
||||
|
||||
putLong(meta, "frequency_hz", matchLong(FREQUENCY, 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);
|
||||
if (packet == null) {
|
||||
packet = matchInt(PACKET, normalized);
|
||||
packet = matchInt(PACKET_TX, normalized);
|
||||
}
|
||||
putInt(meta, "packet", packet);
|
||||
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, "per_percent", matchDouble(PER, 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()) {
|
||||
meta.put("fields", fields);
|
||||
}
|
||||
|
||||
meta.put("stats_at", System.currentTimeMillis() / 1000.0);
|
||||
|
||||
Double rangeM = matchDouble(rangePattern, normalized);
|
||||
Double displayDbm = rssiDbm != null ? rssiDbm : txPower;
|
||||
|
||||
@@ -142,8 +173,12 @@ public class StatsExtractor {
|
||||
return n.equals("frequency") || n.equals("power") || n.equals("rssi")
|
||||
|| n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth")
|
||||
|| 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.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) {
|
||||
@@ -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) {
|
||||
Matcher m = pattern.matcher(text);
|
||||
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;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
import com.grigowashere.loratester.api.ServerApi;
|
||||
import com.grigowashere.loratester.location.GeoUtils;
|
||||
@@ -19,19 +21,26 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class TrackRecorder {
|
||||
|
||||
private static final String TAG = "TrackRecorder";
|
||||
private static final long SAMPLE_MS = 1000;
|
||||
private static final long FLUSH_MS = 30_000;
|
||||
private static final long FLUSH_MS = 10_000;
|
||||
/** Local-only track before first server sync (offline start). */
|
||||
private static final long LOCAL_TRACK_ID = 0;
|
||||
|
||||
/** No time limit — pending points stay on disk until track stop or successful upload. */
|
||||
public static final int MAX_OFFLINE_BUFFER_POINTS = 500_000;
|
||||
|
||||
public interface Listener {
|
||||
void onStateChanged(boolean recording, int pointCount, long trackId);
|
||||
|
||||
void onError(String message);
|
||||
|
||||
default void onSyncComplete(long trackId, int pointCount) {
|
||||
}
|
||||
|
||||
default void onPointRecorded(double lat, double lon) {
|
||||
}
|
||||
}
|
||||
@@ -39,6 +48,8 @@ public class TrackRecorder {
|
||||
private final ServerApi serverApi;
|
||||
private final TelemetryUploader uploader;
|
||||
private final NetworkMonitor networkMonitor;
|
||||
private final TrackPointQueue pendingQueue;
|
||||
private final Context appContext;
|
||||
private final String deviceId;
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
@@ -53,7 +64,9 @@ public class TrackRecorder {
|
||||
private volatile double lon = Double.NaN;
|
||||
private volatile double altitude = Double.NaN;
|
||||
private volatile long trackId = -1;
|
||||
private volatile double localStartedAt;
|
||||
private volatile boolean recording;
|
||||
private volatile boolean pendingSync;
|
||||
private final List<Map<String, Object>> buffer = new ArrayList<>();
|
||||
private int totalPoints;
|
||||
private ScheduledFuture<?> sampleTask;
|
||||
@@ -62,6 +75,7 @@ public class TrackRecorder {
|
||||
private Listener pairedListener;
|
||||
|
||||
public TrackRecorder(
|
||||
Context context,
|
||||
ServerApi serverApi,
|
||||
TelemetryUploader uploader,
|
||||
String deviceId,
|
||||
@@ -71,11 +85,14 @@ public class TrackRecorder {
|
||||
this.uploader = uploader;
|
||||
this.deviceId = deviceId;
|
||||
this.networkMonitor = networkMonitor;
|
||||
this.appContext = context.getApplicationContext();
|
||||
this.pendingQueue = new TrackPointQueue(this.appContext);
|
||||
networkMonitor.addListener(online -> {
|
||||
if (online && recording) {
|
||||
executor.execute(this::flushBuffer);
|
||||
if (online) {
|
||||
executor.execute(this::syncWhenOnline);
|
||||
}
|
||||
});
|
||||
restoreIfNeeded();
|
||||
}
|
||||
|
||||
public void setListener(Listener listener) {
|
||||
@@ -100,6 +117,10 @@ public class TrackRecorder {
|
||||
return recording;
|
||||
}
|
||||
|
||||
public boolean hasPendingSync() {
|
||||
return pendingSync;
|
||||
}
|
||||
|
||||
public int getPointCount() {
|
||||
return totalPoints;
|
||||
}
|
||||
@@ -108,32 +129,99 @@ public class TrackRecorder {
|
||||
return trackId;
|
||||
}
|
||||
|
||||
public int getPendingFlushCount() {
|
||||
synchronized (buffer) {
|
||||
return buffer.size();
|
||||
}
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (recording) {
|
||||
return;
|
||||
}
|
||||
if (!networkMonitor.isOnline()) {
|
||||
notifyError("Нужна сеть для начала трека");
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
long id = serverApi.startTrack(deviceId);
|
||||
synchronized (buffer) {
|
||||
buffer.clear();
|
||||
if (pendingSync) {
|
||||
syncWhenOnline();
|
||||
if (pendingSync) {
|
||||
notifyError(appContext.getString(R.string.track_sync_pending));
|
||||
return;
|
||||
}
|
||||
totalPoints = 0;
|
||||
trackId = id;
|
||||
recording = true;
|
||||
startTimers();
|
||||
notifyState();
|
||||
}
|
||||
if (!networkMonitor.isOnline()) {
|
||||
startLocalRecording();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
startOnlineRecording();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "start track failed", e);
|
||||
notifyError(e.getMessage());
|
||||
Log.w(TAG, "start track on server failed", e);
|
||||
if (isReachabilityError(e)) {
|
||||
startLocalRecording();
|
||||
} else {
|
||||
notifyError(e.getMessage() != null ? e.getMessage() : "start failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Retry upload of a track stopped offline. */
|
||||
public void retryPendingSync() {
|
||||
executor.execute(this::syncWhenOnline);
|
||||
}
|
||||
|
||||
private void startOnlineRecording() throws Exception {
|
||||
long id = serverApi.startTrack(deviceId);
|
||||
synchronized (buffer) {
|
||||
buffer.clear();
|
||||
}
|
||||
totalPoints = 0;
|
||||
trackId = id;
|
||||
localStartedAt = System.currentTimeMillis() / 1000.0;
|
||||
recording = true;
|
||||
pendingSync = false;
|
||||
persistState(false);
|
||||
startTimers();
|
||||
notifyState();
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!recording) {
|
||||
return;
|
||||
@@ -141,22 +229,146 @@ public class TrackRecorder {
|
||||
recording = false;
|
||||
stopTimers();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
flushBuffer();
|
||||
if (trackId > 0) {
|
||||
serverApi.finishTrack(trackId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "stop track failed", e);
|
||||
notifyError(e.getMessage());
|
||||
} finally {
|
||||
trackId = -1;
|
||||
notifyState();
|
||||
boolean synced = completeTrackUpload(true);
|
||||
if (!synced) {
|
||||
pendingSync = true;
|
||||
persistState(true);
|
||||
notifyError(appContext.getString(R.string.track_sync_pending));
|
||||
} else {
|
||||
resetAfterSuccessfulSync(-1);
|
||||
}
|
||||
notifyState();
|
||||
});
|
||||
}
|
||||
|
||||
private void restoreIfNeeded() {
|
||||
TrackPointQueue.Snapshot snap = pendingQueue.load();
|
||||
if (snap == null) {
|
||||
return;
|
||||
}
|
||||
if (snap.deviceId != null && !snap.deviceId.equals(deviceId)) {
|
||||
return;
|
||||
}
|
||||
trackId = snap.trackId;
|
||||
totalPoints = snap.totalPoints;
|
||||
localStartedAt = snap.startedAt > 0 ? snap.startedAt : System.currentTimeMillis() / 1000.0;
|
||||
pendingSync = snap.pendingSync;
|
||||
synchronized (buffer) {
|
||||
buffer.clear();
|
||||
if (snap.pending != null) {
|
||||
for (TrackPointQueue.PendingPoint point : snap.pending) {
|
||||
buffer.add(point.toMap());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (snap.recording) {
|
||||
recording = true;
|
||||
Log.i(TAG, "restored active track " + trackId + ", pending=" + buffer.size());
|
||||
startTimers();
|
||||
executor.execute(this::syncWhenOnline);
|
||||
notifyState();
|
||||
return;
|
||||
}
|
||||
if (snap.pendingSync) {
|
||||
Log.i(TAG, "restored pending sync track " + trackId + ", points=" + buffer.size());
|
||||
executor.execute(this::syncWhenOnline);
|
||||
notifyState();
|
||||
}
|
||||
}
|
||||
|
||||
private void syncWhenOnline() {
|
||||
if (!networkMonitor.isOnline()) {
|
||||
return;
|
||||
}
|
||||
if (recording) {
|
||||
if (trackId == LOCAL_TRACK_ID) {
|
||||
promoteLocalTrackToServer();
|
||||
} else if (trackId > 0) {
|
||||
flushBuffer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pendingSync) {
|
||||
if (completeTrackUpload(true)) {
|
||||
long finishedId = trackId > 0 ? trackId : -1;
|
||||
int count = totalPoints;
|
||||
resetAfterSuccessfulSync(-1);
|
||||
notifySyncComplete(finishedId, count);
|
||||
notifyState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void promoteLocalTrackToServer() {
|
||||
if (trackId != LOCAL_TRACK_ID || !recording) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long id = serverApi.startTrack(deviceId);
|
||||
trackId = id;
|
||||
flushBuffer();
|
||||
persistState(false);
|
||||
notifyState();
|
||||
Log.i(TAG, "promoted local track to server id " + id);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "promote local track failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean completeTrackUpload(boolean finish) {
|
||||
if (!networkMonitor.isOnline()) {
|
||||
return false;
|
||||
}
|
||||
List<Map<String, Object>> batch;
|
||||
synchronized (buffer) {
|
||||
if (buffer.isEmpty() && !finish) {
|
||||
return true;
|
||||
}
|
||||
batch = new ArrayList<>(buffer);
|
||||
buffer.clear();
|
||||
}
|
||||
try {
|
||||
Long serverTrackId = trackId > 0 ? trackId : null;
|
||||
long resultId = serverApi.syncTrack(
|
||||
deviceId,
|
||||
serverTrackId,
|
||||
localStartedAt,
|
||||
batch,
|
||||
finish
|
||||
);
|
||||
if (trackId <= 0) {
|
||||
trackId = resultId;
|
||||
}
|
||||
persistState(false);
|
||||
if (finish && batch.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "track sync failed", e);
|
||||
synchronized (buffer) {
|
||||
buffer.addAll(0, batch);
|
||||
}
|
||||
persistState(finish);
|
||||
notifyError(e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void resetAfterSuccessfulSync(long finishedTrackId) {
|
||||
trackId = -1;
|
||||
pendingSync = false;
|
||||
recording = false;
|
||||
synchronized (buffer) {
|
||||
buffer.clear();
|
||||
}
|
||||
pendingQueue.clear();
|
||||
}
|
||||
|
||||
private void startTimers() {
|
||||
if (sampleTask != null || flushTask != null) {
|
||||
return;
|
||||
}
|
||||
sampleTask = scheduler.scheduleAtFixedRate(
|
||||
() -> executor.execute(this::samplePoint),
|
||||
SAMPLE_MS,
|
||||
@@ -210,12 +422,58 @@ public class TrackRecorder {
|
||||
}
|
||||
synchronized (buffer) {
|
||||
buffer.add(point);
|
||||
if (buffer.size() > MAX_OFFLINE_BUFFER_POINTS) {
|
||||
buffer.remove(0);
|
||||
Log.w(TAG, "offline buffer trimmed at " + MAX_OFFLINE_BUFFER_POINTS);
|
||||
}
|
||||
}
|
||||
totalPoints++;
|
||||
persistState(false);
|
||||
notifyState();
|
||||
notifyPoint(lat, lon);
|
||||
}
|
||||
|
||||
private void persistState(boolean markPendingSync) {
|
||||
List<Map<String, Object>> copy;
|
||||
synchronized (buffer) {
|
||||
copy = new ArrayList<>(buffer);
|
||||
}
|
||||
pendingQueue.save(
|
||||
deviceId,
|
||||
trackId,
|
||||
recording,
|
||||
markPendingSync || pendingSync,
|
||||
totalPoints,
|
||||
localStartedAt,
|
||||
copy
|
||||
);
|
||||
}
|
||||
|
||||
private void flushBuffer() {
|
||||
if (!recording || trackId <= 0 || !networkMonitor.isOnline()) {
|
||||
return;
|
||||
}
|
||||
List<Map<String, Object>> batch;
|
||||
synchronized (buffer) {
|
||||
if (buffer.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
batch = new ArrayList<>(buffer);
|
||||
buffer.clear();
|
||||
}
|
||||
try {
|
||||
serverApi.addTrackPoints(trackId, batch);
|
||||
persistState(false);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "flush points failed", e);
|
||||
synchronized (buffer) {
|
||||
buffer.addAll(0, batch);
|
||||
}
|
||||
persistState(false);
|
||||
notifyError(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyPoint(double lat, double lon) {
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) {
|
||||
@@ -227,27 +485,15 @@ public class TrackRecorder {
|
||||
});
|
||||
}
|
||||
|
||||
private void flushBuffer() {
|
||||
if (trackId < 0) {
|
||||
return;
|
||||
}
|
||||
List<Map<String, Object>> batch;
|
||||
synchronized (buffer) {
|
||||
if (buffer.isEmpty()) {
|
||||
return;
|
||||
private void notifySyncComplete(long trackId, int pointCount) {
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) {
|
||||
listener.onSyncComplete(trackId, pointCount);
|
||||
}
|
||||
batch = new ArrayList<>(buffer);
|
||||
buffer.clear();
|
||||
}
|
||||
try {
|
||||
serverApi.addTrackPoints(trackId, batch);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "flush points failed", e);
|
||||
synchronized (buffer) {
|
||||
buffer.addAll(0, batch);
|
||||
if (pairedListener != null) {
|
||||
pairedListener.onSyncComplete(trackId, pointCount);
|
||||
}
|
||||
notifyError(e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void notifyState() {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import com.grigowashere.loratester.api.DeviceInfo;
|
||||
|
||||
/** Human-readable device labels (matches web deviceDisplayName). */
|
||||
public final class DeviceNames {
|
||||
|
||||
private DeviceNames() {
|
||||
}
|
||||
|
||||
public static String displayName(DeviceInfo device, String fallbackId) {
|
||||
if (device != null && device.label != null) {
|
||||
String label = device.label.trim();
|
||||
if (!label.isEmpty()
|
||||
&& device.device_id != null
|
||||
&& !label.equals(device.device_id)) {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
if (fallbackId != null && !fallbackId.isEmpty()) {
|
||||
return fallbackId;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import com.grigowashere.loratester.api.TrackDetail;
|
||||
import com.grigowashere.loratester.api.TrackInfo;
|
||||
import com.grigowashere.loratester.location.GeoUtils;
|
||||
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.track.TrackRecorder;
|
||||
|
||||
@@ -51,6 +53,7 @@ import org.mapsforge.map.layer.Layer;
|
||||
import org.mapsforge.map.layer.cache.TileCache;
|
||||
import org.mapsforge.map.layer.download.TileDownloadLayer;
|
||||
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.Polyline;
|
||||
import org.mapsforge.map.model.MapViewPosition;
|
||||
@@ -108,6 +111,8 @@ public class MapFragment extends Fragment {
|
||||
private TileCache tileCache;
|
||||
private TextView mapStatus;
|
||||
private TextView mapDistance;
|
||||
private TextView mapRxQuality;
|
||||
private TextView mapTrackStatus;
|
||||
private TextView mapHillStatus;
|
||||
private TextView trackStatus;
|
||||
private ImageView iconServer;
|
||||
@@ -122,6 +127,7 @@ public class MapFragment extends Fragment {
|
||||
private MaterialButtonToggleGroup mapCenterMode;
|
||||
private Spinner trackSpinner;
|
||||
private Spinner mapHeatmapRadius;
|
||||
private Spinner mapBasemap;
|
||||
private TextView mapHeatmapStatus;
|
||||
private View mapHeatmapLegend;
|
||||
private List<TrackInfo> savedTracks = new ArrayList<>();
|
||||
@@ -150,6 +156,8 @@ public class MapFragment extends Fragment {
|
||||
private double lastHeatmapLat = Double.NaN;
|
||||
private double lastHeatmapLon = Double.NaN;
|
||||
private boolean suppressHeatmapSpinner;
|
||||
private boolean suppressBasemapSpinner;
|
||||
private TileSource currentTileSource = OpenStreetMapMapnik.INSTANCE;
|
||||
private Runnable heatmapReloadRunnable;
|
||||
private boolean suppressCenterToggle;
|
||||
private boolean mapGestureActive;
|
||||
@@ -183,6 +191,8 @@ public class MapFragment extends Fragment {
|
||||
mapView = view.findViewById(R.id.mapView);
|
||||
mapStatus = view.findViewById(R.id.mapStatus);
|
||||
mapDistance = view.findViewById(R.id.mapDistance);
|
||||
mapRxQuality = view.findViewById(R.id.mapRxQuality);
|
||||
mapTrackStatus = view.findViewById(R.id.mapTrackStatus);
|
||||
mapHillStatus = view.findViewById(R.id.mapHillStatus);
|
||||
iconServer = view.findViewById(R.id.iconServer);
|
||||
iconLora = view.findViewById(R.id.iconLora);
|
||||
@@ -196,6 +206,7 @@ public class MapFragment extends Fragment {
|
||||
mapToolDrawer = view.findViewById(R.id.mapToolDrawer);
|
||||
mapCenterMode = view.findViewById(R.id.mapCenterMode);
|
||||
mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius);
|
||||
mapBasemap = view.findViewById(R.id.mapBasemap);
|
||||
mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus);
|
||||
mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend);
|
||||
trackSpinner = view.findViewById(R.id.trackSpinner);
|
||||
@@ -208,14 +219,35 @@ public class MapFragment extends Fragment {
|
||||
btnFindHill.setOnClickListener(v -> toggleHill());
|
||||
}
|
||||
setupHeatmapUi();
|
||||
setupBasemapUi();
|
||||
|
||||
updateConnectionIcons(lastDevices, serverConnected);
|
||||
|
||||
networkOnline = networkMonitor != null && networkMonitor.isOnline();
|
||||
networkListener = online -> {
|
||||
boolean wasOnline = networkOnline;
|
||||
networkOnline = online;
|
||||
if (isAdded() && mapStatus != null) {
|
||||
requireActivity().runOnUiThread(this::updateNetworkStatusLine);
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
updateNetworkStatusLine();
|
||||
updateTrackStatusUi();
|
||||
});
|
||||
}
|
||||
if (isAdded() && trackRecorder != null && trackRecorder.isRecording()) {
|
||||
int pending = trackRecorder.getPendingFlushCount();
|
||||
if (!online && wasOnline) {
|
||||
requireActivity().runOnUiThread(() -> Toast.makeText(
|
||||
requireContext(),
|
||||
getString(R.string.track_offline_toast, pending),
|
||||
Toast.LENGTH_LONG
|
||||
).show());
|
||||
} else if (online && !wasOnline && pending > 0) {
|
||||
requireActivity().runOnUiThread(() -> Toast.makeText(
|
||||
requireContext(),
|
||||
getString(R.string.track_online_toast, pending),
|
||||
Toast.LENGTH_SHORT
|
||||
).show());
|
||||
}
|
||||
}
|
||||
};
|
||||
if (networkMonitor != null) {
|
||||
@@ -347,18 +379,18 @@ public class MapFragment extends Fragment {
|
||||
mapView.getModel().frameBufferModel.getOverdrawFactor()
|
||||
);
|
||||
|
||||
OpenStreetMapMapnik tileSource = OpenStreetMapMapnik.INSTANCE;
|
||||
tileSource.setUserAgent("LoraTester/1.0");
|
||||
OpenStreetMapMapnik.INSTANCE.setUserAgent("LoraTester/1.0");
|
||||
currentTileSource = OpenStreetMapMapnik.INSTANCE;
|
||||
|
||||
downloadLayer = new TileDownloadLayer(
|
||||
tileCache,
|
||||
mapView.getModel().mapViewPosition,
|
||||
tileSource,
|
||||
currentTileSource,
|
||||
AndroidGraphicFactory.INSTANCE
|
||||
);
|
||||
mapView.getLayerManager().getLayers().add(downloadLayer);
|
||||
mapView.setZoomLevelMin(tileSource.getZoomLevelMin());
|
||||
mapView.setZoomLevelMax(tileSource.getZoomLevelMax());
|
||||
mapView.setZoomLevelMin(currentTileSource.getZoomLevelMin());
|
||||
mapView.setZoomLevelMax(currentTileSource.getZoomLevelMax());
|
||||
downloadLayer.start();
|
||||
|
||||
MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition;
|
||||
@@ -457,6 +489,8 @@ public class MapFragment extends Fragment {
|
||||
heatmapActive = false;
|
||||
mapStatus = null;
|
||||
mapDistance = null;
|
||||
mapRxQuality = null;
|
||||
mapTrackStatus = null;
|
||||
trackStatus = null;
|
||||
btnTrack = null;
|
||||
btnPairedTrack = null;
|
||||
@@ -499,9 +533,7 @@ public class MapFragment extends Fragment {
|
||||
btnTrack.setActivated(recording);
|
||||
btnTrack.setContentDescription(getString(
|
||||
recording ? R.string.track_stop : R.string.track_start));
|
||||
if (trackStatus != null) {
|
||||
trackStatus.setText(getString(R.string.track_status, pointCount));
|
||||
}
|
||||
updateTrackStatusUi();
|
||||
if (recording && pointCount <= 1) {
|
||||
clearLiveTrackLayers();
|
||||
}
|
||||
@@ -515,9 +547,26 @@ public class MapFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
if (isAdded() && trackStatus != null) {
|
||||
if (!isAdded()) {
|
||||
return;
|
||||
}
|
||||
if (trackStatus != null) {
|
||||
trackStatus.setText(getString(R.string.track_error, message));
|
||||
}
|
||||
Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSyncComplete(long trackId, int pointCount) {
|
||||
if (!isAdded()) {
|
||||
return;
|
||||
}
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
getString(R.string.track_sync_done, trackId, pointCount),
|
||||
Toast.LENGTH_LONG
|
||||
).show();
|
||||
loadTrackList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -593,10 +642,49 @@ public class MapFragment extends Fragment {
|
||||
liveTrackMarker = null;
|
||||
}
|
||||
|
||||
private void updateTrackStatusUi() {
|
||||
if (trackRecorder == null) {
|
||||
return;
|
||||
}
|
||||
if (!trackRecorder.isRecording()) {
|
||||
if (trackStatus != null) {
|
||||
trackStatus.setText("");
|
||||
}
|
||||
if (mapTrackStatus != null) {
|
||||
mapTrackStatus.setVisibility(View.GONE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
int total = trackRecorder.getPointCount();
|
||||
int pending = trackRecorder.getPendingFlushCount();
|
||||
CharSequence line;
|
||||
if (!networkOnline && pending > 0) {
|
||||
line = getString(R.string.track_status_offline, total, pending);
|
||||
} else {
|
||||
line = getString(R.string.track_status_online, total);
|
||||
}
|
||||
if (trackStatus != null) {
|
||||
trackStatus.setText(line);
|
||||
}
|
||||
if (mapTrackStatus != null) {
|
||||
mapTrackStatus.setVisibility(View.VISIBLE);
|
||||
if (!networkOnline && pending > 0) {
|
||||
mapTrackStatus.setText(getString(R.string.map_track_status_offline, total, pending));
|
||||
} else {
|
||||
mapTrackStatus.setText(getString(R.string.map_track_status_online, total));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void toggleTracking() {
|
||||
if (trackRecorder.isRecording()) {
|
||||
trackRecorder.stop();
|
||||
} else {
|
||||
if (trackRecorder.hasPendingSync()) {
|
||||
trackRecorder.retryPendingSync();
|
||||
Toast.makeText(requireContext(), R.string.track_sync_pending, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
trackRecorder.start();
|
||||
}
|
||||
}
|
||||
@@ -707,29 +795,89 @@ public class MapFragment extends Fragment {
|
||||
}
|
||||
clearTrackLayers();
|
||||
|
||||
List<LatLong> line = 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);
|
||||
line.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);
|
||||
}
|
||||
|
||||
if (line.size() >= 2) {
|
||||
Polyline polyline = new Polyline(
|
||||
MapsforgeBitmaps.linePaint(Color.GREEN, 4f),
|
||||
AndroidGraphicFactory.INSTANCE
|
||||
);
|
||||
polyline.getLatLongs().addAll(line);
|
||||
addTrackLayer(polyline);
|
||||
if (i > 0) {
|
||||
TrackDetail.TrackPoint prev = detail.points.get(i - 1);
|
||||
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
|
||||
);
|
||||
segment.getLatLongs().add(new LatLong(prev.lat, prev.lon));
|
||||
segment.getLatLongs().add(latLong);
|
||||
addTrackLayer(segment);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
mapView.getLayerManager().getLayers().add(layer);
|
||||
trackLayers.add(layer);
|
||||
@@ -1015,6 +1163,68 @@ public class MapFragment extends Fragment {
|
||||
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() {
|
||||
if (mapHeatmapRadius == null) {
|
||||
return;
|
||||
@@ -1284,6 +1494,7 @@ public class MapFragment extends Fragment {
|
||||
));
|
||||
}
|
||||
updateGpsDistance();
|
||||
updateRxQuality();
|
||||
updateConnectionIcons(lastDevices, serverConnected);
|
||||
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() {
|
||||
if (mapDistance == null) {
|
||||
return;
|
||||
|
||||
@@ -35,4 +35,12 @@ final class MapsforgeBitmaps {
|
||||
paint.setStyle(org.mapsforge.core.graphics.Style.STROKE);
|
||||
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, "Payload", str(tx.payload), str(rx.payload), "payload", 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, "RX spd", fmtSuffix(tx.rxPktPerS, " p/s"), fmtSuffix(rx.rxPktPerS, " p/s"), "rxSpeed", changedTx, changedRx);
|
||||
} else {
|
||||
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, "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, "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,
|
||||
Set<String> changedLocal,
|
||||
Set<String> changedPeer
|
||||
) {
|
||||
bindByRole(
|
||||
panel,
|
||||
local,
|
||||
peer,
|
||||
localId,
|
||||
peerId,
|
||||
localId,
|
||||
peerId,
|
||||
changedLocal,
|
||||
changedPeer
|
||||
);
|
||||
}
|
||||
|
||||
public static void bindByRole(
|
||||
RadioComparePanel panel,
|
||||
RadioSnapshot local,
|
||||
RadioSnapshot peer,
|
||||
String localId,
|
||||
String peerId,
|
||||
String localDisplayName,
|
||||
String peerDisplayName,
|
||||
Set<String> changedLocal,
|
||||
Set<String> changedPeer
|
||||
) {
|
||||
RadioSnapshot tx = local;
|
||||
RadioSnapshot rx = peer;
|
||||
String txId = localId;
|
||||
String rxId = peerId;
|
||||
String txName = localDisplayName != null ? localDisplayName : localId;
|
||||
String rxName = peerDisplayName != null ? peerDisplayName : peerId;
|
||||
Set<String> chTx = changedLocal;
|
||||
Set<String> chRx = changedPeer;
|
||||
if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) {
|
||||
tx = peer;
|
||||
rx = local;
|
||||
txId = peerId;
|
||||
rxId = localId;
|
||||
txName = peerDisplayName != null ? peerDisplayName : peerId;
|
||||
rxName = localDisplayName != null ? localDisplayName : localId;
|
||||
chTx = changedPeer;
|
||||
chRx = changedLocal;
|
||||
}
|
||||
if (tx == null) tx = RadioSnapshot.empty();
|
||||
if (rx == null) rx = RadioSnapshot.empty();
|
||||
panel.bind(tx, rx, txId, rxId, chTx, chRx);
|
||||
panel.bind(tx, rx, txName, rxName, chTx, chRx);
|
||||
}
|
||||
|
||||
private static String str(String v) {
|
||||
@@ -183,6 +223,17 @@ public class RadioComparePanel extends LinearLayout {
|
||||
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) {
|
||||
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.textfield.TextInputEditText;
|
||||
import com.grigowashere.loratester.LoraApp;
|
||||
import com.grigowashere.loratester.LoraForegroundService;
|
||||
import com.grigowashere.loratester.MainActivity;
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.SettingsRepository;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
@@ -42,7 +44,9 @@ public class SettingsFragment extends Fragment {
|
||||
TextInputEditText editPort = view.findViewById(R.id.editTelnetPort);
|
||||
TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex);
|
||||
TextInputEditText editRange = view.findViewById(R.id.editRangeRegex);
|
||||
TextInputEditText editDeviceLabel = view.findViewById(R.id.editDeviceLabel);
|
||||
SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet);
|
||||
Button batteryBtn = view.findViewById(R.id.btnBatteryOptimization);
|
||||
TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel);
|
||||
Button save = view.findViewById(R.id.btnSaveSettings);
|
||||
|
||||
@@ -51,9 +55,21 @@ public class SettingsFragment extends Fragment {
|
||||
editPort.setText(String.valueOf(settings.getTelnetPort()));
|
||||
editRssi.setText(settings.getRssiRegex());
|
||||
editRange.setText(settings.getRangeRegex());
|
||||
String savedLabel = settings.getDeviceLabel();
|
||||
if (savedLabel != null) {
|
||||
editDeviceLabel.setText(savedLabel);
|
||||
}
|
||||
switchTelnet.setChecked(settings.isTelnetEnabled());
|
||||
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 -> {
|
||||
settings.setServerUrl(textOf(editServer, SettingsRepository.DEFAULT_SERVER));
|
||||
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.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX));
|
||||
settings.setDeviceLabel(textOf(editDeviceLabel, ""));
|
||||
settings.setTelnetEnabled(switchTelnet.isChecked());
|
||||
uploader.refreshApi();
|
||||
uploader.registerPresence();
|
||||
if (switchTelnet.isChecked()) {
|
||||
uploader.startTelnet();
|
||||
} else {
|
||||
uploader.stopTelnet();
|
||||
}
|
||||
LoraForegroundService.ensureRunning(requireContext());
|
||||
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.api.DeviceInfo;
|
||||
import com.grigowashere.loratester.api.TelemetryHistoryItem;
|
||||
import com.grigowashere.loratester.location.GeoUtils;
|
||||
import com.grigowashere.loratester.model.RadioSnapshot;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -43,6 +45,7 @@ public class StatsFragment extends Fragment {
|
||||
private PeerStatsCache peerStatsCache;
|
||||
private TextView statsStatus;
|
||||
private TextView statsPeerWarning;
|
||||
private TextView statsDistance;
|
||||
private RadioComparePanel radioComparePanel;
|
||||
private RecyclerView statsHistoryList;
|
||||
private final HistoryAdapter historyAdapter = new HistoryAdapter();
|
||||
@@ -54,6 +57,10 @@ public class StatsFragment extends Fragment {
|
||||
private String cachedPeerId;
|
||||
private String cachedPeerError;
|
||||
private int cachedDeviceCount;
|
||||
private String cachedSelfDisplayName;
|
||||
private String cachedPeerDisplayName;
|
||||
private DeviceInfo cachedTxDev;
|
||||
private DeviceInfo cachedRxDev;
|
||||
|
||||
private final TelemetryUploader.StatsListener statsListener = stats -> postRender();
|
||||
|
||||
@@ -80,6 +87,7 @@ public class StatsFragment extends Fragment {
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
statsStatus = view.findViewById(R.id.statsStatus);
|
||||
statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
|
||||
statsDistance = view.findViewById(R.id.statsDistance);
|
||||
radioComparePanel = view.findViewById(R.id.radioComparePanel);
|
||||
statsHistoryList = view.findViewById(R.id.statsHistoryList);
|
||||
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
@@ -157,6 +165,7 @@ public class StatsFragment extends Fragment {
|
||||
}
|
||||
statsStatus = null;
|
||||
statsPeerWarning = null;
|
||||
statsDistance = null;
|
||||
radioComparePanel = null;
|
||||
statsHistoryList = null;
|
||||
pollHelper = null;
|
||||
@@ -183,13 +192,17 @@ public class StatsFragment extends Fragment {
|
||||
String deviceId = uploader.getDeviceId();
|
||||
statsStatus.setText(getString(
|
||||
R.string.stats_status,
|
||||
deviceId,
|
||||
cachedSelfDisplayName != null ? cachedSelfDisplayName : deviceId,
|
||||
uploader.isTelnetConnected()
|
||||
? getString(R.string.connected) : getString(R.string.disconnected)
|
||||
));
|
||||
|
||||
executor.execute(() -> {
|
||||
List<TelemetryHistoryItem> history = null;
|
||||
DeviceInfo txDev = null;
|
||||
DeviceInfo rxDev = null;
|
||||
String selfName = deviceId;
|
||||
String peerName = cachedPeerId;
|
||||
try {
|
||||
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||
cachedDeviceCount = devices.size();
|
||||
@@ -200,12 +213,23 @@ public class StatsFragment extends Fragment {
|
||||
DeviceInfo self = null;
|
||||
DeviceInfo peerDev = null;
|
||||
for (DeviceInfo d : devices) {
|
||||
if (StatsExtractor.ROLE_TX.equals(d.role)) {
|
||||
txDev = d;
|
||||
} else if (StatsExtractor.ROLE_RX.equals(d.role)) {
|
||||
rxDev = d;
|
||||
}
|
||||
if (deviceId.equals(d.device_id)) {
|
||||
self = d;
|
||||
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
|
||||
peerDev = d;
|
||||
}
|
||||
}
|
||||
selfName = DeviceNames.displayName(self, deviceId);
|
||||
peerName = DeviceNames.displayName(peerDev, peer.peerId);
|
||||
cachedSelfDisplayName = selfName;
|
||||
cachedPeerDisplayName = peerName;
|
||||
cachedTxDev = txDev;
|
||||
cachedRxDev = rxDev;
|
||||
|
||||
StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
|
||||
snapLocal = localStats != null
|
||||
@@ -264,10 +288,32 @@ public class StatsFragment extends Fragment {
|
||||
snapPeer,
|
||||
uploader.getDeviceId(),
|
||||
cachedPeerId,
|
||||
cachedSelfDisplayName,
|
||||
cachedPeerDisplayName,
|
||||
chLocal,
|
||||
chPeer
|
||||
);
|
||||
updateStatsDistance();
|
||||
prevLocal = snapLocal;
|
||||
prevPeer = snapPeer;
|
||||
}
|
||||
|
||||
private void updateStatsDistance() {
|
||||
if (statsDistance == null) {
|
||||
return;
|
||||
}
|
||||
DeviceInfo tx = cachedTxDev;
|
||||
DeviceInfo rx = cachedRxDev;
|
||||
if (tx != null && rx != null
|
||||
&& GeoUtils.isValidCoordinate(tx.lat, tx.lon)
|
||||
&& GeoUtils.isValidCoordinate(rx.lat, rx.lon)) {
|
||||
double dist = GeoUtils.haversineMeters(tx.lat, tx.lon, rx.lat, rx.lon);
|
||||
statsDistance.setVisibility(View.VISIBLE);
|
||||
statsDistance.setText(getString(
|
||||
R.string.stats_gps_distance,
|
||||
String.format(Locale.US, "%.0f", dist)));
|
||||
} else {
|
||||
statsDistance.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -80,6 +80,24 @@
|
||||
android:textColor="#00FF88"
|
||||
android:textSize="9sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapRxQuality"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="#4FC3F7"
|
||||
android:textSize="9sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapTrackStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="#FF9800"
|
||||
android:textSize="9sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
@@ -222,6 +240,20 @@
|
||||
android:textSize="10sp" />
|
||||
</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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -73,6 +73,19 @@
|
||||
android:inputType="text" />
|
||||
</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
|
||||
android:id="@+id/switchTelnet"
|
||||
android:layout_width="match_parent"
|
||||
@@ -80,6 +93,20 @@
|
||||
android:layout_marginTop="16dp"
|
||||
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
|
||||
android:id="@+id/deviceIdLabel"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -24,6 +24,15 @@
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statsDistance"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="#00FF88"
|
||||
android:textSize="13sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.grigowashere.loratester.ui.RadioComparePanel
|
||||
android:id="@+id/radioComparePanel"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<string name="range_regex">Range regex</string>
|
||||
<string name="telnet_enabled">Подключить telnet</string>
|
||||
<string name="device_id_label">ID устройства: %1$s</string>
|
||||
<string name="device_display_name">Имя на карте (realme, OPPO…)</string>
|
||||
<string name="save">Сохранить</string>
|
||||
<string name="saved">Сохранено</string>
|
||||
<string name="chat_hint">Сообщение…</string>
|
||||
@@ -36,6 +37,18 @@
|
||||
<string name="map_network_online">онлайн</string>
|
||||
<string name="map_network_offline">офлайн (кэш)</string>
|
||||
<string name="track_need_network">Нужна сеть для начала трека</string>
|
||||
<string name="track_offline_started">Запись без сети — точки сохраняются на телефоне</string>
|
||||
<string name="track_sync_pending">Трек сохранён локально — отправится при появлении сети</string>
|
||||
<string name="track_sync_done">Трек #%1$d загружен (%2$d точек)</string>
|
||||
<string name="track_status_online">Трекинг: %1$d точек</string>
|
||||
<string name="track_status_offline">Трекинг: %1$d точек · буфер %2$d · офлайн</string>
|
||||
<string name="track_offline_toast">Сеть пропала — точки сохраняются на устройстве (буфер %1$d). Без ограничения по времени, до ~500 тыс. точек.</string>
|
||||
<string name="track_online_toast">Сеть восстановлена — отправка буфера (%1$d точек)</string>
|
||||
<string name="map_track_status_online">Трек: %1$d точек</string>
|
||||
<string name="map_track_status_offline">Трек: %1$d точек · буфер %2$d</string>
|
||||
<string name="map_rx_quality">RX Quality: %1$s%%</string>
|
||||
<string name="map_rx_quality_unknown">RX Quality: —</string>
|
||||
<string name="stats_gps_distance">Расстояние TX↔RX: %1$s m</string>
|
||||
<string name="upload_queue_pending">В очереди: %1$d</string>
|
||||
<string name="gps_waiting">GPS: ожидание фикса…</string>
|
||||
<string name="stats_updated_at">обновлено %1$s</string>
|
||||
@@ -81,6 +94,9 @@
|
||||
<string name="map_center_rx">RX</string>
|
||||
<string name="map_center_both">Оба</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_tool_center">Центрировать карту</string>
|
||||
<string name="map_tool_track">Трекинг пути</string>
|
||||
@@ -109,4 +125,16 @@
|
||||
<string name="map_heatmap_legend_level">уровень</string>
|
||||
<string name="map_heatmap_legend_low">низина</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>
|
||||
|
||||
@@ -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() {
|
||||
StatsExtractor extractor = StatsExtractor.withDefaults();
|
||||
StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND);
|
||||
assertTrue(stats.metaJson.contains("\"fields\""));
|
||||
assertTrue(stats.metaJson.contains("Frequency"));
|
||||
assertTrue(stats.metaJson.contains("Spreading Factor"));
|
||||
assertTrue(stats.metaJson.contains("Packet"));
|
||||
assertTrue(stats.metaJson.contains("Payload"));
|
||||
assertTrue(stats.metaJson.contains("\"code_rate\":\"4/5\""));
|
||||
assertTrue(stats.metaJson.contains("\"spreading_factor\":12"));
|
||||
assertTrue(stats.metaJson.contains("\"packet\":304"));
|
||||
assertTrue(stats.metaJson.contains("Test TX!"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -83,7 +82,8 @@ public class LoraFrameExtractTest {
|
||||
assertEquals(StatsExtractor.ROLE_RX, stats.role);
|
||||
assertEquals(-78.0, stats.rssi, 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
|
||||
@@ -96,6 +96,73 @@ public class LoraFrameExtractTest {
|
||||
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
|
||||
public void splitsTwoFramesByReceiveHeaderWithoutEsc() {
|
||||
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
@@ -22,7 +22,7 @@ python flask_app.py
|
||||
| `LORATESTER_PORT` | `7634` |
|
||||
| `LORATESTER_DB` | `./loratester.db` |
|
||||
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
|
||||
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) |
|
||||
| `LORATESTER_TRACK_POINTS_LIMIT` | `500000` (точек на один трек) |
|
||||
| `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` |
|
||||
| `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
|
||||
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
|
||||
@@ -40,14 +40,17 @@ docker compose up -d --build
|
||||
curl http://127.0.0.1:7634/api/health | jq
|
||||
```
|
||||
|
||||
Ожидается `"elevation_ok": true` если локальный Open-Meteo доступен с хоста/контейнера.
|
||||
Ожидается `"elevation_ok": true` если OpenTopoData (основной) или Open-Meteo (fallback) доступны с хоста/контейнера.
|
||||
|
||||
Переопределить URL высот (`.env` рядом с `docker-compose.yml`):
|
||||
|
||||
```env
|
||||
LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
|
||||
LORATESTER_ELEVATION_OPENTOPO_URL=http://grigowashere.ru:5300/v1/srtm30
|
||||
LORATESTER_ELEVATION_FALLBACK_URL=http://192.168.1.109:8085/v1/elevation
|
||||
```
|
||||
|
||||
`LORATESTER_ELEVATION_URL` — устаревший alias для fallback (Open-Meteo-compatible).
|
||||
|
||||
БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера).
|
||||
|
||||
## Деплой (lora.grigowashere.ru)
|
||||
@@ -63,7 +66,8 @@ docker compose up -d --build
|
||||
cd /srv/storage/disk2/services/LoraTester/server
|
||||
pip install -r requirements.txt
|
||||
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
|
||||
export LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
|
||||
export LORATESTER_ELEVATION_OPENTOPO_URL=http://grigowashere.ru:5300/v1/srtm30
|
||||
export LORATESTER_ELEVATION_FALLBACK_URL=http://192.168.1.109:8085/v1/elevation
|
||||
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
|
||||
```
|
||||
|
||||
@@ -95,11 +99,12 @@ curl http://127.0.0.1:7634/api/health
|
||||
|
||||
### Треки (запись с Android)
|
||||
|
||||
- `POST /api/tracks/sync` — `{device_id, track_id?, started_at?, points[], finish?}` — офлайн-догрузка точек и завершение трека
|
||||
- `POST /api/tracks/start` — `{device_id}` → `{track_id}`
|
||||
- `POST /api/tracks/{id}/points` — `{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
|
||||
- `POST /api/tracks/{id}/finish`
|
||||
- `GET /api/tracks?device_id=`
|
||||
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через локальный Open-Meteo)
|
||||
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через OpenTopoData → Open-Meteo fallback)
|
||||
|
||||
### Команды (очередь на устройство)
|
||||
|
||||
@@ -110,9 +115,9 @@ curl http://127.0.0.1:7634/api/health
|
||||
|
||||
### Профиль высот (веб, треки)
|
||||
|
||||
- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo)
|
||||
- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (OpenTopoData → Open-Meteo)
|
||||
- `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку
|
||||
- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность (прокси Open-Meteo)
|
||||
- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность
|
||||
- `GET /api/elevation/grid?lat=&lon=&radius_m=200&step_m=0` — сетка высот для хитмапы (100–500 m, step_m=0 авто)
|
||||
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
|
||||
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
-4
@@ -8,11 +8,20 @@ DATABASE_PATH = os.environ.get(
|
||||
HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
|
||||
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
|
||||
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
|
||||
ELEVATION_API_URL = os.environ.get(
|
||||
"LORATESTER_ELEVATION_URL",
|
||||
"http://192.168.1.109:8085/v1/elevation",
|
||||
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "500000"))
|
||||
ELEVATION_OPENTOPO_URL = os.environ.get(
|
||||
"LORATESTER_ELEVATION_OPENTOPO_URL",
|
||||
"http://grigowashere.ru:5300/v1/srtm30",
|
||||
).rstrip("/")
|
||||
ELEVATION_FALLBACK_URL = os.environ.get(
|
||||
"LORATESTER_ELEVATION_FALLBACK_URL",
|
||||
os.environ.get(
|
||||
"LORATESTER_ELEVATION_URL",
|
||||
"http://192.168.1.109:8085/v1/elevation",
|
||||
),
|
||||
).rstrip("/")
|
||||
# Backward-compatible alias for Open-Meteo-compatible fallback API.
|
||||
ELEVATION_API_URL = ELEVATION_FALLBACK_URL
|
||||
ELEVATION_PROBE_TTL_SEC = float(
|
||||
os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60")
|
||||
)
|
||||
|
||||
+235
-41
@@ -1,4 +1,4 @@
|
||||
"""Terrain elevation via self-hosted Open-Meteo-compatible API."""
|
||||
"""Terrain elevation: OpenTopoData primary, Open-Meteo-compatible fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,8 +10,9 @@ from typing import Any, Optional
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
ELEVATION_API_URL,
|
||||
ELEVATION_CONNECT_TIMEOUT,
|
||||
ELEVATION_FALLBACK_URL,
|
||||
ELEVATION_OPENTOPO_URL,
|
||||
ELEVATION_PROBE_TTL_SEC,
|
||||
)
|
||||
|
||||
@@ -21,8 +22,11 @@ _BATCH_SIZE = 100
|
||||
_MAX_PROFILE_POINTS = 500
|
||||
_CACHE: dict[tuple[float, float], Optional[float]] = {}
|
||||
_probe_checked_at = 0.0
|
||||
_probe_ok = False
|
||||
_probe_error: Optional[str] = None
|
||||
_probe_opentopo_ok = False
|
||||
_probe_opentopo_error: Optional[str] = None
|
||||
_probe_fallback_ok = False
|
||||
_probe_fallback_error: Optional[str] = None
|
||||
_last_fetch_source: Optional[str] = None
|
||||
|
||||
|
||||
def _cache_key(lat: float, lon: float) -> tuple[float, float]:
|
||||
@@ -42,9 +46,8 @@ def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
||||
"""Ping elevation service before batch requests (cached for TTL)."""
|
||||
global _probe_checked_at, _probe_ok, _probe_error
|
||||
def _probe_opentopodata(force: bool = False) -> dict[str, Any]:
|
||||
global _probe_checked_at, _probe_opentopo_ok, _probe_opentopo_error
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
@@ -53,35 +56,129 @@ def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
||||
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
||||
):
|
||||
return {
|
||||
"ok": _probe_ok,
|
||||
"url": ELEVATION_API_URL,
|
||||
"error": _probe_error,
|
||||
"ok": _probe_opentopo_ok,
|
||||
"url": ELEVATION_OPENTOPO_URL,
|
||||
"error": _probe_opentopo_error,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(
|
||||
ELEVATION_API_URL,
|
||||
ELEVATION_OPENTOPO_URL,
|
||||
params={"locations": "0.000000,0.000000"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("status") != "OK":
|
||||
raise ValueError(f"status={data.get('status')}")
|
||||
results = data.get("results") or []
|
||||
if not results or results[0].get("elevation") is None:
|
||||
raise ValueError("response has no elevation values")
|
||||
_probe_opentopo_ok = True
|
||||
_probe_opentopo_error = None
|
||||
logger.info("OpenTopoData ok: %s", ELEVATION_OPENTOPO_URL)
|
||||
except Exception as e:
|
||||
_probe_opentopo_ok = False
|
||||
_probe_opentopo_error = str(e)
|
||||
logger.warning(
|
||||
"OpenTopoData unreachable %s: %s", ELEVATION_OPENTOPO_URL, e
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": _probe_opentopo_ok,
|
||||
"url": ELEVATION_OPENTOPO_URL,
|
||||
"error": _probe_opentopo_error,
|
||||
}
|
||||
|
||||
|
||||
def _probe_fallback(force: bool = False) -> dict[str, Any]:
|
||||
global _probe_checked_at, _probe_fallback_ok, _probe_fallback_error
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
not force
|
||||
and _probe_checked_at > 0
|
||||
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
||||
):
|
||||
return {
|
||||
"ok": _probe_fallback_ok,
|
||||
"url": ELEVATION_FALLBACK_URL,
|
||||
"error": _probe_fallback_error,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(
|
||||
ELEVATION_FALLBACK_URL,
|
||||
params={"latitude": "0.000000", "longitude": "0.000000"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if "elevation" not in data:
|
||||
raise ValueError("response has no elevation field")
|
||||
_probe_checked_at = now
|
||||
_probe_ok = True
|
||||
_probe_error = None
|
||||
logger.info("elevation API ok: %s", ELEVATION_API_URL)
|
||||
_probe_fallback_ok = True
|
||||
_probe_fallback_error = None
|
||||
logger.info("elevation fallback ok: %s", ELEVATION_FALLBACK_URL)
|
||||
except Exception as e:
|
||||
_probe_checked_at = now
|
||||
_probe_ok = False
|
||||
_probe_error = str(e)
|
||||
logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e)
|
||||
_probe_fallback_ok = False
|
||||
_probe_fallback_error = str(e)
|
||||
logger.warning(
|
||||
"elevation fallback unreachable %s: %s", ELEVATION_FALLBACK_URL, e
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": _probe_ok,
|
||||
"url": ELEVATION_API_URL,
|
||||
"error": _probe_error,
|
||||
"ok": _probe_fallback_ok,
|
||||
"url": ELEVATION_FALLBACK_URL,
|
||||
"error": _probe_fallback_error,
|
||||
}
|
||||
|
||||
|
||||
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
||||
"""Ping elevation providers before batch requests (cached for TTL)."""
|
||||
global _probe_checked_at
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
not force
|
||||
and _probe_checked_at > 0
|
||||
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
||||
):
|
||||
op = {
|
||||
"ok": _probe_opentopo_ok,
|
||||
"url": ELEVATION_OPENTOPO_URL,
|
||||
"error": _probe_opentopo_error,
|
||||
}
|
||||
fb = {
|
||||
"ok": _probe_fallback_ok,
|
||||
"url": ELEVATION_FALLBACK_URL,
|
||||
"error": _probe_fallback_error,
|
||||
}
|
||||
else:
|
||||
op = _probe_opentopodata(force=True)
|
||||
fb = _probe_fallback(force=True)
|
||||
_probe_checked_at = now
|
||||
|
||||
ok = op["ok"] or fb["ok"]
|
||||
if op["ok"]:
|
||||
url = op["url"]
|
||||
error = None
|
||||
elif fb["ok"]:
|
||||
url = fb["url"]
|
||||
error = None
|
||||
else:
|
||||
url = ELEVATION_OPENTOPO_URL
|
||||
error = f"opentopodata: {op['error']}; fallback: {fb['error']}"
|
||||
|
||||
return {
|
||||
"ok": ok,
|
||||
"url": url,
|
||||
"error": error,
|
||||
"opentopodata_ok": op["ok"],
|
||||
"opentopodata_url": op["url"],
|
||||
"opentopodata_error": op["error"],
|
||||
"fallback_ok": fb["ok"],
|
||||
"fallback_url": fb["url"],
|
||||
"fallback_error": fb["error"],
|
||||
}
|
||||
|
||||
|
||||
@@ -91,10 +188,40 @@ def elevation_status(force: bool = False) -> dict[str, Any]:
|
||||
"elevation_ok": probe["ok"],
|
||||
"elevation_url": probe["url"],
|
||||
"elevation_error": probe["error"],
|
||||
"elevation_opentopodata_ok": probe.get("opentopodata_ok"),
|
||||
"elevation_opentopodata_url": probe.get("opentopodata_url"),
|
||||
"elevation_fallback_ok": probe.get("fallback_ok"),
|
||||
"elevation_fallback_url": probe.get("fallback_url"),
|
||||
}
|
||||
|
||||
|
||||
def _fetch_elevation_batch(
|
||||
def _fetch_opentopodata_batch(
|
||||
batch_lat: list[float], batch_lon: list[float]
|
||||
) -> list[Optional[float]]:
|
||||
if not batch_lat:
|
||||
return []
|
||||
locations = "|".join(
|
||||
f"{lat:.6f},{lon:.6f}" for lat, lon in zip(batch_lat, batch_lon)
|
||||
)
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(ELEVATION_OPENTOPO_URL, params={"locations": locations})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("status") != "OK":
|
||||
raise ValueError(f"OpenTopoData status={data.get('status')}")
|
||||
results = data.get("results") or []
|
||||
out: list[Optional[float]] = []
|
||||
for j, item in enumerate(results):
|
||||
if j >= len(batch_lat):
|
||||
break
|
||||
elev = item.get("elevation")
|
||||
out.append(None if elev is None else float(elev))
|
||||
while len(out) < len(batch_lat):
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_fallback_batch(
|
||||
batch_lat: list[float], batch_lon: list[float]
|
||||
) -> list[Optional[float]]:
|
||||
if not batch_lat:
|
||||
@@ -104,7 +231,7 @@ def _fetch_elevation_batch(
|
||||
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
|
||||
}
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(ELEVATION_API_URL, params=params)
|
||||
r = client.get(ELEVATION_FALLBACK_URL, params=params)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
elevations = data.get("elevation") or []
|
||||
@@ -112,15 +239,81 @@ def _fetch_elevation_batch(
|
||||
for j, elev in enumerate(elevations):
|
||||
if j >= len(batch_lat):
|
||||
break
|
||||
if elev is None:
|
||||
out.append(None)
|
||||
else:
|
||||
out.append(float(elev))
|
||||
out.append(None if elev is None else float(elev))
|
||||
while len(out) < len(batch_lat):
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_batch_with_fallback(
|
||||
batch_lat: list[float], batch_lon: list[float]
|
||||
) -> list[Optional[float]]:
|
||||
global _last_fetch_source
|
||||
|
||||
probe = probe_elevation_api()
|
||||
op_ok = probe.get("opentopodata_ok", False)
|
||||
fb_ok = probe.get("fallback_ok", False)
|
||||
if not op_ok and not fb_ok:
|
||||
return [None] * len(batch_lat)
|
||||
|
||||
out: list[Optional[float]] = [None] * len(batch_lat)
|
||||
used_opentopo = False
|
||||
used_fallback = False
|
||||
|
||||
if op_ok:
|
||||
try:
|
||||
out = _fetch_opentopodata_batch(batch_lat, batch_lon)
|
||||
used_opentopo = any(v is not None for v in out)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"OpenTopoData batch failed (%s points): %s", len(batch_lat), e
|
||||
)
|
||||
out = [None] * len(batch_lat)
|
||||
|
||||
missing_idx = [i for i, v in enumerate(out) if v is None]
|
||||
if missing_idx and fb_ok:
|
||||
miss_lat = [batch_lat[i] for i in missing_idx]
|
||||
miss_lon = [batch_lon[i] for i in missing_idx]
|
||||
try:
|
||||
fb_vals = _fetch_fallback_batch(miss_lat, miss_lon)
|
||||
for j, idx in enumerate(missing_idx):
|
||||
out[idx] = fb_vals[j] if j < len(fb_vals) else None
|
||||
if any(v is not None for v in fb_vals):
|
||||
used_fallback = True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"elevation fallback batch failed (%s points): %s",
|
||||
len(miss_lat),
|
||||
e,
|
||||
)
|
||||
|
||||
if used_opentopo and used_fallback:
|
||||
_last_fetch_source = "opentopodata+openmeteo"
|
||||
elif used_opentopo:
|
||||
_last_fetch_source = "opentopodata"
|
||||
elif used_fallback:
|
||||
_last_fetch_source = "openmeteo"
|
||||
else:
|
||||
_last_fetch_source = None
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _active_elevation_url() -> str:
|
||||
probe = probe_elevation_api()
|
||||
if probe.get("opentopodata_ok"):
|
||||
return ELEVATION_OPENTOPO_URL
|
||||
if probe.get("fallback_ok"):
|
||||
return ELEVATION_FALLBACK_URL
|
||||
return ELEVATION_OPENTOPO_URL
|
||||
|
||||
|
||||
def _active_api_source() -> str:
|
||||
return _last_fetch_source or (
|
||||
"opentopodata" if probe_elevation_api().get("opentopodata_ok") else "openmeteo"
|
||||
)
|
||||
|
||||
|
||||
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
|
||||
vals = fetch_elevations_batch([lat], [lon])
|
||||
return vals[0] if vals else None
|
||||
@@ -135,7 +328,7 @@ def fetch_elevations_batch(
|
||||
probe = probe_elevation_api()
|
||||
if not probe["ok"]:
|
||||
logger.warning(
|
||||
"skip elevation fetch: API unreachable (%s)",
|
||||
"skip elevation fetch: all providers unreachable (%s)",
|
||||
probe.get("error"),
|
||||
)
|
||||
return [None] * len(lats)
|
||||
@@ -159,14 +352,15 @@ def fetch_elevations_batch(
|
||||
batch_lat = pending_lat[start : start + _BATCH_SIZE]
|
||||
batch_lon = pending_lon[start : start + _BATCH_SIZE]
|
||||
try:
|
||||
batch_vals = _fetch_elevation_batch(batch_lat, batch_lon)
|
||||
batch_vals = _fetch_batch_with_fallback(batch_lat, batch_lon)
|
||||
for j, val in enumerate(batch_vals):
|
||||
lat = batch_lat[j]
|
||||
lon = batch_lon[j]
|
||||
_CACHE[_cache_key(lat, lon)] = val
|
||||
out[batch_i[j]] = val
|
||||
logger.info(
|
||||
"elevation ok: %s points, sample=%s",
|
||||
"elevation ok (%s): %s points, sample=%s",
|
||||
_last_fetch_source,
|
||||
len(batch_lat),
|
||||
batch_vals[0] if batch_vals else None,
|
||||
)
|
||||
@@ -178,7 +372,7 @@ def fetch_elevations_batch(
|
||||
)
|
||||
for j in range(len(batch_lat)):
|
||||
try:
|
||||
single = _fetch_elevation_batch(
|
||||
single = _fetch_batch_with_fallback(
|
||||
[batch_lat[j]], [batch_lon[j]]
|
||||
)
|
||||
val = single[0] if single else None
|
||||
@@ -329,7 +523,7 @@ def build_elevation_profile(
|
||||
"total_m": 0.0,
|
||||
"api_source": "elevation",
|
||||
"api_error": f"elevation API unreachable: {probe['error']}",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
lats = [s["lat"] for s in samples]
|
||||
@@ -356,8 +550,8 @@ def build_elevation_profile(
|
||||
"min_elevation_m": min(elev_vals) if elev_vals else None,
|
||||
"max_elevation_m": max(elev_vals) if elev_vals else None,
|
||||
"points": profile,
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"api_source": _active_api_source(),
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
if not elev_vals:
|
||||
result["api_error"] = "elevation API returned no values"
|
||||
@@ -429,7 +623,7 @@ def build_elevation_grid(
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"elevation API unreachable: {probe['error']}",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
radius_m = max(50.0, min(float(radius_m), 500.0))
|
||||
@@ -481,8 +675,8 @@ def build_elevation_grid(
|
||||
"points": points,
|
||||
"min_delta_m": round(min(deltas), 1),
|
||||
"max_delta_m": round(max(deltas), 1),
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"api_source": _active_api_source(),
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
|
||||
@@ -499,7 +693,7 @@ def find_nearest_hill(
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"elevation API unreachable: {probe['error']}",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
radius_m = max(500.0, min(float(radius_m), 15_000.0))
|
||||
@@ -590,6 +784,6 @@ def find_nearest_hill(
|
||||
"candidates": len(candidates),
|
||||
"radius_m": radius_m,
|
||||
"step_m": step_m,
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
"api_source": _active_api_source(),
|
||||
"elevation_url": _active_elevation_url(),
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class TelemetryIn:
|
||||
role: Optional[str] = None
|
||||
ts: Optional[float] = None
|
||||
source: str = "android"
|
||||
device_label: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+142
-21
@@ -15,6 +15,8 @@ WEB_SENDER_ID = "web"
|
||||
COMMAND_KINDS = frozenset({"at", "mode", "stats_push"})
|
||||
PAIRED_ONLINE_SEC = 30.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__)
|
||||
|
||||
@@ -88,14 +90,27 @@ def record_telemetry(data: TelemetryIn) -> dict[str, Any]:
|
||||
ts = data.ts if data.ts is not None else time.time()
|
||||
lat, lon = _sanitize_coords(data.lat, data.lon)
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO devices (device_id, label, last_seen)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET last_seen = excluded.last_seen
|
||||
""",
|
||||
(data.device_id, data.device_id, ts),
|
||||
)
|
||||
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(
|
||||
"""
|
||||
INSERT INTO devices (device_id, label, last_seen)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET last_seen = excluded.last_seen
|
||||
""",
|
||||
(data.device_id, data.device_id, ts),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO telemetry
|
||||
@@ -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]]:
|
||||
with _db() as conn:
|
||||
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
|
||||
FROM devices d
|
||||
INNER JOIN telemetry t ON t.id = (
|
||||
@@ -150,8 +184,13 @@ def list_devices() -> list[dict[str, Any]]:
|
||||
ORDER BY d.last_seen DESC
|
||||
"""
|
||||
).fetchall()
|
||||
cutoff = time.time() - DEVICE_VISIBLE_SEC
|
||||
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:
|
||||
@@ -164,6 +203,7 @@ def _is_null_island(device: dict[str, Any]) -> bool:
|
||||
def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"device_id": row["device_id"],
|
||||
"label": row["label"] if "label" in row.keys() else None,
|
||||
"last_seen": row["last_seen"],
|
||||
"lat": row["lat"],
|
||||
"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}
|
||||
|
||||
|
||||
def sync_track(
|
||||
device_id: str,
|
||||
points: list[dict[str, Any]],
|
||||
track_id: Optional[int] = None,
|
||||
started_at: Optional[float] = None,
|
||||
finish: bool = False,
|
||||
label: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload buffered points after offline recording; optionally create track and finish."""
|
||||
if not is_valid_device_id(device_id):
|
||||
raise ValueError(f"invalid device_id '{device_id}'")
|
||||
points = points or []
|
||||
|
||||
if track_id is not None:
|
||||
with _db() as conn:
|
||||
track = conn.execute(
|
||||
"SELECT id, device_id, ended_at FROM tracks WHERE id = ?",
|
||||
(track_id,),
|
||||
).fetchone()
|
||||
if not track:
|
||||
raise ValueError(f"track {track_id} not found")
|
||||
if track["device_id"] != device_id:
|
||||
raise ValueError("device_id does not match track owner")
|
||||
if track["ended_at"] is not None:
|
||||
raise ValueError(f"track {track_id} already finished")
|
||||
else:
|
||||
if not points and not finish:
|
||||
raise ValueError("points required when creating a new track")
|
||||
ts = float(started_at) if started_at is not None else time.time()
|
||||
with _db() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO tracks (device_id, started_at, label)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(device_id, ts, label),
|
||||
)
|
||||
track_id = int(cur.lastrowid)
|
||||
|
||||
added = 0
|
||||
batch_size = 100
|
||||
for i in range(0, len(points), batch_size):
|
||||
chunk = points[i : i + batch_size]
|
||||
if not chunk:
|
||||
continue
|
||||
result = add_track_points(track_id, chunk)
|
||||
added += int(result.get("added") or 0)
|
||||
|
||||
finished = False
|
||||
ended_at = None
|
||||
point_count = added
|
||||
if finish:
|
||||
fin = finish_track(track_id)
|
||||
finished = True
|
||||
ended_at = fin.get("ended_at")
|
||||
point_count = int(fin.get("point_count") or 0)
|
||||
else:
|
||||
with _db() as conn:
|
||||
point_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM track_points WHERE track_id = ?",
|
||||
(track_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"track_id": track_id,
|
||||
"added": added,
|
||||
"point_count": point_count,
|
||||
"finished": finished,
|
||||
"ended_at": ended_at,
|
||||
}
|
||||
|
||||
|
||||
def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
|
||||
limit = min(max(1, limit), 200)
|
||||
with _db() as conn:
|
||||
@@ -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 != ''
|
||||
ORDER BY p.ts DESC LIMIT 1)
|
||||
"""
|
||||
if device_id:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
track_cols = f"""
|
||||
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,
|
||||
{role_sub} AS role
|
||||
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 = ?
|
||||
ORDER BY t.started_at DESC
|
||||
LIMIT ?
|
||||
@@ -337,10 +455,7 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
|
||||
else:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
|
||||
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
|
||||
{role_sub} AS role
|
||||
FROM tracks t
|
||||
{track_cols}
|
||||
ORDER BY t.started_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
@@ -353,7 +468,11 @@ def get_track(track_id: int) -> dict[str, Any]:
|
||||
with _db() as conn:
|
||||
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,),
|
||||
).fetchone()
|
||||
@@ -394,9 +513,11 @@ def get_chat(since: float = 0.0, limit: int = 200) -> list[dict[str, Any]]:
|
||||
with _db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, device_id, text, ts FROM chat
|
||||
WHERE ts > ?
|
||||
ORDER BY ts ASC LIMIT ?
|
||||
SELECT c.id, c.device_id, c.text, c.ts, d.label AS device_label
|
||||
FROM chat c
|
||||
LEFT JOIN devices d ON d.device_id = c.device_id
|
||||
WHERE c.ts > ?
|
||||
ORDER BY c.ts ASC LIMIT ?
|
||||
""",
|
||||
(since, limit),
|
||||
).fetchall()
|
||||
|
||||
@@ -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:
|
||||
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(
|
||||
device_id=str(body["device_id"]),
|
||||
lat=_float_or_none(body.get("lat")),
|
||||
@@ -58,4 +62,5 @@ def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
|
||||
meta=meta,
|
||||
role=role,
|
||||
ts=_float_or_none(body.get("ts")),
|
||||
device_label=device_label,
|
||||
)
|
||||
|
||||
@@ -10,11 +10,13 @@ services:
|
||||
environment:
|
||||
LORATESTER_DB: /data/loratester.db
|
||||
LORATESTER_PORT: "7634"
|
||||
LORATESTER_ELEVATION_OPENTOPO_URL: ${LORATESTER_ELEVATION_OPENTOPO_URL:-http://grigowashere.ru:5300/v1/srtm30}
|
||||
LORATESTER_ELEVATION_FALLBACK_URL: ${LORATESTER_ELEVATION_FALLBACK_URL:-http://192.168.1.109:8085/v1/elevation}
|
||||
LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation}
|
||||
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
|
||||
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
|
||||
LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000}
|
||||
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-10000}
|
||||
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-500000}
|
||||
|
||||
volumes:
|
||||
loratester-data:
|
||||
|
||||
@@ -31,6 +31,7 @@ storage.init_db()
|
||||
|
||||
class TelemetryBody(BaseModel):
|
||||
device_id: str
|
||||
device_label: Optional[str] = None
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
rssi: Optional[float] = None
|
||||
@@ -53,6 +54,10 @@ class TrackStartBody(BaseModel):
|
||||
label: Optional[str] = None
|
||||
|
||||
|
||||
class DeviceLabelBody(BaseModel):
|
||||
label: str
|
||||
|
||||
|
||||
class TrackPoint(BaseModel):
|
||||
ts: Optional[float] = None
|
||||
lat: float
|
||||
@@ -67,6 +72,15 @@ class TrackPointsBody(BaseModel):
|
||||
points: list[TrackPoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TrackSyncBody(BaseModel):
|
||||
device_id: str
|
||||
track_id: Optional[int] = None
|
||||
started_at: Optional[float] = None
|
||||
points: list[TrackPoint] = Field(default_factory=list)
|
||||
finish: bool = False
|
||||
label: Optional[str] = None
|
||||
|
||||
|
||||
class CommandBody(BaseModel):
|
||||
from_device_id: str
|
||||
to_device_id: str
|
||||
@@ -120,6 +134,14 @@ def get_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")
|
||||
def get_telemetry_history(
|
||||
device_id: Optional[str] = None,
|
||||
@@ -180,6 +202,26 @@ def tracks_finish(
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.post("/api/tracks/sync")
|
||||
def tracks_sync(
|
||||
body: TrackSyncBody,
|
||||
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
|
||||
):
|
||||
_require_android(x_lora_client)
|
||||
try:
|
||||
points = [p.model_dump(exclude_none=True) for p in body.points]
|
||||
return storage.sync_track(
|
||||
body.device_id,
|
||||
points,
|
||||
track_id=body.track_id,
|
||||
started_at=body.started_at,
|
||||
finish=body.finish,
|
||||
label=body.label,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/tracks")
|
||||
def tracks_list(
|
||||
device_id: Optional[str] = None,
|
||||
@@ -366,6 +408,7 @@ def health():
|
||||
return {
|
||||
"ok": status["db_ok"],
|
||||
"ts": time.time(),
|
||||
"api_build": "2026-06-19a",
|
||||
**status,
|
||||
**elevation_status(),
|
||||
}
|
||||
|
||||
+974
-187
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -5,7 +5,10 @@
|
||||
const KNOWN_LABELS = new Set([
|
||||
'send', 'receive', 'frequency', 'power', 'rssi', 'snr',
|
||||
'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) {
|
||||
@@ -22,6 +25,15 @@
|
||||
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) {
|
||||
const snap = {
|
||||
role: roleFallback || null,
|
||||
@@ -39,6 +51,18 @@
|
||||
rxPktPerS: null,
|
||||
perPercent: 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: {}
|
||||
};
|
||||
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.per_percent != null) snap.perPercent = Number(o.per_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') {
|
||||
for (const [k, v] of Object.entries(o.fields)) {
|
||||
if (!isKnownLabel(k)) snap.extraFields[k] = String(v);
|
||||
@@ -77,12 +114,17 @@
|
||||
function diffSnapshots(a, b) {
|
||||
const changed = new Set();
|
||||
if (!a || !b) return changed;
|
||||
const keys = ['role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent',
|
||||
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm'];
|
||||
const map = { role: 'role', rssiDbm: 'rssi', snrDb: 'snr', rxQualityPercent: 'rxQuality',
|
||||
packet: 'packet',
|
||||
payload: 'payload', perPercent: 'per', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed',
|
||||
frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power' };
|
||||
const keys = ['gps', 'packetTime', 'role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent',
|
||||
'packetReceive', 'packetTotal', 'packetError', 'crcError', 'preambleDetected', 'headerValid',
|
||||
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm', 'codeRate', 'crcEnabled'];
|
||||
const map = {
|
||||
gps: 'gps', packetTime: 'packetTime', role: 'role', rssiDbm: 'rssi', snrDb: 'snr',
|
||||
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) {
|
||||
if (a[k] !== b[k] && !(a[k] == null && b[k] == null)) changed.add(map[k]);
|
||||
}
|
||||
@@ -90,12 +132,20 @@
|
||||
}
|
||||
|
||||
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: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' },
|
||||
{ key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' },
|
||||
{ key: 'packet', label: 'Пакет', fmt: s => s.packet != null ? String(s.packet) : '—' },
|
||||
{ key: 'payload', label: 'Payload', fmt: s => s.payload || '—' },
|
||||
{ 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: '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: '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: '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: '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` : '—' }
|
||||
];
|
||||
|
||||
@@ -116,8 +172,10 @@
|
||||
|
||||
function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) {
|
||||
let html = '<div class="radio-compare-grid">';
|
||||
html += `<div class="radio-compare-head"><span class="legend-tx">TX</span> ${escapeHtml(txId || '—')}`;
|
||||
html += `<span class="legend-rx">RX</span> ${escapeHtml(rxId || '—')}</div>`;
|
||||
html += '<div class="radio-compare-head">';
|
||||
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) {
|
||||
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
|
||||
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -23,6 +23,16 @@ class _FakeClient:
|
||||
return False
|
||||
|
||||
def get(self, url, params=None):
|
||||
params = params or {}
|
||||
if "locations" in params:
|
||||
locs = params["locations"].split("|")
|
||||
return _FakeResponse({
|
||||
"status": "OK",
|
||||
"results": [
|
||||
{"elevation": 11.0 + i, "location": {"lat": 0, "lng": 0}}
|
||||
for i, _ in enumerate(locs)
|
||||
],
|
||||
})
|
||||
return _FakeResponse({"elevation": [152.0]})
|
||||
|
||||
|
||||
@@ -34,13 +44,20 @@ def test_probe_elevation_api_ok(monkeypatch):
|
||||
|
||||
assert status["ok"] is True
|
||||
assert status["error"] is None
|
||||
assert status["opentopodata_ok"] is True
|
||||
|
||||
|
||||
def test_fetch_skips_when_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
lambda force=False: {
|
||||
"ok": False,
|
||||
"url": elev.ELEVATION_OPENTOPO_URL,
|
||||
"error": "down",
|
||||
"opentopodata_ok": False,
|
||||
"fallback_ok": False,
|
||||
},
|
||||
)
|
||||
|
||||
vals = elev.fetch_elevations_batch([55.75], [37.62])
|
||||
@@ -52,7 +69,13 @@ def test_build_profile_reports_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
lambda force=False: {
|
||||
"ok": False,
|
||||
"url": elev.ELEVATION_OPENTOPO_URL,
|
||||
"error": "down",
|
||||
"opentopodata_ok": False,
|
||||
"fallback_ok": False,
|
||||
},
|
||||
)
|
||||
|
||||
profile = elev.build_elevation_profile(
|
||||
@@ -76,7 +99,16 @@ def test_resample_track_path_count_even_spacing():
|
||||
|
||||
def test_build_profile_target_points(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"fetch_elevations_batch",
|
||||
@@ -96,7 +128,13 @@ def test_find_nearest_hill_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
lambda force=False: {
|
||||
"ok": False,
|
||||
"url": elev.ELEVATION_OPENTOPO_URL,
|
||||
"error": "down",
|
||||
"opentopodata_ok": False,
|
||||
"fallback_ok": False,
|
||||
},
|
||||
)
|
||||
result = elev.find_nearest_hill(55.75, 37.62)
|
||||
assert result["ok"] is False
|
||||
@@ -104,7 +142,16 @@ def test_find_nearest_hill_unreachable(monkeypatch):
|
||||
|
||||
def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
|
||||
def fake_batch(lats, lons):
|
||||
out = []
|
||||
@@ -125,7 +172,16 @@ def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
|
||||
|
||||
def test_build_elevation_grid_delta(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
|
||||
def fake_batch(lats, lons):
|
||||
return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(lats, lons)]
|
||||
@@ -143,7 +199,16 @@ def test_build_elevation_grid_delta(monkeypatch):
|
||||
|
||||
def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 120.0)
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
@@ -159,7 +224,16 @@ def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
|
||||
|
||||
def test_build_elevation_grid_limits_points(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 50.0)
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
@@ -170,3 +244,31 @@ def test_build_elevation_grid_limits_points(monkeypatch):
|
||||
step = elev._resolve_grid_step(55.75, 37.62, 500.0, 5.0)
|
||||
cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step)
|
||||
assert len(cells) <= elev._MAX_GRID_POINTS
|
||||
|
||||
|
||||
def test_fetch_uses_fallback_when_opentopo_missing(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_CACHE", {})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"opentopodata_ok": True,
|
||||
"fallback_ok": True,
|
||||
},
|
||||
)
|
||||
|
||||
def fake_opentopo(batch_lat, batch_lon):
|
||||
return [None] * len(batch_lat)
|
||||
|
||||
def fake_fallback(batch_lat, batch_lon):
|
||||
return [42.0] * len(batch_lat)
|
||||
|
||||
monkeypatch.setattr(elev, "_fetch_opentopodata_batch", fake_opentopo)
|
||||
monkeypatch.setattr(elev, "_fetch_fallback_batch", fake_fallback)
|
||||
|
||||
vals = elev.fetch_elevations_batch([55.75], [37.62])
|
||||
|
||||
assert vals == [42.0]
|
||||
assert elev._last_fetch_source == "openmeteo"
|
||||
|
||||
@@ -45,6 +45,50 @@ def test_old_telemetry_without_meta_gets_migrated(temp_db):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_sync_track_offline_upload(temp_db, monkeypatch):
|
||||
storage.init_db()
|
||||
monkeypatch.setattr(storage, "fetch_elevation_m", lambda lat, lon: 100.0)
|
||||
|
||||
start = storage.start_track("android-12345678")
|
||||
tid = start["track_id"]
|
||||
|
||||
result = storage.sync_track(
|
||||
"android-12345678",
|
||||
[
|
||||
{"ts": 1.0, "lat": 55.75, "lon": 37.62, "role": "TX"},
|
||||
{"ts": 2.0, "lat": 55.751, "lon": 37.621, "role": "TX"},
|
||||
],
|
||||
track_id=tid,
|
||||
finish=True,
|
||||
)
|
||||
assert result["added"] == 2
|
||||
assert result["finished"] is True
|
||||
assert result["point_count"] == 2
|
||||
|
||||
track = storage.get_track(tid)
|
||||
assert len(track["points"]) == 2
|
||||
assert track["ended_at"] is not None
|
||||
|
||||
|
||||
def test_sync_track_create_offline(temp_db, monkeypatch):
|
||||
storage.init_db()
|
||||
monkeypatch.setattr(storage, "fetch_elevation_m", lambda lat, lon: 50.0)
|
||||
|
||||
result = storage.sync_track(
|
||||
"android-abcdef01",
|
||||
[
|
||||
{"ts": 10.0, "lat": 59.93, "lon": 30.33, "role": "RX"},
|
||||
],
|
||||
track_id=None,
|
||||
started_at=10.0,
|
||||
finish=True,
|
||||
)
|
||||
assert result["track_id"] > 0
|
||||
assert result["point_count"] == 1
|
||||
track = storage.get_track(result["track_id"])
|
||||
assert track["started_at"] == 10.0
|
||||
|
||||
|
||||
def test_tracks_crud(temp_db):
|
||||
storage.init_db()
|
||||
start = storage.start_track("android-12345678")
|
||||
|
||||
Reference in New Issue
Block a user