commit 83d03537540824fb7749a52f774ead85840b7897 Author: grigo Date: Thu Jun 4 13:05:21 2026 +0300 Initial commit: LoraTester Android + server diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..97f0a8e --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..d843f34 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..416ffc5 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# LoraTester + +Android-клиент и Python-сервер для мониторинга LoRa приёмопередатчика (telnet → парсинг кадров), GPS-позиций, карты и чата между устройствами. + +## Компоненты + +- **Android** (`app/`) — telnet на `127.0.0.1:2727`, AT-команды (AT+H, AT+TX, …), отправка телеметрии на сервер, карта OSMDroid, чат, настройки. +- **Server** (`server/`) — Flask (основной) + FastAPI, веб-карта Leaflet, REST API. См. [server/README.md](server/README.md). + +## Быстрый старт + +1. Запустите сервер: `cd server && pip install -r requirements.txt && python flask_app.py` +2. Соберите APK в Android Studio или `./gradlew assembleDebug` +3. В приложении: Настройки → URL `http://<ваш-сервер>:7634`, включите telnet при наличии моста COM→telnet + +## Тесты + +```bash +./gradlew test +``` + +Симуляция телнет-кадра: вкладка **Статистика** → «Симуляция телнет-кадра». + +AT-команды: вкладка **AT** — быстрые кнопки и произвольная строка (добавляются префикс `AT` и `\r\n`). Нужен включённый telnet в **Настройках**. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..eda6116 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace = "com.grigowashere.loratester" + compileSdk = 35 + + defaultConfig { + applicationId = "com.grigowashere.loratester" + minSdk = 30 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + + implementation(libs.appcompat) + implementation(libs.material) + implementation(libs.activity) + implementation(libs.constraintlayout) + implementation(libs.okhttp) + implementation(libs.gson) + implementation(libs.play.services.location) + implementation(libs.mapsforge.core) + implementation(libs.mapsforge.map) + implementation(libs.mapsforge.map.android) + implementation(libs.mapsforge.map.reader) + implementation(libs.mapsforge.themes) + implementation(libs.viewpager2) + implementation(libs.fragment) + implementation(libs.recyclerview) + testImplementation(libs.junit) + testImplementation(libs.okhttp) + testImplementation(libs.gson) + testImplementation(libs.mockwebserver) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.espresso.core) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/grigowashere/loratester/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/grigowashere/loratester/ExampleInstrumentedTest.java new file mode 100644 index 0000000..abecb41 --- /dev/null +++ b/app/src/androidTest/java/com/grigowashere/loratester/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.grigowashere.loratester; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.grigowashere.loratester", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9b405e0 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/grigowashere/loratester/LoraApp.java b/app/src/main/java/com/grigowashere/loratester/LoraApp.java new file mode 100644 index 0000000..14307d8 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/LoraApp.java @@ -0,0 +1,58 @@ +package com.grigowashere.loratester; + +import android.app.Application; + +import com.grigowashere.loratester.api.ServerApi; +import com.grigowashere.loratester.net.NetworkMonitor; +import com.grigowashere.loratester.track.TrackRecorder; + +import org.mapsforge.map.android.graphics.AndroidGraphicFactory; + +public class LoraApp extends Application { + + private TelemetryUploader telemetryUploader; + private SettingsRepository settingsRepository; + private TrackRecorder trackRecorder; + private NetworkMonitor networkMonitor; + + @Override + public void onCreate() { + super.onCreate(); + AndroidGraphicFactory.createInstance(this); + settingsRepository = new SettingsRepository(this); + networkMonitor = new NetworkMonitor(this); + networkMonitor.start(); + telemetryUploader = new TelemetryUploader(this, settingsRepository, networkMonitor); + trackRecorder = new TrackRecorder( + new ServerApi(settingsRepository.getServerUrl()), + telemetryUploader, + settingsRepository.getOrCreateDeviceId(), + networkMonitor + ); + } + + public NetworkMonitor getNetworkMonitor() { + return networkMonitor; + } + + public TelemetryUploader getTelemetryUploader() { + return telemetryUploader; + } + + public SettingsRepository getSettingsRepository() { + return settingsRepository; + } + + public TrackRecorder getTrackRecorder() { + return trackRecorder; + } + + public void refreshTrackRecorder() { + trackRecorder = new TrackRecorder( + new ServerApi(settingsRepository.getServerUrl()), + telemetryUploader, + settingsRepository.getOrCreateDeviceId(), + networkMonitor + ); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/MainActivity.java b/app/src/main/java/com/grigowashere/loratester/MainActivity.java new file mode 100644 index 0000000..925d59b --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/MainActivity.java @@ -0,0 +1,101 @@ +package com.grigowashere.loratester; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.os.Bundle; + +import androidx.activity.EdgeToEdge; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.viewpager2.widget.ViewPager2; + +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; +import com.grigowashere.loratester.location.LocationTracker; +import com.grigowashere.loratester.track.TrackRecorder; +import com.grigowashere.loratester.ui.MainPagerAdapter; + +public class MainActivity extends AppCompatActivity { + + private TelemetryUploader telemetryUploader; + private LocationTracker locationTracker; + + private final ActivityResultLauncher locationPermissionLauncher = + registerForActivityResult( + new ActivityResultContracts.RequestMultiplePermissions(), + result -> startLocationIfPermitted() + ); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + EdgeToEdge.enable(this); + setContentView(R.layout.activity_main); + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); + return insets; + }); + + LoraApp app = (LoraApp) getApplication(); + telemetryUploader = app.getTelemetryUploader(); + SettingsRepository settings = app.getSettingsRepository(); + + ViewPager2 pager = findViewById(R.id.viewPager); + TabLayout tabs = findViewById(R.id.tabLayout); + pager.setAdapter(new MainPagerAdapter(this)); + new TabLayoutMediator(tabs, pager, (tab, position) -> { + int titleRes = switch (position) { + case 0 -> R.string.tab_map; + case 1 -> R.string.tab_stats; + case 2 -> R.string.tab_at; + case 3 -> R.string.tab_chat; + default -> R.string.tab_settings; + }; + tab.setText(titleRes); + }).attach(); + + TrackRecorder trackRecorder = app.getTrackRecorder(); + locationTracker = new LocationTracker(this, (lat, lon, alt) -> { + telemetryUploader.updateLocation(lat, lon); + trackRecorder.updateLocation(lat, lon, alt); + }); + + requestLocationPermission(); + if (settings.isTelnetEnabled()) { + telemetryUploader.startTelnet(); + } + } + + private void requestLocationPermission() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED) { + startLocationIfPermitted(); + } else { + locationPermissionLauncher.launch(new String[]{ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + }); + } + } + + private void startLocationIfPermitted() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED) { + locationTracker.start(); + } + } + + @Override + protected void onDestroy() { + locationTracker.stop(); + telemetryUploader.stopTelnet(); + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java b/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java new file mode 100644 index 0000000..e4d2c10 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java @@ -0,0 +1,86 @@ +package com.grigowashere.loratester; + +import android.content.Context; +import android.content.SharedPreferences; + +public class SettingsRepository { + + private static final String PREFS = "loratester_settings"; + private static final String KEY_SERVER_URL = "server_url"; + private static final String KEY_TELNET_HOST = "telnet_host"; + private static final String KEY_TELNET_PORT = "telnet_port"; + private static final String KEY_RSSI_REGEX = "rssi_regex"; + private static final String KEY_RANGE_REGEX = "range_regex"; + private static final String KEY_TELNET_ENABLED = "telnet_enabled"; + private static final String KEY_DEVICE_ID = "device_id"; + + public static final String DEFAULT_SERVER = "http://grigowashere.ru:7634"; + public static final String DEFAULT_TELNET_HOST = "127.0.0.1"; + 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_RANGE_REGEX = "range[:\\s]*([\\d.]+)"; + + private final SharedPreferences prefs; + + public SettingsRepository(Context context) { + prefs = context.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + public String getServerUrl() { + return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER); + } + + public void setServerUrl(String url) { + prefs.edit().putString(KEY_SERVER_URL, url).apply(); + } + + public String getTelnetHost() { + return prefs.getString(KEY_TELNET_HOST, DEFAULT_TELNET_HOST); + } + + public void setTelnetHost(String host) { + prefs.edit().putString(KEY_TELNET_HOST, host).apply(); + } + + public int getTelnetPort() { + return prefs.getInt(KEY_TELNET_PORT, DEFAULT_TELNET_PORT); + } + + public void setTelnetPort(int port) { + prefs.edit().putInt(KEY_TELNET_PORT, port).apply(); + } + + public String getRssiRegex() { + return prefs.getString(KEY_RSSI_REGEX, DEFAULT_RSSI_REGEX); + } + + public void setRssiRegex(String regex) { + prefs.edit().putString(KEY_RSSI_REGEX, regex).apply(); + } + + public String getRangeRegex() { + return prefs.getString(KEY_RANGE_REGEX, DEFAULT_RANGE_REGEX); + } + + public void setRangeRegex(String regex) { + prefs.edit().putString(KEY_RANGE_REGEX, regex).apply(); + } + + public boolean isTelnetEnabled() { + return prefs.getBoolean(KEY_TELNET_ENABLED, false); + } + + public void setTelnetEnabled(boolean enabled) { + prefs.edit().putBoolean(KEY_TELNET_ENABLED, enabled).apply(); + } + + public String getOrCreateDeviceId() { + String id = prefs.getString(KEY_DEVICE_ID, null); + if (id == null || id.isEmpty()) { + id = "android-" + java.util.UUID.randomUUID().toString().substring(0, 8); + prefs.edit().putString(KEY_DEVICE_ID, id).apply(); + } + return id; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java new file mode 100644 index 0000000..786665b --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java @@ -0,0 +1,337 @@ +package com.grigowashere.loratester; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import com.grigowashere.loratester.api.ServerApi; +import com.grigowashere.loratester.api.TelemetryPayload; +import com.grigowashere.loratester.api.UploadQueue; +import com.grigowashere.loratester.net.NetworkMonitor; +import com.grigowashere.loratester.location.GeoUtils; +import com.grigowashere.loratester.telnet.AtCommandFormatter; +import com.grigowashere.loratester.telnet.StatsExtractor; +import com.grigowashere.loratester.telnet.TelnetClient; +import com.grigowashere.loratester.telnet.TelnetFrameParser; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public class TelemetryUploader implements TelnetClient.Listener { + + private static final String TAG = "TelemetryUploader"; + + public interface AtSendCallback { + void onResult(TelnetClient.SendResult result); + } + + public interface StatsListener { + void onStatsUpdated(StatsExtractor.ExtractedStats stats); + } + + private final SettingsRepository settings; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final ExecutorService uploadExecutor = Executors.newSingleThreadExecutor(); + private final ExecutorService telnetExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "TelnetWorker"); + t.setDaemon(true); + return t; + }); + private TelnetClient telnetClient; + private TelnetFrameParser frameParser; + private StatsExtractor statsExtractor; + private ServerApi serverApi; + + private static final int CONSOLE_MAX_CHARS = 16_384; + private static final long SNAPSHOT_INTERVAL_MS = 1000; + + private final ScheduledExecutorService snapshotScheduler = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "TelnetSnapshot"); + t.setDaemon(true); + return t; + }); + private volatile ScheduledFuture snapshotFuture; + + private volatile double lat = Double.NaN; + private volatile double lon = Double.NaN; + private volatile boolean connected; + private final StringBuilder consoleLog = new StringBuilder(); + private volatile StatsExtractor.ExtractedStats lastStats; + private volatile long lastStatsAtMs; + private StatsListener statsListener; + private final UploadQueue uploadQueue; + private final NetworkMonitor networkMonitor; + + public TelemetryUploader( + Context context, + SettingsRepository settings, + NetworkMonitor networkMonitor + ) { + this.settings = settings; + this.networkMonitor = networkMonitor; + uploadQueue = new UploadQueue(context.getApplicationContext()); + serverApi = new ServerApi(settings.getServerUrl()); + statsExtractor = StatsExtractor.withDefaults(); + frameParser = new TelnetFrameParser(this::onFrame); + networkMonitor.addListener(online -> { + if (online) { + flushUploadQueue(); + } + }); + if (networkMonitor.isOnline()) { + uploadExecutor.execute(this::flushUploadQueue); + } + } + + public int getPendingUploadCount() { + return uploadQueue.size(); + } + + public void refreshApi() { + serverApi = new ServerApi(settings.getServerUrl()); + telnetExecutor.execute(() -> { + statsExtractor = new StatsExtractor( + settings.getRssiRegex(), + settings.getRangeRegex() + ); + frameParser = new TelnetFrameParser(TelemetryUploader.this::onFrame); + }); + } + + public void updateLocation(double lat, double lon) { + if (GeoUtils.isValidCoordinate(lat, lon)) { + this.lat = lat; + this.lon = lon; + } + } + + private Double validLat() { + return GeoUtils.isValidCoordinate(lat, lon) ? lat : null; + } + + private Double validLon() { + return GeoUtils.isValidCoordinate(lat, lon) ? lon : null; + } + + public void startTelnet() { + serverApi = new ServerApi(settings.getServerUrl()); + telnetExecutor.execute(() -> { + if (telnetClient != null) { + telnetClient.stop(); + telnetClient = null; + } + statsExtractor = new StatsExtractor( + settings.getRssiRegex(), + settings.getRangeRegex() + ); + frameParser = new TelnetFrameParser(TelemetryUploader.this::onFrame); + telnetClient = new TelnetClient( + settings.getTelnetHost(), + settings.getTelnetPort(), + TelemetryUploader.this + ); + telnetClient.start(); + }); + } + + public void stopTelnet() { + stopSnapshotTicker(); + telnetExecutor.execute(() -> { + if (telnetClient != null) { + telnetClient.stop(); + telnetClient = null; + } + if (frameParser != null) { + frameParser.flush(); + } + }); + } + + public boolean isTelnetConnected() { + return connected; + } + + /** Sends AT command on telnet worker thread (safe while receiving data). */ + public void sendAtCommand(String command, AtSendCallback callback) { + telnetExecutor.execute(() -> { + TelnetClient.SendResult result = sendAtCommandOnWorker(command); + if (callback != null) { + mainHandler.post(() -> callback.onResult(result)); + } + }); + } + + private TelnetClient.SendResult sendAtCommandOnWorker(String command) { + String normalized = AtCommandFormatter.normalize(command); + appendConsole(">> " + normalized + "\n"); + if (telnetClient == null) { + appendConsole("!! telnet not started\n"); + return TelnetClient.SendResult.NOT_CONNECTED; + } + TelnetClient.SendResult result = telnetClient.sendAtCommand(command); + if (result != TelnetClient.SendResult.SENT) { + appendConsole("!! send failed: " + result + "\n"); + } + return result; + } + + public synchronized String getConsoleLog() { + return consoleLog.toString(); + } + + public synchronized void clearConsoleLog() { + consoleLog.setLength(0); + } + + private synchronized void appendConsole(String text) { + consoleLog.append(text); + if (consoleLog.length() > CONSOLE_MAX_CHARS) { + consoleLog.delete(0, consoleLog.length() - CONSOLE_MAX_CHARS); + } + } + + public void setStatsListener(StatsListener listener) { + this.statsListener = listener; + if (listener != null && lastStats != null) { + mainHandler.post(() -> listener.onStatsUpdated(lastStats)); + } + } + + public StatsExtractor.ExtractedStats getLastStats() { + return lastStats; + } + + public long getLastStatsAtMs() { + return lastStatsAtMs; + } + + private void onFrame(String frame) { + StatsExtractor.ExtractedStats stats = statsExtractor.extract(frame); + if (!stats.hasRadioFrame()) { + return; + } + lastStats = stats; + lastStatsAtMs = System.currentTimeMillis(); + StatsListener listener = statsListener; + if (listener != null) { + mainHandler.post(() -> listener.onStatsUpdated(stats)); + } + TelemetryPayload payload = new TelemetryPayload( + settings.getOrCreateDeviceId(), + validLat(), + validLon(), + stats.rssi, + stats.rangeM, + null, + stats.metaJson, + stats.role, + System.currentTimeMillis() / 1000.0 + ); + uploadExecutor.execute(() -> uploadTelemetry(payload)); + } + + private void uploadTelemetry(TelemetryPayload payload) { + if (networkMonitor.isOnline()) { + try { + serverApi.postTelemetry(payload); + flushUploadQueue(); + return; + } catch (Exception e) { + Log.e(TAG, "upload failed", e); + appendConsole("!! server upload: " + e.getMessage() + "\n"); + } + } + uploadQueue.enqueue(payload); + int pending = uploadQueue.size(); + if (pending > 0 && pending % 10 == 0) { + appendConsole("!! queued uploads: " + pending + "\n"); + } + } + + private void flushUploadQueue() { + if (!networkMonitor.isOnline()) { + return; + } + int sent = uploadQueue.flushAll(serverApi); + if (sent > 0) { + appendConsole(">> uploaded " + sent + " queued frame(s)\n"); + } + } + + @Override + public void onConnected() { + mainHandler.post(() -> connected = true); + startSnapshotTicker(); + } + + @Override + public void onDisconnected() { + stopSnapshotTicker(); + mainHandler.post(() -> connected = false); + } + + private void startSnapshotTicker() { + stopSnapshotTicker(); + snapshotFuture = snapshotScheduler.scheduleAtFixedRate( + () -> telnetExecutor.execute(this::tickSnapshot), + SNAPSHOT_INTERVAL_MS, + SNAPSHOT_INTERVAL_MS, + TimeUnit.MILLISECONDS + ); + } + + private void stopSnapshotTicker() { + if (snapshotFuture != null) { + snapshotFuture.cancel(false); + snapshotFuture = null; + } + } + + private void tickSnapshot() { + if (frameParser != null && connected) { + frameParser.emitSnapshot(); + } + } + + @Override + public void onBytes(byte[] data, int length) { + byte[] copy = Arrays.copyOf(data, length); + telnetExecutor.execute(() -> { + appendConsole(new String(copy, StandardCharsets.UTF_8)); + if (frameParser != null) { + frameParser.append(copy); + } + }); + } + + @Override + public void onError(String message) { + Log.w(TAG, "telnet: " + message); + telnetExecutor.execute(() -> appendConsole("!! " + message + "\n")); + } + + /** Feeds simulated telnet stream for testing without hardware. */ + public void simulateChunk(String text) { + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + telnetExecutor.execute(() -> { + if (frameParser != null) { + frameParser.append(bytes); + } + }); + } + + public ServerApi getServerApi() { + return serverApi; + } + + public String getDeviceId() { + return settings.getOrCreateDeviceId(); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/ChatMessage.java b/app/src/main/java/com/grigowashere/loratester/api/ChatMessage.java new file mode 100644 index 0000000..ef7ef60 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/ChatMessage.java @@ -0,0 +1,8 @@ +package com.grigowashere.loratester.api; + +public class ChatMessage { + public long id; + public String device_id; + public String text; + public double ts; +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/DeviceInfo.java b/app/src/main/java/com/grigowashere/loratester/api/DeviceInfo.java new file mode 100644 index 0000000..19ac7d5 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/DeviceInfo.java @@ -0,0 +1,15 @@ +package com.grigowashere.loratester.api; + +public class DeviceInfo { + public String device_id; + public double last_seen; + public Double lat; + public Double lon; + public Double rssi; + public Double range_m; + public String raw_frame; + public String meta; + /** TX or RX from last telnet frame. */ + public String role; + public Double ts; +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java new file mode 100644 index 0000000..6cec023 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java @@ -0,0 +1,182 @@ +package com.grigowashere.loratester.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.reflect.TypeToken; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class ServerApi { + + public static final String HEADER_LORA_CLIENT = "X-Lora-Client"; + public static final String CLIENT_ANDROID = "android"; + + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private static final Gson GSON = new Gson(); + private static final Type DEVICE_LIST = new TypeToken>() {}.getType(); + private static final Type CHAT_LIST = new TypeToken>() {}.getType(); + private static final Type TELEMETRY_HISTORY = + new TypeToken>() {}.getType(); + private static final Type TRACK_LIST = new TypeToken>() {}.getType(); + + private final String baseUrl; + private final OkHttpClient client; + + public ServerApi(String baseUrl) { + String url = baseUrl == null ? "" : baseUrl.trim(); + while (url.endsWith("/")) { + url = url.substring(0, url.length() - 1); + } + this.baseUrl = url; + this.client = new OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build(); + } + + public void postTelemetry(TelemetryPayload payload) throws IOException { + Map body = new HashMap<>(); + body.put("device_id", payload.deviceId); + if (payload.lat != null) body.put("lat", payload.lat); + if (payload.lon != null) body.put("lon", payload.lon); + if (payload.rssi != null) body.put("rssi", payload.rssi); + if (payload.rangeM != null) body.put("range_m", payload.rangeM); + if (payload.meta != null) { + try { + JsonObject meta = JsonParser.parseString(payload.meta).getAsJsonObject(); + body.put("meta", GSON.fromJson(meta, Map.class)); + if (meta.has("fields") && meta.get("fields").isJsonObject()) { + body.put("fields", GSON.fromJson(meta.get("fields"), Map.class)); + } + } catch (Exception ignored) { + body.put("meta", payload.meta); + } + } + if (payload.role != null) body.put("role", payload.role); + if (payload.ts != null) body.put("ts", payload.ts); + postJson("/api/telemetry", body, true); + } + + public List getDevices() throws IOException { + return getJsonList("/api/devices", DEVICE_LIST); + } + + public void postChat(String deviceId, String text) throws IOException { + Map body = new HashMap<>(); + body.put("device_id", deviceId); + body.put("text", text); + postJson("/api/chat", body); + } + + public List getChat(double since) throws IOException { + String path = "/api/chat?since=" + since; + return getJsonList(path, CHAT_LIST); + } + + public List getTelemetryHistory(String deviceId, int limit) + throws IOException { + String path = "/api/telemetry?device_id=" + deviceId + "&limit=" + limit; + return getJsonList(path, TELEMETRY_HISTORY); + } + + public long startTrack(String deviceId) throws IOException { + Map body = new HashMap<>(); + body.put("device_id", deviceId); + Map resp = postJsonMap("/api/tracks/start", body, true); + Number id = (Number) resp.get("track_id"); + return id.longValue(); + } + + public void addTrackPoints(long trackId, List> points) throws IOException { + Map body = new HashMap<>(); + body.put("points", points); + postJson("/api/tracks/" + trackId + "/points", body, true); + } + + public void finishTrack(long trackId) throws IOException { + postJson("/api/tracks/" + trackId + "/finish", new HashMap<>(), true); + } + + public List listTracks(String deviceId) throws IOException { + return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST); + } + + public TrackDetail getTrack(long trackId) throws IOException { + Request request = new Request.Builder() + .url(baseUrl + "/api/tracks/" + trackId) + .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(), TrackDetail.class); + } + } + + @SuppressWarnings("unchecked") + private Map postJsonMap(String path, Map body, boolean android) + throws IOException { + Request.Builder builder = new Request.Builder() + .url(baseUrl + path) + .post(RequestBody.create(GSON.toJson(body), JSON)); + if (android) { + builder.header(HEADER_LORA_CLIENT, CLIENT_ANDROID); + } + try (Response response = client.newCall(builder.build()).execute()) { + if (!response.isSuccessful() || response.body() == null) { + throw new IOException("HTTP " + response.code()); + } + return GSON.fromJson(response.body().string(), Map.class); + } + } + + private void postJson(String path, Map body) throws IOException { + postJson(path, body, false); + } + + private void postJson(String path, Map body, boolean androidClient) + throws IOException { + Request.Builder builder = new Request.Builder() + .url(baseUrl + path) + .post(RequestBody.create(GSON.toJson(body), JSON)); + if (androidClient) { + builder.header(HEADER_LORA_CLIENT, CLIENT_ANDROID); + } + execute(builder.build()); + } + + private T getJsonList(String path, Type type) throws IOException { + 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(), type); + } + } + + private void execute(Request request) throws IOException { + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("HTTP " + response.code()); + } + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/TelemetryHistoryItem.java b/app/src/main/java/com/grigowashere/loratester/api/TelemetryHistoryItem.java new file mode 100644 index 0000000..fb02de5 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/TelemetryHistoryItem.java @@ -0,0 +1,14 @@ +package com.grigowashere.loratester.api; + +public class TelemetryHistoryItem { + public long id; + public String device_id; + public Double lat; + public Double lon; + public Double rssi; + public Double range_m; + public String meta; + public String role; + public double ts; + public String source; +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/TelemetryPayload.java b/app/src/main/java/com/grigowashere/loratester/api/TelemetryPayload.java new file mode 100644 index 0000000..66fa1cd --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/TelemetryPayload.java @@ -0,0 +1,36 @@ +package com.grigowashere.loratester.api; + +public class TelemetryPayload { + public final String deviceId; + public final Double lat; + public final Double lon; + public final Double rssi; + public final Double rangeM; + public final String rawFrame; + public final String meta; + /** TX = передатчик, RX = приёмник. */ + public final String role; + public final Double ts; + + public TelemetryPayload( + String deviceId, + Double lat, + Double lon, + Double rssi, + Double rangeM, + String rawFrame, + String meta, + String role, + Double ts + ) { + this.deviceId = deviceId; + this.lat = lat; + this.lon = lon; + this.rssi = rssi; + this.rangeM = rangeM; + this.rawFrame = rawFrame; + this.meta = meta; + this.role = role; + this.ts = ts; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/TrackDetail.java b/app/src/main/java/com/grigowashere/loratester/api/TrackDetail.java new file mode 100644 index 0000000..8bde4ca --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/TrackDetail.java @@ -0,0 +1,23 @@ +package com.grigowashere.loratester.api; + +import java.util.List; + +public class TrackDetail { + public long id; + public String device_id; + public double started_at; + public Double ended_at; + public String label; + public List points; + + public static class TrackPoint { + public double ts; + public double lat; + public double lon; + public Double altitude_gps; + public Double elevation_m; + public Double rssi; + public String role; + public String meta; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java b/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java new file mode 100644 index 0000000..46e9ba7 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java @@ -0,0 +1,10 @@ +package com.grigowashere.loratester.api; + +public class TrackInfo { + public long id; + public String device_id; + public double started_at; + public Double ended_at; + public String label; + public int point_count; +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/UploadQueue.java b/app/src/main/java/com/grigowashere/loratester/api/UploadQueue.java new file mode 100644 index 0000000..6928269 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/UploadQueue.java @@ -0,0 +1,152 @@ +package com.grigowashere.loratester.api; + +import android.content.Context; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; + +/** Persistent queue for telemetry uploads when the network is down. */ +public class UploadQueue { + + private static final String TAG = "UploadQueue"; + private static final String FILE_NAME = "telemetry_upload_queue.json"; + private static final int MAX_ITEMS = 500; + + private static final Gson GSON = new Gson(); + private static final Type LIST_TYPE = new TypeToken>() {}.getType(); + + private final File queueFile; + private final List items = new ArrayList<>(); + private String lastMeta; + + public UploadQueue(Context context) { + queueFile = new File(context.getFilesDir(), FILE_NAME); + load(); + } + + public synchronized int size() { + return items.size(); + } + + public synchronized void enqueue(TelemetryPayload payload) { + if (payload == null) { + return; + } + if (payload.meta != null && payload.meta.equals(lastMeta)) { + return; + } + lastMeta = payload.meta; + items.add(QueuedItem.from(payload)); + trim(); + persist(); + } + + public synchronized int flushAll(ServerApi api) { + if (api == null || items.isEmpty()) { + return 0; + } + int sent = 0; + Iterator it = items.iterator(); + while (it.hasNext()) { + QueuedItem item = it.next(); + try { + api.postTelemetry(item.toPayload()); + it.remove(); + sent++; + } catch (Exception e) { + Log.w(TAG, "flush stopped at " + sent + " sent", e); + break; + } + } + if (sent > 0) { + persist(); + } + return sent; + } + + private void trim() { + while (items.size() > MAX_ITEMS) { + items.remove(0); + } + } + + private void load() { + if (!queueFile.exists()) { + return; + } + try (FileReader reader = new FileReader(queueFile)) { + List loaded = GSON.fromJson(reader, LIST_TYPE); + if (loaded != null) { + items.clear(); + items.addAll(loaded); + trim(); + } + } catch (Exception e) { + Log.w(TAG, "load queue failed", e); + } + } + + private void persist() { + try (FileWriter writer = new FileWriter(queueFile)) { + GSON.toJson(items, writer); + } catch (Exception e) { + Log.w(TAG, "persist queue failed", e); + } + } + + static final class QueuedItem { + String deviceId; + Double lat; + Double lon; + Double rssi; + Double rangeM; + String rawFrame; + String meta; + String role; + Double ts; + + static QueuedItem from(TelemetryPayload p) { + QueuedItem q = new QueuedItem(); + q.deviceId = p.deviceId; + q.lat = p.lat; + q.lon = p.lon; + q.rssi = p.rssi; + q.rangeM = p.rangeM; + q.rawFrame = p.rawFrame; + q.meta = p.meta; + q.role = p.role; + q.ts = p.ts; + return q; + } + + TelemetryPayload toPayload() { + return new TelemetryPayload( + deviceId, lat, lon, rssi, rangeM, rawFrame, meta, role, ts); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof QueuedItem other)) { + return false; + } + return Objects.equals(deviceId, other.deviceId) + && Objects.equals(meta, other.meta) + && Objects.equals(ts, other.ts); + } + + @Override + public int hashCode() { + return Objects.hash(deviceId, meta, ts); + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/location/GeoUtils.java b/app/src/main/java/com/grigowashere/loratester/location/GeoUtils.java new file mode 100644 index 0000000..da8ee09 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/location/GeoUtils.java @@ -0,0 +1,23 @@ +package com.grigowashere.loratester.location; + +public final class GeoUtils { + + private static final double ZERO_EPS = 1e-5; + + private GeoUtils() { + } + + public static boolean isValidCoordinate(double lat, double lon) { + if (Double.isNaN(lat) || Double.isNaN(lon) || Double.isInfinite(lat) || Double.isInfinite(lon)) { + return false; + } + if (Math.abs(lat) < ZERO_EPS && Math.abs(lon) < ZERO_EPS) { + return false; + } + return lat >= -90.0 && lat <= 90.0 && lon >= -180.0 && lon <= 180.0; + } + + public static boolean isValidCoordinate(Double lat, Double lon) { + return lat != null && lon != null && isValidCoordinate(lat.doubleValue(), lon.doubleValue()); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java b/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java new file mode 100644 index 0000000..0d135ca --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java @@ -0,0 +1,61 @@ +package com.grigowashere.loratester.location; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Looper; + +import com.google.android.gms.location.FusedLocationProviderClient; +import com.google.android.gms.location.LocationCallback; +import com.google.android.gms.location.LocationRequest; +import com.google.android.gms.location.LocationResult; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.location.Priority; + +public class LocationTracker { + + public interface Listener { + void onLocation(double lat, double lon, double altitude); + } + + private final FusedLocationProviderClient client; + private final Listener listener; + private LocationCallback callback; + + public LocationTracker(Context context, Listener listener) { + this.client = LocationServices.getFusedLocationProviderClient(context); + this.listener = listener; + } + + @SuppressLint("MissingPermission") + public void start() { + if (callback != null) { + return; + } + LocationRequest request = new LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, 10_000L + ).setMinUpdateIntervalMillis(5_000L).build(); + + callback = new LocationCallback() { + @Override + public void onLocationResult(LocationResult result) { + if (result.getLastLocation() == null) { + return; + } + double lat = result.getLastLocation().getLatitude(); + double lon = result.getLastLocation().getLongitude(); + double alt = result.getLastLocation().getAltitude(); + if (GeoUtils.isValidCoordinate(lat, lon)) { + listener.onLocation(lat, lon, alt); + } + } + }; + client.requestLocationUpdates(request, callback, Looper.getMainLooper()); + } + + public void stop() { + if (callback != null) { + client.removeLocationUpdates(callback); + callback = null; + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/net/NetworkMonitor.java b/app/src/main/java/com/grigowashere/loratester/net/NetworkMonitor.java new file mode 100644 index 0000000..75de073 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/net/NetworkMonitor.java @@ -0,0 +1,114 @@ +package com.grigowashere.loratester.net; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.os.Build; + +import androidx.annotation.NonNull; + +import java.util.concurrent.CopyOnWriteArrayList; + +/** Observes validated internet connectivity. */ +public class NetworkMonitor { + + public interface Listener { + void onConnectivityChanged(boolean online); + } + + private final ConnectivityManager connectivityManager; + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private volatile boolean online; + private ConnectivityManager.NetworkCallback networkCallback; + + public NetworkMonitor(@NonNull Context context) { + connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + online = checkOnline(); + } + + public boolean isOnline() { + return online; + } + + public void addListener(@NonNull Listener listener) { + listeners.add(listener); + listener.onConnectivityChanged(online); + } + + public void removeListener(@NonNull Listener listener) { + listeners.remove(listener); + } + + public void start() { + if (networkCallback != null || connectivityManager == null) { + return; + } + networkCallback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(@NonNull Network network) { + updateState(); + } + + @Override + public void onLost(@NonNull Network network) { + updateState(); + } + + @Override + public void onCapabilitiesChanged( + @NonNull Network network, + @NonNull NetworkCapabilities caps + ) { + updateState(); + } + }; + NetworkRequest request = new NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + connectivityManager.registerDefaultNetworkCallback(networkCallback); + } else { + connectivityManager.registerNetworkCallback(request, networkCallback); + } + updateState(); + } + + public void stop() { + if (networkCallback != null && connectivityManager != null) { + try { + connectivityManager.unregisterNetworkCallback(networkCallback); + } catch (Exception ignored) { + // ignore + } + networkCallback = null; + } + } + + private void updateState() { + boolean now = checkOnline(); + if (now == online) { + return; + } + online = now; + for (Listener l : listeners) { + l.onConnectivityChanged(online); + } + } + + private boolean checkOnline() { + if (connectivityManager == null) { + return false; + } + Network network = connectivityManager.getActiveNetwork(); + if (network == null) { + return false; + } + NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(network); + return caps != null + && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/telnet/AtCommandFormatter.java b/app/src/main/java/com/grigowashere/loratester/telnet/AtCommandFormatter.java new file mode 100644 index 0000000..0ba6c44 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/telnet/AtCommandFormatter.java @@ -0,0 +1,33 @@ +package com.grigowashere.loratester.telnet; + +import java.nio.charset.StandardCharsets; + +/** Formats user input as an AT command line for serial/telnet bridges. */ +public final class AtCommandFormatter { + + private AtCommandFormatter() { + } + + public static String normalize(String input) { + if (input == null) { + return ""; + } + String line = input.trim(); + if (line.isEmpty()) { + return ""; + } + if (!line.regionMatches(true, 0, "AT", 0, 2)) { + line = "AT" + line; + } + return line; + } + + public static byte[] toWireBytes(String input) { + String line = normalize(input); + if (line.isEmpty()) { + return new byte[0]; + } + String wire = line + "\r\n"; + return wire.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/telnet/AtCommands.java b/app/src/main/java/com/grigowashere/loratester/telnet/AtCommands.java new file mode 100644 index 0000000..6916ad8 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/telnet/AtCommands.java @@ -0,0 +1,15 @@ +package com.grigowashere.loratester.telnet; + +/** Common AT commands for LoRa modules (via telnet bridge). */ +public final class AtCommands { + + public static final String HELP = "AT+H"; + public static final String TRANSMIT = "AT+TX"; + public static final String RECEIVE = "AT+RX"; + public static final String STATUS = "AT+STATUS"; + public static final String RESET = "AT+RESET"; + public static final String BASIC = "AT"; + + private AtCommands() { + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/telnet/LoraStatsFormatter.java b/app/src/main/java/com/grigowashere/loratester/telnet/LoraStatsFormatter.java new file mode 100644 index 0000000..cf7f05c --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/telnet/LoraStatsFormatter.java @@ -0,0 +1,129 @@ +package com.grigowashere.loratester.telnet; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class LoraStatsFormatter { + + private LoraStatsFormatter() { + } + + /** Human-readable lines from telemetry meta JSON (fields first). */ + public static String formatMeta(String metaJson) { + if (metaJson == null || metaJson.isEmpty()) { + return ""; + } + try { + JsonObject o = JsonParser.parseString(metaJson).getAsJsonObject(); + StringBuilder sb = new StringBuilder(); + Set shown = new HashSet<>(); + + appendFieldsBlock(sb, o.get("fields"), shown); + + String role = text(o, "role"); + if (role != null) { + append(sb, "Роль", roleLabel(role)); + } + 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(); + } catch (Exception ignored) { + return metaJson; + } + } + + private static void appendFieldsBlock(StringBuilder sb, JsonElement fieldsEl, Set shown) { + if (fieldsEl == null || !fieldsEl.isJsonObject()) { + return; + } + JsonObject fields = fieldsEl.getAsJsonObject(); + for (Map.Entry e : fields.entrySet()) { + String label = e.getKey(); + if (isSkippedFieldLabel(label)) { + continue; + } + String norm = normalizeLabel(label); + if (shown.contains(norm)) { + continue; + } + shown.add(norm); + append(sb, label, e.getValue().getAsString()); + } + } + + private static String normalizeLabel(String label) { + return label.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ").trim(); + } + + private static boolean isSkippedFieldLabel(String label) { + String l = normalizeLabel(label); + return l.equals("send") || l.equals("receive"); + } + + public static String roleLabel(String role) { + if (StatsExtractor.ROLE_TX.equals(role)) { + return "Передатчик (TX)"; + } + if (StatsExtractor.ROLE_RX.equals(role)) { + return "Приёмник (RX)"; + } + return role; + } + + private static String freqMhz(JsonObject o) { + if (!o.has("frequency_hz")) { + return null; + } + long hz = o.get("frequency_hz").getAsLong(); + return String.format(Locale.US, "%.3f", hz / 1_000_000.0); + } + + private static void append(StringBuilder sb, String label, String value) { + if (value == null || value.isEmpty()) { + return; + } + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append(label).append(": ").append(value); + } + + private static void append(StringBuilder sb, String label, String value, String suffix) { + if (value == null) { + return; + } + append(sb, label, value + suffix); + } + + private static String text(JsonObject o, String key) { + JsonElement e = o.get(key); + return e != null && !e.isJsonNull() ? e.getAsString() : null; + } + + private static String integer(JsonObject o, String key) { + JsonElement e = o.get(key); + return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsInt()) : null; + } + + private static String dbl(JsonObject o, String key) { + JsonElement e = o.get(key); + return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsDouble()) : null; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java b/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java new file mode 100644 index 0000000..6cd1c35 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java @@ -0,0 +1,318 @@ +package com.grigowashere.loratester.telnet; + +import com.google.gson.Gson; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Label-based regex parsing — order and count of fields may change on the device. + */ +public class StatsExtractor { + + public static final String ROLE_TX = "TX"; + public static final String ROLE_RX = "RX"; + + private static final Gson GSON = new Gson(); + + private static final Pattern FRAME_SEND = Pattern.compile("(?m)^\\s*SEND\\b"); + private static final Pattern FRAME_RECEIVE = Pattern.compile("(?m)^\\s*RECEIVE\\b"); + private static final Pattern POWER = Pattern.compile("Power\\s*:\\s*(\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE); + private static final Pattern RSSI = Pattern.compile("RSSI\\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 SPREADING = Pattern.compile("Spreading Factor\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PACKET = Pattern.compile("Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PACKET_NUMBER = Pattern.compile("Packet Number\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PAYLOAD = Pattern.compile("Payload\\s*:\\s*(.+)", Pattern.CASE_INSENSITIVE); + private static final Pattern ON_AIR = Pattern.compile("On Air\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); + 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 final Pattern rssiPattern; + private final Pattern rangePattern; + + public StatsExtractor(String rssiRegex, String rangeRegex) { + this.rssiPattern = Pattern.compile(rssiRegex, Pattern.CASE_INSENSITIVE); + this.rangePattern = Pattern.compile(rangeRegex, Pattern.CASE_INSENSITIVE); + } + + public static StatsExtractor withDefaults() { + return new StatsExtractor( + "RSSI\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", + "range\\s*:\\s*([\\d.]+)" + ); + } + + public ExtractedStats extract(String frame) { + if (frame == null || frame.isBlank()) { + return empty(frame); + } + + String normalized = TelnetText.normalize(frame); + + Map meta = new LinkedHashMap<>(); + Map fields = new LinkedHashMap<>(); + collectLabeledLines(normalized, fields); + + String frameType = detectFrameType(normalized); + String role = frameTypeToRole(frameType); + if (frameType != null) { + meta.put("frame", frameType); + } + if (role != null) { + meta.put("role", role); + } + if (!fields.isEmpty()) { + meta.put("fields", fields); + } + + Double rssiDbm = firstDouble(RSSI, normalized); + if (rssiDbm == null) { + rssiDbm = matchDouble(rssiPattern, normalized); + } + Double txPower = matchDouble(POWER, normalized); + Double snrDb = matchDouble(SNR, normalized); + + if (rssiDbm != null) { + meta.put("rssi_dbm", rssiDbm); + } + if (txPower != null) { + meta.put("power_dbm", txPower); + } + if (snrDb != null) { + meta.put("snr_db", snrDb); + } + + putLong(meta, "frequency_hz", matchLong(FREQUENCY, normalized)); + putInt(meta, "spreading_factor", matchInt(SPREADING, normalized)); + putInt(meta, "bandwidth_khz", matchInt(BANDWIDTH, normalized)); + Integer packet = matchInt(PACKET_NUMBER, normalized); + if (packet == null) { + packet = matchInt(PACKET, normalized); + } + putInt(meta, "packet", packet); + putString(meta, "payload", matchString(PAYLOAD, normalized)); + putDouble(meta, "on_air_ms", matchDouble(ON_AIR, normalized)); + putDouble(meta, "tx_pkt_per_s", matchDouble(TX_SPEED, normalized)); + putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized)); + putDouble(meta, "per_percent", matchDouble(PER, normalized)); + + enrichFieldsFromStructured(meta, fields); + + Double rangeM = matchDouble(rangePattern, normalized); + Double displayDbm = rssiDbm != null ? rssiDbm : txPower; + + String metaJson = meta.isEmpty() ? null : GSON.toJson(meta); + return new ExtractedStats( + displayDbm, rangeM, frame, metaJson, frameType, role, txPower, rssiDbm, snrDb + ); + } + + private static void collectLabeledLines(String frame, Map fields) { + for (String line : frame.split("\n")) { + String trimmed = line.trim(); + if (trimmed.isEmpty()) { + continue; + } + int colon = trimmed.indexOf(':'); + if (colon <= 0) { + continue; + } + String label = trimmed.substring(0, colon).trim(); + String value = trimmed.substring(colon + 1).trim(); + if (label.isEmpty() + || label.equalsIgnoreCase("SEND") + || label.equalsIgnoreCase("RECEIVE")) { + continue; + } + fields.put(label, value); + } + } + + /** Ensure meta.fields has display lines even when line split missed some rows. */ + private static void enrichFieldsFromStructured( + Map meta, + Map fields + ) { + putFieldIfAbsent(fields, "Frequency", meta.get("frequency_hz"), + v -> v + " Hz"); + putFieldIfAbsent(fields, "Power", meta.get("power_dbm"), + v -> v + " dBm"); + putFieldIfAbsent(fields, "RSSI", meta.get("rssi_dbm"), + v -> String.valueOf(v)); + 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 fields, + String label, + Object value, + java.util.function.Function format + ) { + if (value == null || fields.containsKey(label)) { + return; + } + fields.put(label, format.apply(value)); + } + + private static ExtractedStats empty(String frame) { + return new ExtractedStats(null, null, frame, null, null, null, null, null, null); + } + + private static String detectFrameType(String frame) { + boolean send = FRAME_SEND.matcher(frame).find(); + boolean recv = FRAME_RECEIVE.matcher(frame).find(); + if (send && !recv) { + return "SEND"; + } + if (recv && !send) { + return "RECEIVE"; + } + if (send) { + return "SEND"; + } + if (recv) { + return "RECEIVE"; + } + return null; + } + + private static String frameTypeToRole(String frameType) { + if ("SEND".equals(frameType)) { + return ROLE_TX; + } + if ("RECEIVE".equals(frameType)) { + return ROLE_RX; + } + return null; + } + + private static Double firstDouble(Pattern pattern, String text) { + return matchDouble(pattern, text); + } + + private static void putInt(Map meta, String key, Integer value) { + if (value != null) { + meta.put(key, value); + } + } + + private static void putLong(Map meta, String key, Long value) { + if (value != null) { + meta.put(key, value); + } + } + + private static void putDouble(Map meta, String key, Double value) { + if (value != null) { + meta.put(key, value); + } + } + + private static void putString(Map meta, String key, String value) { + if (value != null && !value.isEmpty()) { + meta.put(key, value.trim()); + } + } + + private static Double matchDouble(Pattern pattern, String text) { + Matcher m = pattern.matcher(text); + if (m.find()) { + try { + return Double.parseDouble(m.group(1).trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + private static Integer matchInt(Pattern pattern, String text) { + Long v = matchLong(pattern, text); + return v != null ? v.intValue() : null; + } + + private static Long matchLong(Pattern pattern, String text) { + Matcher m = pattern.matcher(text); + if (m.find()) { + try { + return Long.parseLong(m.group(1).trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + private static String matchString(Pattern pattern, String text) { + Matcher m = pattern.matcher(text); + if (m.find()) { + return m.group(1).trim(); + } + return null; + } + + public static final class ExtractedStats { + public final Double rssi; + public final Double rangeM; + public final String rawFrame; + public final String metaJson; + public final String frameType; + public final String role; + public final Double txPowerDbm; + public final Double rssiDbm; + public final Double snrDb; + + public boolean hasRadioFrame() { + return frameType != null; + } + + public ExtractedStats( + Double rssi, + Double rangeM, + String rawFrame, + String metaJson, + String frameType, + String role, + Double txPowerDbm, + Double rssiDbm, + Double snrDb + ) { + this.rssi = rssi; + this.rangeM = rangeM; + this.rawFrame = rawFrame; + this.metaJson = metaJson; + this.frameType = frameType; + this.role = role; + this.txPowerDbm = txPowerDbm; + this.rssiDbm = rssiDbm; + this.snrDb = snrDb; + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/telnet/TelnetClient.java b/app/src/main/java/com/grigowashere/loratester/telnet/TelnetClient.java new file mode 100644 index 0000000..5ca5442 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/telnet/TelnetClient.java @@ -0,0 +1,163 @@ +package com.grigowashere.loratester.telnet; + +import android.util.Log; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public class TelnetClient { + + private static final String TAG = "TelnetClient"; + + public interface Listener { + void onConnected(); + + void onDisconnected(); + + void onBytes(byte[] data, int length); + + void onError(String message); + } + + public enum SendResult { + SENT, + NOT_CONNECTED, + EMPTY, + IO_ERROR + } + + private final String host; + private final int port; + private final Listener listener; + private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicReference activeSocket = new AtomicReference<>(); + private final Object sendLock = new Object(); + private Thread worker; + + public TelnetClient(String host, int port, Listener listener) { + this.host = host; + this.port = port; + this.listener = listener; + } + + public void start() { + if (running.getAndSet(true)) { + return; + } + worker = new Thread(this::runLoop, "TelnetClient"); + worker.setDaemon(true); + worker.start(); + } + + public void stop() { + running.set(false); + closeActiveSocket(); + if (worker != null) { + worker.interrupt(); + } + } + + public boolean isRunning() { + return running.get(); + } + + public boolean isConnected() { + Socket s = activeSocket.get(); + return s != null && s.isConnected() && !s.isClosed(); + } + + /** + * Sends an AT command. Adds AT prefix and CR+LF if missing. + */ + public SendResult sendAtCommand(String command) { + byte[] wire = AtCommandFormatter.toWireBytes(command); + if (wire.length == 0) { + return SendResult.EMPTY; + } + Socket socket = activeSocket.get(); + if (socket == null || socket.isClosed()) { + return SendResult.NOT_CONNECTED; + } + synchronized (sendLock) { + try { + OutputStream out = socket.getOutputStream(); + out.write(wire); + out.flush(); + return SendResult.SENT; + } catch (IOException e) { + Log.e(TAG, "send failed", e); + return SendResult.IO_ERROR; + } + } + } + + private void runLoop() { + int backoffMs = 1000; + while (running.get()) { + Socket socket = null; + try { + socket = new Socket(); + socket.connect(new InetSocketAddress(host, port), 5000); + socket.setTcpNoDelay(true); + activeSocket.set(socket); + listener.onConnected(); + backoffMs = 1000; + readStream(socket); + } catch (IOException e) { + if (running.get()) { + listener.onError(e.getMessage()); + } + } finally { + activeSocket.compareAndSet(socket, null); + listener.onDisconnected(); + if (socket != null) { + try { + socket.close(); + } catch (IOException ignored) { + } + } + } + if (!running.get()) { + break; + } + try { + Thread.sleep(backoffMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + backoffMs = Math.min(backoffMs * 2, 15000); + } + } + + private void closeActiveSocket() { + Socket s = activeSocket.getAndSet(null); + if (s != null) { + try { + s.close(); + } catch (IOException ignored) { + } + } + } + + private void readStream(Socket socket) throws IOException { + InputStream in = socket.getInputStream(); + byte[] buf = new byte[4096]; + while (running.get()) { + int n = in.read(buf); + if (n < 0) { + break; + } + if (n > 0) { + byte[] chunk = new byte[n]; + System.arraycopy(buf, 0, chunk, 0, n); + listener.onBytes(chunk, n); + } + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/telnet/TelnetFrameParser.java b/app/src/main/java/com/grigowashere/loratester/telnet/TelnetFrameParser.java new file mode 100644 index 0000000..b0c9518 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/telnet/TelnetFrameParser.java @@ -0,0 +1,200 @@ +package com.grigowashere.loratester.telnet; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Splits telnet stream into LoRa screen frames by ESC clear and/or SEND/RECEIVE headers. + */ +public class TelnetFrameParser { + + public interface FrameListener { + void onFrame(String text); + } + + private static final Charset CHARSET = StandardCharsets.UTF_8; + /** Start of a new radio screen (TX or RX). */ + private static final Pattern FRAME_HEADER = + Pattern.compile("(?m)^\\s*(SEND|RECEIVE)\\b"); + + private final List delimiters; + private final FrameListener listener; + private final byte[] buffer = new byte[65536]; + private int bufferLen; + + public TelnetFrameParser(FrameListener listener) { + this(listener, defaultDelimiters()); + } + + public TelnetFrameParser(FrameListener listener, List delimiters) { + this.listener = listener; + this.delimiters = new ArrayList<>(delimiters); + } + + public static List defaultDelimiters() { + return Arrays.asList( + new byte[] {0x1b, '[', '2', 'J'}, + new byte[] {0x1b, '[', 'H'}, + new byte[] {0x0c} + ); + } + + public void append(byte[] chunk, int offset, int length) { + if (length <= 0) { + return; + } + int splitAt = findEarliestDelimiter(chunk, offset, length); + if (splitAt < 0) { + appendToBuffer(chunk, offset, length); + tryEmitByHeaders(); + return; + } + appendToBuffer(chunk, offset, splitAt - offset); + emitFrame(); + clearBuffer(); + int delimLen = delimiterLengthAt(chunk, splitAt, offset, length); + int tailOffset = splitAt + delimLen; + int tailLen = offset + length - tailOffset; + append(chunk, tailOffset, tailLen); + tryEmitByHeaders(); + } + + public void append(byte[] chunk) { + append(chunk, 0, chunk.length); + } + + public void flush() { + tryEmitByHeaders(); + if (bufferLen > 0) { + emitFrame(); + clearBuffer(); + } + } + + /** + * Emit latest SEND/RECEIVE screen from buffer (in-place refresh without ESC). + * Does not clear the buffer. + */ + public void emitSnapshot() { + if (bufferLen == 0) { + return; + } + String text = TelnetText.normalize(bufferText()); + Matcher m = FRAME_HEADER.matcher(text); + int lastStart = -1; + while (m.find()) { + lastStart = m.start(); + } + if (lastStart < 0) { + return; + } + String frame = text.substring(lastStart).trim(); + if (!frame.isEmpty()) { + listener.onFrame(frame); + } + } + + /** Emit all complete frames when buffer holds multiple SEND/RECEIVE blocks. */ + private void tryEmitByHeaders() { + if (bufferLen == 0) { + return; + } + String text = bufferText(); + Matcher m = FRAME_HEADER.matcher(text); + List starts = new ArrayList<>(); + while (m.find()) { + starts.add(m.start()); + } + if (starts.size() < 2) { + return; + } + for (int i = 0; i < starts.size() - 1; i++) { + String frame = text.substring(starts.get(i), starts.get(i + 1)).trim(); + if (!frame.isEmpty()) { + listener.onFrame(frame); + } + } + String tail = text.substring(starts.get(starts.size() - 1)); + clearBuffer(); + byte[] tailBytes = tail.getBytes(CHARSET); + appendToBuffer(tailBytes, 0, tailBytes.length); + } + + private String bufferText() { + return new String(buffer, 0, bufferLen, CHARSET); + } + + private void emitFrame() { + if (bufferLen == 0) { + return; + } + String text = bufferText().trim(); + if (!text.isEmpty()) { + listener.onFrame(text); + } + } + + private void clearBuffer() { + bufferLen = 0; + } + + private void appendToBuffer(byte[] src, int offset, int length) { + if (length <= 0) { + return; + } + if (bufferLen + length > buffer.length) { + tryEmitByHeaders(); + if (bufferLen + length > buffer.length) { + emitFrame(); + clearBuffer(); + } + } + System.arraycopy(src, offset, buffer, bufferLen, length); + bufferLen += length; + } + + private int findEarliestDelimiter(byte[] chunk, int offset, int length) { + int best = -1; + for (byte[] delim : delimiters) { + int idx = indexOf(chunk, offset, length, delim); + if (idx >= 0 && (best < 0 || idx < best)) { + best = idx; + } + } + return best; + } + + private int delimiterLengthAt(byte[] chunk, int pos, int offset, int length) { + int end = offset + length; + for (byte[] delim : delimiters) { + if (pos + delim.length <= end && matchesAt(chunk, pos, delim)) { + return delim.length; + } + } + return 0; + } + + private static int indexOf(byte[] haystack, int offset, int length, byte[] needle) { + int end = offset + length - needle.length; + for (int i = offset; i <= end; i++) { + if (matchesAt(haystack, i, needle)) { + return i; + } + } + return -1; + } + + private static boolean matchesAt(byte[] haystack, int pos, byte[] needle) { + for (int i = 0; i < needle.length; i++) { + if (haystack[pos + i] != needle[i]) { + return false; + } + } + return true; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/telnet/TelnetText.java b/app/src/main/java/com/grigowashere/loratester/telnet/TelnetText.java new file mode 100644 index 0000000..9a5080d --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/telnet/TelnetText.java @@ -0,0 +1,24 @@ +package com.grigowashere.loratester.telnet; + +import java.util.regex.Pattern; + +/** Cleans telnet screen text before field extraction. */ +public final class TelnetText { + + private static final Pattern ANSI = Pattern.compile( + "\u001b\\[[0-9;?]*[ -/]*[@-~]|\u001b\\].*?\u0007|\u001b[@-Z\\\\-_]" + ); + + private TelnetText() { + } + + public static String normalize(String text) { + if (text == null || text.isEmpty()) { + return ""; + } + String s = ANSI.matcher(text).replaceAll(""); + s = s.replace('\r', '\n'); + s = s.replace("\u000c", "\n"); + return s; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java b/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java new file mode 100644 index 0000000..1480335 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java @@ -0,0 +1,245 @@ +package com.grigowashere.loratester.track; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import com.grigowashere.loratester.TelemetryUploader; +import com.grigowashere.loratester.api.ServerApi; +import com.grigowashere.loratester.location.GeoUtils; +import com.grigowashere.loratester.net.NetworkMonitor; +import com.grigowashere.loratester.telnet.StatsExtractor; + +import java.util.ArrayList; +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.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class TrackRecorder { + + private static final String TAG = "TrackRecorder"; + private static final long SAMPLE_MS = 1000; + private static final long FLUSH_MS = 30_000; + + public interface Listener { + void onStateChanged(boolean recording, int pointCount, long trackId); + void onError(String message); + } + + private final ServerApi serverApi; + private final TelemetryUploader uploader; + private final NetworkMonitor networkMonitor; + private final String deviceId; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "TrackSampler"); + t.setDaemon(true); + return t; + }); + + private volatile double lat = Double.NaN; + private volatile double lon = Double.NaN; + private volatile double altitude = Double.NaN; + private volatile long trackId = -1; + private volatile boolean recording; + private final List> buffer = new ArrayList<>(); + private int totalPoints; + private ScheduledFuture sampleTask; + private ScheduledFuture flushTask; + private Listener listener; + + public TrackRecorder( + ServerApi serverApi, + TelemetryUploader uploader, + String deviceId, + NetworkMonitor networkMonitor + ) { + this.serverApi = serverApi; + this.uploader = uploader; + this.deviceId = deviceId; + this.networkMonitor = networkMonitor; + networkMonitor.addListener(online -> { + if (online && recording) { + executor.execute(this::flushBuffer); + } + }); + } + + public void setListener(Listener listener) { + this.listener = listener; + } + + public void updateLocation(double lat, double lon, double altitude) { + if (GeoUtils.isValidCoordinate(lat, lon)) { + this.lat = lat; + this.lon = lon; + } + if (!Double.isNaN(altitude) && altitude != 0.0) { + this.altitude = altitude; + } + } + + public boolean isRecording() { + return recording; + } + + public int getPointCount() { + return totalPoints; + } + + public long getTrackId() { + return trackId; + } + + public void start() { + if (recording) { + return; + } + if (!networkMonitor.isOnline()) { + notifyError("Нужна сеть для начала трека"); + return; + } + executor.execute(() -> { + try { + long id = serverApi.startTrack(deviceId); + synchronized (buffer) { + buffer.clear(); + } + totalPoints = 0; + trackId = id; + recording = true; + startTimers(); + notifyState(); + } catch (Exception e) { + Log.e(TAG, "start track failed", e); + notifyError(e.getMessage()); + } + }); + } + + public void stop() { + if (!recording) { + return; + } + recording = false; + stopTimers(); + executor.execute(() -> { + try { + flushBuffer(); + if (trackId > 0) { + serverApi.finishTrack(trackId); + } + } catch (Exception e) { + Log.e(TAG, "stop track failed", e); + notifyError(e.getMessage()); + } finally { + trackId = -1; + notifyState(); + } + }); + } + + private void startTimers() { + sampleTask = scheduler.scheduleAtFixedRate( + () -> executor.execute(this::samplePoint), + SAMPLE_MS, + SAMPLE_MS, + TimeUnit.MILLISECONDS + ); + flushTask = scheduler.scheduleAtFixedRate( + () -> executor.execute(this::flushBuffer), + FLUSH_MS, + FLUSH_MS, + TimeUnit.MILLISECONDS + ); + } + + private void stopTimers() { + if (sampleTask != null) { + sampleTask.cancel(false); + sampleTask = null; + } + if (flushTask != null) { + flushTask.cancel(false); + flushTask = null; + } + } + + private void samplePoint() { + if (!recording || trackId < 0) { + return; + } + if (!GeoUtils.isValidCoordinate(lat, lon)) { + return; + } + StatsExtractor.ExtractedStats stats = uploader.getLastStats(); + Map point = new HashMap<>(); + point.put("ts", System.currentTimeMillis() / 1000.0); + point.put("lat", lat); + point.put("lon", lon); + if (!Double.isNaN(altitude)) { + point.put("altitude_gps", altitude); + } + if (stats != null) { + if (stats.rssi != null) { + point.put("rssi", stats.rssi); + } + if (stats.role != null) { + point.put("role", stats.role); + } + if (stats.metaJson != null) { + point.put("meta", stats.metaJson); + } + } + synchronized (buffer) { + buffer.add(point); + } + totalPoints++; + notifyState(); + } + + private void flushBuffer() { + if (trackId < 0) { + return; + } + List> batch; + synchronized (buffer) { + if (buffer.isEmpty()) { + return; + } + batch = new ArrayList<>(buffer); + buffer.clear(); + } + try { + serverApi.addTrackPoints(trackId, batch); + } catch (Exception e) { + Log.e(TAG, "flush points failed", e); + synchronized (buffer) { + buffer.addAll(0, batch); + } + notifyError(e.getMessage()); + } + } + + private void notifyState() { + if (listener == null) { + return; + } + mainHandler.post(() -> listener.onStateChanged(recording, totalPoints, trackId)); + } + + private void notifyError(String msg) { + if (listener == null) { + return; + } + mainHandler.post(() -> listener.onError(msg)); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/AtFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/AtFragment.java new file mode 100644 index 0000000..6217775 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/AtFragment.java @@ -0,0 +1,188 @@ +package com.grigowashere.loratester.ui; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.android.material.textfield.TextInputEditText; +import com.grigowashere.loratester.LoraApp; +import com.grigowashere.loratester.R; +import com.grigowashere.loratester.TelemetryUploader; +import com.grigowashere.loratester.telnet.AtCommands; +import com.grigowashere.loratester.telnet.TelnetClient; + +public class AtFragment extends Fragment { + + private FragmentPollHelper pollHelper; + private TelemetryUploader uploader; + private TextView atStatus; + private TextView atConsole; + private ScrollView atConsoleScroll; + private TextInputEditText atCommandInput; + private String lastConsole = ""; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader(); + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState + ) { + return inflater.inflate(R.layout.fragment_at, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + atStatus = view.findViewById(R.id.atStatus); + atConsole = view.findViewById(R.id.atConsole); + atConsoleScroll = view.findViewById(R.id.atConsoleScroll); + atCommandInput = view.findViewById(R.id.atCommandInput); + ChipGroup chips = view.findViewById(R.id.atQuickChips); + Button sendBtn = view.findViewById(R.id.atSendBtn); + Button clearLog = view.findViewById(R.id.atClearLog); + pollHelper = new FragmentPollHelper(this, this::refreshConsole); + + addQuickChip(chips, "AT+H", AtCommands.HELP); + addQuickChip(chips, "AT+TX", AtCommands.TRANSMIT); + addQuickChip(chips, "AT+RX", AtCommands.RECEIVE); + addQuickChip(chips, "AT+STATUS", AtCommands.STATUS); + addQuickChip(chips, "AT", AtCommands.BASIC); + + sendBtn.setOnClickListener(v -> sendFromInput()); + clearLog.setOnClickListener(v -> { + if (uploader != null) { + uploader.clearConsoleLog(); + } + lastConsole = ""; + if (atConsole != null) { + atConsole.setText(""); + } + }); + + if (atCommandInput != null) { + atCommandInput.setOnEditorActionListener((textView, actionId, event) -> { + sendFromInput(); + return true; + }); + } + } + + private void addQuickChip(ChipGroup group, String label, String command) { + Chip chip = new Chip(requireContext()); + chip.setText(label); + chip.setCheckable(false); + chip.setOnClickListener(v -> sendCommand(command)); + group.addView(chip); + } + + private void sendFromInput() { + if (atCommandInput == null || atCommandInput.getText() == null) { + return; + } + String cmd = atCommandInput.getText().toString().trim(); + if (cmd.isEmpty()) { + return; + } + sendCommand(cmd); + atCommandInput.setText(""); + } + + private void sendCommand(String command) { + if (uploader == null || !isAdded()) { + return; + } + uploader.sendAtCommand(command, result -> { + if (!isAdded()) { + return; + } + Context ctx = getContext(); + if (ctx == null) { + return; + } + if (result == TelnetClient.SendResult.NOT_CONNECTED) { + Toast.makeText(ctx, R.string.at_not_connected, Toast.LENGTH_SHORT).show(); + } else if (result == TelnetClient.SendResult.IO_ERROR) { + Toast.makeText(ctx, R.string.at_send_error, Toast.LENGTH_SHORT).show(); + } + updateConsoleView(); + }); + } + + private void refreshConsole() { + if (!isAdded() || uploader == null || atStatus == null) { + return; + } + boolean telnetOn = uploader.isTelnetConnected(); + atStatus.setText(getString( + R.string.at_status, + telnetOn ? getString(R.string.connected) : getString(R.string.disconnected) + )); + updateConsoleView(); + if (pollHelper != null) { + pollHelper.scheduleNext(400); + } + } + + private void updateConsoleView() { + if (uploader == null || atConsole == null || atConsoleScroll == null) { + return; + } + String log = uploader.getConsoleLog(); + if (!log.equals(lastConsole)) { + lastConsole = log; + atConsole.setText(log); + atConsoleScroll.post(() -> { + if (atConsoleScroll != null) { + atConsoleScroll.fullScroll(View.FOCUS_DOWN); + } + }); + } + } + + @Override + public void onResume() { + super.onResume(); + if (pollHelper != null) { + pollHelper.start(0); + } + } + + @Override + public void onPause() { + if (pollHelper != null) { + pollHelper.stop(); + } + super.onPause(); + } + + @Override + public void onDestroyView() { + if (pollHelper != null) { + pollHelper.stop(); + } + atStatus = null; + atConsole = null; + atConsoleScroll = null; + atCommandInput = null; + pollHelper = null; + super.onDestroyView(); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/ChatAdapter.java b/app/src/main/java/com/grigowashere/loratester/ui/ChatAdapter.java new file mode 100644 index 0000000..d6bc585 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/ChatAdapter.java @@ -0,0 +1,76 @@ +package com.grigowashere.loratester.ui; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.grigowashere.loratester.R; +import com.grigowashere.loratester.api.ChatMessage; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class ChatAdapter extends RecyclerView.Adapter { + + private final List messages = new ArrayList<>(); + private final DateFormat timeFormat = + DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()); + + public void setMessages(List newMessages) { + messages.clear(); + messages.addAll(newMessages); + notifyDataSetChanged(); + } + + public void appendMessages(List more) { + if (more.isEmpty()) { + return; + } + int start = messages.size(); + messages.addAll(more); + notifyItemRangeInserted(start, more.size()); + } + + public double lastTs() { + if (messages.isEmpty()) { + return 0; + } + return messages.get(messages.size() - 1).ts; + } + + @NonNull + @Override + public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_chat, parent, false); + return new Holder(v); + } + + @Override + public void onBindViewHolder(@NonNull Holder holder, int position) { + ChatMessage m = messages.get(position); + String time = timeFormat.format(new Date((long) (m.ts * 1000))); + holder.text.setText(time + " " + m.device_id + ": " + m.text); + } + + @Override + public int getItemCount() { + return messages.size(); + } + + static class Holder extends RecyclerView.ViewHolder { + final TextView text; + + Holder(@NonNull View itemView) { + super(itemView); + text = itemView.findViewById(R.id.chatItemText); + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/ChatFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/ChatFragment.java new file mode 100644 index 0000000..4564dbb --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/ChatFragment.java @@ -0,0 +1,237 @@ +package com.grigowashere.loratester.ui; + +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.os.VibratorManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.Button; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.grigowashere.loratester.LoraApp; +import com.grigowashere.loratester.R; +import com.grigowashere.loratester.TelemetryUploader; +import com.grigowashere.loratester.api.ChatMessage; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class ChatFragment extends Fragment { + + private static final int VIBRATE_MS = 40; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private FragmentPollHelper pollHelper; + private TelemetryUploader uploader; + private ChatAdapter adapter; + private RecyclerView recycler; + private double chatSince; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader(); + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState + ) { + return inflater.inflate(R.layout.fragment_chat, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + recycler = view.findViewById(R.id.chatRecycler); + EditText input = view.findViewById(R.id.chatInput); + Button send = view.findViewById(R.id.chatSend); + View inputBar = view.findViewById(R.id.chatInputBar); + + adapter = new ChatAdapter(); + LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext()); + recycler.setLayoutManager(layoutManager); + recycler.setAdapter(adapter); + pollHelper = new FragmentPollHelper(this, this::pollChat); + + ViewCompat.setOnApplyWindowInsetsListener(inputBar, (v, windowInsets) -> { + Insets ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime()); + v.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(), ime.bottom); + if (ime.bottom > 0 && adapter.getItemCount() > 0) { + scrollToLatest(); + } + return windowInsets; + }); + + Runnable doSend = () -> sendMessage(input); + send.setOnClickListener(v -> doSend.run()); + input.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_SEND) { + doSend.run(); + return true; + } + return false; + }); + + input.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + scrollToLatest(); + } + }); + } + + private void sendMessage(EditText input) { + String text = input.getText().toString().trim(); + if (text.isEmpty() || uploader == null) { + return; + } + input.setText(""); + executor.execute(() -> { + try { + uploader.getServerApi().postChat(uploader.getDeviceId(), text); + } catch (Exception ignored) { + // ignore + } + if (pollHelper != null && pollHelper.canRun()) { + pollChat(); + } + }); + } + + private void scrollToLatest() { + if (recycler == null || adapter.getItemCount() == 0) { + return; + } + int last = adapter.getItemCount() - 1; + recycler.post(() -> recycler.smoothScrollToPosition(last)); + } + + private void vibrateOnNewMessage() { + Context ctx = getContext(); + if (ctx == null) { + return; + } + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + VibratorManager vm = (VibratorManager) ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE); + if (vm != null) { + Vibrator v = vm.getDefaultVibrator(); + if (v != null && v.hasVibrator()) { + v.vibrate(VibrationEffect.createOneShot( + VIBRATE_MS, + VibrationEffect.DEFAULT_AMPLITUDE + )); + } + } + } else { + Vibrator v = (Vibrator) ctx.getSystemService(Context.VIBRATOR_SERVICE); + if (v != null && v.hasVibrator()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + v.vibrate(VibrationEffect.createOneShot( + VIBRATE_MS, + VibrationEffect.DEFAULT_AMPLITUDE + )); + } else { + v.vibrate(VIBRATE_MS); + } + } + } + } catch (Exception ignored) { + // ignore missing vibrator + } + } + + @Override + public void onResume() { + super.onResume(); + if (pollHelper != null) { + pollHelper.start(0); + } + } + + @Override + public void onPause() { + if (pollHelper != null) { + pollHelper.stop(); + } + super.onPause(); + } + + @Override + public void onDestroyView() { + if (pollHelper != null) { + pollHelper.stop(); + } + recycler = null; + super.onDestroyView(); + } + + @Override + public void onDestroy() { + executor.shutdownNow(); + super.onDestroy(); + } + + private void pollChat() { + if (!pollHelper.canRun() || uploader == null) { + return; + } + String myId = uploader.getDeviceId(); + executor.execute(() -> { + try { + List msgs = uploader.getServerApi().getChat(chatSince); + for (ChatMessage m : msgs) { + chatSince = Math.max(chatSince, m.ts); + } + if (!pollHelper.canRun() || msgs.isEmpty()) { + if (pollHelper.canRun()) { + pollHelper.scheduleNext(2500); + } + return; + } + boolean vibrate = false; + for (ChatMessage m : msgs) { + if (m.device_id != null && !m.device_id.equals(myId)) { + vibrate = true; + break; + } + } + boolean shouldVibrate = vibrate; + requireActivity().runOnUiThread(() -> { + if (!pollHelper.canRun()) { + return; + } + if (adapter.getItemCount() == 0) { + adapter.setMessages(msgs); + } else { + adapter.appendMessages(msgs); + } + scrollToLatest(); + if (shouldVibrate) { + vibrateOnNewMessage(); + } + }); + } catch (Exception ignored) { + // ignore + } + pollHelper.scheduleNext(2500); + }); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/FragmentPollHelper.java b/app/src/main/java/com/grigowashere/loratester/ui/FragmentPollHelper.java new file mode 100644 index 0000000..2c4ab07 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/FragmentPollHelper.java @@ -0,0 +1,50 @@ +package com.grigowashere.loratester.ui; + +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.Lifecycle; + +/** + * Safe periodic polling: no callbacks after onPause / when fragment is detached. + */ +public final class FragmentPollHelper { + + private final Fragment fragment; + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable task; + private boolean active; + + public FragmentPollHelper(@NonNull Fragment fragment, @NonNull Runnable task) { + this.fragment = fragment; + this.task = task; + } + + public void start(long initialDelayMs) { + active = true; + handler.removeCallbacks(task); + handler.postDelayed(task, initialDelayMs); + } + + public void stop() { + active = false; + handler.removeCallbacks(task); + } + + public void scheduleNext(long delayMs) { + if (!active || !fragment.isAdded()) { + return; + } + handler.removeCallbacks(task); + handler.postDelayed(task, delayMs); + } + + public boolean canRun() { + return active + && fragment.isAdded() + && fragment.getView() != null + && fragment.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/HistoryAdapter.java b/app/src/main/java/com/grigowashere/loratester/ui/HistoryAdapter.java new file mode 100644 index 0000000..7941404 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/HistoryAdapter.java @@ -0,0 +1,73 @@ +package com.grigowashere.loratester.ui; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.grigowashere.loratester.R; +import com.grigowashere.loratester.api.TelemetryHistoryItem; +import com.grigowashere.loratester.telnet.LoraStatsFormatter; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class HistoryAdapter extends RecyclerView.Adapter { + + private final DateFormat timeFormat = + DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault()); + private final List lines = new ArrayList<>(); + + public void setItems(List items) { + lines.clear(); + if (items == null) { + notifyDataSetChanged(); + return; + } + for (TelemetryHistoryItem item : items) { + String time = timeFormat.format(new Date((long) (item.ts * 1000))); + String role = item.role != null ? item.role : "—"; + String rssi = item.rssi != null ? item.rssi + " dBm" : "—"; + String summary = LoraStatsFormatter.formatMeta(item.meta); + String shortMeta = summary.length() > 80 + ? summary.substring(0, 80) + "…" + : summary; + lines.add(time + " · " + role + " · " + rssi + + (shortMeta.isEmpty() ? "" : "\n" + shortMeta)); + } + notifyDataSetChanged(); + } + + @NonNull + @Override + public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_history, parent, false); + return new Holder(v); + } + + @Override + public void onBindViewHolder(@NonNull Holder holder, int position) { + holder.line.setText(lines.get(position)); + } + + @Override + public int getItemCount() { + return lines.size(); + } + + static class Holder extends RecyclerView.ViewHolder { + final TextView line; + + Holder(@NonNull View itemView) { + super(itemView); + line = itemView.findViewById(R.id.historyLine); + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MainPagerAdapter.java b/app/src/main/java/com/grigowashere/loratester/ui/MainPagerAdapter.java new file mode 100644 index 0000000..48bf3cf --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/MainPagerAdapter.java @@ -0,0 +1,30 @@ +package com.grigowashere.loratester.ui; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +public class MainPagerAdapter extends FragmentStateAdapter { + + public MainPagerAdapter(@NonNull FragmentActivity activity) { + super(activity); + } + + @NonNull + @Override + public Fragment createFragment(int position) { + return switch (position) { + case 0 -> new MapFragment(); + case 1 -> new StatsFragment(); + case 2 -> new AtFragment(); + case 3 -> new ChatFragment(); + default -> new SettingsFragment(); + }; + } + + @Override + public int getItemCount() { + return 5; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java new file mode 100644 index 0000000..175056c --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -0,0 +1,620 @@ +package com.grigowashere.loratester.ui; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.grigowashere.loratester.LoraApp; +import com.grigowashere.loratester.R; +import com.grigowashere.loratester.TelemetryUploader; +import com.grigowashere.loratester.api.DeviceInfo; +import com.grigowashere.loratester.api.ServerApi; +import com.grigowashere.loratester.api.TrackDetail; +import com.grigowashere.loratester.api.TrackInfo; +import com.grigowashere.loratester.location.GeoUtils; +import com.grigowashere.loratester.net.NetworkMonitor; +import com.grigowashere.loratester.telnet.StatsExtractor; +import com.grigowashere.loratester.track.TrackRecorder; + +import org.mapsforge.core.graphics.Bitmap; +import org.mapsforge.core.graphics.Color; +import org.mapsforge.core.model.BoundingBox; +import org.mapsforge.core.model.LatLong; +import org.mapsforge.map.android.graphics.AndroidGraphicFactory; +import org.mapsforge.map.android.util.AndroidUtil; +import org.mapsforge.map.android.view.MapView; +import org.mapsforge.map.layer.Layer; +import org.mapsforge.map.layer.cache.TileCache; +import org.mapsforge.map.layer.download.TileDownloadLayer; +import org.mapsforge.map.layer.download.tilesource.OpenStreetMapMapnik; +import org.mapsforge.map.layer.overlay.Marker; +import org.mapsforge.map.layer.overlay.Polyline; +import org.mapsforge.map.model.MapViewPosition; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class MapFragment extends Fragment { + + private static final int TILE_SIZE_PX = 256; + private static final long DEVICE_POLL_MS = 5000; + /** Ignore GPS jitter smaller than ~11 m. */ + private static final double POSITION_EPS = 0.0001; + private static final float USER_PAN_THRESHOLD_PX = 12f; + + private static final int ARGB_TX = 0xFFE94560; + private static final int ARGB_RX = 0xFF4FC3F7; + private static final int ARGB_TRACK = 0xFF00FF88; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final DateFormat timeFormat = + DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()); + private final Map deviceMarkers = new HashMap<>(); + private final List trackLayers = new ArrayList<>(); + + private FragmentPollHelper pollHelper; + private TelemetryUploader uploader; + private TrackRecorder trackRecorder; + private MapView mapView; + private TileDownloadLayer downloadLayer; + private TileCache tileCache; + private TextView mapStatus; + private TextView trackStatus; + private Button btnTrack; + private Spinner trackSpinner; + private List savedTracks = new ArrayList<>(); + private boolean mapResumed; + private boolean mapInitialized; + private NetworkMonitor networkMonitor; + private NetworkMonitor.Listener networkListener; + private boolean networkOnline = true; + private boolean initialFitDone; + private boolean userMovedMap; + private boolean suppressTrackSpinner; + private Bitmap bitmapTx; + private Bitmap bitmapRx; + private Bitmap bitmapTrackPoint; + private float touchDownX; + private float touchDownY; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + LoraApp app = (LoraApp) context.getApplicationContext(); + uploader = app.getTelemetryUploader(); + trackRecorder = app.getTrackRecorder(); + networkMonitor = app.getNetworkMonitor(); + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState + ) { + return inflater.inflate(R.layout.fragment_map, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + mapView = view.findViewById(R.id.mapView); + mapStatus = view.findViewById(R.id.mapStatus); + trackStatus = view.findViewById(R.id.trackStatus); + btnTrack = view.findViewById(R.id.btnTrack); + trackSpinner = view.findViewById(R.id.trackSpinner); + pollHelper = new FragmentPollHelper(this, this::refreshDevices); + + networkOnline = networkMonitor != null && networkMonitor.isOnline(); + networkListener = online -> { + networkOnline = online; + if (isAdded() && mapStatus != null) { + requireActivity().runOnUiThread(this::updateNetworkStatusLine); + } + if (online && downloadLayer != null && mapResumed) { + downloadLayer.onResume(); + } + }; + if (networkMonitor != null) { + networkMonitor.addListener(networkListener); + } + + setupMapView(); + btnTrack.setOnClickListener(v -> toggleTracking()); + setupTrackRecorderListener(); + setupTrackSpinnerListener(); + } + + private void setupTrackSpinnerListener() { + trackSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View v, int pos, long id) { + if (suppressTrackSpinner || pos <= 0 || pos - 1 >= savedTracks.size()) { + return; + } + showTrack(savedTracks.get(pos - 1).id); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + } + + private void setupMapView() { + mapView.setClickable(true); + mapView.getMapScaleBar().setVisible(true); + mapView.setBuiltInZoomControls(false); + + mapView.setOnTouchListener((v, event) -> { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + touchDownX = event.getX(); + touchDownY = event.getY(); + v.getParent().requestDisallowInterceptTouchEvent(true); + } else if (action == MotionEvent.ACTION_MOVE) { + v.getParent().requestDisallowInterceptTouchEvent(true); + float dx = event.getX() - touchDownX; + float dy = event.getY() - touchDownY; + if (dx * dx + dy * dy > USER_PAN_THRESHOLD_PX * USER_PAN_THRESHOLD_PX) { + userMovedMap = true; + } + } else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + v.getParent().requestDisallowInterceptTouchEvent(false); + } + return false; + }); + + mapView.getModel().displayModel.setFixedTileSize(TILE_SIZE_PX); + tileCache = AndroidUtil.createTileCache( + requireContext(), + "loratester-tiles", + TILE_SIZE_PX, + 1f, + mapView.getModel().frameBufferModel.getOverdrawFactor() + ); + + OpenStreetMapMapnik tileSource = OpenStreetMapMapnik.INSTANCE; + tileSource.setUserAgent("LoraTester/1.0"); + + downloadLayer = new TileDownloadLayer( + tileCache, + mapView.getModel().mapViewPosition, + tileSource, + AndroidGraphicFactory.INSTANCE + ); + mapView.getLayerManager().getLayers().add(downloadLayer); + mapView.setZoomLevelMin(tileSource.getZoomLevelMin()); + mapView.setZoomLevelMax(tileSource.getZoomLevelMax()); + downloadLayer.start(); + + MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition; + position.setCenter(new LatLong(55.75, 37.62)); + position.setZoomLevel((byte) 10); + + bitmapTx = MapsforgeBitmaps.dot(ARGB_TX, 20); + bitmapRx = MapsforgeBitmaps.dot(ARGB_RX, 20); + bitmapTrackPoint = MapsforgeBitmaps.dot(ARGB_TRACK, 12); + + mapInitialized = true; + } + + @Override + public void onResume() { + super.onResume(); + mapResumed = true; + if (downloadLayer != null) { + downloadLayer.onResume(); + } + if (pollHelper != null) { + pollHelper.start(0); + } + if (trackRecorder != null && btnTrack != null) { + setupTrackRecorderListener(); + } + loadTrackList(); + } + + @Override + public void onPause() { + mapResumed = false; + if (pollHelper != null) { + pollHelper.stop(); + } + if (downloadLayer != null) { + downloadLayer.onPause(); + } + super.onPause(); + } + + @Override + public void onDestroyView() { + mapResumed = false; + mapInitialized = false; + initialFitDone = false; + if (pollHelper != null) { + pollHelper.stop(); + } + if (networkMonitor != null && networkListener != null) { + networkMonitor.removeListener(networkListener); + } + networkListener = null; + if (trackRecorder != null) { + trackRecorder.setListener(null); + } + removeAllDeviceMarkers(); + clearTrackLayers(); + deviceMarkers.clear(); + if (downloadLayer != null) { + downloadLayer.onDestroy(); + downloadLayer = null; + } + if (mapView != null) { + mapView.destroy(); + } + mapView = null; + tileCache = null; + bitmapTx = null; + bitmapRx = null; + bitmapTrackPoint = null; + mapStatus = null; + trackStatus = null; + btnTrack = null; + trackSpinner = null; + pollHelper = null; + super.onDestroyView(); + } + + @Override + public void onDestroy() { + executor.shutdownNow(); + super.onDestroy(); + } + + private void updateNetworkStatusLine() { + if (mapStatus == null) { + return; + } + CharSequence current = mapStatus.getText(); + String net = networkStatusSuffix(); + if (current != null && current.toString().contains(" · ")) { + int idx = current.toString().lastIndexOf(" · "); + mapStatus.setText(current.subSequence(0, idx) + " · " + net); + } + } + + private String networkStatusSuffix() { + return getString(networkOnline + ? R.string.map_network_online + : R.string.map_network_offline); + } + + private void setupTrackRecorderListener() { + trackRecorder.setListener(new TrackRecorder.Listener() { + @Override + public void onStateChanged(boolean recording, int pointCount, long trackId) { + if (!isAdded() || btnTrack == null) { + return; + } + btnTrack.setText(recording ? R.string.track_stop : R.string.track_start); + if (trackStatus != null) { + trackStatus.setText(getString(R.string.track_status, pointCount)); + } + if (!recording && trackId > 0) { + loadTrackList(); + } + } + + @Override + public void onError(String message) { + if (isAdded() && trackStatus != null) { + trackStatus.setText(getString(R.string.track_error, message)); + } + } + }); + } + + private void toggleTracking() { + if (trackRecorder.isRecording()) { + trackRecorder.stop(); + } else { + trackRecorder.start(); + } + } + + private void loadTrackList() { + if (uploader == null || !mapResumed) { + return; + } + String deviceId = uploader.getDeviceId(); + executor.execute(() -> { + try { + List tracks = uploader.getServerApi().listTracks(deviceId); + if (!isAdded() || !mapResumed) { + return; + } + requireActivity().runOnUiThread(() -> { + if (mapResumed) { + updateTrackSpinner(tracks); + } + }); + } catch (Exception ignored) { + // optional + } + }); + } + + private void updateTrackSpinner(List tracks) { + if (trackSpinner == null) { + return; + } + savedTracks = tracks != null ? tracks : new ArrayList<>(); + List labels = new ArrayList<>(); + labels.add(getString(R.string.track_spinner_hint)); + for (TrackInfo t : savedTracks) { + String start = timeFormat.format(new Date((long) (t.started_at * 1000))); + labels.add("#" + t.id + " " + start + " (" + t.point_count + " pts)"); + } + if (savedTracks.isEmpty()) { + labels.set(0, getString(R.string.track_none)); + } + suppressTrackSpinner = true; + trackSpinner.setAdapter(new ArrayAdapter<>( + requireContext(), + android.R.layout.simple_spinner_dropdown_item, + labels + )); + trackSpinner.setSelection(0, false); + suppressTrackSpinner = false; + } + + private void showTrack(long trackId) { + executor.execute(() -> { + try { + TrackDetail detail = uploader.getServerApi().getTrack(trackId); + if (!isAdded()) { + return; + } + requireActivity().runOnUiThread(() -> + runWhenMapReady(() -> drawTrack(detail))); + } catch (Exception e) { + if (isAdded() && mapStatus != null) { + requireActivity().runOnUiThread(() -> + mapStatus.setText(getString(R.string.track_error, e.getMessage()))); + } + } + }); + } + + private void drawTrack(TrackDetail detail) { + if (!isMapReady() || detail.points == null || detail.points.isEmpty()) { + return; + } + clearTrackLayers(); + + List line = new ArrayList<>(); + List boundsPoints = new ArrayList<>(); + + for (TrackDetail.TrackPoint p : detail.points) { + LatLong latLong = new LatLong(p.lat, p.lon); + line.add(latLong); + boundsPoints.add(latLong); + Marker marker = new Marker(latLong, bitmapTrackPoint, 0, 0); + addTrackLayer(marker); + } + + if (line.size() >= 2) { + Polyline polyline = new Polyline( + MapsforgeBitmaps.linePaint(Color.GREEN, 4f), + AndroidGraphicFactory.INSTANCE + ); + polyline.getLatLongs().addAll(line); + addTrackLayer(polyline); + } + + fitBoundsOnce(boundsPoints, detail.points.size() == 1, true); + } + + private void addTrackLayer(Layer layer) { + mapView.getLayerManager().getLayers().add(layer); + trackLayers.add(layer); + } + + private void clearTrackLayers() { + for (Layer layer : trackLayers) { + mapView.getLayerManager().getLayers().remove(layer); + } + trackLayers.clear(); + } + + private void removeAllDeviceMarkers() { + if (mapView == null) { + return; + } + for (Marker marker : deviceMarkers.values()) { + mapView.getLayerManager().getLayers().remove(marker); + } + } + + private Bitmap roleBitmap(String role) { + return StatsExtractor.ROLE_RX.equals(role) ? bitmapRx : bitmapTx; + } + + private static boolean samePosition(LatLong a, LatLong b) { + return Math.abs(a.latitude - b.latitude) < POSITION_EPS + && Math.abs(a.longitude - b.longitude) < POSITION_EPS; + } + + private boolean isMapReady() { + return mapResumed + && isAdded() + && getView() != null + && mapView != null + && mapInitialized + && pollHelper != null + && pollHelper.canRun(); + } + + private void runWhenMapReady(Runnable action) { + if (isMapReady()) { + action.run(); + } + } + + private void refreshDevices() { + if (!mapResumed || uploader == null) { + return; + } + ServerApi api = uploader.getServerApi(); + executor.execute(() -> { + try { + List devices = api.getDevices(); + if (!isAdded() || !mapResumed) { + return; + } + requireActivity().runOnUiThread(() -> + runWhenMapReady(() -> updateMap(devices))); + } catch (Exception e) { + if (!isAdded() || !mapResumed) { + return; + } + requireActivity().runOnUiThread(() -> { + if (mapStatus != null && pollHelper != null && pollHelper.canRun()) { + mapStatus.setText(getString(R.string.map_error, e.getMessage())); + } + }); + } + if (pollHelper != null && pollHelper.canRun()) { + pollHelper.scheduleNext(DEVICE_POLL_MS); + } + }); + } + + private void updateMap(List devices) { + if (!isMapReady()) { + return; + } + + int txCount = 0; + int rxCount = 0; + int onMap = 0; + List boundsPoints = new ArrayList<>(); + Set seen = new HashSet<>(); + + for (DeviceInfo d : devices) { + if (StatsExtractor.ROLE_TX.equals(d.role)) { + txCount++; + } else if (StatsExtractor.ROLE_RX.equals(d.role)) { + rxCount++; + } + if (!GeoUtils.isValidCoordinate(d.lat, d.lon)) { + continue; + } + onMap++; + seen.add(d.device_id); + LatLong pos = new LatLong(d.lat, d.lon); + boundsPoints.add(pos); + + Marker marker = deviceMarkers.get(d.device_id); + if (marker == null) { + marker = new Marker(pos, roleBitmap(d.role), 0, 0); + deviceMarkers.put(d.device_id, marker); + mapView.getLayerManager().getLayers().add(marker); + } else if (!samePosition(marker.getLatLong(), pos)) { + marker.setLatLong(pos); + } + } + + Iterator> it = deviceMarkers.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + if (!seen.contains(entry.getKey())) { + mapView.getLayerManager().getLayers().remove(entry.getValue()); + it.remove(); + } + } + + if (mapStatus != null) { + mapStatus.setText(getString( + R.string.map_status_roles, + onMap, + txCount, + rxCount, + networkStatusSuffix() + )); + } + + if (!boundsPoints.isEmpty() && !userMovedMap && !initialFitDone) { + fitBoundsOnce(boundsPoints, onMap == 1, false); + initialFitDone = true; + } + } + + /** Adjust camera only on first device load or when user picks a saved track. */ + private void fitBoundsOnce(List points, boolean singlePoint, boolean force) { + if (!isMapReady() || points.isEmpty() || (!force && userMovedMap)) { + return; + } + MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition; + Runnable apply = () -> { + if (!isMapReady()) { + return; + } + if (singlePoint) { + position.setCenter(points.get(0)); + position.setZoomLevel((byte) 13); + return; + } + double minLat = Double.MAX_VALUE; + double maxLat = -Double.MAX_VALUE; + double minLon = Double.MAX_VALUE; + double maxLon = -Double.MAX_VALUE; + for (LatLong p : points) { + minLat = Math.min(minLat, p.latitude); + maxLat = Math.max(maxLat, p.latitude); + minLon = Math.min(minLon, p.longitude); + maxLon = Math.max(maxLon, p.longitude); + } + double padLat = Math.max((maxLat - minLat) * 0.2, 0.003); + double padLon = Math.max((maxLon - minLon) * 0.2, 0.003); + BoundingBox box = new BoundingBox( + minLat - padLat, minLon - padLon, maxLat + padLat, maxLon + padLon); + position.setCenter(box.getCenterPoint()); + int w = Math.max(mapView.getWidth(), 1); + int h = Math.max(mapView.getHeight(), 1); + double latSpan = Math.max(box.maxLatitude - box.minLatitude, 0.001); + double lonSpan = Math.max(box.maxLongitude - box.minLongitude, 0.001); + double latZoom = Math.log(h / (double) TILE_SIZE_PX / latSpan) / Math.log(2); + double lonZoom = Math.log(w / (double) TILE_SIZE_PX / lonSpan) / Math.log(2); + byte zoom = (byte) Math.max(8, Math.min(15, Math.floor(Math.min(latZoom, lonZoom)))); + position.setZoomLevel(zoom); + }; + if (mapView.getWidth() > 0 && mapView.getHeight() > 0) { + apply.run(); + } else { + mapView.post(apply); + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapsforgeBitmaps.java b/app/src/main/java/com/grigowashere/loratester/ui/MapsforgeBitmaps.java new file mode 100644 index 0000000..3e2aa73 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapsforgeBitmaps.java @@ -0,0 +1,38 @@ +package com.grigowashere.loratester.ui; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; + +import org.mapsforge.core.graphics.Color; +import org.mapsforge.map.android.graphics.AndroidBitmap; +import org.mapsforge.map.android.graphics.AndroidGraphicFactory; + +/** Small colored bitmaps for device/track markers on Mapsforge. */ +final class MapsforgeBitmaps { + + private MapsforgeBitmaps() { + } + + static org.mapsforge.core.graphics.Bitmap dot(int argb, int sizePx) { + Bitmap androidBitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(androidBitmap); + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setColor(argb); + canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 1f, paint); + Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG); + stroke.setStyle(Paint.Style.STROKE); + stroke.setColor(0xFFFFFFFF); + stroke.setStrokeWidth(2f); + canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 2f, stroke); + return new AndroidBitmap(androidBitmap); + } + + static org.mapsforge.core.graphics.Paint linePaint(Color color, float strokeWidth) { + org.mapsforge.core.graphics.Paint paint = AndroidGraphicFactory.INSTANCE.createPaint(); + paint.setColor(color); + paint.setStrokeWidth(strokeWidth); + paint.setStyle(org.mapsforge.core.graphics.Style.STROKE); + return paint; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java new file mode 100644 index 0000000..0d4b390 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java @@ -0,0 +1,84 @@ +package com.grigowashere.loratester.ui; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.google.android.material.switchmaterial.SwitchMaterial; +import com.google.android.material.textfield.TextInputEditText; +import com.grigowashere.loratester.LoraApp; +import com.grigowashere.loratester.R; +import com.grigowashere.loratester.SettingsRepository; +import com.grigowashere.loratester.TelemetryUploader; + +public class SettingsFragment extends Fragment { + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState + ) { + return inflater.inflate(R.layout.fragment_settings, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + LoraApp app = (LoraApp) requireActivity().getApplication(); + SettingsRepository settings = app.getSettingsRepository(); + TelemetryUploader uploader = app.getTelemetryUploader(); + + TextInputEditText editServer = view.findViewById(R.id.editServerUrl); + TextInputEditText editHost = view.findViewById(R.id.editTelnetHost); + TextInputEditText editPort = view.findViewById(R.id.editTelnetPort); + TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex); + TextInputEditText editRange = view.findViewById(R.id.editRangeRegex); + SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet); + TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel); + Button save = view.findViewById(R.id.btnSaveSettings); + + editServer.setText(settings.getServerUrl()); + editHost.setText(settings.getTelnetHost()); + editPort.setText(String.valueOf(settings.getTelnetPort())); + editRssi.setText(settings.getRssiRegex()); + editRange.setText(settings.getRangeRegex()); + switchTelnet.setChecked(settings.isTelnetEnabled()); + deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId())); + + save.setOnClickListener(v -> { + settings.setServerUrl(textOf(editServer, SettingsRepository.DEFAULT_SERVER)); + settings.setTelnetHost(textOf(editHost, SettingsRepository.DEFAULT_TELNET_HOST)); + try { + settings.setTelnetPort(Integer.parseInt(textOf(editPort, "2727"))); + } catch (NumberFormatException e) { + settings.setTelnetPort(SettingsRepository.DEFAULT_TELNET_PORT); + } + settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX)); + settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX)); + settings.setTelnetEnabled(switchTelnet.isChecked()); + uploader.refreshApi(); + if (switchTelnet.isChecked()) { + uploader.startTelnet(); + } else { + uploader.stopTelnet(); + } + Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show(); + }); + } + + private static String textOf(TextInputEditText edit, String fallback) { + if (edit.getText() == null || edit.getText().toString().trim().isEmpty()) { + return fallback; + } + return edit.getText().toString().trim(); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java new file mode 100644 index 0000000..444ef31 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java @@ -0,0 +1,266 @@ +package com.grigowashere.loratester.ui; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.grigowashere.loratester.LoraApp; +import com.grigowashere.loratester.R; +import com.grigowashere.loratester.TelemetryUploader; +import com.grigowashere.loratester.api.DeviceInfo; +import com.grigowashere.loratester.api.TelemetryHistoryItem; +import com.grigowashere.loratester.location.GeoUtils; +import com.grigowashere.loratester.telnet.LoraStatsFormatter; +import com.grigowashere.loratester.telnet.StatsExtractor; + +import java.text.DateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class StatsFragment extends Fragment { + + private static final long SERVER_POLL_MS = 1000; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final DateFormat timeFormat = + DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault()); + private FragmentPollHelper pollHelper; + private TelemetryUploader uploader; + private TextView statsStatus; + private TextView statsDetails; + private RecyclerView statsHistoryList; + private final HistoryAdapter historyAdapter = new HistoryAdapter(); + + private StatsExtractor.ExtractedStats cachedLocal; + private DeviceInfo cachedServer; + private int cachedDeviceCount; + private String cachedError; + + private final TelemetryUploader.StatsListener statsListener = stats -> { + cachedLocal = stats; + cachedError = null; + postRender(); + }; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader(); + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState + ) { + return inflater.inflate(R.layout.fragment_stats, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + statsStatus = view.findViewById(R.id.statsStatus); + statsDetails = view.findViewById(R.id.statsDetails); + statsHistoryList = view.findViewById(R.id.statsHistoryList); + statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext())); + statsHistoryList.setAdapter(historyAdapter); + Button btnSimulate = view.findViewById(R.id.btnSimulate); + pollHelper = new FragmentPollHelper(this, this::refresh); + + btnSimulate.setOnClickListener(v -> { + String chunk = """ + SEND + Frequency: 433000000 Hz + Power: 22 dBm + Packet: 1 + Payload: Sim TX + \u001b[2J"""; + uploader.simulateChunk(chunk); + if (pollHelper.canRun()) { + statsStatus.setText(R.string.simulate_sent); + } + }); + } + + @Override + public void onResume() { + super.onResume(); + if (uploader != null) { + uploader.setStatsListener(statsListener); + cachedLocal = uploader.getLastStats(); + postRender(); + } + if (pollHelper != null) { + pollHelper.start(0); + } + } + + @Override + public void onPause() { + if (uploader != null) { + uploader.setStatsListener(null); + } + if (pollHelper != null) { + pollHelper.stop(); + } + super.onPause(); + } + + @Override + public void onDestroyView() { + if (pollHelper != null) { + pollHelper.stop(); + } + statsStatus = null; + statsDetails = null; + statsHistoryList = null; + pollHelper = null; + super.onDestroyView(); + } + + @Override + public void onDestroy() { + executor.shutdownNow(); + super.onDestroy(); + } + + private void postRender() { + if (!isAdded() || statsDetails == null) { + return; + } + requireActivity().runOnUiThread(this::renderDetails); + } + + private void refresh() { + if (!pollHelper.canRun() || uploader == null || statsStatus == null) { + return; + } + String deviceId = uploader.getDeviceId(); + boolean telnet = uploader.isTelnetConnected(); + statsStatus.setText(getString( + R.string.stats_status, + deviceId, + telnet ? getString(R.string.connected) : getString(R.string.disconnected) + )); + + executor.execute(() -> { + List history = null; + try { + List devices = uploader.getServerApi().getDevices(); + DeviceInfo self = null; + for (DeviceInfo d : devices) { + if (deviceId.equals(d.device_id)) { + self = d; + break; + } + } + cachedServer = self; + cachedDeviceCount = devices.size(); + cachedError = null; + history = uploader.getServerApi().getTelemetryHistory(deviceId, 30); + } catch (Exception e) { + cachedError = e.getMessage() != null ? e.getMessage() : "error"; + } + List finalHistory = history; + if (isAdded()) { + requireActivity().runOnUiThread(() -> { + if (historyAdapter != null) { + historyAdapter.setItems(finalHistory); + } + }); + } + postRender(); + if (pollHelper != null) { + pollHelper.scheduleNext(SERVER_POLL_MS); + } + }); + } + + private void renderDetails() { + if (!isAdded() || statsDetails == null || uploader == null) { + return; + } + + StringBuilder sb = new StringBuilder(); + sb.append(getString(R.string.devices_on_server, cachedDeviceCount)).append("\n"); + sb.append(getString( + uploader.isTelnetConnected() ? R.string.telnet_connected : R.string.telnet_disconnected + )); + 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 { + sb.append(getString(R.string.no_telemetry_yet)).append("\n"); + } + + 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)) { + sb.append("GPS: ").append(lat).append(", ").append(lon).append("\n"); + } else { + sb.append(getString(R.string.gps_waiting)).append("\n"); + } + + statsDetails.setText(sb.toString()); + } + + private String pickMetaJson() { + boolean telnet = uploader.isTelnetConnected(); + if (telnet && cachedLocal != null && cachedLocal.metaJson != null) { + return cachedLocal.metaJson; + } + 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; + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_marker.xml b/app/src/main/res/drawable/ic_marker.xml new file mode 100644 index 0000000..531585f --- /dev/null +++ b/app/src/main/res/drawable/ic_marker.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_marker_rx.xml b/app/src/main/res/drawable/ic_marker_rx.xml new file mode 100644 index 0000000..81eee46 --- /dev/null +++ b/app/src/main/res/drawable/ic_marker_rx.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_marker_tx.xml b/app/src/main/res/drawable/ic_marker_tx.xml new file mode 100644 index 0000000..80e9877 --- /dev/null +++ b/app/src/main/res/drawable/ic_marker_tx.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..42d9c4b --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/main/res/layout/fragment_at.xml b/app/src/main/res/layout/fragment_at.xml new file mode 100644 index 0000000..482b5a7 --- /dev/null +++ b/app/src/main/res/layout/fragment_at.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + +
Выберите TX и RX треки
+
+

Время теста

+
+
+ + + +
+ +
Расстояние GPS: —
+
+

TX

+

RX

+
+ +
+ +
+

Чат

+
+
+ + +
+
+ + +
+
+ Детали + +
+
+
+ + + + diff --git a/server/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc b/server/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..1eae6d2 Binary files /dev/null and b/server/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc differ diff --git a/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc b/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..aea25cc Binary files /dev/null and b/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc differ diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..ece8e0c --- /dev/null +++ b/server/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/server/tests/test_schema.py b/server/tests/test_schema.py new file mode 100644 index 0000000..5c8a9a9 --- /dev/null +++ b/server/tests/test_schema.py @@ -0,0 +1,68 @@ +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +from core import storage +from core.schema import SCHEMA_VERSION, apply_migrations, check_db_ok, column_exists + + +@pytest.fixture +def temp_db(monkeypatch): + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "test.db") + monkeypatch.setattr(storage, "DATABASE_PATH", path) + yield path + + +def test_fresh_db_has_all_tables(temp_db): + storage.init_db() + status = storage.db_status() + assert status["db_ok"] is True + assert status["schema_version"] == SCHEMA_VERSION + + +def test_old_telemetry_without_meta_gets_migrated(temp_db): + conn = sqlite3.connect(temp_db) + conn.execute( + """ + CREATE TABLE telemetry ( + id INTEGER PRIMARY KEY, + device_id TEXT, + lat REAL, lon REAL, rssi REAL, range_m REAL, + raw_frame TEXT, ts REAL + ) + """ + ) + conn.commit() + conn.close() + + storage.init_db() + conn = sqlite3.connect(temp_db) + assert column_exists(conn, "telemetry", "meta") + assert check_db_ok(conn) + conn.close() + + +def test_tracks_crud(temp_db): + storage.init_db() + start = storage.start_track("android-12345678") + tid = start["track_id"] + storage.add_track_points( + tid, + [ + { + "ts": 1.0, + "lat": 55.75, + "lon": 37.62, + "rssi": -70.0, + "role": "RX", + "meta": '{"packet":1}', + } + ], + ) + fin = storage.finish_track(tid) + assert fin["point_count"] == 1 + track = storage.get_track(tid) + assert len(track["points"]) == 1 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..13dab91 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "LoraTester" +include(":app") + \ No newline at end of file