20 Commits
v6 ... 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
Grigo c5805eaa5c added rx Quality 2026-06-15 11:57:23 +03:00
Grigo 012947fd99 added rx Quality 2026-06-15 11:41:39 +03:00
Grigo 23eb7ffb91 added rx Quality 2026-06-15 11:17:10 +03:00
Grigo e20b81c817 fixed modal 2026-06-15 11:04:34 +03:00
Grigo 2f303134c1 added linear slider 2026-06-15 08:40:27 +03:00
Grigo ab2a3bb035 added linear slider 2026-06-15 07:50:41 +03:00
Grigo d28391c71f added grid 2026-06-11 10:22:36 +03:00
Grigo c2f26c8ec3 added subprox 2026-06-11 09:32:33 +03:00
Grigo 94e2b772e8 added subproxy 2026-06-11 09:09:28 +03:00
Grigo 17d383ddc6 added bind 2026-06-11 08:46:49 +03:00
Grigo 8fd7e85c83 added local api 2026-06-11 08:38:08 +03:00
Grigo 81eaa95df3 Initial commit: LoraTester Android + server 2026-06-04 14:39:14 +03:00
87 changed files with 8988 additions and 817 deletions
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
Generated
+3 -1
View File
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings" defaultProject="true" /> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project> </project>
+1 -1
View File
@@ -11,7 +11,7 @@ Android-клиент и Python-сервер для мониторинга LoRa
1. Запустите сервер: `cd server && pip install -r requirements.txt && python flask_app.py` 1. Запустите сервер: `cd server && pip install -r requirements.txt && python flask_app.py`
2. Соберите APK в Android Studio или `./gradlew assembleDebug` 2. Соберите APK в Android Studio или `./gradlew assembleDebug`
3. В приложении: Настройки → URL `http://<ваш-сервер>:7634`, включите telnet при наличии моста COM→telnet 3. В приложении: Настройки → URL `https://lora.grigowashere.ru` (или свой сервер), включите telnet при наличии моста COM→telnet
## Тесты ## Тесты
+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>
@@ -0,0 +1,293 @@
package com.grigowashere.loratester;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.Nullable;
import com.grigowashere.loratester.api.DeviceCommand;
import com.grigowashere.loratester.api.PairedTrackSession;
import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.telnet.AtCommands;
import com.grigowashere.loratester.telnet.TelnetClient;
import com.grigowashere.loratester.track.TrackRecorder;
import java.util.HashMap;
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 {
private static final String TAG = "CommandPoller";
private static final long COMMAND_POLL_MS = 2000;
private static final long PAIRED_POLL_MS = 1500;
private final ServerApi serverApi;
private final String deviceId;
private final TelemetryUploader uploader;
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);
private volatile long pendingAckSessionId = -1;
private volatile long startedSessionId = -1;
public CommandPoller(
ServerApi serverApi,
String deviceId,
TelemetryUploader uploader,
TrackRecorder trackRecorder,
PeerStatsCache peerStatsCache
) {
this.serverApi = serverApi;
this.deviceId = deviceId;
this.uploader = uploader;
this.trackRecorder = trackRecorder;
this.peerStatsCache = peerStatsCache;
trackRecorder.setPairedListener(new TrackRecorder.Listener() {
@Override
public void onStateChanged(boolean recording, int pointCount, long trackId) {
if (recording && trackId > 0 && pendingAckSessionId > 0) {
long sid = pendingAckSessionId;
pendingAckSessionId = -1;
executor.execute(() -> ackSession(sid, trackId));
}
}
@Override
public void onError(String message) {
Log.w(TAG, "track: " + message);
}
});
}
public PeerStatsCache getPeerStatsCache() {
return peerStatsCache;
}
public void start() {
if (!running.compareAndSet(false, true)) {
return;
}
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 pollCommandsSafe() {
if (!running.get()) {
return;
}
pollCommands();
}
private void pollPairedSafe() {
if (!running.get()) {
return;
}
pollPairedSession();
}
private void pollCommands() {
try {
List<DeviceCommand> cmds = serverApi.pollPendingCommands(deviceId);
for (DeviceCommand cmd : cmds) {
execute(cmd);
}
} catch (Exception e) {
Log.w(TAG, "command poll failed", e);
}
}
private void execute(DeviceCommand cmd) {
if (cmd == null || cmd.kind == null) {
return;
}
switch (cmd.kind) {
case "at" -> {
List<String> lines = extractLines(cmd.payload);
if (lines != null && !lines.isEmpty()) {
uploader.sendMacroSequence(lines, r ->
Log.i(TAG, "remote macro " + lines + " -> " + r));
} else {
String line = cmd.payload != null && cmd.payload.get("line") != null
? String.valueOf(cmd.payload.get("line")) : null;
if (line != null) {
uploader.sendAtCommand(line, r ->
Log.i(TAG, "remote AT " + line + " -> " + r));
}
}
}
case "mode" -> {
String role = cmd.payload != null && cmd.payload.get("role") != null
? String.valueOf(cmd.payload.get("role")) : null;
if ("TX".equalsIgnoreCase(role)) {
uploader.sendAtCommand(AtCommands.TRANSMIT, r -> {});
} else if ("RX".equalsIgnoreCase(role)) {
uploader.sendAtCommand(AtCommands.RECEIVE, r -> {});
}
}
case "stats_push" -> peerStatsCache.updateFromPayload(cmd.payload);
default -> Log.w(TAG, "unknown kind " + cmd.kind);
}
}
private void pollPairedSession() {
try {
Map<String, Object> resp = serverApi.getActivePairedTrack();
Object sessionObj = resp.get("session");
if (!(sessionObj instanceof Map)) {
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> m = (Map<String, Object>) sessionObj;
PairedTrackSession session = mapSession(m);
if (session == null) {
return;
}
boolean inSession = deviceId.equals(session.device_a)
|| deviceId.equals(session.device_b);
if (!inSession) {
return;
}
if (!session.ready || trackRecorder.isRecording()) {
return;
}
if (startedSessionId == session.id) {
return;
}
Long myTrack = deviceId.equals(session.device_a)
? session.track_id_a : session.track_id_b;
if (myTrack != null && myTrack > 0) {
startedSessionId = session.id;
return;
}
startedSessionId = session.id;
pendingAckSessionId = session.id;
trackRecorder.start();
} catch (Exception e) {
Log.w(TAG, "paired poll failed", e);
}
}
private void ackSession(long sessionId, long trackId) {
try {
serverApi.ackPairedTrack(sessionId, deviceId, trackId);
Log.i(TAG, "paired ack session=" + sessionId + " track=" + trackId);
} catch (Exception e) {
Log.e(TAG, "paired ack failed", e);
pendingAckSessionId = sessionId;
}
}
private static PairedTrackSession mapSession(Map<String, Object> m) {
if (m == null) {
return null;
}
PairedTrackSession s = new PairedTrackSession();
Object id = m.get("id");
if (id instanceof Number) {
s.id = ((Number) id).longValue();
}
s.device_a = str(m.get("device_a"));
s.device_b = str(m.get("device_b"));
s.initiator = str(m.get("initiator"));
s.status = str(m.get("status"));
s.start_at = num(m.get("start_at"));
s.created_at = num(m.get("created_at"));
s.server_time = num(m.get("server_time"));
Object ready = m.get("ready");
s.ready = ready instanceof Boolean && (Boolean) ready;
s.track_id_a = longOrNull(m.get("track_id_a"));
s.track_id_b = longOrNull(m.get("track_id_b"));
return s;
}
private static String str(Object o) {
return o != null ? String.valueOf(o) : null;
}
private static double num(Object o) {
return o instanceof Number ? ((Number) o).doubleValue() : 0;
}
private static Long longOrNull(Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
@Nullable
private static List<String> extractLines(Map<String, Object> payload) {
if (payload == null) {
return null;
}
Object raw = payload.get("lines");
if (!(raw instanceof List<?> list) || list.isEmpty()) {
return null;
}
List<String> lines = new java.util.ArrayList<>();
for (Object item : list) {
if (item != null) {
String line = String.valueOf(item).trim();
if (!line.isEmpty()) {
lines.add(line);
}
}
}
return lines.isEmpty() ? null : lines;
}
public void postMacroToPeer(String peerId, List<String> lines) {
Map<String, Object> payload = new HashMap<>();
payload.put("lines", lines);
postCommandToPeer(peerId, "at", payload);
}
public void postCommandToPeer(String peerId, String kind, Map<String, Object> payload) {
executor.execute(() -> {
try {
serverApi.postCommand(deviceId, peerId, kind, payload);
} catch (Exception e) {
Log.e(TAG, "post command failed", e);
}
});
}
public void startPairedTrack(Runnable onDone, java.util.function.Consumer<String> onError) {
executor.execute(() -> {
try {
Map<String, Object> body = new HashMap<>();
body.put("device_id", deviceId);
serverApi.startPairedTrack(body);
startedSessionId = -1;
pendingAckSessionId = -1;
if (onDone != null) {
mainHandler.post(onDone);
}
} catch (Exception e) {
Log.e(TAG, "start paired failed", e);
if (onError != null) {
mainHandler.post(() -> onError.accept(
e.getMessage() != null ? e.getMessage() : "error"));
}
}
});
}
}
@@ -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;
@@ -14,6 +15,9 @@ public class LoraApp extends Application {
private SettingsRepository settingsRepository; private SettingsRepository settingsRepository;
private TrackRecorder trackRecorder; private TrackRecorder trackRecorder;
private NetworkMonitor networkMonitor; private NetworkMonitor networkMonitor;
private PeerStatsCache peerStatsCache;
private CommandPoller commandPoller;
private LocationTracker locationTracker;
@Override @Override
public void onCreate() { public void onCreate() {
@@ -23,12 +27,31 @@ public class LoraApp extends Application {
networkMonitor = new NetworkMonitor(this); networkMonitor = new NetworkMonitor(this);
networkMonitor.start(); networkMonitor.start();
telemetryUploader = new TelemetryUploader(this, settingsRepository, networkMonitor); telemetryUploader = new TelemetryUploader(this, settingsRepository, networkMonitor);
peerStatsCache = new PeerStatsCache();
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
String deviceId = settingsRepository.getOrCreateDeviceId();
trackRecorder = new TrackRecorder( trackRecorder = new TrackRecorder(
new ServerApi(settingsRepository.getServerUrl()), serverApi,
telemetryUploader, telemetryUploader,
settingsRepository.getOrCreateDeviceId(), deviceId,
networkMonitor networkMonitor
); );
commandPoller = new CommandPoller(
serverApi,
deviceId,
telemetryUploader,
trackRecorder,
peerStatsCache
);
commandPoller.start();
telemetryUploader.registerPresence();
if (networkMonitor != null) {
networkMonitor.addListener(online -> {
if (online) {
telemetryUploader.registerPresence();
}
});
}
} }
public NetworkMonitor getNetworkMonitor() { public NetworkMonitor getNetworkMonitor() {
@@ -47,12 +70,51 @@ public class LoraApp extends Application {
return trackRecorder; return trackRecorder;
} }
public PeerStatsCache getPeerStatsCache() {
return peerStatsCache;
}
public CommandPoller getCommandPoller() {
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) {
commandPoller.stop();
}
if (peerStatsCache == null) {
peerStatsCache = new PeerStatsCache();
}
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
trackRecorder = new TrackRecorder( trackRecorder = new TrackRecorder(
new ServerApi(settingsRepository.getServerUrl()), serverApi,
telemetryUploader, telemetryUploader,
settingsRepository.getOrCreateDeviceId(), settingsRepository.getOrCreateDeviceId(),
networkMonitor networkMonitor
); );
commandPoller = new CommandPoller(
serverApi,
settingsRepository.getOrCreateDeviceId(),
telemetryUploader,
trackRecorder,
peerStatsCache
);
commandPoller.start();
} }
} }
@@ -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,12 +60,12 @@ 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);
pager.setOffscreenPageLimit(1);
pager.setAdapter(new MainPagerAdapter(this)); pager.setAdapter(new MainPagerAdapter(this));
new TabLayoutMediator(tabs, pager, (tab, position) -> { new TabLayoutMediator(tabs, pager, (tab, position) -> {
int titleRes = switch (position) { int titleRes = switch (position) {
@@ -61,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());
} }
} }
@@ -0,0 +1,77 @@
package com.grigowashere.loratester;
import com.grigowashere.loratester.api.DeviceInfo;
import java.util.ArrayList;
import java.util.List;
public final class PeerDevices {
private static final long ONLINE_MS = 30_000;
private PeerDevices() {
}
public static Result resolve(List<DeviceInfo> devices, String selfId) {
if (devices == null || selfId == null) {
return Result.error("no_devices");
}
List<DeviceInfo> android = new ArrayList<>();
long now = System.currentTimeMillis();
for (DeviceInfo d : devices) {
if (d.device_id != null && d.device_id.startsWith("android-")) {
android.add(d);
}
}
if (android.size() != 2) {
return Result.error("expected_two");
}
String peer = null;
int online = 0;
for (DeviceInfo d : android) {
if (d.last_seen > 0 && (now / 1000.0 - d.last_seen) <= 30) {
online++;
}
if (!selfId.equals(d.device_id)) {
peer = d.device_id;
}
}
if (peer == null) {
return Result.error("peer_missing");
}
return new Result(peer, android.size(), online);
}
public static final class Result {
public final String peerId;
public final int deviceCount;
public final int onlineCount;
public final String error;
private Result(String peerId, int deviceCount, int onlineCount) {
this.peerId = peerId;
this.deviceCount = deviceCount;
this.onlineCount = onlineCount;
this.error = null;
}
private Result(String error) {
this.peerId = null;
this.deviceCount = 0;
this.onlineCount = 0;
this.error = error;
}
static Result error(String code) {
return new Result(code);
}
public boolean ok() {
return peerId != null;
}
public boolean bothOnline() {
return ok() && onlineCount >= 2;
}
}
}
@@ -0,0 +1,48 @@
package com.grigowashere.loratester;
import androidx.annotation.Nullable;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
public class PeerStatsCache {
public static final class Snapshot {
public final String meta;
public final String role;
public final Double rssi;
public final long atMs;
public Snapshot(String meta, String role, Double rssi, long atMs) {
this.meta = meta;
this.role = role;
this.rssi = rssi;
this.atMs = atMs;
}
}
private final AtomicReference<Snapshot> snapshot = new AtomicReference<>();
public void updateFromPayload(Map<String, Object> payload) {
if (payload == null) {
return;
}
String meta = payload.get("meta") != null ? String.valueOf(payload.get("meta")) : null;
String role = payload.get("role") != null ? String.valueOf(payload.get("role")) : null;
Double rssi = null;
Object r = payload.get("rssi");
if (r instanceof Number) {
rssi = ((Number) r).doubleValue();
}
snapshot.set(new Snapshot(meta, role, rssi, System.currentTimeMillis()));
}
@Nullable
public Snapshot get() {
return snapshot.get();
}
public void clear() {
snapshot.set(null);
}
}
@@ -13,8 +13,10 @@ 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 = "http://grigowashere.ru:7634"; public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru";
private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634";
public static final String DEFAULT_TELNET_HOST = "127.0.0.1"; public static final String DEFAULT_TELNET_HOST = "127.0.0.1";
public static final int DEFAULT_TELNET_PORT = 2727; public static final int DEFAULT_TELNET_PORT = 2727;
public static final String DEFAULT_RSSI_REGEX = "(?:RSSI|Power)[:\\s]*(-?\\d+(?:\\.\\d+)?)"; public static final String DEFAULT_RSSI_REGEX = "(?:RSSI|Power)[:\\s]*(-?\\d+(?:\\.\\d+)?)";
@@ -25,6 +27,28 @@ public class SettingsRepository {
public SettingsRepository(Context context) { public SettingsRepository(Context context) {
prefs = context.getApplicationContext() prefs = context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE); .getSharedPreferences(PREFS, Context.MODE_PRIVATE);
migrateLegacyServerUrl();
}
private void migrateLegacyServerUrl() {
String current = prefs.getString(KEY_SERVER_URL, null);
if (current == null || !isLegacyServerUrl(current)) {
return;
}
prefs.edit().putString(KEY_SERVER_URL, DEFAULT_SERVER).apply();
}
static boolean isLegacyServerUrl(String url) {
if (url == null) {
return false;
}
String u = url.trim().toLowerCase();
while (u.endsWith("/")) {
u = u.substring(0, u.length() - 1);
}
return u.equals(LEGACY_SERVER_HTTP)
|| u.equals("http://grigowashere.ru")
|| u.equals("https://grigowashere.ru:7634");
} }
public String getServerUrl() { public String getServerUrl() {
@@ -83,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;
@@ -11,12 +12,14 @@ import com.grigowashere.loratester.api.UploadQueue;
import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.telnet.AtCommandFormatter; import com.grigowashere.loratester.telnet.AtCommandFormatter;
import com.grigowashere.loratester.telnet.RadioMacroBuilder;
import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.telnet.TelnetClient; import com.grigowashere.loratester.telnet.TelnetClient;
import com.grigowashere.loratester.telnet.TelnetFrameParser; import com.grigowashere.loratester.telnet.TelnetFrameParser;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
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.ScheduledExecutorService;
@@ -112,6 +115,18 @@ public class TelemetryUploader implements TelnetClient.Listener {
} }
} }
public boolean hasGpsFix() {
return GeoUtils.isValidCoordinate(lat, lon);
}
public double getGpsLat() {
return lat;
}
public double getGpsLon() {
return lon;
}
private Double validLat() { private Double validLat() {
return GeoUtils.isValidCoordinate(lat, lon) ? lat : null; return GeoUtils.isValidCoordinate(lat, lon) ? lat : null;
} }
@@ -168,6 +183,52 @@ public class TelemetryUploader implements TelnetClient.Listener {
}); });
} }
public void sendMacroSequence(List<String> lines, AtSendCallback callback) {
telnetExecutor.execute(() -> {
TelnetClient.SendResult last = TelnetClient.SendResult.SENT;
if (lines != null) {
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
if (line == null || line.isEmpty()) {
continue;
}
last = sendLineOnWorker(line);
if (last != TelnetClient.SendResult.SENT) {
break;
}
if (i < lines.size() - 1) {
try {
Thread.sleep(150);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
break;
}
}
}
}
if (callback != null) {
TelnetClient.SendResult r = last;
mainHandler.post(() -> callback.onResult(r));
}
});
}
private TelnetClient.SendResult sendLineOnWorker(String line) {
if (RadioMacroBuilder.STOP.equals(line)) {
appendConsole(">> S\n");
if (telnetClient == null) {
appendConsole("!! telnet not started\n");
return TelnetClient.SendResult.NOT_CONNECTED;
}
TelnetClient.SendResult result = telnetClient.sendRawLine(line);
if (result != TelnetClient.SendResult.SENT) {
appendConsole("!! send failed: " + result + "\n");
}
return result;
}
return sendAtCommandOnWorker(line);
}
private TelnetClient.SendResult sendAtCommandOnWorker(String command) { private TelnetClient.SendResult sendAtCommandOnWorker(String command) {
String normalized = AtCommandFormatter.normalize(command); String normalized = AtCommandFormatter.normalize(command);
appendConsole(">> " + normalized + "\n"); appendConsole(">> " + normalized + "\n");
@@ -225,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,
@@ -237,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 {
@@ -0,0 +1,13 @@
package com.grigowashere.loratester.api;
import java.util.Map;
public class DeviceCommand {
public long id;
public String from_device_id;
public String to_device_id;
public String kind;
public Map<String, Object> payload;
public double created_at;
public Double delivered_at;
}
@@ -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;
@@ -0,0 +1,30 @@
package com.grigowashere.loratester.api;
import java.util.List;
public class ElevationGridResult {
public boolean ok;
public String error;
public Center center;
public double radius_m;
public double step_m;
public double min_delta_m;
public double max_delta_m;
public List<GridPoint> points;
public static class Center {
public double lat;
public double lon;
public double elevation_m;
}
public static class GridPoint {
public int i;
public int j;
public double lat;
public double lon;
public double dist_m;
public Double elevation_m;
public double delta_m;
}
}
@@ -0,0 +1,19 @@
package com.grigowashere.loratester.api;
public class NearestHillResult {
public boolean ok;
public String error;
public HillPoint center;
public HillPoint hill;
public double radius_m;
public int candidates;
public static class HillPoint {
public double lat;
public double lon;
public Double elevation_m;
public Double dist_m;
public Double prominence_m;
public Boolean is_local_max;
}
}
@@ -0,0 +1,15 @@
package com.grigowashere.loratester.api;
public class PairedTrackSession {
public long id;
public String device_a;
public String device_b;
public String initiator;
public String status;
public double start_at;
public Long track_id_a;
public Long track_id_b;
public double created_at;
public double server_time;
public boolean ready;
}
@@ -30,6 +30,7 @@ public class ServerApi {
private static final Type TELEMETRY_HISTORY = private static final Type TELEMETRY_HISTORY =
new TypeToken<List<TelemetryHistoryItem>>() {}.getType(); new TypeToken<List<TelemetryHistoryItem>>() {}.getType();
private static final Type TRACK_LIST = new TypeToken<List<TrackInfo>>() {}.getType(); private static final Type TRACK_LIST = new TypeToken<List<TrackInfo>>() {}.getType();
private static final Type COMMAND_LIST = new TypeToken<List<DeviceCommand>>() {}.getType();
private final String baseUrl; private final String baseUrl;
private final OkHttpClient client; private final OkHttpClient client;
@@ -50,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);
@@ -114,6 +118,73 @@ public class ServerApi {
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST); return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
} }
public void postCommand(
String fromDeviceId,
String toDeviceId,
String kind,
Map<String, Object> payload
) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("from_device_id", fromDeviceId);
body.put("to_device_id", toDeviceId);
body.put("kind", kind);
if (payload != null) {
body.put("payload", payload);
}
postJson("/api/commands", body, true);
}
public List<DeviceCommand> pollPendingCommands(String deviceId) throws IOException {
String path = "/api/commands/pending?device_id="
+ java.net.URLEncoder.encode(deviceId, "UTF-8") + "&limit=20";
Request request = new Request.Builder()
.url(baseUrl + path)
.header(HEADER_LORA_CLIENT, CLIENT_ANDROID)
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), COMMAND_LIST);
}
}
@SuppressWarnings("unchecked")
public Map<String, Object> startPairedTrack(Map<String, Object> body) throws IOException {
return postJsonMap("/api/paired-tracks/start", body, true);
}
@SuppressWarnings("unchecked")
public Map<String, Object> getActivePairedTrack() throws IOException {
Request request = new Request.Builder()
.url(baseUrl + "/api/paired-tracks/active")
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), Map.class);
}
}
public void ackPairedTrack(long sessionId, String deviceId, long trackId) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("session_id", sessionId);
body.put("device_id", deviceId);
body.put("track_id", trackId);
postJson("/api/paired-tracks/ack", body, true);
}
public void cancelPairedTrack(Long sessionId) throws IOException {
Map<String, Object> body = new HashMap<>();
if (sessionId != null) {
body.put("session_id", sessionId);
}
postJson("/api/paired-tracks/cancel", body, false);
}
public TrackDetail getTrack(long trackId) throws IOException { public TrackDetail getTrack(long trackId) throws IOException {
Request request = new Request.Builder() Request request = new Request.Builder()
.url(baseUrl + "/api/tracks/" + trackId) .url(baseUrl + "/api/tracks/" + trackId)
@@ -127,6 +198,62 @@ public class ServerApi {
} }
} }
@SuppressWarnings("unchecked")
public Map<String, Object> getHealth() throws IOException {
Request request = new Request.Builder()
.url(baseUrl + "/api/health")
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), Map.class);
}
}
public NearestHillResult findNearestHill(double lat, double lon, int radiusM)
throws IOException {
String path = "/api/elevation/nearest-hill?lat="
+ lat
+ "&lon="
+ lon
+ "&radius_m="
+ radiusM;
Request request = new Request.Builder()
.url(baseUrl + path)
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), NearestHillResult.class);
}
}
public ElevationGridResult getElevationGrid(double lat, double lon, int radiusM, int stepM)
throws IOException {
String path = "/api/elevation/grid?lat="
+ lat
+ "&lon="
+ lon
+ "&radius_m="
+ radiusM
+ "&step_m="
+ stepM;
Request request = new Request.Builder()
.url(baseUrl + path)
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), ElevationGridResult.class);
}
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android) private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
throws IOException { throws IOException {
@@ -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;
@@ -20,4 +20,14 @@ public final class GeoUtils {
public static boolean isValidCoordinate(Double lat, Double lon) { public static boolean isValidCoordinate(Double lat, Double lon) {
return lat != null && lon != null && isValidCoordinate(lat.doubleValue(), lon.doubleValue()); return lat != null && lon != null && isValidCoordinate(lat.doubleValue(), lon.doubleValue());
} }
public static double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
final double r = 6_371_000;
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
return r * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
} }
@@ -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());
}
}
@@ -0,0 +1,271 @@
package com.grigowashere.loratester.model;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.grigowashere.loratester.telnet.StatsExtractor;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Normalized radio stats from telemetry meta JSON (no duplicate fields). */
public final class RadioSnapshot {
public final String role;
public final String frame;
public final Double frequencyMhz;
public final Integer sf;
public final Double bwKhz;
public final Double powerDbm;
public final Double rssiDbm;
public final Double snrDb;
public final Integer packet;
public final String payload;
public final Double onAirMs;
public final Double txPktPerS;
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(
String role,
String frame,
Double frequencyMhz,
Integer sf,
Double bwKhz,
Double powerDbm,
Double rssiDbm,
Double snrDb,
Integer packet,
String payload,
Double onAirMs,
Double txPktPerS,
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;
this.frame = frame;
this.frequencyMhz = frequencyMhz;
this.sf = sf;
this.bwKhz = bwKhz;
this.powerDbm = powerDbm;
this.rssiDbm = rssiDbm;
this.snrDb = snrDb;
this.packet = packet;
this.payload = payload;
this.onAirMs = onAirMs;
this.txPktPerS = txPktPerS;
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,
null, null, null, null, null, null, null, null, null, null, null, null,
Map.of());
}
public static RadioSnapshot fromMeta(String metaJson, String roleFallback, Double rssiFallback) {
if (metaJson == null || metaJson.isBlank()) {
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,
null, null, null, null, null, null, null, null, null, null, null, null,
Map.of());
}
return snap;
}
try {
JsonObject o = JsonParser.parseString(metaJson).getAsJsonObject();
String role = text(o, "role");
if (role == null) {
role = roleFallback;
}
Double rssi = dbl(o, "rssi_dbm");
if (rssi == null) {
rssi = rssiFallback;
}
Map<String, String> extra = new LinkedHashMap<>();
JsonElement fieldsEl = o.get("fields");
if (fieldsEl != null && fieldsEl.isJsonObject()) {
for (Map.Entry<String, JsonElement> e : fieldsEl.getAsJsonObject().entrySet()) {
String label = e.getKey();
if (isKnownFieldLabel(label)) {
continue;
}
extra.put(label, e.getValue().getAsString());
}
}
return new RadioSnapshot(
role,
text(o, "frame"),
hzToMhz(lng(o, "frequency_hz")),
integer(o, "spreading_factor"),
dbl(o, "bandwidth_khz"),
dbl(o, "power_dbm"),
rssi,
dbl(o, "snr_db"),
integer(o, "packet"),
text(o, "payload"),
dbl(o, "on_air_ms"),
dbl(o, "tx_pkt_per_s"),
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,
null, null, null, null, null, null, null, null, null, null, null, null,
Map.of());
}
}
public static RadioSnapshot fromExtracted(StatsExtractor.ExtractedStats stats) {
if (stats == null) {
return empty();
}
return fromMeta(stats.metaJson, stats.role, stats.rssiDbm != null ? stats.rssiDbm : stats.rssi);
}
public Set<String> diff(RadioSnapshot prev) {
Set<String> changed = new HashSet<>();
if (prev == null) {
return changed;
}
cmp(changed, "role", role, prev.role);
cmp(changed, "rssi", rssiDbm, prev.rssiDbm);
cmp(changed, "snr", snrDb, prev.snrDb);
cmp(changed, "packet", packet, prev.packet);
cmp(changed, "payload", payload, prev.payload);
cmp(changed, "per", perPercent, prev.perPercent);
cmp(changed, "rxQuality", rxQualityPercent, prev.rxQualityPercent);
cmp(changed, "txSpeed", txPktPerS, prev.txPktPerS);
cmp(changed, "rxSpeed", rxPktPerS, prev.rxPktPerS);
cmp(changed, "frequency", frequencyMhz, prev.frequencyMhz);
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;
}
private static void cmp(Set<String> changed, String key, Object a, Object b) {
if (!Objects.equals(a, b)) {
changed.add(key);
}
}
private static boolean isKnownFieldLabel(String label) {
String n = label.toLowerCase(Locale.ROOT).trim();
return n.equals("send") || n.equals("receive")
|| 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.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) {
JsonElement e = o.get(key);
return e != null && !e.isJsonNull() ? e.getAsString() : null;
}
private static Integer integer(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsInt() : null;
}
private static Double dbl(JsonObject o, String key) {
JsonElement e = o.get(key);
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;
}
private static Double hzToMhz(Long hz) {
if (hz == null) {
return null;
}
return hz / 1_000_000.0;
}
}
@@ -30,4 +30,12 @@ public final class AtCommandFormatter {
String wire = line + "\r\n"; String wire = line + "\r\n";
return wire.getBytes(StandardCharsets.UTF_8); return wire.getBytes(StandardCharsets.UTF_8);
} }
/** Literal line (e.g. screen reset "S") without AT prefix. */
public static byte[] toWireBytesLiteral(String line) {
if (line == null || line.isEmpty()) {
return new byte[0];
}
return (line + "\r\n").getBytes(StandardCharsets.UTF_8);
}
} }
@@ -1,15 +1,33 @@
package com.grigowashere.loratester.telnet; package com.grigowashere.loratester.telnet;
/** Common AT commands for LoRa modules (via telnet bridge). */ /** LoRa module AT commands (telnet bridge). */
public final class AtCommands { public final class AtCommands {
public static final String HELP = "AT+H";
public static final String TRANSMIT = "AT+TX"; public static final String TRANSMIT = "AT+TX";
public static final String RECEIVE = "AT+RX"; public static final String RECEIVE = "AT+RX";
/** Stop TX or RX test. */
public static final String STOP = "S";
public static final String TIMEOUT_MS = "AT+TM=";
public static final String FREQUENCY_HZ = "AT+FQ=";
public static final String POWER_DBM = "AT+PW=";
public static final String SPREADING_FACTOR = "AT+SF=";
public static final String BANDWIDTH = "AT+BW=";
public static final String CODE_RATE = "AT+CR=";
public static final String PREAMBLE = "AT+PL=";
/** Legacy / bridge helpers (if supported by firmware). */
public static final String HELP = "AT+H";
public static final String STATUS = "AT+STATUS"; public static final String STATUS = "AT+STATUS";
public static final String RESET = "AT+RESET"; public static final String RESET = "AT+RESET";
public static final String BASIC = "AT"; public static final String BASIC = "AT";
public static final String[] BW_KHZ = {
"7.81", "10.42", "15.63", "20.83", "31.25",
"41.67", "62.5", "125", "250", "500"
};
public static final String[] CODE_RATES = {"4/5", "4/6", "4/7", "4/8"};
private AtCommands() { private AtCommands() {
} }
} }
@@ -1,10 +1,7 @@
package com.grigowashere.loratester.telnet; package com.grigowashere.loratester.telnet;
import com.google.gson.JsonElement; import com.grigowashere.loratester.model.RadioSnapshot;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.HashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -14,67 +11,69 @@ public final class LoraStatsFormatter {
private LoraStatsFormatter() { private LoraStatsFormatter() {
} }
/** Human-readable lines from telemetry meta JSON (fields first). */ @Deprecated
public static String formatMeta(String metaJson) { public static String formatMeta(String metaJson) {
if (metaJson == null || metaJson.isEmpty()) { RadioSnapshot snap = RadioSnapshot.fromMeta(metaJson, null, null);
StringBuilder sb = new StringBuilder();
String dynamic = formatDynamic(snap, Set.of());
if (!dynamic.isEmpty()) {
sb.append(dynamic);
}
String stat = formatStatic(snap, Set.of());
if (!stat.isEmpty()) {
if (sb.length() > 0) {
sb.append("\n");
}
sb.append(stat);
}
return sb.toString().trim();
}
public static String formatDynamic(RadioSnapshot s, Set<String> changed) {
if (s == null) {
return ""; return "";
} }
try {
JsonObject o = JsonParser.parseString(metaJson).getAsJsonObject();
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
Set<String> shown = new HashSet<>(); appendLine(sb, "RSSI", fmtDbm(s.rssiDbm), "rssi", changed);
appendLine(sb, "SNR", fmtSuffix(s.snrDb, " dB"), "snr", changed);
appendFieldsBlock(sb, o.get("fields"), shown); appendLine(sb, "RX Quality", fmtSuffix(s.rxQualityPercent, " %"), "rxQuality", changed);
appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed);
String role = text(o, "role"); appendLine(sb, "Payload", s.payload, "payload", changed);
if (role != null) { appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed);
append(sb, "Роль", roleLabel(role)); 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()) {
append(sb, e.getKey(), e.getValue());
} }
append(sb, "Кадр", text(o, "frame"));
append(sb, "Мощность TX", dbl(o, "power_dbm"), " dBm");
append(sb, "RSSI", dbl(o, "rssi_dbm"), " dBm");
append(sb, "SNR", dbl(o, "snr_db"), " dB");
append(sb, "Частота", freqMhz(o), " MHz");
append(sb, "SF", integer(o, "spreading_factor"));
append(sb, "BW", integer(o, "bandwidth_khz"), " kHz");
append(sb, "Пакет", integer(o, "packet"));
append(sb, "Payload", text(o, "payload"));
append(sb, "On Air", dbl(o, "on_air_ms"), " ms");
append(sb, "TX Speed", dbl(o, "tx_pkt_per_s"), " pkt/s");
append(sb, "RX Speed", dbl(o, "rx_pkt_per_s"), " pkt/s");
append(sb, "PER", dbl(o, "per_percent"), " %");
return sb.toString().trim(); return sb.toString().trim();
} catch (Exception ignored) {
return metaJson;
}
} }
private static void appendFieldsBlock(StringBuilder sb, JsonElement fieldsEl, Set<String> shown) { public static String formatStatic(RadioSnapshot s, Set<String> changed) {
if (fieldsEl == null || !fieldsEl.isJsonObject()) { if (s == null) {
return; return "";
} }
JsonObject fields = fieldsEl.getAsJsonObject(); StringBuilder sb = new StringBuilder();
for (Map.Entry<String, JsonElement> e : fields.entrySet()) { if (s.role != null) {
String label = e.getKey(); appendLine(sb, "Роль", roleLabel(s.role), "role", changed);
if (isSkippedFieldLabel(label)) {
continue;
} }
String norm = normalizeLabel(label); appendLine(sb, "Частота", fmtSuffix(s.frequencyMhz, " MHz"), "frequency", changed);
if (shown.contains(norm)) { appendLine(sb, "SF", fmtInt(s.sf), "sf", changed);
continue; appendLine(sb, "BW", fmtBw(s.bwKhz), "bw", changed);
} appendLine(sb, "Мощность TX", fmtDbm(s.powerDbm), "power", changed);
shown.add(norm); appendLine(sb, "Code Rate", s.codeRate, "codeRate", changed);
append(sb, label, e.getValue().getAsString()); 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);
private static String normalizeLabel(String label) { appendLine(sb, "TX Timeout", fmtSuffix(s.txTimeoutMs, " ms"), "txTimeout", changed);
return label.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ").trim(); appendLine(sb, "On Air", fmtSuffix(s.onAirMs, " ms"), "onAir", changed);
} return sb.toString().trim();
private static boolean isSkippedFieldLabel(String label) {
String l = normalizeLabel(label);
return l.equals("send") || l.equals("receive");
} }
public static String roleLabel(String role) { public static String roleLabel(String role) {
@@ -87,12 +86,23 @@ public final class LoraStatsFormatter {
return role; return role;
} }
private static String freqMhz(JsonObject o) { private static void appendLine(
if (!o.has("frequency_hz")) { StringBuilder sb,
return null; String label,
String value,
String changeKey,
Set<String> changed
) {
if (value == null || value.isEmpty()) {
return;
} }
long hz = o.get("frequency_hz").getAsLong(); if (sb.length() > 0) {
return String.format(Locale.US, "%.3f", hz / 1_000_000.0); sb.append("\n");
}
if (changed != null && changed.contains(changeKey)) {
sb.append("");
}
sb.append(label).append(": ").append(value);
} }
private static void append(StringBuilder sb, String label, String value) { private static void append(StringBuilder sb, String label, String value) {
@@ -105,25 +115,30 @@ public final class LoraStatsFormatter {
sb.append(label).append(": ").append(value); sb.append(label).append(": ").append(value);
} }
private static void append(StringBuilder sb, String label, String value, String suffix) { private static String fmtDbm(Double v) {
if (value == null) { return v != null ? String.format(Locale.US, "%.0f dBm", v) : null;
return;
}
append(sb, label, value + suffix);
} }
private static String text(JsonObject o, String key) { private static String fmtInt(Integer v) {
JsonElement e = o.get(key); return v != null ? String.valueOf(v) : null;
return e != null && !e.isJsonNull() ? e.getAsString() : null;
} }
private static String integer(JsonObject o, String key) { private static String fmtBw(Double v) {
JsonElement e = o.get(key); return v != null ? String.format(Locale.US, "%.2f kHz", v) : null;
return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsInt()) : null;
} }
private static String dbl(JsonObject o, String key) { private static String fmtCrc(Boolean enabled) {
JsonElement e = o.get(key); if (enabled == null) {
return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsDouble()) : 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;
} }
} }
@@ -0,0 +1,74 @@
package com.grigowashere.loratester.telnet;
import java.util.ArrayList;
import java.util.List;
/** Builds macro: S (stop) then configuration AT commands, then TX/RX if requested. */
public final class RadioMacroBuilder {
/** Stop TX or RX before applying new settings. */
public static final String STOP = AtCommands.STOP;
/** @deprecated use {@link #STOP} */
@Deprecated
public static final String SCREEN_RESET = STOP;
public static final class Params {
public Long frequencyHz;
public Integer powerDbm;
public Integer sf;
public String bwKhz;
public String codeRate;
public Integer preamble;
public Integer sendTimeoutMs;
public String role;
}
private RadioMacroBuilder() {
}
public static List<String> apply(Integer sf, Integer bwKhz, String role) {
Params p = new Params();
p.sf = sf;
if (bwKhz != null) {
p.bwKhz = String.valueOf(bwKhz);
}
p.role = role;
return apply(p);
}
public static List<String> apply(Params p) {
List<String> lines = new ArrayList<>();
lines.add(STOP);
if (p == null) {
return lines;
}
if (p.frequencyHz != null && p.frequencyHz >= 430_000_000L && p.frequencyHz <= 470_000_000L) {
lines.add(AtCommands.FREQUENCY_HZ + p.frequencyHz);
}
if (p.powerDbm != null && p.powerDbm >= -9 && p.powerDbm <= 22) {
lines.add(AtCommands.POWER_DBM + p.powerDbm);
}
if (p.sf != null && p.sf >= 5 && p.sf <= 12) {
lines.add(AtCommands.SPREADING_FACTOR + p.sf);
}
if (p.bwKhz != null && !p.bwKhz.isBlank()) {
lines.add(AtCommands.BANDWIDTH + p.bwKhz.trim());
}
if (p.codeRate != null && !p.codeRate.isBlank()) {
lines.add(AtCommands.CODE_RATE + p.codeRate.trim());
}
if (p.preamble != null && p.preamble >= 1 && p.preamble <= 64) {
lines.add(AtCommands.PREAMBLE + p.preamble);
}
if (p.sendTimeoutMs != null && p.sendTimeoutMs >= 0 && p.sendTimeoutMs <= 60_000) {
lines.add(AtCommands.TIMEOUT_MS + p.sendTimeoutMs);
}
if (StatsExtractor.ROLE_TX.equals(p.role)) {
lines.add(AtCommands.TRANSMIT);
} else if (StatsExtractor.ROLE_RX.equals(p.role)) {
lines.add(AtCommands.RECEIVE);
}
return lines;
}
}
@@ -25,14 +25,33 @@ 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);
private static final Pattern RX_SPEED = Pattern.compile("RX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern RX_SPEED = Pattern.compile("RX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PER = Pattern.compile("PER\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern PER = Pattern.compile("PER\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern RX_QUALITY = Pattern.compile(
"RX Quality\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private final Pattern rssiPattern; private final Pattern rssiPattern;
private final Pattern rangePattern; private final Pattern rangePattern;
@@ -68,10 +87,6 @@ public class StatsExtractor {
if (role != null) { if (role != null) {
meta.put("role", role); meta.put("role", role);
} }
if (!fields.isEmpty()) {
meta.put("fields", fields);
}
Double rssiDbm = firstDouble(RSSI, normalized); Double rssiDbm = firstDouble(RSSI, normalized);
if (rssiDbm == null) { if (rssiDbm == null) {
rssiDbm = matchDouble(rssiPattern, normalized); rssiDbm = matchDouble(rssiPattern, normalized);
@@ -91,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));
@@ -102,8 +117,25 @@ public class StatsExtractor {
putDouble(meta, "tx_pkt_per_s", matchDouble(TX_SPEED, normalized)); putDouble(meta, "tx_pkt_per_s", matchDouble(TX_SPEED, normalized));
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));
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));
enrichFieldsFromStructured(meta, fields); if (!fields.isEmpty()) {
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;
@@ -128,57 +160,25 @@ public class StatsExtractor {
String value = trimmed.substring(colon + 1).trim(); String value = trimmed.substring(colon + 1).trim();
if (label.isEmpty() if (label.isEmpty()
|| label.equalsIgnoreCase("SEND") || label.equalsIgnoreCase("SEND")
|| label.equalsIgnoreCase("RECEIVE")) { || label.equalsIgnoreCase("RECEIVE")
|| isStructuredLabel(label)) {
continue; continue;
} }
fields.put(label, value); fields.put(label, value);
} }
} }
/** Ensure meta.fields has display lines even when line split missed some rows. */ private static boolean isStructuredLabel(String label) {
private static void enrichFieldsFromStructured( String n = label.toLowerCase(Locale.ROOT).trim();
Map<String, Object> meta, return n.equals("frequency") || n.equals("power") || n.equals("rssi")
Map<String, String> fields || n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth")
) { || n.equals("packet") || n.contains("packet number") || n.equals("payload")
putFieldIfAbsent(fields, "Frequency", meta.get("frequency_hz"), || n.contains("packet receive") || n.contains("packet total") || n.contains("packet error")
v -> v + " Hz"); || n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid")
putFieldIfAbsent(fields, "Power", meta.get("power_dbm"), || n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
v -> v + " dBm"); || n.equals("per") || n.contains("rx quality") || n.contains("tx timeout")
putFieldIfAbsent(fields, "RSSI", meta.get("rssi_dbm"), || n.contains("code rate") || n.contains("preamble length")
v -> String.valueOf(v)); || n.contains("low data rate") || n.equals("crc") || n.contains("payload length");
putFieldIfAbsent(fields, "SNR", meta.get("snr_db"),
v -> String.valueOf(v));
putFieldIfAbsent(fields, "Spreading Factor", meta.get("spreading_factor"),
String::valueOf);
putFieldIfAbsent(fields, "Bandwidth", meta.get("bandwidth_khz"),
v -> v + " kHz");
Object packet = meta.get("packet");
if (packet != null) {
putFieldIfAbsent(fields, "Packet", packet, String::valueOf);
putFieldIfAbsent(fields, "Packet Number", packet, String::valueOf);
}
putFieldIfAbsent(fields, "Payload", meta.get("payload"),
v -> (String) v);
putFieldIfAbsent(fields, "On Air", meta.get("on_air_ms"),
v -> v + " ms");
putFieldIfAbsent(fields, "TX Speed", meta.get("tx_pkt_per_s"),
v -> v + " pkt/s");
putFieldIfAbsent(fields, "RX Speed", meta.get("rx_pkt_per_s"),
v -> v + " pkt/s");
putFieldIfAbsent(fields, "PER", meta.get("per_percent"),
v -> v + " %");
}
private static void putFieldIfAbsent(
Map<String, String> fields,
String label,
Object value,
java.util.function.Function<Object, String> format
) {
if (value == null || fields.containsKey(label)) {
return;
}
fields.put(label, format.apply(value));
} }
private static ExtractedStats empty(String frame) { private static ExtractedStats empty(String frame) {
@@ -241,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()) {
@@ -74,11 +74,23 @@ public class TelnetClient {
/** /**
* Sends an AT command. Adds AT prefix and CR+LF if missing. * Sends an AT command. Adds AT prefix and CR+LF if missing.
*/ */
public SendResult sendRawLine(String line) {
byte[] wire = AtCommandFormatter.toWireBytesLiteral(line);
if (wire.length == 0) {
return SendResult.EMPTY;
}
return writeWire(wire);
}
public SendResult sendAtCommand(String command) { public SendResult sendAtCommand(String command) {
byte[] wire = AtCommandFormatter.toWireBytes(command); byte[] wire = AtCommandFormatter.toWireBytes(command);
if (wire.length == 0) { if (wire.length == 0) {
return SendResult.EMPTY; return SendResult.EMPTY;
} }
return writeWire(wire);
}
private SendResult writeWire(byte[] wire) {
Socket socket = activeSocket.get(); Socket socket = activeSocket.get();
if (socket == null || socket.isClosed()) { if (socket == null || socket.isClosed()) {
return SendResult.NOT_CONNECTED; return SendResult.NOT_CONNECTED;
@@ -29,7 +29,11 @@ public class TrackRecorder {
public interface Listener { public interface Listener {
void onStateChanged(boolean recording, int pointCount, long trackId); void onStateChanged(boolean recording, int pointCount, long trackId);
void onError(String message); void onError(String message);
default void onPointRecorded(double lat, double lon) {
}
} }
private final ServerApi serverApi; private final ServerApi serverApi;
@@ -55,6 +59,7 @@ public class TrackRecorder {
private ScheduledFuture<?> sampleTask; private ScheduledFuture<?> sampleTask;
private ScheduledFuture<?> flushTask; private ScheduledFuture<?> flushTask;
private Listener listener; private Listener listener;
private Listener pairedListener;
public TrackRecorder( public TrackRecorder(
ServerApi serverApi, ServerApi serverApi,
@@ -77,6 +82,10 @@ public class TrackRecorder {
this.listener = listener; this.listener = listener;
} }
public void setPairedListener(Listener pairedListener) {
this.pairedListener = pairedListener;
}
public void updateLocation(double lat, double lon, double altitude) { public void updateLocation(double lat, double lon, double altitude) {
if (GeoUtils.isValidCoordinate(lat, lon)) { if (GeoUtils.isValidCoordinate(lat, lon)) {
this.lat = lat; this.lat = lat;
@@ -204,6 +213,18 @@ public class TrackRecorder {
} }
totalPoints++; totalPoints++;
notifyState(); notifyState();
notifyPoint(lat, lon);
}
private void notifyPoint(double lat, double lon) {
mainHandler.post(() -> {
if (listener != null) {
listener.onPointRecorded(lat, lon);
}
if (pairedListener != null) {
pairedListener.onPointRecorded(lat, lon);
}
});
} }
private void flushBuffer() { private void flushBuffer() {
@@ -230,16 +251,24 @@ public class TrackRecorder {
} }
private void notifyState() { private void notifyState() {
if (listener == null) { mainHandler.post(() -> {
return; if (listener != null) {
listener.onStateChanged(recording, totalPoints, trackId);
} }
mainHandler.post(() -> listener.onStateChanged(recording, totalPoints, trackId)); if (pairedListener != null) {
pairedListener.onStateChanged(recording, totalPoints, trackId);
}
});
} }
private void notifyError(String msg) { private void notifyError(String msg) {
if (listener == null) { mainHandler.post(() -> {
return; if (listener != null) {
listener.onError(msg);
} }
mainHandler.post(() -> listener.onError(msg)); if (pairedListener != null) {
pairedListener.onError(msg);
}
});
} }
} }
@@ -5,8 +5,10 @@ import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.Button; import android.widget.Button;
import android.widget.ScrollView; import android.widget.ScrollView;
import android.widget.Spinner;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
@@ -14,29 +16,56 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import com.google.android.material.chip.Chip; import com.google.android.material.button.MaterialButton;
import com.google.android.material.chip.ChipGroup; import com.google.android.material.button.MaterialButtonToggleGroup;
import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputEditText;
import com.grigowashere.loratester.CommandPoller;
import com.grigowashere.loratester.LoraApp; import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.PeerDevices;
import com.grigowashere.loratester.R; import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.AtCommands; import com.grigowashere.loratester.telnet.AtCommands;
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
import com.grigowashere.loratester.telnet.RadioMacroBuilder;
import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.telnet.TelnetClient; import com.grigowashere.loratester.telnet.TelnetClient;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class AtFragment extends Fragment { public class AtFragment extends Fragment {
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private FragmentPollHelper pollHelper; private FragmentPollHelper pollHelper;
private TelemetryUploader uploader; private TelemetryUploader uploader;
private CommandPoller commandPoller;
private TextView atStatus; private TextView atStatus;
private TextView atConsole; private TextView atCurrentSnapshot;
private TextInputEditText atInputFq;
private TextInputEditText atInputPw;
private TextInputEditText atInputSf;
private TextInputEditText atInputPl;
private TextInputEditText atInputTm;
private Spinner atBwSpinner;
private Spinner atCrSpinner;
private Spinner atRoleSpinner;
private MaterialButtonToggleGroup atTargetGroup;
private ScrollView atConsoleScroll; private ScrollView atConsoleScroll;
private TextInputEditText atCommandInput; private TextView atConsole;
private String lastConsole = ""; private String lastConsole = "";
private boolean consoleVisible;
private boolean formInitialized;
@Override @Override
public void onAttach(@NonNull Context context) { public void onAttach(@NonNull Context context) {
super.onAttach(context); super.onAttach(context);
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader(); LoraApp app = (LoraApp) context.getApplicationContext();
uploader = app.getTelemetryUploader();
commandPoller = app.getCommandPoller();
} }
@Nullable @Nullable
@@ -52,137 +81,233 @@ public class AtFragment extends Fragment {
@Override @Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
atStatus = view.findViewById(R.id.atStatus); atStatus = view.findViewById(R.id.atStatus);
atConsole = view.findViewById(R.id.atConsole); atCurrentSnapshot = view.findViewById(R.id.atCurrentSnapshot);
atInputFq = view.findViewById(R.id.atInputFq);
atInputPw = view.findViewById(R.id.atInputPw);
atInputSf = view.findViewById(R.id.atInputSf);
atInputPl = view.findViewById(R.id.atInputPl);
atInputTm = view.findViewById(R.id.atInputTm);
atBwSpinner = view.findViewById(R.id.atBwSpinner);
atCrSpinner = view.findViewById(R.id.atCrSpinner);
atRoleSpinner = view.findViewById(R.id.atRoleSpinner);
atTargetGroup = view.findViewById(R.id.atTargetGroup);
atConsoleScroll = view.findViewById(R.id.atConsoleScroll); atConsoleScroll = view.findViewById(R.id.atConsoleScroll);
atCommandInput = view.findViewById(R.id.atCommandInput); atConsole = view.findViewById(R.id.atConsole);
ChipGroup chips = view.findViewById(R.id.atQuickChips); Button applyBtn = view.findViewById(R.id.atApplyBtn);
Button sendBtn = view.findViewById(R.id.atSendBtn); Button stopBtn = view.findViewById(R.id.atStopBtn);
Button clearLog = view.findViewById(R.id.atClearLog); MaterialButton consoleToggle = view.findViewById(R.id.atConsoleToggle);
pollHelper = new FragmentPollHelper(this, this::refreshConsole); pollHelper = new FragmentPollHelper(this, this::refresh);
addQuickChip(chips, "AT+H", AtCommands.HELP); atTargetGroup.check(R.id.atTargetLocal);
addQuickChip(chips, "AT+TX", AtCommands.TRANSMIT); atBwSpinner.setAdapter(new ArrayAdapter<>(
addQuickChip(chips, "AT+RX", AtCommands.RECEIVE); requireContext(),
addQuickChip(chips, "AT+STATUS", AtCommands.STATUS); android.R.layout.simple_spinner_dropdown_item,
addQuickChip(chips, "AT", AtCommands.BASIC); AtCommands.BW_KHZ
));
atCrSpinner.setAdapter(new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
AtCommands.CODE_RATES
));
atRoleSpinner.setAdapter(new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
new String[]{"", "TX", "RX"}
));
sendBtn.setOnClickListener(v -> sendFromInput()); applyBtn.setOnClickListener(v -> applyMacro());
clearLog.setOnClickListener(v -> { stopBtn.setOnClickListener(v -> sendLines(List.of(AtCommands.STOP)));
if (uploader != null) { consoleToggle.setOnClickListener(v -> {
uploader.clearConsoleLog(); consoleVisible = !consoleVisible;
} atConsoleScroll.setVisibility(consoleVisible ? View.VISIBLE : View.GONE);
lastConsole = ""; consoleToggle.setText(consoleVisible
if (atConsole != null) { ? getString(R.string.at_console_hide)
atConsole.setText(""); : getString(R.string.at_console_toggle));
}
});
if (atCommandInput != null) {
atCommandInput.setOnEditorActionListener((textView, actionId, event) -> {
sendFromInput();
return true;
}); });
} }
private RadioMacroBuilder.Params buildParams() {
RadioMacroBuilder.Params p = new RadioMacroBuilder.Params();
Double fqMhz = parseDouble(atInputFq);
if (fqMhz != null) {
p.frequencyHz = Math.round(fqMhz * 1_000_000L);
}
p.powerDbm = parseInt(atInputPw);
p.sf = parseInt(atInputSf);
int bwPos = atBwSpinner != null ? atBwSpinner.getSelectedItemPosition() : -1;
if (bwPos >= 0 && bwPos < AtCommands.BW_KHZ.length) {
p.bwKhz = AtCommands.BW_KHZ[bwPos];
}
if (atCrSpinner != null && atCrSpinner.getSelectedItem() != null) {
p.codeRate = atCrSpinner.getSelectedItem().toString();
}
p.preamble = parseInt(atInputPl);
p.sendTimeoutMs = parseInt(atInputTm);
if (atRoleSpinner != null && atRoleSpinner.getSelectedItem() != null) {
String role = atRoleSpinner.getSelectedItem().toString();
if (!"".equals(role)) {
p.role = role;
}
}
return p;
} }
private void addQuickChip(ChipGroup group, String label, String command) { private void applyMacro() {
Chip chip = new Chip(requireContext()); sendLines(RadioMacroBuilder.apply(buildParams()));
chip.setText(label);
chip.setCheckable(false);
chip.setOnClickListener(v -> sendCommand(command));
group.addView(chip);
} }
private void sendFromInput() { private void sendLines(List<String> lines) {
if (atCommandInput == null || atCommandInput.getText() == null) { if (isPeerTarget()) {
return; sendMacroToPeer(lines);
} else {
uploader.sendMacroSequence(lines, this::onSendResult);
} }
String cmd = atCommandInput.getText().toString().trim();
if (cmd.isEmpty()) {
return;
}
sendCommand(cmd);
atCommandInput.setText("");
} }
private void sendCommand(String command) { private void sendMacroToPeer(List<String> lines) {
if (uploader == null || !isAdded()) { executor.execute(() -> {
try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
PeerDevices.Result peer = PeerDevices.resolve(devices, uploader.getDeviceId());
if (!peer.ok()) {
showToast(R.string.at_peer_unavailable);
return; return;
} }
uploader.sendAtCommand(command, result -> { commandPoller.postMacroToPeer(peer.peerId, lines);
if (!isAdded()) { showToast(getString(R.string.at_sent_to_peer, peer.peerId));
return; } catch (Exception e) {
showToast(R.string.stats_push_failed);
} }
Context ctx = getContext(); });
if (ctx == null) {
return;
} }
private void onSendResult(TelnetClient.SendResult result) {
if (!isAdded()) return;
if (result == TelnetClient.SendResult.NOT_CONNECTED) { if (result == TelnetClient.SendResult.NOT_CONNECTED) {
Toast.makeText(ctx, R.string.at_not_connected, Toast.LENGTH_SHORT).show(); showToast(R.string.at_not_connected);
} else if (result == TelnetClient.SendResult.IO_ERROR) { } else if (result == TelnetClient.SendResult.IO_ERROR) {
Toast.makeText(ctx, R.string.at_send_error, Toast.LENGTH_SHORT).show(); showToast(R.string.at_send_error);
} }
updateConsoleView(); updateConsoleView();
});
} }
private void refreshConsole() { private Integer parseInt(TextInputEditText field) {
if (!isAdded() || uploader == null || atStatus == null) { if (field == null || field.getText() == null) return null;
return; String s = field.getText().toString().trim();
if (s.isEmpty()) return null;
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return null;
} }
boolean telnetOn = uploader.isTelnetConnected(); }
private Double parseDouble(TextInputEditText field) {
if (field == null || field.getText() == null) return null;
String s = field.getText().toString().trim();
if (s.isEmpty()) return null;
try {
return Double.parseDouble(s);
} catch (NumberFormatException e) {
return null;
}
}
private boolean isPeerTarget() {
return atTargetGroup != null && atTargetGroup.getCheckedButtonId() == R.id.atTargetPeer;
}
private void refresh() {
if (!isAdded() || uploader == null || atStatus == null) return;
atStatus.setText(getString( atStatus.setText(getString(
R.string.at_status, R.string.at_status,
telnetOn ? getString(R.string.connected) : getString(R.string.disconnected) uploader.isTelnetConnected()
? getString(R.string.connected) : getString(R.string.disconnected)
)); ));
RadioSnapshot snap = RadioSnapshot.fromExtracted(uploader.getLastStats());
atCurrentSnapshot.setText(LoraStatsFormatter.formatStatic(snap, java.util.Set.of())
+ "\n" + LoraStatsFormatter.formatDynamic(snap, java.util.Set.of()));
if (!formInitialized) {
if (snap.frequencyMhz != null && isEmpty(atInputFq)) {
atInputFq.setText(String.format(Locale.US, "%.3f", snap.frequencyMhz));
}
if (snap.powerDbm != null && isEmpty(atInputPw)) {
atInputPw.setText(String.valueOf(snap.powerDbm.intValue()));
}
if (snap.sf != null && isEmpty(atInputSf)) {
atInputSf.setText(String.valueOf(snap.sf));
}
if (snap.bwKhz != null) {
selectBw(String.valueOf(snap.bwKhz));
}
if (snap.role != null && atRoleSpinner != null) {
atRoleSpinner.setSelection(StatsExtractor.ROLE_RX.equals(snap.role) ? 2 : 1);
}
formInitialized = true;
}
updateConsoleView(); updateConsoleView();
if (pollHelper != null) { if (pollHelper != null) {
pollHelper.scheduleNext(400); pollHelper.scheduleNext(400);
} }
} }
private void updateConsoleView() { private static boolean isEmpty(TextInputEditText field) {
if (uploader == null || atConsole == null || atConsoleScroll == null) { return field == null || field.getText() == null || field.getText().length() == 0;
}
private void selectBw(String bw) {
if (atBwSpinner == null || bw == null) return;
for (int i = 0; i < AtCommands.BW_KHZ.length; i++) {
if (AtCommands.BW_KHZ[i].equals(bw) || AtCommands.BW_KHZ[i].equals(bw.replace(".0", ""))) {
atBwSpinner.setSelection(i);
return; return;
} }
}
}
private void updateConsoleView() {
if (uploader == null || atConsole == null || atConsoleScroll == null) return;
String log = uploader.getConsoleLog(); String log = uploader.getConsoleLog();
if (!log.equals(lastConsole)) { if (!log.equals(lastConsole)) {
lastConsole = log; lastConsole = log;
atConsole.setText(log); atConsole.setText(log);
atConsoleScroll.post(() -> { if (consoleVisible) {
if (atConsoleScroll != null) { atConsoleScroll.post(() -> atConsoleScroll.fullScroll(View.FOCUS_DOWN));
atConsoleScroll.fullScroll(View.FOCUS_DOWN);
} }
});
} }
} }
private void showToast(int resId) {
if (isAdded()) Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
}
private void showToast(String msg) {
if (isAdded()) Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
}
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
if (pollHelper != null) { if (pollHelper != null) pollHelper.start(0);
pollHelper.start(0);
}
} }
@Override @Override
public void onPause() { public void onPause() {
if (pollHelper != null) { if (pollHelper != null) pollHelper.stop();
pollHelper.stop();
}
super.onPause(); super.onPause();
} }
@Override @Override
public void onDestroyView() { public void onDestroyView() {
if (pollHelper != null) { if (pollHelper != null) pollHelper.stop();
pollHelper.stop();
}
atStatus = null;
atConsole = null;
atConsoleScroll = null;
atCommandInput = null;
pollHelper = null; pollHelper = null;
formInitialized = false;
super.onDestroyView(); super.onDestroyView();
} }
@Override
public void onDestroy() {
executor.shutdownNow();
super.onDestroy();
}
} }
@@ -1,8 +1,14 @@
package com.grigowashere.loratester.ui; package com.grigowashere.loratester.ui;
import android.graphics.Color;
import android.os.Handler;
import android.os.Looper;
import android.view.Gravity;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -14,17 +20,37 @@ import com.grigowashere.loratester.api.ChatMessage;
import java.text.DateFormat; import java.text.DateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set;
public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> { public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
private static final int COLOR_SELF_BG = 0xFF16213E;
private static final int COLOR_OTHER_BG = 0xFF1A4A6E;
private static final int COLOR_NEW_HIGHLIGHT = 0x33E94560;
private final List<ChatMessage> messages = new ArrayList<>(); private final List<ChatMessage> messages = new ArrayList<>();
private final DateFormat timeFormat = private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()); DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
private final Handler handler = new Handler(Looper.getMainLooper());
private final Set<Integer> highlightedPositions = new HashSet<>();
private String selfDeviceId;
private double lastSeenTs;
public void setSelfDeviceId(String selfDeviceId) {
this.selfDeviceId = selfDeviceId;
}
public void setLastSeenTs(double lastSeenTs) {
this.lastSeenTs = lastSeenTs;
}
public void setMessages(List<ChatMessage> newMessages) { public void setMessages(List<ChatMessage> newMessages) {
messages.clear(); messages.clear();
highlightedPositions.clear();
messages.addAll(newMessages); messages.addAll(newMessages);
notifyDataSetChanged(); notifyDataSetChanged();
} }
@@ -35,7 +61,26 @@ public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
} }
int start = messages.size(); int start = messages.size();
messages.addAll(more); messages.addAll(more);
for (int i = 0; i < more.size(); i++) {
if (more.get(i).ts > lastSeenTs) {
highlightedPositions.add(start + i);
}
}
notifyItemRangeInserted(start, more.size()); notifyItemRangeInserted(start, more.size());
for (int i = 0; i < more.size(); i++) {
int pos = start + i;
if (highlightedPositions.contains(pos)) {
scheduleClearHighlight(pos);
}
}
}
private void scheduleClearHighlight(int position) {
handler.postDelayed(() -> {
if (highlightedPositions.remove(position)) {
notifyItemChanged(position);
}
}, 3000);
} }
public double lastTs() { public double lastTs() {
@@ -56,8 +101,42 @@ public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
@Override @Override
public void onBindViewHolder(@NonNull Holder holder, int position) { public void onBindViewHolder(@NonNull Holder holder, int position) {
ChatMessage m = messages.get(position); ChatMessage m = messages.get(position);
boolean self = selfDeviceId != null && selfDeviceId.equals(m.device_id);
String time = timeFormat.format(new Date((long) (m.ts * 1000))); String time = timeFormat.format(new Date((long) (m.ts * 1000)));
holder.text.setText(time + " " + m.device_id + ": " + m.text);
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) holder.bubble.getLayoutParams();
lp.gravity = self ? Gravity.END : Gravity.START;
holder.bubble.setLayoutParams(lp);
holder.bubble.setBackgroundColor(self ? COLOR_SELF_BG : COLOR_OTHER_BG);
holder.author.setText(self
? holder.itemView.getContext().getString(R.string.chat_self_label)
: m.device_id);
holder.text.setText(m.text);
holder.time.setText(time);
int bg = self ? COLOR_SELF_BG : COLOR_OTHER_BG;
if (highlightedPositions.contains(position)) {
holder.bubble.setBackgroundColor(blend(bg, COLOR_NEW_HIGHLIGHT));
} else {
holder.bubble.setBackgroundColor(bg);
}
}
private static int blend(int base, int overlay) {
int ba = Color.alpha(base);
int br = Color.red(base);
int bg = Color.green(base);
int bb = Color.blue(base);
int oa = Color.alpha(overlay);
int or = Color.red(overlay);
int og = Color.green(overlay);
int ob = Color.blue(overlay);
float ratio = oa / 255f;
int r = (int) (br * (1 - ratio) + or * ratio);
int g = (int) (bg * (1 - ratio) + og * ratio);
int b = (int) (bb * (1 - ratio) + ob * ratio);
return Color.argb(ba, r, g, b);
} }
@Override @Override
@@ -66,11 +145,17 @@ public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
} }
static class Holder extends RecyclerView.ViewHolder { static class Holder extends RecyclerView.ViewHolder {
final LinearLayout bubble;
final TextView author;
final TextView text; final TextView text;
final TextView time;
Holder(@NonNull View itemView) { Holder(@NonNull View itemView) {
super(itemView); super(itemView);
text = itemView.findViewById(R.id.chatItemText); bubble = itemView.findViewById(R.id.chatBubble);
author = itemView.findViewById(R.id.chatAuthor);
text = itemView.findViewById(R.id.chatText);
time = itemView.findViewById(R.id.chatTime);
} }
} }
} }
@@ -66,6 +66,9 @@ public class ChatFragment extends Fragment {
View inputBar = view.findViewById(R.id.chatInputBar); View inputBar = view.findViewById(R.id.chatInputBar);
adapter = new ChatAdapter(); adapter = new ChatAdapter();
if (uploader != null) {
adapter.setSelfDeviceId(uploader.getDeviceId());
}
LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext()); LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
recycler.setLayoutManager(layoutManager); recycler.setLayoutManager(layoutManager);
recycler.setAdapter(adapter); recycler.setAdapter(adapter);
@@ -161,6 +164,10 @@ public class ChatFragment extends Fragment {
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
if (adapter != null && uploader != null) {
adapter.setSelfDeviceId(uploader.getDeviceId());
adapter.setLastSeenTs(chatSince);
}
if (pollHelper != null) { if (pollHelper != null) {
pollHelper.start(0); pollHelper.start(0);
} }
@@ -0,0 +1,39 @@
package com.grigowashere.loratester.ui;
/** Diverging color ramp: blue = below, green = level, brown = above. */
final class ElevationColorRamp {
private static final int ALPHA = 0x8C;
private ElevationColorRamp() {
}
static int deltaToArgb(double deltaM) {
if (deltaM <= -8.0) {
return argb(0x1A, 0x4A, 0x8C);
}
if (deltaM <= -2.0) {
return lerp(argb(0x1A, 0x4A, 0x8C), argb(0x4F, 0xC3, 0xF7), (deltaM + 8.0) / 6.0);
}
if (deltaM <= 2.0) {
return lerp(argb(0x4F, 0xC3, 0xF7), argb(0x00, 0xFF, 0x88), (deltaM + 2.0) / 4.0);
}
if (deltaM <= 8.0) {
return lerp(argb(0x00, 0xFF, 0x88), argb(0xFF, 0xC1, 0x07), (deltaM - 2.0) / 6.0);
}
return argb(0x8B, 0x5A, 0x2B);
}
private static int argb(int r, int g, int b) {
return (ALPHA << 24) | (r << 16) | (g << 8) | b;
}
private static int lerp(int from, int to, double t) {
t = Math.max(0.0, Math.min(1.0, t));
int a = (int) Math.round(((from >>> 24) & 0xFF) + t * (((to >>> 24) & 0xFF) - ((from >>> 24) & 0xFF)));
int r = (int) Math.round(((from >> 16) & 0xFF) + t * (((to >> 16) & 0xFF) - ((from >> 16) & 0xFF)));
int g = (int) Math.round(((from >> 8) & 0xFF) + t * (((to >> 8) & 0xFF) - ((from >> 8) & 0xFF)));
int b = (int) Math.round((from & 0xFF) + t * ((to & 0xFF) - (from & 0xFF)));
return (a << 24) | (r << 16) | (g << 8) | b;
}
}
@@ -0,0 +1,100 @@
package com.grigowashere.loratester.ui;
import android.graphics.Bitmap;
import androidx.annotation.Nullable;
import com.grigowashere.loratester.api.ElevationGridResult;
import org.mapsforge.core.model.LatLong;
import java.util.HashMap;
import java.util.Map;
/** Builds a geo-referenced raster from elevation grid API response. */
final class ElevationHeatmapBitmap {
static final class Raster {
final Bitmap bitmap;
final LatLong northWest;
final LatLong southEast;
Raster(Bitmap bitmap, LatLong northWest, LatLong southEast) {
this.bitmap = bitmap;
this.northWest = northWest;
this.southEast = southEast;
}
}
private ElevationHeatmapBitmap() {
}
@Nullable
static Raster build(ElevationGridResult grid) {
if (grid == null || !grid.ok || grid.points == null || grid.points.isEmpty()) {
return null;
}
int minI = Integer.MAX_VALUE;
int maxI = Integer.MIN_VALUE;
int minJ = Integer.MAX_VALUE;
int maxJ = Integer.MIN_VALUE;
double minLat = Double.MAX_VALUE;
double maxLat = -Double.MAX_VALUE;
double minLon = Double.MAX_VALUE;
double maxLon = -Double.MAX_VALUE;
Map<Long, ElevationGridResult.GridPoint> byIndex = new HashMap<>();
for (ElevationGridResult.GridPoint p : grid.points) {
minI = Math.min(minI, p.i);
maxI = Math.max(maxI, p.i);
minJ = Math.min(minJ, p.j);
maxJ = Math.max(maxJ, p.j);
minLat = Math.min(minLat, p.lat);
maxLat = Math.max(maxLat, p.lat);
minLon = Math.min(minLon, p.lon);
maxLon = Math.max(maxLon, p.lon);
byIndex.put(pack(p.i, p.j), p);
}
int cols = maxJ - minJ + 1;
int rows = maxI - minI + 1;
if (cols < 1 || rows < 1) {
return null;
}
int[] pixels = new int[cols * rows];
double radiusM = grid.radius_m > 0 ? grid.radius_m : 200.0;
double stepM = grid.step_m > 0 ? grid.step_m : 15.0;
for (int row = 0; row < rows; row++) {
int i = maxI - row;
for (int col = 0; col < cols; col++) {
int j = minJ + col;
double dist = Math.hypot(i * stepM, j * stepM);
if (dist > radiusM + stepM * 0.5) {
continue;
}
ElevationGridResult.GridPoint p = byIndex.get(pack(i, j));
if (p != null && p.elevation_m != null) {
pixels[row * cols + col] = ElevationColorRamp.deltaToArgb(p.delta_m);
}
}
}
Bitmap bitmap = Bitmap.createBitmap(cols, rows, Bitmap.Config.ARGB_8888);
bitmap.setPixels(pixels, 0, cols, 0, 0, cols, rows);
double halfStepLat = (stepM / 2.0) / 111_320.0;
double halfStepLon = (stepM / 2.0)
/ (111_320.0 * Math.max(Math.cos(Math.toRadians((minLat + maxLat) / 2.0)), 1e-6));
LatLong northWest = new LatLong(maxLat + halfStepLat, minLon - halfStepLon);
LatLong southEast = new LatLong(minLat - halfStepLat, maxLon + halfStepLon);
return new Raster(bitmap, northWest, southEast);
}
private static long pack(int i, int j) {
return ((long) i << 32) ^ (j & 0xFFFFFFFFL);
}
}
@@ -0,0 +1,85 @@
package com.grigowashere.loratester.ui;
import android.graphics.Bitmap;
import com.grigowashere.loratester.api.ElevationGridResult;
import org.mapsforge.core.graphics.Canvas;
import org.mapsforge.core.model.BoundingBox;
import org.mapsforge.core.model.LatLong;
import org.mapsforge.core.model.Point;
import org.mapsforge.core.util.MercatorProjection;
import org.mapsforge.map.android.graphics.AndroidBitmap;
import org.mapsforge.map.layer.Layer;
/** Geo-referenced elevation heatmap overlay for Mapsforge. */
public class ElevationHeatmapLayer extends Layer {
private static final int TILE_SIZE = 256;
private Bitmap sourceBitmap;
private org.mapsforge.core.graphics.Bitmap mapsforgeBitmap;
private LatLong northWest;
private LatLong southEast;
public void setGrid(ElevationGridResult grid) {
sourceBitmap = null;
mapsforgeBitmap = null;
ElevationHeatmapBitmap.Raster raster = ElevationHeatmapBitmap.build(grid);
if (raster == null) {
northWest = null;
southEast = null;
return;
}
sourceBitmap = raster.bitmap;
northWest = raster.northWest;
southEast = raster.southEast;
}
public boolean hasData() {
return sourceBitmap != null && northWest != null && southEast != null;
}
@Override
public void draw(BoundingBox boundingBox, byte zoomLevel, Canvas canvas, Point topLeftPoint) {
if (!hasData()) {
return;
}
BoundingBox rasterBox = new BoundingBox(
southEast.latitude,
northWest.longitude,
northWest.latitude,
southEast.longitude
);
if (!boundingBox.intersects(rasterBox)) {
return;
}
long mapSize = MercatorProjection.getMapSize(zoomLevel, TILE_SIZE);
int left = (int) Math.round(
MercatorProjection.longitudeToPixelX(northWest.longitude, mapSize) - topLeftPoint.x);
int top = (int) Math.round(
MercatorProjection.latitudeToPixelY(northWest.latitude, mapSize) - topLeftPoint.y);
int right = (int) Math.round(
MercatorProjection.longitudeToPixelX(southEast.longitude, mapSize) - topLeftPoint.x);
int bottom = (int) Math.round(
MercatorProjection.latitudeToPixelY(southEast.latitude, mapSize) - topLeftPoint.y);
int width = right - left;
int height = bottom - top;
if (width <= 0 || height <= 0) {
return;
}
if (mapsforgeBitmap == null) {
mapsforgeBitmap = new AndroidBitmap(sourceBitmap);
}
canvas.drawBitmap(
mapsforgeBitmap,
0, 0, sourceBitmap.getWidth(), sourceBitmap.getHeight(),
left, top, right, bottom);
}
}
@@ -0,0 +1,125 @@
package com.grigowashere.loratester.ui;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
import com.grigowashere.loratester.R;
/** Color scale for elevation heatmap (relative to GPS center). */
public class ElevationHeatmapLegendView extends View {
private static final double DELTA_MIN = -10.0;
private static final double DELTA_MAX = 10.0;
private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF barRect = new RectF();
private final RectF bgRect = new RectF();
public ElevationHeatmapLegendView(Context context) {
super(context);
init();
}
public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
bgPaint.setColor(0xCC0F3460);
labelPaint.setColor(0xFFEEEEEE);
labelPaint.setTextSize(sp(9));
titlePaint.setColor(0xFFFFFFFF);
titlePaint.setTextSize(sp(9));
titlePaint.setFakeBoldText(true);
setLayerType(LAYER_TYPE_SOFTWARE, null);
}
private float sp(float value) {
return value * getResources().getDisplayMetrics().scaledDensity;
}
private float dp(float value) {
return value * getResources().getDisplayMetrics().density;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int w = (int) dp(118);
int h = (int) dp(118);
setMeasuredDimension(
resolveSize(w, widthMeasureSpec),
resolveSize(h, heightMeasureSpec));
}
@Override
protected void onDraw(Canvas canvas) {
float pad = dp(6);
bgRect.set(pad, pad, getWidth() - pad, getHeight() - pad);
canvas.drawRoundRect(bgRect, dp(4), dp(4), bgPaint);
float titleY = bgRect.top + dp(12);
canvas.drawText(getContext().getString(R.string.map_heatmap_legend_title),
bgRect.left + dp(6), titleY, titlePaint);
float barLeft = bgRect.left + dp(8);
float barTop = titleY + dp(6);
float barBottom = bgRect.bottom - dp(8);
float barRight = barLeft + dp(14);
barRect.set(barLeft, barTop, barRight, barBottom);
int[] colors = sampleRampColors(24);
float[] positions = new float[colors.length];
for (int i = 0; i < colors.length; i++) {
positions[i] = i / (float) (colors.length - 1);
}
barPaint.setShader(new LinearGradient(
barRect.left, barRect.top, barRect.left, barRect.bottom,
colors, positions, Shader.TileMode.CLAMP));
canvas.drawRoundRect(barRect, dp(2), dp(2), barPaint);
barPaint.setShader(null);
float textX = barRect.right + dp(6);
drawLegendRow(canvas, textX, barRect.top + dp(4),
getContext().getString(R.string.map_heatmap_legend_high),
"+8 m");
drawLegendRow(canvas, textX, (barRect.top + barRect.bottom) / 2f,
getContext().getString(R.string.map_heatmap_legend_level),
"0 m");
drawLegendRow(canvas, textX, barRect.bottom - dp(4),
getContext().getString(R.string.map_heatmap_legend_low),
"-8 m");
}
private void drawLegendRow(Canvas canvas, float x, float centerY, String label, String meters) {
float lineH = labelPaint.getTextSize();
canvas.drawText(label, x, centerY - dp(1), labelPaint);
canvas.drawText(meters, x, centerY + lineH - dp(2), labelPaint);
}
private static int[] sampleRampColors(int steps) {
int[] colors = new int[steps];
for (int i = 0; i < steps; i++) {
double t = i / (double) (steps - 1);
double delta = DELTA_MAX + t * (DELTA_MIN - DELTA_MAX);
colors[i] = ElevationColorRamp.deltaToArgb(delta) | 0xFF000000;
}
return colors;
}
}
File diff suppressed because it is too large Load Diff
@@ -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);
}
} }
@@ -0,0 +1,216 @@
package com.grigowashere.loratester.ui;
import android.content.Context;
import android.graphics.Color;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TableLayout;
import android.widget.TableRow;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.google.android.material.button.MaterialButton;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
import com.grigowashere.loratester.telnet.StatsExtractor;
import java.util.Locale;
import java.util.Set;
public class RadioComparePanel extends LinearLayout {
private static final int COLOR_TX = 0xFFE94560;
private static final int COLOR_RX = 0xFF4FC3F7;
private static final int COLOR_CHANGED = 0x33E94560;
private TextView txHeader;
private TextView rxHeader;
private TableLayout dynamicTable;
private TableLayout staticTable;
private MaterialButton staticToggle;
private boolean staticExpanded;
public RadioComparePanel(Context context) {
super(context);
init(context);
}
public RadioComparePanel(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context);
}
private void init(Context context) {
setOrientation(VERTICAL);
LayoutInflater.from(context).inflate(R.layout.view_radio_compare, this, true);
txHeader = findViewById(R.id.compareTxHeader);
rxHeader = findViewById(R.id.compareRxHeader);
dynamicTable = findViewById(R.id.compareDynamicTable);
staticTable = findViewById(R.id.compareStaticTable);
staticToggle = findViewById(R.id.compareStaticToggle);
staticToggle.setOnClickListener(v -> {
staticExpanded = !staticExpanded;
staticTable.setVisibility(staticExpanded ? VISIBLE : GONE);
staticToggle.setText(staticExpanded
? getContext().getString(R.string.stats_static_hide)
: getContext().getString(R.string.stats_static_toggle));
});
}
public void bind(
RadioSnapshot txSnap,
RadioSnapshot rxSnap,
String txDeviceId,
String rxDeviceId,
Set<String> changedTx,
Set<String> changedRx
) {
txHeader.setText("TX · " + (txDeviceId != null ? txDeviceId : ""));
rxHeader.setText("RX · " + (rxDeviceId != null ? rxDeviceId : ""));
fillTable(dynamicTable, true, txSnap, rxSnap, changedTx, changedRx);
fillTable(staticTable, false, txSnap, rxSnap, changedTx, changedRx);
}
private void fillTable(
TableLayout table,
boolean dynamic,
RadioSnapshot tx,
RadioSnapshot rx,
Set<String> changedTx,
Set<String> changedRx
) {
table.removeAllViews();
if (dynamic) {
addRow(table, "RSSI", fmtDbm(tx.rssiDbm), fmtDbm(rx.rssiDbm), "rssi", changedTx, changedRx);
addRow(table, "SNR", fmtSuffix(tx.snrDb, " dB"), fmtSuffix(rx.snrDb, " dB"), "snr", changedTx, changedRx);
addRow(table, "RX Quality", fmtSuffix(tx.rxQualityPercent, " %"), fmtSuffix(rx.rxQualityPercent, " %"),
"rxQuality", 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, "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", 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);
}
}
private void addRow(
TableLayout table,
String label,
String txVal,
String rxVal,
String changeKey,
Set<String> changedTx,
Set<String> changedRx
) {
TableRow row = new TableRow(getContext());
TextView lbl = cell(label, 0xFFAAAAAA, false);
TextView tx = cell(txVal, COLOR_TX, changedTx != null && changedTx.contains(changeKey));
TextView rx = cell(rxVal, COLOR_RX, changedRx != null && changedRx.contains(changeKey));
row.addView(lbl);
row.addView(tx);
row.addView(rx);
table.addView(row);
}
private TextView cell(String text, int color, boolean changed) {
TextView tv = new TextView(getContext());
tv.setText(text != null ? text : "");
tv.setTextColor(color);
tv.setTextSize(11f);
tv.setPadding(4, 2, 4, 2);
if (changed) {
tv.setBackgroundColor(COLOR_CHANGED);
}
return tv;
}
/** Assign TX/RX snapshots by device role. */
public static void bindByRole(
RadioComparePanel panel,
RadioSnapshot local,
RadioSnapshot peer,
String localId,
String peerId,
Set<String> changedLocal,
Set<String> changedPeer
) {
RadioSnapshot tx = local;
RadioSnapshot rx = peer;
String txId = localId;
String rxId = peerId;
Set<String> chTx = changedLocal;
Set<String> chRx = changedPeer;
if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) {
tx = peer;
rx = local;
txId = peerId;
rxId = localId;
chTx = changedPeer;
chRx = changedLocal;
}
if (tx == null) tx = RadioSnapshot.empty();
if (rx == null) rx = RadioSnapshot.empty();
panel.bind(tx, rx, txId, rxId, chTx, chRx);
}
private static String str(String v) {
return v != null && !v.isEmpty() ? v : "";
}
private static String fmtInt(Integer v) {
return v != null ? String.valueOf(v) : "";
}
private static String fmtDbm(Double v) {
return v != null ? String.format(Locale.US, "%.0f dBm", v) : "";
}
private static String fmtMhz(Double v) {
return v != null ? String.format(Locale.US, "%.3f MHz", v) : "";
}
private static String fmtSuffix(Double v, String 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) {
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();
}); });
} }
@@ -7,6 +7,7 @@ import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button; import android.widget.Button;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@@ -14,19 +15,20 @@ import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.grigowashere.loratester.CommandPoller;
import com.grigowashere.loratester.LoraApp; import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.PeerDevices;
import com.grigowashere.loratester.PeerStatsCache;
import com.grigowashere.loratester.R; import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo; import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.api.TelemetryHistoryItem; import com.grigowashere.loratester.api.TelemetryHistoryItem;
import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.telnet.StatsExtractor;
import java.text.DateFormat; import java.util.HashMap;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -35,30 +37,33 @@ public class StatsFragment extends Fragment {
private static final long SERVER_POLL_MS = 1000; private static final long SERVER_POLL_MS = 1000;
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault());
private FragmentPollHelper pollHelper; private FragmentPollHelper pollHelper;
private TelemetryUploader uploader; private TelemetryUploader uploader;
private CommandPoller commandPoller;
private PeerStatsCache peerStatsCache;
private TextView statsStatus; private TextView statsStatus;
private TextView statsDetails; private TextView statsPeerWarning;
private RadioComparePanel radioComparePanel;
private RecyclerView statsHistoryList; private RecyclerView statsHistoryList;
private final HistoryAdapter historyAdapter = new HistoryAdapter(); private final HistoryAdapter historyAdapter = new HistoryAdapter();
private StatsExtractor.ExtractedStats cachedLocal; private RadioSnapshot prevLocal = RadioSnapshot.empty();
private DeviceInfo cachedServer; private RadioSnapshot prevPeer = RadioSnapshot.empty();
private RadioSnapshot snapLocal = RadioSnapshot.empty();
private RadioSnapshot snapPeer = RadioSnapshot.empty();
private String cachedPeerId;
private String cachedPeerError;
private int cachedDeviceCount; private int cachedDeviceCount;
private String cachedError;
private final TelemetryUploader.StatsListener statsListener = stats -> { private final TelemetryUploader.StatsListener statsListener = stats -> postRender();
cachedLocal = stats;
cachedError = null;
postRender();
};
@Override @Override
public void onAttach(@NonNull Context context) { public void onAttach(@NonNull Context context) {
super.onAttach(context); super.onAttach(context);
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader(); LoraApp app = (LoraApp) context.getApplicationContext();
uploader = app.getTelemetryUploader();
commandPoller = app.getCommandPoller();
peerStatsCache = app.getPeerStatsCache();
} }
@Nullable @Nullable
@@ -74,11 +79,13 @@ public class StatsFragment extends Fragment {
@Override @Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
statsStatus = view.findViewById(R.id.statsStatus); statsStatus = view.findViewById(R.id.statsStatus);
statsDetails = view.findViewById(R.id.statsDetails); statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
radioComparePanel = view.findViewById(R.id.radioComparePanel);
statsHistoryList = view.findViewById(R.id.statsHistoryList); statsHistoryList = view.findViewById(R.id.statsHistoryList);
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext())); statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
statsHistoryList.setAdapter(historyAdapter); statsHistoryList.setAdapter(historyAdapter);
Button btnSimulate = view.findViewById(R.id.btnSimulate); Button btnSimulate = view.findViewById(R.id.btnSimulate);
Button btnPushStats = view.findViewById(R.id.btnPushStats);
pollHelper = new FragmentPollHelper(this, this::refresh); pollHelper = new FragmentPollHelper(this, this::refresh);
btnSimulate.setOnClickListener(v -> { btnSimulate.setOnClickListener(v -> {
@@ -86,14 +93,38 @@ public class StatsFragment extends Fragment {
SEND SEND
Frequency: 433000000 Hz Frequency: 433000000 Hz
Power: 22 dBm Power: 22 dBm
Spreading Factor: 7
Bandwidth: 125 kHz
Packet: 1 Packet: 1
Payload: Sim TX Payload: Sim TX
\u001b[2J"""; \u001b[2J""";
uploader.simulateChunk(chunk); uploader.simulateChunk(chunk);
if (pollHelper.canRun()) {
statsStatus.setText(R.string.simulate_sent);
}
}); });
btnPushStats.setOnClickListener(v -> pushStatsToPeer());
}
private void pushStatsToPeer() {
if (commandPoller == null || cachedPeerId == null) {
toast(R.string.at_peer_unavailable);
return;
}
Map<String, Object> payload = new HashMap<>();
StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
if (localStats != null && localStats.metaJson != null) {
payload.put("meta", localStats.metaJson);
}
if (snapLocal.role != null) payload.put("role", snapLocal.role);
if (snapLocal.rssiDbm != null) payload.put("rssi", snapLocal.rssiDbm);
if (snapLocal.sf != null) payload.put("sf", snapLocal.sf);
if (snapLocal.bwKhz != null) payload.put("bw", snapLocal.bwKhz);
commandPoller.postCommandToPeer(cachedPeerId, "stats_push", payload);
toast(R.string.stats_pushed);
}
private void toast(int resId) {
if (isAdded()) {
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
}
} }
@Override @Override
@@ -101,7 +132,6 @@ public class StatsFragment extends Fragment {
super.onResume(); super.onResume();
if (uploader != null) { if (uploader != null) {
uploader.setStatsListener(statsListener); uploader.setStatsListener(statsListener);
cachedLocal = uploader.getLastStats();
postRender(); postRender();
} }
if (pollHelper != null) { if (pollHelper != null) {
@@ -126,7 +156,8 @@ public class StatsFragment extends Fragment {
pollHelper.stop(); pollHelper.stop();
} }
statsStatus = null; statsStatus = null;
statsDetails = null; statsPeerWarning = null;
radioComparePanel = null;
statsHistoryList = null; statsHistoryList = null;
pollHelper = null; pollHelper = null;
super.onDestroyView(); super.onDestroyView();
@@ -139,10 +170,10 @@ public class StatsFragment extends Fragment {
} }
private void postRender() { private void postRender() {
if (!isAdded() || statsDetails == null) { if (!isAdded() || radioComparePanel == null) {
return; return;
} }
requireActivity().runOnUiThread(this::renderDetails); requireActivity().runOnUiThread(this::render);
} }
private void refresh() { private void refresh() {
@@ -150,30 +181,52 @@ public class StatsFragment extends Fragment {
return; return;
} }
String deviceId = uploader.getDeviceId(); String deviceId = uploader.getDeviceId();
boolean telnet = uploader.isTelnetConnected();
statsStatus.setText(getString( statsStatus.setText(getString(
R.string.stats_status, R.string.stats_status,
deviceId, deviceId,
telnet ? getString(R.string.connected) : getString(R.string.disconnected) uploader.isTelnetConnected()
? getString(R.string.connected) : getString(R.string.disconnected)
)); ));
executor.execute(() -> { executor.execute(() -> {
List<TelemetryHistoryItem> history = null; List<TelemetryHistoryItem> history = null;
try { try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices(); List<DeviceInfo> devices = uploader.getServerApi().getDevices();
cachedDeviceCount = devices.size();
PeerDevices.Result peer = PeerDevices.resolve(devices, deviceId);
cachedPeerId = peer.peerId;
cachedPeerError = peer.error;
DeviceInfo self = null; DeviceInfo self = null;
DeviceInfo peerDev = null;
for (DeviceInfo d : devices) { for (DeviceInfo d : devices) {
if (deviceId.equals(d.device_id)) { if (deviceId.equals(d.device_id)) {
self = d; self = d;
break; } else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
peerDev = d;
} }
} }
cachedServer = self;
cachedDeviceCount = devices.size(); StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
cachedError = null; snapLocal = localStats != null
? RadioSnapshot.fromExtracted(localStats)
: RadioSnapshot.fromMeta(
self != null ? self.meta : null,
self != null ? self.role : null,
self != null ? self.rssi : null);
PeerStatsCache.Snapshot push = peerStatsCache != null ? peerStatsCache.get() : null;
if (push != null && push.meta != null) {
snapPeer = RadioSnapshot.fromMeta(push.meta, push.role, push.rssi);
} else {
snapPeer = RadioSnapshot.fromMeta(
peerDev != null ? peerDev.meta : null,
peerDev != null ? peerDev.role : null,
peerDev != null ? peerDev.rssi : null);
}
history = uploader.getServerApi().getTelemetryHistory(deviceId, 30); history = uploader.getServerApi().getTelemetryHistory(deviceId, 30);
} catch (Exception e) { } catch (Exception ignored) {
cachedError = e.getMessage() != null ? e.getMessage() : "error";
} }
List<TelemetryHistoryItem> finalHistory = history; List<TelemetryHistoryItem> finalHistory = history;
if (isAdded()) { if (isAdded()) {
@@ -190,77 +243,31 @@ public class StatsFragment extends Fragment {
}); });
} }
private void renderDetails() { private void render() {
if (!isAdded() || statsDetails == null || uploader == null) { if (!isAdded() || radioComparePanel == null || uploader == null) {
return; return;
} }
if (statsPeerWarning != null) {
StringBuilder sb = new StringBuilder(); if (cachedPeerError != null) {
sb.append(getString(R.string.devices_on_server, cachedDeviceCount)).append("\n"); statsPeerWarning.setVisibility(View.VISIBLE);
sb.append(getString( statsPeerWarning.setText(
uploader.isTelnetConnected() ? R.string.telnet_connected : R.string.telnet_disconnected getString(R.string.stats_two_devices_required, cachedDeviceCount));
));
long at = uploader.getLastStatsAtMs();
if (at > 0) {
sb.append(" · ").append(getString(R.string.stats_updated_at, timeFormat.format(new Date(at))));
}
sb.append("\n\n");
String meta = pickMetaJson();
if (meta != null && !meta.isEmpty()) {
String fields = LoraStatsFormatter.formatMeta(meta);
if (!fields.isEmpty()) {
sb.append(fields).append("\n");
}
} else if (cachedError != null) {
sb.append(getString(R.string.stats_error, cachedError)).append("\n");
} else { } else {
sb.append(getString(R.string.no_telemetry_yet)).append("\n"); statsPeerWarning.setVisibility(View.GONE);
} }
Double rssi = pickRssi();
sb.append("\nСигнал (dBm): ").append(rssi != null ? rssi : "").append("\n");
Double lat = null;
Double lon = null;
if (cachedServer != null) {
lat = cachedServer.lat;
lon = cachedServer.lon;
} }
if (GeoUtils.isValidCoordinate(lat, lon)) { var chLocal = snapLocal.diff(prevLocal);
sb.append("GPS: ").append(lat).append(", ").append(lon).append("\n"); var chPeer = snapPeer.diff(prevPeer);
} else { RadioComparePanel.bindByRole(
sb.append(getString(R.string.gps_waiting)).append("\n"); radioComparePanel,
} snapLocal,
snapPeer,
statsDetails.setText(sb.toString()); uploader.getDeviceId(),
} cachedPeerId,
chLocal,
private String pickMetaJson() { chPeer
boolean telnet = uploader.isTelnetConnected(); );
if (telnet && cachedLocal != null && cachedLocal.metaJson != null) { prevLocal = snapLocal;
return cachedLocal.metaJson; prevPeer = snapPeer;
}
if (cachedServer != null && cachedServer.meta != null && !cachedServer.meta.isEmpty()) {
return cachedServer.meta;
}
if (cachedLocal != null && cachedLocal.metaJson != null) {
return cachedLocal.metaJson;
}
return null;
}
private Double pickRssi() {
boolean telnet = uploader.isTelnetConnected();
if (telnet && cachedLocal != null && cachedLocal.rssi != null) {
return cachedLocal.rssi;
}
if (cachedServer != null && cachedServer.rssi != null) {
return cachedServer.rssi;
}
if (cachedLocal != null) {
return cachedLocal.rssi;
}
return null;
} }
} }
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#00FF88" android:state_activated="true" />
<item android:color="#FFFFFF" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#CC0F3460" />
<corners android:radius="12dp" />
</shape>
+10
View File
@@ -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,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
</vector>
+10
View File
@@ -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="M3,17h2v-3H3v3zM3,12h2V9H3v3zM7,17h2v-5H7v5zM7,7h2V5H7v2zM11,17h2V7h-2v10zM15,17h2v-3h-2v3zM15,12h2V9h-2v3zM19,17h2v-7h-2v7zM19,8h2V5h-2v3z" />
</vector>
+10
View File
@@ -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="M14,6l-3.75,5 2.75,3.5L9,18H3l8.5,-10.5L14,6zM17.5,10.5L14,15h6l-2.5,-4.5z" />
</vector>
@@ -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,3C7.03,3 3,7.03 3,12h2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7h2c0,-4.97 -4.03,-9 -9,-9zM12,7c-2.76,0 -5,2.24 -5,5h2c0,-1.66 1.34,-3 3,-3s3,1.34 3,3h2c0,-2.76 -2.24,-5 -5,-5zM12,11c-0.55,0 -1,0.45 -1,1h2c0,-0.55 -0.45,-1 -1,-1zM4.5,14.5L2,17l2.5,2.5 1.4,-1.4L4.8,17l1.1,-1.1 -1.4,-1.4zM19.5,14.5l-1.4,1.4 1.1,1.1 -1.1,1.1 1.4,1.4L22,17l-2.5,-2.5z" />
</vector>
@@ -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="M4,18h16v2H4v-2zM6,15h2v2H6v-2zM16,15h2v2h-2v-2zM8,12h8v2H8v-2zM10,9h4v2h-4V9zM12,6c-2.2,0 -4,1.8 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2h2c0,-2.2 -1.8,-4 -4,-4z" />
</vector>
@@ -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,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,14c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,20c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2z" />
</vector>
@@ -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="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5V19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45V19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z" />
</vector>
@@ -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>
+10
View File
@@ -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>
+167 -21
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
@@ -11,64 +12,209 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="14sp" /> android:textSize="14sp" />
<com.google.android.material.chip.ChipGroup <ScrollView
android:id="@+id/atQuickChips"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="0dp"
android:layout_marginTop="8dp" /> android:layout_weight="1"
android:fillViewport="true">
<LinearLayout <LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="@string/at_current_values"
android:textStyle="bold" />
<TextView
android:id="@+id/atCurrentSnapshot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textSize="11sp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/at_hint_fq_mhz">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputFq"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"> android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:hint="@string/at_command_hint"> android:hint="@string/at_hint_power">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/atCommandInput" android:id="@+id/atInputPw"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:fontFamily="monospace" android:inputType="numberSigned" />
android:inputType="text"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<Button <com.google.android.material.textfield.TextInputLayout
android:id="@+id/atSendBtn" android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:text="@string/send" /> android:layout_weight="1"
android:hint="SF">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputSf"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout> </LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/at_hint_bw"
android:textSize="12sp" />
<Spinner
android:id="@+id/atBwSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/at_hint_cr"
android:textSize="12sp" />
<Spinner
android:id="@+id/atCrSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout <LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/at_hint_pl">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputPl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:hint="@string/at_hint_tm">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputTm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:text="@string/at_hint_role"
android:textSize="12sp" />
<Spinner
android:id="@+id/atRoleSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/atTargetGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/atTargetLocal"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_target_local" />
<com.google.android.material.button.MaterialButton
android:id="@+id/atTargetPeer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_target_peer" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal"> android:orientation="horizontal">
<Button <Button
android:id="@+id/atClearLog" android:id="@+id/atApplyBtn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_apply" />
<Button
android:id="@+id/atStopBtn"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text="S" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<com.google.android.material.button.MaterialButton
android:id="@+id/atConsoleToggle"
style="@style/Widget.Material3.Button.TextButton" style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/at_clear_log" /> android:text="@string/at_console_toggle" />
</LinearLayout>
<ScrollView <ScrollView
android:id="@+id/atConsoleScroll" android:id="@+id/atConsoleScroll"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="120dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:background="#0D1117" android:background="#0D1117"
android:padding="8dp"> android:padding="8dp"
android:visibility="gone">
<TextView <TextView
android:id="@+id/atConsole" android:id="@+id/atConsole"
+261 -23
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@@ -8,47 +9,279 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<LinearLayout
android:id="@+id/mapStatusChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="56dp"
android:background="@drawable/bg_map_panel"
android:elevation="6dp"
android:orientation="vertical"
android:paddingStart="8dp"
android:paddingTop="6dp"
android:paddingEnd="8dp"
android:paddingBottom="6dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iconServer"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/status_server"
android:src="@drawable/ic_link_server" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="3dp"
android:text="@string/status_server_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
<ImageView
android:id="@+id/iconLora"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/status_lora"
android:src="@drawable/ic_link_lora" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="3dp"
android:text="@string/status_lora_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
</LinearLayout>
<TextView
android:id="@+id/mapStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:maxLines="2"
android:textColor="#FFFFFF"
android:textSize="10sp" />
<TextView
android:id="@+id/mapDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#00FF88"
android:textSize="9sp"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
android:id="@+id/mapToolRail"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="6dp"
android:background="@drawable/bg_map_panel"
android:elevation="6dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageButton
android:id="@+id/btnToolCenter"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_center"
android:src="@drawable/ic_center"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnFindHill"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_find_hill"
android:src="@drawable/ic_hill"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnHeatmap"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_heatmap"
android:src="@drawable/ic_heatmap"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnTrack"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_track"
android:src="@drawable/ic_track"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnPairedTrack"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_paired"
android:src="@drawable/ic_paired_track"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnToolMore"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_more"
android:src="@drawable/ic_more_vert"
app:tint="@color/map_tool_icon_tint" />
</LinearLayout>
<ScrollView <ScrollView
android:id="@+id/mapSidePanel" android:id="@+id/mapToolDrawer"
android:layout_width="152dp" android:layout_width="148dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="end|top" android:layout_gravity="end|top"
android:layout_margin="6dp" android:layout_marginTop="8dp"
android:background="#CC0F3460" android:layout_marginEnd="56dp"
android:elevation="4dp" android:background="@drawable/bg_map_panel"
android:fillViewport="false" android:elevation="6dp"
android:scrollbars="none"> android:scrollbars="none"
android:visibility="gone">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:padding="6dp"> android:padding="8dp">
<TextView <TextView
android:id="@+id/mapStatus"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="#FFFFFF" android:text="@string/map_center_mode"
android:textColor="#00FF88"
android:textSize="10sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/mapCenterMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="vertical"
app:selectionRequired="false"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/centerMe"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="32dp"
android:text="@string/map_center_me"
android:textSize="10sp" /> android:textSize="10sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/centerTx"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="32dp"
android:text="@string/map_center_tx"
android:textSize="10sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/centerRx"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="32dp"
android:text="@string/map_center_rx"
android:textSize="10sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/centerBoth"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="32dp"
android:text="@string/map_center_both"
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"
android:layout_marginTop="8dp"
android:text="@string/map_heatmap_radius"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/mapHeatmapRadius"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/mapHeatmapStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#AAAAAA"
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapHillStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#FFC107"
android:textSize="9sp"
android:visibility="gone" />
<TextView <TextView
android:id="@+id/mapLegend" android:id="@+id/mapLegend"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:layout_marginTop="6dp"
android:text="@string/map_legend" android:text="@string/map_legend"
android:textColor="#CCCCCC" android:textColor="#CCCCCC"
android:textSize="9sp" /> android:textSize="9sp" />
<Button <Spinner
android:id="@+id/btnTrack" android:id="@+id/trackSpinner"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="6dp" />
android:minHeight="36dp"
android:text="@string/track_start"
android:textSize="11sp" />
<TextView <TextView
android:id="@+id/trackStatus" android:id="@+id/trackStatus"
@@ -57,13 +290,18 @@
android:layout_marginTop="2dp" android:layout_marginTop="2dp"
android:textColor="#CCCCCC" android:textColor="#CCCCCC"
android:textSize="9sp" /> android:textSize="9sp" />
<Spinner
android:id="@+id/trackSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
<com.grigowashere.loratester.ui.ElevationHeatmapLegendView
android:id="@+id/mapHeatmapLegend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="10dp"
android:elevation="4dp"
android:visibility="gone" />
</FrameLayout> </FrameLayout>
@@ -73,6 +73,19 @@
android:inputType="text" /> android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/device_display_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editDeviceLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchTelnet" android:id="@+id/switchTelnet"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -80,6 +93,20 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:text="@string/telnet_enabled" /> android:text="@string/telnet_enabled" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/battery_optimization_hint"
android:textSize="12sp" />
<Button
android:id="@+id/btnBatteryOptimization"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/battery_optimization" />
<TextView <TextView
android:id="@+id/deviceIdLabel" android:id="@+id/deviceIdLabel"
android:layout_width="match_parent" android:layout_width="match_parent"
+24 -9
View File
@@ -15,21 +15,36 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="14sp" /> android:textSize="14sp" />
<TextView
android:id="@+id/statsPeerWarning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#FF9800"
android:textSize="12sp"
android:visibility="gone" />
<com.grigowashere.loratester.ui.RadioComparePanel
android:id="@+id/radioComparePanel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp" />
<Button
android:id="@+id/btnPushStats"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/stats_push_peer" />
<Button <Button
android:id="@+id/btnSimulate" android:id="@+id/btnSimulate"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="8dp"
android:text="@string/simulate_telnet" /> android:text="@string/simulate_telnet" />
<TextView
android:id="@+id/statsDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="monospace"
android:textSize="12sp" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
+40 -3
View File
@@ -1,7 +1,44 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/chatItemText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="6dp" android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<LinearLayout
android:id="@+id/chatBubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:background="#1A4A6E"
android:maxWidth="280dp"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/chatAuthor"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#AAAAAA"
android:textSize="10sp"
android:textStyle="bold" />
<TextView
android:id="@+id/chatText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#EEEEEE"
android:textSize="13sp" /> android:textSize="13sp" />
<TextView
android:id="@+id/chatTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#888888"
android:textSize="9sp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="4dp">
<TextView
android:id="@+id/compareTxHeader"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#E94560"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/compareRxHeader"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#4FC3F7"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<TableLayout
android:id="@+id/compareDynamicTable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stretchColumns="1,2" />
<com.google.android.material.button.MaterialButton
android:id="@+id/compareStaticToggle"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:padding="0dp"
android:text="@string/stats_static_toggle"
android:textSize="12sp" />
<TableLayout
android:id="@+id/compareStaticTable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stretchColumns="1,2"
android:visibility="gone" />
</LinearLayout>
+3
View File
@@ -2,4 +2,7 @@
<resources> <resources>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<color name="status_ok">#FF00FF88</color>
<color name="status_warn">#FFFFC107</color>
<color name="status_off">#FF888888</color>
</resources> </resources>
+76
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>
@@ -49,4 +50,79 @@
<string name="track_error">Трек: %1$s</string> <string name="track_error">Трек: %1$s</string>
<string name="track_spinner_hint">Сохранённые треки</string> <string name="track_spinner_hint">Сохранённые треки</string>
<string name="track_none">— нет треков —</string> <string name="track_none">— нет треков —</string>
<string name="stats_local_title">Это устройство</string>
<string name="stats_peer_title">Другое устройство</string>
<string name="stats_peer_absent">Нет данных (ожидается 2 устройства online)</string>
<string name="stats_two_devices_required">Нужно ровно 2 устройства на сервере (сейчас %1$d)</string>
<string name="stats_push_peer">Отправить статистику на другое</string>
<string name="stats_pushed">Статистика отправлена</string>
<string name="stats_push_failed">Не удалось отправить</string>
<string name="at_target_local">Локально</string>
<string name="at_target_peer">На другое устройство</string>
<string name="at_sent_to_peer">Команда отправлена на %1$s</string>
<string name="at_peer_unavailable">Нет второго устройства</string>
<string name="track_paired_start">Старт трека (оба)</string>
<string name="track_paired_started">Синхронный старт запланирован</string>
<string name="track_paired_need_two">Нужны 2 устройства online</string>
<string name="stats_static_toggle">▼ Статика</string>
<string name="stats_static_hide">▲ Статика</string>
<string name="at_current_values">Текущие значения</string>
<string name="at_apply">Применить</string>
<string name="at_hint_fq_mhz">Частота MHz (430470)</string>
<string name="at_hint_power">Мощность dBm (-9…22)</string>
<string name="at_hint_bw">Bandwidth kHz</string>
<string name="at_hint_cr">Code rate</string>
<string name="at_hint_pl">Preamble (164)</string>
<string name="at_hint_tm">Timeout ms (060000)</string>
<string name="at_hint_role">Роль после настройки</string>
<string name="at_console_toggle">▼ Консоль</string>
<string name="at_console_hide">▲ Консоль</string>
<string name="map_center_me">Я</string>
<string name="map_center_tx">TX</string>
<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>
<string name="map_tool_paired">Синхр. трек TX/RX</string>
<string name="map_tool_more">Дополнительно</string>
<string name="map_gps_distance">GPS между устройствами: %1$s m</string>
<string name="status_server">Связь с сервером</string>
<string name="status_server_short">сервер</string>
<string name="status_lora">Связь LoRa с другим устройством</string>
<string name="status_lora_short">LoRa</string>
<string name="map_find_hill">Ближайшая возвышенность</string>
<string name="map_find_hill_search">Поиск возвышенности…</string>
<string name="map_find_hill_result">Возвышенность: %1$.0f m · +%2$.0f m · %3$.0f m</string>
<string name="map_find_hill_none">В радиусе 5 км возвышенностей не найдено</string>
<string name="map_find_hill_no_gps">Нужен GPS для поиска</string>
<string name="map_find_hill_error">Ошибка: %1$s</string>
<string name="map_heatmap">Рельеф (хитмапа)</string>
<string name="map_heatmap_radius">Радиус рельефа</string>
<string name="map_heatmap_radius_m">%1$d m</string>
<string name="map_heatmap_loading">Загрузка рельефа…</string>
<string name="map_heatmap_result">Δ %1$.0f…%2$.0f m · шаг %3$.0f m · %4$d точек</string>
<string name="map_heatmap_error">Рельеф: %1$s</string>
<string name="map_heatmap_no_gps">Нужен GPS для рельефа</string>
<string name="map_heatmap_legend_title">Относ. высота</string>
<string name="map_heatmap_legend_high">возвышенность</string>
<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> </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,85 @@ 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
public void parsesRxQualityPercent() {
StatsExtractor extractor = StatsExtractor.withDefaults();
String frame = RECEIVE_FRAME + " RX Quality: 87 %\n";
StatsExtractor.ExtractedStats stats = extractor.extract(frame);
assertTrue(stats.metaJson.contains("\"rx_quality_percent\":87"));
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
@@ -0,0 +1,24 @@
package com.grigowashere.loratester;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class SettingsRepositoryTest {
@Test
public void detectsLegacyServerUrls() {
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru:7634"));
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru:7634/"));
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru"));
assertTrue(SettingsRepository.isLegacyServerUrl("https://grigowashere.ru:7634"));
}
@Test
public void ignoresCurrentServerUrl() {
assertFalse(SettingsRepository.isLegacyServerUrl(SettingsRepository.DEFAULT_SERVER));
assertFalse(SettingsRepository.isLegacyServerUrl("https://example.com"));
assertFalse(SettingsRepository.isLegacyServerUrl(null));
}
}
+8
View File
@@ -0,0 +1,8 @@
.venv
__pycache__
*.pyc
*.pyo
*.db
.pytest_cache
.git
*.md
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
LORATESTER_HOST=0.0.0.0 \
LORATESTER_PORT=7634 \
LORATESTER_DB=/data/loratester.db
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 7634
CMD ["uvicorn", "fastapi_app:app", "--host", "0.0.0.0", "--port", "7634"]
+63 -7
View File
@@ -23,14 +23,47 @@ python flask_app.py
| `LORATESTER_DB` | `./loratester.db` | | `LORATESTER_DB` | `./loratester.db` |
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) | | `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) | | `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) |
| `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` |
| `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
## Деплой (grigowashere.ru:7634) ## Docker Compose
```bash ```bash
cd /srv/storage/disk2/services/LoraTester cd server
docker compose up -d --build
```
Проверка:
```bash
curl http://127.0.0.1:7634/api/health | jq
```
Ожидается `"elevation_ok": true` если локальный Open-Meteo доступен с хоста/контейнера.
Переопределить URL высот (`.env` рядом с `docker-compose.yml`):
```env
LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
```
БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера).
## Деплой (lora.grigowashere.ru)
```bash
cd /srv/storage/disk2/services/LoraTester/server
docker compose up -d --build
```
Или без Docker:
```bash
cd /srv/storage/disk2/services/LoraTester/server
pip install -r requirements.txt pip install -r requirements.txt
# один путь БД для всех воркеров:
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
export LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634 uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
``` ```
@@ -42,7 +75,7 @@ uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
curl http://127.0.0.1:7634/api/health curl http://127.0.0.1:7634/api/health
``` ```
Ожидается `"db_ok": true`, `"schema_version": 3`. Ожидается `"db_ok": true`, `"schema_version": 4`, `"elevation_ok": true`.
Если БД создана вручную и схема битая (`no such table: devices` / `no such column: t.meta`): Если БД создана вручную и схема битая (`no such table: devices` / `no such column: t.meta`):
@@ -66,13 +99,36 @@ curl http://127.0.0.1:7634/api/health
- `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}` - `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
- `POST /api/tracks/{id}/finish` - `POST /api/tracks/{id}/finish`
- `GET /api/tracks?device_id=` - `GET /api/tracks?device_id=`
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через Open-Meteo) - `GET /api/tracks/{id}` — метаданные + точки (высота terrain через локальный Open-Meteo)
### Команды (очередь на устройство)
- `POST /api/commands``{from_device_id, to_device_id, kind, payload?}`
`kind`: `at` (`payload.line` — одна строка, или `payload.lines` — массив макроса), `mode` (`payload.role`: TX/RX), `stats_push` (снимок meta/rssi/role/sf/bw)
`from_device_id`: `web` или `android-xxxxxxxx`
Макрос обычно: `S` (стоп TX/RX), затем `AT+FQ=`, `AT+PW=`, `AT+SF=`, `AT+BW=`, `AT+CR=`, `AT+PL=`, `AT+TM=`, при необходимости `AT+TX` / `AT+RX`.
### Профиль высот (веб, треки)
- `POST /api/elevation/profile``{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo)
- `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку
- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность (прокси Open-Meteo)
- `GET /api/elevation/grid?lat=&lon=&radius_m=200&step_m=0` — сетка высот для хитмапы (100–500 m, step_m=0 авто)
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
- `GET /api/commands?to_device_id=&limit=` — история (веб)
### Синхронный трек (два устройства)
- `POST /api/paired-tracks/start``{device_ids?: [a,b], initiator?, device_id?}` → сессия `armed`, `start_at = now+3s`
- `GET /api/paired-tracks/active``{active, session?}`
- `POST /api/paired-tracks/ack` — Android: `{session_id, device_id, track_id}`
- `POST /api/paired-tracks/cancel``{session_id?}`
### Прочее ### Прочее
- `POST /api/chat``{device_id, text}` - `POST /api/chat``{device_id, text}`
- `GET /api/chat?since=0` - `GET /api/chat?since=0`
- `GET /api/health``{ok, db_ok, schema_version, database_path}` - `GET /api/health``{ok, db_ok, schema_version, database_path, elevation_ok, elevation_url, elevation_error}`
## FastAPI (прод) ## FastAPI (прод)
@@ -92,6 +148,6 @@ python -m pytest tests/ -v
## Android ## Android
URL: `http://grigowashere.ru:7634`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (Open-Meteo на сервере). Вкладка **Статистика** — история с сервера. URL: `https://lora.grigowashere.ru`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (локальный Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
Telnet: `127.0.0.1:2727` — мост COM→telnet на устройстве. Telnet: `127.0.0.1:2727` — мост COM→telnet на устройстве.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
View File
@@ -9,3 +9,13 @@ HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
PORT = int(os.environ.get("LORATESTER_PORT", "7634")) PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000")) TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000")) TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
ELEVATION_API_URL = os.environ.get(
"LORATESTER_ELEVATION_URL",
"http://192.168.1.109:8085/v1/elevation",
).rstrip("/")
ELEVATION_PROBE_TTL_SEC = float(
os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60")
)
ELEVATION_CONNECT_TIMEOUT = float(
os.environ.get("LORATESTER_ELEVATION_TIMEOUT", "8")
)
+574 -19
View File
@@ -1,40 +1,595 @@
"""Terrain elevation via Open-Meteo (cached per coordinate).""" """Terrain elevation via self-hosted Open-Meteo-compatible API."""
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Optional import math
import time
from typing import Any, Optional
import httpx import httpx
from .config import (
ELEVATION_API_URL,
ELEVATION_CONNECT_TIMEOUT,
ELEVATION_PROBE_TTL_SEC,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_BATCH_SIZE = 100
_MAX_PROFILE_POINTS = 500
_CACHE: dict[tuple[float, float], Optional[float]] = {} _CACHE: dict[tuple[float, float], Optional[float]] = {}
_TIMEOUT = 3.0 _probe_checked_at = 0.0
_probe_ok = False
_probe_error: Optional[str] = None
def _cache_key(lat: float, lon: float) -> tuple[float, float]: def _cache_key(lat: float, lon: float) -> tuple[float, float]:
return (round(lat, 4), round(lon, 4)) return (round(lat, 6), round(lon, 6))
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]: def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
key = _cache_key(lat, lon) r = 6_371_000.0
if key in _CACHE: d_lat = math.radians(lat2 - lat1)
return _CACHE[key] d_lon = math.radians(lon2 - lon1)
a = (
math.sin(d_lat / 2) ** 2
+ math.cos(math.radians(lat1))
* math.cos(math.radians(lat2))
* math.sin(d_lon / 2) ** 2
)
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
"""Ping elevation service before batch requests (cached for TTL)."""
global _probe_checked_at, _probe_ok, _probe_error
now = time.monotonic()
if (
not force
and _probe_checked_at > 0
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
):
return {
"ok": _probe_ok,
"url": ELEVATION_API_URL,
"error": _probe_error,
}
try: try:
with httpx.Client(timeout=_TIMEOUT) as client: with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get( r = client.get(
"https://api.open-meteo.com/v1/elevation", ELEVATION_API_URL,
params={"latitude": lat, "longitude": lon}, params={"latitude": "0.000000", "longitude": "0.000000"},
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
elevations = data.get("elevation") or [] if "elevation" not in data:
if elevations: raise ValueError("response has no elevation field")
val = float(elevations[0]) _probe_checked_at = now
_CACHE[key] = val _probe_ok = True
return val _probe_error = None
logger.info("elevation API ok: %s", ELEVATION_API_URL)
except Exception as e: except Exception as e:
logger.warning("open-meteo elevation failed for %s,%s: %s", lat, lon, e) _probe_checked_at = now
_CACHE[key] = None _probe_ok = False
return None _probe_error = str(e)
logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e)
return {
"ok": _probe_ok,
"url": ELEVATION_API_URL,
"error": _probe_error,
}
def elevation_status(force: bool = False) -> dict[str, Any]:
probe = probe_elevation_api(force=force)
return {
"elevation_ok": probe["ok"],
"elevation_url": probe["url"],
"elevation_error": probe["error"],
}
def _fetch_elevation_batch(
batch_lat: list[float], batch_lon: list[float]
) -> list[Optional[float]]:
if not batch_lat:
return []
params = {
"latitude": ",".join(f"{lat:.6f}" for lat in batch_lat),
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
}
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get(ELEVATION_API_URL, params=params)
r.raise_for_status()
data = r.json()
elevations = data.get("elevation") or []
out: list[Optional[float]] = []
for j, elev in enumerate(elevations):
if j >= len(batch_lat):
break
if elev is None:
out.append(None)
else:
out.append(float(elev))
while len(out) < len(batch_lat):
out.append(None)
return out
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
vals = fetch_elevations_batch([lat], [lon])
return vals[0] if vals else None
def fetch_elevations_batch(
lats: list[float], lons: list[float]
) -> list[Optional[float]]:
if not lats or len(lats) != len(lons):
return []
probe = probe_elevation_api()
if not probe["ok"]:
logger.warning(
"skip elevation fetch: API unreachable (%s)",
probe.get("error"),
)
return [None] * len(lats)
out: list[Optional[float]] = [None] * len(lats)
pending_idx: list[int] = []
pending_lat: list[float] = []
pending_lon: list[float] = []
for i, (lat, lon) in enumerate(zip(lats, lons)):
key = _cache_key(lat, lon)
if key in _CACHE:
out[i] = _CACHE[key]
else:
pending_idx.append(i)
pending_lat.append(float(lat))
pending_lon.append(float(lon))
for start in range(0, len(pending_lat), _BATCH_SIZE):
batch_i = pending_idx[start : start + _BATCH_SIZE]
batch_lat = pending_lat[start : start + _BATCH_SIZE]
batch_lon = pending_lon[start : start + _BATCH_SIZE]
try:
batch_vals = _fetch_elevation_batch(batch_lat, batch_lon)
for j, val in enumerate(batch_vals):
lat = batch_lat[j]
lon = batch_lon[j]
_CACHE[_cache_key(lat, lon)] = val
out[batch_i[j]] = val
logger.info(
"elevation ok: %s points, sample=%s",
len(batch_lat),
batch_vals[0] if batch_vals else None,
)
except Exception as e:
logger.warning(
"elevation batch failed (%s points): %s",
len(batch_lat),
e,
)
for j in range(len(batch_lat)):
try:
single = _fetch_elevation_batch(
[batch_lat[j]], [batch_lon[j]]
)
val = single[0] if single else None
except Exception as e2:
logger.warning(
"elevation single failed %.6f,%.6f: %s",
batch_lat[j],
batch_lon[j],
e2,
)
val = None
_CACHE[_cache_key(batch_lat[j], batch_lon[j])] = val
out[batch_i[j]] = val
return out
def _interp_at_dist(
cleaned: list[tuple[float, float]], cum: list[float], dist_m: float
) -> tuple[float, float]:
if dist_m <= 0:
return cleaned[0]
if dist_m >= cum[-1]:
return cleaned[-1]
for i in range(1, len(cum)):
if dist_m <= cum[i]:
seg = cum[i] - cum[i - 1]
t = 0.0 if seg <= 0 else (dist_m - cum[i - 1]) / seg
lat1, lon1 = cleaned[i - 1]
lat2, lon2 = cleaned[i]
return lat1 + (lat2 - lat1) * t, lon1 + (lon2 - lon1) * t
return cleaned[-1]
def resample_track_path(
points: list[dict[str, Any]], step_m: float = 10.0
) -> list[dict[str, float]]:
"""Sample (lat, lon, dist_m) along polyline every ~step_m meters."""
if not points or step_m <= 0:
return []
cleaned: list[tuple[float, float]] = []
for p in points:
lat = p.get("lat")
lon = p.get("lon")
if lat is None or lon is None:
continue
lat_f, lon_f = float(lat), float(lon)
if not cleaned or haversine_m(cleaned[-1][0], cleaned[-1][1], lat_f, lon_f) > 0.5:
cleaned.append((lat_f, lon_f))
if not cleaned:
return []
if len(cleaned) == 1:
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
cum = [0.0]
for i in range(1, len(cleaned)):
cum.append(
cum[-1]
+ haversine_m(
cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]
)
)
total = cum[-1]
samples: list[dict[str, float]] = []
dist = 0.0
while dist <= total + 1e-6:
lat, lon = _interp_at_dist(cleaned, cum, dist)
samples.append({"lat": lat, "lon": lon, "dist_m": round(dist, 1)})
if dist >= total:
break
dist += step_m
return samples
def resample_track_path_count(
points: list[dict[str, Any]], count: int
) -> list[dict[str, float]]:
"""Sample exactly `count` points evenly spaced along polyline."""
if not points or count < 2:
return []
cleaned: list[tuple[float, float]] = []
for p in points:
lat = p.get("lat")
lon = p.get("lon")
if lat is None or lon is None:
continue
lat_f, lon_f = float(lat), float(lon)
if not cleaned or haversine_m(cleaned[-1][0], cleaned[-1][1], lat_f, lon_f) > 0.5:
cleaned.append((lat_f, lon_f))
if not cleaned:
return []
if len(cleaned) == 1:
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
cum = [0.0]
for i in range(1, len(cleaned)):
cum.append(
cum[-1]
+ haversine_m(
cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]
)
)
total = cum[-1]
if total < 1e-6:
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
n = max(2, min(_MAX_PROFILE_POINTS, int(count)))
samples: list[dict[str, float]] = []
for i in range(n):
dist = (total * i) / (n - 1)
lat, lon = _interp_at_dist(cleaned, cum, dist)
samples.append({"lat": lat, "lon": lon, "dist_m": round(dist, 1)})
return samples
def build_elevation_profile(
points: list[dict[str, Any]],
step_m: float = 10.0,
target_points: int | None = None,
) -> dict[str, Any]:
"""Resample track and fetch terrain elevations."""
if target_points is not None:
n = max(2, min(_MAX_PROFILE_POINTS, int(target_points)))
samples = resample_track_path_count(points, n)
if len(samples) > 1:
step_m = round(
(samples[-1]["dist_m"] - samples[0]["dist_m"]) / (len(samples) - 1),
2,
)
else:
step_m = 0.0
else:
step_m = max(5.0, min(10.0, float(step_m)))
samples = resample_track_path(points, step_m)
if not samples:
return {
"step_m": step_m,
"points": [],
"total_m": 0.0,
"api_source": "elevation",
"api_error": "no samples",
}
probe = probe_elevation_api()
if not probe["ok"]:
return {
"step_m": step_m,
"points": [],
"total_m": 0.0,
"api_source": "elevation",
"api_error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL,
}
lats = [s["lat"] for s in samples]
lons = [s["lon"] for s in samples]
elevations = fetch_elevations_batch(lats, lons)
profile: list[dict[str, Any]] = []
elev_vals: list[float] = []
for s, elev in zip(samples, elevations):
item = {
"dist_m": round(s["dist_m"], 1),
"lat": round(s["lat"], 6),
"lon": round(s["lon"], 6),
"elevation_m": elev,
}
profile.append(item)
if elev is not None:
elev_vals.append(elev)
total_m = profile[-1]["dist_m"] if profile else 0.0
result: dict[str, Any] = {
"step_m": step_m,
"total_m": total_m,
"min_elevation_m": min(elev_vals) if elev_vals else None,
"max_elevation_m": max(elev_vals) if elev_vals else None,
"points": profile,
"api_source": "elevation",
"elevation_url": ELEVATION_API_URL,
}
if not elev_vals:
result["api_error"] = "elevation API returned no values"
return result
def _offset_m(lat: float, lon: float, north_m: float, east_m: float) -> tuple[float, float]:
dlat = north_m / 111_320.0
dlon = east_m / (111_320.0 * max(math.cos(math.radians(lat)), 1e-6))
return lat + dlat, lon + dlon
_MAX_GRID_POINTS = 2500
_MAX_GRID_POINTS_FINE = 12000
def _auto_step_m(radius_m: float) -> float:
if radius_m <= 150:
return 10.0
if radius_m <= 300:
return 15.0
return 20.0
def _sample_circular_grid(
lat: float,
lon: float,
radius_m: float,
step_m: float,
) -> list[tuple[int, int, float, float, float]]:
steps = int(radius_m / step_m)
cells: list[tuple[int, int, float, float, float]] = []
for i in range(-steps, steps + 1):
for j in range(-steps, steps + 1):
north = i * step_m
east = j * step_m
dist = math.hypot(north, east)
if dist > radius_m:
continue
la, lo = _offset_m(lat, lon, north, east)
cells.append((i, j, la, lo, dist))
return cells
def _resolve_grid_step(
lat: float, lon: float, radius_m: float, step_m: float
) -> float:
if step_m <= 0:
step_m = _auto_step_m(radius_m)
min_step = 1.0 if radius_m <= 100.0 else 5.0
step_m = max(min_step, min(float(step_m), 100.0))
max_points = _MAX_GRID_POINTS_FINE if radius_m <= 100.0 and step_m <= 1.0 else _MAX_GRID_POINTS
while len(_sample_circular_grid(lat, lon, radius_m, step_m)) > max_points:
step_m = math.ceil(step_m * 1.25)
if step_m >= radius_m:
break
return step_m
def build_elevation_grid(
lat: float,
lon: float,
radius_m: float = 200.0,
step_m: float = 0.0,
) -> dict[str, Any]:
"""Circular elevation grid for heatmap (delta relative to center)."""
probe = probe_elevation_api()
if not probe["ok"]:
return {
"ok": False,
"error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL,
}
radius_m = max(50.0, min(float(radius_m), 500.0))
step_m = _resolve_grid_step(lat, lon, radius_m, step_m)
center_elev = fetch_elevation_m(lat, lon)
if center_elev is None:
return {"ok": False, "error": "no elevation at center"}
grid_cells = _sample_circular_grid(lat, lon, radius_m, step_m)
if not grid_cells:
return {"ok": False, "error": "empty search grid"}
lats = [c[2] for c in grid_cells]
lons = [c[3] for c in grid_cells]
elevations = fetch_elevations_batch(lats, lons)
points: list[dict[str, Any]] = []
deltas: list[float] = []
for (i, j, la, lo, dist), elev in zip(grid_cells, elevations):
if elev is None:
continue
delta = float(elev) - center_elev
deltas.append(delta)
points.append(
{
"i": i,
"j": j,
"lat": round(la, 6),
"lon": round(lo, 6),
"dist_m": round(dist, 1),
"elevation_m": float(elev),
"delta_m": round(delta, 1),
}
)
if not points:
return {"ok": False, "error": "no elevation values in grid"}
return {
"ok": True,
"center": {
"lat": round(lat, 6),
"lon": round(lon, 6),
"elevation_m": center_elev,
},
"radius_m": radius_m,
"step_m": step_m,
"points": points,
"min_delta_m": round(min(deltas), 1),
"max_delta_m": round(max(deltas), 1),
"api_source": "elevation",
"elevation_url": ELEVATION_API_URL,
}
def find_nearest_hill(
lat: float,
lon: float,
radius_m: float = 5000.0,
step_m: float = 300.0,
min_prominence_m: float = 8.0,
) -> dict[str, Any]:
"""Find nearest local elevation maximum around a point."""
probe = probe_elevation_api()
if not probe["ok"]:
return {
"ok": False,
"error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL,
}
radius_m = max(500.0, min(float(radius_m), 15_000.0))
step_m = max(100.0, min(float(step_m), 500.0))
min_prominence_m = max(3.0, min(float(min_prominence_m), 100.0))
center_elev = fetch_elevation_m(lat, lon)
if center_elev is None:
return {"ok": False, "error": "no elevation at center"}
grid_cells = _sample_circular_grid(lat, lon, radius_m, step_m)
if not grid_cells:
return {"ok": False, "error": "empty search grid"}
lats = [c[2] for c in grid_cells]
lons = [c[3] for c in grid_cells]
elevations = fetch_elevations_batch(lats, lons)
grid: dict[tuple[int, int], dict[str, Any]] = {}
for (i, j, la, lo, dist), elev in zip(grid_cells, elevations):
grid[(i, j)] = {
"lat": round(la, 6),
"lon": round(lo, 6),
"dist_m": round(dist, 1),
"elevation_m": elev,
}
def is_local_max(i: int, j: int, elev: float) -> bool:
for di in (-1, 0, 1):
for dj in (-1, 0, 1):
if di == 0 and dj == 0:
continue
n = grid.get((i + di, j + dj))
if n and n["elevation_m"] is not None and n["elevation_m"] >= elev:
return False
return True
candidates: list[dict[str, Any]] = []
for (i, j), cell in grid.items():
elev = cell.get("elevation_m")
if elev is None:
continue
prominence = float(elev) - center_elev
if prominence < min_prominence_m:
continue
if is_local_max(i, j, float(elev)):
candidates.append({**cell, "prominence_m": round(prominence, 1)})
if not candidates:
best = None
for cell in grid.values():
elev = cell.get("elevation_m")
if elev is None:
continue
prominence = float(elev) - center_elev
if prominence < min_prominence_m * 0.5:
continue
if best is None or cell["dist_m"] < best["dist_m"]:
best = {
**cell,
"prominence_m": round(prominence, 1),
"is_local_max": False,
}
if best is None:
return {
"ok": False,
"error": "no hill found in radius",
"center": {
"lat": round(lat, 6),
"lon": round(lon, 6),
"elevation_m": center_elev,
},
"radius_m": radius_m,
}
hill = best
else:
candidates.sort(key=lambda c: c["dist_m"])
hill = {**candidates[0], "is_local_max": True}
return {
"ok": True,
"center": {
"lat": round(lat, 6),
"lon": round(lon, 6),
"elevation_m": center_elev,
},
"hill": hill,
"candidates": len(candidates),
"radius_m": radius_m,
"step_m": step_m,
"api_source": "elevation",
"elevation_url": ELEVATION_API_URL,
}
+1
View File
@@ -14,6 +14,7 @@ class TelemetryIn:
role: Optional[str] = None role: Optional[str] = None
ts: Optional[float] = None ts: Optional[float] = None
source: str = "android" source: str = "android"
device_label: Optional[str] = None
@dataclass @dataclass
+41 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import sqlite3 import sqlite3
SCHEMA_VERSION = 3 SCHEMA_VERSION = 4
def table_exists(conn: sqlite3.Connection, name: str) -> bool: def table_exists(conn: sqlite3.Connection, name: str) -> bool:
@@ -121,6 +121,44 @@ def apply_migrations(conn: sqlite3.Connection) -> list[str]:
) )
log.append("CREATE track_points") log.append("CREATE track_points")
if not table_exists(conn, "device_commands"):
conn.executescript(
"""
CREATE TABLE device_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_device_id TEXT NOT NULL,
to_device_id TEXT NOT NULL,
kind TEXT NOT NULL,
payload TEXT,
created_at REAL NOT NULL,
delivered_at REAL
);
CREATE INDEX IF NOT EXISTS idx_commands_to_pending
ON device_commands(to_device_id, delivered_at, created_at);
"""
)
log.append("CREATE device_commands")
if not table_exists(conn, "paired_track_sessions"):
conn.executescript(
"""
CREATE TABLE paired_track_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_a TEXT NOT NULL,
device_b TEXT NOT NULL,
initiator TEXT NOT NULL,
status TEXT NOT NULL,
start_at REAL NOT NULL,
track_id_a INTEGER,
track_id_b INTEGER,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_paired_status
ON paired_track_sessions(status, created_at DESC);
"""
)
log.append("CREATE paired_track_sessions")
set_schema_version(conn, SCHEMA_VERSION) set_schema_version(conn, SCHEMA_VERSION)
log.append(f"schema_version={SCHEMA_VERSION}") log.append(f"schema_version={SCHEMA_VERSION}")
return log return log
@@ -132,6 +170,8 @@ def check_db_ok(conn: sqlite3.Connection) -> bool:
("telemetry", "meta"), ("telemetry", "meta"),
("tracks", None), ("tracks", None),
("track_points", "elevation_m"), ("track_points", "elevation_m"),
("device_commands", None),
("paired_track_sessions", None),
] ]
for table, col in required: for table, col in required:
if not table_exists(conn, table): if not table_exists(conn, table):
+361 -13
View File
@@ -11,6 +11,13 @@ from .elevation import fetch_elevation_m
from .models import ChatIn, TelemetryIn from .models import ChatIn, TelemetryIn
from .schema import SCHEMA_VERSION, apply_migrations, check_db_ok, get_schema_version from .schema import SCHEMA_VERSION, apply_migrations, check_db_ok, get_schema_version
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__) logger = logging.getLogger(__name__)
_HISTORY_COLUMNS = ( _HISTORY_COLUMNS = (
@@ -83,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)
@@ -129,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 = (
@@ -145,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:
@@ -159,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"],
@@ -316,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 ?
@@ -332,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 ?
""", """,
@@ -348,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()
@@ -389,10 +440,307 @@ 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()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def _is_valid_sender(device_id: str) -> bool:
d = (device_id or "").strip()
return d == WEB_SENDER_ID or is_valid_device_id(d)
def _row_to_command(row: sqlite3.Row) -> dict[str, Any]:
payload = row["payload"]
if payload:
try:
payload = json.loads(payload)
except json.JSONDecodeError:
pass
return {
"id": row["id"],
"from_device_id": row["from_device_id"],
"to_device_id": row["to_device_id"],
"kind": row["kind"],
"payload": payload,
"created_at": row["created_at"],
"delivered_at": row["delivered_at"],
}
def enqueue_command(
from_device_id: str,
to_device_id: str,
kind: str,
payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
from_id = (from_device_id or "").strip()
to_id = (to_device_id or "").strip()
kind = (kind or "").strip().lower()
if not _is_valid_sender(from_id):
raise ValueError(f"invalid from_device_id '{from_id}'")
if not is_valid_device_id(to_id):
raise ValueError(f"invalid to_device_id '{to_id}'")
if kind not in COMMAND_KINDS:
raise ValueError(f"invalid kind '{kind}', expected at|mode|stats_push")
if from_id == to_id:
raise ValueError("from and to device must differ")
ts = time.time()
payload_json = json.dumps(payload or {}, ensure_ascii=False)
with _db() as conn:
cur = conn.execute(
"""
INSERT INTO device_commands
(from_device_id, to_device_id, kind, payload, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(from_id, to_id, kind, payload_json, ts),
)
cmd_id = cur.lastrowid
return {
"ok": True,
"id": cmd_id,
"from_device_id": from_id,
"to_device_id": to_id,
"kind": kind,
"created_at": ts,
}
def poll_pending_commands(device_id: str, limit: int = 20) -> list[dict[str, Any]]:
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
limit = min(max(1, limit), 50)
now = time.time()
with _db() as conn:
rows = conn.execute(
"""
SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at
FROM device_commands
WHERE to_device_id = ? AND delivered_at IS NULL
ORDER BY created_at ASC
LIMIT ?
""",
(device_id, limit),
).fetchall()
ids = [r["id"] for r in rows]
if ids:
placeholders = ",".join("?" * len(ids))
conn.execute(
f"UPDATE device_commands SET delivered_at = ? WHERE id IN ({placeholders})",
[now, *ids],
)
return [_row_to_command(r) for r in rows]
def list_commands(
to_device_id: Optional[str] = None, limit: int = 50
) -> list[dict[str, Any]]:
limit = min(max(1, limit), 200)
with _db() as conn:
if to_device_id:
rows = conn.execute(
"""
SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at
FROM device_commands
WHERE to_device_id = ?
ORDER BY created_at DESC
LIMIT ?
""",
(to_device_id, limit),
).fetchall()
else:
rows = conn.execute(
"""
SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at
FROM device_commands
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [_row_to_command(r) for r in rows]
def _online_android_devices(within_sec: float = PAIRED_ONLINE_SEC) -> list[str]:
cutoff = time.time() - within_sec
devices = list_devices()
return [
d["device_id"]
for d in devices
if d.get("last_seen", 0) >= cutoff
]
def _row_to_paired_session(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"device_a": row["device_a"],
"device_b": row["device_b"],
"initiator": row["initiator"],
"status": row["status"],
"start_at": row["start_at"],
"track_id_a": row["track_id_a"],
"track_id_b": row["track_id_b"],
"created_at": row["created_at"],
}
def _cancel_active_paired_sessions(conn: sqlite3.Connection) -> None:
conn.execute(
"""
UPDATE paired_track_sessions
SET status = 'cancelled'
WHERE status IN ('armed', 'recording')
"""
)
def start_paired_track(
device_ids: Optional[list[str]] = None,
initiator: str = WEB_SENDER_ID,
) -> dict[str, Any]:
initiator = (initiator or WEB_SENDER_ID).strip()
if not _is_valid_sender(initiator):
raise ValueError(f"invalid initiator '{initiator}'")
if device_ids and len(device_ids) == 2:
a, b = [str(x).strip() for x in device_ids]
if not is_valid_device_id(a) or not is_valid_device_id(b):
raise ValueError("device_ids must be two valid android-* ids")
if a == b:
raise ValueError("device_ids must differ")
else:
online = _online_android_devices()
if len(online) != 2:
raise ValueError(
f"expected exactly 2 online devices, found {len(online)}"
)
a, b = sorted(online)
now = time.time()
start_at = now + PAIRED_START_DELAY_SEC
with _db() as conn:
_cancel_active_paired_sessions(conn)
cur = conn.execute(
"""
INSERT INTO paired_track_sessions
(device_a, device_b, initiator, status, start_at, created_at)
VALUES (?, ?, ?, 'armed', ?, ?)
""",
(a, b, initiator, start_at, now),
)
session_id = cur.lastrowid
return {
"ok": True,
"session": get_paired_track_session(session_id),
}
def get_active_paired_track() -> Optional[dict[str, Any]]:
with _db() as conn:
row = conn.execute(
"""
SELECT id, device_a, device_b, initiator, status, start_at,
track_id_a, track_id_b, created_at
FROM paired_track_sessions
WHERE status IN ('armed', 'recording')
ORDER BY id DESC
LIMIT 1
"""
).fetchone()
if not row:
return None
session = _row_to_paired_session(row)
now = time.time()
session["server_time"] = now
session["ready"] = session["status"] == "armed" and now >= session["start_at"]
return session
def get_paired_track_session(session_id: int) -> dict[str, Any]:
with _db() as conn:
row = conn.execute(
"""
SELECT id, device_a, device_b, initiator, status, start_at,
track_id_a, track_id_b, created_at
FROM paired_track_sessions WHERE id = ?
""",
(session_id,),
).fetchone()
if not row:
raise ValueError(f"session {session_id} not found")
session = _row_to_paired_session(row)
now = time.time()
session["server_time"] = now
session["ready"] = session["status"] == "armed" and now >= session["start_at"]
return session
def ack_paired_track(
session_id: int, device_id: str, track_id: int
) -> dict[str, Any]:
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
with _db() as conn:
row = conn.execute(
"""
SELECT id, device_a, device_b, status, track_id_a, track_id_b
FROM paired_track_sessions WHERE id = ?
""",
(session_id,),
).fetchone()
if not row:
raise ValueError(f"session {session_id} not found")
if row["status"] not in ("armed", "recording"):
raise ValueError(f"session {session_id} not active")
col = None
if device_id == row["device_a"]:
col = "track_id_a"
elif device_id == row["device_b"]:
col = "track_id_b"
else:
raise ValueError(f"device {device_id} not in session")
conn.execute(
f"UPDATE paired_track_sessions SET {col} = ? WHERE id = ?",
(track_id, session_id),
)
updated = conn.execute(
"""
SELECT track_id_a, track_id_b, status FROM paired_track_sessions
WHERE id = ?
""",
(session_id,),
).fetchone()
if updated["track_id_a"] and updated["track_id_b"]:
conn.execute(
"UPDATE paired_track_sessions SET status = 'recording' WHERE id = ?",
(session_id,),
)
return {"ok": True, "session": get_paired_track_session(session_id)}
def cancel_paired_track(session_id: Optional[int] = None) -> dict[str, Any]:
with _db() as conn:
if session_id is not None:
cur = conn.execute(
"""
UPDATE paired_track_sessions
SET status = 'cancelled'
WHERE id = ? AND status IN ('armed', 'recording')
""",
(session_id,),
)
if cur.rowcount == 0:
raise ValueError(f"session {session_id} not found or not active")
else:
_cancel_active_paired_sessions(conn)
return {"ok": True, "active": get_active_paired_track()}
+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,
) )
+20
View File
@@ -0,0 +1,20 @@
services:
loratester:
build: .
container_name: loratester
restart: unless-stopped
ports:
- "${LORATESTER_PORT:-7634}:7634"
volumes:
- loratester-data:/data
environment:
LORATESTER_DB: /data/loratester.db
LORATESTER_PORT: "7634"
LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation}
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000}
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-10000}
volumes:
loratester-data:
+189 -1
View File
@@ -7,6 +7,7 @@ from typing import Any, Optional
from fastapi import FastAPI, Header, HTTPException, Query from fastapi import FastAPI, Header, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from core.auth import ANDROID_CLIENT_HEADER, ANDROID_CLIENT_VALUE from core.auth import ANDROID_CLIENT_HEADER, ANDROID_CLIENT_VALUE
@@ -30,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
@@ -52,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
@@ -66,6 +72,29 @@ class TrackPointsBody(BaseModel):
points: list[TrackPoint] = Field(default_factory=list) points: list[TrackPoint] = Field(default_factory=list)
class CommandBody(BaseModel):
from_device_id: str
to_device_id: str
kind: str
payload: Optional[dict[str, Any]] = None
class PairedTrackStartBody(BaseModel):
device_ids: Optional[list[str]] = None
initiator: Optional[str] = None
device_id: Optional[str] = None
class PairedTrackAckBody(BaseModel):
session_id: int
device_id: str
track_id: int
class PairedTrackCancelBody(BaseModel):
session_id: Optional[int] = None
@app.get("/") @app.get("/")
def index(): def index():
return FileResponse( return FileResponse(
@@ -96,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,
@@ -194,10 +231,161 @@ def get_chat(since: float = 0, limit: int = Query(200, ge=1, le=500)):
return storage.get_chat(since, limit) return storage.get_chat(since, limit)
@app.post("/api/commands")
def post_command(body: CommandBody):
try:
return storage.enqueue_command(
body.from_device_id,
body.to_device_id,
body.kind,
body.payload,
)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/commands/pending")
def commands_pending(
device_id: str = Query(...),
limit: int = Query(20, ge=1, le=50),
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
return storage.poll_pending_commands(device_id, limit)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/commands")
def commands_list(
to_device_id: Optional[str] = None,
limit: int = Query(50, ge=1, le=200),
):
return storage.list_commands(to_device_id, limit)
@app.post("/api/paired-tracks/start")
def paired_tracks_start(
body: PairedTrackStartBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
if body.initiator:
initiator = body.initiator
elif body.device_id:
initiator = body.device_id
elif (x_lora_client or "").strip().lower() == ANDROID_CLIENT_VALUE:
raise HTTPException(400, detail="initiator or device_id required")
else:
initiator = storage.WEB_SENDER_ID
try:
return storage.start_paired_track(body.device_ids, str(initiator))
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/paired-tracks/active")
def paired_tracks_active():
session = storage.get_active_paired_track()
return {"active": session is not None, "session": session}
@app.post("/api/paired-tracks/ack")
def paired_tracks_ack(
body: PairedTrackAckBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
return storage.ack_paired_track(
body.session_id, body.device_id, body.track_id
)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.post("/api/paired-tracks/cancel")
def paired_tracks_cancel(body: PairedTrackCancelBody):
try:
return storage.cancel_paired_track(body.session_id)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
class ElevationPoint(BaseModel):
lat: float
lon: float
ts: Optional[float] = None
class ElevationProfileBody(BaseModel):
points: list[ElevationPoint] = Field(default_factory=list)
step_m: float = 10.0
target_points: Optional[int] = Field(None, ge=2, le=500)
@app.post("/api/elevation/profile")
def elevation_profile(body: ElevationProfileBody):
from core.elevation import build_elevation_profile
pts = [p.model_dump(exclude_none=True) for p in body.points]
return build_elevation_profile(pts, body.step_m, body.target_points)
@app.get("/api/tracks/{track_id}/elevation-profile")
def track_elevation_profile(
track_id: int,
step_m: float = Query(10.0, ge=5.0, le=10.0),
):
from core.elevation import build_elevation_profile
try:
track = storage.get_track(track_id)
except ValueError as e:
raise HTTPException(404, detail=str(e)) from e
return build_elevation_profile(track.get("points") or [], step_m)
@app.get("/api/elevation/nearest-hill")
def elevation_nearest_hill(
lat: float = Query(..., ge=-90.0, le=90.0),
lon: float = Query(..., ge=-180.0, le=180.0),
radius_m: float = Query(5000.0, ge=500.0, le=15000.0),
step_m: float = Query(300.0, ge=100.0, le=500.0),
min_prominence_m: float = Query(8.0, ge=3.0, le=100.0),
):
from core.elevation import find_nearest_hill
return find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m)
@app.get("/api/elevation/grid")
def elevation_grid(
lat: float = Query(..., ge=-90.0, le=90.0),
lon: float = Query(..., ge=-180.0, le=180.0),
radius_m: float = Query(200.0, ge=50.0, le=500.0),
step_m: float = Query(0.0, ge=1.0, le=100.0),
):
from core.elevation import build_elevation_grid
return build_elevation_grid(lat, lon, radius_m, step_m)
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
from core.elevation import elevation_status
status = storage.db_status() status = storage.db_status()
return {"ok": status["db_ok"], "ts": time.time(), **status} return {
"ok": status["db_ok"],
"ts": time.time(),
"api_build": "2026-06-16g",
**status,
**elevation_status(),
}
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
if __name__ == "__main__": if __name__ == "__main__":
+156 -1
View File
@@ -145,10 +145,165 @@ def get_chat():
return jsonify(storage.get_chat(since, limit)) return jsonify(storage.get_chat(since, limit))
@app.post("/api/commands")
def post_command():
body = request.get_json(force=True, silent=True) or {}
from_id = body.get("from_device_id")
to_id = body.get("to_device_id")
kind = body.get("kind")
if not from_id or not to_id or not kind:
return jsonify({"error": "from_device_id, to_device_id, kind required"}), 400
try:
return jsonify(
storage.enqueue_command(
str(from_id), str(to_id), str(kind), body.get("payload")
)
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.get("/api/commands/pending")
def commands_pending():
if not is_android_client(request.headers):
return jsonify({"error": "Android only"}), 403
device_id = request.args.get("device_id")
if not device_id:
return jsonify({"error": "device_id required"}), 400
limit = int(request.args.get("limit", 20))
try:
return jsonify(storage.poll_pending_commands(str(device_id), limit))
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.get("/api/commands")
def commands_list():
to_device_id = request.args.get("to_device_id")
limit = int(request.args.get("limit", 50))
return jsonify(storage.list_commands(to_device_id, limit))
@app.post("/api/paired-tracks/start")
def paired_tracks_start():
body = request.get_json(force=True, silent=True) or {}
initiator = body.get("initiator") or (
body.get("device_id") if is_android_client(request.headers) else storage.WEB_SENDER_ID
)
device_ids = body.get("device_ids")
try:
return jsonify(
storage.start_paired_track(
device_ids if isinstance(device_ids, list) else None,
str(initiator),
)
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.get("/api/paired-tracks/active")
def paired_tracks_active():
session = storage.get_active_paired_track()
return jsonify({"active": session is not None, "session": session})
@app.post("/api/paired-tracks/ack")
def paired_tracks_ack():
if not is_android_client(request.headers):
return jsonify({"error": "Android only"}), 403
body = request.get_json(force=True, silent=True) or {}
session_id = body.get("session_id")
device_id = body.get("device_id")
track_id = body.get("track_id")
if session_id is None or not device_id or track_id is None:
return jsonify({"error": "session_id, device_id, track_id required"}), 400
try:
return jsonify(
storage.ack_paired_track(int(session_id), str(device_id), int(track_id))
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.post("/api/paired-tracks/cancel")
def paired_tracks_cancel():
body = request.get_json(force=True, silent=True) or {}
session_id = body.get("session_id")
try:
sid = int(session_id) if session_id is not None else None
return jsonify(storage.cancel_paired_track(sid))
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.post("/api/elevation/profile")
def elevation_profile():
from core.elevation import build_elevation_profile
body = request.get_json(force=True, silent=True) or {}
points = body.get("points") or []
step_m = body.get("step_m", 10)
try:
step = float(step_m)
except (TypeError, ValueError):
step = 10.0
target_points = body.get("target_points")
try:
tp = int(target_points) if target_points is not None else None
except (TypeError, ValueError):
tp = None
return jsonify(build_elevation_profile(points, step, tp))
@app.get("/api/tracks/<int:track_id>/elevation-profile")
def track_elevation_profile(track_id: int):
from core.elevation import build_elevation_profile
step_m = request.args.get("step_m", 10, type=float)
try:
track = storage.get_track(track_id)
except ValueError as e:
return jsonify({"error": str(e)}), 404
points = track.get("points") or []
return jsonify(build_elevation_profile(points, step_m or 10.0))
@app.get("/api/elevation/nearest-hill")
def elevation_nearest_hill():
from core.elevation import find_nearest_hill
lat = request.args.get("lat", type=float)
lon = request.args.get("lon", type=float)
if lat is None or lon is None:
return jsonify({"ok": False, "error": "lat and lon required"}), 400
radius_m = request.args.get("radius_m", 5000, type=float)
step_m = request.args.get("step_m", 300, type=float)
min_prominence_m = request.args.get("min_prominence_m", 8, type=float)
return jsonify(find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m))
@app.get("/api/elevation/grid")
def elevation_grid():
from core.elevation import build_elevation_grid
lat = request.args.get("lat", type=float)
lon = request.args.get("lon", type=float)
if lat is None or lon is None:
return jsonify({"ok": False, "error": "lat and lon required"}), 400
radius_m = request.args.get("radius_m", 200, type=float)
step_m = request.args.get("step_m", 0, type=float)
return jsonify(build_elevation_grid(lat, lon, radius_m, step_m))
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
from core.elevation import elevation_status
status = storage.db_status() status = storage.db_status()
return jsonify({"ok": status["db_ok"], "ts": time.time(), **status}) return jsonify(
{"ok": status["db_ok"], "ts": time.time(), **status, **elevation_status()}
)
def _float_or_none(value): def _float_or_none(value):
Binary file not shown.
+2406 -190
View File
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
/** Shared radio stats parsing/formatting (mirror of Android RadioSnapshot). */
(function (global) {
'use strict';
const KNOWN_LABELS = new Set([
'send', 'receive', 'frequency', 'power', 'rssi', 'snr',
'spreading factor', 'bandwidth', 'packet', 'packet number', 'payload',
'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) {
if (role === 'TX') return 'Передатчик (TX)';
if (role === 'RX') return 'Приёмник (RX)';
return role || '—';
}
function isKnownLabel(label) {
const n = String(label || '').toLowerCase().trim();
for (const k of KNOWN_LABELS) {
if (n === k || n.includes(k)) return true;
}
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,
frame: null,
frequencyMhz: null,
sf: null,
bwKhz: null,
powerDbm: null,
rssiDbm: rssiFallback ?? null,
snrDb: null,
packet: null,
payload: null,
onAirMs: null,
txPktPerS: null,
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;
let o = meta;
if (typeof meta === 'string') {
try { o = JSON.parse(meta); } catch (e) { return snap; }
}
if (o.role) snap.role = o.role;
if (o.frame) snap.frame = o.frame;
if (o.rssi_dbm != null) snap.rssiDbm = Number(o.rssi_dbm);
if (o.power_dbm != null) snap.powerDbm = Number(o.power_dbm);
if (o.snr_db != null) snap.snrDb = Number(o.snr_db);
if (o.frequency_hz != null) snap.frequencyMhz = Number(o.frequency_hz) / 1e6;
if (o.spreading_factor != null) snap.sf = Number(o.spreading_factor);
if (o.bandwidth_khz != null) snap.bwKhz = Number(o.bandwidth_khz);
if (o.packet != null) snap.packet = Number(o.packet);
if (o.payload) snap.payload = String(o.payload);
if (o.on_air_ms != null) snap.onAirMs = Number(o.on_air_ms);
if (o.tx_pkt_per_s != null) snap.txPktPerS = Number(o.tx_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.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);
const nk = String(k).toLowerCase().trim();
if (snap.rxQualityPercent == null && nk.includes('rx quality')) {
const n = parseFloat(String(v).replace('%', '').trim());
if (!Number.isNaN(n)) snap.rxQualityPercent = n;
}
}
}
return snap;
}
function diffSnapshots(a, b) {
const changed = new Set();
if (!a || !b) return changed;
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]);
}
return changed;
}
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` : '—' }
];
const STATIC_ROWS = [
{ 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 => 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` : '—' }
];
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) {
let html = '<div class="radio-compare-grid">';
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' : '';
html += `<div class="radio-row"><span class="radio-label">${row.label}</span>`;
html += `<span class="radio-tx${txCls}">${escapeHtml(row.fmt(txSnap))}</span>`;
html += `<span class="radio-rx${rxCls}">${escapeHtml(row.fmt(rxSnap))}</span></div>`;
}
html += `<details class="radio-static"${staticOpen ? ' open' : ''}><summary>Статика</summary>`;
for (const row of STATIC_ROWS) {
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
html += `<div class="radio-row"><span class="radio-label">${row.label}</span>`;
html += `<span class="radio-tx${txCls}">${escapeHtml(row.fmt(txSnap))}</span>`;
html += `<span class="radio-rx${rxCls}">${escapeHtml(row.fmt(rxSnap))}</span></div>`;
}
html += '</details></div>';
return html;
}
function formatRadioPanel(snap, changed, staticOpen) {
if (!snap) return '—';
const ch = changed || new Set();
let html = '';
for (const row of DYNAMIC_ROWS) {
const cls = ch.has(row.key) ? ' class="changed"' : '';
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
}
for (const [label, value] of Object.entries(snap.extraFields || {})) {
html += `<div><b>${escapeHtml(label)}:</b> ${escapeHtml(value)}</div>`;
}
html += `<details class="radio-static"${staticOpen ? ' open' : ''}><summary>Статика</summary>`;
for (const row of STATIC_ROWS) {
const cls = ch.has(row.key) ? ' class="changed"' : '';
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
}
html += '</details>';
return html;
}
global.RadioUI = {
roleLabel,
parseRadioSnapshot,
diffSnapshots,
renderCompareGrid,
formatRadioPanel,
DYNAMIC_ROWS,
STATIC_ROWS
};
})(typeof window !== 'undefined' ? window : globalThis);
+172
View File
@@ -0,0 +1,172 @@
import core.elevation as elev
class _FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class _FakeClient:
def __init__(self, **kwargs):
self.kwargs = kwargs
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, url, params=None):
return _FakeResponse({"elevation": [152.0]})
def test_probe_elevation_api_ok(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev.httpx, "Client", _FakeClient)
status = elev.probe_elevation_api(force=True)
assert status["ok"] is True
assert status["error"] is None
def test_fetch_skips_when_unreachable(monkeypatch):
monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
)
vals = elev.fetch_elevations_batch([55.75], [37.62])
assert vals == [None]
def test_build_profile_reports_unreachable(monkeypatch):
monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
)
profile = elev.build_elevation_profile(
[{"lat": 55.75, "lon": 37.62}, {"lat": 55.76, "lon": 37.63}],
10,
)
assert profile["points"] == []
assert "unreachable" in profile["api_error"]
def test_resample_track_path_count_even_spacing():
pts = [{"lat": 55.0, "lon": 37.0}, {"lat": 55.01, "lon": 37.0}]
samples = elev.resample_track_path_count(pts, 50)
assert len(samples) == 50
assert samples[0]["dist_m"] == 0.0
assert samples[-1]["dist_m"] > samples[0]["dist_m"]
gaps = [samples[i]["dist_m"] - samples[i - 1]["dist_m"] for i in range(1, len(samples))]
assert max(gaps) - min(gaps) < 1.0
def test_build_profile_target_points(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
monkeypatch.setattr(
elev,
"fetch_elevations_batch",
lambda lats, lons: [100.0 + i for i in range(len(lats))],
)
profile = elev.build_elevation_profile(
[{"lat": 55.0, "lon": 37.0}, {"lat": 55.01, "lon": 37.0}],
target_points=120,
)
assert len(profile["points"]) == 120
assert profile["step_m"] > 0
def test_find_nearest_hill_unreachable(monkeypatch):
monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
)
result = elev.find_nearest_hill(55.75, 37.62)
assert result["ok"] is False
def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
def fake_batch(lats, lons):
out = []
for la, lo in zip(lats, lons):
if abs(la - 55.75) < 1e-4 and abs(lo - 37.62) < 1e-4:
out.append(100.0)
elif la > 55.75:
out.append(130.0)
else:
out.append(95.0)
return out
monkeypatch.setattr(elev, "fetch_elevations_batch", fake_batch)
result = elev.find_nearest_hill(55.75, 37.62, radius_m=2000, step_m=300, min_prominence_m=8)
assert result["ok"] is True
assert result["hill"]["elevation_m"] >= 120.0
def test_build_elevation_grid_delta(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
def fake_batch(lats, lons):
return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(lats, lons)]
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 100.0)
monkeypatch.setattr(elev, "fetch_elevations_batch", fake_batch)
result = elev.build_elevation_grid(55.75, 37.62, radius_m=100, step_m=10)
assert result["ok"] is True
assert result["step_m"] == 10
assert len(result["points"]) > 0
assert result["min_delta_m"] <= 0 <= result["max_delta_m"]
assert all("delta_m" in p for p in result["points"])
def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 120.0)
monkeypatch.setattr(
elev,
"fetch_elevations_batch",
lambda lats, lons: [120.0 + i * 0.1 for i in range(len(lats))],
)
result = elev.build_elevation_grid(55.75, 37.62, radius_m=50, step_m=1)
assert result["ok"] is True
assert result["step_m"] == 1
assert len(result["points"]) > 1000
def test_build_elevation_grid_limits_points(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 50.0)
monkeypatch.setattr(
elev,
"fetch_elevations_batch",
lambda lats, lons: [50.0] * len(lats),
)
step = elev._resolve_grid_step(55.75, 37.62, 500.0, 5.0)
cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step)
assert len(cells) <= elev._MAX_GRID_POINTS