2 Commits

124 changed files with 7892 additions and 0 deletions
+15
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
Generated
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings" defaultProject="true" />
</project>
+24
View File
@@ -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 в **Настройках**.
+1
View File
@@ -0,0 +1 @@
/build
+57
View File
@@ -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)
}
+21
View File
@@ -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
@@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@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());
}
}
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".LoraApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LoraTester"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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
);
}
}
@@ -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<String[]> 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();
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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;
}
@@ -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<List<DeviceInfo>>() {}.getType();
private static final Type CHAT_LIST = new TypeToken<List<ChatMessage>>() {}.getType();
private static final Type TELEMETRY_HISTORY =
new TypeToken<List<TelemetryHistoryItem>>() {}.getType();
private static final Type TRACK_LIST = new TypeToken<List<TrackInfo>>() {}.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<String, Object> 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<DeviceInfo> getDevices() throws IOException {
return getJsonList("/api/devices", DEVICE_LIST);
}
public void postChat(String deviceId, String text) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("device_id", deviceId);
body.put("text", text);
postJson("/api/chat", body);
}
public List<ChatMessage> getChat(double since) throws IOException {
String path = "/api/chat?since=" + since;
return getJsonList(path, CHAT_LIST);
}
public List<TelemetryHistoryItem> 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<String, Object> body = new HashMap<>();
body.put("device_id", deviceId);
Map<String, Object> resp = postJsonMap("/api/tracks/start", body, true);
Number id = (Number) resp.get("track_id");
return id.longValue();
}
public void addTrackPoints(long trackId, List<Map<String, Object>> points) throws IOException {
Map<String, Object> 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<TrackInfo> 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<String, Object> postJsonMap(String path, Map<String, Object> 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<String, Object> body) throws IOException {
postJson(path, body, false);
}
private void postJson(String path, Map<String, Object> 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> 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());
}
}
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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<TrackPoint> 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;
}
}
@@ -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;
}
@@ -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<List<QueuedItem>>() {}.getType();
private final File queueFile;
private final List<QueuedItem> 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<QueuedItem> 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<QueuedItem> 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);
}
}
}
@@ -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());
}
}
@@ -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;
}
}
}
@@ -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<Listener> 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);
}
}
@@ -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);
}
}
@@ -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() {
}
}
@@ -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<String> 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<String> shown) {
if (fieldsEl == null || !fieldsEl.isJsonObject()) {
return;
}
JsonObject fields = fieldsEl.getAsJsonObject();
for (Map.Entry<String, JsonElement> 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;
}
}
@@ -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<String, Object> meta = new LinkedHashMap<>();
Map<String, String> 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<String, String> 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<String, Object> meta,
Map<String, String> 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<String, String> fields,
String label,
Object value,
java.util.function.Function<Object, String> format
) {
if (value == null || fields.containsKey(label)) {
return;
}
fields.put(label, format.apply(value));
}
private static ExtractedStats empty(String frame) {
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<String, Object> meta, String key, Integer value) {
if (value != null) {
meta.put(key, value);
}
}
private static void putLong(Map<String, Object> meta, String key, Long value) {
if (value != null) {
meta.put(key, value);
}
}
private static void putDouble(Map<String, Object> meta, String key, Double value) {
if (value != null) {
meta.put(key, value);
}
}
private static void putString(Map<String, Object> 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;
}
}
}
@@ -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<Socket> 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);
}
}
}
}
@@ -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<byte[]> 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<byte[]> delimiters) {
this.listener = listener;
this.delimiters = new ArrayList<>(delimiters);
}
public static List<byte[]> 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<Integer> 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;
}
}
@@ -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;
}
}
@@ -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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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));
}
}
@@ -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();
}
}
@@ -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<ChatAdapter.Holder> {
private final List<ChatMessage> messages = new ArrayList<>();
private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
public void setMessages(List<ChatMessage> newMessages) {
messages.clear();
messages.addAll(newMessages);
notifyDataSetChanged();
}
public void appendMessages(List<ChatMessage> 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);
}
}
}
@@ -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<ChatMessage> 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);
});
}
}
@@ -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);
}
}
@@ -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<HistoryAdapter.Holder> {
private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault());
private final List<String> lines = new ArrayList<>();
public void setItems(List<TelemetryHistoryItem> 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);
}
}
}
@@ -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;
}
}
@@ -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<String, Marker> deviceMarkers = new HashMap<>();
private final List<Layer> 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<TrackInfo> 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<TrackInfo> tracks = uploader.getServerApi().listTracks(deviceId);
if (!isAdded() || !mapResumed) {
return;
}
requireActivity().runOnUiThread(() -> {
if (mapResumed) {
updateTrackSpinner(tracks);
}
});
} catch (Exception ignored) {
// optional
}
});
}
private void updateTrackSpinner(List<TrackInfo> tracks) {
if (trackSpinner == null) {
return;
}
savedTracks = tracks != null ? tracks : new ArrayList<>();
List<String> 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<LatLong> line = new ArrayList<>();
List<LatLong> 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<DeviceInfo> 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<DeviceInfo> devices) {
if (!isMapReady()) {
return;
}
int txCount = 0;
int rxCount = 0;
int onMap = 0;
List<LatLong> boundsPoints = new ArrayList<>();
Set<String> 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<Map.Entry<String, Marker>> it = deviceMarkers.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Marker> 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<LatLong> 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);
}
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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<TelemetryHistoryItem> history = null;
try {
List<DeviceInfo> 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<TelemetryHistoryItem> 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;
}
}
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="16dp"
android:height="16dp" />
<solid android:color="#E94560" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="18dp"
android:height="18dp" />
<solid android:color="#4FC3F7" />
<stroke
android:width="2dp"
android:color="#FFFFFF" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="18dp"
android:height="18dp" />
<solid android:color="#E94560" />
<stroke
android:width="2dp"
android:color="#FFFFFF" />
</shape>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/atStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/atQuickChips"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/at_command_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atCommandInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:inputType="text"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/atSendBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="8dp"
android:text="@string/send" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="horizontal">
<Button
android:id="@+id/atClearLog"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/at_clear_log" />
</LinearLayout>
<ScrollView
android:id="@+id/atConsoleScroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:background="#0D1117"
android:padding="8dp">
<TextView
android:id="@+id/atConsole"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textColor="#C9D1D9"
android:textIsSelectable="true"
android:textSize="11sp" />
</ScrollView>
</LinearLayout>
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/chatRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="8dp"
android:paddingTop="8dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatRecycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingBottom="4dp" />
<LinearLayout
android:id="@+id/chatInputBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="8dp">
<EditText
android:id="@+id/chatInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/chat_hint"
android:imeOptions="actionSend"
android:inputType="textCapSentences" />
<Button
android:id="@+id/chatSend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/send" />
</LinearLayout>
</LinearLayout>
+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.mapsforge.map.android.view.MapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ScrollView
android:id="@+id/mapSidePanel"
android:layout_width="152dp"
android:layout_height="wrap_content"
android:layout_gravity="end|top"
android:layout_margin="6dp"
android:background="#CC0F3460"
android:elevation="4dp"
android:fillViewport="false"
android:scrollbars="none">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="6dp">
<TextView
android:id="@+id/mapStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:textSize="10sp" />
<TextView
android:id="@+id/mapLegend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/map_legend"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Button
android:id="@+id/btnTrack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:minHeight="36dp"
android:text="@string/track_start"
android:textSize="11sp" />
<TextView
android:id="@+id/trackStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/trackSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
</LinearLayout>
</ScrollView>
</FrameLayout>
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/server_url">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editServerUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/telnet_host">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTelnetHost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/telnet_port">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTelnetPort"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/rssi_regex">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editRssiRegex"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/range_regex">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editRangeRegex"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchTelnet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/telnet_enabled" />
<TextView
android:id="@+id/deviceIdLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textSize="12sp" />
<Button
android:id="@+id/btnSaveSettings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/save" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/statsStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp" />
<Button
android:id="@+id/btnSimulate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/simulate_telnet" />
<TextView
android:id="@+id/statsDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="monospace"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/stats_history_title"
android:textSize="14sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/statsHistoryList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
</LinearLayout>
</ScrollView>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/chatItemText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="6dp"
android:textSize="13sp" />
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/historyLine"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:paddingVertical="4dp"
android:textSize="11sp" />
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+7
View File
@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.LoraTester" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+52
View File
@@ -0,0 +1,52 @@
<resources>
<string name="app_name">LoraTester</string>
<string name="tab_map">Карта</string>
<string name="tab_stats">Статистика</string>
<string name="tab_at">AT</string>
<string name="tab_chat">Чат</string>
<string name="tab_settings">Настройки</string>
<string name="at_status">Telnet: %1$s</string>
<string name="at_command_hint">AT+TX или +H …</string>
<string name="at_clear_log">Очистить лог</string>
<string name="at_not_connected">Telnet не подключён. Включите в Настройках.</string>
<string name="at_send_error">Ошибка отправки команды</string>
<string name="server_url">URL сервера</string>
<string name="telnet_host">Telnet host</string>
<string name="telnet_port">Telnet port</string>
<string name="rssi_regex">RSSI regex</string>
<string name="range_regex">Range regex</string>
<string name="telnet_enabled">Подключить telnet</string>
<string name="device_id_label">ID устройства: %1$s</string>
<string name="save">Сохранить</string>
<string name="saved">Сохранено</string>
<string name="chat_hint">Сообщение…</string>
<string name="send">Отправить</string>
<string name="simulate_telnet">Симуляция телнет-кадра</string>
<string name="simulate_sent">Кадр отправлен на сервер</string>
<string name="stats_status">%1$s · Telnet: %2$s</string>
<string name="connected">подключён</string>
<string name="disconnected">нет</string>
<string name="devices_on_server">Устройств на сервере: %1$d</string>
<string name="no_telemetry_yet">Телеметрия ещё не получена. Нажмите «Симуляция» или включите telnet.</string>
<string name="stats_error">Ошибка: %1$s</string>
<string name="map_error">Ошибка карты: %1$s</string>
<string name="devices_label">устройств</string>
<string name="map_legend">● красный — передатчик (TX) ● голубой — приёмник (RX)</string>
<string name="map_status_roles">На карте: %1$d · TX: %2$d · RX: %3$d · %4$s</string>
<string name="map_network_online">онлайн</string>
<string name="map_network_offline">офлайн (кэш)</string>
<string name="track_need_network">Нужна сеть для начала трека</string>
<string name="upload_queue_pending">В очереди: %1$d</string>
<string name="gps_waiting">GPS: ожидание фикса…</string>
<string name="stats_updated_at">обновлено %1$s</string>
<string name="telnet_connected">Telnet: подключён</string>
<string name="telnet_disconnected">Telnet: нет</string>
<string name="stats_history_title">История (сервер)</string>
<string name="track_start">Начать трекинг пути</string>
<string name="track_stop">Остановить трекинг</string>
<string name="track_status">Трекинг: %1$d точек</string>
<string name="track_saved">Трек #%1$d сохранён (%2$d точек)</string>
<string name="track_error">Трек: %1$s</string>
<string name="track_spinner_hint">Сохранённые треки</string>
<string name="track_none">— нет треков —</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.LoraTester" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Theme.LoraTester" parent="Base.Theme.LoraTester" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">grigowashere.ru</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,29 @@
package com.grigowashere.loratester;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import com.grigowashere.loratester.telnet.AtCommandFormatter;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
public class AtCommandFormatterTest {
@Test
public void addsAtPrefix() {
assertEquals("AT+H", AtCommandFormatter.normalize("+H"));
}
@Test
public void keepsExistingAt() {
assertEquals("AT+TX", AtCommandFormatter.normalize("AT+TX"));
}
@Test
public void wireEndsWithCrLf() {
byte[] wire = AtCommandFormatter.toWireBytes("AT+H");
assertEquals("AT+H\r\n", new String(wire, StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,17 @@
package com.grigowashere.loratester;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}
@@ -0,0 +1,21 @@
package com.grigowashere.loratester;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import com.grigowashere.loratester.location.GeoUtils;
import org.junit.Test;
public class GeoUtilsTest {
@Test
public void rejectsNullIsland() {
assertFalse(GeoUtils.isValidCoordinate(0.0, 0.0));
}
@Test
public void acceptsRealCoords() {
assertTrue(GeoUtils.isValidCoordinate(55.75, 37.62));
}
}
@@ -0,0 +1,105 @@
package com.grigowashere.loratester;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.telnet.TelnetFrameParser;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class LoraFrameExtractTest {
private static final String SEND_FRAME = """
SEND
Frequency: 433000000 Hz
Power: 22 dBm
Spreading Factor: 12
Bandwidth: 125 kHz
Packet: 304
Payload: Test TX!
TX Speed: 0.60 pkt/s, 155 bit/s
""";
private static final String RECEIVE_FRAME = """
RECEIVE
Frequency: 433000000 Hz
Power: 0 dBm
Packet Number: 0
Payload: test
RSSI: -78
SNR: 10.5
RX Speed: 0.45 pkt/s, 120 bit/s
PER: 0.00 %
""";
private static final String FULL_SEND = """
SEND
Frequency: 433000000 Hz
Power: 22 dBm
Spreading Factor: 12
Bandwidth: 125 kHz
Code Rate: 4/5
Packet: 304
Payload: Test TX!
TX Speed: 0.60 pkt/s, 155 bit/s
""";
@Test
public void parsesAllLabeledLinesFromSendScreen() {
StatsExtractor extractor = StatsExtractor.withDefaults();
StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND);
assertTrue(stats.metaJson.contains("\"fields\""));
assertTrue(stats.metaJson.contains("Frequency"));
assertTrue(stats.metaJson.contains("Spreading Factor"));
assertTrue(stats.metaJson.contains("Packet"));
assertTrue(stats.metaJson.contains("Payload"));
}
@Test
public void parsesSendFrameAsTransmitter() {
StatsExtractor extractor = StatsExtractor.withDefaults();
StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND);
assertEquals("SEND", stats.frameType);
assertEquals(StatsExtractor.ROLE_TX, stats.role);
assertEquals(22.0, stats.rssi, 0.01);
assertNotNull(stats.metaJson);
assertTrue(stats.metaJson.contains("\"role\":\"TX\""));
assertTrue(stats.metaJson.contains("Test TX!"));
}
@Test
public void parsesReceiveFrameAsReceiver() {
StatsExtractor extractor = StatsExtractor.withDefaults();
StatsExtractor.ExtractedStats stats = extractor.extract(RECEIVE_FRAME);
assertEquals("RECEIVE", stats.frameType);
assertEquals(StatsExtractor.ROLE_RX, stats.role);
assertEquals(-78.0, stats.rssi, 0.01);
assertEquals(10.5, stats.snrDb, 0.01);
assertTrue(stats.metaJson.contains("\"fields\""));
}
@Test
public void splitsTwoFramesByReceiveHeaderWithoutEsc() {
List<String> frames = new ArrayList<>();
TelnetFrameParser parser = new TelnetFrameParser(frames::add);
String stream = SEND_FRAME + "\n" + RECEIVE_FRAME;
parser.append(stream.getBytes(StandardCharsets.UTF_8));
parser.flush();
assertTrue(frames.size() >= 1);
boolean hasSend = false;
boolean hasReceive = false;
for (String f : frames) {
if (f.contains("SEND")) hasSend = true;
if (f.contains("RECEIVE")) hasReceive = true;
}
assertTrue(hasSend || frames.get(0).contains("SEND"));
}
}
@@ -0,0 +1,84 @@
package com.grigowashere.loratester;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.api.TelemetryPayload;
import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.telnet.TelnetFrameParser;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
public class TelemetryUploadTest {
@Test
public void frameTriggersTelemetryPost() throws Exception {
try (MockWebServer server = new MockWebServer()) {
server.enqueue(new MockResponse().setBody("{\"ok\":true}"));
server.start();
String baseUrl = server.url("/").toString();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
ServerApi api = new ServerApi(baseUrl);
StatsExtractor extractor = StatsExtractor.withDefaults();
AtomicReference<String> postedMeta = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
TelnetFrameParser parser = new TelnetFrameParser(frame -> {
StatsExtractor.ExtractedStats stats = extractor.extract(frame);
TelemetryPayload payload = new TelemetryPayload(
"test-device",
55.75,
37.62,
stats.rssi,
stats.rangeM,
null,
stats.metaJson,
stats.role,
System.currentTimeMillis() / 1000.0
);
try {
api.postTelemetry(payload);
postedMeta.set(stats.metaJson);
latch.countDown();
} catch (Exception e) {
throw new RuntimeException(e);
}
});
String chunk = """
SEND
Power: 22 dBm
RSSI: -72
range: 1200
\u001b[2J""";
parser.append(chunk.getBytes(StandardCharsets.UTF_8));
assertTrue(latch.await(5, TimeUnit.SECONDS));
RecordedRequest req = server.takeRequest(5, TimeUnit.SECONDS);
assertEquals("POST", req.getMethod());
assertEquals("android", req.getHeader(ServerApi.HEADER_LORA_CLIENT));
assertTrue(req.getPath().contains("/api/telemetry"));
String body = req.getBody().readUtf8();
assertTrue(body.contains("test-device"));
assertTrue(body.contains("\"meta\""));
assertTrue(body.contains("\"fields\""));
assertTrue(postedMeta.get().contains("SEND"));
assertTrue(postedMeta.get().contains("TX"));
}
}
}
@@ -0,0 +1,76 @@
package com.grigowashere.loratester;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import com.grigowashere.loratester.telnet.TelnetClient;
import org.junit.Test;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public class TelnetClientSendTest {
@Test
public void sendAtCommandWritesCrLfLine() throws Exception {
AtomicReference<String> received = new AtomicReference<>();
CountDownLatch connectedLatch = new CountDownLatch(1);
try (ServerSocket serverSocket = new ServerSocket(0)) {
int port = serverSocket.getLocalPort();
Thread acceptThread = new Thread(() -> {
try (Socket client = serverSocket.accept()) {
connectedLatch.countDown();
InputStream in = client.getInputStream();
OutputStream out = client.getOutputStream();
byte[] buf = new byte[256];
int n = in.read(buf);
if (n > 0) {
received.set(new String(buf, 0, n, StandardCharsets.UTF_8));
}
out.write("OK\r\n".getBytes(StandardCharsets.UTF_8));
out.flush();
Thread.sleep(300);
} catch (Exception ignored) {
}
});
acceptThread.start();
TelnetClient telnet = new TelnetClient("127.0.0.1", port, new TelnetClient.Listener() {
@Override
public void onConnected() {
}
@Override
public void onDisconnected() {
}
@Override
public void onBytes(byte[] data, int length) {
}
@Override
public void onError(String message) {
}
});
telnet.start();
assertTrue(connectedLatch.await(5, TimeUnit.SECONDS));
Thread.sleep(300);
TelnetClient.SendResult result = telnet.sendAtCommand("AT+H");
assertEquals(TelnetClient.SendResult.SENT, result);
Thread.sleep(400);
telnet.stop();
acceptThread.join(2000);
assertEquals("AT+H\r\n", received.get());
}
}
}
@@ -0,0 +1,66 @@
package com.grigowashere.loratester;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import com.grigowashere.loratester.telnet.TelnetFrameParser;
import org.junit.Before;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class TelnetFrameParserTest {
private final List<String> frames = new ArrayList<>();
private TelnetFrameParser parser;
@Before
public void setUp() {
frames.clear();
parser = new TelnetFrameParser(frames::add);
}
@Test
public void splitsOnAnsiClear() {
byte[] data = "frame1\u001b[2Jframe2".getBytes(StandardCharsets.UTF_8);
parser.append(data);
assertEquals(1, frames.size());
assertEquals("frame1", frames.get(0));
parser.flush();
assertEquals(2, frames.size());
assertEquals("frame2", frames.get(1));
}
@Test
public void splitsOnFormFeed() {
byte[] data = "alpha\u000cbeta".getBytes(StandardCharsets.UTF_8);
parser.append(data);
parser.flush();
assertEquals(2, frames.size());
assertEquals("alpha", frames.get(0));
assertEquals("beta", frames.get(1));
}
@Test
public void twoFramesInOneChunk() {
byte[] data = "A\u001b[2JB\u001b[2JC".getBytes(StandardCharsets.UTF_8);
parser.append(data);
parser.flush();
assertEquals(3, frames.size());
assertEquals("A", frames.get(0));
assertEquals("B", frames.get(1));
assertEquals("C", frames.get(2));
}
@Test
public void flushEmitsTrailingBuffer() {
parser.append("tail only".getBytes(StandardCharsets.UTF_8));
assertEquals(0, frames.size());
parser.flush();
assertEquals(1, frames.size());
assertTrue(frames.get(0).contains("tail"));
}
}
+4
View File
@@ -0,0 +1,4 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
}
+21
View File
@@ -0,0 +1,21 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
+42
View File
@@ -0,0 +1,42 @@
[versions]
agp = "8.9.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
appcompat = "1.7.1"
material = "1.13.0"
activity = "1.9.3"
constraintlayout = "2.2.1"
okhttp = "4.12.0"
gson = "2.11.0"
playServicesLocation = "21.3.0"
mapsforge = "0.21.0"
viewpager2 = "1.1.0"
fragment = "1.8.5"
recyclerview = "1.3.2"
mockwebserver = "4.12.0"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
mapsforge-core = { group = "org.mapsforge", name = "mapsforge-core", version.ref = "mapsforge" }
mapsforge-map = { group = "org.mapsforge", name = "mapsforge-map", version.ref = "mapsforge" }
mapsforge-map-android = { group = "org.mapsforge", name = "mapsforge-map-android", version.ref = "mapsforge" }
mapsforge-map-reader = { group = "org.mapsforge", name = "mapsforge-map-reader", version.ref = "mapsforge" }
mapsforge-themes = { group = "org.mapsforge", name = "mapsforge-themes", version.ref = "mapsforge" }
viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" }
fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" }
recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
#Thu Jun 04 08:48:49 MSK 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
Vendored
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+97
View File
@@ -0,0 +1,97 @@
# LoraTester Server
Единый HTTP-сервер для телеметрии LoRa, GPS устройств, истории статистики, треков и чата.
## Быстрый старт
```bash
cd server
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
python flask_app.py
```
Откройте http://localhost:7634
## Переменные окружения
| Переменная | По умолчанию |
|------------|----------------|
| `LORATESTER_HOST` | `0.0.0.0` |
| `LORATESTER_PORT` | `7634` |
| `LORATESTER_DB` | `./loratester.db` |
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) |
## Деплой (grigowashere.ru:7634)
```bash
cd /srv/storage/disk2/services/LoraTester
pip install -r requirements.txt
# один путь БД для всех воркеров:
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
```
После обновления кода **обязательно перезапустите** сервис. При старте выполняются миграции SQLite (`devices`, `telemetry.meta`, таблицы `tracks`).
Проверка:
```bash
curl http://127.0.0.1:7634/api/health
```
Ожидается `"db_ok": true`, `"schema_version": 3`.
Если БД создана вручную и схема битая (`no such table: devices` / `no such column: t.meta`):
1. Остановить сервис
2. `cp loratester.db loratester.db.bak`
3. Удалить `loratester.db` (или оставить бэкап и дать миграциям дописать колонки после рестарта с новым кодом)
4. Запустить снова — `init_db()` создаст полную схему
## API
### Телеметрия (только Android, заголовок `X-Lora-Client: android`)
- `POST /api/telemetry``{device_id, lat?, lon?, rssi?, meta?, fields?, role?, ts?}`
- `GET /api/devices` — последнее состояние устройств
- `GET /api/telemetry?device_id=&limit=&since=&until=&role=` — история (без `raw_frame`)
- `GET /api/stats/history?device_id=` — то же, alias
### Треки (запись с Android)
- `POST /api/tracks/start``{device_id}``{track_id}`
- `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
- `POST /api/tracks/{id}/finish`
- `GET /api/tracks?device_id=`
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через Open-Meteo)
### Прочее
- `POST /api/chat``{device_id, text}`
- `GET /api/chat?since=0`
- `GET /api/health``{ok, db_ok, schema_version, database_path}`
## FastAPI (прод)
```bash
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
```
Flask (`flask_app.py`) — тот же API для локальной разработки.
## Тесты
```bash
cd server
pip install httpx pytest
python -m pytest tests/ -v
```
## Android
URL: `http://grigowashere.ru:7634`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
Telnet: `127.0.0.1:2727` — мост COM→telnet на устройстве.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More