8 Commits
v8 .. v9

Author SHA1 Message Date
Grigo 40a1ccab1e added more fields 2026-06-16 12:53:43 +03:00
Grigo e71b6eed2f added sleepmode 2026-06-16 12:42:36 +03:00
Grigo dbef86d2c9 fix elevation 2026-06-16 12:04:23 +03:00
Grigo 6b34e75f35 fix elevation 2026-06-16 11:54:41 +03:00
Grigo 0e1fa15a2f fix 2026-06-16 11:42:23 +03:00
Grigo 64607def4a fix 2026-06-16 11:24:21 +03:00
Grigo 3399e81447 fix 2026-06-16 11:10:15 +03:00
Grigo 0571291b69 update 2026-06-16 10:36:18 +03:00
32 changed files with 1879 additions and 306 deletions
+10
View File
@@ -6,6 +6,12 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<application <application
@@ -30,6 +36,10 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".LoraForegroundService"
android:exported="false"
android:foregroundServiceType="location|dataSync" />
</application> </application>
</manifest> </manifest>
@@ -18,6 +18,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
public class CommandPoller { public class CommandPoller {
@@ -32,6 +34,11 @@ public class CommandPoller {
private final TrackRecorder trackRecorder; private final TrackRecorder trackRecorder;
private final PeerStatsCache peerStatsCache; private final PeerStatsCache peerStatsCache;
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "CommandPoller");
t.setDaemon(true);
return t;
});
private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final AtomicBoolean running = new AtomicBoolean(false); private final AtomicBoolean running = new AtomicBoolean(false);
@@ -75,35 +82,29 @@ public class CommandPoller {
if (!running.compareAndSet(false, true)) { if (!running.compareAndSet(false, true)) {
return; return;
} }
scheduleCommandPoll(); scheduler.scheduleWithFixedDelay(
schedulePairedPoll(); this::pollCommandsSafe, 0, COMMAND_POLL_MS, TimeUnit.MILLISECONDS);
scheduler.scheduleWithFixedDelay(
this::pollPairedSafe, 0, PAIRED_POLL_MS, TimeUnit.MILLISECONDS);
} }
public void stop() { public void stop() {
running.set(false); running.set(false);
} }
private void scheduleCommandPoll() { private void pollCommandsSafe() {
executor.execute(() -> { if (!running.get()) {
if (running.get()) { return;
}
pollCommands(); pollCommands();
} }
if (running.get()) {
mainHandler.postDelayed(this::scheduleCommandPoll, COMMAND_POLL_MS);
}
});
}
private void schedulePairedPoll() { private void pollPairedSafe() {
executor.execute(() -> { if (!running.get()) {
if (running.get()) { return;
}
pollPairedSession(); pollPairedSession();
} }
if (running.get()) {
mainHandler.postDelayed(this::schedulePairedPoll, PAIRED_POLL_MS);
}
});
}
private void pollCommands() { private void pollCommands() {
try { try {
@@ -181,7 +182,7 @@ public class CommandPoller {
} }
startedSessionId = session.id; startedSessionId = session.id;
pendingAckSessionId = session.id; pendingAckSessionId = session.id;
mainHandler.post(trackRecorder::start); trackRecorder.start();
} catch (Exception e) { } catch (Exception e) {
Log.w(TAG, "paired poll failed", e); Log.w(TAG, "paired poll failed", e);
} }
@@ -3,6 +3,7 @@ package com.grigowashere.loratester;
import android.app.Application; import android.app.Application;
import com.grigowashere.loratester.api.ServerApi; import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.location.LocationTracker;
import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.track.TrackRecorder; import com.grigowashere.loratester.track.TrackRecorder;
@@ -16,6 +17,7 @@ public class LoraApp extends Application {
private NetworkMonitor networkMonitor; private NetworkMonitor networkMonitor;
private PeerStatsCache peerStatsCache; private PeerStatsCache peerStatsCache;
private CommandPoller commandPoller; private CommandPoller commandPoller;
private LocationTracker locationTracker;
@Override @Override
public void onCreate() { public void onCreate() {
@@ -42,6 +44,14 @@ public class LoraApp extends Application {
peerStatsCache peerStatsCache
); );
commandPoller.start(); commandPoller.start();
telemetryUploader.registerPresence();
if (networkMonitor != null) {
networkMonitor.addListener(online -> {
if (online) {
telemetryUploader.registerPresence();
}
});
}
} }
public NetworkMonitor getNetworkMonitor() { public NetworkMonitor getNetworkMonitor() {
@@ -68,6 +78,22 @@ public class LoraApp extends Application {
return commandPoller; return commandPoller;
} }
public synchronized void startLocationUpdates() {
if (locationTracker == null) {
locationTracker = new LocationTracker(this, (lat, lon, alt) -> {
telemetryUploader.updateLocation(lat, lon);
trackRecorder.updateLocation(lat, lon, alt);
});
}
locationTracker.start();
}
public synchronized void stopLocationUpdates() {
if (locationTracker != null) {
locationTracker.stop();
}
}
public void refreshTrackRecorder() { public void refreshTrackRecorder() {
if (commandPoller != null) { if (commandPoller != null) {
commandPoller.stop(); commandPoller.stop();
@@ -0,0 +1,178 @@
package com.grigowashere.loratester;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.PowerManager;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import com.grigowashere.loratester.track.TrackRecorder;
public class LoraForegroundService extends Service {
private static final String CHANNEL_ID = "lora_background";
private static final int NOTIFICATION_ID = 1;
private final Handler handler = new Handler(Looper.getMainLooper());
private PowerManager.WakeLock wakeLock;
private LoraApp app;
private final Runnable notificationTicker = new Runnable() {
@Override
public void run() {
updateNotification();
handler.postDelayed(this, 5000L);
}
};
public static void ensureRunning(Context context) {
Context appContext = context.getApplicationContext();
Intent intent = new Intent(appContext, LoraForegroundService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent);
} else {
appContext.startService(intent);
}
}
@Override
public void onCreate() {
super.onCreate();
app = (LoraApp) getApplication();
createNotificationChannel();
acquireWakeLock();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Notification notification = buildNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
| ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
);
} else {
startForeground(NOTIFICATION_ID, notification);
}
app.startLocationUpdates();
handler.removeCallbacks(notificationTicker);
handler.post(notificationTicker);
return START_STICKY;
}
@Override
public void onDestroy() {
handler.removeCallbacks(notificationTicker);
releaseWakeLock();
app.stopLocationUpdates();
super.onDestroy();
}
@Override
public void onTaskRemoved(Intent rootIntent) {
TelemetryUploader uploader = app.getTelemetryUploader();
if (uploader != null) {
uploader.stopTelnet();
}
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
super.onTaskRemoved(rootIntent);
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void acquireWakeLock() {
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
if (pm == null) {
return;
}
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "LoraTester::Background");
wakeLock.setReferenceCounted(false);
wakeLock.acquire();
}
private void releaseWakeLock() {
if (wakeLock != null && wakeLock.isHeld()) {
wakeLock.release();
}
wakeLock = null;
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription(getString(R.string.notification_channel_desc));
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm != null) {
nm.createNotificationChannel(channel);
}
}
private Notification buildNotification() {
Intent open = new Intent(this, MainActivity.class);
open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent pending = PendingIntent.getActivity(
this,
0,
open,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
TelemetryUploader uploader = app.getTelemetryUploader();
TrackRecorder recorder = app.getTrackRecorder();
SettingsRepository settings = app.getSettingsRepository();
boolean telnetOn = settings.isTelnetEnabled();
boolean telnetConnected = uploader != null && uploader.isTelnetConnected();
boolean recording = recorder != null && recorder.isRecording();
int points = recorder != null ? recorder.getPointCount() : 0;
String telnetLine = telnetOn
? getString(telnetConnected ? R.string.telnet_connected : R.string.telnet_disconnected)
: getString(R.string.telnet_disabled_short);
String trackLine = recording
? getString(R.string.notification_track_recording, points)
: getString(R.string.notification_track_idle);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_service)
.setContentTitle(getString(R.string.notification_title))
.setContentText(telnetLine + " · " + trackLine)
.setSubText(getString(R.string.notification_subtitle))
.setContentIntent(pending)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build();
}
private void updateNotification() {
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm != null) {
nm.notify(NOTIFICATION_ID, buildNotification());
}
}
}
@@ -1,8 +1,14 @@
package com.grigowashere.loratester; package com.grigowashere.loratester;
import android.Manifest; import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.PowerManager;
import android.provider.Settings;
import android.widget.Toast;
import androidx.activity.EdgeToEdge; import androidx.activity.EdgeToEdge;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
@@ -17,19 +23,30 @@ import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator; import com.google.android.material.tabs.TabLayoutMediator;
import com.grigowashere.loratester.location.LocationTracker;
import com.grigowashere.loratester.track.TrackRecorder;
import com.grigowashere.loratester.ui.MainPagerAdapter; import com.grigowashere.loratester.ui.MainPagerAdapter;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
private TelemetryUploader telemetryUploader; private LoraApp app;
private LocationTracker locationTracker; private SettingsRepository settings;
private boolean backgroundLocationRequested;
private final ActivityResultLauncher<String[]> locationPermissionLauncher = private final ActivityResultLauncher<String[]> locationPermissionLauncher =
registerForActivityResult( registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultContracts.RequestMultiplePermissions(),
result -> startLocationIfPermitted() result -> onForegroundLocationReady()
);
private final ActivityResultLauncher<String> backgroundLocationLauncher =
registerForActivityResult(
new ActivityResultContracts.RequestPermission(),
granted -> startBackgroundWork()
);
private final ActivityResultLauncher<String> notificationPermissionLauncher =
registerForActivityResult(
new ActivityResultContracts.RequestPermission(),
granted -> startBackgroundWork()
); );
@Override @Override
@@ -43,9 +60,8 @@ public class MainActivity extends AppCompatActivity {
return insets; return insets;
}); });
LoraApp app = (LoraApp) getApplication(); app = (LoraApp) getApplication();
telemetryUploader = app.getTelemetryUploader(); settings = app.getSettingsRepository();
SettingsRepository settings = app.getSettingsRepository();
ViewPager2 pager = findViewById(R.id.viewPager); ViewPager2 pager = findViewById(R.id.viewPager);
TabLayout tabs = findViewById(R.id.tabLayout); TabLayout tabs = findViewById(R.id.tabLayout);
@@ -62,41 +78,92 @@ public class MainActivity extends AppCompatActivity {
tab.setText(titleRes); tab.setText(titleRes);
}).attach(); }).attach();
TrackRecorder trackRecorder = app.getTrackRecorder(); requestStartupPermissions();
locationTracker = new LocationTracker(this, (lat, lon, alt) -> {
telemetryUploader.updateLocation(lat, lon);
trackRecorder.updateLocation(lat, lon, alt);
});
requestLocationPermission();
if (settings.isTelnetEnabled()) { if (settings.isTelnetEnabled()) {
telemetryUploader.startTelnet(); app.getTelemetryUploader().startTelnet();
} }
} }
private void requestLocationPermission() { private void requestStartupPermissions() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) if (hasForegroundLocation()) {
== PackageManager.PERMISSION_GRANTED) { onForegroundLocationReady();
startLocationIfPermitted(); return;
} else { }
locationPermissionLauncher.launch(new String[]{ locationPermissionLauncher.launch(new String[]{
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION Manifest.permission.ACCESS_COARSE_LOCATION
}); });
} }
private void onForegroundLocationReady() {
if (!hasForegroundLocation()) {
Toast.makeText(this, R.string.background_location_required, Toast.LENGTH_LONG).show();
return;
}
requestNotificationPermissionIfNeeded();
requestBackgroundLocationIfNeeded();
startBackgroundWork();
} }
private void startLocationIfPermitted() { private void requestNotificationPermissionIfNeeded() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return;
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) { == PackageManager.PERMISSION_GRANTED) {
locationTracker.start(); return;
}
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
}
private void requestBackgroundLocationIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return;
}
if (hasBackgroundLocation()) {
return;
}
if (backgroundLocationRequested) {
return;
}
backgroundLocationRequested = true;
if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_BACKGROUND_LOCATION)) {
Toast.makeText(this, R.string.background_location_rationale, Toast.LENGTH_LONG).show();
}
backgroundLocationLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION);
}
private void startBackgroundWork() {
LoraForegroundService.ensureRunning(this);
if (settings.isTelnetEnabled()) {
app.getTelemetryUploader().startTelnet();
} }
} }
@Override private boolean hasForegroundLocation() {
protected void onDestroy() { return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
locationTracker.stop(); == PackageManager.PERMISSION_GRANTED;
telemetryUploader.stopTelnet(); }
super.onDestroy();
private boolean hasBackgroundLocation() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return hasForegroundLocation();
}
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
== PackageManager.PERMISSION_GRANTED;
}
public static void openBatteryOptimizationSettings(@NonNull android.content.Context context) {
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + context.getPackageName()));
context.startActivity(intent);
}
public static boolean isIgnoringBatteryOptimizations(@NonNull android.content.Context context) {
PowerManager pm = (PowerManager) context.getSystemService(POWER_SERVICE);
if (pm == null) {
return true;
}
return pm.isIgnoringBatteryOptimizations(context.getPackageName());
} }
} }
@@ -13,6 +13,7 @@ public class SettingsRepository {
private static final String KEY_RANGE_REGEX = "range_regex"; private static final String KEY_RANGE_REGEX = "range_regex";
private static final String KEY_TELNET_ENABLED = "telnet_enabled"; private static final String KEY_TELNET_ENABLED = "telnet_enabled";
private static final String KEY_DEVICE_ID = "device_id"; private static final String KEY_DEVICE_ID = "device_id";
private static final String KEY_DEVICE_LABEL = "device_label";
public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru"; public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru";
private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634"; private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634";
@@ -106,4 +107,16 @@ public class SettingsRepository {
} }
return id; return id;
} }
public String getDeviceLabel() {
return prefs.getString(KEY_DEVICE_LABEL, null);
}
public void setDeviceLabel(String label) {
if (label == null) {
prefs.edit().remove(KEY_DEVICE_LABEL).apply();
} else {
prefs.edit().putString(KEY_DEVICE_LABEL, label.trim()).apply();
}
}
} }
@@ -1,6 +1,7 @@
package com.grigowashere.loratester; package com.grigowashere.loratester;
import android.content.Context; import android.content.Context;
import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
@@ -285,6 +286,7 @@ public class TelemetryUploader implements TelnetClient.Listener {
} }
TelemetryPayload payload = new TelemetryPayload( TelemetryPayload payload = new TelemetryPayload(
settings.getOrCreateDeviceId(), settings.getOrCreateDeviceId(),
phoneLabel(),
validLat(), validLat(),
validLon(), validLon(),
stats.rssi, stats.rssi,
@@ -297,6 +299,35 @@ public class TelemetryUploader implements TelnetClient.Listener {
uploadExecutor.execute(() -> uploadTelemetry(payload)); uploadExecutor.execute(() -> uploadTelemetry(payload));
} }
public void registerPresence() {
uploadExecutor.execute(() -> {
TelemetryPayload payload = new TelemetryPayload(
settings.getOrCreateDeviceId(),
phoneLabel(),
null,
null,
null,
null,
null,
null,
null,
System.currentTimeMillis() / 1000.0
);
uploadTelemetry(payload);
});
}
private String phoneLabel() {
String custom = settings.getDeviceLabel();
if (custom != null && !custom.isBlank()) {
return custom.trim();
}
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER : "";
String model = Build.MODEL != null ? Build.MODEL : "";
String label = (manufacturer + " " + model).trim();
return label.isEmpty() ? null : label;
}
private void uploadTelemetry(TelemetryPayload payload) { private void uploadTelemetry(TelemetryPayload payload) {
if (networkMonitor.isOnline()) { if (networkMonitor.isOnline()) {
try { try {
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
public class DeviceInfo { public class DeviceInfo {
public String device_id; public String device_id;
public String label;
public double last_seen; public double last_seen;
public Double lat; public Double lat;
public Double lon; public Double lon;
@@ -51,6 +51,9 @@ public class ServerApi {
public void postTelemetry(TelemetryPayload payload) throws IOException { public void postTelemetry(TelemetryPayload payload) throws IOException {
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("device_id", payload.deviceId); body.put("device_id", payload.deviceId);
if (payload.deviceLabel != null && !payload.deviceLabel.isBlank()) {
body.put("device_label", payload.deviceLabel);
}
if (payload.lat != null) body.put("lat", payload.lat); if (payload.lat != null) body.put("lat", payload.lat);
if (payload.lon != null) body.put("lon", payload.lon); if (payload.lon != null) body.put("lon", payload.lon);
if (payload.rssi != null) body.put("rssi", payload.rssi); if (payload.rssi != null) body.put("rssi", payload.rssi);
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
public class TelemetryPayload { public class TelemetryPayload {
public final String deviceId; public final String deviceId;
public final String deviceLabel;
public final Double lat; public final Double lat;
public final Double lon; public final Double lon;
public final Double rssi; public final Double rssi;
@@ -22,8 +23,24 @@ public class TelemetryPayload {
String meta, String meta,
String role, String role,
Double ts Double ts
) {
this(deviceId, null, lat, lon, rssi, rangeM, rawFrame, meta, role, ts);
}
public TelemetryPayload(
String deviceId,
String deviceLabel,
Double lat,
Double lon,
Double rssi,
Double rangeM,
String rawFrame,
String meta,
String role,
Double ts
) { ) {
this.deviceId = deviceId; this.deviceId = deviceId;
this.deviceLabel = deviceLabel;
this.lat = lat; this.lat = lat;
this.lon = lon; this.lon = lon;
this.rssi = rssi; this.rssi = rssi;
@@ -33,7 +33,11 @@ public class LocationTracker {
} }
LocationRequest request = new LocationRequest.Builder( LocationRequest request = new LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY, 10_000L Priority.PRIORITY_HIGH_ACCURACY, 10_000L
).setMinUpdateIntervalMillis(5_000L).build(); )
.setMinUpdateIntervalMillis(5_000L)
.setMaxUpdateDelayMillis(15_000L)
.setWaitForAccurateLocation(false)
.build();
callback = new LocationCallback() { callback = new LocationCallback() {
@Override @Override
@@ -0,0 +1,37 @@
package com.grigowashere.loratester.map;
import org.mapsforge.core.model.Tile;
import org.mapsforge.map.layer.download.tilesource.OnlineTileSource;
import java.net.MalformedURLException;
import java.net.URL;
/** Esri World Imagery — tile path is zoom/y/x (not OSM zoom/x/y). */
public final class EsriWorldImagery extends OnlineTileSource {
public static final EsriWorldImagery INSTANCE = new EsriWorldImagery();
private EsriWorldImagery() {
super(new String[]{"server.arcgisonline.com"}, 443);
setName("Esri.WorldImagery")
.setAlpha(false)
.setBaseUrl("/ArcGIS/rest/services/World_Imagery/MapServer/tile/")
.setExtension("png")
.setParallelRequestsLimit(4)
.setProtocol("https")
.setTileSize(256)
.setZoomLevelMax((byte) 18)
.setZoomLevelMin((byte) 0);
setUserAgent("LoraTester/1.0");
}
@Override
public URL getTileUrl(Tile tile) throws MalformedURLException {
StringBuilder path = new StringBuilder(48);
path.append(getBaseUrl());
path.append(tile.zoomLevel).append('/');
path.append(tile.tileY).append('/');
path.append(tile.tileX).append('.').append(getExtension());
return new URL(getProtocol(), getHostName(), 443, path.toString());
}
}
@@ -19,7 +19,7 @@ public final class RadioSnapshot {
public final String frame; public final String frame;
public final Double frequencyMhz; public final Double frequencyMhz;
public final Integer sf; public final Integer sf;
public final Integer bwKhz; public final Double bwKhz;
public final Double powerDbm; public final Double powerDbm;
public final Double rssiDbm; public final Double rssiDbm;
public final Double snrDb; public final Double snrDb;
@@ -30,6 +30,18 @@ public final class RadioSnapshot {
public final Double rxPktPerS; public final Double rxPktPerS;
public final Double perPercent; public final Double perPercent;
public final Double rxQualityPercent; public final Double rxQualityPercent;
public final String codeRate;
public final Integer preambleLength;
public final String lowDataRateOpt;
public final Boolean crcEnabled;
public final Integer payloadLengthBytes;
public final Double txTimeoutMs;
public final Integer packetReceive;
public final Integer packetTotal;
public final Integer packetError;
public final Integer crcError;
public final Integer preambleDetected;
public final Integer headerValid;
public final Map<String, String> extraFields; public final Map<String, String> extraFields;
public RadioSnapshot( public RadioSnapshot(
@@ -37,7 +49,7 @@ public final class RadioSnapshot {
String frame, String frame,
Double frequencyMhz, Double frequencyMhz,
Integer sf, Integer sf,
Integer bwKhz, Double bwKhz,
Double powerDbm, Double powerDbm,
Double rssiDbm, Double rssiDbm,
Double snrDb, Double snrDb,
@@ -48,6 +60,18 @@ public final class RadioSnapshot {
Double rxPktPerS, Double rxPktPerS,
Double perPercent, Double perPercent,
Double rxQualityPercent, Double rxQualityPercent,
String codeRate,
Integer preambleLength,
String lowDataRateOpt,
Boolean crcEnabled,
Integer payloadLengthBytes,
Double txTimeoutMs,
Integer packetReceive,
Integer packetTotal,
Integer packetError,
Integer crcError,
Integer preambleDetected,
Integer headerValid,
Map<String, String> extraFields Map<String, String> extraFields
) { ) {
this.role = role; this.role = role;
@@ -65,12 +89,26 @@ public final class RadioSnapshot {
this.rxPktPerS = rxPktPerS; this.rxPktPerS = rxPktPerS;
this.perPercent = perPercent; this.perPercent = perPercent;
this.rxQualityPercent = rxQualityPercent; this.rxQualityPercent = rxQualityPercent;
this.codeRate = codeRate;
this.preambleLength = preambleLength;
this.lowDataRateOpt = lowDataRateOpt;
this.crcEnabled = crcEnabled;
this.payloadLengthBytes = payloadLengthBytes;
this.txTimeoutMs = txTimeoutMs;
this.packetReceive = packetReceive;
this.packetTotal = packetTotal;
this.packetError = packetError;
this.crcError = crcError;
this.preambleDetected = preambleDetected;
this.headerValid = headerValid;
this.extraFields = extraFields != null ? extraFields : Map.of(); this.extraFields = extraFields != null ? extraFields : Map.of();
} }
public static RadioSnapshot empty() { public static RadioSnapshot empty() {
return new RadioSnapshot(null, null, null, null, null, null, null, null, return new RadioSnapshot(null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, Map.of()); null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
Map.of());
} }
public static RadioSnapshot fromMeta(String metaJson, String roleFallback, Double rssiFallback) { public static RadioSnapshot fromMeta(String metaJson, String roleFallback, Double rssiFallback) {
@@ -78,7 +116,9 @@ public final class RadioSnapshot {
RadioSnapshot snap = empty(); RadioSnapshot snap = empty();
if (roleFallback != null || rssiFallback != null) { if (roleFallback != null || rssiFallback != null) {
return new RadioSnapshot(roleFallback, null, null, null, null, null, return new RadioSnapshot(roleFallback, null, null, null, null, null,
rssiFallback, null, null, null, null, null, null, null, null, Map.of()); rssiFallback, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
Map.of());
} }
return snap; return snap;
} }
@@ -108,7 +148,7 @@ public final class RadioSnapshot {
text(o, "frame"), text(o, "frame"),
hzToMhz(lng(o, "frequency_hz")), hzToMhz(lng(o, "frequency_hz")),
integer(o, "spreading_factor"), integer(o, "spreading_factor"),
integer(o, "bandwidth_khz"), dbl(o, "bandwidth_khz"),
dbl(o, "power_dbm"), dbl(o, "power_dbm"),
rssi, rssi,
dbl(o, "snr_db"), dbl(o, "snr_db"),
@@ -119,11 +159,25 @@ public final class RadioSnapshot {
dbl(o, "rx_pkt_per_s"), dbl(o, "rx_pkt_per_s"),
dbl(o, "per_percent"), dbl(o, "per_percent"),
dbl(o, "rx_quality_percent"), dbl(o, "rx_quality_percent"),
text(o, "code_rate"),
integer(o, "preamble_length"),
text(o, "low_data_rate_opt"),
bool(o, "crc_enabled"),
integer(o, "payload_length_bytes"),
dbl(o, "tx_timeout_ms"),
integer(o, "packet_receive"),
integer(o, "packet_total"),
integer(o, "packet_error"),
integer(o, "crc_error"),
integer(o, "preamble_detected"),
integer(o, "header_valid"),
extra extra
); );
} catch (Exception ignored) { } catch (Exception ignored) {
return new RadioSnapshot(roleFallback, null, null, null, null, null, return new RadioSnapshot(roleFallback, null, null, null, null, null,
rssiFallback, null, null, null, null, null, null, null, null, Map.of()); rssiFallback, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
Map.of());
} }
} }
@@ -152,6 +206,14 @@ public final class RadioSnapshot {
cmp(changed, "sf", sf, prev.sf); cmp(changed, "sf", sf, prev.sf);
cmp(changed, "bw", bwKhz, prev.bwKhz); cmp(changed, "bw", bwKhz, prev.bwKhz);
cmp(changed, "power", powerDbm, prev.powerDbm); cmp(changed, "power", powerDbm, prev.powerDbm);
cmp(changed, "packetReceive", packetReceive, prev.packetReceive);
cmp(changed, "packetTotal", packetTotal, prev.packetTotal);
cmp(changed, "packetError", packetError, prev.packetError);
cmp(changed, "crcError", crcError, prev.crcError);
cmp(changed, "preambleDetected", preambleDetected, prev.preambleDetected);
cmp(changed, "headerValid", headerValid, prev.headerValid);
cmp(changed, "codeRate", codeRate, prev.codeRate);
cmp(changed, "crc", crcEnabled, prev.crcEnabled);
return changed; return changed;
} }
@@ -167,8 +229,12 @@ public final class RadioSnapshot {
|| n.contains("frequency") || n.equals("power") || n.equals("rssi") || n.contains("frequency") || n.equals("power") || n.equals("rssi")
|| n.equals("snr") || n.contains("spreading") || n.contains("bandwidth") || n.equals("snr") || n.contains("spreading") || n.contains("bandwidth")
|| n.equals("packet") || n.contains("packet number") || n.equals("payload") || n.equals("packet") || n.contains("packet number") || n.equals("payload")
|| n.contains("packet receive") || n.contains("packet total") || n.contains("packet error")
|| n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid")
|| n.contains("on air") || n.contains("tx speed") || n.contains("rx speed") || n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
|| n.equals("per") || n.contains("rx quality"); || n.equals("per") || n.contains("rx quality") || n.contains("tx timeout")
|| n.contains("code rate") || n.contains("preamble length")
|| n.contains("low data rate") || n.equals("crc") || n.contains("payload length");
} }
private static String text(JsonObject o, String key) { private static String text(JsonObject o, String key) {
@@ -186,6 +252,11 @@ public final class RadioSnapshot {
return e != null && e.isJsonPrimitive() ? e.getAsDouble() : null; return e != null && e.isJsonPrimitive() ? e.getAsDouble() : null;
} }
private static Boolean bool(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsBoolean() : null;
}
private static Long lng(JsonObject o, String key) { private static Long lng(JsonObject o, String key) {
JsonElement e = o.get(key); JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsLong() : null; return e != null && e.isJsonPrimitive() ? e.getAsLong() : null;
@@ -40,6 +40,12 @@ public final class LoraStatsFormatter {
appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed); appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed);
appendLine(sb, "Payload", s.payload, "payload", changed); appendLine(sb, "Payload", s.payload, "payload", changed);
appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed); appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed);
appendLine(sb, "Принято", fmtInt(s.packetReceive), "packetReceive", changed);
appendLine(sb, "Всего пакетов", fmtInt(s.packetTotal), "packetTotal", changed);
appendLine(sb, "Ошибки пакетов", fmtInt(s.packetError), "packetError", changed);
appendLine(sb, "CRC Error", fmtInt(s.crcError), "crcError", changed);
appendLine(sb, "Preamble Det.", fmtInt(s.preambleDetected), "preambleDetected", changed);
appendLine(sb, "Header Valid", fmtInt(s.headerValid), "headerValid", changed);
appendLine(sb, "TX Speed", fmtSuffix(s.txPktPerS, " pkt/s"), "txSpeed", changed); appendLine(sb, "TX Speed", fmtSuffix(s.txPktPerS, " pkt/s"), "txSpeed", changed);
appendLine(sb, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed); appendLine(sb, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed);
for (Map.Entry<String, String> e : s.extraFields.entrySet()) { for (Map.Entry<String, String> e : s.extraFields.entrySet()) {
@@ -58,8 +64,14 @@ public final class LoraStatsFormatter {
} }
appendLine(sb, "Частота", fmtSuffix(s.frequencyMhz, " MHz"), "frequency", changed); appendLine(sb, "Частота", fmtSuffix(s.frequencyMhz, " MHz"), "frequency", changed);
appendLine(sb, "SF", fmtInt(s.sf), "sf", changed); appendLine(sb, "SF", fmtInt(s.sf), "sf", changed);
appendLine(sb, "BW", fmtSuffix(s.bwKhz, " kHz"), "bw", changed); appendLine(sb, "BW", fmtBw(s.bwKhz), "bw", changed);
appendLine(sb, "Мощность TX", fmtDbm(s.powerDbm), "power", changed); appendLine(sb, "Мощность TX", fmtDbm(s.powerDbm), "power", changed);
appendLine(sb, "Code Rate", s.codeRate, "codeRate", changed);
appendLine(sb, "Preamble Len", fmtInt(s.preambleLength), "preambleLength", changed);
appendLine(sb, "Low DR Opt", s.lowDataRateOpt, "lowDataRateOpt", changed);
appendLine(sb, "CRC", fmtCrc(s.crcEnabled), "crc", changed);
appendLine(sb, "Payload len", fmtSuffix(s.payloadLengthBytes, " byte"), "payloadLength", changed);
appendLine(sb, "TX Timeout", fmtSuffix(s.txTimeoutMs, " ms"), "txTimeout", changed);
appendLine(sb, "On Air", fmtSuffix(s.onAirMs, " ms"), "onAir", changed); appendLine(sb, "On Air", fmtSuffix(s.onAirMs, " ms"), "onAir", changed);
return sb.toString().trim(); return sb.toString().trim();
} }
@@ -111,11 +123,22 @@ public final class LoraStatsFormatter {
return v != null ? String.valueOf(v) : null; return v != null ? String.valueOf(v) : null;
} }
private static String fmtSuffix(Double v, String suffix) { private static String fmtBw(Double v) {
return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null; return v != null ? String.format(Locale.US, "%.2f kHz", v) : null;
}
private static String fmtCrc(Boolean enabled) {
if (enabled == null) {
return null;
}
return enabled ? "On" : "Off";
} }
private static String fmtSuffix(Integer v, String suffix) { private static String fmtSuffix(Integer v, String suffix) {
return v != null ? v + suffix : null; return v != null ? v + suffix : null;
} }
private static String fmtSuffix(Double v, String suffix) {
return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null;
}
} }
@@ -25,9 +25,26 @@ public class StatsExtractor {
private static final Pattern SNR = Pattern.compile("SNR\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE); private static final Pattern SNR = Pattern.compile("SNR\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
private static final Pattern FREQUENCY = Pattern.compile("Frequency\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern FREQUENCY = Pattern.compile("Frequency\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern SPREADING = Pattern.compile("Spreading Factor\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern SPREADING = Pattern.compile("Spreading Factor\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET = Pattern.compile("Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern PACKET_TX = Pattern.compile("(?m)^\\s*Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET_NUMBER = Pattern.compile("Packet Number\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern PACKET_NUMBER = Pattern.compile("Packet Number\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET_RECEIVE = Pattern.compile("Packet Receive\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET_TOTAL = Pattern.compile("Packet Total\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET_ERROR = Pattern.compile("Packet Error\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern CRC_ERROR = Pattern.compile("CRC Error\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PREAMBLE_DETECTED = Pattern.compile(
"Preamble Detected\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern HEADER_VALID = Pattern.compile("Header Valid\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern CODE_RATE = Pattern.compile("Code Rate\\s*:\\s*(\\S+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PREAMBLE_LENGTH = Pattern.compile(
"Preamble Length\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern LOW_DATA_RATE = Pattern.compile(
"Low Data Rate Opt\\s*:\\s*(\\S+)", Pattern.CASE_INSENSITIVE);
private static final Pattern CRC = Pattern.compile("CRC\\s*:\\s*(On|Off)", Pattern.CASE_INSENSITIVE);
private static final Pattern PAYLOAD_LENGTH = Pattern.compile(
"Payload length\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern TX_TIMEOUT = Pattern.compile(
"TX Timeout\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PAYLOAD = Pattern.compile("Payload\\s*:\\s*(.+)", Pattern.CASE_INSENSITIVE); private static final Pattern PAYLOAD = Pattern.compile("Payload\\s*:\\s*(.+)", Pattern.CASE_INSENSITIVE);
private static final Pattern ON_AIR = Pattern.compile("On Air\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern ON_AIR = Pattern.compile("On Air\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
@@ -89,10 +106,10 @@ public class StatsExtractor {
putLong(meta, "frequency_hz", matchLong(FREQUENCY, normalized)); putLong(meta, "frequency_hz", matchLong(FREQUENCY, normalized));
putInt(meta, "spreading_factor", matchInt(SPREADING, normalized)); putInt(meta, "spreading_factor", matchInt(SPREADING, normalized));
putInt(meta, "bandwidth_khz", matchInt(BANDWIDTH, normalized)); putDouble(meta, "bandwidth_khz", matchDouble(BANDWIDTH, normalized));
Integer packet = matchInt(PACKET_NUMBER, normalized); Integer packet = matchInt(PACKET_NUMBER, normalized);
if (packet == null) { if (packet == null) {
packet = matchInt(PACKET, normalized); packet = matchInt(PACKET_TX, normalized);
} }
putInt(meta, "packet", packet); putInt(meta, "packet", packet);
putString(meta, "payload", matchString(PAYLOAD, normalized)); putString(meta, "payload", matchString(PAYLOAD, normalized));
@@ -101,11 +118,25 @@ public class StatsExtractor {
putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized)); putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized));
putDouble(meta, "per_percent", matchDouble(PER, normalized)); putDouble(meta, "per_percent", matchDouble(PER, normalized));
putDouble(meta, "rx_quality_percent", matchDouble(RX_QUALITY, normalized)); putDouble(meta, "rx_quality_percent", matchDouble(RX_QUALITY, normalized));
putString(meta, "code_rate", matchString(CODE_RATE, normalized));
putInt(meta, "preamble_length", matchInt(PREAMBLE_LENGTH, normalized));
putString(meta, "low_data_rate_opt", matchString(LOW_DATA_RATE, normalized));
putBool(meta, "crc_enabled", matchBool(CRC, normalized));
putInt(meta, "payload_length_bytes", matchInt(PAYLOAD_LENGTH, normalized));
putDouble(meta, "tx_timeout_ms", matchDouble(TX_TIMEOUT, normalized));
putInt(meta, "packet_receive", matchInt(PACKET_RECEIVE, normalized));
putInt(meta, "packet_total", matchInt(PACKET_TOTAL, normalized));
putInt(meta, "packet_error", matchInt(PACKET_ERROR, normalized));
putInt(meta, "crc_error", matchInt(CRC_ERROR, normalized));
putInt(meta, "preamble_detected", matchInt(PREAMBLE_DETECTED, normalized));
putInt(meta, "header_valid", matchInt(HEADER_VALID, normalized));
if (!fields.isEmpty()) { if (!fields.isEmpty()) {
meta.put("fields", fields); meta.put("fields", fields);
} }
meta.put("stats_at", System.currentTimeMillis() / 1000.0);
Double rangeM = matchDouble(rangePattern, normalized); Double rangeM = matchDouble(rangePattern, normalized);
Double displayDbm = rssiDbm != null ? rssiDbm : txPower; Double displayDbm = rssiDbm != null ? rssiDbm : txPower;
@@ -142,8 +173,12 @@ public class StatsExtractor {
return n.equals("frequency") || n.equals("power") || n.equals("rssi") return n.equals("frequency") || n.equals("power") || n.equals("rssi")
|| n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth") || n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth")
|| n.equals("packet") || n.contains("packet number") || n.equals("payload") || n.equals("packet") || n.contains("packet number") || n.equals("payload")
|| n.contains("packet receive") || n.contains("packet total") || n.contains("packet error")
|| n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid")
|| n.contains("on air") || n.contains("tx speed") || n.contains("rx speed") || n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
|| n.equals("per") || n.contains("rx quality"); || n.equals("per") || n.contains("rx quality") || n.contains("tx timeout")
|| n.contains("code rate") || n.contains("preamble length")
|| n.contains("low data rate") || n.equals("crc") || n.contains("payload length");
} }
private static ExtractedStats empty(String frame) { private static ExtractedStats empty(String frame) {
@@ -206,6 +241,20 @@ public class StatsExtractor {
} }
} }
private static void putBool(Map<String, Object> meta, String key, Boolean value) {
if (value != null) {
meta.put(key, value);
}
}
private static Boolean matchBool(Pattern pattern, String text) {
Matcher m = pattern.matcher(text);
if (!m.find()) {
return null;
}
return "on".equalsIgnoreCase(m.group(1).trim());
}
private static Double matchDouble(Pattern pattern, String text) { private static Double matchDouble(Pattern pattern, String text) {
Matcher m = pattern.matcher(text); Matcher m = pattern.matcher(text);
if (m.find()) { if (m.find()) {
@@ -37,6 +37,8 @@ import com.grigowashere.loratester.api.TrackDetail;
import com.grigowashere.loratester.api.TrackInfo; import com.grigowashere.loratester.api.TrackInfo;
import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.map.EsriWorldImagery;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.track.TrackRecorder; import com.grigowashere.loratester.track.TrackRecorder;
@@ -51,6 +53,7 @@ import org.mapsforge.map.layer.Layer;
import org.mapsforge.map.layer.cache.TileCache; import org.mapsforge.map.layer.cache.TileCache;
import org.mapsforge.map.layer.download.TileDownloadLayer; import org.mapsforge.map.layer.download.TileDownloadLayer;
import org.mapsforge.map.layer.download.tilesource.OpenStreetMapMapnik; import org.mapsforge.map.layer.download.tilesource.OpenStreetMapMapnik;
import org.mapsforge.map.layer.download.tilesource.TileSource;
import org.mapsforge.map.layer.overlay.Marker; import org.mapsforge.map.layer.overlay.Marker;
import org.mapsforge.map.layer.overlay.Polyline; import org.mapsforge.map.layer.overlay.Polyline;
import org.mapsforge.map.model.MapViewPosition; import org.mapsforge.map.model.MapViewPosition;
@@ -122,6 +125,7 @@ public class MapFragment extends Fragment {
private MaterialButtonToggleGroup mapCenterMode; private MaterialButtonToggleGroup mapCenterMode;
private Spinner trackSpinner; private Spinner trackSpinner;
private Spinner mapHeatmapRadius; private Spinner mapHeatmapRadius;
private Spinner mapBasemap;
private TextView mapHeatmapStatus; private TextView mapHeatmapStatus;
private View mapHeatmapLegend; private View mapHeatmapLegend;
private List<TrackInfo> savedTracks = new ArrayList<>(); private List<TrackInfo> savedTracks = new ArrayList<>();
@@ -150,6 +154,8 @@ public class MapFragment extends Fragment {
private double lastHeatmapLat = Double.NaN; private double lastHeatmapLat = Double.NaN;
private double lastHeatmapLon = Double.NaN; private double lastHeatmapLon = Double.NaN;
private boolean suppressHeatmapSpinner; private boolean suppressHeatmapSpinner;
private boolean suppressBasemapSpinner;
private TileSource currentTileSource = OpenStreetMapMapnik.INSTANCE;
private Runnable heatmapReloadRunnable; private Runnable heatmapReloadRunnable;
private boolean suppressCenterToggle; private boolean suppressCenterToggle;
private boolean mapGestureActive; private boolean mapGestureActive;
@@ -196,6 +202,7 @@ public class MapFragment extends Fragment {
mapToolDrawer = view.findViewById(R.id.mapToolDrawer); mapToolDrawer = view.findViewById(R.id.mapToolDrawer);
mapCenterMode = view.findViewById(R.id.mapCenterMode); mapCenterMode = view.findViewById(R.id.mapCenterMode);
mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius); mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius);
mapBasemap = view.findViewById(R.id.mapBasemap);
mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus); mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus);
mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend); mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend);
trackSpinner = view.findViewById(R.id.trackSpinner); trackSpinner = view.findViewById(R.id.trackSpinner);
@@ -208,6 +215,7 @@ public class MapFragment extends Fragment {
btnFindHill.setOnClickListener(v -> toggleHill()); btnFindHill.setOnClickListener(v -> toggleHill());
} }
setupHeatmapUi(); setupHeatmapUi();
setupBasemapUi();
updateConnectionIcons(lastDevices, serverConnected); updateConnectionIcons(lastDevices, serverConnected);
@@ -347,18 +355,18 @@ public class MapFragment extends Fragment {
mapView.getModel().frameBufferModel.getOverdrawFactor() mapView.getModel().frameBufferModel.getOverdrawFactor()
); );
OpenStreetMapMapnik tileSource = OpenStreetMapMapnik.INSTANCE; OpenStreetMapMapnik.INSTANCE.setUserAgent("LoraTester/1.0");
tileSource.setUserAgent("LoraTester/1.0"); currentTileSource = OpenStreetMapMapnik.INSTANCE;
downloadLayer = new TileDownloadLayer( downloadLayer = new TileDownloadLayer(
tileCache, tileCache,
mapView.getModel().mapViewPosition, mapView.getModel().mapViewPosition,
tileSource, currentTileSource,
AndroidGraphicFactory.INSTANCE AndroidGraphicFactory.INSTANCE
); );
mapView.getLayerManager().getLayers().add(downloadLayer); mapView.getLayerManager().getLayers().add(downloadLayer);
mapView.setZoomLevelMin(tileSource.getZoomLevelMin()); mapView.setZoomLevelMin(currentTileSource.getZoomLevelMin());
mapView.setZoomLevelMax(tileSource.getZoomLevelMax()); mapView.setZoomLevelMax(currentTileSource.getZoomLevelMax());
downloadLayer.start(); downloadLayer.start();
MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition; MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition;
@@ -707,29 +715,89 @@ public class MapFragment extends Fragment {
} }
clearTrackLayers(); clearTrackLayers();
List<LatLong> line = new ArrayList<>();
List<LatLong> boundsPoints = new ArrayList<>(); List<LatLong> boundsPoints = new ArrayList<>();
boolean colorByQuality = isRxTrack(detail);
int defaultColor = colorByQuality ? ARGB_RX : ARGB_TRACK;
for (TrackDetail.TrackPoint p : detail.points) { for (int i = 0; i < detail.points.size(); i++) {
TrackDetail.TrackPoint p = detail.points.get(i);
LatLong latLong = new LatLong(p.lat, p.lon); LatLong latLong = new LatLong(p.lat, p.lon);
line.add(latLong);
boundsPoints.add(latLong); boundsPoints.add(latLong);
Marker marker = new Marker(latLong, bitmapTrackPoint, 0, 0); int pointColor = trackPointColor(p.meta, colorByQuality, defaultColor);
Marker marker = new Marker(latLong, MapsforgeBitmaps.dot(pointColor, 12), 0, 0);
addTrackLayer(marker); addTrackLayer(marker);
}
if (line.size() >= 2) { if (i > 0) {
Polyline polyline = new Polyline( TrackDetail.TrackPoint prev = detail.points.get(i - 1);
MapsforgeBitmaps.linePaint(Color.GREEN, 4f), Double qa = rxQualityFromMeta(prev.meta);
Double qb = rxQualityFromMeta(p.meta);
Double q = qa != null && qb != null ? (qa + qb) / 2.0 : (qa != null ? qa : qb);
int segColor = colorByQuality && q != null ? qualityArgb(q) : defaultColor;
Polyline segment = new Polyline(
MapsforgeBitmaps.linePaint(segColor, 4f),
AndroidGraphicFactory.INSTANCE AndroidGraphicFactory.INSTANCE
); );
polyline.getLatLongs().addAll(line); segment.getLatLongs().add(new LatLong(prev.lat, prev.lon));
addTrackLayer(polyline); segment.getLatLongs().add(latLong);
addTrackLayer(segment);
}
} }
fitBoundsOnce(boundsPoints, detail.points.size() == 1, true); fitBoundsOnce(boundsPoints, detail.points.size() == 1, true);
} }
private static boolean isRxTrack(TrackDetail detail) {
if (detail.points == null) {
return false;
}
for (TrackDetail.TrackPoint p : detail.points) {
if (StatsExtractor.ROLE_RX.equals(p.role)) {
return true;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(p.meta, null, null);
if (StatsExtractor.ROLE_RX.equals(snap.role)) {
return true;
}
}
return false;
}
private static Double rxQualityFromMeta(String meta) {
if (meta == null || meta.isBlank()) {
return null;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(meta, null, null);
return snap.rxQualityPercent;
}
private static int qualityArgb(double pct) {
double p = Math.max(0.0, Math.min(100.0, pct));
int r;
int g;
if (p < 40.0) {
double t = p / 40.0;
r = 255;
g = (int) Math.round(140.0 * t);
} else if (p < 85.0) {
double t = (p - 40.0) / 45.0;
r = 255;
g = (int) Math.round(140.0 + 115.0 * t);
} else {
double t = (p - 85.0) / 15.0;
r = (int) Math.round(255.0 * (1.0 - t));
g = 255;
}
return 0xFF000000 | (r << 16) | (g << 8);
}
private static int trackPointColor(String meta, boolean colorByQuality, int fallback) {
if (!colorByQuality) {
return fallback;
}
Double q = rxQualityFromMeta(meta);
return q != null ? qualityArgb(q) : fallback;
}
private void addTrackLayer(Layer layer) { private void addTrackLayer(Layer layer) {
mapView.getLayerManager().getLayers().add(layer); mapView.getLayerManager().getLayers().add(layer);
trackLayers.add(layer); trackLayers.add(layer);
@@ -1015,6 +1083,68 @@ public class MapFragment extends Fragment {
hillPathLine = null; hillPathLine = null;
} }
private void setupBasemapUi() {
if (mapBasemap == null) {
return;
}
List<String> labels = List.of(
getString(R.string.map_layer_scheme),
getString(R.string.map_layer_satellite)
);
suppressBasemapSpinner = true;
mapBasemap.setAdapter(new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
labels
));
mapBasemap.setSelection(0, false);
suppressBasemapSpinner = false;
mapBasemap.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View v, int pos, long id) {
if (suppressBasemapSpinner) {
return;
}
TileSource next = pos == 1 ? EsriWorldImagery.INSTANCE : OpenStreetMapMapnik.INSTANCE;
if (next != currentTileSource) {
switchBasemap(next);
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
}
private void switchBasemap(TileSource tileSource) {
if (!isMapReady() || tileSource == null || tileSource == currentTileSource) {
return;
}
if (tileSource instanceof OpenStreetMapMapnik osm) {
osm.setUserAgent("LoraTester/1.0");
}
if (downloadLayer != null) {
downloadLayer.onPause();
mapView.getLayerManager().getLayers().remove(downloadLayer);
}
currentTileSource = tileSource;
downloadLayer = new TileDownloadLayer(
tileCache,
mapView.getModel().mapViewPosition,
currentTileSource,
AndroidGraphicFactory.INSTANCE
);
mapView.getLayerManager().getLayers().add(0, downloadLayer);
mapView.setZoomLevelMin(currentTileSource.getZoomLevelMin());
mapView.setZoomLevelMax(currentTileSource.getZoomLevelMax());
downloadLayer.start();
if (mapResumed) {
downloadLayer.onResume();
}
requestMapInvalidate();
}
private void setupHeatmapUi() { private void setupHeatmapUi() {
if (mapHeatmapRadius == null) { if (mapHeatmapRadius == null) {
return; return;
@@ -35,4 +35,12 @@ final class MapsforgeBitmaps {
paint.setStyle(org.mapsforge.core.graphics.Style.STROKE); paint.setStyle(org.mapsforge.core.graphics.Style.STROKE);
return paint; return paint;
} }
static org.mapsforge.core.graphics.Paint linePaint(int argb, float strokeWidth) {
int a = (argb >> 24) & 0xFF;
int r = (argb >> 16) & 0xFF;
int g = (argb >> 8) & 0xFF;
int b = argb & 0xFF;
return linePaint(AndroidGraphicFactory.INSTANCE.createColor(r, g, b, a), strokeWidth);
}
} }
@@ -92,14 +92,30 @@ public class RadioComparePanel extends LinearLayout {
addRow(table, "Пакет", fmtInt(tx.packet), fmtInt(rx.packet), "packet", changedTx, changedRx); addRow(table, "Пакет", fmtInt(tx.packet), fmtInt(rx.packet), "packet", changedTx, changedRx);
addRow(table, "Payload", str(tx.payload), str(rx.payload), "payload", changedTx, changedRx); addRow(table, "Payload", str(tx.payload), str(rx.payload), "payload", changedTx, changedRx);
addRow(table, "PER", fmtSuffix(tx.perPercent, " %"), fmtSuffix(rx.perPercent, " %"), "per", changedTx, changedRx); addRow(table, "PER", fmtSuffix(tx.perPercent, " %"), fmtSuffix(rx.perPercent, " %"), "per", changedTx, changedRx);
addRow(table, "Принято", fmtInt(tx.packetReceive), fmtInt(rx.packetReceive), "packetReceive", changedTx, changedRx);
addRow(table, "Всего", fmtInt(tx.packetTotal), fmtInt(rx.packetTotal), "packetTotal", changedTx, changedRx);
addRow(table, "Ошибки", fmtInt(tx.packetError), fmtInt(rx.packetError), "packetError", changedTx, changedRx);
addRow(table, "CRC err", fmtInt(tx.crcError), fmtInt(rx.crcError), "crcError", changedTx, changedRx);
addRow(table, "Preamble", fmtInt(tx.preambleDetected), fmtInt(rx.preambleDetected),
"preambleDetected", changedTx, changedRx);
addRow(table, "Header OK", fmtInt(tx.headerValid), fmtInt(rx.headerValid), "headerValid", changedTx, changedRx);
addRow(table, "TX spd", fmtSuffix(tx.txPktPerS, " p/s"), fmtSuffix(rx.txPktPerS, " p/s"), "txSpeed", changedTx, changedRx); addRow(table, "TX spd", fmtSuffix(tx.txPktPerS, " p/s"), fmtSuffix(rx.txPktPerS, " p/s"), "txSpeed", changedTx, changedRx);
addRow(table, "RX spd", fmtSuffix(tx.rxPktPerS, " p/s"), fmtSuffix(rx.rxPktPerS, " p/s"), "rxSpeed", changedTx, changedRx); addRow(table, "RX spd", fmtSuffix(tx.rxPktPerS, " p/s"), fmtSuffix(rx.rxPktPerS, " p/s"), "rxSpeed", changedTx, changedRx);
} else { } else {
addRow(table, "Роль", LoraStatsFormatter.roleLabel(tx.role), LoraStatsFormatter.roleLabel(rx.role), "role", changedTx, changedRx); addRow(table, "Роль", LoraStatsFormatter.roleLabel(tx.role), LoraStatsFormatter.roleLabel(rx.role), "role", changedTx, changedRx);
addRow(table, "Частота", fmtMhz(tx.frequencyMhz), fmtMhz(rx.frequencyMhz), "frequency", changedTx, changedRx); addRow(table, "Частота", fmtMhz(tx.frequencyMhz), fmtMhz(rx.frequencyMhz), "frequency", changedTx, changedRx);
addRow(table, "SF", fmtInt(tx.sf), fmtInt(rx.sf), "sf", changedTx, changedRx); addRow(table, "SF", fmtInt(tx.sf), fmtInt(rx.sf), "sf", changedTx, changedRx);
addRow(table, "BW", fmtSuffixInt(tx.bwKhz, " kHz"), fmtSuffixInt(rx.bwKhz, " kHz"), "bw", changedTx, changedRx); addRow(table, "BW", fmtBw(tx.bwKhz), fmtBw(rx.bwKhz), "bw", changedTx, changedRx);
addRow(table, "Мощн.", fmtDbm(tx.powerDbm), fmtDbm(rx.powerDbm), "power", changedTx, changedRx); addRow(table, "Мощн.", fmtDbm(tx.powerDbm), fmtDbm(rx.powerDbm), "power", changedTx, changedRx);
addRow(table, "Code Rate", str(tx.codeRate), str(rx.codeRate), "codeRate", changedTx, changedRx);
addRow(table, "Preamble", fmtInt(tx.preambleLength), fmtInt(rx.preambleLength), "preambleLength", changedTx, changedRx);
addRow(table, "LDR", str(tx.lowDataRateOpt), str(rx.lowDataRateOpt), "lowDataRateOpt", changedTx, changedRx);
addRow(table, "CRC", fmtCrc(tx.crcEnabled), fmtCrc(rx.crcEnabled), "crc", changedTx, changedRx);
addRow(table, "Payl.len", fmtSuffixInt(tx.payloadLengthBytes, " B"), fmtSuffixInt(rx.payloadLengthBytes, " B"),
"payloadLength", changedTx, changedRx);
addRow(table, "TX Timeout", fmtSuffix(tx.txTimeoutMs, " ms"), fmtSuffix(rx.txTimeoutMs, " ms"),
"txTimeout", changedTx, changedRx);
addRow(table, "On Air", fmtSuffix(tx.onAirMs, " ms"), fmtSuffix(rx.onAirMs, " ms"), "onAir", changedTx, changedRx);
} }
} }
@@ -183,6 +199,17 @@ public class RadioComparePanel extends LinearLayout {
return v != null ? v + suffix : ""; return v != null ? v + suffix : "";
} }
private static String fmtBw(Double v) {
return v != null ? String.format(Locale.US, "%.2f kHz", v) : "";
}
private static String fmtCrc(Boolean enabled) {
if (enabled == null) {
return "";
}
return enabled ? "On" : "Off";
}
private static String fmtSuffixInt(Integer v, String suffix) { private static String fmtSuffixInt(Integer v, String suffix) {
return v != null ? v + suffix : ""; return v != null ? v + suffix : "";
} }
@@ -15,6 +15,8 @@ import androidx.fragment.app.Fragment;
import com.google.android.material.switchmaterial.SwitchMaterial; import com.google.android.material.switchmaterial.SwitchMaterial;
import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputEditText;
import com.grigowashere.loratester.LoraApp; import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.LoraForegroundService;
import com.grigowashere.loratester.MainActivity;
import com.grigowashere.loratester.R; import com.grigowashere.loratester.R;
import com.grigowashere.loratester.SettingsRepository; import com.grigowashere.loratester.SettingsRepository;
import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.TelemetryUploader;
@@ -42,7 +44,9 @@ public class SettingsFragment extends Fragment {
TextInputEditText editPort = view.findViewById(R.id.editTelnetPort); TextInputEditText editPort = view.findViewById(R.id.editTelnetPort);
TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex); TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex);
TextInputEditText editRange = view.findViewById(R.id.editRangeRegex); TextInputEditText editRange = view.findViewById(R.id.editRangeRegex);
TextInputEditText editDeviceLabel = view.findViewById(R.id.editDeviceLabel);
SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet); SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet);
Button batteryBtn = view.findViewById(R.id.btnBatteryOptimization);
TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel); TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel);
Button save = view.findViewById(R.id.btnSaveSettings); Button save = view.findViewById(R.id.btnSaveSettings);
@@ -51,9 +55,21 @@ public class SettingsFragment extends Fragment {
editPort.setText(String.valueOf(settings.getTelnetPort())); editPort.setText(String.valueOf(settings.getTelnetPort()));
editRssi.setText(settings.getRssiRegex()); editRssi.setText(settings.getRssiRegex());
editRange.setText(settings.getRangeRegex()); editRange.setText(settings.getRangeRegex());
String savedLabel = settings.getDeviceLabel();
if (savedLabel != null) {
editDeviceLabel.setText(savedLabel);
}
switchTelnet.setChecked(settings.isTelnetEnabled()); switchTelnet.setChecked(settings.isTelnetEnabled());
deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId())); deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId()));
batteryBtn.setOnClickListener(v -> {
if (MainActivity.isIgnoringBatteryOptimizations(requireContext())) {
Toast.makeText(requireContext(), R.string.battery_optimization_done, Toast.LENGTH_SHORT).show();
return;
}
MainActivity.openBatteryOptimizationSettings(requireContext());
});
save.setOnClickListener(v -> { save.setOnClickListener(v -> {
settings.setServerUrl(textOf(editServer, SettingsRepository.DEFAULT_SERVER)); settings.setServerUrl(textOf(editServer, SettingsRepository.DEFAULT_SERVER));
settings.setTelnetHost(textOf(editHost, SettingsRepository.DEFAULT_TELNET_HOST)); settings.setTelnetHost(textOf(editHost, SettingsRepository.DEFAULT_TELNET_HOST));
@@ -64,13 +80,16 @@ public class SettingsFragment extends Fragment {
} }
settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX)); settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX));
settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX)); settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX));
settings.setDeviceLabel(textOf(editDeviceLabel, ""));
settings.setTelnetEnabled(switchTelnet.isChecked()); settings.setTelnetEnabled(switchTelnet.isChecked());
uploader.refreshApi(); uploader.refreshApi();
uploader.registerPresence();
if (switchTelnet.isChecked()) { if (switchTelnet.isChecked()) {
uploader.startTelnet(); uploader.startTelnet();
} else { } else {
uploader.stopTelnet(); uploader.stopTelnet();
} }
LoraForegroundService.ensureRunning(requireContext());
Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show(); Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show();
}); });
} }
@@ -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>
+14
View File
@@ -222,6 +222,20 @@
android:textSize="10sp" /> android:textSize="10sp" />
</com.google.android.material.button.MaterialButtonToggleGroup> </com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/map_layer"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/mapBasemap"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -73,6 +73,19 @@
android:inputType="text" /> android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/device_display_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editDeviceLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchTelnet" android:id="@+id/switchTelnet"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -80,6 +93,20 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:text="@string/telnet_enabled" /> android:text="@string/telnet_enabled" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/battery_optimization_hint"
android:textSize="12sp" />
<Button
android:id="@+id/btnBatteryOptimization"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/battery_optimization" />
<TextView <TextView
android:id="@+id/deviceIdLabel" android:id="@+id/deviceIdLabel"
android:layout_width="match_parent" android:layout_width="match_parent"
+16
View File
@@ -17,6 +17,7 @@
<string name="range_regex">Range regex</string> <string name="range_regex">Range regex</string>
<string name="telnet_enabled">Подключить telnet</string> <string name="telnet_enabled">Подключить telnet</string>
<string name="device_id_label">ID устройства: %1$s</string> <string name="device_id_label">ID устройства: %1$s</string>
<string name="device_display_name">Имя на карте (realme, OPPO…)</string>
<string name="save">Сохранить</string> <string name="save">Сохранить</string>
<string name="saved">Сохранено</string> <string name="saved">Сохранено</string>
<string name="chat_hint">Сообщение…</string> <string name="chat_hint">Сообщение…</string>
@@ -81,6 +82,9 @@
<string name="map_center_rx">RX</string> <string name="map_center_rx">RX</string>
<string name="map_center_both">Оба</string> <string name="map_center_both">Оба</string>
<string name="map_center_mode">Центр карты</string> <string name="map_center_mode">Центр карты</string>
<string name="map_layer">Слой карты</string>
<string name="map_layer_scheme">Схема</string>
<string name="map_layer_satellite">Спутник</string>
<string name="map_center_unavailable">Нет координат для выбранного режима</string> <string name="map_center_unavailable">Нет координат для выбранного режима</string>
<string name="map_tool_center">Центрировать карту</string> <string name="map_tool_center">Центрировать карту</string>
<string name="map_tool_track">Трекинг пути</string> <string name="map_tool_track">Трекинг пути</string>
@@ -109,4 +113,16 @@
<string name="map_heatmap_legend_level">уровень</string> <string name="map_heatmap_legend_level">уровень</string>
<string name="map_heatmap_legend_low">низина</string> <string name="map_heatmap_legend_low">низина</string>
<string name="chat_self_label">Вы</string> <string name="chat_self_label">Вы</string>
<string name="notification_channel_name">Фоновая работа</string>
<string name="notification_channel_desc">Telnet, GPS и запись трека при свёрнутом приложении</string>
<string name="notification_title">LoraTester активен</string>
<string name="notification_subtitle">Работа в фоне</string>
<string name="notification_track_recording">трек: %1$d точек</string>
<string name="notification_track_idle">трек: нет</string>
<string name="telnet_disabled_short">telnet: выкл</string>
<string name="background_location_rationale">Для GPS при выключенном экране разрешите геолокацию «Всегда»</string>
<string name="background_location_required">Нужен доступ к геолокации для трека и карты</string>
<string name="battery_optimization">Без ограничений батареи</string>
<string name="battery_optimization_hint">Рекомендуется на MIUI / ColorOS / EMUI для стабильного telnet</string>
<string name="battery_optimization_done">Ограничения батареи уже отключены</string>
</resources> </resources>
@@ -54,11 +54,10 @@ public class LoraFrameExtractTest {
public void parsesAllLabeledLinesFromSendScreen() { public void parsesAllLabeledLinesFromSendScreen() {
StatsExtractor extractor = StatsExtractor.withDefaults(); StatsExtractor extractor = StatsExtractor.withDefaults();
StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND); StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND);
assertTrue(stats.metaJson.contains("\"fields\"")); assertTrue(stats.metaJson.contains("\"code_rate\":\"4/5\""));
assertTrue(stats.metaJson.contains("Frequency")); assertTrue(stats.metaJson.contains("\"spreading_factor\":12"));
assertTrue(stats.metaJson.contains("Spreading Factor")); assertTrue(stats.metaJson.contains("\"packet\":304"));
assertTrue(stats.metaJson.contains("Packet")); assertTrue(stats.metaJson.contains("Test TX!"));
assertTrue(stats.metaJson.contains("Payload"));
} }
@Test @Test
@@ -83,7 +82,8 @@ public class LoraFrameExtractTest {
assertEquals(StatsExtractor.ROLE_RX, stats.role); assertEquals(StatsExtractor.ROLE_RX, stats.role);
assertEquals(-78.0, stats.rssi, 0.01); assertEquals(-78.0, stats.rssi, 0.01);
assertEquals(10.5, stats.snrDb, 0.01); assertEquals(10.5, stats.snrDb, 0.01);
assertTrue(stats.metaJson.contains("\"fields\"")); assertTrue(stats.metaJson.contains("\"packet\":0"));
assertTrue(stats.metaJson.contains("\"rx_pkt_per_s\":0.45"));
} }
@Test @Test
@@ -96,6 +96,73 @@ public class LoraFrameExtractTest {
assertTrue(!stats.metaJson.contains("RX Quality")); assertTrue(!stats.metaJson.contains("RX Quality"));
} }
@Test
public void parsesRxCountersAndCrcErrors() {
StatsExtractor extractor = StatsExtractor.withDefaults();
String frame = """
RECEIVE
Frequency: 433500000 Hz
Power: 0 dBm
Spreading Factor: 5
Bandwidth: 7.81 kHz
Code Rate: 4/6
Preamble Length: 8
Low Data Rate Opt: Off
CRC: Off
Payload length: 32 byte
On Air: 427.14 ms, 2.34 pkt/c
Packet Number: 0
Payload: test
RSSI: -78
SNR: 10.5
RX Speed: 0.45 pkt/s, 120 bit/s
Packet Receive: 12
Packet Total: 100
Packet Error: 3
CRC Error: 2
PER: 3.00 %
Preamble Detected: 2
Header Valid: 1
RX Quality: 87 %
""";
StatsExtractor.ExtractedStats stats = extractor.extract(frame);
assertTrue(stats.metaJson.contains("\"crc_error\":2"));
assertTrue(stats.metaJson.contains("\"packet_error\":3"));
assertTrue(stats.metaJson.contains("\"packet_total\":100"));
assertTrue(stats.metaJson.contains("\"packet_receive\":12"));
assertTrue(stats.metaJson.contains("\"bandwidth_khz\":7.81"));
assertTrue(stats.metaJson.contains("\"crc_enabled\":false"));
assertTrue(stats.metaJson.contains("\"code_rate\":\"4/6\""));
}
@Test
public void parsesSendCrcAndConfig() {
StatsExtractor extractor = StatsExtractor.withDefaults();
String frame = """
SEND
Frequency: 433500000 Hz
Power: 0 dBm
Spreading Factor: 5
Bandwidth: 125 kHz
Code Rate: 4/5
Preamble Length: 8
Low Data Rate Opt: Off
CRC: On
Payload length: 32 byte
On Air: 23.10 ms, 43.28 pkt/c
Packet: 3816
Payload: Test TX!
TX Timeout: 0 ms
TX Speed: 32.06 pkt/s, 8206 bit/s
""";
StatsExtractor.ExtractedStats stats = extractor.extract(frame);
assertTrue(stats.metaJson.contains("\"packet\":3816"));
assertTrue(stats.metaJson.contains("\"crc_enabled\":true"));
assertTrue(stats.metaJson.contains("\"payload_length_bytes\":32"));
assertTrue(stats.metaJson.contains("\"tx_timeout_ms\":0"));
assertTrue(stats.metaJson.contains("\"preamble_length\":8"));
}
@Test @Test
public void splitsTwoFramesByReceiveHeaderWithoutEsc() { public void splitsTwoFramesByReceiveHeaderWithoutEsc() {
List<String> frames = new ArrayList<>(); List<String> frames = new ArrayList<>();
Binary file not shown.
Binary file not shown.
+1
View File
@@ -14,6 +14,7 @@ class TelemetryIn:
role: Optional[str] = None role: Optional[str] = None
ts: Optional[float] = None ts: Optional[float] = None
source: str = "android" source: str = "android"
device_label: Optional[str] = None
@dataclass @dataclass
+61 -13
View File
@@ -15,6 +15,8 @@ WEB_SENDER_ID = "web"
COMMAND_KINDS = frozenset({"at", "mode", "stats_push"}) COMMAND_KINDS = frozenset({"at", "mode", "stats_push"})
PAIRED_ONLINE_SEC = 30.0 PAIRED_ONLINE_SEC = 30.0
PAIRED_START_DELAY_SEC = 3.0 PAIRED_START_DELAY_SEC = 3.0
# Hide devices on map/UI after this many seconds without telemetry.
DEVICE_VISIBLE_SEC = 180.0
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -88,6 +90,19 @@ def record_telemetry(data: TelemetryIn) -> dict[str, Any]:
ts = data.ts if data.ts is not None else time.time() ts = data.ts if data.ts is not None else time.time()
lat, lon = _sanitize_coords(data.lat, data.lon) lat, lon = _sanitize_coords(data.lat, data.lon)
with _db() as conn: with _db() as conn:
phone_label = (data.device_label or "").strip()
if phone_label:
conn.execute(
"""
INSERT INTO devices (device_id, label, last_seen)
VALUES (?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET
last_seen = excluded.last_seen,
label = excluded.label
""",
(data.device_id, phone_label, ts),
)
else:
conn.execute( conn.execute(
""" """
INSERT INTO devices (device_id, label, last_seen) INSERT INTO devices (device_id, label, last_seen)
@@ -134,11 +149,30 @@ def _trim_telemetry(conn: sqlite3.Connection, device_id: str) -> None:
) )
def update_device_label(device_id: str, label: str) -> dict[str, Any]:
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
clean = (label or "").strip()
if not clean:
raise ValueError("label required")
ts = time.time()
with _db() as conn:
conn.execute(
"""
INSERT INTO devices (device_id, label, last_seen)
VALUES (?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET label = excluded.label
""",
(device_id, clean, ts),
)
return {"ok": True, "device_id": device_id, "label": clean}
def list_devices() -> list[dict[str, Any]]: def list_devices() -> list[dict[str, Any]]:
with _db() as conn: with _db() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT d.device_id, d.last_seen, SELECT d.device_id, d.label, d.last_seen,
t.lat, t.lon, t.rssi, t.range_m, t.raw_frame, t.meta, t.role, t.ts, t.source t.lat, t.lon, t.rssi, t.range_m, t.raw_frame, t.meta, t.role, t.ts, t.source
FROM devices d FROM devices d
INNER JOIN telemetry t ON t.id = ( INNER JOIN telemetry t ON t.id = (
@@ -150,8 +184,13 @@ def list_devices() -> list[dict[str, Any]]:
ORDER BY d.last_seen DESC ORDER BY d.last_seen DESC
""" """
).fetchall() ).fetchall()
cutoff = time.time() - DEVICE_VISIBLE_SEC
devices = [_row_to_device(r) for r in rows] devices = [_row_to_device(r) for r in rows]
return [d for d in devices if not _is_null_island(d)] return [
d
for d in devices
if not _is_null_island(d) and d.get("last_seen", 0) >= cutoff
]
def _is_null_island(device: dict[str, Any]) -> bool: def _is_null_island(device: dict[str, Any]) -> bool:
@@ -164,6 +203,7 @@ def _is_null_island(device: dict[str, Any]) -> bool:
def _row_to_device(row: sqlite3.Row) -> dict[str, Any]: def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
return { return {
"device_id": row["device_id"], "device_id": row["device_id"],
"label": row["label"] if "label" in row.keys() else None,
"last_seen": row["last_seen"], "last_seen": row["last_seen"],
"lat": row["lat"], "lat": row["lat"],
"lon": row["lon"], "lon": row["lon"],
@@ -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 != '' WHERE p.track_id = t.id AND p.role IS NOT NULL AND p.role != ''
ORDER BY p.ts DESC LIMIT 1) ORDER BY p.ts DESC LIMIT 1)
""" """
if device_id: track_cols = f"""
rows = conn.execute(
f"""
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label, SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
d.label AS device_label,
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count, (SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
{role_sub} AS role {role_sub} AS role
FROM tracks t FROM tracks t
LEFT JOIN devices d ON d.device_id = t.device_id
"""
if device_id:
rows = conn.execute(
f"""
{track_cols}
WHERE t.device_id = ? WHERE t.device_id = ?
ORDER BY t.started_at DESC ORDER BY t.started_at DESC
LIMIT ? LIMIT ?
@@ -337,10 +382,7 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
else: else:
rows = conn.execute( rows = conn.execute(
f""" f"""
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label, {track_cols}
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
{role_sub} AS role
FROM tracks t
ORDER BY t.started_at DESC ORDER BY t.started_at DESC
LIMIT ? LIMIT ?
""", """,
@@ -353,7 +395,11 @@ def get_track(track_id: int) -> dict[str, Any]:
with _db() as conn: with _db() as conn:
track = conn.execute( track = conn.execute(
""" """
SELECT id, device_id, started_at, ended_at, label FROM tracks WHERE id = ? SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
d.label AS device_label
FROM tracks t
LEFT JOIN devices d ON d.device_id = t.device_id
WHERE t.id = ?
""", """,
(track_id,), (track_id,),
).fetchone() ).fetchone()
@@ -394,9 +440,11 @@ def get_chat(since: float = 0.0, limit: int = 200) -> list[dict[str, Any]]:
with _db() as conn: with _db() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, device_id, text, ts FROM chat SELECT c.id, c.device_id, c.text, c.ts, d.label AS device_label
WHERE ts > ? FROM chat c
ORDER BY ts ASC LIMIT ? LEFT JOIN devices d ON d.device_id = c.device_id
WHERE c.ts > ?
ORDER BY c.ts ASC LIMIT ?
""", """,
(since, limit), (since, limit),
).fetchall() ).fetchall()
+5
View File
@@ -48,6 +48,10 @@ def merge_meta(body: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn: def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
meta, role = merge_meta(body) meta, role = merge_meta(body)
label = body.get("device_label") or body.get("label")
device_label = str(label).strip() if label else None
if device_label == "":
device_label = None
return TelemetryIn( return TelemetryIn(
device_id=str(body["device_id"]), device_id=str(body["device_id"]),
lat=_float_or_none(body.get("lat")), lat=_float_or_none(body.get("lat")),
@@ -58,4 +62,5 @@ def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
meta=meta, meta=meta,
role=role, role=role,
ts=_float_or_none(body.get("ts")), ts=_float_or_none(body.get("ts")),
device_label=device_label,
) )
+14
View File
@@ -31,6 +31,7 @@ storage.init_db()
class TelemetryBody(BaseModel): class TelemetryBody(BaseModel):
device_id: str device_id: str
device_label: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
rssi: Optional[float] = None rssi: Optional[float] = None
@@ -53,6 +54,10 @@ class TrackStartBody(BaseModel):
label: Optional[str] = None label: Optional[str] = None
class DeviceLabelBody(BaseModel):
label: str
class TrackPoint(BaseModel): class TrackPoint(BaseModel):
ts: Optional[float] = None ts: Optional[float] = None
lat: float lat: float
@@ -120,6 +125,14 @@ def get_devices():
return storage.list_devices() return storage.list_devices()
@app.patch("/api/devices/{device_id}/label")
def patch_device_label(device_id: str, body: DeviceLabelBody):
try:
return storage.update_device_label(device_id, body.label)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/telemetry") @app.get("/api/telemetry")
def get_telemetry_history( def get_telemetry_history(
device_id: Optional[str] = None, device_id: Optional[str] = None,
@@ -366,6 +379,7 @@ def health():
return { return {
"ok": status["db_ok"], "ok": status["db_ok"],
"ts": time.time(), "ts": time.time(),
"api_build": "2026-06-16g",
**status, **status,
**elevation_status(), **elevation_status(),
} }
+750 -152
View File
File diff suppressed because it is too large Load Diff
+68 -10
View File
@@ -5,7 +5,10 @@
const KNOWN_LABELS = new Set([ const KNOWN_LABELS = new Set([
'send', 'receive', 'frequency', 'power', 'rssi', 'snr', 'send', 'receive', 'frequency', 'power', 'rssi', 'snr',
'spreading factor', 'bandwidth', 'packet', 'packet number', 'payload', 'spreading factor', 'bandwidth', 'packet', 'packet number', 'payload',
'on air', 'tx speed', 'rx speed', 'per', 'rx quality' 'packet receive', 'packet total', 'packet error', 'crc error',
'preamble detected', 'header valid',
'on air', 'tx speed', 'rx speed', 'per', 'rx quality',
'code rate', 'preamble length', 'low data rate', 'crc', 'payload length', 'tx timeout'
]); ]);
function roleLabel(role) { function roleLabel(role) {
@@ -22,6 +25,15 @@
return false; return false;
} }
function fmtCrc(enabled) {
if (enabled == null) return '—';
return enabled ? 'On' : 'Off';
}
function fmtBw(khz) {
return khz != null ? `${Number(khz).toFixed(2)} kHz` : '—';
}
function parseRadioSnapshot(meta, roleFallback, rssiFallback) { function parseRadioSnapshot(meta, roleFallback, rssiFallback) {
const snap = { const snap = {
role: roleFallback || null, role: roleFallback || null,
@@ -39,6 +51,18 @@
rxPktPerS: null, rxPktPerS: null,
perPercent: null, perPercent: null,
rxQualityPercent: null, rxQualityPercent: null,
codeRate: null,
preambleLength: null,
lowDataRateOpt: null,
crcEnabled: null,
payloadLengthBytes: null,
txTimeoutMs: null,
packetReceive: null,
packetTotal: null,
packetError: null,
crcError: null,
preambleDetected: null,
headerValid: null,
extraFields: {} extraFields: {}
}; };
if (!meta) return snap; if (!meta) return snap;
@@ -61,6 +85,19 @@
if (o.rx_pkt_per_s != null) snap.rxPktPerS = Number(o.rx_pkt_per_s); if (o.rx_pkt_per_s != null) snap.rxPktPerS = Number(o.rx_pkt_per_s);
if (o.per_percent != null) snap.perPercent = Number(o.per_percent); if (o.per_percent != null) snap.perPercent = Number(o.per_percent);
if (o.rx_quality_percent != null) snap.rxQualityPercent = Number(o.rx_quality_percent); if (o.rx_quality_percent != null) snap.rxQualityPercent = Number(o.rx_quality_percent);
if (o.stats_at != null) snap.statsAt = Number(o.stats_at);
if (o.code_rate != null) snap.codeRate = String(o.code_rate);
if (o.preamble_length != null) snap.preambleLength = Number(o.preamble_length);
if (o.low_data_rate_opt != null) snap.lowDataRateOpt = String(o.low_data_rate_opt);
if (o.crc_enabled != null) snap.crcEnabled = Boolean(o.crc_enabled);
if (o.payload_length_bytes != null) snap.payloadLengthBytes = Number(o.payload_length_bytes);
if (o.tx_timeout_ms != null) snap.txTimeoutMs = Number(o.tx_timeout_ms);
if (o.packet_receive != null) snap.packetReceive = Number(o.packet_receive);
if (o.packet_total != null) snap.packetTotal = Number(o.packet_total);
if (o.packet_error != null) snap.packetError = Number(o.packet_error);
if (o.crc_error != null) snap.crcError = Number(o.crc_error);
if (o.preamble_detected != null) snap.preambleDetected = Number(o.preamble_detected);
if (o.header_valid != null) snap.headerValid = Number(o.header_valid);
if (o.fields && typeof o.fields === 'object') { if (o.fields && typeof o.fields === 'object') {
for (const [k, v] of Object.entries(o.fields)) { for (const [k, v] of Object.entries(o.fields)) {
if (!isKnownLabel(k)) snap.extraFields[k] = String(v); if (!isKnownLabel(k)) snap.extraFields[k] = String(v);
@@ -77,12 +114,17 @@
function diffSnapshots(a, b) { function diffSnapshots(a, b) {
const changed = new Set(); const changed = new Set();
if (!a || !b) return changed; if (!a || !b) return changed;
const keys = ['role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent', const keys = ['gps', 'packetTime', 'role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent',
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm']; 'packetReceive', 'packetTotal', 'packetError', 'crcError', 'preambleDetected', 'headerValid',
const map = { role: 'role', rssiDbm: 'rssi', snrDb: 'snr', rxQualityPercent: 'rxQuality', 'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm', 'codeRate', 'crcEnabled'];
packet: 'packet', const map = {
payload: 'payload', perPercent: 'per', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed', gps: 'gps', packetTime: 'packetTime', role: 'role', rssiDbm: 'rssi', snrDb: 'snr',
frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power' }; rxQualityPercent: 'rxQuality', packet: 'packet', payload: 'payload', perPercent: 'per',
packetReceive: 'packetReceive', packetTotal: 'packetTotal', packetError: 'packetError',
crcError: 'crcError', preambleDetected: 'preambleDetected', headerValid: 'headerValid',
txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed',
frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power', codeRate: 'codeRate', crcEnabled: 'crc'
};
for (const k of keys) { for (const k of keys) {
if (a[k] !== b[k] && !(a[k] == null && b[k] == null)) changed.add(map[k]); if (a[k] !== b[k] && !(a[k] == null && b[k] == null)) changed.add(map[k]);
} }
@@ -90,12 +132,20 @@
} }
const DYNAMIC_ROWS = [ const DYNAMIC_ROWS = [
{ key: 'gps', label: 'GPS', fmt: s => s.gps || '—' },
{ key: 'packetTime', label: 'Время пакета', fmt: s => s.packetTime || '—' },
{ key: 'rssi', label: 'RSSI', fmt: s => s.rssiDbm != null ? `${s.rssiDbm} dBm` : '—' }, { key: 'rssi', label: 'RSSI', fmt: s => s.rssiDbm != null ? `${s.rssiDbm} dBm` : '—' },
{ key: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' }, { key: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' },
{ key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' }, { key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' },
{ key: 'packet', label: 'Пакет', fmt: s => s.packet != null ? String(s.packet) : '—' }, { key: 'packet', label: 'Пакет', fmt: s => s.packet != null ? String(s.packet) : '—' },
{ key: 'payload', label: 'Payload', fmt: s => s.payload || '—' }, { key: 'payload', label: 'Payload', fmt: s => s.payload || '—' },
{ key: 'per', label: 'PER', fmt: s => s.perPercent != null ? `${s.perPercent} %` : '—' }, { key: 'per', label: 'PER', fmt: s => s.perPercent != null ? `${s.perPercent} %` : '—' },
{ key: 'packetReceive', label: 'Принято', fmt: s => s.packetReceive != null ? String(s.packetReceive) : '—' },
{ key: 'packetTotal', label: 'Всего', fmt: s => s.packetTotal != null ? String(s.packetTotal) : '—' },
{ key: 'packetError', label: 'Ошибки', fmt: s => s.packetError != null ? String(s.packetError) : '—' },
{ key: 'crcError', label: 'CRC err', fmt: s => s.crcError != null ? String(s.crcError) : '—' },
{ key: 'preambleDetected', label: 'Preamble', fmt: s => s.preambleDetected != null ? String(s.preambleDetected) : '—' },
{ key: 'headerValid', label: 'Header OK', fmt: s => s.headerValid != null ? String(s.headerValid) : '—' },
{ key: 'txSpeed', label: 'TX Speed', fmt: s => s.txPktPerS != null ? `${s.txPktPerS} pkt/s` : '—' }, { key: 'txSpeed', label: 'TX Speed', fmt: s => s.txPktPerS != null ? `${s.txPktPerS} pkt/s` : '—' },
{ key: 'rxSpeed', label: 'RX Speed', fmt: s => s.rxPktPerS != null ? `${s.rxPktPerS} pkt/s` : '—' } { key: 'rxSpeed', label: 'RX Speed', fmt: s => s.rxPktPerS != null ? `${s.rxPktPerS} pkt/s` : '—' }
]; ];
@@ -104,8 +154,14 @@
{ key: 'role', label: 'Роль', fmt: s => roleLabel(s.role) }, { key: 'role', label: 'Роль', fmt: s => roleLabel(s.role) },
{ key: 'frequency', label: 'Частота', fmt: s => s.frequencyMhz != null ? `${s.frequencyMhz.toFixed(3)} MHz` : '—' }, { key: 'frequency', label: 'Частота', fmt: s => s.frequencyMhz != null ? `${s.frequencyMhz.toFixed(3)} MHz` : '—' },
{ key: 'sf', label: 'SF', fmt: s => s.sf != null ? String(s.sf) : '—' }, { key: 'sf', label: 'SF', fmt: s => s.sf != null ? String(s.sf) : '—' },
{ key: 'bw', label: 'BW', fmt: s => s.bwKhz != null ? `${s.bwKhz} kHz` : '—' }, { key: 'bw', label: 'BW', fmt: s => fmtBw(s.bwKhz) },
{ key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' }, { key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' },
{ key: 'codeRate', label: 'Code Rate', fmt: s => s.codeRate || '—' },
{ key: 'preambleLength', label: 'Preamble', fmt: s => s.preambleLength != null ? String(s.preambleLength) : '—' },
{ key: 'lowDataRateOpt', label: 'LDR', fmt: s => s.lowDataRateOpt || '—' },
{ key: 'crc', label: 'CRC', fmt: s => fmtCrc(s.crcEnabled) },
{ key: 'payloadLength', label: 'Payl.len', fmt: s => s.payloadLengthBytes != null ? `${s.payloadLengthBytes} B` : '—' },
{ key: 'txTimeout', label: 'TX Timeout', fmt: s => s.txTimeoutMs != null ? `${s.txTimeoutMs} ms` : '—' },
{ key: 'onAir', label: 'On Air', fmt: s => s.onAirMs != null ? `${s.onAirMs} ms` : '—' } { key: 'onAir', label: 'On Air', fmt: s => s.onAirMs != null ? `${s.onAirMs} ms` : '—' }
]; ];
@@ -116,8 +172,10 @@
function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) { function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) {
let html = '<div class="radio-compare-grid">'; let html = '<div class="radio-compare-grid">';
html += `<div class="radio-compare-head"><span class="legend-tx">TX</span> ${escapeHtml(txId || '—')}`; html += '<div class="radio-compare-head">';
html += `<span class="legend-rx">RX</span> ${escapeHtml(rxId || '—')}</div>`; html += `<span><span class="legend-tx">TX</span> ${escapeHtml(txId || '—')}</span>`;
html += `<span><span class="legend-rx">RX</span> ${escapeHtml(rxId || '—')}</span>`;
html += '</div>';
for (const row of DYNAMIC_ROWS) { for (const row of DYNAMIC_ROWS) {
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : ''; const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : ''; const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';