generated from Grigo/AndroidTemplate
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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);
|
||||
|
||||
@@ -75,35 +82,29 @@ 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()) {
|
||||
private void pollCommandsSafe() {
|
||||
if (!running.get()) {
|
||||
return;
|
||||
}
|
||||
pollCommands();
|
||||
}
|
||||
if (running.get()) {
|
||||
mainHandler.postDelayed(this::scheduleCommandPoll, COMMAND_POLL_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void schedulePairedPoll() {
|
||||
executor.execute(() -> {
|
||||
if (running.get()) {
|
||||
private void pollPairedSafe() {
|
||||
if (!running.get()) {
|
||||
return;
|
||||
}
|
||||
pollPairedSession();
|
||||
}
|
||||
if (running.get()) {
|
||||
mainHandler.postDelayed(this::schedulePairedPoll, PAIRED_POLL_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void pollCommands() {
|
||||
try {
|
||||
@@ -181,7 +182,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() {
|
||||
@@ -42,6 +44,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 +78,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();
|
||||
|
||||
@@ -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)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
startLocationIfPermitted();
|
||||
} else {
|
||||
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 startLocationIfPermitted() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
private void requestNotificationPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
return;
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
locationTracker.start();
|
||||
return;
|
||||
}
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
|
||||
}
|
||||
|
||||
private void requestBackgroundLocationIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return;
|
||||
}
|
||||
if (hasBackgroundLocation()) {
|
||||
return;
|
||||
}
|
||||
if (backgroundLocationRequested) {
|
||||
return;
|
||||
}
|
||||
backgroundLocationRequested = true;
|
||||
if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_BACKGROUND_LOCATION)) {
|
||||
Toast.makeText(this, R.string.background_location_rationale, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
backgroundLocationLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION);
|
||||
}
|
||||
|
||||
private void startBackgroundWork() {
|
||||
LoraForegroundService.ensureRunning(this);
|
||||
if (settings.isTelnetEnabled()) {
|
||||
app.getTelemetryUploader().startTelnet();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
locationTracker.stop();
|
||||
telemetryUploader.stopTelnet();
|
||||
super.onDestroy();
|
||||
private boolean hasForegroundLocation() {
|
||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,7 +33,11 @@ public class LocationTracker {
|
||||
}
|
||||
LocationRequest request = new LocationRequest.Builder(
|
||||
Priority.PRIORITY_HIGH_ACCURACY, 10_000L
|
||||
).setMinUpdateIntervalMillis(5_000L).build();
|
||||
)
|
||||
.setMinUpdateIntervalMillis(5_000L)
|
||||
.setMaxUpdateDelayMillis(15_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()) {
|
||||
|
||||
@@ -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;
|
||||
@@ -122,6 +125,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 +154,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;
|
||||
@@ -196,6 +202,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,6 +215,7 @@ public class MapFragment extends Fragment {
|
||||
btnFindHill.setOnClickListener(v -> toggleHill());
|
||||
}
|
||||
setupHeatmapUi();
|
||||
setupBasemapUi();
|
||||
|
||||
updateConnectionIcons(lastDevices, serverConnected);
|
||||
|
||||
@@ -347,18 +355,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;
|
||||
@@ -707,29 +715,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),
|
||||
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
|
||||
);
|
||||
polyline.getLatLongs().addAll(line);
|
||||
addTrackLayer(polyline);
|
||||
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 +1083,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +199,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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -222,6 +222,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"
|
||||
|
||||
@@ -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>
|
||||
@@ -81,6 +82,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 +113,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>
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,7 @@ class TelemetryIn:
|
||||
role: Optional[str] = None
|
||||
ts: Optional[float] = None
|
||||
source: str = "android"
|
||||
device_label: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+61
-13
@@ -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,6 +90,19 @@ 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:
|
||||
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)
|
||||
@@ -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"],
|
||||
@@ -321,13 +361,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 +382,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 +395,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 +440,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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -120,6 +125,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,
|
||||
@@ -366,6 +379,7 @@ def health():
|
||||
return {
|
||||
"ok": status["db_ok"],
|
||||
"ts": time.time(),
|
||||
"api_build": "2026-06-16g",
|
||||
**status,
|
||||
**elevation_status(),
|
||||
}
|
||||
|
||||
+750
-152
File diff suppressed because it is too large
Load Diff
+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' : '';
|
||||
|
||||
Reference in New Issue
Block a user