generated from Grigo/AndroidTemplate
Compare commits
22 Commits
cbcd3399b3
..
v9
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a1ccab1e | |||
| e71b6eed2f | |||
| dbef86d2c9 | |||
| 6b34e75f35 | |||
| 0e1fa15a2f | |||
| 64607def4a | |||
| 3399e81447 | |||
| 0571291b69 | |||
| c5805eaa5c | |||
| 012947fd99 | |||
| 23eb7ffb91 | |||
| e20b81c817 | |||
| 2f303134c1 | |||
| ab2a3bb035 | |||
| d28391c71f | |||
| c2f26c8ec3 | |||
| 94e2b772e8 | |||
| 17d383ddc6 | |||
| 8fd7e85c83 | |||
| 81eaa95df3 | |||
| 253a7d74ca | |||
| ab7c214966 |
Generated
+1
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
|
||||
Generated
-1
@@ -1,4 +1,3 @@
|
||||
<?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">
|
||||
|
||||
Generated
+3
-1
@@ -1,4 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings" defaultProject="true" />
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Vendored
+6
-98
@@ -7,10 +7,8 @@ pipeline {
|
||||
ANDROID_SDK_ROOT = '/opt/android-sdk'
|
||||
PATH = "/usr/lib/jvm/java-21-openjdk-amd64/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${env.PATH}"
|
||||
|
||||
TAIGA_PROJECT_ID = '2'
|
||||
TAIGA_URL = 'https://taiga.grigowashere.ru'
|
||||
GITEA_OWNER = 'Grigo'
|
||||
GITEA_REPO = 'TestingAndroidBuild' // Замените на нужный репозиторий
|
||||
GITEA_REPO = 'LoraMapTester' // Замените на нужный репозиторий
|
||||
GITEA_URL = 'https://git.grigowashere.ru' // Базовый URL Gitea
|
||||
GITEA_API_URL = "${GITEA_URL}/api/v1"
|
||||
GITEA_TOKEN_CREDENTIALS_ID = 'Gitea_Credentials' // ID ваших креденшлов для Gitea в Jenkins
|
||||
@@ -40,7 +38,11 @@ pipeline {
|
||||
writeFile file: 'gitea-release.sh', text: '''
|
||||
#!/bin/bash
|
||||
|
||||
apkPath="build/outputs/apk/debug/app-debug.apk"
|
||||
apkPath=$(find . -path '*/build/outputs/apk/debug/*.apk' -type f | head -1)
|
||||
if [ -z "$apkPath" ]; then
|
||||
echo "APK not found under */build/outputs/apk/debug/"
|
||||
exit 1
|
||||
fi
|
||||
headers="Authorization: token $GITEA_TOKEN"
|
||||
|
||||
# Создаем релиз на Gitea
|
||||
@@ -80,98 +82,4 @@ fi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
def result = currentBuild.currentResult ?: 'UNKNOWN'
|
||||
|
||||
withCredentials([string(credentialsId: 'TAIGA_TOKEN', variable: 'TAIGA_TOKEN')]) {
|
||||
sh(returnStatus: true, script: """
|
||||
set +e
|
||||
|
||||
REF=\$(git log -1 --pretty=%B | grep -oE 'TG-[0-9]+' | head -1 | cut -d- -f2 || true)
|
||||
|
||||
if [ -z "\$REF" ]; then
|
||||
echo "No TG-* reference found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export REF
|
||||
export BUILD_RESULT="${result}"
|
||||
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
taiga_url = os.environ["TAIGA_URL"]
|
||||
project_id = os.environ["TAIGA_PROJECT_ID"]
|
||||
token = os.environ["TAIGA_TOKEN"]
|
||||
ref = os.environ["REF"]
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def get_json(path):
|
||||
url = f"{taiga_url}{path}"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
targets = [
|
||||
("userstories", "User Story"),
|
||||
("issues", "Issue"),
|
||||
("tasks", "Task"),
|
||||
]
|
||||
|
||||
found = None
|
||||
|
||||
for endpoint, label in targets:
|
||||
data = get_json(f"/api/v1/{endpoint}/by_ref?project={project_id}&ref={ref}")
|
||||
if data and "id" in data:
|
||||
found = (endpoint, label, data)
|
||||
break
|
||||
|
||||
if not found:
|
||||
print(f"Taiga TG-{ref} not found")
|
||||
raise SystemExit(0)
|
||||
|
||||
endpoint, label, data = found
|
||||
|
||||
comment = (
|
||||
f"Jenkins Android build #{os.environ['BUILD_NUMBER']}: {os.environ['BUILD_RESULT']}\\n"
|
||||
f"{os.environ['BUILD_URL']}"
|
||||
)
|
||||
|
||||
payload = json.dumps({
|
||||
"comment": comment,
|
||||
"version": data["version"],
|
||||
}).encode("utf-8")
|
||||
|
||||
url = f"{taiga_url}/api/v1/{endpoint}/{data['id']}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers=headers,
|
||||
method="PATCH",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
print(f"Commented Taiga TG-{ref} ({label}), HTTP {r.status}")
|
||||
except Exception as e:
|
||||
print(f"Taiga comment warning: {e}")
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
""".stripIndent())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ Android-клиент и Python-сервер для мониторинга LoRa
|
||||
|
||||
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
|
||||
3. В приложении: Настройки → URL `https://lora.grigowashere.ru` (или свой сервер), включите telnet при наличии моста COM→telnet
|
||||
|
||||
## Тесты
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
@@ -30,6 +36,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".LoraForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location|dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.grigowashere.loratester.api.DeviceCommand;
|
||||
import com.grigowashere.loratester.api.PairedTrackSession;
|
||||
import com.grigowashere.loratester.api.ServerApi;
|
||||
import com.grigowashere.loratester.telnet.AtCommands;
|
||||
import com.grigowashere.loratester.telnet.TelnetClient;
|
||||
import com.grigowashere.loratester.track.TrackRecorder;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class CommandPoller {
|
||||
|
||||
private static final String TAG = "CommandPoller";
|
||||
private static final long COMMAND_POLL_MS = 2000;
|
||||
private static final long PAIRED_POLL_MS = 1500;
|
||||
|
||||
private final ServerApi serverApi;
|
||||
private final String deviceId;
|
||||
private final TelemetryUploader uploader;
|
||||
private final TrackRecorder trackRecorder;
|
||||
private final PeerStatsCache peerStatsCache;
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "CommandPoller");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
private volatile long pendingAckSessionId = -1;
|
||||
private volatile long startedSessionId = -1;
|
||||
|
||||
public CommandPoller(
|
||||
ServerApi serverApi,
|
||||
String deviceId,
|
||||
TelemetryUploader uploader,
|
||||
TrackRecorder trackRecorder,
|
||||
PeerStatsCache peerStatsCache
|
||||
) {
|
||||
this.serverApi = serverApi;
|
||||
this.deviceId = deviceId;
|
||||
this.uploader = uploader;
|
||||
this.trackRecorder = trackRecorder;
|
||||
this.peerStatsCache = peerStatsCache;
|
||||
trackRecorder.setPairedListener(new TrackRecorder.Listener() {
|
||||
@Override
|
||||
public void onStateChanged(boolean recording, int pointCount, long trackId) {
|
||||
if (recording && trackId > 0 && pendingAckSessionId > 0) {
|
||||
long sid = pendingAckSessionId;
|
||||
pendingAckSessionId = -1;
|
||||
executor.execute(() -> ackSession(sid, trackId));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
Log.w(TAG, "track: " + message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public PeerStatsCache getPeerStatsCache() {
|
||||
return peerStatsCache;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::pollCommandsSafe, 0, COMMAND_POLL_MS, TimeUnit.MILLISECONDS);
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::pollPairedSafe, 0, PAIRED_POLL_MS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running.set(false);
|
||||
}
|
||||
|
||||
private void pollCommandsSafe() {
|
||||
if (!running.get()) {
|
||||
return;
|
||||
}
|
||||
pollCommands();
|
||||
}
|
||||
|
||||
private void pollPairedSafe() {
|
||||
if (!running.get()) {
|
||||
return;
|
||||
}
|
||||
pollPairedSession();
|
||||
}
|
||||
|
||||
private void pollCommands() {
|
||||
try {
|
||||
List<DeviceCommand> cmds = serverApi.pollPendingCommands(deviceId);
|
||||
for (DeviceCommand cmd : cmds) {
|
||||
execute(cmd);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "command poll failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void execute(DeviceCommand cmd) {
|
||||
if (cmd == null || cmd.kind == null) {
|
||||
return;
|
||||
}
|
||||
switch (cmd.kind) {
|
||||
case "at" -> {
|
||||
List<String> lines = extractLines(cmd.payload);
|
||||
if (lines != null && !lines.isEmpty()) {
|
||||
uploader.sendMacroSequence(lines, r ->
|
||||
Log.i(TAG, "remote macro " + lines + " -> " + r));
|
||||
} else {
|
||||
String line = cmd.payload != null && cmd.payload.get("line") != null
|
||||
? String.valueOf(cmd.payload.get("line")) : null;
|
||||
if (line != null) {
|
||||
uploader.sendAtCommand(line, r ->
|
||||
Log.i(TAG, "remote AT " + line + " -> " + r));
|
||||
}
|
||||
}
|
||||
}
|
||||
case "mode" -> {
|
||||
String role = cmd.payload != null && cmd.payload.get("role") != null
|
||||
? String.valueOf(cmd.payload.get("role")) : null;
|
||||
if ("TX".equalsIgnoreCase(role)) {
|
||||
uploader.sendAtCommand(AtCommands.TRANSMIT, r -> {});
|
||||
} else if ("RX".equalsIgnoreCase(role)) {
|
||||
uploader.sendAtCommand(AtCommands.RECEIVE, r -> {});
|
||||
}
|
||||
}
|
||||
case "stats_push" -> peerStatsCache.updateFromPayload(cmd.payload);
|
||||
default -> Log.w(TAG, "unknown kind " + cmd.kind);
|
||||
}
|
||||
}
|
||||
|
||||
private void pollPairedSession() {
|
||||
try {
|
||||
Map<String, Object> resp = serverApi.getActivePairedTrack();
|
||||
Object sessionObj = resp.get("session");
|
||||
if (!(sessionObj instanceof Map)) {
|
||||
return;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> m = (Map<String, Object>) sessionObj;
|
||||
PairedTrackSession session = mapSession(m);
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
boolean inSession = deviceId.equals(session.device_a)
|
||||
|| deviceId.equals(session.device_b);
|
||||
if (!inSession) {
|
||||
return;
|
||||
}
|
||||
if (!session.ready || trackRecorder.isRecording()) {
|
||||
return;
|
||||
}
|
||||
if (startedSessionId == session.id) {
|
||||
return;
|
||||
}
|
||||
Long myTrack = deviceId.equals(session.device_a)
|
||||
? session.track_id_a : session.track_id_b;
|
||||
if (myTrack != null && myTrack > 0) {
|
||||
startedSessionId = session.id;
|
||||
return;
|
||||
}
|
||||
startedSessionId = session.id;
|
||||
pendingAckSessionId = session.id;
|
||||
trackRecorder.start();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "paired poll failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void ackSession(long sessionId, long trackId) {
|
||||
try {
|
||||
serverApi.ackPairedTrack(sessionId, deviceId, trackId);
|
||||
Log.i(TAG, "paired ack session=" + sessionId + " track=" + trackId);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "paired ack failed", e);
|
||||
pendingAckSessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
private static PairedTrackSession mapSession(Map<String, Object> m) {
|
||||
if (m == null) {
|
||||
return null;
|
||||
}
|
||||
PairedTrackSession s = new PairedTrackSession();
|
||||
Object id = m.get("id");
|
||||
if (id instanceof Number) {
|
||||
s.id = ((Number) id).longValue();
|
||||
}
|
||||
s.device_a = str(m.get("device_a"));
|
||||
s.device_b = str(m.get("device_b"));
|
||||
s.initiator = str(m.get("initiator"));
|
||||
s.status = str(m.get("status"));
|
||||
s.start_at = num(m.get("start_at"));
|
||||
s.created_at = num(m.get("created_at"));
|
||||
s.server_time = num(m.get("server_time"));
|
||||
Object ready = m.get("ready");
|
||||
s.ready = ready instanceof Boolean && (Boolean) ready;
|
||||
s.track_id_a = longOrNull(m.get("track_id_a"));
|
||||
s.track_id_b = longOrNull(m.get("track_id_b"));
|
||||
return s;
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o != null ? String.valueOf(o) : null;
|
||||
}
|
||||
|
||||
private static double num(Object o) {
|
||||
return o instanceof Number ? ((Number) o).doubleValue() : 0;
|
||||
}
|
||||
|
||||
private static Long longOrNull(Object o) {
|
||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static List<String> extractLines(Map<String, Object> payload) {
|
||||
if (payload == null) {
|
||||
return null;
|
||||
}
|
||||
Object raw = payload.get("lines");
|
||||
if (!(raw instanceof List<?> list) || list.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<String> lines = new java.util.ArrayList<>();
|
||||
for (Object item : list) {
|
||||
if (item != null) {
|
||||
String line = String.valueOf(item).trim();
|
||||
if (!line.isEmpty()) {
|
||||
lines.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.isEmpty() ? null : lines;
|
||||
}
|
||||
|
||||
public void postMacroToPeer(String peerId, List<String> lines) {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("lines", lines);
|
||||
postCommandToPeer(peerId, "at", payload);
|
||||
}
|
||||
|
||||
public void postCommandToPeer(String peerId, String kind, Map<String, Object> payload) {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
serverApi.postCommand(deviceId, peerId, kind, payload);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "post command failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void startPairedTrack(Runnable onDone, java.util.function.Consumer<String> onError) {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("device_id", deviceId);
|
||||
serverApi.startPairedTrack(body);
|
||||
startedSessionId = -1;
|
||||
pendingAckSessionId = -1;
|
||||
if (onDone != null) {
|
||||
mainHandler.post(onDone);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "start paired failed", e);
|
||||
if (onError != null) {
|
||||
mainHandler.post(() -> onError.accept(
|
||||
e.getMessage() != null ? e.getMessage() : "error"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.grigowashere.loratester;
|
||||
import android.app.Application;
|
||||
|
||||
import com.grigowashere.loratester.api.ServerApi;
|
||||
import com.grigowashere.loratester.location.LocationTracker;
|
||||
import com.grigowashere.loratester.net.NetworkMonitor;
|
||||
import com.grigowashere.loratester.track.TrackRecorder;
|
||||
|
||||
@@ -14,6 +15,9 @@ public class LoraApp extends Application {
|
||||
private SettingsRepository settingsRepository;
|
||||
private TrackRecorder trackRecorder;
|
||||
private NetworkMonitor networkMonitor;
|
||||
private PeerStatsCache peerStatsCache;
|
||||
private CommandPoller commandPoller;
|
||||
private LocationTracker locationTracker;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
@@ -23,12 +27,31 @@ public class LoraApp extends Application {
|
||||
networkMonitor = new NetworkMonitor(this);
|
||||
networkMonitor.start();
|
||||
telemetryUploader = new TelemetryUploader(this, settingsRepository, networkMonitor);
|
||||
peerStatsCache = new PeerStatsCache();
|
||||
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
|
||||
String deviceId = settingsRepository.getOrCreateDeviceId();
|
||||
trackRecorder = new TrackRecorder(
|
||||
new ServerApi(settingsRepository.getServerUrl()),
|
||||
serverApi,
|
||||
telemetryUploader,
|
||||
settingsRepository.getOrCreateDeviceId(),
|
||||
deviceId,
|
||||
networkMonitor
|
||||
);
|
||||
commandPoller = new CommandPoller(
|
||||
serverApi,
|
||||
deviceId,
|
||||
telemetryUploader,
|
||||
trackRecorder,
|
||||
peerStatsCache
|
||||
);
|
||||
commandPoller.start();
|
||||
telemetryUploader.registerPresence();
|
||||
if (networkMonitor != null) {
|
||||
networkMonitor.addListener(online -> {
|
||||
if (online) {
|
||||
telemetryUploader.registerPresence();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public NetworkMonitor getNetworkMonitor() {
|
||||
@@ -47,12 +70,51 @@ public class LoraApp extends Application {
|
||||
return trackRecorder;
|
||||
}
|
||||
|
||||
public PeerStatsCache getPeerStatsCache() {
|
||||
return peerStatsCache;
|
||||
}
|
||||
|
||||
public CommandPoller getCommandPoller() {
|
||||
return commandPoller;
|
||||
}
|
||||
|
||||
public synchronized void startLocationUpdates() {
|
||||
if (locationTracker == null) {
|
||||
locationTracker = new LocationTracker(this, (lat, lon, alt) -> {
|
||||
telemetryUploader.updateLocation(lat, lon);
|
||||
trackRecorder.updateLocation(lat, lon, alt);
|
||||
});
|
||||
}
|
||||
locationTracker.start();
|
||||
}
|
||||
|
||||
public synchronized void stopLocationUpdates() {
|
||||
if (locationTracker != null) {
|
||||
locationTracker.stop();
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshTrackRecorder() {
|
||||
if (commandPoller != null) {
|
||||
commandPoller.stop();
|
||||
}
|
||||
if (peerStatsCache == null) {
|
||||
peerStatsCache = new PeerStatsCache();
|
||||
}
|
||||
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
|
||||
trackRecorder = new TrackRecorder(
|
||||
new ServerApi(settingsRepository.getServerUrl()),
|
||||
serverApi,
|
||||
telemetryUploader,
|
||||
settingsRepository.getOrCreateDeviceId(),
|
||||
networkMonitor
|
||||
);
|
||||
commandPoller = new CommandPoller(
|
||||
serverApi,
|
||||
settingsRepository.getOrCreateDeviceId(),
|
||||
telemetryUploader,
|
||||
trackRecorder,
|
||||
peerStatsCache
|
||||
);
|
||||
commandPoller.start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.PowerManager;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import com.grigowashere.loratester.track.TrackRecorder;
|
||||
|
||||
public class LoraForegroundService extends Service {
|
||||
|
||||
private static final String CHANNEL_ID = "lora_background";
|
||||
private static final int NOTIFICATION_ID = 1;
|
||||
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
private LoraApp app;
|
||||
|
||||
private final Runnable notificationTicker = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
updateNotification();
|
||||
handler.postDelayed(this, 5000L);
|
||||
}
|
||||
};
|
||||
|
||||
public static void ensureRunning(Context context) {
|
||||
Context appContext = context.getApplicationContext();
|
||||
Intent intent = new Intent(appContext, LoraForegroundService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
appContext.startForegroundService(intent);
|
||||
} else {
|
||||
appContext.startService(intent);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
app = (LoraApp) getApplication();
|
||||
createNotificationChannel();
|
||||
acquireWakeLock();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
Notification notification = buildNotification();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||
| ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
);
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification);
|
||||
}
|
||||
app.startLocationUpdates();
|
||||
handler.removeCallbacks(notificationTicker);
|
||||
handler.post(notificationTicker);
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
handler.removeCallbacks(notificationTicker);
|
||||
releaseWakeLock();
|
||||
app.stopLocationUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskRemoved(Intent rootIntent) {
|
||||
TelemetryUploader uploader = app.getTelemetryUploader();
|
||||
if (uploader != null) {
|
||||
uploader.stopTelnet();
|
||||
}
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
stopSelf();
|
||||
super.onTaskRemoved(rootIntent);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void acquireWakeLock() {
|
||||
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
|
||||
if (pm == null) {
|
||||
return;
|
||||
}
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "LoraTester::Background");
|
||||
wakeLock.setReferenceCounted(false);
|
||||
wakeLock.acquire();
|
||||
}
|
||||
|
||||
private void releaseWakeLock() {
|
||||
if (wakeLock != null && wakeLock.isHeld()) {
|
||||
wakeLock.release();
|
||||
}
|
||||
wakeLock = null;
|
||||
}
|
||||
|
||||
private void createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
);
|
||||
channel.setDescription(getString(R.string.notification_channel_desc));
|
||||
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||
if (nm != null) {
|
||||
nm.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
|
||||
private Notification buildNotification() {
|
||||
Intent open = new Intent(this, MainActivity.class);
|
||||
open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
PendingIntent pending = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
open,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
|
||||
TelemetryUploader uploader = app.getTelemetryUploader();
|
||||
TrackRecorder recorder = app.getTrackRecorder();
|
||||
SettingsRepository settings = app.getSettingsRepository();
|
||||
|
||||
boolean telnetOn = settings.isTelnetEnabled();
|
||||
boolean telnetConnected = uploader != null && uploader.isTelnetConnected();
|
||||
boolean recording = recorder != null && recorder.isRecording();
|
||||
int points = recorder != null ? recorder.getPointCount() : 0;
|
||||
|
||||
String telnetLine = telnetOn
|
||||
? getString(telnetConnected ? R.string.telnet_connected : R.string.telnet_disconnected)
|
||||
: getString(R.string.telnet_disabled_short);
|
||||
String trackLine = recording
|
||||
? getString(R.string.notification_track_recording, points)
|
||||
: getString(R.string.notification_track_idle);
|
||||
|
||||
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_service)
|
||||
.setContentTitle(getString(R.string.notification_title))
|
||||
.setContentText(telnetLine + " · " + trackLine)
|
||||
.setSubText(getString(R.string.notification_subtitle))
|
||||
.setContentIntent(pending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void updateNotification() {
|
||||
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||
if (nm != null) {
|
||||
nm.notify(NOTIFICATION_ID, buildNotification());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.PowerManager;
|
||||
import android.provider.Settings;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
@@ -17,19 +23,30 @@ import androidx.viewpager2.widget.ViewPager2;
|
||||
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.google.android.material.tabs.TabLayoutMediator;
|
||||
import com.grigowashere.loratester.location.LocationTracker;
|
||||
import com.grigowashere.loratester.track.TrackRecorder;
|
||||
import com.grigowashere.loratester.ui.MainPagerAdapter;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private TelemetryUploader telemetryUploader;
|
||||
private LocationTracker locationTracker;
|
||||
private LoraApp app;
|
||||
private SettingsRepository settings;
|
||||
private boolean backgroundLocationRequested;
|
||||
|
||||
private final ActivityResultLauncher<String[]> locationPermissionLauncher =
|
||||
registerForActivityResult(
|
||||
new ActivityResultContracts.RequestMultiplePermissions(),
|
||||
result -> startLocationIfPermitted()
|
||||
result -> onForegroundLocationReady()
|
||||
);
|
||||
|
||||
private final ActivityResultLauncher<String> backgroundLocationLauncher =
|
||||
registerForActivityResult(
|
||||
new ActivityResultContracts.RequestPermission(),
|
||||
granted -> startBackgroundWork()
|
||||
);
|
||||
|
||||
private final ActivityResultLauncher<String> notificationPermissionLauncher =
|
||||
registerForActivityResult(
|
||||
new ActivityResultContracts.RequestPermission(),
|
||||
granted -> startBackgroundWork()
|
||||
);
|
||||
|
||||
@Override
|
||||
@@ -43,12 +60,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
return insets;
|
||||
});
|
||||
|
||||
LoraApp app = (LoraApp) getApplication();
|
||||
telemetryUploader = app.getTelemetryUploader();
|
||||
SettingsRepository settings = app.getSettingsRepository();
|
||||
app = (LoraApp) getApplication();
|
||||
settings = app.getSettingsRepository();
|
||||
|
||||
ViewPager2 pager = findViewById(R.id.viewPager);
|
||||
TabLayout tabs = findViewById(R.id.tabLayout);
|
||||
pager.setOffscreenPageLimit(1);
|
||||
pager.setAdapter(new MainPagerAdapter(this));
|
||||
new TabLayoutMediator(tabs, pager, (tab, position) -> {
|
||||
int titleRes = switch (position) {
|
||||
@@ -61,41 +78,92 @@ public class MainActivity extends AppCompatActivity {
|
||||
tab.setText(titleRes);
|
||||
}).attach();
|
||||
|
||||
TrackRecorder trackRecorder = app.getTrackRecorder();
|
||||
locationTracker = new LocationTracker(this, (lat, lon, alt) -> {
|
||||
telemetryUploader.updateLocation(lat, lon);
|
||||
trackRecorder.updateLocation(lat, lon, alt);
|
||||
});
|
||||
|
||||
requestLocationPermission();
|
||||
requestStartupPermissions();
|
||||
if (settings.isTelnetEnabled()) {
|
||||
telemetryUploader.startTelnet();
|
||||
app.getTelemetryUploader().startTelnet();
|
||||
}
|
||||
}
|
||||
|
||||
private void requestLocationPermission() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
startLocationIfPermitted();
|
||||
} else {
|
||||
private void requestStartupPermissions() {
|
||||
if (hasForegroundLocation()) {
|
||||
onForegroundLocationReady();
|
||||
return;
|
||||
}
|
||||
locationPermissionLauncher.launch(new String[]{
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
});
|
||||
}
|
||||
|
||||
private void onForegroundLocationReady() {
|
||||
if (!hasForegroundLocation()) {
|
||||
Toast.makeText(this, R.string.background_location_required, Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
requestNotificationPermissionIfNeeded();
|
||||
requestBackgroundLocationIfNeeded();
|
||||
startBackgroundWork();
|
||||
}
|
||||
|
||||
private void startLocationIfPermitted() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
private void requestNotificationPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
return;
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
locationTracker.start();
|
||||
return;
|
||||
}
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
|
||||
}
|
||||
|
||||
private void requestBackgroundLocationIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return;
|
||||
}
|
||||
if (hasBackgroundLocation()) {
|
||||
return;
|
||||
}
|
||||
if (backgroundLocationRequested) {
|
||||
return;
|
||||
}
|
||||
backgroundLocationRequested = true;
|
||||
if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_BACKGROUND_LOCATION)) {
|
||||
Toast.makeText(this, R.string.background_location_rationale, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
backgroundLocationLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION);
|
||||
}
|
||||
|
||||
private void startBackgroundWork() {
|
||||
LoraForegroundService.ensureRunning(this);
|
||||
if (settings.isTelnetEnabled()) {
|
||||
app.getTelemetryUploader().startTelnet();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
locationTracker.stop();
|
||||
telemetryUploader.stopTelnet();
|
||||
super.onDestroy();
|
||||
private boolean hasForegroundLocation() {
|
||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
private boolean hasBackgroundLocation() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return hasForegroundLocation();
|
||||
}
|
||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
public static void openBatteryOptimizationSettings(@NonNull android.content.Context context) {
|
||||
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
|
||||
intent.setData(Uri.parse("package:" + context.getPackageName()));
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
public static boolean isIgnoringBatteryOptimizations(@NonNull android.content.Context context) {
|
||||
PowerManager pm = (PowerManager) context.getSystemService(POWER_SERVICE);
|
||||
if (pm == null) {
|
||||
return true;
|
||||
}
|
||||
return pm.isIgnoringBatteryOptimizations(context.getPackageName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import com.grigowashere.loratester.api.DeviceInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class PeerDevices {
|
||||
|
||||
private static final long ONLINE_MS = 30_000;
|
||||
|
||||
private PeerDevices() {
|
||||
}
|
||||
|
||||
public static Result resolve(List<DeviceInfo> devices, String selfId) {
|
||||
if (devices == null || selfId == null) {
|
||||
return Result.error("no_devices");
|
||||
}
|
||||
List<DeviceInfo> android = new ArrayList<>();
|
||||
long now = System.currentTimeMillis();
|
||||
for (DeviceInfo d : devices) {
|
||||
if (d.device_id != null && d.device_id.startsWith("android-")) {
|
||||
android.add(d);
|
||||
}
|
||||
}
|
||||
if (android.size() != 2) {
|
||||
return Result.error("expected_two");
|
||||
}
|
||||
String peer = null;
|
||||
int online = 0;
|
||||
for (DeviceInfo d : android) {
|
||||
if (d.last_seen > 0 && (now / 1000.0 - d.last_seen) <= 30) {
|
||||
online++;
|
||||
}
|
||||
if (!selfId.equals(d.device_id)) {
|
||||
peer = d.device_id;
|
||||
}
|
||||
}
|
||||
if (peer == null) {
|
||||
return Result.error("peer_missing");
|
||||
}
|
||||
return new Result(peer, android.size(), online);
|
||||
}
|
||||
|
||||
public static final class Result {
|
||||
public final String peerId;
|
||||
public final int deviceCount;
|
||||
public final int onlineCount;
|
||||
public final String error;
|
||||
|
||||
private Result(String peerId, int deviceCount, int onlineCount) {
|
||||
this.peerId = peerId;
|
||||
this.deviceCount = deviceCount;
|
||||
this.onlineCount = onlineCount;
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
private Result(String error) {
|
||||
this.peerId = null;
|
||||
this.deviceCount = 0;
|
||||
this.onlineCount = 0;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
static Result error(String code) {
|
||||
return new Result(code);
|
||||
}
|
||||
|
||||
public boolean ok() {
|
||||
return peerId != null;
|
||||
}
|
||||
|
||||
public boolean bothOnline() {
|
||||
return ok() && onlineCount >= 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class PeerStatsCache {
|
||||
|
||||
public static final class Snapshot {
|
||||
public final String meta;
|
||||
public final String role;
|
||||
public final Double rssi;
|
||||
public final long atMs;
|
||||
|
||||
public Snapshot(String meta, String role, Double rssi, long atMs) {
|
||||
this.meta = meta;
|
||||
this.role = role;
|
||||
this.rssi = rssi;
|
||||
this.atMs = atMs;
|
||||
}
|
||||
}
|
||||
|
||||
private final AtomicReference<Snapshot> snapshot = new AtomicReference<>();
|
||||
|
||||
public void updateFromPayload(Map<String, Object> payload) {
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
String meta = payload.get("meta") != null ? String.valueOf(payload.get("meta")) : null;
|
||||
String role = payload.get("role") != null ? String.valueOf(payload.get("role")) : null;
|
||||
Double rssi = null;
|
||||
Object r = payload.get("rssi");
|
||||
if (r instanceof Number) {
|
||||
rssi = ((Number) r).doubleValue();
|
||||
}
|
||||
snapshot.set(new Snapshot(meta, role, rssi, System.currentTimeMillis()));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Snapshot get() {
|
||||
return snapshot.get();
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
snapshot.set(null);
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,10 @@ public class SettingsRepository {
|
||||
private static final String KEY_RANGE_REGEX = "range_regex";
|
||||
private static final String KEY_TELNET_ENABLED = "telnet_enabled";
|
||||
private static final String KEY_DEVICE_ID = "device_id";
|
||||
private static final String KEY_DEVICE_LABEL = "device_label";
|
||||
|
||||
public static final String DEFAULT_SERVER = "http://grigowashere.ru:7634";
|
||||
public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru";
|
||||
private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634";
|
||||
public static final String DEFAULT_TELNET_HOST = "127.0.0.1";
|
||||
public static final int DEFAULT_TELNET_PORT = 2727;
|
||||
public static final String DEFAULT_RSSI_REGEX = "(?:RSSI|Power)[:\\s]*(-?\\d+(?:\\.\\d+)?)";
|
||||
@@ -25,6 +27,28 @@ public class SettingsRepository {
|
||||
public SettingsRepository(Context context) {
|
||||
prefs = context.getApplicationContext()
|
||||
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
migrateLegacyServerUrl();
|
||||
}
|
||||
|
||||
private void migrateLegacyServerUrl() {
|
||||
String current = prefs.getString(KEY_SERVER_URL, null);
|
||||
if (current == null || !isLegacyServerUrl(current)) {
|
||||
return;
|
||||
}
|
||||
prefs.edit().putString(KEY_SERVER_URL, DEFAULT_SERVER).apply();
|
||||
}
|
||||
|
||||
static boolean isLegacyServerUrl(String url) {
|
||||
if (url == null) {
|
||||
return false;
|
||||
}
|
||||
String u = url.trim().toLowerCase();
|
||||
while (u.endsWith("/")) {
|
||||
u = u.substring(0, u.length() - 1);
|
||||
}
|
||||
return u.equals(LEGACY_SERVER_HTTP)
|
||||
|| u.equals("http://grigowashere.ru")
|
||||
|| u.equals("https://grigowashere.ru:7634");
|
||||
}
|
||||
|
||||
public String getServerUrl() {
|
||||
@@ -83,4 +107,16 @@ public class SettingsRepository {
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getDeviceLabel() {
|
||||
return prefs.getString(KEY_DEVICE_LABEL, null);
|
||||
}
|
||||
|
||||
public void setDeviceLabel(String label) {
|
||||
if (label == null) {
|
||||
prefs.edit().remove(KEY_DEVICE_LABEL).apply();
|
||||
} else {
|
||||
prefs.edit().putString(KEY_DEVICE_LABEL, label.trim()).apply();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
@@ -11,12 +12,14 @@ 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.RadioMacroBuilder;
|
||||
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.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
@@ -112,6 +115,18 @@ public class TelemetryUploader implements TelnetClient.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasGpsFix() {
|
||||
return GeoUtils.isValidCoordinate(lat, lon);
|
||||
}
|
||||
|
||||
public double getGpsLat() {
|
||||
return lat;
|
||||
}
|
||||
|
||||
public double getGpsLon() {
|
||||
return lon;
|
||||
}
|
||||
|
||||
private Double validLat() {
|
||||
return GeoUtils.isValidCoordinate(lat, lon) ? lat : null;
|
||||
}
|
||||
@@ -168,6 +183,52 @@ public class TelemetryUploader implements TelnetClient.Listener {
|
||||
});
|
||||
}
|
||||
|
||||
public void sendMacroSequence(List<String> lines, AtSendCallback callback) {
|
||||
telnetExecutor.execute(() -> {
|
||||
TelnetClient.SendResult last = TelnetClient.SendResult.SENT;
|
||||
if (lines != null) {
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
String line = lines.get(i);
|
||||
if (line == null || line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
last = sendLineOnWorker(line);
|
||||
if (last != TelnetClient.SendResult.SENT) {
|
||||
break;
|
||||
}
|
||||
if (i < lines.size() - 1) {
|
||||
try {
|
||||
Thread.sleep(150);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (callback != null) {
|
||||
TelnetClient.SendResult r = last;
|
||||
mainHandler.post(() -> callback.onResult(r));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private TelnetClient.SendResult sendLineOnWorker(String line) {
|
||||
if (RadioMacroBuilder.STOP.equals(line)) {
|
||||
appendConsole(">> S\n");
|
||||
if (telnetClient == null) {
|
||||
appendConsole("!! telnet not started\n");
|
||||
return TelnetClient.SendResult.NOT_CONNECTED;
|
||||
}
|
||||
TelnetClient.SendResult result = telnetClient.sendRawLine(line);
|
||||
if (result != TelnetClient.SendResult.SENT) {
|
||||
appendConsole("!! send failed: " + result + "\n");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return sendAtCommandOnWorker(line);
|
||||
}
|
||||
|
||||
private TelnetClient.SendResult sendAtCommandOnWorker(String command) {
|
||||
String normalized = AtCommandFormatter.normalize(command);
|
||||
appendConsole(">> " + normalized + "\n");
|
||||
@@ -225,6 +286,7 @@ public class TelemetryUploader implements TelnetClient.Listener {
|
||||
}
|
||||
TelemetryPayload payload = new TelemetryPayload(
|
||||
settings.getOrCreateDeviceId(),
|
||||
phoneLabel(),
|
||||
validLat(),
|
||||
validLon(),
|
||||
stats.rssi,
|
||||
@@ -237,6 +299,35 @@ public class TelemetryUploader implements TelnetClient.Listener {
|
||||
uploadExecutor.execute(() -> uploadTelemetry(payload));
|
||||
}
|
||||
|
||||
public void registerPresence() {
|
||||
uploadExecutor.execute(() -> {
|
||||
TelemetryPayload payload = new TelemetryPayload(
|
||||
settings.getOrCreateDeviceId(),
|
||||
phoneLabel(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
System.currentTimeMillis() / 1000.0
|
||||
);
|
||||
uploadTelemetry(payload);
|
||||
});
|
||||
}
|
||||
|
||||
private String phoneLabel() {
|
||||
String custom = settings.getDeviceLabel();
|
||||
if (custom != null && !custom.isBlank()) {
|
||||
return custom.trim();
|
||||
}
|
||||
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER : "";
|
||||
String model = Build.MODEL != null ? Build.MODEL : "";
|
||||
String label = (manufacturer + " " + model).trim();
|
||||
return label.isEmpty() ? null : label;
|
||||
}
|
||||
|
||||
private void uploadTelemetry(TelemetryPayload payload) {
|
||||
if (networkMonitor.isOnline()) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class DeviceCommand {
|
||||
public long id;
|
||||
public String from_device_id;
|
||||
public String to_device_id;
|
||||
public String kind;
|
||||
public Map<String, Object> payload;
|
||||
public double created_at;
|
||||
public Double delivered_at;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
|
||||
|
||||
public class DeviceInfo {
|
||||
public String device_id;
|
||||
public String label;
|
||||
public double last_seen;
|
||||
public Double lat;
|
||||
public Double lon;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ElevationGridResult {
|
||||
public boolean ok;
|
||||
public String error;
|
||||
public Center center;
|
||||
public double radius_m;
|
||||
public double step_m;
|
||||
public double min_delta_m;
|
||||
public double max_delta_m;
|
||||
public List<GridPoint> points;
|
||||
|
||||
public static class Center {
|
||||
public double lat;
|
||||
public double lon;
|
||||
public double elevation_m;
|
||||
}
|
||||
|
||||
public static class GridPoint {
|
||||
public int i;
|
||||
public int j;
|
||||
public double lat;
|
||||
public double lon;
|
||||
public double dist_m;
|
||||
public Double elevation_m;
|
||||
public double delta_m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
public class NearestHillResult {
|
||||
public boolean ok;
|
||||
public String error;
|
||||
public HillPoint center;
|
||||
public HillPoint hill;
|
||||
public double radius_m;
|
||||
public int candidates;
|
||||
|
||||
public static class HillPoint {
|
||||
public double lat;
|
||||
public double lon;
|
||||
public Double elevation_m;
|
||||
public Double dist_m;
|
||||
public Double prominence_m;
|
||||
public Boolean is_local_max;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
public class PairedTrackSession {
|
||||
public long id;
|
||||
public String device_a;
|
||||
public String device_b;
|
||||
public String initiator;
|
||||
public String status;
|
||||
public double start_at;
|
||||
public Long track_id_a;
|
||||
public Long track_id_b;
|
||||
public double created_at;
|
||||
public double server_time;
|
||||
public boolean ready;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ public class ServerApi {
|
||||
private static final Type TELEMETRY_HISTORY =
|
||||
new TypeToken<List<TelemetryHistoryItem>>() {}.getType();
|
||||
private static final Type TRACK_LIST = new TypeToken<List<TrackInfo>>() {}.getType();
|
||||
private static final Type COMMAND_LIST = new TypeToken<List<DeviceCommand>>() {}.getType();
|
||||
|
||||
private final String baseUrl;
|
||||
private final OkHttpClient client;
|
||||
@@ -50,6 +51,9 @@ public class ServerApi {
|
||||
public void postTelemetry(TelemetryPayload payload) throws IOException {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("device_id", payload.deviceId);
|
||||
if (payload.deviceLabel != null && !payload.deviceLabel.isBlank()) {
|
||||
body.put("device_label", payload.deviceLabel);
|
||||
}
|
||||
if (payload.lat != null) body.put("lat", payload.lat);
|
||||
if (payload.lon != null) body.put("lon", payload.lon);
|
||||
if (payload.rssi != null) body.put("rssi", payload.rssi);
|
||||
@@ -114,6 +118,73 @@ public class ServerApi {
|
||||
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
|
||||
}
|
||||
|
||||
public void postCommand(
|
||||
String fromDeviceId,
|
||||
String toDeviceId,
|
||||
String kind,
|
||||
Map<String, Object> payload
|
||||
) throws IOException {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("from_device_id", fromDeviceId);
|
||||
body.put("to_device_id", toDeviceId);
|
||||
body.put("kind", kind);
|
||||
if (payload != null) {
|
||||
body.put("payload", payload);
|
||||
}
|
||||
postJson("/api/commands", body, true);
|
||||
}
|
||||
|
||||
public List<DeviceCommand> pollPendingCommands(String deviceId) throws IOException {
|
||||
String path = "/api/commands/pending?device_id="
|
||||
+ java.net.URLEncoder.encode(deviceId, "UTF-8") + "&limit=20";
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.header(HEADER_LORA_CLIENT, CLIENT_ANDROID)
|
||||
.get()
|
||||
.build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
return GSON.fromJson(response.body().string(), COMMAND_LIST);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> startPairedTrack(Map<String, Object> body) throws IOException {
|
||||
return postJsonMap("/api/paired-tracks/start", body, true);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getActivePairedTrack() throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + "/api/paired-tracks/active")
|
||||
.get()
|
||||
.build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
return GSON.fromJson(response.body().string(), Map.class);
|
||||
}
|
||||
}
|
||||
|
||||
public void ackPairedTrack(long sessionId, String deviceId, long trackId) throws IOException {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("session_id", sessionId);
|
||||
body.put("device_id", deviceId);
|
||||
body.put("track_id", trackId);
|
||||
postJson("/api/paired-tracks/ack", body, true);
|
||||
}
|
||||
|
||||
public void cancelPairedTrack(Long sessionId) throws IOException {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
if (sessionId != null) {
|
||||
body.put("session_id", sessionId);
|
||||
}
|
||||
postJson("/api/paired-tracks/cancel", body, false);
|
||||
}
|
||||
|
||||
public TrackDetail getTrack(long trackId) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + "/api/tracks/" + trackId)
|
||||
@@ -127,6 +198,62 @@ public class ServerApi {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getHealth() throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + "/api/health")
|
||||
.get()
|
||||
.build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
return GSON.fromJson(response.body().string(), Map.class);
|
||||
}
|
||||
}
|
||||
|
||||
public NearestHillResult findNearestHill(double lat, double lon, int radiusM)
|
||||
throws IOException {
|
||||
String path = "/api/elevation/nearest-hill?lat="
|
||||
+ lat
|
||||
+ "&lon="
|
||||
+ lon
|
||||
+ "&radius_m="
|
||||
+ radiusM;
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.get()
|
||||
.build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
return GSON.fromJson(response.body().string(), NearestHillResult.class);
|
||||
}
|
||||
}
|
||||
|
||||
public ElevationGridResult getElevationGrid(double lat, double lon, int radiusM, int stepM)
|
||||
throws IOException {
|
||||
String path = "/api/elevation/grid?lat="
|
||||
+ lat
|
||||
+ "&lon="
|
||||
+ lon
|
||||
+ "&radius_m="
|
||||
+ radiusM
|
||||
+ "&step_m="
|
||||
+ stepM;
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.get()
|
||||
.build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
return GSON.fromJson(response.body().string(), ElevationGridResult.class);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
|
||||
throws IOException {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
|
||||
|
||||
public class TelemetryPayload {
|
||||
public final String deviceId;
|
||||
public final String deviceLabel;
|
||||
public final Double lat;
|
||||
public final Double lon;
|
||||
public final Double rssi;
|
||||
@@ -22,8 +23,24 @@ public class TelemetryPayload {
|
||||
String meta,
|
||||
String role,
|
||||
Double ts
|
||||
) {
|
||||
this(deviceId, null, lat, lon, rssi, rangeM, rawFrame, meta, role, ts);
|
||||
}
|
||||
|
||||
public TelemetryPayload(
|
||||
String deviceId,
|
||||
String deviceLabel,
|
||||
Double lat,
|
||||
Double lon,
|
||||
Double rssi,
|
||||
Double rangeM,
|
||||
String rawFrame,
|
||||
String meta,
|
||||
String role,
|
||||
Double ts
|
||||
) {
|
||||
this.deviceId = deviceId;
|
||||
this.deviceLabel = deviceLabel;
|
||||
this.lat = lat;
|
||||
this.lon = lon;
|
||||
this.rssi = rssi;
|
||||
|
||||
@@ -20,4 +20,14 @@ public final class GeoUtils {
|
||||
public static boolean isValidCoordinate(Double lat, Double lon) {
|
||||
return lat != null && lon != null && isValidCoordinate(lat.doubleValue(), lon.doubleValue());
|
||||
}
|
||||
|
||||
public static double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
||||
final double r = 6_371_000;
|
||||
double dLat = Math.toRadians(lat2 - lat1);
|
||||
double dLon = Math.toRadians(lon2 - lon1);
|
||||
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
|
||||
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
return r * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,11 @@ public class LocationTracker {
|
||||
}
|
||||
LocationRequest request = new LocationRequest.Builder(
|
||||
Priority.PRIORITY_HIGH_ACCURACY, 10_000L
|
||||
).setMinUpdateIntervalMillis(5_000L).build();
|
||||
)
|
||||
.setMinUpdateIntervalMillis(5_000L)
|
||||
.setMaxUpdateDelayMillis(15_000L)
|
||||
.setWaitForAccurateLocation(false)
|
||||
.build();
|
||||
|
||||
callback = new LocationCallback() {
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.grigowashere.loratester.map;
|
||||
|
||||
import org.mapsforge.core.model.Tile;
|
||||
import org.mapsforge.map.layer.download.tilesource.OnlineTileSource;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
|
||||
/** Esri World Imagery — tile path is zoom/y/x (not OSM zoom/x/y). */
|
||||
public final class EsriWorldImagery extends OnlineTileSource {
|
||||
|
||||
public static final EsriWorldImagery INSTANCE = new EsriWorldImagery();
|
||||
|
||||
private EsriWorldImagery() {
|
||||
super(new String[]{"server.arcgisonline.com"}, 443);
|
||||
setName("Esri.WorldImagery")
|
||||
.setAlpha(false)
|
||||
.setBaseUrl("/ArcGIS/rest/services/World_Imagery/MapServer/tile/")
|
||||
.setExtension("png")
|
||||
.setParallelRequestsLimit(4)
|
||||
.setProtocol("https")
|
||||
.setTileSize(256)
|
||||
.setZoomLevelMax((byte) 18)
|
||||
.setZoomLevelMin((byte) 0);
|
||||
setUserAgent("LoraTester/1.0");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getTileUrl(Tile tile) throws MalformedURLException {
|
||||
StringBuilder path = new StringBuilder(48);
|
||||
path.append(getBaseUrl());
|
||||
path.append(tile.zoomLevel).append('/');
|
||||
path.append(tile.tileY).append('/');
|
||||
path.append(tile.tileX).append('.').append(getExtension());
|
||||
return new URL(getProtocol(), getHostName(), 443, path.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.grigowashere.loratester.model;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Normalized radio stats from telemetry meta JSON (no duplicate fields). */
|
||||
public final class RadioSnapshot {
|
||||
|
||||
public final String role;
|
||||
public final String frame;
|
||||
public final Double frequencyMhz;
|
||||
public final Integer sf;
|
||||
public final Double bwKhz;
|
||||
public final Double powerDbm;
|
||||
public final Double rssiDbm;
|
||||
public final Double snrDb;
|
||||
public final Integer packet;
|
||||
public final String payload;
|
||||
public final Double onAirMs;
|
||||
public final Double txPktPerS;
|
||||
public final Double rxPktPerS;
|
||||
public final Double perPercent;
|
||||
public final Double rxQualityPercent;
|
||||
public final String codeRate;
|
||||
public final Integer preambleLength;
|
||||
public final String lowDataRateOpt;
|
||||
public final Boolean crcEnabled;
|
||||
public final Integer payloadLengthBytes;
|
||||
public final Double txTimeoutMs;
|
||||
public final Integer packetReceive;
|
||||
public final Integer packetTotal;
|
||||
public final Integer packetError;
|
||||
public final Integer crcError;
|
||||
public final Integer preambleDetected;
|
||||
public final Integer headerValid;
|
||||
public final Map<String, String> extraFields;
|
||||
|
||||
public RadioSnapshot(
|
||||
String role,
|
||||
String frame,
|
||||
Double frequencyMhz,
|
||||
Integer sf,
|
||||
Double bwKhz,
|
||||
Double powerDbm,
|
||||
Double rssiDbm,
|
||||
Double snrDb,
|
||||
Integer packet,
|
||||
String payload,
|
||||
Double onAirMs,
|
||||
Double txPktPerS,
|
||||
Double rxPktPerS,
|
||||
Double perPercent,
|
||||
Double rxQualityPercent,
|
||||
String codeRate,
|
||||
Integer preambleLength,
|
||||
String lowDataRateOpt,
|
||||
Boolean crcEnabled,
|
||||
Integer payloadLengthBytes,
|
||||
Double txTimeoutMs,
|
||||
Integer packetReceive,
|
||||
Integer packetTotal,
|
||||
Integer packetError,
|
||||
Integer crcError,
|
||||
Integer preambleDetected,
|
||||
Integer headerValid,
|
||||
Map<String, String> extraFields
|
||||
) {
|
||||
this.role = role;
|
||||
this.frame = frame;
|
||||
this.frequencyMhz = frequencyMhz;
|
||||
this.sf = sf;
|
||||
this.bwKhz = bwKhz;
|
||||
this.powerDbm = powerDbm;
|
||||
this.rssiDbm = rssiDbm;
|
||||
this.snrDb = snrDb;
|
||||
this.packet = packet;
|
||||
this.payload = payload;
|
||||
this.onAirMs = onAirMs;
|
||||
this.txPktPerS = txPktPerS;
|
||||
this.rxPktPerS = rxPktPerS;
|
||||
this.perPercent = perPercent;
|
||||
this.rxQualityPercent = rxQualityPercent;
|
||||
this.codeRate = codeRate;
|
||||
this.preambleLength = preambleLength;
|
||||
this.lowDataRateOpt = lowDataRateOpt;
|
||||
this.crcEnabled = crcEnabled;
|
||||
this.payloadLengthBytes = payloadLengthBytes;
|
||||
this.txTimeoutMs = txTimeoutMs;
|
||||
this.packetReceive = packetReceive;
|
||||
this.packetTotal = packetTotal;
|
||||
this.packetError = packetError;
|
||||
this.crcError = crcError;
|
||||
this.preambleDetected = preambleDetected;
|
||||
this.headerValid = headerValid;
|
||||
this.extraFields = extraFields != null ? extraFields : Map.of();
|
||||
}
|
||||
|
||||
public static RadioSnapshot empty() {
|
||||
return new RadioSnapshot(null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
Map.of());
|
||||
}
|
||||
|
||||
public static RadioSnapshot fromMeta(String metaJson, String roleFallback, Double rssiFallback) {
|
||||
if (metaJson == null || metaJson.isBlank()) {
|
||||
RadioSnapshot snap = empty();
|
||||
if (roleFallback != null || rssiFallback != null) {
|
||||
return new RadioSnapshot(roleFallback, null, null, null, null, null,
|
||||
rssiFallback, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
Map.of());
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
try {
|
||||
JsonObject o = JsonParser.parseString(metaJson).getAsJsonObject();
|
||||
String role = text(o, "role");
|
||||
if (role == null) {
|
||||
role = roleFallback;
|
||||
}
|
||||
Double rssi = dbl(o, "rssi_dbm");
|
||||
if (rssi == null) {
|
||||
rssi = rssiFallback;
|
||||
}
|
||||
Map<String, String> extra = new LinkedHashMap<>();
|
||||
JsonElement fieldsEl = o.get("fields");
|
||||
if (fieldsEl != null && fieldsEl.isJsonObject()) {
|
||||
for (Map.Entry<String, JsonElement> e : fieldsEl.getAsJsonObject().entrySet()) {
|
||||
String label = e.getKey();
|
||||
if (isKnownFieldLabel(label)) {
|
||||
continue;
|
||||
}
|
||||
extra.put(label, e.getValue().getAsString());
|
||||
}
|
||||
}
|
||||
return new RadioSnapshot(
|
||||
role,
|
||||
text(o, "frame"),
|
||||
hzToMhz(lng(o, "frequency_hz")),
|
||||
integer(o, "spreading_factor"),
|
||||
dbl(o, "bandwidth_khz"),
|
||||
dbl(o, "power_dbm"),
|
||||
rssi,
|
||||
dbl(o, "snr_db"),
|
||||
integer(o, "packet"),
|
||||
text(o, "payload"),
|
||||
dbl(o, "on_air_ms"),
|
||||
dbl(o, "tx_pkt_per_s"),
|
||||
dbl(o, "rx_pkt_per_s"),
|
||||
dbl(o, "per_percent"),
|
||||
dbl(o, "rx_quality_percent"),
|
||||
text(o, "code_rate"),
|
||||
integer(o, "preamble_length"),
|
||||
text(o, "low_data_rate_opt"),
|
||||
bool(o, "crc_enabled"),
|
||||
integer(o, "payload_length_bytes"),
|
||||
dbl(o, "tx_timeout_ms"),
|
||||
integer(o, "packet_receive"),
|
||||
integer(o, "packet_total"),
|
||||
integer(o, "packet_error"),
|
||||
integer(o, "crc_error"),
|
||||
integer(o, "preamble_detected"),
|
||||
integer(o, "header_valid"),
|
||||
extra
|
||||
);
|
||||
} catch (Exception ignored) {
|
||||
return new RadioSnapshot(roleFallback, null, null, null, null, null,
|
||||
rssiFallback, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
Map.of());
|
||||
}
|
||||
}
|
||||
|
||||
public static RadioSnapshot fromExtracted(StatsExtractor.ExtractedStats stats) {
|
||||
if (stats == null) {
|
||||
return empty();
|
||||
}
|
||||
return fromMeta(stats.metaJson, stats.role, stats.rssiDbm != null ? stats.rssiDbm : stats.rssi);
|
||||
}
|
||||
|
||||
public Set<String> diff(RadioSnapshot prev) {
|
||||
Set<String> changed = new HashSet<>();
|
||||
if (prev == null) {
|
||||
return changed;
|
||||
}
|
||||
cmp(changed, "role", role, prev.role);
|
||||
cmp(changed, "rssi", rssiDbm, prev.rssiDbm);
|
||||
cmp(changed, "snr", snrDb, prev.snrDb);
|
||||
cmp(changed, "packet", packet, prev.packet);
|
||||
cmp(changed, "payload", payload, prev.payload);
|
||||
cmp(changed, "per", perPercent, prev.perPercent);
|
||||
cmp(changed, "rxQuality", rxQualityPercent, prev.rxQualityPercent);
|
||||
cmp(changed, "txSpeed", txPktPerS, prev.txPktPerS);
|
||||
cmp(changed, "rxSpeed", rxPktPerS, prev.rxPktPerS);
|
||||
cmp(changed, "frequency", frequencyMhz, prev.frequencyMhz);
|
||||
cmp(changed, "sf", sf, prev.sf);
|
||||
cmp(changed, "bw", bwKhz, prev.bwKhz);
|
||||
cmp(changed, "power", powerDbm, prev.powerDbm);
|
||||
cmp(changed, "packetReceive", packetReceive, prev.packetReceive);
|
||||
cmp(changed, "packetTotal", packetTotal, prev.packetTotal);
|
||||
cmp(changed, "packetError", packetError, prev.packetError);
|
||||
cmp(changed, "crcError", crcError, prev.crcError);
|
||||
cmp(changed, "preambleDetected", preambleDetected, prev.preambleDetected);
|
||||
cmp(changed, "headerValid", headerValid, prev.headerValid);
|
||||
cmp(changed, "codeRate", codeRate, prev.codeRate);
|
||||
cmp(changed, "crc", crcEnabled, prev.crcEnabled);
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static void cmp(Set<String> changed, String key, Object a, Object b) {
|
||||
if (!Objects.equals(a, b)) {
|
||||
changed.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isKnownFieldLabel(String label) {
|
||||
String n = label.toLowerCase(Locale.ROOT).trim();
|
||||
return n.equals("send") || n.equals("receive")
|
||||
|| n.contains("frequency") || n.equals("power") || n.equals("rssi")
|
||||
|| n.equals("snr") || n.contains("spreading") || n.contains("bandwidth")
|
||||
|| n.equals("packet") || n.contains("packet number") || n.equals("payload")
|
||||
|| n.contains("packet receive") || n.contains("packet total") || n.contains("packet error")
|
||||
|| n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid")
|
||||
|| n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
|
||||
|| n.equals("per") || n.contains("rx quality") || n.contains("tx timeout")
|
||||
|| n.contains("code rate") || n.contains("preamble length")
|
||||
|| n.contains("low data rate") || n.equals("crc") || n.contains("payload length");
|
||||
}
|
||||
|
||||
private static String text(JsonObject o, String key) {
|
||||
JsonElement e = o.get(key);
|
||||
return e != null && !e.isJsonNull() ? e.getAsString() : null;
|
||||
}
|
||||
|
||||
private static Integer integer(JsonObject o, String key) {
|
||||
JsonElement e = o.get(key);
|
||||
return e != null && e.isJsonPrimitive() ? e.getAsInt() : null;
|
||||
}
|
||||
|
||||
private static Double dbl(JsonObject o, String key) {
|
||||
JsonElement e = o.get(key);
|
||||
return e != null && e.isJsonPrimitive() ? e.getAsDouble() : null;
|
||||
}
|
||||
|
||||
private static Boolean bool(JsonObject o, String key) {
|
||||
JsonElement e = o.get(key);
|
||||
return e != null && e.isJsonPrimitive() ? e.getAsBoolean() : null;
|
||||
}
|
||||
|
||||
private static Long lng(JsonObject o, String key) {
|
||||
JsonElement e = o.get(key);
|
||||
return e != null && e.isJsonPrimitive() ? e.getAsLong() : null;
|
||||
}
|
||||
|
||||
private static Double hzToMhz(Long hz) {
|
||||
if (hz == null) {
|
||||
return null;
|
||||
}
|
||||
return hz / 1_000_000.0;
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,12 @@ public final class AtCommandFormatter {
|
||||
String wire = line + "\r\n";
|
||||
return wire.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Literal line (e.g. screen reset "S") without AT prefix. */
|
||||
public static byte[] toWireBytesLiteral(String line) {
|
||||
if (line == null || line.isEmpty()) {
|
||||
return new byte[0];
|
||||
}
|
||||
return (line + "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
package com.grigowashere.loratester.telnet;
|
||||
|
||||
/** Common AT commands for LoRa modules (via telnet bridge). */
|
||||
/** LoRa module AT commands (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";
|
||||
/** Stop TX or RX test. */
|
||||
public static final String STOP = "S";
|
||||
public static final String TIMEOUT_MS = "AT+TM=";
|
||||
public static final String FREQUENCY_HZ = "AT+FQ=";
|
||||
public static final String POWER_DBM = "AT+PW=";
|
||||
public static final String SPREADING_FACTOR = "AT+SF=";
|
||||
public static final String BANDWIDTH = "AT+BW=";
|
||||
public static final String CODE_RATE = "AT+CR=";
|
||||
public static final String PREAMBLE = "AT+PL=";
|
||||
|
||||
/** Legacy / bridge helpers (if supported by firmware). */
|
||||
public static final String HELP = "AT+H";
|
||||
public static final String STATUS = "AT+STATUS";
|
||||
public static final String RESET = "AT+RESET";
|
||||
public static final String BASIC = "AT";
|
||||
|
||||
public static final String[] BW_KHZ = {
|
||||
"7.81", "10.42", "15.63", "20.83", "31.25",
|
||||
"41.67", "62.5", "125", "250", "500"
|
||||
};
|
||||
|
||||
public static final String[] CODE_RATES = {"4/5", "4/6", "4/7", "4/8"};
|
||||
|
||||
private AtCommands() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package com.grigowashere.loratester.telnet;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.grigowashere.loratester.model.RadioSnapshot;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -14,67 +11,69 @@ public final class LoraStatsFormatter {
|
||||
private LoraStatsFormatter() {
|
||||
}
|
||||
|
||||
/** Human-readable lines from telemetry meta JSON (fields first). */
|
||||
@Deprecated
|
||||
public static String formatMeta(String metaJson) {
|
||||
if (metaJson == null || metaJson.isEmpty()) {
|
||||
RadioSnapshot snap = RadioSnapshot.fromMeta(metaJson, null, null);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String dynamic = formatDynamic(snap, Set.of());
|
||||
if (!dynamic.isEmpty()) {
|
||||
sb.append(dynamic);
|
||||
}
|
||||
String stat = formatStatic(snap, Set.of());
|
||||
if (!stat.isEmpty()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append(stat);
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
public static String formatDynamic(RadioSnapshot s, Set<String> changed) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
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));
|
||||
appendLine(sb, "RSSI", fmtDbm(s.rssiDbm), "rssi", changed);
|
||||
appendLine(sb, "SNR", fmtSuffix(s.snrDb, " dB"), "snr", changed);
|
||||
appendLine(sb, "RX Quality", fmtSuffix(s.rxQualityPercent, " %"), "rxQuality", changed);
|
||||
appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed);
|
||||
appendLine(sb, "Payload", s.payload, "payload", changed);
|
||||
appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed);
|
||||
appendLine(sb, "Принято", fmtInt(s.packetReceive), "packetReceive", changed);
|
||||
appendLine(sb, "Всего пакетов", fmtInt(s.packetTotal), "packetTotal", changed);
|
||||
appendLine(sb, "Ошибки пакетов", fmtInt(s.packetError), "packetError", changed);
|
||||
appendLine(sb, "CRC Error", fmtInt(s.crcError), "crcError", changed);
|
||||
appendLine(sb, "Preamble Det.", fmtInt(s.preambleDetected), "preambleDetected", changed);
|
||||
appendLine(sb, "Header Valid", fmtInt(s.headerValid), "headerValid", changed);
|
||||
appendLine(sb, "TX Speed", fmtSuffix(s.txPktPerS, " pkt/s"), "txSpeed", changed);
|
||||
appendLine(sb, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed);
|
||||
for (Map.Entry<String, String> e : s.extraFields.entrySet()) {
|
||||
append(sb, e.getKey(), e.getValue());
|
||||
}
|
||||
append(sb, "Кадр", text(o, "frame"));
|
||||
append(sb, "Мощность TX", dbl(o, "power_dbm"), " dBm");
|
||||
append(sb, "RSSI", dbl(o, "rssi_dbm"), " dBm");
|
||||
append(sb, "SNR", dbl(o, "snr_db"), " dB");
|
||||
append(sb, "Частота", freqMhz(o), " MHz");
|
||||
append(sb, "SF", integer(o, "spreading_factor"));
|
||||
append(sb, "BW", integer(o, "bandwidth_khz"), " kHz");
|
||||
append(sb, "Пакет", integer(o, "packet"));
|
||||
append(sb, "Payload", text(o, "payload"));
|
||||
append(sb, "On Air", dbl(o, "on_air_ms"), " ms");
|
||||
append(sb, "TX Speed", dbl(o, "tx_pkt_per_s"), " pkt/s");
|
||||
append(sb, "RX Speed", dbl(o, "rx_pkt_per_s"), " pkt/s");
|
||||
append(sb, "PER", dbl(o, "per_percent"), " %");
|
||||
return sb.toString().trim();
|
||||
} catch (Exception ignored) {
|
||||
return metaJson;
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendFieldsBlock(StringBuilder sb, JsonElement fieldsEl, Set<String> shown) {
|
||||
if (fieldsEl == null || !fieldsEl.isJsonObject()) {
|
||||
return;
|
||||
public static String formatStatic(RadioSnapshot s, Set<String> changed) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
JsonObject fields = fieldsEl.getAsJsonObject();
|
||||
for (Map.Entry<String, JsonElement> e : fields.entrySet()) {
|
||||
String label = e.getKey();
|
||||
if (isSkippedFieldLabel(label)) {
|
||||
continue;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (s.role != null) {
|
||||
appendLine(sb, "Роль", roleLabel(s.role), "role", changed);
|
||||
}
|
||||
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");
|
||||
appendLine(sb, "Частота", fmtSuffix(s.frequencyMhz, " MHz"), "frequency", changed);
|
||||
appendLine(sb, "SF", fmtInt(s.sf), "sf", changed);
|
||||
appendLine(sb, "BW", fmtBw(s.bwKhz), "bw", changed);
|
||||
appendLine(sb, "Мощность TX", fmtDbm(s.powerDbm), "power", changed);
|
||||
appendLine(sb, "Code Rate", s.codeRate, "codeRate", changed);
|
||||
appendLine(sb, "Preamble Len", fmtInt(s.preambleLength), "preambleLength", changed);
|
||||
appendLine(sb, "Low DR Opt", s.lowDataRateOpt, "lowDataRateOpt", changed);
|
||||
appendLine(sb, "CRC", fmtCrc(s.crcEnabled), "crc", changed);
|
||||
appendLine(sb, "Payload len", fmtSuffix(s.payloadLengthBytes, " byte"), "payloadLength", changed);
|
||||
appendLine(sb, "TX Timeout", fmtSuffix(s.txTimeoutMs, " ms"), "txTimeout", changed);
|
||||
appendLine(sb, "On Air", fmtSuffix(s.onAirMs, " ms"), "onAir", changed);
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
public static String roleLabel(String role) {
|
||||
@@ -87,12 +86,23 @@ public final class LoraStatsFormatter {
|
||||
return role;
|
||||
}
|
||||
|
||||
private static String freqMhz(JsonObject o) {
|
||||
if (!o.has("frequency_hz")) {
|
||||
return null;
|
||||
private static void appendLine(
|
||||
StringBuilder sb,
|
||||
String label,
|
||||
String value,
|
||||
String changeKey,
|
||||
Set<String> changed
|
||||
) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long hz = o.get("frequency_hz").getAsLong();
|
||||
return String.format(Locale.US, "%.3f", hz / 1_000_000.0);
|
||||
if (sb.length() > 0) {
|
||||
sb.append("\n");
|
||||
}
|
||||
if (changed != null && changed.contains(changeKey)) {
|
||||
sb.append("▸ ");
|
||||
}
|
||||
sb.append(label).append(": ").append(value);
|
||||
}
|
||||
|
||||
private static void append(StringBuilder sb, String label, String value) {
|
||||
@@ -105,25 +115,30 @@ public final class LoraStatsFormatter {
|
||||
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 fmtDbm(Double v) {
|
||||
return v != null ? String.format(Locale.US, "%.0f dBm", v) : null;
|
||||
}
|
||||
|
||||
private static String text(JsonObject o, String key) {
|
||||
JsonElement e = o.get(key);
|
||||
return e != null && !e.isJsonNull() ? e.getAsString() : null;
|
||||
private static String fmtInt(Integer v) {
|
||||
return v != null ? String.valueOf(v) : 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 fmtBw(Double v) {
|
||||
return v != null ? String.format(Locale.US, "%.2f kHz", v) : null;
|
||||
}
|
||||
|
||||
private static String dbl(JsonObject o, String key) {
|
||||
JsonElement e = o.get(key);
|
||||
return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsDouble()) : null;
|
||||
private static String fmtCrc(Boolean enabled) {
|
||||
if (enabled == null) {
|
||||
return null;
|
||||
}
|
||||
return enabled ? "On" : "Off";
|
||||
}
|
||||
|
||||
private static String fmtSuffix(Integer v, String suffix) {
|
||||
return v != null ? v + suffix : null;
|
||||
}
|
||||
|
||||
private static String fmtSuffix(Double v, String suffix) {
|
||||
return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.grigowashere.loratester.telnet;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Builds macro: S (stop) then configuration AT commands, then TX/RX if requested. */
|
||||
public final class RadioMacroBuilder {
|
||||
|
||||
/** Stop TX or RX before applying new settings. */
|
||||
public static final String STOP = AtCommands.STOP;
|
||||
|
||||
/** @deprecated use {@link #STOP} */
|
||||
@Deprecated
|
||||
public static final String SCREEN_RESET = STOP;
|
||||
|
||||
public static final class Params {
|
||||
public Long frequencyHz;
|
||||
public Integer powerDbm;
|
||||
public Integer sf;
|
||||
public String bwKhz;
|
||||
public String codeRate;
|
||||
public Integer preamble;
|
||||
public Integer sendTimeoutMs;
|
||||
public String role;
|
||||
}
|
||||
|
||||
private RadioMacroBuilder() {
|
||||
}
|
||||
|
||||
public static List<String> apply(Integer sf, Integer bwKhz, String role) {
|
||||
Params p = new Params();
|
||||
p.sf = sf;
|
||||
if (bwKhz != null) {
|
||||
p.bwKhz = String.valueOf(bwKhz);
|
||||
}
|
||||
p.role = role;
|
||||
return apply(p);
|
||||
}
|
||||
|
||||
public static List<String> apply(Params p) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add(STOP);
|
||||
if (p == null) {
|
||||
return lines;
|
||||
}
|
||||
if (p.frequencyHz != null && p.frequencyHz >= 430_000_000L && p.frequencyHz <= 470_000_000L) {
|
||||
lines.add(AtCommands.FREQUENCY_HZ + p.frequencyHz);
|
||||
}
|
||||
if (p.powerDbm != null && p.powerDbm >= -9 && p.powerDbm <= 22) {
|
||||
lines.add(AtCommands.POWER_DBM + p.powerDbm);
|
||||
}
|
||||
if (p.sf != null && p.sf >= 5 && p.sf <= 12) {
|
||||
lines.add(AtCommands.SPREADING_FACTOR + p.sf);
|
||||
}
|
||||
if (p.bwKhz != null && !p.bwKhz.isBlank()) {
|
||||
lines.add(AtCommands.BANDWIDTH + p.bwKhz.trim());
|
||||
}
|
||||
if (p.codeRate != null && !p.codeRate.isBlank()) {
|
||||
lines.add(AtCommands.CODE_RATE + p.codeRate.trim());
|
||||
}
|
||||
if (p.preamble != null && p.preamble >= 1 && p.preamble <= 64) {
|
||||
lines.add(AtCommands.PREAMBLE + p.preamble);
|
||||
}
|
||||
if (p.sendTimeoutMs != null && p.sendTimeoutMs >= 0 && p.sendTimeoutMs <= 60_000) {
|
||||
lines.add(AtCommands.TIMEOUT_MS + p.sendTimeoutMs);
|
||||
}
|
||||
if (StatsExtractor.ROLE_TX.equals(p.role)) {
|
||||
lines.add(AtCommands.TRANSMIT);
|
||||
} else if (StatsExtractor.ROLE_RX.equals(p.role)) {
|
||||
lines.add(AtCommands.RECEIVE);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,33 @@ public class StatsExtractor {
|
||||
private static final Pattern SNR = Pattern.compile("SNR\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern FREQUENCY = Pattern.compile("Frequency\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern SPREADING = Pattern.compile("Spreading Factor\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PACKET = Pattern.compile("Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PACKET_TX = Pattern.compile("(?m)^\\s*Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PACKET_NUMBER = Pattern.compile("Packet Number\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PACKET_RECEIVE = Pattern.compile("Packet Receive\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PACKET_TOTAL = Pattern.compile("Packet Total\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PACKET_ERROR = Pattern.compile("Packet Error\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern CRC_ERROR = Pattern.compile("CRC Error\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PREAMBLE_DETECTED = Pattern.compile(
|
||||
"Preamble Detected\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern HEADER_VALID = Pattern.compile("Header Valid\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern CODE_RATE = Pattern.compile("Code Rate\\s*:\\s*(\\S+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PREAMBLE_LENGTH = Pattern.compile(
|
||||
"Preamble Length\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern LOW_DATA_RATE = Pattern.compile(
|
||||
"Low Data Rate Opt\\s*:\\s*(\\S+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern CRC = Pattern.compile("CRC\\s*:\\s*(On|Off)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PAYLOAD_LENGTH = Pattern.compile(
|
||||
"Payload length\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern TX_TIMEOUT = Pattern.compile(
|
||||
"TX Timeout\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PAYLOAD = Pattern.compile("Payload\\s*:\\s*(.+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern ON_AIR = Pattern.compile("On Air\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern RX_SPEED = Pattern.compile("RX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PER = Pattern.compile("PER\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern RX_QUALITY = Pattern.compile(
|
||||
"RX Quality\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private final Pattern rssiPattern;
|
||||
private final Pattern rangePattern;
|
||||
@@ -68,10 +87,6 @@ public class StatsExtractor {
|
||||
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);
|
||||
@@ -91,10 +106,10 @@ public class StatsExtractor {
|
||||
|
||||
putLong(meta, "frequency_hz", matchLong(FREQUENCY, normalized));
|
||||
putInt(meta, "spreading_factor", matchInt(SPREADING, normalized));
|
||||
putInt(meta, "bandwidth_khz", matchInt(BANDWIDTH, normalized));
|
||||
putDouble(meta, "bandwidth_khz", matchDouble(BANDWIDTH, normalized));
|
||||
Integer packet = matchInt(PACKET_NUMBER, normalized);
|
||||
if (packet == null) {
|
||||
packet = matchInt(PACKET, normalized);
|
||||
packet = matchInt(PACKET_TX, normalized);
|
||||
}
|
||||
putInt(meta, "packet", packet);
|
||||
putString(meta, "payload", matchString(PAYLOAD, normalized));
|
||||
@@ -102,8 +117,25 @@ public class StatsExtractor {
|
||||
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));
|
||||
putDouble(meta, "rx_quality_percent", matchDouble(RX_QUALITY, normalized));
|
||||
putString(meta, "code_rate", matchString(CODE_RATE, normalized));
|
||||
putInt(meta, "preamble_length", matchInt(PREAMBLE_LENGTH, normalized));
|
||||
putString(meta, "low_data_rate_opt", matchString(LOW_DATA_RATE, normalized));
|
||||
putBool(meta, "crc_enabled", matchBool(CRC, normalized));
|
||||
putInt(meta, "payload_length_bytes", matchInt(PAYLOAD_LENGTH, normalized));
|
||||
putDouble(meta, "tx_timeout_ms", matchDouble(TX_TIMEOUT, normalized));
|
||||
putInt(meta, "packet_receive", matchInt(PACKET_RECEIVE, normalized));
|
||||
putInt(meta, "packet_total", matchInt(PACKET_TOTAL, normalized));
|
||||
putInt(meta, "packet_error", matchInt(PACKET_ERROR, normalized));
|
||||
putInt(meta, "crc_error", matchInt(CRC_ERROR, normalized));
|
||||
putInt(meta, "preamble_detected", matchInt(PREAMBLE_DETECTED, normalized));
|
||||
putInt(meta, "header_valid", matchInt(HEADER_VALID, normalized));
|
||||
|
||||
enrichFieldsFromStructured(meta, fields);
|
||||
if (!fields.isEmpty()) {
|
||||
meta.put("fields", fields);
|
||||
}
|
||||
|
||||
meta.put("stats_at", System.currentTimeMillis() / 1000.0);
|
||||
|
||||
Double rangeM = matchDouble(rangePattern, normalized);
|
||||
Double displayDbm = rssiDbm != null ? rssiDbm : txPower;
|
||||
@@ -128,57 +160,25 @@ public class StatsExtractor {
|
||||
String value = trimmed.substring(colon + 1).trim();
|
||||
if (label.isEmpty()
|
||||
|| label.equalsIgnoreCase("SEND")
|
||||
|| label.equalsIgnoreCase("RECEIVE")) {
|
||||
|| label.equalsIgnoreCase("RECEIVE")
|
||||
|| isStructuredLabel(label)) {
|
||||
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 boolean isStructuredLabel(String label) {
|
||||
String n = label.toLowerCase(Locale.ROOT).trim();
|
||||
return n.equals("frequency") || n.equals("power") || n.equals("rssi")
|
||||
|| n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth")
|
||||
|| n.equals("packet") || n.contains("packet number") || n.equals("payload")
|
||||
|| n.contains("packet receive") || n.contains("packet total") || n.contains("packet error")
|
||||
|| n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid")
|
||||
|| n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
|
||||
|| n.equals("per") || n.contains("rx quality") || n.contains("tx timeout")
|
||||
|| n.contains("code rate") || n.contains("preamble length")
|
||||
|| n.contains("low data rate") || n.equals("crc") || n.contains("payload length");
|
||||
}
|
||||
|
||||
private static ExtractedStats empty(String frame) {
|
||||
@@ -241,6 +241,20 @@ public class StatsExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
private static void putBool(Map<String, Object> meta, String key, Boolean value) {
|
||||
if (value != null) {
|
||||
meta.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static Boolean matchBool(Pattern pattern, String text) {
|
||||
Matcher m = pattern.matcher(text);
|
||||
if (!m.find()) {
|
||||
return null;
|
||||
}
|
||||
return "on".equalsIgnoreCase(m.group(1).trim());
|
||||
}
|
||||
|
||||
private static Double matchDouble(Pattern pattern, String text) {
|
||||
Matcher m = pattern.matcher(text);
|
||||
if (m.find()) {
|
||||
|
||||
@@ -74,11 +74,23 @@ public class TelnetClient {
|
||||
/**
|
||||
* Sends an AT command. Adds AT prefix and CR+LF if missing.
|
||||
*/
|
||||
public SendResult sendRawLine(String line) {
|
||||
byte[] wire = AtCommandFormatter.toWireBytesLiteral(line);
|
||||
if (wire.length == 0) {
|
||||
return SendResult.EMPTY;
|
||||
}
|
||||
return writeWire(wire);
|
||||
}
|
||||
|
||||
public SendResult sendAtCommand(String command) {
|
||||
byte[] wire = AtCommandFormatter.toWireBytes(command);
|
||||
if (wire.length == 0) {
|
||||
return SendResult.EMPTY;
|
||||
}
|
||||
return writeWire(wire);
|
||||
}
|
||||
|
||||
private SendResult writeWire(byte[] wire) {
|
||||
Socket socket = activeSocket.get();
|
||||
if (socket == null || socket.isClosed()) {
|
||||
return SendResult.NOT_CONNECTED;
|
||||
|
||||
@@ -29,7 +29,11 @@ public class TrackRecorder {
|
||||
|
||||
public interface Listener {
|
||||
void onStateChanged(boolean recording, int pointCount, long trackId);
|
||||
|
||||
void onError(String message);
|
||||
|
||||
default void onPointRecorded(double lat, double lon) {
|
||||
}
|
||||
}
|
||||
|
||||
private final ServerApi serverApi;
|
||||
@@ -55,6 +59,7 @@ public class TrackRecorder {
|
||||
private ScheduledFuture<?> sampleTask;
|
||||
private ScheduledFuture<?> flushTask;
|
||||
private Listener listener;
|
||||
private Listener pairedListener;
|
||||
|
||||
public TrackRecorder(
|
||||
ServerApi serverApi,
|
||||
@@ -77,6 +82,10 @@ public class TrackRecorder {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void setPairedListener(Listener pairedListener) {
|
||||
this.pairedListener = pairedListener;
|
||||
}
|
||||
|
||||
public void updateLocation(double lat, double lon, double altitude) {
|
||||
if (GeoUtils.isValidCoordinate(lat, lon)) {
|
||||
this.lat = lat;
|
||||
@@ -204,6 +213,18 @@ public class TrackRecorder {
|
||||
}
|
||||
totalPoints++;
|
||||
notifyState();
|
||||
notifyPoint(lat, lon);
|
||||
}
|
||||
|
||||
private void notifyPoint(double lat, double lon) {
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) {
|
||||
listener.onPointRecorded(lat, lon);
|
||||
}
|
||||
if (pairedListener != null) {
|
||||
pairedListener.onPointRecorded(lat, lon);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void flushBuffer() {
|
||||
@@ -230,16 +251,24 @@ public class TrackRecorder {
|
||||
}
|
||||
|
||||
private void notifyState() {
|
||||
if (listener == null) {
|
||||
return;
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) {
|
||||
listener.onStateChanged(recording, totalPoints, trackId);
|
||||
}
|
||||
mainHandler.post(() -> listener.onStateChanged(recording, totalPoints, trackId));
|
||||
if (pairedListener != null) {
|
||||
pairedListener.onStateChanged(recording, totalPoints, trackId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void notifyError(String msg) {
|
||||
if (listener == null) {
|
||||
return;
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) {
|
||||
listener.onError(msg);
|
||||
}
|
||||
mainHandler.post(() -> listener.onError(msg));
|
||||
if (pairedListener != null) {
|
||||
pairedListener.onError(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -14,29 +16,56 @@ 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.button.MaterialButton;
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.grigowashere.loratester.CommandPoller;
|
||||
import com.grigowashere.loratester.LoraApp;
|
||||
import com.grigowashere.loratester.PeerDevices;
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
import com.grigowashere.loratester.api.DeviceInfo;
|
||||
import com.grigowashere.loratester.model.RadioSnapshot;
|
||||
import com.grigowashere.loratester.telnet.AtCommands;
|
||||
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
|
||||
import com.grigowashere.loratester.telnet.RadioMacroBuilder;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
import com.grigowashere.loratester.telnet.TelnetClient;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class AtFragment extends Fragment {
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private FragmentPollHelper pollHelper;
|
||||
private TelemetryUploader uploader;
|
||||
private CommandPoller commandPoller;
|
||||
private TextView atStatus;
|
||||
private TextView atConsole;
|
||||
private TextView atCurrentSnapshot;
|
||||
private TextInputEditText atInputFq;
|
||||
private TextInputEditText atInputPw;
|
||||
private TextInputEditText atInputSf;
|
||||
private TextInputEditText atInputPl;
|
||||
private TextInputEditText atInputTm;
|
||||
private Spinner atBwSpinner;
|
||||
private Spinner atCrSpinner;
|
||||
private Spinner atRoleSpinner;
|
||||
private MaterialButtonToggleGroup atTargetGroup;
|
||||
private ScrollView atConsoleScroll;
|
||||
private TextInputEditText atCommandInput;
|
||||
private TextView atConsole;
|
||||
private String lastConsole = "";
|
||||
private boolean consoleVisible;
|
||||
private boolean formInitialized;
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
|
||||
LoraApp app = (LoraApp) context.getApplicationContext();
|
||||
uploader = app.getTelemetryUploader();
|
||||
commandPoller = app.getCommandPoller();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -52,137 +81,233 @@ public class AtFragment extends Fragment {
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
atStatus = view.findViewById(R.id.atStatus);
|
||||
atConsole = view.findViewById(R.id.atConsole);
|
||||
atCurrentSnapshot = view.findViewById(R.id.atCurrentSnapshot);
|
||||
atInputFq = view.findViewById(R.id.atInputFq);
|
||||
atInputPw = view.findViewById(R.id.atInputPw);
|
||||
atInputSf = view.findViewById(R.id.atInputSf);
|
||||
atInputPl = view.findViewById(R.id.atInputPl);
|
||||
atInputTm = view.findViewById(R.id.atInputTm);
|
||||
atBwSpinner = view.findViewById(R.id.atBwSpinner);
|
||||
atCrSpinner = view.findViewById(R.id.atCrSpinner);
|
||||
atRoleSpinner = view.findViewById(R.id.atRoleSpinner);
|
||||
atTargetGroup = view.findViewById(R.id.atTargetGroup);
|
||||
atConsoleScroll = view.findViewById(R.id.atConsoleScroll);
|
||||
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);
|
||||
atConsole = view.findViewById(R.id.atConsole);
|
||||
Button applyBtn = view.findViewById(R.id.atApplyBtn);
|
||||
Button stopBtn = view.findViewById(R.id.atStopBtn);
|
||||
MaterialButton consoleToggle = view.findViewById(R.id.atConsoleToggle);
|
||||
pollHelper = new FragmentPollHelper(this, this::refresh);
|
||||
|
||||
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);
|
||||
atTargetGroup.check(R.id.atTargetLocal);
|
||||
atBwSpinner.setAdapter(new ArrayAdapter<>(
|
||||
requireContext(),
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
AtCommands.BW_KHZ
|
||||
));
|
||||
atCrSpinner.setAdapter(new ArrayAdapter<>(
|
||||
requireContext(),
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
AtCommands.CODE_RATES
|
||||
));
|
||||
atRoleSpinner.setAdapter(new ArrayAdapter<>(
|
||||
requireContext(),
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"—", "TX", "RX"}
|
||||
));
|
||||
|
||||
sendBtn.setOnClickListener(v -> sendFromInput());
|
||||
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;
|
||||
applyBtn.setOnClickListener(v -> applyMacro());
|
||||
stopBtn.setOnClickListener(v -> sendLines(List.of(AtCommands.STOP)));
|
||||
consoleToggle.setOnClickListener(v -> {
|
||||
consoleVisible = !consoleVisible;
|
||||
atConsoleScroll.setVisibility(consoleVisible ? View.VISIBLE : View.GONE);
|
||||
consoleToggle.setText(consoleVisible
|
||||
? getString(R.string.at_console_hide)
|
||||
: getString(R.string.at_console_toggle));
|
||||
});
|
||||
}
|
||||
|
||||
private RadioMacroBuilder.Params buildParams() {
|
||||
RadioMacroBuilder.Params p = new RadioMacroBuilder.Params();
|
||||
Double fqMhz = parseDouble(atInputFq);
|
||||
if (fqMhz != null) {
|
||||
p.frequencyHz = Math.round(fqMhz * 1_000_000L);
|
||||
}
|
||||
p.powerDbm = parseInt(atInputPw);
|
||||
p.sf = parseInt(atInputSf);
|
||||
int bwPos = atBwSpinner != null ? atBwSpinner.getSelectedItemPosition() : -1;
|
||||
if (bwPos >= 0 && bwPos < AtCommands.BW_KHZ.length) {
|
||||
p.bwKhz = AtCommands.BW_KHZ[bwPos];
|
||||
}
|
||||
if (atCrSpinner != null && atCrSpinner.getSelectedItem() != null) {
|
||||
p.codeRate = atCrSpinner.getSelectedItem().toString();
|
||||
}
|
||||
p.preamble = parseInt(atInputPl);
|
||||
p.sendTimeoutMs = parseInt(atInputTm);
|
||||
if (atRoleSpinner != null && atRoleSpinner.getSelectedItem() != null) {
|
||||
String role = atRoleSpinner.getSelectedItem().toString();
|
||||
if (!"—".equals(role)) {
|
||||
p.role = role;
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private void addQuickChip(ChipGroup group, String label, String command) {
|
||||
Chip chip = new Chip(requireContext());
|
||||
chip.setText(label);
|
||||
chip.setCheckable(false);
|
||||
chip.setOnClickListener(v -> sendCommand(command));
|
||||
group.addView(chip);
|
||||
private void applyMacro() {
|
||||
sendLines(RadioMacroBuilder.apply(buildParams()));
|
||||
}
|
||||
|
||||
private void sendFromInput() {
|
||||
if (atCommandInput == null || atCommandInput.getText() == null) {
|
||||
return;
|
||||
private void sendLines(List<String> lines) {
|
||||
if (isPeerTarget()) {
|
||||
sendMacroToPeer(lines);
|
||||
} else {
|
||||
uploader.sendMacroSequence(lines, this::onSendResult);
|
||||
}
|
||||
String cmd = atCommandInput.getText().toString().trim();
|
||||
if (cmd.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
sendCommand(cmd);
|
||||
atCommandInput.setText("");
|
||||
}
|
||||
|
||||
private void sendCommand(String command) {
|
||||
if (uploader == null || !isAdded()) {
|
||||
private void sendMacroToPeer(List<String> lines) {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||
PeerDevices.Result peer = PeerDevices.resolve(devices, uploader.getDeviceId());
|
||||
if (!peer.ok()) {
|
||||
showToast(R.string.at_peer_unavailable);
|
||||
return;
|
||||
}
|
||||
uploader.sendAtCommand(command, result -> {
|
||||
if (!isAdded()) {
|
||||
return;
|
||||
commandPoller.postMacroToPeer(peer.peerId, lines);
|
||||
showToast(getString(R.string.at_sent_to_peer, peer.peerId));
|
||||
} catch (Exception e) {
|
||||
showToast(R.string.stats_push_failed);
|
||||
}
|
||||
Context ctx = getContext();
|
||||
if (ctx == null) {
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
private void onSendResult(TelnetClient.SendResult result) {
|
||||
if (!isAdded()) return;
|
||||
if (result == TelnetClient.SendResult.NOT_CONNECTED) {
|
||||
Toast.makeText(ctx, R.string.at_not_connected, Toast.LENGTH_SHORT).show();
|
||||
showToast(R.string.at_not_connected);
|
||||
} else if (result == TelnetClient.SendResult.IO_ERROR) {
|
||||
Toast.makeText(ctx, R.string.at_send_error, Toast.LENGTH_SHORT).show();
|
||||
showToast(R.string.at_send_error);
|
||||
}
|
||||
updateConsoleView();
|
||||
});
|
||||
}
|
||||
|
||||
private void refreshConsole() {
|
||||
if (!isAdded() || uploader == null || atStatus == null) {
|
||||
return;
|
||||
private Integer parseInt(TextInputEditText field) {
|
||||
if (field == null || field.getText() == null) return null;
|
||||
String s = field.getText().toString().trim();
|
||||
if (s.isEmpty()) return null;
|
||||
try {
|
||||
return Integer.parseInt(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
boolean telnetOn = uploader.isTelnetConnected();
|
||||
}
|
||||
|
||||
private Double parseDouble(TextInputEditText field) {
|
||||
if (field == null || field.getText() == null) return null;
|
||||
String s = field.getText().toString().trim();
|
||||
if (s.isEmpty()) return null;
|
||||
try {
|
||||
return Double.parseDouble(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPeerTarget() {
|
||||
return atTargetGroup != null && atTargetGroup.getCheckedButtonId() == R.id.atTargetPeer;
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
if (!isAdded() || uploader == null || atStatus == null) return;
|
||||
atStatus.setText(getString(
|
||||
R.string.at_status,
|
||||
telnetOn ? getString(R.string.connected) : getString(R.string.disconnected)
|
||||
uploader.isTelnetConnected()
|
||||
? getString(R.string.connected) : getString(R.string.disconnected)
|
||||
));
|
||||
RadioSnapshot snap = RadioSnapshot.fromExtracted(uploader.getLastStats());
|
||||
atCurrentSnapshot.setText(LoraStatsFormatter.formatStatic(snap, java.util.Set.of())
|
||||
+ "\n" + LoraStatsFormatter.formatDynamic(snap, java.util.Set.of()));
|
||||
if (!formInitialized) {
|
||||
if (snap.frequencyMhz != null && isEmpty(atInputFq)) {
|
||||
atInputFq.setText(String.format(Locale.US, "%.3f", snap.frequencyMhz));
|
||||
}
|
||||
if (snap.powerDbm != null && isEmpty(atInputPw)) {
|
||||
atInputPw.setText(String.valueOf(snap.powerDbm.intValue()));
|
||||
}
|
||||
if (snap.sf != null && isEmpty(atInputSf)) {
|
||||
atInputSf.setText(String.valueOf(snap.sf));
|
||||
}
|
||||
if (snap.bwKhz != null) {
|
||||
selectBw(String.valueOf(snap.bwKhz));
|
||||
}
|
||||
if (snap.role != null && atRoleSpinner != null) {
|
||||
atRoleSpinner.setSelection(StatsExtractor.ROLE_RX.equals(snap.role) ? 2 : 1);
|
||||
}
|
||||
formInitialized = true;
|
||||
}
|
||||
updateConsoleView();
|
||||
if (pollHelper != null) {
|
||||
pollHelper.scheduleNext(400);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateConsoleView() {
|
||||
if (uploader == null || atConsole == null || atConsoleScroll == null) {
|
||||
private static boolean isEmpty(TextInputEditText field) {
|
||||
return field == null || field.getText() == null || field.getText().length() == 0;
|
||||
}
|
||||
|
||||
private void selectBw(String bw) {
|
||||
if (atBwSpinner == null || bw == null) return;
|
||||
for (int i = 0; i < AtCommands.BW_KHZ.length; i++) {
|
||||
if (AtCommands.BW_KHZ[i].equals(bw) || AtCommands.BW_KHZ[i].equals(bw.replace(".0", ""))) {
|
||||
atBwSpinner.setSelection(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (consoleVisible) {
|
||||
atConsoleScroll.post(() -> atConsoleScroll.fullScroll(View.FOCUS_DOWN));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void showToast(int resId) {
|
||||
if (isAdded()) Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private void showToast(String msg) {
|
||||
if (isAdded()) Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (pollHelper != null) {
|
||||
pollHelper.start(0);
|
||||
}
|
||||
if (pollHelper != null) pollHelper.start(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
if (pollHelper != null) {
|
||||
pollHelper.stop();
|
||||
}
|
||||
if (pollHelper != null) pollHelper.stop();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
if (pollHelper != null) {
|
||||
pollHelper.stop();
|
||||
}
|
||||
atStatus = null;
|
||||
atConsole = null;
|
||||
atConsoleScroll = null;
|
||||
atCommandInput = null;
|
||||
if (pollHelper != null) pollHelper.stop();
|
||||
pollHelper = null;
|
||||
formInitialized = false;
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.Gravity;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -14,17 +20,37 @@ import com.grigowashere.loratester.api.ChatMessage;
|
||||
import java.text.DateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
|
||||
|
||||
private static final int COLOR_SELF_BG = 0xFF16213E;
|
||||
private static final int COLOR_OTHER_BG = 0xFF1A4A6E;
|
||||
private static final int COLOR_NEW_HIGHLIGHT = 0x33E94560;
|
||||
|
||||
private final List<ChatMessage> messages = new ArrayList<>();
|
||||
private final DateFormat timeFormat =
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private final Set<Integer> highlightedPositions = new HashSet<>();
|
||||
|
||||
private String selfDeviceId;
|
||||
private double lastSeenTs;
|
||||
|
||||
public void setSelfDeviceId(String selfDeviceId) {
|
||||
this.selfDeviceId = selfDeviceId;
|
||||
}
|
||||
|
||||
public void setLastSeenTs(double lastSeenTs) {
|
||||
this.lastSeenTs = lastSeenTs;
|
||||
}
|
||||
|
||||
public void setMessages(List<ChatMessage> newMessages) {
|
||||
messages.clear();
|
||||
highlightedPositions.clear();
|
||||
messages.addAll(newMessages);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
@@ -35,7 +61,26 @@ public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
|
||||
}
|
||||
int start = messages.size();
|
||||
messages.addAll(more);
|
||||
for (int i = 0; i < more.size(); i++) {
|
||||
if (more.get(i).ts > lastSeenTs) {
|
||||
highlightedPositions.add(start + i);
|
||||
}
|
||||
}
|
||||
notifyItemRangeInserted(start, more.size());
|
||||
for (int i = 0; i < more.size(); i++) {
|
||||
int pos = start + i;
|
||||
if (highlightedPositions.contains(pos)) {
|
||||
scheduleClearHighlight(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleClearHighlight(int position) {
|
||||
handler.postDelayed(() -> {
|
||||
if (highlightedPositions.remove(position)) {
|
||||
notifyItemChanged(position);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
public double lastTs() {
|
||||
@@ -56,8 +101,42 @@ public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull Holder holder, int position) {
|
||||
ChatMessage m = messages.get(position);
|
||||
boolean self = selfDeviceId != null && selfDeviceId.equals(m.device_id);
|
||||
String time = timeFormat.format(new Date((long) (m.ts * 1000)));
|
||||
holder.text.setText(time + " " + m.device_id + ": " + m.text);
|
||||
|
||||
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) holder.bubble.getLayoutParams();
|
||||
lp.gravity = self ? Gravity.END : Gravity.START;
|
||||
holder.bubble.setLayoutParams(lp);
|
||||
holder.bubble.setBackgroundColor(self ? COLOR_SELF_BG : COLOR_OTHER_BG);
|
||||
|
||||
holder.author.setText(self
|
||||
? holder.itemView.getContext().getString(R.string.chat_self_label)
|
||||
: m.device_id);
|
||||
holder.text.setText(m.text);
|
||||
holder.time.setText(time);
|
||||
|
||||
int bg = self ? COLOR_SELF_BG : COLOR_OTHER_BG;
|
||||
if (highlightedPositions.contains(position)) {
|
||||
holder.bubble.setBackgroundColor(blend(bg, COLOR_NEW_HIGHLIGHT));
|
||||
} else {
|
||||
holder.bubble.setBackgroundColor(bg);
|
||||
}
|
||||
}
|
||||
|
||||
private static int blend(int base, int overlay) {
|
||||
int ba = Color.alpha(base);
|
||||
int br = Color.red(base);
|
||||
int bg = Color.green(base);
|
||||
int bb = Color.blue(base);
|
||||
int oa = Color.alpha(overlay);
|
||||
int or = Color.red(overlay);
|
||||
int og = Color.green(overlay);
|
||||
int ob = Color.blue(overlay);
|
||||
float ratio = oa / 255f;
|
||||
int r = (int) (br * (1 - ratio) + or * ratio);
|
||||
int g = (int) (bg * (1 - ratio) + og * ratio);
|
||||
int b = (int) (bb * (1 - ratio) + ob * ratio);
|
||||
return Color.argb(ba, r, g, b);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -66,11 +145,17 @@ public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
|
||||
}
|
||||
|
||||
static class Holder extends RecyclerView.ViewHolder {
|
||||
final LinearLayout bubble;
|
||||
final TextView author;
|
||||
final TextView text;
|
||||
final TextView time;
|
||||
|
||||
Holder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
text = itemView.findViewById(R.id.chatItemText);
|
||||
bubble = itemView.findViewById(R.id.chatBubble);
|
||||
author = itemView.findViewById(R.id.chatAuthor);
|
||||
text = itemView.findViewById(R.id.chatText);
|
||||
time = itemView.findViewById(R.id.chatTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ public class ChatFragment extends Fragment {
|
||||
View inputBar = view.findViewById(R.id.chatInputBar);
|
||||
|
||||
adapter = new ChatAdapter();
|
||||
if (uploader != null) {
|
||||
adapter.setSelfDeviceId(uploader.getDeviceId());
|
||||
}
|
||||
LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||
recycler.setLayoutManager(layoutManager);
|
||||
recycler.setAdapter(adapter);
|
||||
@@ -161,6 +164,10 @@ public class ChatFragment extends Fragment {
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (adapter != null && uploader != null) {
|
||||
adapter.setSelfDeviceId(uploader.getDeviceId());
|
||||
adapter.setLastSeenTs(chatSince);
|
||||
}
|
||||
if (pollHelper != null) {
|
||||
pollHelper.start(0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
/** Diverging color ramp: blue = below, green = level, brown = above. */
|
||||
final class ElevationColorRamp {
|
||||
|
||||
private static final int ALPHA = 0x8C;
|
||||
|
||||
private ElevationColorRamp() {
|
||||
}
|
||||
|
||||
static int deltaToArgb(double deltaM) {
|
||||
if (deltaM <= -8.0) {
|
||||
return argb(0x1A, 0x4A, 0x8C);
|
||||
}
|
||||
if (deltaM <= -2.0) {
|
||||
return lerp(argb(0x1A, 0x4A, 0x8C), argb(0x4F, 0xC3, 0xF7), (deltaM + 8.0) / 6.0);
|
||||
}
|
||||
if (deltaM <= 2.0) {
|
||||
return lerp(argb(0x4F, 0xC3, 0xF7), argb(0x00, 0xFF, 0x88), (deltaM + 2.0) / 4.0);
|
||||
}
|
||||
if (deltaM <= 8.0) {
|
||||
return lerp(argb(0x00, 0xFF, 0x88), argb(0xFF, 0xC1, 0x07), (deltaM - 2.0) / 6.0);
|
||||
}
|
||||
return argb(0x8B, 0x5A, 0x2B);
|
||||
}
|
||||
|
||||
private static int argb(int r, int g, int b) {
|
||||
return (ALPHA << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
private static int lerp(int from, int to, double t) {
|
||||
t = Math.max(0.0, Math.min(1.0, t));
|
||||
int a = (int) Math.round(((from >>> 24) & 0xFF) + t * (((to >>> 24) & 0xFF) - ((from >>> 24) & 0xFF)));
|
||||
int r = (int) Math.round(((from >> 16) & 0xFF) + t * (((to >> 16) & 0xFF) - ((from >> 16) & 0xFF)));
|
||||
int g = (int) Math.round(((from >> 8) & 0xFF) + t * (((to >> 8) & 0xFF) - ((from >> 8) & 0xFF)));
|
||||
int b = (int) Math.round((from & 0xFF) + t * ((to & 0xFF) - (from & 0xFF)));
|
||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.grigowashere.loratester.api.ElevationGridResult;
|
||||
|
||||
import org.mapsforge.core.model.LatLong;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Builds a geo-referenced raster from elevation grid API response. */
|
||||
final class ElevationHeatmapBitmap {
|
||||
|
||||
static final class Raster {
|
||||
final Bitmap bitmap;
|
||||
final LatLong northWest;
|
||||
final LatLong southEast;
|
||||
|
||||
Raster(Bitmap bitmap, LatLong northWest, LatLong southEast) {
|
||||
this.bitmap = bitmap;
|
||||
this.northWest = northWest;
|
||||
this.southEast = southEast;
|
||||
}
|
||||
}
|
||||
|
||||
private ElevationHeatmapBitmap() {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static Raster build(ElevationGridResult grid) {
|
||||
if (grid == null || !grid.ok || grid.points == null || grid.points.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int minI = Integer.MAX_VALUE;
|
||||
int maxI = Integer.MIN_VALUE;
|
||||
int minJ = Integer.MAX_VALUE;
|
||||
int maxJ = Integer.MIN_VALUE;
|
||||
double minLat = Double.MAX_VALUE;
|
||||
double maxLat = -Double.MAX_VALUE;
|
||||
double minLon = Double.MAX_VALUE;
|
||||
double maxLon = -Double.MAX_VALUE;
|
||||
|
||||
Map<Long, ElevationGridResult.GridPoint> byIndex = new HashMap<>();
|
||||
for (ElevationGridResult.GridPoint p : grid.points) {
|
||||
minI = Math.min(minI, p.i);
|
||||
maxI = Math.max(maxI, p.i);
|
||||
minJ = Math.min(minJ, p.j);
|
||||
maxJ = Math.max(maxJ, p.j);
|
||||
minLat = Math.min(minLat, p.lat);
|
||||
maxLat = Math.max(maxLat, p.lat);
|
||||
minLon = Math.min(minLon, p.lon);
|
||||
maxLon = Math.max(maxLon, p.lon);
|
||||
byIndex.put(pack(p.i, p.j), p);
|
||||
}
|
||||
|
||||
int cols = maxJ - minJ + 1;
|
||||
int rows = maxI - minI + 1;
|
||||
if (cols < 1 || rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int[] pixels = new int[cols * rows];
|
||||
double radiusM = grid.radius_m > 0 ? grid.radius_m : 200.0;
|
||||
double stepM = grid.step_m > 0 ? grid.step_m : 15.0;
|
||||
|
||||
for (int row = 0; row < rows; row++) {
|
||||
int i = maxI - row;
|
||||
for (int col = 0; col < cols; col++) {
|
||||
int j = minJ + col;
|
||||
double dist = Math.hypot(i * stepM, j * stepM);
|
||||
if (dist > radiusM + stepM * 0.5) {
|
||||
continue;
|
||||
}
|
||||
ElevationGridResult.GridPoint p = byIndex.get(pack(i, j));
|
||||
if (p != null && p.elevation_m != null) {
|
||||
pixels[row * cols + col] = ElevationColorRamp.deltaToArgb(p.delta_m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Bitmap bitmap = Bitmap.createBitmap(cols, rows, Bitmap.Config.ARGB_8888);
|
||||
bitmap.setPixels(pixels, 0, cols, 0, 0, cols, rows);
|
||||
|
||||
double halfStepLat = (stepM / 2.0) / 111_320.0;
|
||||
double halfStepLon = (stepM / 2.0)
|
||||
/ (111_320.0 * Math.max(Math.cos(Math.toRadians((minLat + maxLat) / 2.0)), 1e-6));
|
||||
|
||||
LatLong northWest = new LatLong(maxLat + halfStepLat, minLon - halfStepLon);
|
||||
LatLong southEast = new LatLong(minLat - halfStepLat, maxLon + halfStepLon);
|
||||
return new Raster(bitmap, northWest, southEast);
|
||||
}
|
||||
|
||||
private static long pack(int i, int j) {
|
||||
return ((long) i << 32) ^ (j & 0xFFFFFFFFL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
|
||||
import com.grigowashere.loratester.api.ElevationGridResult;
|
||||
|
||||
import org.mapsforge.core.graphics.Canvas;
|
||||
import org.mapsforge.core.model.BoundingBox;
|
||||
import org.mapsforge.core.model.LatLong;
|
||||
import org.mapsforge.core.model.Point;
|
||||
import org.mapsforge.core.util.MercatorProjection;
|
||||
import org.mapsforge.map.android.graphics.AndroidBitmap;
|
||||
import org.mapsforge.map.layer.Layer;
|
||||
|
||||
/** Geo-referenced elevation heatmap overlay for Mapsforge. */
|
||||
public class ElevationHeatmapLayer extends Layer {
|
||||
|
||||
private static final int TILE_SIZE = 256;
|
||||
|
||||
private Bitmap sourceBitmap;
|
||||
private org.mapsforge.core.graphics.Bitmap mapsforgeBitmap;
|
||||
private LatLong northWest;
|
||||
private LatLong southEast;
|
||||
|
||||
public void setGrid(ElevationGridResult grid) {
|
||||
sourceBitmap = null;
|
||||
mapsforgeBitmap = null;
|
||||
|
||||
ElevationHeatmapBitmap.Raster raster = ElevationHeatmapBitmap.build(grid);
|
||||
if (raster == null) {
|
||||
northWest = null;
|
||||
southEast = null;
|
||||
return;
|
||||
}
|
||||
sourceBitmap = raster.bitmap;
|
||||
northWest = raster.northWest;
|
||||
southEast = raster.southEast;
|
||||
}
|
||||
|
||||
public boolean hasData() {
|
||||
return sourceBitmap != null && northWest != null && southEast != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(BoundingBox boundingBox, byte zoomLevel, Canvas canvas, Point topLeftPoint) {
|
||||
if (!hasData()) {
|
||||
return;
|
||||
}
|
||||
|
||||
BoundingBox rasterBox = new BoundingBox(
|
||||
southEast.latitude,
|
||||
northWest.longitude,
|
||||
northWest.latitude,
|
||||
southEast.longitude
|
||||
);
|
||||
if (!boundingBox.intersects(rasterBox)) {
|
||||
return;
|
||||
}
|
||||
|
||||
long mapSize = MercatorProjection.getMapSize(zoomLevel, TILE_SIZE);
|
||||
int left = (int) Math.round(
|
||||
MercatorProjection.longitudeToPixelX(northWest.longitude, mapSize) - topLeftPoint.x);
|
||||
int top = (int) Math.round(
|
||||
MercatorProjection.latitudeToPixelY(northWest.latitude, mapSize) - topLeftPoint.y);
|
||||
int right = (int) Math.round(
|
||||
MercatorProjection.longitudeToPixelX(southEast.longitude, mapSize) - topLeftPoint.x);
|
||||
int bottom = (int) Math.round(
|
||||
MercatorProjection.latitudeToPixelY(southEast.latitude, mapSize) - topLeftPoint.y);
|
||||
|
||||
int width = right - left;
|
||||
int height = bottom - top;
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mapsforgeBitmap == null) {
|
||||
mapsforgeBitmap = new AndroidBitmap(sourceBitmap);
|
||||
}
|
||||
|
||||
canvas.drawBitmap(
|
||||
mapsforgeBitmap,
|
||||
0, 0, sourceBitmap.getWidth(), sourceBitmap.getHeight(),
|
||||
left, top, right, bottom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.LinearGradient;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Shader;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.grigowashere.loratester.R;
|
||||
|
||||
/** Color scale for elevation heatmap (relative to GPS center). */
|
||||
public class ElevationHeatmapLegendView extends View {
|
||||
|
||||
private static final double DELTA_MIN = -10.0;
|
||||
private static final double DELTA_MAX = 10.0;
|
||||
|
||||
private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final RectF barRect = new RectF();
|
||||
private final RectF bgRect = new RectF();
|
||||
|
||||
public ElevationHeatmapLegendView(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
bgPaint.setColor(0xCC0F3460);
|
||||
labelPaint.setColor(0xFFEEEEEE);
|
||||
labelPaint.setTextSize(sp(9));
|
||||
titlePaint.setColor(0xFFFFFFFF);
|
||||
titlePaint.setTextSize(sp(9));
|
||||
titlePaint.setFakeBoldText(true);
|
||||
setLayerType(LAYER_TYPE_SOFTWARE, null);
|
||||
}
|
||||
|
||||
private float sp(float value) {
|
||||
return value * getResources().getDisplayMetrics().scaledDensity;
|
||||
}
|
||||
|
||||
private float dp(float value) {
|
||||
return value * getResources().getDisplayMetrics().density;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
int w = (int) dp(118);
|
||||
int h = (int) dp(118);
|
||||
setMeasuredDimension(
|
||||
resolveSize(w, widthMeasureSpec),
|
||||
resolveSize(h, heightMeasureSpec));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
float pad = dp(6);
|
||||
bgRect.set(pad, pad, getWidth() - pad, getHeight() - pad);
|
||||
canvas.drawRoundRect(bgRect, dp(4), dp(4), bgPaint);
|
||||
|
||||
float titleY = bgRect.top + dp(12);
|
||||
canvas.drawText(getContext().getString(R.string.map_heatmap_legend_title),
|
||||
bgRect.left + dp(6), titleY, titlePaint);
|
||||
|
||||
float barLeft = bgRect.left + dp(8);
|
||||
float barTop = titleY + dp(6);
|
||||
float barBottom = bgRect.bottom - dp(8);
|
||||
float barRight = barLeft + dp(14);
|
||||
barRect.set(barLeft, barTop, barRight, barBottom);
|
||||
|
||||
int[] colors = sampleRampColors(24);
|
||||
float[] positions = new float[colors.length];
|
||||
for (int i = 0; i < colors.length; i++) {
|
||||
positions[i] = i / (float) (colors.length - 1);
|
||||
}
|
||||
barPaint.setShader(new LinearGradient(
|
||||
barRect.left, barRect.top, barRect.left, barRect.bottom,
|
||||
colors, positions, Shader.TileMode.CLAMP));
|
||||
canvas.drawRoundRect(barRect, dp(2), dp(2), barPaint);
|
||||
barPaint.setShader(null);
|
||||
|
||||
float textX = barRect.right + dp(6);
|
||||
drawLegendRow(canvas, textX, barRect.top + dp(4),
|
||||
getContext().getString(R.string.map_heatmap_legend_high),
|
||||
"+8 m");
|
||||
drawLegendRow(canvas, textX, (barRect.top + barRect.bottom) / 2f,
|
||||
getContext().getString(R.string.map_heatmap_legend_level),
|
||||
"0 m");
|
||||
drawLegendRow(canvas, textX, barRect.bottom - dp(4),
|
||||
getContext().getString(R.string.map_heatmap_legend_low),
|
||||
"-8 m");
|
||||
}
|
||||
|
||||
private void drawLegendRow(Canvas canvas, float x, float centerY, String label, String meters) {
|
||||
float lineH = labelPaint.getTextSize();
|
||||
canvas.drawText(label, x, centerY - dp(1), labelPaint);
|
||||
canvas.drawText(meters, x, centerY + lineH - dp(2), labelPaint);
|
||||
}
|
||||
|
||||
private static int[] sampleRampColors(int steps) {
|
||||
int[] colors = new int[steps];
|
||||
for (int i = 0; i < steps; i++) {
|
||||
double t = i / (double) (steps - 1);
|
||||
double delta = DELTA_MAX + t * (DELTA_MIN - DELTA_MAX);
|
||||
colors[i] = ElevationColorRamp.deltaToArgb(delta) | 0xFF000000;
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,4 +35,12 @@ final class MapsforgeBitmaps {
|
||||
paint.setStyle(org.mapsforge.core.graphics.Style.STROKE);
|
||||
return paint;
|
||||
}
|
||||
|
||||
static org.mapsforge.core.graphics.Paint linePaint(int argb, float strokeWidth) {
|
||||
int a = (argb >> 24) & 0xFF;
|
||||
int r = (argb >> 16) & 0xFF;
|
||||
int g = (argb >> 8) & 0xFF;
|
||||
int b = argb & 0xFF;
|
||||
return linePaint(AndroidGraphicFactory.INSTANCE.createColor(r, g, b, a), strokeWidth);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TableLayout;
|
||||
import android.widget.TableRow;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.model.RadioSnapshot;
|
||||
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
public class RadioComparePanel extends LinearLayout {
|
||||
|
||||
private static final int COLOR_TX = 0xFFE94560;
|
||||
private static final int COLOR_RX = 0xFF4FC3F7;
|
||||
private static final int COLOR_CHANGED = 0x33E94560;
|
||||
|
||||
private TextView txHeader;
|
||||
private TextView rxHeader;
|
||||
private TableLayout dynamicTable;
|
||||
private TableLayout staticTable;
|
||||
private MaterialButton staticToggle;
|
||||
private boolean staticExpanded;
|
||||
|
||||
public RadioComparePanel(Context context) {
|
||||
super(context);
|
||||
init(context);
|
||||
}
|
||||
|
||||
public RadioComparePanel(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init(context);
|
||||
}
|
||||
|
||||
private void init(Context context) {
|
||||
setOrientation(VERTICAL);
|
||||
LayoutInflater.from(context).inflate(R.layout.view_radio_compare, this, true);
|
||||
txHeader = findViewById(R.id.compareTxHeader);
|
||||
rxHeader = findViewById(R.id.compareRxHeader);
|
||||
dynamicTable = findViewById(R.id.compareDynamicTable);
|
||||
staticTable = findViewById(R.id.compareStaticTable);
|
||||
staticToggle = findViewById(R.id.compareStaticToggle);
|
||||
staticToggle.setOnClickListener(v -> {
|
||||
staticExpanded = !staticExpanded;
|
||||
staticTable.setVisibility(staticExpanded ? VISIBLE : GONE);
|
||||
staticToggle.setText(staticExpanded
|
||||
? getContext().getString(R.string.stats_static_hide)
|
||||
: getContext().getString(R.string.stats_static_toggle));
|
||||
});
|
||||
}
|
||||
|
||||
public void bind(
|
||||
RadioSnapshot txSnap,
|
||||
RadioSnapshot rxSnap,
|
||||
String txDeviceId,
|
||||
String rxDeviceId,
|
||||
Set<String> changedTx,
|
||||
Set<String> changedRx
|
||||
) {
|
||||
txHeader.setText("TX · " + (txDeviceId != null ? txDeviceId : "—"));
|
||||
rxHeader.setText("RX · " + (rxDeviceId != null ? rxDeviceId : "—"));
|
||||
fillTable(dynamicTable, true, txSnap, rxSnap, changedTx, changedRx);
|
||||
fillTable(staticTable, false, txSnap, rxSnap, changedTx, changedRx);
|
||||
}
|
||||
|
||||
private void fillTable(
|
||||
TableLayout table,
|
||||
boolean dynamic,
|
||||
RadioSnapshot tx,
|
||||
RadioSnapshot rx,
|
||||
Set<String> changedTx,
|
||||
Set<String> changedRx
|
||||
) {
|
||||
table.removeAllViews();
|
||||
if (dynamic) {
|
||||
addRow(table, "RSSI", fmtDbm(tx.rssiDbm), fmtDbm(rx.rssiDbm), "rssi", changedTx, changedRx);
|
||||
addRow(table, "SNR", fmtSuffix(tx.snrDb, " dB"), fmtSuffix(rx.snrDb, " dB"), "snr", changedTx, changedRx);
|
||||
addRow(table, "RX Quality", fmtSuffix(tx.rxQualityPercent, " %"), fmtSuffix(rx.rxQualityPercent, " %"),
|
||||
"rxQuality", changedTx, changedRx);
|
||||
addRow(table, "Пакет", fmtInt(tx.packet), fmtInt(rx.packet), "packet", changedTx, changedRx);
|
||||
addRow(table, "Payload", str(tx.payload), str(rx.payload), "payload", changedTx, changedRx);
|
||||
addRow(table, "PER", fmtSuffix(tx.perPercent, " %"), fmtSuffix(rx.perPercent, " %"), "per", changedTx, changedRx);
|
||||
addRow(table, "Принято", fmtInt(tx.packetReceive), fmtInt(rx.packetReceive), "packetReceive", changedTx, changedRx);
|
||||
addRow(table, "Всего", fmtInt(tx.packetTotal), fmtInt(rx.packetTotal), "packetTotal", changedTx, changedRx);
|
||||
addRow(table, "Ошибки", fmtInt(tx.packetError), fmtInt(rx.packetError), "packetError", changedTx, changedRx);
|
||||
addRow(table, "CRC err", fmtInt(tx.crcError), fmtInt(rx.crcError), "crcError", changedTx, changedRx);
|
||||
addRow(table, "Preamble", fmtInt(tx.preambleDetected), fmtInt(rx.preambleDetected),
|
||||
"preambleDetected", changedTx, changedRx);
|
||||
addRow(table, "Header OK", fmtInt(tx.headerValid), fmtInt(rx.headerValid), "headerValid", changedTx, changedRx);
|
||||
addRow(table, "TX spd", fmtSuffix(tx.txPktPerS, " p/s"), fmtSuffix(rx.txPktPerS, " p/s"), "txSpeed", changedTx, changedRx);
|
||||
addRow(table, "RX spd", fmtSuffix(tx.rxPktPerS, " p/s"), fmtSuffix(rx.rxPktPerS, " p/s"), "rxSpeed", changedTx, changedRx);
|
||||
} else {
|
||||
addRow(table, "Роль", LoraStatsFormatter.roleLabel(tx.role), LoraStatsFormatter.roleLabel(rx.role), "role", changedTx, changedRx);
|
||||
addRow(table, "Частота", fmtMhz(tx.frequencyMhz), fmtMhz(rx.frequencyMhz), "frequency", changedTx, changedRx);
|
||||
addRow(table, "SF", fmtInt(tx.sf), fmtInt(rx.sf), "sf", changedTx, changedRx);
|
||||
addRow(table, "BW", fmtBw(tx.bwKhz), fmtBw(rx.bwKhz), "bw", changedTx, changedRx);
|
||||
addRow(table, "Мощн.", fmtDbm(tx.powerDbm), fmtDbm(rx.powerDbm), "power", changedTx, changedRx);
|
||||
addRow(table, "Code Rate", str(tx.codeRate), str(rx.codeRate), "codeRate", changedTx, changedRx);
|
||||
addRow(table, "Preamble", fmtInt(tx.preambleLength), fmtInt(rx.preambleLength), "preambleLength", changedTx, changedRx);
|
||||
addRow(table, "LDR", str(tx.lowDataRateOpt), str(rx.lowDataRateOpt), "lowDataRateOpt", changedTx, changedRx);
|
||||
addRow(table, "CRC", fmtCrc(tx.crcEnabled), fmtCrc(rx.crcEnabled), "crc", changedTx, changedRx);
|
||||
addRow(table, "Payl.len", fmtSuffixInt(tx.payloadLengthBytes, " B"), fmtSuffixInt(rx.payloadLengthBytes, " B"),
|
||||
"payloadLength", changedTx, changedRx);
|
||||
addRow(table, "TX Timeout", fmtSuffix(tx.txTimeoutMs, " ms"), fmtSuffix(rx.txTimeoutMs, " ms"),
|
||||
"txTimeout", changedTx, changedRx);
|
||||
addRow(table, "On Air", fmtSuffix(tx.onAirMs, " ms"), fmtSuffix(rx.onAirMs, " ms"), "onAir", changedTx, changedRx);
|
||||
}
|
||||
}
|
||||
|
||||
private void addRow(
|
||||
TableLayout table,
|
||||
String label,
|
||||
String txVal,
|
||||
String rxVal,
|
||||
String changeKey,
|
||||
Set<String> changedTx,
|
||||
Set<String> changedRx
|
||||
) {
|
||||
TableRow row = new TableRow(getContext());
|
||||
TextView lbl = cell(label, 0xFFAAAAAA, false);
|
||||
TextView tx = cell(txVal, COLOR_TX, changedTx != null && changedTx.contains(changeKey));
|
||||
TextView rx = cell(rxVal, COLOR_RX, changedRx != null && changedRx.contains(changeKey));
|
||||
row.addView(lbl);
|
||||
row.addView(tx);
|
||||
row.addView(rx);
|
||||
table.addView(row);
|
||||
}
|
||||
|
||||
private TextView cell(String text, int color, boolean changed) {
|
||||
TextView tv = new TextView(getContext());
|
||||
tv.setText(text != null ? text : "—");
|
||||
tv.setTextColor(color);
|
||||
tv.setTextSize(11f);
|
||||
tv.setPadding(4, 2, 4, 2);
|
||||
if (changed) {
|
||||
tv.setBackgroundColor(COLOR_CHANGED);
|
||||
}
|
||||
return tv;
|
||||
}
|
||||
|
||||
/** Assign TX/RX snapshots by device role. */
|
||||
public static void bindByRole(
|
||||
RadioComparePanel panel,
|
||||
RadioSnapshot local,
|
||||
RadioSnapshot peer,
|
||||
String localId,
|
||||
String peerId,
|
||||
Set<String> changedLocal,
|
||||
Set<String> changedPeer
|
||||
) {
|
||||
RadioSnapshot tx = local;
|
||||
RadioSnapshot rx = peer;
|
||||
String txId = localId;
|
||||
String rxId = peerId;
|
||||
Set<String> chTx = changedLocal;
|
||||
Set<String> chRx = changedPeer;
|
||||
if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) {
|
||||
tx = peer;
|
||||
rx = local;
|
||||
txId = peerId;
|
||||
rxId = localId;
|
||||
chTx = changedPeer;
|
||||
chRx = changedLocal;
|
||||
}
|
||||
if (tx == null) tx = RadioSnapshot.empty();
|
||||
if (rx == null) rx = RadioSnapshot.empty();
|
||||
panel.bind(tx, rx, txId, rxId, chTx, chRx);
|
||||
}
|
||||
|
||||
private static String str(String v) {
|
||||
return v != null && !v.isEmpty() ? v : "—";
|
||||
}
|
||||
|
||||
private static String fmtInt(Integer v) {
|
||||
return v != null ? String.valueOf(v) : "—";
|
||||
}
|
||||
|
||||
private static String fmtDbm(Double v) {
|
||||
return v != null ? String.format(Locale.US, "%.0f dBm", v) : "—";
|
||||
}
|
||||
|
||||
private static String fmtMhz(Double v) {
|
||||
return v != null ? String.format(Locale.US, "%.3f MHz", v) : "—";
|
||||
}
|
||||
|
||||
private static String fmtSuffix(Double v, String suffix) {
|
||||
return v != null ? v + suffix : "—";
|
||||
}
|
||||
|
||||
private static String fmtBw(Double v) {
|
||||
return v != null ? String.format(Locale.US, "%.2f kHz", v) : "—";
|
||||
}
|
||||
|
||||
private static String fmtCrc(Boolean enabled) {
|
||||
if (enabled == null) {
|
||||
return "—";
|
||||
}
|
||||
return enabled ? "On" : "Off";
|
||||
}
|
||||
|
||||
private static String fmtSuffixInt(Integer v, String suffix) {
|
||||
return v != null ? v + suffix : "—";
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import androidx.fragment.app.Fragment;
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.grigowashere.loratester.LoraApp;
|
||||
import com.grigowashere.loratester.LoraForegroundService;
|
||||
import com.grigowashere.loratester.MainActivity;
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.SettingsRepository;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
@@ -42,7 +44,9 @@ public class SettingsFragment extends Fragment {
|
||||
TextInputEditText editPort = view.findViewById(R.id.editTelnetPort);
|
||||
TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex);
|
||||
TextInputEditText editRange = view.findViewById(R.id.editRangeRegex);
|
||||
TextInputEditText editDeviceLabel = view.findViewById(R.id.editDeviceLabel);
|
||||
SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet);
|
||||
Button batteryBtn = view.findViewById(R.id.btnBatteryOptimization);
|
||||
TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel);
|
||||
Button save = view.findViewById(R.id.btnSaveSettings);
|
||||
|
||||
@@ -51,9 +55,21 @@ public class SettingsFragment extends Fragment {
|
||||
editPort.setText(String.valueOf(settings.getTelnetPort()));
|
||||
editRssi.setText(settings.getRssiRegex());
|
||||
editRange.setText(settings.getRangeRegex());
|
||||
String savedLabel = settings.getDeviceLabel();
|
||||
if (savedLabel != null) {
|
||||
editDeviceLabel.setText(savedLabel);
|
||||
}
|
||||
switchTelnet.setChecked(settings.isTelnetEnabled());
|
||||
deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId()));
|
||||
|
||||
batteryBtn.setOnClickListener(v -> {
|
||||
if (MainActivity.isIgnoringBatteryOptimizations(requireContext())) {
|
||||
Toast.makeText(requireContext(), R.string.battery_optimization_done, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
MainActivity.openBatteryOptimizationSettings(requireContext());
|
||||
});
|
||||
|
||||
save.setOnClickListener(v -> {
|
||||
settings.setServerUrl(textOf(editServer, SettingsRepository.DEFAULT_SERVER));
|
||||
settings.setTelnetHost(textOf(editHost, SettingsRepository.DEFAULT_TELNET_HOST));
|
||||
@@ -64,13 +80,16 @@ public class SettingsFragment extends Fragment {
|
||||
}
|
||||
settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX));
|
||||
settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX));
|
||||
settings.setDeviceLabel(textOf(editDeviceLabel, ""));
|
||||
settings.setTelnetEnabled(switchTelnet.isChecked());
|
||||
uploader.refreshApi();
|
||||
uploader.registerPresence();
|
||||
if (switchTelnet.isChecked()) {
|
||||
uploader.startTelnet();
|
||||
} else {
|
||||
uploader.stopTelnet();
|
||||
}
|
||||
LoraForegroundService.ensureRunning(requireContext());
|
||||
Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -14,19 +15,20 @@ import androidx.fragment.app.Fragment;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.grigowashere.loratester.CommandPoller;
|
||||
import com.grigowashere.loratester.LoraApp;
|
||||
import com.grigowashere.loratester.PeerDevices;
|
||||
import com.grigowashere.loratester.PeerStatsCache;
|
||||
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.model.RadioSnapshot;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@@ -35,30 +37,33 @@ 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 CommandPoller commandPoller;
|
||||
private PeerStatsCache peerStatsCache;
|
||||
private TextView statsStatus;
|
||||
private TextView statsDetails;
|
||||
private TextView statsPeerWarning;
|
||||
private RadioComparePanel radioComparePanel;
|
||||
private RecyclerView statsHistoryList;
|
||||
private final HistoryAdapter historyAdapter = new HistoryAdapter();
|
||||
|
||||
private StatsExtractor.ExtractedStats cachedLocal;
|
||||
private DeviceInfo cachedServer;
|
||||
private RadioSnapshot prevLocal = RadioSnapshot.empty();
|
||||
private RadioSnapshot prevPeer = RadioSnapshot.empty();
|
||||
private RadioSnapshot snapLocal = RadioSnapshot.empty();
|
||||
private RadioSnapshot snapPeer = RadioSnapshot.empty();
|
||||
private String cachedPeerId;
|
||||
private String cachedPeerError;
|
||||
private int cachedDeviceCount;
|
||||
private String cachedError;
|
||||
|
||||
private final TelemetryUploader.StatsListener statsListener = stats -> {
|
||||
cachedLocal = stats;
|
||||
cachedError = null;
|
||||
postRender();
|
||||
};
|
||||
private final TelemetryUploader.StatsListener statsListener = stats -> postRender();
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
|
||||
LoraApp app = (LoraApp) context.getApplicationContext();
|
||||
uploader = app.getTelemetryUploader();
|
||||
commandPoller = app.getCommandPoller();
|
||||
peerStatsCache = app.getPeerStatsCache();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -74,11 +79,13 @@ public class StatsFragment extends Fragment {
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
statsStatus = view.findViewById(R.id.statsStatus);
|
||||
statsDetails = view.findViewById(R.id.statsDetails);
|
||||
statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
|
||||
radioComparePanel = view.findViewById(R.id.radioComparePanel);
|
||||
statsHistoryList = view.findViewById(R.id.statsHistoryList);
|
||||
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
statsHistoryList.setAdapter(historyAdapter);
|
||||
Button btnSimulate = view.findViewById(R.id.btnSimulate);
|
||||
Button btnPushStats = view.findViewById(R.id.btnPushStats);
|
||||
pollHelper = new FragmentPollHelper(this, this::refresh);
|
||||
|
||||
btnSimulate.setOnClickListener(v -> {
|
||||
@@ -86,14 +93,38 @@ public class StatsFragment extends Fragment {
|
||||
SEND
|
||||
Frequency: 433000000 Hz
|
||||
Power: 22 dBm
|
||||
Spreading Factor: 7
|
||||
Bandwidth: 125 kHz
|
||||
Packet: 1
|
||||
Payload: Sim TX
|
||||
\u001b[2J""";
|
||||
uploader.simulateChunk(chunk);
|
||||
if (pollHelper.canRun()) {
|
||||
statsStatus.setText(R.string.simulate_sent);
|
||||
}
|
||||
});
|
||||
btnPushStats.setOnClickListener(v -> pushStatsToPeer());
|
||||
}
|
||||
|
||||
private void pushStatsToPeer() {
|
||||
if (commandPoller == null || cachedPeerId == null) {
|
||||
toast(R.string.at_peer_unavailable);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
|
||||
if (localStats != null && localStats.metaJson != null) {
|
||||
payload.put("meta", localStats.metaJson);
|
||||
}
|
||||
if (snapLocal.role != null) payload.put("role", snapLocal.role);
|
||||
if (snapLocal.rssiDbm != null) payload.put("rssi", snapLocal.rssiDbm);
|
||||
if (snapLocal.sf != null) payload.put("sf", snapLocal.sf);
|
||||
if (snapLocal.bwKhz != null) payload.put("bw", snapLocal.bwKhz);
|
||||
commandPoller.postCommandToPeer(cachedPeerId, "stats_push", payload);
|
||||
toast(R.string.stats_pushed);
|
||||
}
|
||||
|
||||
private void toast(int resId) {
|
||||
if (isAdded()) {
|
||||
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -101,7 +132,6 @@ public class StatsFragment extends Fragment {
|
||||
super.onResume();
|
||||
if (uploader != null) {
|
||||
uploader.setStatsListener(statsListener);
|
||||
cachedLocal = uploader.getLastStats();
|
||||
postRender();
|
||||
}
|
||||
if (pollHelper != null) {
|
||||
@@ -126,7 +156,8 @@ public class StatsFragment extends Fragment {
|
||||
pollHelper.stop();
|
||||
}
|
||||
statsStatus = null;
|
||||
statsDetails = null;
|
||||
statsPeerWarning = null;
|
||||
radioComparePanel = null;
|
||||
statsHistoryList = null;
|
||||
pollHelper = null;
|
||||
super.onDestroyView();
|
||||
@@ -139,10 +170,10 @@ public class StatsFragment extends Fragment {
|
||||
}
|
||||
|
||||
private void postRender() {
|
||||
if (!isAdded() || statsDetails == null) {
|
||||
if (!isAdded() || radioComparePanel == null) {
|
||||
return;
|
||||
}
|
||||
requireActivity().runOnUiThread(this::renderDetails);
|
||||
requireActivity().runOnUiThread(this::render);
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
@@ -150,30 +181,52 @@ public class StatsFragment extends Fragment {
|
||||
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)
|
||||
uploader.isTelnetConnected()
|
||||
? getString(R.string.connected) : getString(R.string.disconnected)
|
||||
));
|
||||
|
||||
executor.execute(() -> {
|
||||
List<TelemetryHistoryItem> history = null;
|
||||
try {
|
||||
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||
cachedDeviceCount = devices.size();
|
||||
PeerDevices.Result peer = PeerDevices.resolve(devices, deviceId);
|
||||
cachedPeerId = peer.peerId;
|
||||
cachedPeerError = peer.error;
|
||||
|
||||
DeviceInfo self = null;
|
||||
DeviceInfo peerDev = null;
|
||||
for (DeviceInfo d : devices) {
|
||||
if (deviceId.equals(d.device_id)) {
|
||||
self = d;
|
||||
break;
|
||||
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
|
||||
peerDev = d;
|
||||
}
|
||||
}
|
||||
cachedServer = self;
|
||||
cachedDeviceCount = devices.size();
|
||||
cachedError = null;
|
||||
|
||||
StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
|
||||
snapLocal = localStats != null
|
||||
? RadioSnapshot.fromExtracted(localStats)
|
||||
: RadioSnapshot.fromMeta(
|
||||
self != null ? self.meta : null,
|
||||
self != null ? self.role : null,
|
||||
self != null ? self.rssi : null);
|
||||
|
||||
PeerStatsCache.Snapshot push = peerStatsCache != null ? peerStatsCache.get() : null;
|
||||
if (push != null && push.meta != null) {
|
||||
snapPeer = RadioSnapshot.fromMeta(push.meta, push.role, push.rssi);
|
||||
} else {
|
||||
snapPeer = RadioSnapshot.fromMeta(
|
||||
peerDev != null ? peerDev.meta : null,
|
||||
peerDev != null ? peerDev.role : null,
|
||||
peerDev != null ? peerDev.rssi : null);
|
||||
}
|
||||
|
||||
history = uploader.getServerApi().getTelemetryHistory(deviceId, 30);
|
||||
} catch (Exception e) {
|
||||
cachedError = e.getMessage() != null ? e.getMessage() : "error";
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
List<TelemetryHistoryItem> finalHistory = history;
|
||||
if (isAdded()) {
|
||||
@@ -190,77 +243,31 @@ public class StatsFragment extends Fragment {
|
||||
});
|
||||
}
|
||||
|
||||
private void renderDetails() {
|
||||
if (!isAdded() || statsDetails == null || uploader == null) {
|
||||
private void render() {
|
||||
if (!isAdded() || radioComparePanel == 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");
|
||||
if (statsPeerWarning != null) {
|
||||
if (cachedPeerError != null) {
|
||||
statsPeerWarning.setVisibility(View.VISIBLE);
|
||||
statsPeerWarning.setText(
|
||||
getString(R.string.stats_two_devices_required, cachedDeviceCount));
|
||||
} 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;
|
||||
statsPeerWarning.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
var chLocal = snapLocal.diff(prevLocal);
|
||||
var chPeer = snapPeer.diff(prevPeer);
|
||||
RadioComparePanel.bindByRole(
|
||||
radioComparePanel,
|
||||
snapLocal,
|
||||
snapPeer,
|
||||
uploader.getDeviceId(),
|
||||
cachedPeerId,
|
||||
chLocal,
|
||||
chPeer
|
||||
);
|
||||
prevLocal = snapLocal;
|
||||
prevPeer = snapPeer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="#00FF88" android:state_activated="true" />
|
||||
<item android:color="#FFFFFF" />
|
||||
</selector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#CC0F3460" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M3,17h2v-3H3v3zM3,12h2V9H3v3zM7,17h2v-5H7v5zM7,7h2V5H7v2zM11,17h2V7h-2v10zM15,17h2v-3h-2v3zM15,12h2V9h-2v3zM19,17h2v-7h-2v7zM19,8h2V5h-2v3z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,6l-3.75,5 2.75,3.5L9,18H3l8.5,-10.5L14,6zM17.5,10.5L14,15h6l-2.5,-4.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,3C7.03,3 3,7.03 3,12h2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7h2c0,-4.97 -4.03,-9 -9,-9zM12,7c-2.76,0 -5,2.24 -5,5h2c0,-1.66 1.34,-3 3,-3s3,1.34 3,3h2c0,-2.76 -2.24,-5 -5,-5zM12,11c-0.55,0 -1,0.45 -1,1h2c0,-0.55 -0.45,-1 -1,-1zM4.5,14.5L2,17l2.5,2.5 1.4,-1.4L4.8,17l1.1,-1.1 -1.4,-1.4zM19.5,14.5l-1.4,1.4 1.1,1.1 -1.1,1.1 1.4,1.4L22,17l-2.5,-2.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M4,18h16v2H4v-2zM6,15h2v2H6v-2zM16,15h2v2h-2v-2zM8,12h8v2H8v-2zM10,9h4v2h-4V9zM12,6c-2.2,0 -4,1.8 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2h2c0,-2.2 -1.8,-4 -4,-4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,14c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,20c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5V19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45V19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
|
||||
</vector>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?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:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
@@ -11,64 +12,209 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/atQuickChips"
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp" />
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/at_current_values"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/atCurrentSnapshot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/at_hint_fq_mhz">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/atInputFq"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberDecimal" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<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">
|
||||
android:hint="@string/at_hint_power">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/atCommandInput"
|
||||
android:id="@+id/atInputPw"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:inputType="text"
|
||||
android:singleLine="true" />
|
||||
android:inputType="numberSigned" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/atSendBtn"
|
||||
android:layout_width="wrap_content"
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/send" />
|
||||
android:layout_weight="1"
|
||||
android:hint="SF">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/atInputSf"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/at_hint_bw"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/atBwSpinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/at_hint_cr"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/atCrSpinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/at_hint_pl">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/atInputPl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/at_hint_tm">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/atInputTm"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/at_hint_role"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/atRoleSpinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/atTargetGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/atTargetLocal"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/at_target_local" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/atTargetPeer"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/at_target_peer" />
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/atClearLog"
|
||||
android:id="@+id/atApplyBtn"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/at_apply" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/atStopBtn"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text="S" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/atConsoleToggle"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/at_clear_log" />
|
||||
</LinearLayout>
|
||||
android:text="@string/at_console_toggle" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/atConsoleScroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="120dp"
|
||||
android:background="#0D1117"
|
||||
android:padding="8dp">
|
||||
android:padding="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/atConsole"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
@@ -8,47 +9,279 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/mapStatusChip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="56dp"
|
||||
android:background="@drawable/bg_map_panel"
|
||||
android:elevation="6dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingBottom="6dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iconServer"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:contentDescription="@string/status_server"
|
||||
android:src="@drawable/ic_link_server" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="3dp"
|
||||
android:text="@string/status_server_short"
|
||||
android:textColor="#AAAAAA"
|
||||
android:textSize="9sp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iconLora"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@string/status_lora"
|
||||
android:src="@drawable/ic_link_lora" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="3dp"
|
||||
android:text="@string/status_lora_short"
|
||||
android:textColor="#AAAAAA"
|
||||
android:textSize="9sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:maxLines="2"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapDistance"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="#00FF88"
|
||||
android:textSize="9sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/mapToolRail"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|center_vertical"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:background="@drawable/bg_map_panel"
|
||||
android:elevation="6dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnToolCenter"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/map_tool_center"
|
||||
android:src="@drawable/ic_center"
|
||||
app:tint="@color/map_tool_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnFindHill"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/map_find_hill"
|
||||
android:src="@drawable/ic_hill"
|
||||
app:tint="@color/map_tool_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnHeatmap"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/map_heatmap"
|
||||
android:src="@drawable/ic_heatmap"
|
||||
app:tint="@color/map_tool_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnTrack"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/map_tool_track"
|
||||
android:src="@drawable/ic_track"
|
||||
app:tint="@color/map_tool_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnPairedTrack"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/map_tool_paired"
|
||||
android:src="@drawable/ic_paired_track"
|
||||
app:tint="@color/map_tool_icon_tint" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnToolMore"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@string/map_tool_more"
|
||||
android:src="@drawable/ic_more_vert"
|
||||
app:tint="@color/map_tool_icon_tint" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/mapSidePanel"
|
||||
android:layout_width="152dp"
|
||||
android:id="@+id/mapToolDrawer"
|
||||
android:layout_width="148dp"
|
||||
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">
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="56dp"
|
||||
android:background="@drawable/bg_map_panel"
|
||||
android:elevation="6dp"
|
||||
android:scrollbars="none"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="6dp">
|
||||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#FFFFFF"
|
||||
android:text="@string/map_center_mode"
|
||||
android:textColor="#00FF88"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/mapCenterMode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:orientation="vertical"
|
||||
app:selectionRequired="false"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/centerMe"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="32dp"
|
||||
android:text="@string/map_center_me"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/centerTx"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="32dp"
|
||||
android:text="@string/map_center_tx"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/centerRx"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="32dp"
|
||||
android:text="@string/map_center_rx"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/centerBoth"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="32dp"
|
||||
android:text="@string/map_center_both"
|
||||
android:textSize="10sp" />
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/map_layer"
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="9sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/mapBasemap"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/map_heatmap_radius"
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="9sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/mapHeatmapRadius"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapHeatmapStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="#AAAAAA"
|
||||
android:textSize="9sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapHillStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="#FFC107"
|
||||
android:textSize="9sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapLegend"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="@string/map_legend"
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="9sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTrack"
|
||||
<Spinner
|
||||
android:id="@+id/trackSpinner"
|
||||
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" />
|
||||
android:layout_marginTop="6dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/trackStatus"
|
||||
@@ -57,13 +290,18 @@
|
||||
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>
|
||||
|
||||
<com.grigowashere.loratester.ui.ElevationHeatmapLegendView
|
||||
android:id="@+id/mapHeatmapLegend"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:elevation="4dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
@@ -73,6 +73,19 @@
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/device_display_name">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editDeviceLabel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textCapWords" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switchTelnet"
|
||||
android:layout_width="match_parent"
|
||||
@@ -80,6 +93,20 @@
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/telnet_enabled" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/battery_optimization_hint"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnBatteryOptimization"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/battery_optimization" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/deviceIdLabel"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -15,21 +15,36 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statsPeerWarning"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="#FF9800"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.grigowashere.loratester.ui.RadioComparePanel
|
||||
android:id="@+id/radioComparePanel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPushStats"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/stats_push_peer" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSimulate"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
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"
|
||||
|
||||
@@ -1,7 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/chatItemText"
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="6dp"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/chatBubble"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start"
|
||||
android:background="#1A4A6E"
|
||||
android:maxWidth="280dp"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chatAuthor"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#AAAAAA"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chatText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="#EEEEEE"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chatTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="#888888"
|
||||
android:textSize="9sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/compareTxHeader"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textColor="#E94560"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/compareRxHeader"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textColor="#4FC3F7"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<TableLayout
|
||||
android:id="@+id/compareDynamicTable"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:stretchColumns="1,2" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/compareStaticToggle"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="0dp"
|
||||
android:padding="0dp"
|
||||
android:text="@string/stats_static_toggle"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TableLayout
|
||||
android:id="@+id/compareStaticTable"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:stretchColumns="1,2"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -2,4 +2,7 @@
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="status_ok">#FF00FF88</color>
|
||||
<color name="status_warn">#FFFFC107</color>
|
||||
<color name="status_off">#FF888888</color>
|
||||
</resources>
|
||||
@@ -17,6 +17,7 @@
|
||||
<string name="range_regex">Range regex</string>
|
||||
<string name="telnet_enabled">Подключить telnet</string>
|
||||
<string name="device_id_label">ID устройства: %1$s</string>
|
||||
<string name="device_display_name">Имя на карте (realme, OPPO…)</string>
|
||||
<string name="save">Сохранить</string>
|
||||
<string name="saved">Сохранено</string>
|
||||
<string name="chat_hint">Сообщение…</string>
|
||||
@@ -49,4 +50,79 @@
|
||||
<string name="track_error">Трек: %1$s</string>
|
||||
<string name="track_spinner_hint">Сохранённые треки</string>
|
||||
<string name="track_none">— нет треков —</string>
|
||||
<string name="stats_local_title">Это устройство</string>
|
||||
<string name="stats_peer_title">Другое устройство</string>
|
||||
<string name="stats_peer_absent">Нет данных (ожидается 2 устройства online)</string>
|
||||
<string name="stats_two_devices_required">Нужно ровно 2 устройства на сервере (сейчас %1$d)</string>
|
||||
<string name="stats_push_peer">Отправить статистику на другое</string>
|
||||
<string name="stats_pushed">Статистика отправлена</string>
|
||||
<string name="stats_push_failed">Не удалось отправить</string>
|
||||
<string name="at_target_local">Локально</string>
|
||||
<string name="at_target_peer">На другое устройство</string>
|
||||
<string name="at_sent_to_peer">Команда отправлена на %1$s</string>
|
||||
<string name="at_peer_unavailable">Нет второго устройства</string>
|
||||
<string name="track_paired_start">Старт трека (оба)</string>
|
||||
<string name="track_paired_started">Синхронный старт запланирован</string>
|
||||
<string name="track_paired_need_two">Нужны 2 устройства online</string>
|
||||
<string name="stats_static_toggle">▼ Статика</string>
|
||||
<string name="stats_static_hide">▲ Статика</string>
|
||||
<string name="at_current_values">Текущие значения</string>
|
||||
<string name="at_apply">Применить</string>
|
||||
<string name="at_hint_fq_mhz">Частота MHz (430–470)</string>
|
||||
<string name="at_hint_power">Мощность dBm (-9…22)</string>
|
||||
<string name="at_hint_bw">Bandwidth kHz</string>
|
||||
<string name="at_hint_cr">Code rate</string>
|
||||
<string name="at_hint_pl">Preamble (1–64)</string>
|
||||
<string name="at_hint_tm">Timeout ms (0–60000)</string>
|
||||
<string name="at_hint_role">Роль после настройки</string>
|
||||
<string name="at_console_toggle">▼ Консоль</string>
|
||||
<string name="at_console_hide">▲ Консоль</string>
|
||||
<string name="map_center_me">Я</string>
|
||||
<string name="map_center_tx">TX</string>
|
||||
<string name="map_center_rx">RX</string>
|
||||
<string name="map_center_both">Оба</string>
|
||||
<string name="map_center_mode">Центр карты</string>
|
||||
<string name="map_layer">Слой карты</string>
|
||||
<string name="map_layer_scheme">Схема</string>
|
||||
<string name="map_layer_satellite">Спутник</string>
|
||||
<string name="map_center_unavailable">Нет координат для выбранного режима</string>
|
||||
<string name="map_tool_center">Центрировать карту</string>
|
||||
<string name="map_tool_track">Трекинг пути</string>
|
||||
<string name="map_tool_paired">Синхр. трек TX/RX</string>
|
||||
<string name="map_tool_more">Дополнительно</string>
|
||||
<string name="map_gps_distance">GPS между устройствами: %1$s m</string>
|
||||
<string name="status_server">Связь с сервером</string>
|
||||
<string name="status_server_short">сервер</string>
|
||||
<string name="status_lora">Связь LoRa с другим устройством</string>
|
||||
<string name="status_lora_short">LoRa</string>
|
||||
<string name="map_find_hill">Ближайшая возвышенность</string>
|
||||
<string name="map_find_hill_search">Поиск возвышенности…</string>
|
||||
<string name="map_find_hill_result">Возвышенность: %1$.0f m · +%2$.0f m · %3$.0f m</string>
|
||||
<string name="map_find_hill_none">В радиусе 5 км возвышенностей не найдено</string>
|
||||
<string name="map_find_hill_no_gps">Нужен GPS для поиска</string>
|
||||
<string name="map_find_hill_error">Ошибка: %1$s</string>
|
||||
<string name="map_heatmap">Рельеф (хитмапа)</string>
|
||||
<string name="map_heatmap_radius">Радиус рельефа</string>
|
||||
<string name="map_heatmap_radius_m">%1$d m</string>
|
||||
<string name="map_heatmap_loading">Загрузка рельефа…</string>
|
||||
<string name="map_heatmap_result">Δ %1$.0f…%2$.0f m · шаг %3$.0f m · %4$d точек</string>
|
||||
<string name="map_heatmap_error">Рельеф: %1$s</string>
|
||||
<string name="map_heatmap_no_gps">Нужен GPS для рельефа</string>
|
||||
<string name="map_heatmap_legend_title">Относ. высота</string>
|
||||
<string name="map_heatmap_legend_high">возвышенность</string>
|
||||
<string name="map_heatmap_legend_level">уровень</string>
|
||||
<string name="map_heatmap_legend_low">низина</string>
|
||||
<string name="chat_self_label">Вы</string>
|
||||
<string name="notification_channel_name">Фоновая работа</string>
|
||||
<string name="notification_channel_desc">Telnet, GPS и запись трека при свёрнутом приложении</string>
|
||||
<string name="notification_title">LoraTester активен</string>
|
||||
<string name="notification_subtitle">Работа в фоне</string>
|
||||
<string name="notification_track_recording">трек: %1$d точек</string>
|
||||
<string name="notification_track_idle">трек: нет</string>
|
||||
<string name="telnet_disabled_short">telnet: выкл</string>
|
||||
<string name="background_location_rationale">Для GPS при выключенном экране разрешите геолокацию «Всегда»</string>
|
||||
<string name="background_location_required">Нужен доступ к геолокации для трека и карты</string>
|
||||
<string name="battery_optimization">Без ограничений батареи</string>
|
||||
<string name="battery_optimization_hint">Рекомендуется на MIUI / ColorOS / EMUI для стабильного telnet</string>
|
||||
<string name="battery_optimization_done">Ограничения батареи уже отключены</string>
|
||||
</resources>
|
||||
|
||||
@@ -54,11 +54,10 @@ public class LoraFrameExtractTest {
|
||||
public void parsesAllLabeledLinesFromSendScreen() {
|
||||
StatsExtractor extractor = StatsExtractor.withDefaults();
|
||||
StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND);
|
||||
assertTrue(stats.metaJson.contains("\"fields\""));
|
||||
assertTrue(stats.metaJson.contains("Frequency"));
|
||||
assertTrue(stats.metaJson.contains("Spreading Factor"));
|
||||
assertTrue(stats.metaJson.contains("Packet"));
|
||||
assertTrue(stats.metaJson.contains("Payload"));
|
||||
assertTrue(stats.metaJson.contains("\"code_rate\":\"4/5\""));
|
||||
assertTrue(stats.metaJson.contains("\"spreading_factor\":12"));
|
||||
assertTrue(stats.metaJson.contains("\"packet\":304"));
|
||||
assertTrue(stats.metaJson.contains("Test TX!"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -83,7 +82,85 @@ public class LoraFrameExtractTest {
|
||||
assertEquals(StatsExtractor.ROLE_RX, stats.role);
|
||||
assertEquals(-78.0, stats.rssi, 0.01);
|
||||
assertEquals(10.5, stats.snrDb, 0.01);
|
||||
assertTrue(stats.metaJson.contains("\"fields\""));
|
||||
assertTrue(stats.metaJson.contains("\"packet\":0"));
|
||||
assertTrue(stats.metaJson.contains("\"rx_pkt_per_s\":0.45"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parsesRxQualityPercent() {
|
||||
StatsExtractor extractor = StatsExtractor.withDefaults();
|
||||
String frame = RECEIVE_FRAME + " RX Quality: 87 %\n";
|
||||
StatsExtractor.ExtractedStats stats = extractor.extract(frame);
|
||||
|
||||
assertTrue(stats.metaJson.contains("\"rx_quality_percent\":87"));
|
||||
assertTrue(!stats.metaJson.contains("RX Quality"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parsesRxCountersAndCrcErrors() {
|
||||
StatsExtractor extractor = StatsExtractor.withDefaults();
|
||||
String frame = """
|
||||
RECEIVE
|
||||
Frequency: 433500000 Hz
|
||||
Power: 0 dBm
|
||||
Spreading Factor: 5
|
||||
Bandwidth: 7.81 kHz
|
||||
Code Rate: 4/6
|
||||
Preamble Length: 8
|
||||
Low Data Rate Opt: Off
|
||||
CRC: Off
|
||||
Payload length: 32 byte
|
||||
On Air: 427.14 ms, 2.34 pkt/c
|
||||
Packet Number: 0
|
||||
Payload: test
|
||||
RSSI: -78
|
||||
SNR: 10.5
|
||||
RX Speed: 0.45 pkt/s, 120 bit/s
|
||||
Packet Receive: 12
|
||||
Packet Total: 100
|
||||
Packet Error: 3
|
||||
CRC Error: 2
|
||||
PER: 3.00 %
|
||||
Preamble Detected: 2
|
||||
Header Valid: 1
|
||||
RX Quality: 87 %
|
||||
""";
|
||||
StatsExtractor.ExtractedStats stats = extractor.extract(frame);
|
||||
assertTrue(stats.metaJson.contains("\"crc_error\":2"));
|
||||
assertTrue(stats.metaJson.contains("\"packet_error\":3"));
|
||||
assertTrue(stats.metaJson.contains("\"packet_total\":100"));
|
||||
assertTrue(stats.metaJson.contains("\"packet_receive\":12"));
|
||||
assertTrue(stats.metaJson.contains("\"bandwidth_khz\":7.81"));
|
||||
assertTrue(stats.metaJson.contains("\"crc_enabled\":false"));
|
||||
assertTrue(stats.metaJson.contains("\"code_rate\":\"4/6\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parsesSendCrcAndConfig() {
|
||||
StatsExtractor extractor = StatsExtractor.withDefaults();
|
||||
String frame = """
|
||||
SEND
|
||||
Frequency: 433500000 Hz
|
||||
Power: 0 dBm
|
||||
Spreading Factor: 5
|
||||
Bandwidth: 125 kHz
|
||||
Code Rate: 4/5
|
||||
Preamble Length: 8
|
||||
Low Data Rate Opt: Off
|
||||
CRC: On
|
||||
Payload length: 32 byte
|
||||
On Air: 23.10 ms, 43.28 pkt/c
|
||||
Packet: 3816
|
||||
Payload: Test TX!
|
||||
TX Timeout: 0 ms
|
||||
TX Speed: 32.06 pkt/s, 8206 bit/s
|
||||
""";
|
||||
StatsExtractor.ExtractedStats stats = extractor.extract(frame);
|
||||
assertTrue(stats.metaJson.contains("\"packet\":3816"));
|
||||
assertTrue(stats.metaJson.contains("\"crc_enabled\":true"));
|
||||
assertTrue(stats.metaJson.contains("\"payload_length_bytes\":32"));
|
||||
assertTrue(stats.metaJson.contains("\"tx_timeout_ms\":0"));
|
||||
assertTrue(stats.metaJson.contains("\"preamble_length\":8"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class SettingsRepositoryTest {
|
||||
|
||||
@Test
|
||||
public void detectsLegacyServerUrls() {
|
||||
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru:7634"));
|
||||
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru:7634/"));
|
||||
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru"));
|
||||
assertTrue(SettingsRepository.isLegacyServerUrl("https://grigowashere.ru:7634"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ignoresCurrentServerUrl() {
|
||||
assertFalse(SettingsRepository.isLegacyServerUrl(SettingsRepository.DEFAULT_SERVER));
|
||||
assertFalse(SettingsRepository.isLegacyServerUrl("https://example.com"));
|
||||
assertFalse(SettingsRepository.isLegacyServerUrl(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.db
|
||||
.pytest_cache
|
||||
.git
|
||||
*.md
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
LORATESTER_HOST=0.0.0.0 \
|
||||
LORATESTER_PORT=7634 \
|
||||
LORATESTER_DB=/data/loratester.db
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 7634
|
||||
|
||||
CMD ["uvicorn", "fastapi_app:app", "--host", "0.0.0.0", "--port", "7634"]
|
||||
+63
-7
@@ -23,14 +23,47 @@ python flask_app.py
|
||||
| `LORATESTER_DB` | `./loratester.db` |
|
||||
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
|
||||
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) |
|
||||
| `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` |
|
||||
| `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
|
||||
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
|
||||
|
||||
## Деплой (grigowashere.ru:7634)
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
cd /srv/storage/disk2/services/LoraTester
|
||||
cd server
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:7634/api/health | jq
|
||||
```
|
||||
|
||||
Ожидается `"elevation_ok": true` если локальный Open-Meteo доступен с хоста/контейнера.
|
||||
|
||||
Переопределить URL высот (`.env` рядом с `docker-compose.yml`):
|
||||
|
||||
```env
|
||||
LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
|
||||
```
|
||||
|
||||
БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера).
|
||||
|
||||
## Деплой (lora.grigowashere.ru)
|
||||
|
||||
```bash
|
||||
cd /srv/storage/disk2/services/LoraTester/server
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Или без Docker:
|
||||
|
||||
```bash
|
||||
cd /srv/storage/disk2/services/LoraTester/server
|
||||
pip install -r requirements.txt
|
||||
# один путь БД для всех воркеров:
|
||||
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
|
||||
export LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
|
||||
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
|
||||
```
|
||||
|
||||
@@ -42,7 +75,7 @@ uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
|
||||
curl http://127.0.0.1:7634/api/health
|
||||
```
|
||||
|
||||
Ожидается `"db_ok": true`, `"schema_version": 3`.
|
||||
Ожидается `"db_ok": true`, `"schema_version": 4`, `"elevation_ok": true`.
|
||||
|
||||
Если БД создана вручную и схема битая (`no such table: devices` / `no such column: t.meta`):
|
||||
|
||||
@@ -66,13 +99,36 @@ curl http://127.0.0.1:7634/api/health
|
||||
- `POST /api/tracks/{id}/points` — `{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
|
||||
- `POST /api/tracks/{id}/finish`
|
||||
- `GET /api/tracks?device_id=`
|
||||
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через Open-Meteo)
|
||||
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через локальный Open-Meteo)
|
||||
|
||||
### Команды (очередь на устройство)
|
||||
|
||||
- `POST /api/commands` — `{from_device_id, to_device_id, kind, payload?}`
|
||||
`kind`: `at` (`payload.line` — одна строка, или `payload.lines` — массив макроса), `mode` (`payload.role`: TX/RX), `stats_push` (снимок meta/rssi/role/sf/bw)
|
||||
`from_device_id`: `web` или `android-xxxxxxxx`
|
||||
Макрос обычно: `S` (стоп TX/RX), затем `AT+FQ=`, `AT+PW=`, `AT+SF=`, `AT+BW=`, `AT+CR=`, `AT+PL=`, `AT+TM=`, при необходимости `AT+TX` / `AT+RX`.
|
||||
|
||||
### Профиль высот (веб, треки)
|
||||
|
||||
- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo)
|
||||
- `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку
|
||||
- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность (прокси Open-Meteo)
|
||||
- `GET /api/elevation/grid?lat=&lon=&radius_m=200&step_m=0` — сетка высот для хитмапы (100–500 m, step_m=0 авто)
|
||||
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
|
||||
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
||||
|
||||
### Синхронный трек (два устройства)
|
||||
|
||||
- `POST /api/paired-tracks/start` — `{device_ids?: [a,b], initiator?, device_id?}` → сессия `armed`, `start_at = now+3s`
|
||||
- `GET /api/paired-tracks/active` — `{active, session?}`
|
||||
- `POST /api/paired-tracks/ack` — Android: `{session_id, device_id, track_id}`
|
||||
- `POST /api/paired-tracks/cancel` — `{session_id?}`
|
||||
|
||||
### Прочее
|
||||
|
||||
- `POST /api/chat` — `{device_id, text}`
|
||||
- `GET /api/chat?since=0`
|
||||
- `GET /api/health` — `{ok, db_ok, schema_version, database_path}`
|
||||
- `GET /api/health` — `{ok, db_ok, schema_version, database_path, elevation_ok, elevation_url, elevation_error}`
|
||||
|
||||
## FastAPI (прод)
|
||||
|
||||
@@ -92,6 +148,6 @@ python -m pytest tests/ -v
|
||||
|
||||
## Android
|
||||
|
||||
URL: `http://grigowashere.ru:7634`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
|
||||
URL: `https://lora.grigowashere.ru`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (локальный Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
|
||||
|
||||
Telnet: `127.0.0.1:2727` — мост COM→telnet на устройстве.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,3 +9,13 @@ HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
|
||||
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
|
||||
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
|
||||
ELEVATION_API_URL = os.environ.get(
|
||||
"LORATESTER_ELEVATION_URL",
|
||||
"http://192.168.1.109:8085/v1/elevation",
|
||||
).rstrip("/")
|
||||
ELEVATION_PROBE_TTL_SEC = float(
|
||||
os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60")
|
||||
)
|
||||
ELEVATION_CONNECT_TIMEOUT = float(
|
||||
os.environ.get("LORATESTER_ELEVATION_TIMEOUT", "8")
|
||||
)
|
||||
|
||||
+574
-19
@@ -1,40 +1,595 @@
|
||||
"""Terrain elevation via Open-Meteo (cached per coordinate)."""
|
||||
"""Terrain elevation via self-hosted Open-Meteo-compatible API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
import math
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
ELEVATION_API_URL,
|
||||
ELEVATION_CONNECT_TIMEOUT,
|
||||
ELEVATION_PROBE_TTL_SEC,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BATCH_SIZE = 100
|
||||
_MAX_PROFILE_POINTS = 500
|
||||
_CACHE: dict[tuple[float, float], Optional[float]] = {}
|
||||
_TIMEOUT = 3.0
|
||||
_probe_checked_at = 0.0
|
||||
_probe_ok = False
|
||||
_probe_error: Optional[str] = None
|
||||
|
||||
|
||||
def _cache_key(lat: float, lon: float) -> tuple[float, float]:
|
||||
return (round(lat, 4), round(lon, 4))
|
||||
return (round(lat, 6), round(lon, 6))
|
||||
|
||||
|
||||
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
|
||||
key = _cache_key(lat, lon)
|
||||
if key in _CACHE:
|
||||
return _CACHE[key]
|
||||
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
r = 6_371_000.0
|
||||
d_lat = math.radians(lat2 - lat1)
|
||||
d_lon = math.radians(lon2 - lon1)
|
||||
a = (
|
||||
math.sin(d_lat / 2) ** 2
|
||||
+ math.cos(math.radians(lat1))
|
||||
* math.cos(math.radians(lat2))
|
||||
* math.sin(d_lon / 2) ** 2
|
||||
)
|
||||
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
||||
"""Ping elevation service before batch requests (cached for TTL)."""
|
||||
global _probe_checked_at, _probe_ok, _probe_error
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
not force
|
||||
and _probe_checked_at > 0
|
||||
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
||||
):
|
||||
return {
|
||||
"ok": _probe_ok,
|
||||
"url": ELEVATION_API_URL,
|
||||
"error": _probe_error,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT) as client:
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(
|
||||
"https://api.open-meteo.com/v1/elevation",
|
||||
params={"latitude": lat, "longitude": lon},
|
||||
ELEVATION_API_URL,
|
||||
params={"latitude": "0.000000", "longitude": "0.000000"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
elevations = data.get("elevation") or []
|
||||
if elevations:
|
||||
val = float(elevations[0])
|
||||
_CACHE[key] = val
|
||||
return val
|
||||
if "elevation" not in data:
|
||||
raise ValueError("response has no elevation field")
|
||||
_probe_checked_at = now
|
||||
_probe_ok = True
|
||||
_probe_error = None
|
||||
logger.info("elevation API ok: %s", ELEVATION_API_URL)
|
||||
except Exception as e:
|
||||
logger.warning("open-meteo elevation failed for %s,%s: %s", lat, lon, e)
|
||||
_CACHE[key] = None
|
||||
return None
|
||||
_probe_checked_at = now
|
||||
_probe_ok = False
|
||||
_probe_error = str(e)
|
||||
logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e)
|
||||
|
||||
return {
|
||||
"ok": _probe_ok,
|
||||
"url": ELEVATION_API_URL,
|
||||
"error": _probe_error,
|
||||
}
|
||||
|
||||
|
||||
def elevation_status(force: bool = False) -> dict[str, Any]:
|
||||
probe = probe_elevation_api(force=force)
|
||||
return {
|
||||
"elevation_ok": probe["ok"],
|
||||
"elevation_url": probe["url"],
|
||||
"elevation_error": probe["error"],
|
||||
}
|
||||
|
||||
|
||||
def _fetch_elevation_batch(
|
||||
batch_lat: list[float], batch_lon: list[float]
|
||||
) -> list[Optional[float]]:
|
||||
if not batch_lat:
|
||||
return []
|
||||
params = {
|
||||
"latitude": ",".join(f"{lat:.6f}" for lat in batch_lat),
|
||||
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
|
||||
}
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(ELEVATION_API_URL, params=params)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
elevations = data.get("elevation") or []
|
||||
out: list[Optional[float]] = []
|
||||
for j, elev in enumerate(elevations):
|
||||
if j >= len(batch_lat):
|
||||
break
|
||||
if elev is None:
|
||||
out.append(None)
|
||||
else:
|
||||
out.append(float(elev))
|
||||
while len(out) < len(batch_lat):
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
|
||||
vals = fetch_elevations_batch([lat], [lon])
|
||||
return vals[0] if vals else None
|
||||
|
||||
|
||||
def fetch_elevations_batch(
|
||||
lats: list[float], lons: list[float]
|
||||
) -> list[Optional[float]]:
|
||||
if not lats or len(lats) != len(lons):
|
||||
return []
|
||||
|
||||
probe = probe_elevation_api()
|
||||
if not probe["ok"]:
|
||||
logger.warning(
|
||||
"skip elevation fetch: API unreachable (%s)",
|
||||
probe.get("error"),
|
||||
)
|
||||
return [None] * len(lats)
|
||||
|
||||
out: list[Optional[float]] = [None] * len(lats)
|
||||
pending_idx: list[int] = []
|
||||
pending_lat: list[float] = []
|
||||
pending_lon: list[float] = []
|
||||
|
||||
for i, (lat, lon) in enumerate(zip(lats, lons)):
|
||||
key = _cache_key(lat, lon)
|
||||
if key in _CACHE:
|
||||
out[i] = _CACHE[key]
|
||||
else:
|
||||
pending_idx.append(i)
|
||||
pending_lat.append(float(lat))
|
||||
pending_lon.append(float(lon))
|
||||
|
||||
for start in range(0, len(pending_lat), _BATCH_SIZE):
|
||||
batch_i = pending_idx[start : start + _BATCH_SIZE]
|
||||
batch_lat = pending_lat[start : start + _BATCH_SIZE]
|
||||
batch_lon = pending_lon[start : start + _BATCH_SIZE]
|
||||
try:
|
||||
batch_vals = _fetch_elevation_batch(batch_lat, batch_lon)
|
||||
for j, val in enumerate(batch_vals):
|
||||
lat = batch_lat[j]
|
||||
lon = batch_lon[j]
|
||||
_CACHE[_cache_key(lat, lon)] = val
|
||||
out[batch_i[j]] = val
|
||||
logger.info(
|
||||
"elevation ok: %s points, sample=%s",
|
||||
len(batch_lat),
|
||||
batch_vals[0] if batch_vals else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"elevation batch failed (%s points): %s",
|
||||
len(batch_lat),
|
||||
e,
|
||||
)
|
||||
for j in range(len(batch_lat)):
|
||||
try:
|
||||
single = _fetch_elevation_batch(
|
||||
[batch_lat[j]], [batch_lon[j]]
|
||||
)
|
||||
val = single[0] if single else None
|
||||
except Exception as e2:
|
||||
logger.warning(
|
||||
"elevation single failed %.6f,%.6f: %s",
|
||||
batch_lat[j],
|
||||
batch_lon[j],
|
||||
e2,
|
||||
)
|
||||
val = None
|
||||
_CACHE[_cache_key(batch_lat[j], batch_lon[j])] = val
|
||||
out[batch_i[j]] = val
|
||||
return out
|
||||
|
||||
|
||||
def _interp_at_dist(
|
||||
cleaned: list[tuple[float, float]], cum: list[float], dist_m: float
|
||||
) -> tuple[float, float]:
|
||||
if dist_m <= 0:
|
||||
return cleaned[0]
|
||||
if dist_m >= cum[-1]:
|
||||
return cleaned[-1]
|
||||
for i in range(1, len(cum)):
|
||||
if dist_m <= cum[i]:
|
||||
seg = cum[i] - cum[i - 1]
|
||||
t = 0.0 if seg <= 0 else (dist_m - cum[i - 1]) / seg
|
||||
lat1, lon1 = cleaned[i - 1]
|
||||
lat2, lon2 = cleaned[i]
|
||||
return lat1 + (lat2 - lat1) * t, lon1 + (lon2 - lon1) * t
|
||||
return cleaned[-1]
|
||||
|
||||
|
||||
def resample_track_path(
|
||||
points: list[dict[str, Any]], step_m: float = 10.0
|
||||
) -> list[dict[str, float]]:
|
||||
"""Sample (lat, lon, dist_m) along polyline every ~step_m meters."""
|
||||
if not points or step_m <= 0:
|
||||
return []
|
||||
cleaned: list[tuple[float, float]] = []
|
||||
for p in points:
|
||||
lat = p.get("lat")
|
||||
lon = p.get("lon")
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
lat_f, lon_f = float(lat), float(lon)
|
||||
if not cleaned or haversine_m(cleaned[-1][0], cleaned[-1][1], lat_f, lon_f) > 0.5:
|
||||
cleaned.append((lat_f, lon_f))
|
||||
if not cleaned:
|
||||
return []
|
||||
if len(cleaned) == 1:
|
||||
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
|
||||
|
||||
cum = [0.0]
|
||||
for i in range(1, len(cleaned)):
|
||||
cum.append(
|
||||
cum[-1]
|
||||
+ haversine_m(
|
||||
cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]
|
||||
)
|
||||
)
|
||||
total = cum[-1]
|
||||
samples: list[dict[str, float]] = []
|
||||
dist = 0.0
|
||||
while dist <= total + 1e-6:
|
||||
lat, lon = _interp_at_dist(cleaned, cum, dist)
|
||||
samples.append({"lat": lat, "lon": lon, "dist_m": round(dist, 1)})
|
||||
if dist >= total:
|
||||
break
|
||||
dist += step_m
|
||||
return samples
|
||||
|
||||
|
||||
def resample_track_path_count(
|
||||
points: list[dict[str, Any]], count: int
|
||||
) -> list[dict[str, float]]:
|
||||
"""Sample exactly `count` points evenly spaced along polyline."""
|
||||
if not points or count < 2:
|
||||
return []
|
||||
cleaned: list[tuple[float, float]] = []
|
||||
for p in points:
|
||||
lat = p.get("lat")
|
||||
lon = p.get("lon")
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
lat_f, lon_f = float(lat), float(lon)
|
||||
if not cleaned or haversine_m(cleaned[-1][0], cleaned[-1][1], lat_f, lon_f) > 0.5:
|
||||
cleaned.append((lat_f, lon_f))
|
||||
if not cleaned:
|
||||
return []
|
||||
if len(cleaned) == 1:
|
||||
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
|
||||
|
||||
cum = [0.0]
|
||||
for i in range(1, len(cleaned)):
|
||||
cum.append(
|
||||
cum[-1]
|
||||
+ haversine_m(
|
||||
cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]
|
||||
)
|
||||
)
|
||||
total = cum[-1]
|
||||
if total < 1e-6:
|
||||
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
|
||||
|
||||
n = max(2, min(_MAX_PROFILE_POINTS, int(count)))
|
||||
samples: list[dict[str, float]] = []
|
||||
for i in range(n):
|
||||
dist = (total * i) / (n - 1)
|
||||
lat, lon = _interp_at_dist(cleaned, cum, dist)
|
||||
samples.append({"lat": lat, "lon": lon, "dist_m": round(dist, 1)})
|
||||
return samples
|
||||
|
||||
|
||||
def build_elevation_profile(
|
||||
points: list[dict[str, Any]],
|
||||
step_m: float = 10.0,
|
||||
target_points: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resample track and fetch terrain elevations."""
|
||||
if target_points is not None:
|
||||
n = max(2, min(_MAX_PROFILE_POINTS, int(target_points)))
|
||||
samples = resample_track_path_count(points, n)
|
||||
if len(samples) > 1:
|
||||
step_m = round(
|
||||
(samples[-1]["dist_m"] - samples[0]["dist_m"]) / (len(samples) - 1),
|
||||
2,
|
||||
)
|
||||
else:
|
||||
step_m = 0.0
|
||||
else:
|
||||
step_m = max(5.0, min(10.0, float(step_m)))
|
||||
samples = resample_track_path(points, step_m)
|
||||
if not samples:
|
||||
return {
|
||||
"step_m": step_m,
|
||||
"points": [],
|
||||
"total_m": 0.0,
|
||||
"api_source": "elevation",
|
||||
"api_error": "no samples",
|
||||
}
|
||||
|
||||
probe = probe_elevation_api()
|
||||
if not probe["ok"]:
|
||||
return {
|
||||
"step_m": step_m,
|
||||
"points": [],
|
||||
"total_m": 0.0,
|
||||
"api_source": "elevation",
|
||||
"api_error": f"elevation API unreachable: {probe['error']}",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
}
|
||||
|
||||
lats = [s["lat"] for s in samples]
|
||||
lons = [s["lon"] for s in samples]
|
||||
elevations = fetch_elevations_batch(lats, lons)
|
||||
|
||||
profile: list[dict[str, Any]] = []
|
||||
elev_vals: list[float] = []
|
||||
for s, elev in zip(samples, elevations):
|
||||
item = {
|
||||
"dist_m": round(s["dist_m"], 1),
|
||||
"lat": round(s["lat"], 6),
|
||||
"lon": round(s["lon"], 6),
|
||||
"elevation_m": elev,
|
||||
}
|
||||
profile.append(item)
|
||||
if elev is not None:
|
||||
elev_vals.append(elev)
|
||||
|
||||
total_m = profile[-1]["dist_m"] if profile else 0.0
|
||||
result: dict[str, Any] = {
|
||||
"step_m": step_m,
|
||||
"total_m": total_m,
|
||||
"min_elevation_m": min(elev_vals) if elev_vals else None,
|
||||
"max_elevation_m": max(elev_vals) if elev_vals else None,
|
||||
"points": profile,
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
}
|
||||
if not elev_vals:
|
||||
result["api_error"] = "elevation API returned no values"
|
||||
return result
|
||||
|
||||
|
||||
def _offset_m(lat: float, lon: float, north_m: float, east_m: float) -> tuple[float, float]:
|
||||
dlat = north_m / 111_320.0
|
||||
dlon = east_m / (111_320.0 * max(math.cos(math.radians(lat)), 1e-6))
|
||||
return lat + dlat, lon + dlon
|
||||
|
||||
|
||||
_MAX_GRID_POINTS = 2500
|
||||
_MAX_GRID_POINTS_FINE = 12000
|
||||
|
||||
|
||||
def _auto_step_m(radius_m: float) -> float:
|
||||
if radius_m <= 150:
|
||||
return 10.0
|
||||
if radius_m <= 300:
|
||||
return 15.0
|
||||
return 20.0
|
||||
|
||||
|
||||
def _sample_circular_grid(
|
||||
lat: float,
|
||||
lon: float,
|
||||
radius_m: float,
|
||||
step_m: float,
|
||||
) -> list[tuple[int, int, float, float, float]]:
|
||||
steps = int(radius_m / step_m)
|
||||
cells: list[tuple[int, int, float, float, float]] = []
|
||||
for i in range(-steps, steps + 1):
|
||||
for j in range(-steps, steps + 1):
|
||||
north = i * step_m
|
||||
east = j * step_m
|
||||
dist = math.hypot(north, east)
|
||||
if dist > radius_m:
|
||||
continue
|
||||
la, lo = _offset_m(lat, lon, north, east)
|
||||
cells.append((i, j, la, lo, dist))
|
||||
return cells
|
||||
|
||||
|
||||
def _resolve_grid_step(
|
||||
lat: float, lon: float, radius_m: float, step_m: float
|
||||
) -> float:
|
||||
if step_m <= 0:
|
||||
step_m = _auto_step_m(radius_m)
|
||||
min_step = 1.0 if radius_m <= 100.0 else 5.0
|
||||
step_m = max(min_step, min(float(step_m), 100.0))
|
||||
max_points = _MAX_GRID_POINTS_FINE if radius_m <= 100.0 and step_m <= 1.0 else _MAX_GRID_POINTS
|
||||
while len(_sample_circular_grid(lat, lon, radius_m, step_m)) > max_points:
|
||||
step_m = math.ceil(step_m * 1.25)
|
||||
if step_m >= radius_m:
|
||||
break
|
||||
return step_m
|
||||
|
||||
|
||||
def build_elevation_grid(
|
||||
lat: float,
|
||||
lon: float,
|
||||
radius_m: float = 200.0,
|
||||
step_m: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Circular elevation grid for heatmap (delta relative to center)."""
|
||||
probe = probe_elevation_api()
|
||||
if not probe["ok"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"elevation API unreachable: {probe['error']}",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
}
|
||||
|
||||
radius_m = max(50.0, min(float(radius_m), 500.0))
|
||||
step_m = _resolve_grid_step(lat, lon, radius_m, step_m)
|
||||
|
||||
center_elev = fetch_elevation_m(lat, lon)
|
||||
if center_elev is None:
|
||||
return {"ok": False, "error": "no elevation at center"}
|
||||
|
||||
grid_cells = _sample_circular_grid(lat, lon, radius_m, step_m)
|
||||
if not grid_cells:
|
||||
return {"ok": False, "error": "empty search grid"}
|
||||
|
||||
lats = [c[2] for c in grid_cells]
|
||||
lons = [c[3] for c in grid_cells]
|
||||
elevations = fetch_elevations_batch(lats, lons)
|
||||
|
||||
points: list[dict[str, Any]] = []
|
||||
deltas: list[float] = []
|
||||
for (i, j, la, lo, dist), elev in zip(grid_cells, elevations):
|
||||
if elev is None:
|
||||
continue
|
||||
delta = float(elev) - center_elev
|
||||
deltas.append(delta)
|
||||
points.append(
|
||||
{
|
||||
"i": i,
|
||||
"j": j,
|
||||
"lat": round(la, 6),
|
||||
"lon": round(lo, 6),
|
||||
"dist_m": round(dist, 1),
|
||||
"elevation_m": float(elev),
|
||||
"delta_m": round(delta, 1),
|
||||
}
|
||||
)
|
||||
|
||||
if not points:
|
||||
return {"ok": False, "error": "no elevation values in grid"}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"center": {
|
||||
"lat": round(lat, 6),
|
||||
"lon": round(lon, 6),
|
||||
"elevation_m": center_elev,
|
||||
},
|
||||
"radius_m": radius_m,
|
||||
"step_m": step_m,
|
||||
"points": points,
|
||||
"min_delta_m": round(min(deltas), 1),
|
||||
"max_delta_m": round(max(deltas), 1),
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
}
|
||||
|
||||
|
||||
def find_nearest_hill(
|
||||
lat: float,
|
||||
lon: float,
|
||||
radius_m: float = 5000.0,
|
||||
step_m: float = 300.0,
|
||||
min_prominence_m: float = 8.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Find nearest local elevation maximum around a point."""
|
||||
probe = probe_elevation_api()
|
||||
if not probe["ok"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"elevation API unreachable: {probe['error']}",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
}
|
||||
|
||||
radius_m = max(500.0, min(float(radius_m), 15_000.0))
|
||||
step_m = max(100.0, min(float(step_m), 500.0))
|
||||
min_prominence_m = max(3.0, min(float(min_prominence_m), 100.0))
|
||||
|
||||
center_elev = fetch_elevation_m(lat, lon)
|
||||
if center_elev is None:
|
||||
return {"ok": False, "error": "no elevation at center"}
|
||||
|
||||
grid_cells = _sample_circular_grid(lat, lon, radius_m, step_m)
|
||||
if not grid_cells:
|
||||
return {"ok": False, "error": "empty search grid"}
|
||||
|
||||
lats = [c[2] for c in grid_cells]
|
||||
lons = [c[3] for c in grid_cells]
|
||||
elevations = fetch_elevations_batch(lats, lons)
|
||||
|
||||
grid: dict[tuple[int, int], dict[str, Any]] = {}
|
||||
for (i, j, la, lo, dist), elev in zip(grid_cells, elevations):
|
||||
grid[(i, j)] = {
|
||||
"lat": round(la, 6),
|
||||
"lon": round(lo, 6),
|
||||
"dist_m": round(dist, 1),
|
||||
"elevation_m": elev,
|
||||
}
|
||||
|
||||
def is_local_max(i: int, j: int, elev: float) -> bool:
|
||||
for di in (-1, 0, 1):
|
||||
for dj in (-1, 0, 1):
|
||||
if di == 0 and dj == 0:
|
||||
continue
|
||||
n = grid.get((i + di, j + dj))
|
||||
if n and n["elevation_m"] is not None and n["elevation_m"] >= elev:
|
||||
return False
|
||||
return True
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for (i, j), cell in grid.items():
|
||||
elev = cell.get("elevation_m")
|
||||
if elev is None:
|
||||
continue
|
||||
prominence = float(elev) - center_elev
|
||||
if prominence < min_prominence_m:
|
||||
continue
|
||||
if is_local_max(i, j, float(elev)):
|
||||
candidates.append({**cell, "prominence_m": round(prominence, 1)})
|
||||
|
||||
if not candidates:
|
||||
best = None
|
||||
for cell in grid.values():
|
||||
elev = cell.get("elevation_m")
|
||||
if elev is None:
|
||||
continue
|
||||
prominence = float(elev) - center_elev
|
||||
if prominence < min_prominence_m * 0.5:
|
||||
continue
|
||||
if best is None or cell["dist_m"] < best["dist_m"]:
|
||||
best = {
|
||||
**cell,
|
||||
"prominence_m": round(prominence, 1),
|
||||
"is_local_max": False,
|
||||
}
|
||||
if best is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "no hill found in radius",
|
||||
"center": {
|
||||
"lat": round(lat, 6),
|
||||
"lon": round(lon, 6),
|
||||
"elevation_m": center_elev,
|
||||
},
|
||||
"radius_m": radius_m,
|
||||
}
|
||||
hill = best
|
||||
else:
|
||||
candidates.sort(key=lambda c: c["dist_m"])
|
||||
hill = {**candidates[0], "is_local_max": True}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"center": {
|
||||
"lat": round(lat, 6),
|
||||
"lon": round(lon, 6),
|
||||
"elevation_m": center_elev,
|
||||
},
|
||||
"hill": hill,
|
||||
"candidates": len(candidates),
|
||||
"radius_m": radius_m,
|
||||
"step_m": step_m,
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class TelemetryIn:
|
||||
role: Optional[str] = None
|
||||
ts: Optional[float] = None
|
||||
source: str = "android"
|
||||
device_label: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+41
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
SCHEMA_VERSION = 3
|
||||
SCHEMA_VERSION = 4
|
||||
|
||||
|
||||
def table_exists(conn: sqlite3.Connection, name: str) -> bool:
|
||||
@@ -121,6 +121,44 @@ def apply_migrations(conn: sqlite3.Connection) -> list[str]:
|
||||
)
|
||||
log.append("CREATE track_points")
|
||||
|
||||
if not table_exists(conn, "device_commands"):
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE device_commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_device_id TEXT NOT NULL,
|
||||
to_device_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
payload TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
delivered_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_commands_to_pending
|
||||
ON device_commands(to_device_id, delivered_at, created_at);
|
||||
"""
|
||||
)
|
||||
log.append("CREATE device_commands")
|
||||
|
||||
if not table_exists(conn, "paired_track_sessions"):
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE paired_track_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_a TEXT NOT NULL,
|
||||
device_b TEXT NOT NULL,
|
||||
initiator TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
start_at REAL NOT NULL,
|
||||
track_id_a INTEGER,
|
||||
track_id_b INTEGER,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_status
|
||||
ON paired_track_sessions(status, created_at DESC);
|
||||
"""
|
||||
)
|
||||
log.append("CREATE paired_track_sessions")
|
||||
|
||||
set_schema_version(conn, SCHEMA_VERSION)
|
||||
log.append(f"schema_version={SCHEMA_VERSION}")
|
||||
return log
|
||||
@@ -132,6 +170,8 @@ def check_db_ok(conn: sqlite3.Connection) -> bool:
|
||||
("telemetry", "meta"),
|
||||
("tracks", None),
|
||||
("track_points", "elevation_m"),
|
||||
("device_commands", None),
|
||||
("paired_track_sessions", None),
|
||||
]
|
||||
for table, col in required:
|
||||
if not table_exists(conn, table):
|
||||
|
||||
+361
-13
@@ -11,6 +11,13 @@ from .elevation import fetch_elevation_m
|
||||
from .models import ChatIn, TelemetryIn
|
||||
from .schema import SCHEMA_VERSION, apply_migrations, check_db_ok, get_schema_version
|
||||
|
||||
WEB_SENDER_ID = "web"
|
||||
COMMAND_KINDS = frozenset({"at", "mode", "stats_push"})
|
||||
PAIRED_ONLINE_SEC = 30.0
|
||||
PAIRED_START_DELAY_SEC = 3.0
|
||||
# Hide devices on map/UI after this many seconds without telemetry.
|
||||
DEVICE_VISIBLE_SEC = 180.0
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HISTORY_COLUMNS = (
|
||||
@@ -83,6 +90,19 @@ def record_telemetry(data: TelemetryIn) -> dict[str, Any]:
|
||||
ts = data.ts if data.ts is not None else time.time()
|
||||
lat, lon = _sanitize_coords(data.lat, data.lon)
|
||||
with _db() as conn:
|
||||
phone_label = (data.device_label or "").strip()
|
||||
if phone_label:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO devices (device_id, label, last_seen)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
label = excluded.label
|
||||
""",
|
||||
(data.device_id, phone_label, ts),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO devices (device_id, label, last_seen)
|
||||
@@ -129,11 +149,30 @@ def _trim_telemetry(conn: sqlite3.Connection, device_id: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def update_device_label(device_id: str, label: str) -> dict[str, Any]:
|
||||
if not is_valid_device_id(device_id):
|
||||
raise ValueError(f"invalid device_id '{device_id}'")
|
||||
clean = (label or "").strip()
|
||||
if not clean:
|
||||
raise ValueError("label required")
|
||||
ts = time.time()
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO devices (device_id, label, last_seen)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET label = excluded.label
|
||||
""",
|
||||
(device_id, clean, ts),
|
||||
)
|
||||
return {"ok": True, "device_id": device_id, "label": clean}
|
||||
|
||||
|
||||
def list_devices() -> list[dict[str, Any]]:
|
||||
with _db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT d.device_id, d.last_seen,
|
||||
SELECT d.device_id, d.label, d.last_seen,
|
||||
t.lat, t.lon, t.rssi, t.range_m, t.raw_frame, t.meta, t.role, t.ts, t.source
|
||||
FROM devices d
|
||||
INNER JOIN telemetry t ON t.id = (
|
||||
@@ -145,8 +184,13 @@ def list_devices() -> list[dict[str, Any]]:
|
||||
ORDER BY d.last_seen DESC
|
||||
"""
|
||||
).fetchall()
|
||||
cutoff = time.time() - DEVICE_VISIBLE_SEC
|
||||
devices = [_row_to_device(r) for r in rows]
|
||||
return [d for d in devices if not _is_null_island(d)]
|
||||
return [
|
||||
d
|
||||
for d in devices
|
||||
if not _is_null_island(d) and d.get("last_seen", 0) >= cutoff
|
||||
]
|
||||
|
||||
|
||||
def _is_null_island(device: dict[str, Any]) -> bool:
|
||||
@@ -159,6 +203,7 @@ def _is_null_island(device: dict[str, Any]) -> bool:
|
||||
def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"device_id": row["device_id"],
|
||||
"label": row["label"] if "label" in row.keys() else None,
|
||||
"last_seen": row["last_seen"],
|
||||
"lat": row["lat"],
|
||||
"lon": row["lon"],
|
||||
@@ -316,13 +361,18 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
|
||||
WHERE p.track_id = t.id AND p.role IS NOT NULL AND p.role != ''
|
||||
ORDER BY p.ts DESC LIMIT 1)
|
||||
"""
|
||||
if device_id:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
track_cols = f"""
|
||||
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
|
||||
d.label AS device_label,
|
||||
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
|
||||
{role_sub} AS role
|
||||
FROM tracks t
|
||||
LEFT JOIN devices d ON d.device_id = t.device_id
|
||||
"""
|
||||
if device_id:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
{track_cols}
|
||||
WHERE t.device_id = ?
|
||||
ORDER BY t.started_at DESC
|
||||
LIMIT ?
|
||||
@@ -332,10 +382,7 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
|
||||
else:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
|
||||
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
|
||||
{role_sub} AS role
|
||||
FROM tracks t
|
||||
{track_cols}
|
||||
ORDER BY t.started_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
@@ -348,7 +395,11 @@ def get_track(track_id: int) -> dict[str, Any]:
|
||||
with _db() as conn:
|
||||
track = conn.execute(
|
||||
"""
|
||||
SELECT id, device_id, started_at, ended_at, label FROM tracks WHERE id = ?
|
||||
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
|
||||
d.label AS device_label
|
||||
FROM tracks t
|
||||
LEFT JOIN devices d ON d.device_id = t.device_id
|
||||
WHERE t.id = ?
|
||||
""",
|
||||
(track_id,),
|
||||
).fetchone()
|
||||
@@ -389,10 +440,307 @@ def get_chat(since: float = 0.0, limit: int = 200) -> list[dict[str, Any]]:
|
||||
with _db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, device_id, text, ts FROM chat
|
||||
WHERE ts > ?
|
||||
ORDER BY ts ASC LIMIT ?
|
||||
SELECT c.id, c.device_id, c.text, c.ts, d.label AS device_label
|
||||
FROM chat c
|
||||
LEFT JOIN devices d ON d.device_id = c.device_id
|
||||
WHERE c.ts > ?
|
||||
ORDER BY c.ts ASC LIMIT ?
|
||||
""",
|
||||
(since, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _is_valid_sender(device_id: str) -> bool:
|
||||
d = (device_id or "").strip()
|
||||
return d == WEB_SENDER_ID or is_valid_device_id(d)
|
||||
|
||||
|
||||
def _row_to_command(row: sqlite3.Row) -> dict[str, Any]:
|
||||
payload = row["payload"]
|
||||
if payload:
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {
|
||||
"id": row["id"],
|
||||
"from_device_id": row["from_device_id"],
|
||||
"to_device_id": row["to_device_id"],
|
||||
"kind": row["kind"],
|
||||
"payload": payload,
|
||||
"created_at": row["created_at"],
|
||||
"delivered_at": row["delivered_at"],
|
||||
}
|
||||
|
||||
|
||||
def enqueue_command(
|
||||
from_device_id: str,
|
||||
to_device_id: str,
|
||||
kind: str,
|
||||
payload: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
from_id = (from_device_id or "").strip()
|
||||
to_id = (to_device_id or "").strip()
|
||||
kind = (kind or "").strip().lower()
|
||||
if not _is_valid_sender(from_id):
|
||||
raise ValueError(f"invalid from_device_id '{from_id}'")
|
||||
if not is_valid_device_id(to_id):
|
||||
raise ValueError(f"invalid to_device_id '{to_id}'")
|
||||
if kind not in COMMAND_KINDS:
|
||||
raise ValueError(f"invalid kind '{kind}', expected at|mode|stats_push")
|
||||
if from_id == to_id:
|
||||
raise ValueError("from and to device must differ")
|
||||
ts = time.time()
|
||||
payload_json = json.dumps(payload or {}, ensure_ascii=False)
|
||||
with _db() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO device_commands
|
||||
(from_device_id, to_device_id, kind, payload, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(from_id, to_id, kind, payload_json, ts),
|
||||
)
|
||||
cmd_id = cur.lastrowid
|
||||
return {
|
||||
"ok": True,
|
||||
"id": cmd_id,
|
||||
"from_device_id": from_id,
|
||||
"to_device_id": to_id,
|
||||
"kind": kind,
|
||||
"created_at": ts,
|
||||
}
|
||||
|
||||
|
||||
def poll_pending_commands(device_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
if not is_valid_device_id(device_id):
|
||||
raise ValueError(f"invalid device_id '{device_id}'")
|
||||
limit = min(max(1, limit), 50)
|
||||
now = time.time()
|
||||
with _db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at
|
||||
FROM device_commands
|
||||
WHERE to_device_id = ? AND delivered_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(device_id, limit),
|
||||
).fetchall()
|
||||
ids = [r["id"] for r in rows]
|
||||
if ids:
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
conn.execute(
|
||||
f"UPDATE device_commands SET delivered_at = ? WHERE id IN ({placeholders})",
|
||||
[now, *ids],
|
||||
)
|
||||
return [_row_to_command(r) for r in rows]
|
||||
|
||||
|
||||
def list_commands(
|
||||
to_device_id: Optional[str] = None, limit: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
limit = min(max(1, limit), 200)
|
||||
with _db() as conn:
|
||||
if to_device_id:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at
|
||||
FROM device_commands
|
||||
WHERE to_device_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(to_device_id, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at
|
||||
FROM device_commands
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [_row_to_command(r) for r in rows]
|
||||
|
||||
|
||||
def _online_android_devices(within_sec: float = PAIRED_ONLINE_SEC) -> list[str]:
|
||||
cutoff = time.time() - within_sec
|
||||
devices = list_devices()
|
||||
return [
|
||||
d["device_id"]
|
||||
for d in devices
|
||||
if d.get("last_seen", 0) >= cutoff
|
||||
]
|
||||
|
||||
|
||||
def _row_to_paired_session(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"device_a": row["device_a"],
|
||||
"device_b": row["device_b"],
|
||||
"initiator": row["initiator"],
|
||||
"status": row["status"],
|
||||
"start_at": row["start_at"],
|
||||
"track_id_a": row["track_id_a"],
|
||||
"track_id_b": row["track_id_b"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def _cancel_active_paired_sessions(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE paired_track_sessions
|
||||
SET status = 'cancelled'
|
||||
WHERE status IN ('armed', 'recording')
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def start_paired_track(
|
||||
device_ids: Optional[list[str]] = None,
|
||||
initiator: str = WEB_SENDER_ID,
|
||||
) -> dict[str, Any]:
|
||||
initiator = (initiator or WEB_SENDER_ID).strip()
|
||||
if not _is_valid_sender(initiator):
|
||||
raise ValueError(f"invalid initiator '{initiator}'")
|
||||
|
||||
if device_ids and len(device_ids) == 2:
|
||||
a, b = [str(x).strip() for x in device_ids]
|
||||
if not is_valid_device_id(a) or not is_valid_device_id(b):
|
||||
raise ValueError("device_ids must be two valid android-* ids")
|
||||
if a == b:
|
||||
raise ValueError("device_ids must differ")
|
||||
else:
|
||||
online = _online_android_devices()
|
||||
if len(online) != 2:
|
||||
raise ValueError(
|
||||
f"expected exactly 2 online devices, found {len(online)}"
|
||||
)
|
||||
a, b = sorted(online)
|
||||
|
||||
now = time.time()
|
||||
start_at = now + PAIRED_START_DELAY_SEC
|
||||
with _db() as conn:
|
||||
_cancel_active_paired_sessions(conn)
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO paired_track_sessions
|
||||
(device_a, device_b, initiator, status, start_at, created_at)
|
||||
VALUES (?, ?, ?, 'armed', ?, ?)
|
||||
""",
|
||||
(a, b, initiator, start_at, now),
|
||||
)
|
||||
session_id = cur.lastrowid
|
||||
return {
|
||||
"ok": True,
|
||||
"session": get_paired_track_session(session_id),
|
||||
}
|
||||
|
||||
|
||||
def get_active_paired_track() -> Optional[dict[str, Any]]:
|
||||
with _db() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, device_a, device_b, initiator, status, start_at,
|
||||
track_id_a, track_id_b, created_at
|
||||
FROM paired_track_sessions
|
||||
WHERE status IN ('armed', 'recording')
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
session = _row_to_paired_session(row)
|
||||
now = time.time()
|
||||
session["server_time"] = now
|
||||
session["ready"] = session["status"] == "armed" and now >= session["start_at"]
|
||||
return session
|
||||
|
||||
|
||||
def get_paired_track_session(session_id: int) -> dict[str, Any]:
|
||||
with _db() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, device_a, device_b, initiator, status, start_at,
|
||||
track_id_a, track_id_b, created_at
|
||||
FROM paired_track_sessions WHERE id = ?
|
||||
""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"session {session_id} not found")
|
||||
session = _row_to_paired_session(row)
|
||||
now = time.time()
|
||||
session["server_time"] = now
|
||||
session["ready"] = session["status"] == "armed" and now >= session["start_at"]
|
||||
return session
|
||||
|
||||
|
||||
def ack_paired_track(
|
||||
session_id: int, device_id: str, track_id: int
|
||||
) -> dict[str, Any]:
|
||||
if not is_valid_device_id(device_id):
|
||||
raise ValueError(f"invalid device_id '{device_id}'")
|
||||
with _db() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, device_a, device_b, status, track_id_a, track_id_b
|
||||
FROM paired_track_sessions WHERE id = ?
|
||||
""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"session {session_id} not found")
|
||||
if row["status"] not in ("armed", "recording"):
|
||||
raise ValueError(f"session {session_id} not active")
|
||||
|
||||
col = None
|
||||
if device_id == row["device_a"]:
|
||||
col = "track_id_a"
|
||||
elif device_id == row["device_b"]:
|
||||
col = "track_id_b"
|
||||
else:
|
||||
raise ValueError(f"device {device_id} not in session")
|
||||
|
||||
conn.execute(
|
||||
f"UPDATE paired_track_sessions SET {col} = ? WHERE id = ?",
|
||||
(track_id, session_id),
|
||||
)
|
||||
updated = conn.execute(
|
||||
"""
|
||||
SELECT track_id_a, track_id_b, status FROM paired_track_sessions
|
||||
WHERE id = ?
|
||||
""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if updated["track_id_a"] and updated["track_id_b"]:
|
||||
conn.execute(
|
||||
"UPDATE paired_track_sessions SET status = 'recording' WHERE id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
return {"ok": True, "session": get_paired_track_session(session_id)}
|
||||
|
||||
|
||||
def cancel_paired_track(session_id: Optional[int] = None) -> dict[str, Any]:
|
||||
with _db() as conn:
|
||||
if session_id is not None:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE paired_track_sessions
|
||||
SET status = 'cancelled'
|
||||
WHERE id = ? AND status IN ('armed', 'recording')
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise ValueError(f"session {session_id} not found or not active")
|
||||
else:
|
||||
_cancel_active_paired_sessions(conn)
|
||||
return {"ok": True, "active": get_active_paired_track()}
|
||||
|
||||
@@ -48,6 +48,10 @@ def merge_meta(body: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
|
||||
|
||||
def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
|
||||
meta, role = merge_meta(body)
|
||||
label = body.get("device_label") or body.get("label")
|
||||
device_label = str(label).strip() if label else None
|
||||
if device_label == "":
|
||||
device_label = None
|
||||
return TelemetryIn(
|
||||
device_id=str(body["device_id"]),
|
||||
lat=_float_or_none(body.get("lat")),
|
||||
@@ -58,4 +62,5 @@ def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
|
||||
meta=meta,
|
||||
role=role,
|
||||
ts=_float_or_none(body.get("ts")),
|
||||
device_label=device_label,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
loratester:
|
||||
build: .
|
||||
container_name: loratester
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${LORATESTER_PORT:-7634}:7634"
|
||||
volumes:
|
||||
- loratester-data:/data
|
||||
environment:
|
||||
LORATESTER_DB: /data/loratester.db
|
||||
LORATESTER_PORT: "7634"
|
||||
LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation}
|
||||
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
|
||||
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
|
||||
LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000}
|
||||
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-10000}
|
||||
|
||||
volumes:
|
||||
loratester-data:
|
||||
+189
-1
@@ -7,6 +7,7 @@ from typing import Any, Optional
|
||||
from fastapi import FastAPI, Header, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.auth import ANDROID_CLIENT_HEADER, ANDROID_CLIENT_VALUE
|
||||
@@ -30,6 +31,7 @@ storage.init_db()
|
||||
|
||||
class TelemetryBody(BaseModel):
|
||||
device_id: str
|
||||
device_label: Optional[str] = None
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
rssi: Optional[float] = None
|
||||
@@ -52,6 +54,10 @@ class TrackStartBody(BaseModel):
|
||||
label: Optional[str] = None
|
||||
|
||||
|
||||
class DeviceLabelBody(BaseModel):
|
||||
label: str
|
||||
|
||||
|
||||
class TrackPoint(BaseModel):
|
||||
ts: Optional[float] = None
|
||||
lat: float
|
||||
@@ -66,6 +72,29 @@ class TrackPointsBody(BaseModel):
|
||||
points: list[TrackPoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CommandBody(BaseModel):
|
||||
from_device_id: str
|
||||
to_device_id: str
|
||||
kind: str
|
||||
payload: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class PairedTrackStartBody(BaseModel):
|
||||
device_ids: Optional[list[str]] = None
|
||||
initiator: Optional[str] = None
|
||||
device_id: Optional[str] = None
|
||||
|
||||
|
||||
class PairedTrackAckBody(BaseModel):
|
||||
session_id: int
|
||||
device_id: str
|
||||
track_id: int
|
||||
|
||||
|
||||
class PairedTrackCancelBody(BaseModel):
|
||||
session_id: Optional[int] = None
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(
|
||||
@@ -96,6 +125,14 @@ def get_devices():
|
||||
return storage.list_devices()
|
||||
|
||||
|
||||
@app.patch("/api/devices/{device_id}/label")
|
||||
def patch_device_label(device_id: str, body: DeviceLabelBody):
|
||||
try:
|
||||
return storage.update_device_label(device_id, body.label)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/telemetry")
|
||||
def get_telemetry_history(
|
||||
device_id: Optional[str] = None,
|
||||
@@ -194,10 +231,161 @@ def get_chat(since: float = 0, limit: int = Query(200, ge=1, le=500)):
|
||||
return storage.get_chat(since, limit)
|
||||
|
||||
|
||||
@app.post("/api/commands")
|
||||
def post_command(body: CommandBody):
|
||||
try:
|
||||
return storage.enqueue_command(
|
||||
body.from_device_id,
|
||||
body.to_device_id,
|
||||
body.kind,
|
||||
body.payload,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/commands/pending")
|
||||
def commands_pending(
|
||||
device_id: str = Query(...),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
|
||||
):
|
||||
_require_android(x_lora_client)
|
||||
try:
|
||||
return storage.poll_pending_commands(device_id, limit)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/commands")
|
||||
def commands_list(
|
||||
to_device_id: Optional[str] = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
return storage.list_commands(to_device_id, limit)
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/start")
|
||||
def paired_tracks_start(
|
||||
body: PairedTrackStartBody,
|
||||
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
|
||||
):
|
||||
if body.initiator:
|
||||
initiator = body.initiator
|
||||
elif body.device_id:
|
||||
initiator = body.device_id
|
||||
elif (x_lora_client or "").strip().lower() == ANDROID_CLIENT_VALUE:
|
||||
raise HTTPException(400, detail="initiator or device_id required")
|
||||
else:
|
||||
initiator = storage.WEB_SENDER_ID
|
||||
try:
|
||||
return storage.start_paired_track(body.device_ids, str(initiator))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/paired-tracks/active")
|
||||
def paired_tracks_active():
|
||||
session = storage.get_active_paired_track()
|
||||
return {"active": session is not None, "session": session}
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/ack")
|
||||
def paired_tracks_ack(
|
||||
body: PairedTrackAckBody,
|
||||
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
|
||||
):
|
||||
_require_android(x_lora_client)
|
||||
try:
|
||||
return storage.ack_paired_track(
|
||||
body.session_id, body.device_id, body.track_id
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/cancel")
|
||||
def paired_tracks_cancel(body: PairedTrackCancelBody):
|
||||
try:
|
||||
return storage.cancel_paired_track(body.session_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
class ElevationPoint(BaseModel):
|
||||
lat: float
|
||||
lon: float
|
||||
ts: Optional[float] = None
|
||||
|
||||
|
||||
class ElevationProfileBody(BaseModel):
|
||||
points: list[ElevationPoint] = Field(default_factory=list)
|
||||
step_m: float = 10.0
|
||||
target_points: Optional[int] = Field(None, ge=2, le=500)
|
||||
|
||||
|
||||
@app.post("/api/elevation/profile")
|
||||
def elevation_profile(body: ElevationProfileBody):
|
||||
from core.elevation import build_elevation_profile
|
||||
|
||||
pts = [p.model_dump(exclude_none=True) for p in body.points]
|
||||
return build_elevation_profile(pts, body.step_m, body.target_points)
|
||||
|
||||
|
||||
@app.get("/api/tracks/{track_id}/elevation-profile")
|
||||
def track_elevation_profile(
|
||||
track_id: int,
|
||||
step_m: float = Query(10.0, ge=5.0, le=10.0),
|
||||
):
|
||||
from core.elevation import build_elevation_profile
|
||||
|
||||
try:
|
||||
track = storage.get_track(track_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, detail=str(e)) from e
|
||||
return build_elevation_profile(track.get("points") or [], step_m)
|
||||
|
||||
|
||||
@app.get("/api/elevation/nearest-hill")
|
||||
def elevation_nearest_hill(
|
||||
lat: float = Query(..., ge=-90.0, le=90.0),
|
||||
lon: float = Query(..., ge=-180.0, le=180.0),
|
||||
radius_m: float = Query(5000.0, ge=500.0, le=15000.0),
|
||||
step_m: float = Query(300.0, ge=100.0, le=500.0),
|
||||
min_prominence_m: float = Query(8.0, ge=3.0, le=100.0),
|
||||
):
|
||||
from core.elevation import find_nearest_hill
|
||||
|
||||
return find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m)
|
||||
|
||||
|
||||
@app.get("/api/elevation/grid")
|
||||
def elevation_grid(
|
||||
lat: float = Query(..., ge=-90.0, le=90.0),
|
||||
lon: float = Query(..., ge=-180.0, le=180.0),
|
||||
radius_m: float = Query(200.0, ge=50.0, le=500.0),
|
||||
step_m: float = Query(0.0, ge=1.0, le=100.0),
|
||||
):
|
||||
from core.elevation import build_elevation_grid
|
||||
|
||||
return build_elevation_grid(lat, lon, radius_m, step_m)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
from core.elevation import elevation_status
|
||||
|
||||
status = storage.db_status()
|
||||
return {"ok": status["db_ok"], "ts": time.time(), **status}
|
||||
return {
|
||||
"ok": status["db_ok"],
|
||||
"ts": time.time(),
|
||||
"api_build": "2026-06-16g",
|
||||
**status,
|
||||
**elevation_status(),
|
||||
}
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+156
-1
@@ -145,10 +145,165 @@ def get_chat():
|
||||
return jsonify(storage.get_chat(since, limit))
|
||||
|
||||
|
||||
@app.post("/api/commands")
|
||||
def post_command():
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
from_id = body.get("from_device_id")
|
||||
to_id = body.get("to_device_id")
|
||||
kind = body.get("kind")
|
||||
if not from_id or not to_id or not kind:
|
||||
return jsonify({"error": "from_device_id, to_device_id, kind required"}), 400
|
||||
try:
|
||||
return jsonify(
|
||||
storage.enqueue_command(
|
||||
str(from_id), str(to_id), str(kind), body.get("payload")
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.get("/api/commands/pending")
|
||||
def commands_pending():
|
||||
if not is_android_client(request.headers):
|
||||
return jsonify({"error": "Android only"}), 403
|
||||
device_id = request.args.get("device_id")
|
||||
if not device_id:
|
||||
return jsonify({"error": "device_id required"}), 400
|
||||
limit = int(request.args.get("limit", 20))
|
||||
try:
|
||||
return jsonify(storage.poll_pending_commands(str(device_id), limit))
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.get("/api/commands")
|
||||
def commands_list():
|
||||
to_device_id = request.args.get("to_device_id")
|
||||
limit = int(request.args.get("limit", 50))
|
||||
return jsonify(storage.list_commands(to_device_id, limit))
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/start")
|
||||
def paired_tracks_start():
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
initiator = body.get("initiator") or (
|
||||
body.get("device_id") if is_android_client(request.headers) else storage.WEB_SENDER_ID
|
||||
)
|
||||
device_ids = body.get("device_ids")
|
||||
try:
|
||||
return jsonify(
|
||||
storage.start_paired_track(
|
||||
device_ids if isinstance(device_ids, list) else None,
|
||||
str(initiator),
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.get("/api/paired-tracks/active")
|
||||
def paired_tracks_active():
|
||||
session = storage.get_active_paired_track()
|
||||
return jsonify({"active": session is not None, "session": session})
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/ack")
|
||||
def paired_tracks_ack():
|
||||
if not is_android_client(request.headers):
|
||||
return jsonify({"error": "Android only"}), 403
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
session_id = body.get("session_id")
|
||||
device_id = body.get("device_id")
|
||||
track_id = body.get("track_id")
|
||||
if session_id is None or not device_id or track_id is None:
|
||||
return jsonify({"error": "session_id, device_id, track_id required"}), 400
|
||||
try:
|
||||
return jsonify(
|
||||
storage.ack_paired_track(int(session_id), str(device_id), int(track_id))
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/cancel")
|
||||
def paired_tracks_cancel():
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
session_id = body.get("session_id")
|
||||
try:
|
||||
sid = int(session_id) if session_id is not None else None
|
||||
return jsonify(storage.cancel_paired_track(sid))
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.post("/api/elevation/profile")
|
||||
def elevation_profile():
|
||||
from core.elevation import build_elevation_profile
|
||||
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
points = body.get("points") or []
|
||||
step_m = body.get("step_m", 10)
|
||||
try:
|
||||
step = float(step_m)
|
||||
except (TypeError, ValueError):
|
||||
step = 10.0
|
||||
target_points = body.get("target_points")
|
||||
try:
|
||||
tp = int(target_points) if target_points is not None else None
|
||||
except (TypeError, ValueError):
|
||||
tp = None
|
||||
return jsonify(build_elevation_profile(points, step, tp))
|
||||
|
||||
|
||||
@app.get("/api/tracks/<int:track_id>/elevation-profile")
|
||||
def track_elevation_profile(track_id: int):
|
||||
from core.elevation import build_elevation_profile
|
||||
|
||||
step_m = request.args.get("step_m", 10, type=float)
|
||||
try:
|
||||
track = storage.get_track(track_id)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
points = track.get("points") or []
|
||||
return jsonify(build_elevation_profile(points, step_m or 10.0))
|
||||
|
||||
|
||||
@app.get("/api/elevation/nearest-hill")
|
||||
def elevation_nearest_hill():
|
||||
from core.elevation import find_nearest_hill
|
||||
|
||||
lat = request.args.get("lat", type=float)
|
||||
lon = request.args.get("lon", type=float)
|
||||
if lat is None or lon is None:
|
||||
return jsonify({"ok": False, "error": "lat and lon required"}), 400
|
||||
radius_m = request.args.get("radius_m", 5000, type=float)
|
||||
step_m = request.args.get("step_m", 300, type=float)
|
||||
min_prominence_m = request.args.get("min_prominence_m", 8, type=float)
|
||||
return jsonify(find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m))
|
||||
|
||||
|
||||
@app.get("/api/elevation/grid")
|
||||
def elevation_grid():
|
||||
from core.elevation import build_elevation_grid
|
||||
|
||||
lat = request.args.get("lat", type=float)
|
||||
lon = request.args.get("lon", type=float)
|
||||
if lat is None or lon is None:
|
||||
return jsonify({"ok": False, "error": "lat and lon required"}), 400
|
||||
radius_m = request.args.get("radius_m", 200, type=float)
|
||||
step_m = request.args.get("step_m", 0, type=float)
|
||||
return jsonify(build_elevation_grid(lat, lon, radius_m, step_m))
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
from core.elevation import elevation_status
|
||||
|
||||
status = storage.db_status()
|
||||
return jsonify({"ok": status["db_ok"], "ts": time.time(), **status})
|
||||
return jsonify(
|
||||
{"ok": status["db_ok"], "ts": time.time(), **status, **elevation_status()}
|
||||
)
|
||||
|
||||
|
||||
def _float_or_none(value):
|
||||
|
||||
Binary file not shown.
+2406
-190
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
/** Shared radio stats parsing/formatting (mirror of Android RadioSnapshot). */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const KNOWN_LABELS = new Set([
|
||||
'send', 'receive', 'frequency', 'power', 'rssi', 'snr',
|
||||
'spreading factor', 'bandwidth', 'packet', 'packet number', 'payload',
|
||||
'packet receive', 'packet total', 'packet error', 'crc error',
|
||||
'preamble detected', 'header valid',
|
||||
'on air', 'tx speed', 'rx speed', 'per', 'rx quality',
|
||||
'code rate', 'preamble length', 'low data rate', 'crc', 'payload length', 'tx timeout'
|
||||
]);
|
||||
|
||||
function roleLabel(role) {
|
||||
if (role === 'TX') return 'Передатчик (TX)';
|
||||
if (role === 'RX') return 'Приёмник (RX)';
|
||||
return role || '—';
|
||||
}
|
||||
|
||||
function isKnownLabel(label) {
|
||||
const n = String(label || '').toLowerCase().trim();
|
||||
for (const k of KNOWN_LABELS) {
|
||||
if (n === k || n.includes(k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function fmtCrc(enabled) {
|
||||
if (enabled == null) return '—';
|
||||
return enabled ? 'On' : 'Off';
|
||||
}
|
||||
|
||||
function fmtBw(khz) {
|
||||
return khz != null ? `${Number(khz).toFixed(2)} kHz` : '—';
|
||||
}
|
||||
|
||||
function parseRadioSnapshot(meta, roleFallback, rssiFallback) {
|
||||
const snap = {
|
||||
role: roleFallback || null,
|
||||
frame: null,
|
||||
frequencyMhz: null,
|
||||
sf: null,
|
||||
bwKhz: null,
|
||||
powerDbm: null,
|
||||
rssiDbm: rssiFallback ?? null,
|
||||
snrDb: null,
|
||||
packet: null,
|
||||
payload: null,
|
||||
onAirMs: null,
|
||||
txPktPerS: null,
|
||||
rxPktPerS: null,
|
||||
perPercent: null,
|
||||
rxQualityPercent: null,
|
||||
codeRate: null,
|
||||
preambleLength: null,
|
||||
lowDataRateOpt: null,
|
||||
crcEnabled: null,
|
||||
payloadLengthBytes: null,
|
||||
txTimeoutMs: null,
|
||||
packetReceive: null,
|
||||
packetTotal: null,
|
||||
packetError: null,
|
||||
crcError: null,
|
||||
preambleDetected: null,
|
||||
headerValid: null,
|
||||
extraFields: {}
|
||||
};
|
||||
if (!meta) return snap;
|
||||
let o = meta;
|
||||
if (typeof meta === 'string') {
|
||||
try { o = JSON.parse(meta); } catch (e) { return snap; }
|
||||
}
|
||||
if (o.role) snap.role = o.role;
|
||||
if (o.frame) snap.frame = o.frame;
|
||||
if (o.rssi_dbm != null) snap.rssiDbm = Number(o.rssi_dbm);
|
||||
if (o.power_dbm != null) snap.powerDbm = Number(o.power_dbm);
|
||||
if (o.snr_db != null) snap.snrDb = Number(o.snr_db);
|
||||
if (o.frequency_hz != null) snap.frequencyMhz = Number(o.frequency_hz) / 1e6;
|
||||
if (o.spreading_factor != null) snap.sf = Number(o.spreading_factor);
|
||||
if (o.bandwidth_khz != null) snap.bwKhz = Number(o.bandwidth_khz);
|
||||
if (o.packet != null) snap.packet = Number(o.packet);
|
||||
if (o.payload) snap.payload = String(o.payload);
|
||||
if (o.on_air_ms != null) snap.onAirMs = Number(o.on_air_ms);
|
||||
if (o.tx_pkt_per_s != null) snap.txPktPerS = Number(o.tx_pkt_per_s);
|
||||
if (o.rx_pkt_per_s != null) snap.rxPktPerS = Number(o.rx_pkt_per_s);
|
||||
if (o.per_percent != null) snap.perPercent = Number(o.per_percent);
|
||||
if (o.rx_quality_percent != null) snap.rxQualityPercent = Number(o.rx_quality_percent);
|
||||
if (o.stats_at != null) snap.statsAt = Number(o.stats_at);
|
||||
if (o.code_rate != null) snap.codeRate = String(o.code_rate);
|
||||
if (o.preamble_length != null) snap.preambleLength = Number(o.preamble_length);
|
||||
if (o.low_data_rate_opt != null) snap.lowDataRateOpt = String(o.low_data_rate_opt);
|
||||
if (o.crc_enabled != null) snap.crcEnabled = Boolean(o.crc_enabled);
|
||||
if (o.payload_length_bytes != null) snap.payloadLengthBytes = Number(o.payload_length_bytes);
|
||||
if (o.tx_timeout_ms != null) snap.txTimeoutMs = Number(o.tx_timeout_ms);
|
||||
if (o.packet_receive != null) snap.packetReceive = Number(o.packet_receive);
|
||||
if (o.packet_total != null) snap.packetTotal = Number(o.packet_total);
|
||||
if (o.packet_error != null) snap.packetError = Number(o.packet_error);
|
||||
if (o.crc_error != null) snap.crcError = Number(o.crc_error);
|
||||
if (o.preamble_detected != null) snap.preambleDetected = Number(o.preamble_detected);
|
||||
if (o.header_valid != null) snap.headerValid = Number(o.header_valid);
|
||||
if (o.fields && typeof o.fields === 'object') {
|
||||
for (const [k, v] of Object.entries(o.fields)) {
|
||||
if (!isKnownLabel(k)) snap.extraFields[k] = String(v);
|
||||
const nk = String(k).toLowerCase().trim();
|
||||
if (snap.rxQualityPercent == null && nk.includes('rx quality')) {
|
||||
const n = parseFloat(String(v).replace('%', '').trim());
|
||||
if (!Number.isNaN(n)) snap.rxQualityPercent = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
function diffSnapshots(a, b) {
|
||||
const changed = new Set();
|
||||
if (!a || !b) return changed;
|
||||
const keys = ['gps', 'packetTime', 'role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent',
|
||||
'packetReceive', 'packetTotal', 'packetError', 'crcError', 'preambleDetected', 'headerValid',
|
||||
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm', 'codeRate', 'crcEnabled'];
|
||||
const map = {
|
||||
gps: 'gps', packetTime: 'packetTime', role: 'role', rssiDbm: 'rssi', snrDb: 'snr',
|
||||
rxQualityPercent: 'rxQuality', packet: 'packet', payload: 'payload', perPercent: 'per',
|
||||
packetReceive: 'packetReceive', packetTotal: 'packetTotal', packetError: 'packetError',
|
||||
crcError: 'crcError', preambleDetected: 'preambleDetected', headerValid: 'headerValid',
|
||||
txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed',
|
||||
frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power', codeRate: 'codeRate', crcEnabled: 'crc'
|
||||
};
|
||||
for (const k of keys) {
|
||||
if (a[k] !== b[k] && !(a[k] == null && b[k] == null)) changed.add(map[k]);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
const DYNAMIC_ROWS = [
|
||||
{ key: 'gps', label: 'GPS', fmt: s => s.gps || '—' },
|
||||
{ key: 'packetTime', label: 'Время пакета', fmt: s => s.packetTime || '—' },
|
||||
{ key: 'rssi', label: 'RSSI', fmt: s => s.rssiDbm != null ? `${s.rssiDbm} dBm` : '—' },
|
||||
{ key: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' },
|
||||
{ key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' },
|
||||
{ key: 'packet', label: 'Пакет', fmt: s => s.packet != null ? String(s.packet) : '—' },
|
||||
{ key: 'payload', label: 'Payload', fmt: s => s.payload || '—' },
|
||||
{ key: 'per', label: 'PER', fmt: s => s.perPercent != null ? `${s.perPercent} %` : '—' },
|
||||
{ key: 'packetReceive', label: 'Принято', fmt: s => s.packetReceive != null ? String(s.packetReceive) : '—' },
|
||||
{ key: 'packetTotal', label: 'Всего', fmt: s => s.packetTotal != null ? String(s.packetTotal) : '—' },
|
||||
{ key: 'packetError', label: 'Ошибки', fmt: s => s.packetError != null ? String(s.packetError) : '—' },
|
||||
{ key: 'crcError', label: 'CRC err', fmt: s => s.crcError != null ? String(s.crcError) : '—' },
|
||||
{ key: 'preambleDetected', label: 'Preamble', fmt: s => s.preambleDetected != null ? String(s.preambleDetected) : '—' },
|
||||
{ key: 'headerValid', label: 'Header OK', fmt: s => s.headerValid != null ? String(s.headerValid) : '—' },
|
||||
{ key: 'txSpeed', label: 'TX Speed', fmt: s => s.txPktPerS != null ? `${s.txPktPerS} pkt/s` : '—' },
|
||||
{ key: 'rxSpeed', label: 'RX Speed', fmt: s => s.rxPktPerS != null ? `${s.rxPktPerS} pkt/s` : '—' }
|
||||
];
|
||||
|
||||
const STATIC_ROWS = [
|
||||
{ key: 'role', label: 'Роль', fmt: s => roleLabel(s.role) },
|
||||
{ key: 'frequency', label: 'Частота', fmt: s => s.frequencyMhz != null ? `${s.frequencyMhz.toFixed(3)} MHz` : '—' },
|
||||
{ key: 'sf', label: 'SF', fmt: s => s.sf != null ? String(s.sf) : '—' },
|
||||
{ key: 'bw', label: 'BW', fmt: s => fmtBw(s.bwKhz) },
|
||||
{ key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' },
|
||||
{ key: 'codeRate', label: 'Code Rate', fmt: s => s.codeRate || '—' },
|
||||
{ key: 'preambleLength', label: 'Preamble', fmt: s => s.preambleLength != null ? String(s.preambleLength) : '—' },
|
||||
{ key: 'lowDataRateOpt', label: 'LDR', fmt: s => s.lowDataRateOpt || '—' },
|
||||
{ key: 'crc', label: 'CRC', fmt: s => fmtCrc(s.crcEnabled) },
|
||||
{ key: 'payloadLength', label: 'Payl.len', fmt: s => s.payloadLengthBytes != null ? `${s.payloadLengthBytes} B` : '—' },
|
||||
{ key: 'txTimeout', label: 'TX Timeout', fmt: s => s.txTimeoutMs != null ? `${s.txTimeoutMs} ms` : '—' },
|
||||
{ key: 'onAir', label: 'On Air', fmt: s => s.onAirMs != null ? `${s.onAirMs} ms` : '—' }
|
||||
];
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) {
|
||||
let html = '<div class="radio-compare-grid">';
|
||||
html += '<div class="radio-compare-head">';
|
||||
html += `<span><span class="legend-tx">TX</span> ${escapeHtml(txId || '—')}</span>`;
|
||||
html += `<span><span class="legend-rx">RX</span> ${escapeHtml(rxId || '—')}</span>`;
|
||||
html += '</div>';
|
||||
for (const row of DYNAMIC_ROWS) {
|
||||
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
|
||||
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
|
||||
html += `<div class="radio-row"><span class="radio-label">${row.label}</span>`;
|
||||
html += `<span class="radio-tx${txCls}">${escapeHtml(row.fmt(txSnap))}</span>`;
|
||||
html += `<span class="radio-rx${rxCls}">${escapeHtml(row.fmt(rxSnap))}</span></div>`;
|
||||
}
|
||||
html += `<details class="radio-static"${staticOpen ? ' open' : ''}><summary>Статика</summary>`;
|
||||
for (const row of STATIC_ROWS) {
|
||||
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
|
||||
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
|
||||
html += `<div class="radio-row"><span class="radio-label">${row.label}</span>`;
|
||||
html += `<span class="radio-tx${txCls}">${escapeHtml(row.fmt(txSnap))}</span>`;
|
||||
html += `<span class="radio-rx${rxCls}">${escapeHtml(row.fmt(rxSnap))}</span></div>`;
|
||||
}
|
||||
html += '</details></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function formatRadioPanel(snap, changed, staticOpen) {
|
||||
if (!snap) return '—';
|
||||
const ch = changed || new Set();
|
||||
let html = '';
|
||||
for (const row of DYNAMIC_ROWS) {
|
||||
const cls = ch.has(row.key) ? ' class="changed"' : '';
|
||||
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
|
||||
}
|
||||
for (const [label, value] of Object.entries(snap.extraFields || {})) {
|
||||
html += `<div><b>${escapeHtml(label)}:</b> ${escapeHtml(value)}</div>`;
|
||||
}
|
||||
html += `<details class="radio-static"${staticOpen ? ' open' : ''}><summary>Статика</summary>`;
|
||||
for (const row of STATIC_ROWS) {
|
||||
const cls = ch.has(row.key) ? ' class="changed"' : '';
|
||||
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
|
||||
}
|
||||
html += '</details>';
|
||||
return html;
|
||||
}
|
||||
|
||||
global.RadioUI = {
|
||||
roleLabel,
|
||||
parseRadioSnapshot,
|
||||
diffSnapshots,
|
||||
renderCompareGrid,
|
||||
formatRadioPanel,
|
||||
DYNAMIC_ROWS,
|
||||
STATIC_ROWS
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
Binary file not shown.
@@ -0,0 +1,172 @@
|
||||
import core.elevation as elev
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, url, params=None):
|
||||
return _FakeResponse({"elevation": [152.0]})
|
||||
|
||||
|
||||
def test_probe_elevation_api_ok(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev.httpx, "Client", _FakeClient)
|
||||
|
||||
status = elev.probe_elevation_api(force=True)
|
||||
|
||||
assert status["ok"] is True
|
||||
assert status["error"] is None
|
||||
|
||||
|
||||
def test_fetch_skips_when_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
)
|
||||
|
||||
vals = elev.fetch_elevations_batch([55.75], [37.62])
|
||||
|
||||
assert vals == [None]
|
||||
|
||||
|
||||
def test_build_profile_reports_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
)
|
||||
|
||||
profile = elev.build_elevation_profile(
|
||||
[{"lat": 55.75, "lon": 37.62}, {"lat": 55.76, "lon": 37.63}],
|
||||
10,
|
||||
)
|
||||
|
||||
assert profile["points"] == []
|
||||
assert "unreachable" in profile["api_error"]
|
||||
|
||||
|
||||
def test_resample_track_path_count_even_spacing():
|
||||
pts = [{"lat": 55.0, "lon": 37.0}, {"lat": 55.01, "lon": 37.0}]
|
||||
samples = elev.resample_track_path_count(pts, 50)
|
||||
assert len(samples) == 50
|
||||
assert samples[0]["dist_m"] == 0.0
|
||||
assert samples[-1]["dist_m"] > samples[0]["dist_m"]
|
||||
gaps = [samples[i]["dist_m"] - samples[i - 1]["dist_m"] for i in range(1, len(samples))]
|
||||
assert max(gaps) - min(gaps) < 1.0
|
||||
|
||||
|
||||
def test_build_profile_target_points(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"fetch_elevations_batch",
|
||||
lambda lats, lons: [100.0 + i for i in range(len(lats))],
|
||||
)
|
||||
|
||||
profile = elev.build_elevation_profile(
|
||||
[{"lat": 55.0, "lon": 37.0}, {"lat": 55.01, "lon": 37.0}],
|
||||
target_points=120,
|
||||
)
|
||||
|
||||
assert len(profile["points"]) == 120
|
||||
assert profile["step_m"] > 0
|
||||
|
||||
|
||||
def test_find_nearest_hill_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
)
|
||||
result = elev.find_nearest_hill(55.75, 37.62)
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
|
||||
def fake_batch(lats, lons):
|
||||
out = []
|
||||
for la, lo in zip(lats, lons):
|
||||
if abs(la - 55.75) < 1e-4 and abs(lo - 37.62) < 1e-4:
|
||||
out.append(100.0)
|
||||
elif la > 55.75:
|
||||
out.append(130.0)
|
||||
else:
|
||||
out.append(95.0)
|
||||
return out
|
||||
|
||||
monkeypatch.setattr(elev, "fetch_elevations_batch", fake_batch)
|
||||
result = elev.find_nearest_hill(55.75, 37.62, radius_m=2000, step_m=300, min_prominence_m=8)
|
||||
assert result["ok"] is True
|
||||
assert result["hill"]["elevation_m"] >= 120.0
|
||||
|
||||
|
||||
def test_build_elevation_grid_delta(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
|
||||
def fake_batch(lats, lons):
|
||||
return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(lats, lons)]
|
||||
|
||||
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 100.0)
|
||||
monkeypatch.setattr(elev, "fetch_elevations_batch", fake_batch)
|
||||
|
||||
result = elev.build_elevation_grid(55.75, 37.62, radius_m=100, step_m=10)
|
||||
assert result["ok"] is True
|
||||
assert result["step_m"] == 10
|
||||
assert len(result["points"]) > 0
|
||||
assert result["min_delta_m"] <= 0 <= result["max_delta_m"]
|
||||
assert all("delta_m" in p for p in result["points"])
|
||||
|
||||
|
||||
def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 120.0)
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"fetch_elevations_batch",
|
||||
lambda lats, lons: [120.0 + i * 0.1 for i in range(len(lats))],
|
||||
)
|
||||
|
||||
result = elev.build_elevation_grid(55.75, 37.62, radius_m=50, step_m=1)
|
||||
assert result["ok"] is True
|
||||
assert result["step_m"] == 1
|
||||
assert len(result["points"]) > 1000
|
||||
|
||||
|
||||
def test_build_elevation_grid_limits_points(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 50.0)
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"fetch_elevations_batch",
|
||||
lambda lats, lons: [50.0] * len(lats),
|
||||
)
|
||||
|
||||
step = elev._resolve_grid_step(55.75, 37.62, 500.0, 5.0)
|
||||
cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step)
|
||||
assert len(cells) <= elev._MAX_GRID_POINTS
|
||||
Reference in New Issue
Block a user