11 Commits
v7 .. v8

Author SHA1 Message Date
Grigo c5805eaa5c added rx Quality 2026-06-15 11:57:23 +03:00
Grigo 012947fd99 added rx Quality 2026-06-15 11:41:39 +03:00
Grigo 23eb7ffb91 added rx Quality 2026-06-15 11:17:10 +03:00
Grigo e20b81c817 fixed modal 2026-06-15 11:04:34 +03:00
Grigo 2f303134c1 added linear slider 2026-06-15 08:40:27 +03:00
Grigo ab2a3bb035 added linear slider 2026-06-15 07:50:41 +03:00
Grigo d28391c71f added grid 2026-06-11 10:22:36 +03:00
Grigo c2f26c8ec3 added subprox 2026-06-11 09:32:33 +03:00
Grigo 94e2b772e8 added subproxy 2026-06-11 09:09:28 +03:00
Grigo 17d383ddc6 added bind 2026-06-11 08:46:49 +03:00
Grigo 8fd7e85c83 added local api 2026-06-11 08:38:08 +03:00
62 changed files with 5617 additions and 811 deletions
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
+1 -1
View File
@@ -11,7 +11,7 @@ Android-клиент и Python-сервер для мониторинга LoRa
1. Запустите сервер: `cd server && pip install -r requirements.txt && python flask_app.py` 1. Запустите сервер: `cd server && pip install -r requirements.txt && python flask_app.py`
2. Соберите APK в Android Studio или `./gradlew assembleDebug` 2. Соберите APK в Android Studio или `./gradlew assembleDebug`
3. В приложении: Настройки → URL `http://<ваш-сервер>:7634`, включите telnet при наличии моста COM→telnet 3. В приложении: Настройки → URL `https://lora.grigowashere.ru` (или свой сервер), включите telnet при наличии моста COM→telnet
## Тесты ## Тесты
@@ -4,6 +4,8 @@ import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import androidx.annotation.Nullable;
import com.grigowashere.loratester.api.DeviceCommand; import com.grigowashere.loratester.api.DeviceCommand;
import com.grigowashere.loratester.api.PairedTrackSession; import com.grigowashere.loratester.api.PairedTrackSession;
import com.grigowashere.loratester.api.ServerApi; import com.grigowashere.loratester.api.ServerApi;
@@ -120,11 +122,17 @@ public class CommandPoller {
} }
switch (cmd.kind) { switch (cmd.kind) {
case "at" -> { case "at" -> {
String line = cmd.payload != null && cmd.payload.get("line") != null List<String> lines = extractLines(cmd.payload);
? String.valueOf(cmd.payload.get("line")) : null; if (lines != null && !lines.isEmpty()) {
if (line != null) { uploader.sendMacroSequence(lines, r ->
uploader.sendAtCommand(line, r -> Log.i(TAG, "remote macro " + lines + " -> " + r));
Log.i(TAG, "remote AT " + line + " -> " + 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" -> { case "mode" -> {
@@ -224,6 +232,33 @@ public class CommandPoller {
return o instanceof Number ? ((Number) o).longValue() : null; 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) { public void postCommandToPeer(String peerId, String kind, Map<String, Object> payload) {
executor.execute(() -> { executor.execute(() -> {
try { try {
@@ -49,6 +49,7 @@ public class MainActivity extends AppCompatActivity {
ViewPager2 pager = findViewById(R.id.viewPager); ViewPager2 pager = findViewById(R.id.viewPager);
TabLayout tabs = findViewById(R.id.tabLayout); TabLayout tabs = findViewById(R.id.tabLayout);
pager.setOffscreenPageLimit(1);
pager.setAdapter(new MainPagerAdapter(this)); pager.setAdapter(new MainPagerAdapter(this));
new TabLayoutMediator(tabs, pager, (tab, position) -> { new TabLayoutMediator(tabs, pager, (tab, position) -> {
int titleRes = switch (position) { int titleRes = switch (position) {
@@ -14,7 +14,8 @@ public class SettingsRepository {
private static final String KEY_TELNET_ENABLED = "telnet_enabled"; private static final String KEY_TELNET_ENABLED = "telnet_enabled";
private static final String KEY_DEVICE_ID = "device_id"; private static final String KEY_DEVICE_ID = "device_id";
public static final String DEFAULT_SERVER = "http://grigowashere.ru:7634"; public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru";
private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634";
public static final String DEFAULT_TELNET_HOST = "127.0.0.1"; public static final String DEFAULT_TELNET_HOST = "127.0.0.1";
public static final int DEFAULT_TELNET_PORT = 2727; public static final int DEFAULT_TELNET_PORT = 2727;
public static final String DEFAULT_RSSI_REGEX = "(?:RSSI|Power)[:\\s]*(-?\\d+(?:\\.\\d+)?)"; public static final String DEFAULT_RSSI_REGEX = "(?:RSSI|Power)[:\\s]*(-?\\d+(?:\\.\\d+)?)";
@@ -25,6 +26,28 @@ public class SettingsRepository {
public SettingsRepository(Context context) { public SettingsRepository(Context context) {
prefs = context.getApplicationContext() prefs = context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE); .getSharedPreferences(PREFS, Context.MODE_PRIVATE);
migrateLegacyServerUrl();
}
private void migrateLegacyServerUrl() {
String current = prefs.getString(KEY_SERVER_URL, null);
if (current == null || !isLegacyServerUrl(current)) {
return;
}
prefs.edit().putString(KEY_SERVER_URL, DEFAULT_SERVER).apply();
}
static boolean isLegacyServerUrl(String url) {
if (url == null) {
return false;
}
String u = url.trim().toLowerCase();
while (u.endsWith("/")) {
u = u.substring(0, u.length() - 1);
}
return u.equals(LEGACY_SERVER_HTTP)
|| u.equals("http://grigowashere.ru")
|| u.equals("https://grigowashere.ru:7634");
} }
public String getServerUrl() { public String getServerUrl() {
@@ -11,12 +11,14 @@ import com.grigowashere.loratester.api.UploadQueue;
import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.telnet.AtCommandFormatter; import com.grigowashere.loratester.telnet.AtCommandFormatter;
import com.grigowashere.loratester.telnet.RadioMacroBuilder;
import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.telnet.TelnetClient; import com.grigowashere.loratester.telnet.TelnetClient;
import com.grigowashere.loratester.telnet.TelnetFrameParser; import com.grigowashere.loratester.telnet.TelnetFrameParser;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
@@ -112,6 +114,18 @@ public class TelemetryUploader implements TelnetClient.Listener {
} }
} }
public boolean hasGpsFix() {
return GeoUtils.isValidCoordinate(lat, lon);
}
public double getGpsLat() {
return lat;
}
public double getGpsLon() {
return lon;
}
private Double validLat() { private Double validLat() {
return GeoUtils.isValidCoordinate(lat, lon) ? lat : null; return GeoUtils.isValidCoordinate(lat, lon) ? lat : null;
} }
@@ -168,6 +182,52 @@ public class TelemetryUploader implements TelnetClient.Listener {
}); });
} }
public void sendMacroSequence(List<String> lines, AtSendCallback callback) {
telnetExecutor.execute(() -> {
TelnetClient.SendResult last = TelnetClient.SendResult.SENT;
if (lines != null) {
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
if (line == null || line.isEmpty()) {
continue;
}
last = sendLineOnWorker(line);
if (last != TelnetClient.SendResult.SENT) {
break;
}
if (i < lines.size() - 1) {
try {
Thread.sleep(150);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
break;
}
}
}
}
if (callback != null) {
TelnetClient.SendResult r = last;
mainHandler.post(() -> callback.onResult(r));
}
});
}
private TelnetClient.SendResult sendLineOnWorker(String line) {
if (RadioMacroBuilder.STOP.equals(line)) {
appendConsole(">> S\n");
if (telnetClient == null) {
appendConsole("!! telnet not started\n");
return TelnetClient.SendResult.NOT_CONNECTED;
}
TelnetClient.SendResult result = telnetClient.sendRawLine(line);
if (result != TelnetClient.SendResult.SENT) {
appendConsole("!! send failed: " + result + "\n");
}
return result;
}
return sendAtCommandOnWorker(line);
}
private TelnetClient.SendResult sendAtCommandOnWorker(String command) { private TelnetClient.SendResult sendAtCommandOnWorker(String command) {
String normalized = AtCommandFormatter.normalize(command); String normalized = AtCommandFormatter.normalize(command);
appendConsole(">> " + normalized + "\n"); appendConsole(">> " + normalized + "\n");
@@ -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;
}
}
@@ -195,6 +195,62 @@ public class ServerApi {
} }
} }
@SuppressWarnings("unchecked")
public Map<String, Object> getHealth() throws IOException {
Request request = new Request.Builder()
.url(baseUrl + "/api/health")
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), Map.class);
}
}
public NearestHillResult findNearestHill(double lat, double lon, int radiusM)
throws IOException {
String path = "/api/elevation/nearest-hill?lat="
+ lat
+ "&lon="
+ lon
+ "&radius_m="
+ radiusM;
Request request = new Request.Builder()
.url(baseUrl + path)
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), NearestHillResult.class);
}
}
public ElevationGridResult getElevationGrid(double lat, double lon, int radiusM, int stepM)
throws IOException {
String path = "/api/elevation/grid?lat="
+ lat
+ "&lon="
+ lon
+ "&radius_m="
+ radiusM
+ "&step_m="
+ stepM;
Request request = new Request.Builder()
.url(baseUrl + path)
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), ElevationGridResult.class);
}
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android) private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
throws IOException { throws IOException {
@@ -20,4 +20,14 @@ public final class GeoUtils {
public static boolean isValidCoordinate(Double lat, Double lon) { public static boolean isValidCoordinate(Double lat, Double lon) {
return lat != null && lon != null && isValidCoordinate(lat.doubleValue(), lon.doubleValue()); return lat != null && lon != null && isValidCoordinate(lat.doubleValue(), lon.doubleValue());
} }
public static double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
final double r = 6_371_000;
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
return r * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
} }
@@ -0,0 +1,200 @@
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 Integer 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 Map<String, String> extraFields;
public RadioSnapshot(
String role,
String frame,
Double frequencyMhz,
Integer sf,
Integer bwKhz,
Double powerDbm,
Double rssiDbm,
Double snrDb,
Integer packet,
String payload,
Double onAirMs,
Double txPktPerS,
Double rxPktPerS,
Double perPercent,
Double rxQualityPercent,
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.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, 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, 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"),
integer(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"),
extra
);
} catch (Exception ignored) {
return new RadioSnapshot(roleFallback, null, null, null, null, null,
rssiFallback, 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);
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("on air") || n.contains("tx speed") || n.contains("rx speed")
|| n.equals("per") || n.contains("rx quality");
}
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 Long lng(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsLong() : null;
}
private static Double hzToMhz(Long hz) {
if (hz == null) {
return null;
}
return hz / 1_000_000.0;
}
}
@@ -30,4 +30,12 @@ public final class AtCommandFormatter {
String wire = line + "\r\n"; String wire = line + "\r\n";
return wire.getBytes(StandardCharsets.UTF_8); return wire.getBytes(StandardCharsets.UTF_8);
} }
/** Literal line (e.g. screen reset "S") without AT prefix. */
public static byte[] toWireBytesLiteral(String line) {
if (line == null || line.isEmpty()) {
return new byte[0];
}
return (line + "\r\n").getBytes(StandardCharsets.UTF_8);
}
} }
@@ -1,15 +1,33 @@
package com.grigowashere.loratester.telnet; package com.grigowashere.loratester.telnet;
/** Common AT commands for LoRa modules (via telnet bridge). */ /** LoRa module AT commands (telnet bridge). */
public final class AtCommands { public final class AtCommands {
public static final String HELP = "AT+H";
public static final String TRANSMIT = "AT+TX"; public static final String TRANSMIT = "AT+TX";
public static final String RECEIVE = "AT+RX"; public static final String RECEIVE = "AT+RX";
/** Stop TX or RX test. */
public static final String STOP = "S";
public static final String TIMEOUT_MS = "AT+TM=";
public static final String FREQUENCY_HZ = "AT+FQ=";
public static final String POWER_DBM = "AT+PW=";
public static final String SPREADING_FACTOR = "AT+SF=";
public static final String BANDWIDTH = "AT+BW=";
public static final String CODE_RATE = "AT+CR=";
public static final String PREAMBLE = "AT+PL=";
/** Legacy / bridge helpers (if supported by firmware). */
public static final String HELP = "AT+H";
public static final String STATUS = "AT+STATUS"; public static final String STATUS = "AT+STATUS";
public static final String RESET = "AT+RESET"; public static final String RESET = "AT+RESET";
public static final String BASIC = "AT"; public static final String BASIC = "AT";
public static final String[] BW_KHZ = {
"7.81", "10.42", "15.63", "20.83", "31.25",
"41.67", "62.5", "125", "250", "500"
};
public static final String[] CODE_RATES = {"4/5", "4/6", "4/7", "4/8"};
private AtCommands() { private AtCommands() {
} }
} }
@@ -1,10 +1,7 @@
package com.grigowashere.loratester.telnet; package com.grigowashere.loratester.telnet;
import com.google.gson.JsonElement; import com.grigowashere.loratester.model.RadioSnapshot;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.HashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -14,67 +11,57 @@ public final class LoraStatsFormatter {
private LoraStatsFormatter() { private LoraStatsFormatter() {
} }
/** Human-readable lines from telemetry meta JSON (fields first). */ @Deprecated
public static String formatMeta(String metaJson) { public static String formatMeta(String metaJson) {
if (metaJson == null || metaJson.isEmpty()) { RadioSnapshot snap = RadioSnapshot.fromMeta(metaJson, null, null);
StringBuilder sb = new StringBuilder();
String dynamic = formatDynamic(snap, Set.of());
if (!dynamic.isEmpty()) {
sb.append(dynamic);
}
String stat = formatStatic(snap, Set.of());
if (!stat.isEmpty()) {
if (sb.length() > 0) {
sb.append("\n");
}
sb.append(stat);
}
return sb.toString().trim();
}
public static String formatDynamic(RadioSnapshot s, Set<String> changed) {
if (s == null) {
return ""; return "";
} }
try { StringBuilder sb = new StringBuilder();
JsonObject o = JsonParser.parseString(metaJson).getAsJsonObject(); appendLine(sb, "RSSI", fmtDbm(s.rssiDbm), "rssi", changed);
StringBuilder sb = new StringBuilder(); appendLine(sb, "SNR", fmtSuffix(s.snrDb, " dB"), "snr", changed);
Set<String> shown = new HashSet<>(); appendLine(sb, "RX Quality", fmtSuffix(s.rxQualityPercent, " %"), "rxQuality", changed);
appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed);
appendFieldsBlock(sb, o.get("fields"), shown); appendLine(sb, "Payload", s.payload, "payload", changed);
appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed);
String role = text(o, "role"); appendLine(sb, "TX Speed", fmtSuffix(s.txPktPerS, " pkt/s"), "txSpeed", changed);
if (role != null) { appendLine(sb, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed);
append(sb, "Роль", roleLabel(role)); 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;
} }
return sb.toString().trim();
} }
private static void appendFieldsBlock(StringBuilder sb, JsonElement fieldsEl, Set<String> shown) { public static String formatStatic(RadioSnapshot s, Set<String> changed) {
if (fieldsEl == null || !fieldsEl.isJsonObject()) { if (s == null) {
return; return "";
} }
JsonObject fields = fieldsEl.getAsJsonObject(); StringBuilder sb = new StringBuilder();
for (Map.Entry<String, JsonElement> e : fields.entrySet()) { if (s.role != null) {
String label = e.getKey(); appendLine(sb, "Роль", roleLabel(s.role), "role", changed);
if (isSkippedFieldLabel(label)) {
continue;
}
String norm = normalizeLabel(label);
if (shown.contains(norm)) {
continue;
}
shown.add(norm);
append(sb, label, e.getValue().getAsString());
} }
} appendLine(sb, "Частота", fmtSuffix(s.frequencyMhz, " MHz"), "frequency", changed);
appendLine(sb, "SF", fmtInt(s.sf), "sf", changed);
private static String normalizeLabel(String label) { appendLine(sb, "BW", fmtSuffix(s.bwKhz, " kHz"), "bw", changed);
return label.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ").trim(); appendLine(sb, "Мощность TX", fmtDbm(s.powerDbm), "power", changed);
} appendLine(sb, "On Air", fmtSuffix(s.onAirMs, " ms"), "onAir", changed);
return sb.toString().trim();
private static boolean isSkippedFieldLabel(String label) {
String l = normalizeLabel(label);
return l.equals("send") || l.equals("receive");
} }
public static String roleLabel(String role) { public static String roleLabel(String role) {
@@ -87,12 +74,23 @@ public final class LoraStatsFormatter {
return role; return role;
} }
private static String freqMhz(JsonObject o) { private static void appendLine(
if (!o.has("frequency_hz")) { StringBuilder sb,
return null; String label,
String value,
String changeKey,
Set<String> changed
) {
if (value == null || value.isEmpty()) {
return;
} }
long hz = o.get("frequency_hz").getAsLong(); if (sb.length() > 0) {
return String.format(Locale.US, "%.3f", hz / 1_000_000.0); sb.append("\n");
}
if (changed != null && changed.contains(changeKey)) {
sb.append("");
}
sb.append(label).append(": ").append(value);
} }
private static void append(StringBuilder sb, String label, String value) { private static void append(StringBuilder sb, String label, String value) {
@@ -105,25 +103,19 @@ public final class LoraStatsFormatter {
sb.append(label).append(": ").append(value); sb.append(label).append(": ").append(value);
} }
private static void append(StringBuilder sb, String label, String value, String suffix) { private static String fmtDbm(Double v) {
if (value == null) { return v != null ? String.format(Locale.US, "%.0f dBm", v) : null;
return;
}
append(sb, label, value + suffix);
} }
private static String text(JsonObject o, String key) { private static String fmtInt(Integer v) {
JsonElement e = o.get(key); return v != null ? String.valueOf(v) : null;
return e != null && !e.isJsonNull() ? e.getAsString() : null;
} }
private static String integer(JsonObject o, String key) { private static String fmtSuffix(Double v, String suffix) {
JsonElement e = o.get(key); return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null;
return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsInt()) : null;
} }
private static String dbl(JsonObject o, String key) { private static String fmtSuffix(Integer v, String suffix) {
JsonElement e = o.get(key); return v != null ? v + suffix : null;
return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsDouble()) : 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;
}
}
@@ -33,6 +33,8 @@ public class StatsExtractor {
private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern RX_SPEED = Pattern.compile("RX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern RX_SPEED = Pattern.compile("RX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PER = Pattern.compile("PER\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern PER = Pattern.compile("PER\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern RX_QUALITY = Pattern.compile(
"RX Quality\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private final Pattern rssiPattern; private final Pattern rssiPattern;
private final Pattern rangePattern; private final Pattern rangePattern;
@@ -68,10 +70,6 @@ public class StatsExtractor {
if (role != null) { if (role != null) {
meta.put("role", role); meta.put("role", role);
} }
if (!fields.isEmpty()) {
meta.put("fields", fields);
}
Double rssiDbm = firstDouble(RSSI, normalized); Double rssiDbm = firstDouble(RSSI, normalized);
if (rssiDbm == null) { if (rssiDbm == null) {
rssiDbm = matchDouble(rssiPattern, normalized); rssiDbm = matchDouble(rssiPattern, normalized);
@@ -102,8 +100,11 @@ public class StatsExtractor {
putDouble(meta, "tx_pkt_per_s", matchDouble(TX_SPEED, normalized)); putDouble(meta, "tx_pkt_per_s", matchDouble(TX_SPEED, normalized));
putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized)); putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized));
putDouble(meta, "per_percent", matchDouble(PER, normalized)); putDouble(meta, "per_percent", matchDouble(PER, normalized));
putDouble(meta, "rx_quality_percent", matchDouble(RX_QUALITY, normalized));
enrichFieldsFromStructured(meta, fields); if (!fields.isEmpty()) {
meta.put("fields", fields);
}
Double rangeM = matchDouble(rangePattern, normalized); Double rangeM = matchDouble(rangePattern, normalized);
Double displayDbm = rssiDbm != null ? rssiDbm : txPower; Double displayDbm = rssiDbm != null ? rssiDbm : txPower;
@@ -128,57 +129,21 @@ public class StatsExtractor {
String value = trimmed.substring(colon + 1).trim(); String value = trimmed.substring(colon + 1).trim();
if (label.isEmpty() if (label.isEmpty()
|| label.equalsIgnoreCase("SEND") || label.equalsIgnoreCase("SEND")
|| label.equalsIgnoreCase("RECEIVE")) { || label.equalsIgnoreCase("RECEIVE")
|| isStructuredLabel(label)) {
continue; continue;
} }
fields.put(label, value); fields.put(label, value);
} }
} }
/** Ensure meta.fields has display lines even when line split missed some rows. */ private static boolean isStructuredLabel(String label) {
private static void enrichFieldsFromStructured( String n = label.toLowerCase(Locale.ROOT).trim();
Map<String, Object> meta, return n.equals("frequency") || n.equals("power") || n.equals("rssi")
Map<String, String> fields || n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth")
) { || n.equals("packet") || n.contains("packet number") || n.equals("payload")
putFieldIfAbsent(fields, "Frequency", meta.get("frequency_hz"), || n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
v -> v + " Hz"); || n.equals("per") || n.contains("rx quality");
putFieldIfAbsent(fields, "Power", meta.get("power_dbm"),
v -> v + " dBm");
putFieldIfAbsent(fields, "RSSI", meta.get("rssi_dbm"),
v -> String.valueOf(v));
putFieldIfAbsent(fields, "SNR", meta.get("snr_db"),
v -> String.valueOf(v));
putFieldIfAbsent(fields, "Spreading Factor", meta.get("spreading_factor"),
String::valueOf);
putFieldIfAbsent(fields, "Bandwidth", meta.get("bandwidth_khz"),
v -> v + " kHz");
Object packet = meta.get("packet");
if (packet != null) {
putFieldIfAbsent(fields, "Packet", packet, String::valueOf);
putFieldIfAbsent(fields, "Packet Number", packet, String::valueOf);
}
putFieldIfAbsent(fields, "Payload", meta.get("payload"),
v -> (String) v);
putFieldIfAbsent(fields, "On Air", meta.get("on_air_ms"),
v -> v + " ms");
putFieldIfAbsent(fields, "TX Speed", meta.get("tx_pkt_per_s"),
v -> v + " pkt/s");
putFieldIfAbsent(fields, "RX Speed", meta.get("rx_pkt_per_s"),
v -> v + " pkt/s");
putFieldIfAbsent(fields, "PER", meta.get("per_percent"),
v -> v + " %");
}
private static void putFieldIfAbsent(
Map<String, String> fields,
String label,
Object value,
java.util.function.Function<Object, String> format
) {
if (value == null || fields.containsKey(label)) {
return;
}
fields.put(label, format.apply(value));
} }
private static ExtractedStats empty(String frame) { private static ExtractedStats empty(String frame) {
@@ -74,11 +74,23 @@ public class TelnetClient {
/** /**
* Sends an AT command. Adds AT prefix and CR+LF if missing. * Sends an AT command. Adds AT prefix and CR+LF if missing.
*/ */
public SendResult sendRawLine(String line) {
byte[] wire = AtCommandFormatter.toWireBytesLiteral(line);
if (wire.length == 0) {
return SendResult.EMPTY;
}
return writeWire(wire);
}
public SendResult sendAtCommand(String command) { public SendResult sendAtCommand(String command) {
byte[] wire = AtCommandFormatter.toWireBytes(command); byte[] wire = AtCommandFormatter.toWireBytes(command);
if (wire.length == 0) { if (wire.length == 0) {
return SendResult.EMPTY; return SendResult.EMPTY;
} }
return writeWire(wire);
}
private SendResult writeWire(byte[] wire) {
Socket socket = activeSocket.get(); Socket socket = activeSocket.get();
if (socket == null || socket.isClosed()) { if (socket == null || socket.isClosed()) {
return SendResult.NOT_CONNECTED; return SendResult.NOT_CONNECTED;
@@ -29,7 +29,11 @@ public class TrackRecorder {
public interface Listener { public interface Listener {
void onStateChanged(boolean recording, int pointCount, long trackId); void onStateChanged(boolean recording, int pointCount, long trackId);
void onError(String message); void onError(String message);
default void onPointRecorded(double lat, double lon) {
}
} }
private final ServerApi serverApi; private final ServerApi serverApi;
@@ -209,6 +213,18 @@ public class TrackRecorder {
} }
totalPoints++; totalPoints++;
notifyState(); notifyState();
notifyPoint(lat, lon);
}
private void notifyPoint(double lat, double lon) {
mainHandler.post(() -> {
if (listener != null) {
listener.onPointRecorded(lat, lon);
}
if (pairedListener != null) {
pairedListener.onPointRecorded(lat, lon);
}
});
} }
private void flushBuffer() { private void flushBuffer() {
@@ -5,8 +5,10 @@ import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.Button; import android.widget.Button;
import android.widget.ScrollView; import android.widget.ScrollView;
import android.widget.Spinner;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
@@ -14,9 +16,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.button.MaterialButtonToggleGroup; import com.google.android.material.button.MaterialButtonToggleGroup;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputEditText;
import com.grigowashere.loratester.CommandPoller; import com.grigowashere.loratester.CommandPoller;
import com.grigowashere.loratester.LoraApp; import com.grigowashere.loratester.LoraApp;
@@ -24,12 +25,15 @@ import com.grigowashere.loratester.PeerDevices;
import com.grigowashere.loratester.R; import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo; import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.AtCommands; import com.grigowashere.loratester.telnet.AtCommands;
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
import com.grigowashere.loratester.telnet.RadioMacroBuilder;
import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.telnet.TelnetClient; import com.grigowashere.loratester.telnet.TelnetClient;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Locale;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -40,11 +44,21 @@ public class AtFragment extends Fragment {
private TelemetryUploader uploader; private TelemetryUploader uploader;
private CommandPoller commandPoller; private CommandPoller commandPoller;
private TextView atStatus; private TextView atStatus;
private TextView atConsole; private TextView atCurrentSnapshot;
private ScrollView atConsoleScroll; private TextInputEditText atInputFq;
private TextInputEditText atCommandInput; 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 MaterialButtonToggleGroup atTargetGroup;
private ScrollView atConsoleScroll;
private TextView atConsole;
private String lastConsole = ""; private String lastConsole = "";
private boolean consoleVisible;
private boolean formInitialized;
@Override @Override
public void onAttach(@NonNull Context context) { public void onAttach(@NonNull Context context) {
@@ -67,109 +81,99 @@ public class AtFragment extends Fragment {
@Override @Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
atStatus = view.findViewById(R.id.atStatus); atStatus = view.findViewById(R.id.atStatus);
atConsole = view.findViewById(R.id.atConsole); atCurrentSnapshot = view.findViewById(R.id.atCurrentSnapshot);
atConsoleScroll = view.findViewById(R.id.atConsoleScroll); atInputFq = view.findViewById(R.id.atInputFq);
atCommandInput = view.findViewById(R.id.atCommandInput); 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); atTargetGroup = view.findViewById(R.id.atTargetGroup);
ChipGroup chips = view.findViewById(R.id.atQuickChips); atConsoleScroll = view.findViewById(R.id.atConsoleScroll);
Button sendBtn = view.findViewById(R.id.atSendBtn); atConsole = view.findViewById(R.id.atConsole);
Button clearLog = view.findViewById(R.id.atClearLog); Button applyBtn = view.findViewById(R.id.atApplyBtn);
pollHelper = new FragmentPollHelper(this, this::refreshConsole); Button stopBtn = view.findViewById(R.id.atStopBtn);
MaterialButton consoleToggle = view.findViewById(R.id.atConsoleToggle);
pollHelper = new FragmentPollHelper(this, this::refresh);
if (atTargetGroup != null) { atTargetGroup.check(R.id.atTargetLocal);
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"}
));
addQuickChip(chips, "AT+H", AtCommands.HELP); applyBtn.setOnClickListener(v -> applyMacro());
addQuickChip(chips, "AT+TX", AtCommands.TRANSMIT); stopBtn.setOnClickListener(v -> sendLines(List.of(AtCommands.STOP)));
addQuickChip(chips, "AT+RX", AtCommands.RECEIVE); consoleToggle.setOnClickListener(v -> {
addQuickChip(chips, "AT+STATUS", AtCommands.STATUS); consoleVisible = !consoleVisible;
addQuickChip(chips, "AT", AtCommands.BASIC); atConsoleScroll.setVisibility(consoleVisible ? View.VISIBLE : View.GONE);
consoleToggle.setText(consoleVisible
sendBtn.setOnClickListener(v -> sendFromInput()); ? getString(R.string.at_console_hide)
clearLog.setOnClickListener(v -> { : getString(R.string.at_console_toggle));
if (uploader != null) {
uploader.clearConsoleLog();
}
lastConsole = "";
if (atConsole != null) {
atConsole.setText("");
}
}); });
if (atCommandInput != null) {
atCommandInput.setOnEditorActionListener((textView, actionId, event) -> {
sendFromInput();
return true;
});
}
} }
private boolean isPeerTarget() { private RadioMacroBuilder.Params buildParams() {
return atTargetGroup != null && atTargetGroup.getCheckedButtonId() == R.id.atTargetPeer; RadioMacroBuilder.Params p = new RadioMacroBuilder.Params();
Double fqMhz = parseDouble(atInputFq);
if (fqMhz != null) {
p.frequencyHz = Math.round(fqMhz * 1_000_000L);
}
p.powerDbm = parseInt(atInputPw);
p.sf = parseInt(atInputSf);
int bwPos = atBwSpinner != null ? atBwSpinner.getSelectedItemPosition() : -1;
if (bwPos >= 0 && bwPos < AtCommands.BW_KHZ.length) {
p.bwKhz = AtCommands.BW_KHZ[bwPos];
}
if (atCrSpinner != null && atCrSpinner.getSelectedItem() != null) {
p.codeRate = atCrSpinner.getSelectedItem().toString();
}
p.preamble = parseInt(atInputPl);
p.sendTimeoutMs = parseInt(atInputTm);
if (atRoleSpinner != null && atRoleSpinner.getSelectedItem() != null) {
String role = atRoleSpinner.getSelectedItem().toString();
if (!"".equals(role)) {
p.role = role;
}
}
return p;
} }
private void addQuickChip(ChipGroup group, String label, String command) { private void applyMacro() {
Chip chip = new Chip(requireContext()); sendLines(RadioMacroBuilder.apply(buildParams()));
chip.setText(label);
chip.setCheckable(false);
chip.setOnClickListener(v -> sendCommand(command));
group.addView(chip);
} }
private void sendFromInput() { private void sendLines(List<String> lines) {
if (atCommandInput == null || atCommandInput.getText() == null) {
return;
}
String cmd = atCommandInput.getText().toString().trim();
if (cmd.isEmpty()) {
return;
}
sendCommand(cmd);
atCommandInput.setText("");
}
private void sendCommand(String command) {
if (uploader == null || !isAdded()) {
return;
}
if (isPeerTarget()) { if (isPeerTarget()) {
sendToPeer(command); sendMacroToPeer(lines);
return; } else {
uploader.sendMacroSequence(lines, this::onSendResult);
} }
uploader.sendAtCommand(command, result -> {
if (!isAdded()) {
return;
}
Context ctx = getContext();
if (ctx == null) {
return;
}
if (result == TelnetClient.SendResult.NOT_CONNECTED) {
Toast.makeText(ctx, R.string.at_not_connected, Toast.LENGTH_SHORT).show();
} else if (result == TelnetClient.SendResult.IO_ERROR) {
Toast.makeText(ctx, R.string.at_send_error, Toast.LENGTH_SHORT).show();
}
updateConsoleView();
});
} }
private void sendToPeer(String command) { private void sendMacroToPeer(List<String> lines) {
if (commandPoller == null) {
return;
}
executor.execute(() -> { executor.execute(() -> {
try { try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices(); List<DeviceInfo> devices = uploader.getServerApi().getDevices();
PeerDevices.Result peer = PeerDevices.resolve( PeerDevices.Result peer = PeerDevices.resolve(devices, uploader.getDeviceId());
devices, uploader.getDeviceId());
if (!peer.ok()) { if (!peer.ok()) {
showToast(R.string.at_peer_unavailable); showToast(R.string.at_peer_unavailable);
return; return;
} }
Map<String, Object> payload = new HashMap<>(); commandPoller.postMacroToPeer(peer.peerId, lines);
payload.put("line", command);
commandPoller.postCommandToPeer(peer.peerId, "at", payload);
showToast(getString(R.string.at_sent_to_peer, peer.peerId)); showToast(getString(R.string.at_sent_to_peer, peer.peerId));
} catch (Exception e) { } catch (Exception e) {
showToast(R.string.stats_push_failed); showToast(R.string.stats_push_failed);
@@ -177,78 +181,127 @@ public class AtFragment extends Fragment {
}); });
} }
private void showToast(int resId) { private void onSendResult(TelnetClient.SendResult result) {
if (isAdded()) { if (!isAdded()) return;
requireActivity().runOnUiThread(() -> if (result == TelnetClient.SendResult.NOT_CONNECTED) {
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show()); showToast(R.string.at_not_connected);
} else if (result == TelnetClient.SendResult.IO_ERROR) {
showToast(R.string.at_send_error);
}
updateConsoleView();
}
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;
} }
} }
private void showToast(String msg) { private Double parseDouble(TextInputEditText field) {
if (isAdded()) { if (field == null || field.getText() == null) return null;
requireActivity().runOnUiThread(() -> String s = field.getText().toString().trim();
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()); if (s.isEmpty()) return null;
try {
return Double.parseDouble(s);
} catch (NumberFormatException e) {
return null;
} }
} }
private void refreshConsole() { private boolean isPeerTarget() {
if (!isAdded() || uploader == null || atStatus == null) { return atTargetGroup != null && atTargetGroup.getCheckedButtonId() == R.id.atTargetPeer;
return; }
}
boolean telnetOn = uploader.isTelnetConnected(); private void refresh() {
if (!isAdded() || uploader == null || atStatus == null) return;
atStatus.setText(getString( atStatus.setText(getString(
R.string.at_status, R.string.at_status,
telnetOn ? getString(R.string.connected) : getString(R.string.disconnected) uploader.isTelnetConnected()
? getString(R.string.connected) : getString(R.string.disconnected)
)); ));
RadioSnapshot snap = RadioSnapshot.fromExtracted(uploader.getLastStats());
atCurrentSnapshot.setText(LoraStatsFormatter.formatStatic(snap, java.util.Set.of())
+ "\n" + LoraStatsFormatter.formatDynamic(snap, java.util.Set.of()));
if (!formInitialized) {
if (snap.frequencyMhz != null && isEmpty(atInputFq)) {
atInputFq.setText(String.format(Locale.US, "%.3f", snap.frequencyMhz));
}
if (snap.powerDbm != null && isEmpty(atInputPw)) {
atInputPw.setText(String.valueOf(snap.powerDbm.intValue()));
}
if (snap.sf != null && isEmpty(atInputSf)) {
atInputSf.setText(String.valueOf(snap.sf));
}
if (snap.bwKhz != null) {
selectBw(String.valueOf(snap.bwKhz));
}
if (snap.role != null && atRoleSpinner != null) {
atRoleSpinner.setSelection(StatsExtractor.ROLE_RX.equals(snap.role) ? 2 : 1);
}
formInitialized = true;
}
updateConsoleView(); updateConsoleView();
if (pollHelper != null) { if (pollHelper != null) {
pollHelper.scheduleNext(400); pollHelper.scheduleNext(400);
} }
} }
private void updateConsoleView() { private static boolean isEmpty(TextInputEditText field) {
if (uploader == null || atConsole == null || atConsoleScroll == null) { return field == null || field.getText() == null || field.getText().length() == 0;
return; }
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(); String log = uploader.getConsoleLog();
if (!log.equals(lastConsole)) { if (!log.equals(lastConsole)) {
lastConsole = log; lastConsole = log;
atConsole.setText(log); atConsole.setText(log);
atConsoleScroll.post(() -> { if (consoleVisible) {
if (atConsoleScroll != null) { atConsoleScroll.post(() -> atConsoleScroll.fullScroll(View.FOCUS_DOWN));
atConsoleScroll.fullScroll(View.FOCUS_DOWN); }
}
});
} }
} }
private void showToast(int resId) {
if (isAdded()) Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
}
private void showToast(String msg) {
if (isAdded()) Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
}
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
if (pollHelper != null) { if (pollHelper != null) pollHelper.start(0);
pollHelper.start(0);
}
} }
@Override @Override
public void onPause() { public void onPause() {
if (pollHelper != null) { if (pollHelper != null) pollHelper.stop();
pollHelper.stop();
}
super.onPause(); super.onPause();
} }
@Override @Override
public void onDestroyView() { public void onDestroyView() {
if (pollHelper != null) { if (pollHelper != null) pollHelper.stop();
pollHelper.stop();
}
atStatus = null;
atConsole = null;
atConsoleScroll = null;
atCommandInput = null;
atTargetGroup = null;
pollHelper = null; pollHelper = null;
formInitialized = false;
super.onDestroyView(); super.onDestroyView();
} }
@@ -1,8 +1,14 @@
package com.grigowashere.loratester.ui; package com.grigowashere.loratester.ui;
import android.graphics.Color;
import android.os.Handler;
import android.os.Looper;
import android.view.Gravity;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -14,17 +20,37 @@ import com.grigowashere.loratester.api.ChatMessage;
import java.text.DateFormat; import java.text.DateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set;
public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> { public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
private static final int COLOR_SELF_BG = 0xFF16213E;
private static final int COLOR_OTHER_BG = 0xFF1A4A6E;
private static final int COLOR_NEW_HIGHLIGHT = 0x33E94560;
private final List<ChatMessage> messages = new ArrayList<>(); private final List<ChatMessage> messages = new ArrayList<>();
private final DateFormat timeFormat = private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()); DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
private final Handler handler = new Handler(Looper.getMainLooper());
private final Set<Integer> highlightedPositions = new HashSet<>();
private String selfDeviceId;
private double lastSeenTs;
public void setSelfDeviceId(String selfDeviceId) {
this.selfDeviceId = selfDeviceId;
}
public void setLastSeenTs(double lastSeenTs) {
this.lastSeenTs = lastSeenTs;
}
public void setMessages(List<ChatMessage> newMessages) { public void setMessages(List<ChatMessage> newMessages) {
messages.clear(); messages.clear();
highlightedPositions.clear();
messages.addAll(newMessages); messages.addAll(newMessages);
notifyDataSetChanged(); notifyDataSetChanged();
} }
@@ -35,7 +61,26 @@ public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
} }
int start = messages.size(); int start = messages.size();
messages.addAll(more); messages.addAll(more);
for (int i = 0; i < more.size(); i++) {
if (more.get(i).ts > lastSeenTs) {
highlightedPositions.add(start + i);
}
}
notifyItemRangeInserted(start, more.size()); notifyItemRangeInserted(start, more.size());
for (int i = 0; i < more.size(); i++) {
int pos = start + i;
if (highlightedPositions.contains(pos)) {
scheduleClearHighlight(pos);
}
}
}
private void scheduleClearHighlight(int position) {
handler.postDelayed(() -> {
if (highlightedPositions.remove(position)) {
notifyItemChanged(position);
}
}, 3000);
} }
public double lastTs() { public double lastTs() {
@@ -56,8 +101,42 @@ public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
@Override @Override
public void onBindViewHolder(@NonNull Holder holder, int position) { public void onBindViewHolder(@NonNull Holder holder, int position) {
ChatMessage m = messages.get(position); ChatMessage m = messages.get(position);
boolean self = selfDeviceId != null && selfDeviceId.equals(m.device_id);
String time = timeFormat.format(new Date((long) (m.ts * 1000))); String time = timeFormat.format(new Date((long) (m.ts * 1000)));
holder.text.setText(time + " " + m.device_id + ": " + m.text);
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) holder.bubble.getLayoutParams();
lp.gravity = self ? Gravity.END : Gravity.START;
holder.bubble.setLayoutParams(lp);
holder.bubble.setBackgroundColor(self ? COLOR_SELF_BG : COLOR_OTHER_BG);
holder.author.setText(self
? holder.itemView.getContext().getString(R.string.chat_self_label)
: m.device_id);
holder.text.setText(m.text);
holder.time.setText(time);
int bg = self ? COLOR_SELF_BG : COLOR_OTHER_BG;
if (highlightedPositions.contains(position)) {
holder.bubble.setBackgroundColor(blend(bg, COLOR_NEW_HIGHLIGHT));
} else {
holder.bubble.setBackgroundColor(bg);
}
}
private static int blend(int base, int overlay) {
int ba = Color.alpha(base);
int br = Color.red(base);
int bg = Color.green(base);
int bb = Color.blue(base);
int oa = Color.alpha(overlay);
int or = Color.red(overlay);
int og = Color.green(overlay);
int ob = Color.blue(overlay);
float ratio = oa / 255f;
int r = (int) (br * (1 - ratio) + or * ratio);
int g = (int) (bg * (1 - ratio) + og * ratio);
int b = (int) (bb * (1 - ratio) + ob * ratio);
return Color.argb(ba, r, g, b);
} }
@Override @Override
@@ -66,11 +145,17 @@ public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
} }
static class Holder extends RecyclerView.ViewHolder { static class Holder extends RecyclerView.ViewHolder {
final LinearLayout bubble;
final TextView author;
final TextView text; final TextView text;
final TextView time;
Holder(@NonNull View itemView) { Holder(@NonNull View itemView) {
super(itemView); super(itemView);
text = itemView.findViewById(R.id.chatItemText); bubble = itemView.findViewById(R.id.chatBubble);
author = itemView.findViewById(R.id.chatAuthor);
text = itemView.findViewById(R.id.chatText);
time = itemView.findViewById(R.id.chatTime);
} }
} }
} }
@@ -66,6 +66,9 @@ public class ChatFragment extends Fragment {
View inputBar = view.findViewById(R.id.chatInputBar); View inputBar = view.findViewById(R.id.chatInputBar);
adapter = new ChatAdapter(); adapter = new ChatAdapter();
if (uploader != null) {
adapter.setSelfDeviceId(uploader.getDeviceId());
}
LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext()); LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
recycler.setLayoutManager(layoutManager); recycler.setLayoutManager(layoutManager);
recycler.setAdapter(adapter); recycler.setAdapter(adapter);
@@ -161,6 +164,10 @@ public class ChatFragment extends Fragment {
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
if (adapter != null && uploader != null) {
adapter.setSelfDeviceId(uploader.getDeviceId());
adapter.setLastSeenTs(chatSince);
}
if (pollHelper != null) { if (pollHelper != null) {
pollHelper.start(0); pollHelper.start(0);
} }
@@ -0,0 +1,39 @@
package com.grigowashere.loratester.ui;
/** Diverging color ramp: blue = below, green = level, brown = above. */
final class ElevationColorRamp {
private static final int ALPHA = 0x8C;
private ElevationColorRamp() {
}
static int deltaToArgb(double deltaM) {
if (deltaM <= -8.0) {
return argb(0x1A, 0x4A, 0x8C);
}
if (deltaM <= -2.0) {
return lerp(argb(0x1A, 0x4A, 0x8C), argb(0x4F, 0xC3, 0xF7), (deltaM + 8.0) / 6.0);
}
if (deltaM <= 2.0) {
return lerp(argb(0x4F, 0xC3, 0xF7), argb(0x00, 0xFF, 0x88), (deltaM + 2.0) / 4.0);
}
if (deltaM <= 8.0) {
return lerp(argb(0x00, 0xFF, 0x88), argb(0xFF, 0xC1, 0x07), (deltaM - 2.0) / 6.0);
}
return argb(0x8B, 0x5A, 0x2B);
}
private static int argb(int r, int g, int b) {
return (ALPHA << 24) | (r << 16) | (g << 8) | b;
}
private static int lerp(int from, int to, double t) {
t = Math.max(0.0, Math.min(1.0, t));
int a = (int) Math.round(((from >>> 24) & 0xFF) + t * (((to >>> 24) & 0xFF) - ((from >>> 24) & 0xFF)));
int r = (int) Math.round(((from >> 16) & 0xFF) + t * (((to >> 16) & 0xFF) - ((from >> 16) & 0xFF)));
int g = (int) Math.round(((from >> 8) & 0xFF) + t * (((to >> 8) & 0xFF) - ((from >> 8) & 0xFF)));
int b = (int) Math.round((from & 0xFF) + t * ((to & 0xFF) - (from & 0xFF)));
return (a << 24) | (r << 16) | (g << 8) | b;
}
}
@@ -0,0 +1,100 @@
package com.grigowashere.loratester.ui;
import android.graphics.Bitmap;
import androidx.annotation.Nullable;
import com.grigowashere.loratester.api.ElevationGridResult;
import org.mapsforge.core.model.LatLong;
import java.util.HashMap;
import java.util.Map;
/** Builds a geo-referenced raster from elevation grid API response. */
final class ElevationHeatmapBitmap {
static final class Raster {
final Bitmap bitmap;
final LatLong northWest;
final LatLong southEast;
Raster(Bitmap bitmap, LatLong northWest, LatLong southEast) {
this.bitmap = bitmap;
this.northWest = northWest;
this.southEast = southEast;
}
}
private ElevationHeatmapBitmap() {
}
@Nullable
static Raster build(ElevationGridResult grid) {
if (grid == null || !grid.ok || grid.points == null || grid.points.isEmpty()) {
return null;
}
int minI = Integer.MAX_VALUE;
int maxI = Integer.MIN_VALUE;
int minJ = Integer.MAX_VALUE;
int maxJ = Integer.MIN_VALUE;
double minLat = Double.MAX_VALUE;
double maxLat = -Double.MAX_VALUE;
double minLon = Double.MAX_VALUE;
double maxLon = -Double.MAX_VALUE;
Map<Long, ElevationGridResult.GridPoint> byIndex = new HashMap<>();
for (ElevationGridResult.GridPoint p : grid.points) {
minI = Math.min(minI, p.i);
maxI = Math.max(maxI, p.i);
minJ = Math.min(minJ, p.j);
maxJ = Math.max(maxJ, p.j);
minLat = Math.min(minLat, p.lat);
maxLat = Math.max(maxLat, p.lat);
minLon = Math.min(minLon, p.lon);
maxLon = Math.max(maxLon, p.lon);
byIndex.put(pack(p.i, p.j), p);
}
int cols = maxJ - minJ + 1;
int rows = maxI - minI + 1;
if (cols < 1 || rows < 1) {
return null;
}
int[] pixels = new int[cols * rows];
double radiusM = grid.radius_m > 0 ? grid.radius_m : 200.0;
double stepM = grid.step_m > 0 ? grid.step_m : 15.0;
for (int row = 0; row < rows; row++) {
int i = maxI - row;
for (int col = 0; col < cols; col++) {
int j = minJ + col;
double dist = Math.hypot(i * stepM, j * stepM);
if (dist > radiusM + stepM * 0.5) {
continue;
}
ElevationGridResult.GridPoint p = byIndex.get(pack(i, j));
if (p != null && p.elevation_m != null) {
pixels[row * cols + col] = ElevationColorRamp.deltaToArgb(p.delta_m);
}
}
}
Bitmap bitmap = Bitmap.createBitmap(cols, rows, Bitmap.Config.ARGB_8888);
bitmap.setPixels(pixels, 0, cols, 0, 0, cols, rows);
double halfStepLat = (stepM / 2.0) / 111_320.0;
double halfStepLon = (stepM / 2.0)
/ (111_320.0 * Math.max(Math.cos(Math.toRadians((minLat + maxLat) / 2.0)), 1e-6));
LatLong northWest = new LatLong(maxLat + halfStepLat, minLon - halfStepLon);
LatLong southEast = new LatLong(minLat - halfStepLat, maxLon + halfStepLon);
return new Raster(bitmap, northWest, southEast);
}
private static long pack(int i, int j) {
return ((long) i << 32) ^ (j & 0xFFFFFFFFL);
}
}
@@ -0,0 +1,85 @@
package com.grigowashere.loratester.ui;
import android.graphics.Bitmap;
import com.grigowashere.loratester.api.ElevationGridResult;
import org.mapsforge.core.graphics.Canvas;
import org.mapsforge.core.model.BoundingBox;
import org.mapsforge.core.model.LatLong;
import org.mapsforge.core.model.Point;
import org.mapsforge.core.util.MercatorProjection;
import org.mapsforge.map.android.graphics.AndroidBitmap;
import org.mapsforge.map.layer.Layer;
/** Geo-referenced elevation heatmap overlay for Mapsforge. */
public class ElevationHeatmapLayer extends Layer {
private static final int TILE_SIZE = 256;
private Bitmap sourceBitmap;
private org.mapsforge.core.graphics.Bitmap mapsforgeBitmap;
private LatLong northWest;
private LatLong southEast;
public void setGrid(ElevationGridResult grid) {
sourceBitmap = null;
mapsforgeBitmap = null;
ElevationHeatmapBitmap.Raster raster = ElevationHeatmapBitmap.build(grid);
if (raster == null) {
northWest = null;
southEast = null;
return;
}
sourceBitmap = raster.bitmap;
northWest = raster.northWest;
southEast = raster.southEast;
}
public boolean hasData() {
return sourceBitmap != null && northWest != null && southEast != null;
}
@Override
public void draw(BoundingBox boundingBox, byte zoomLevel, Canvas canvas, Point topLeftPoint) {
if (!hasData()) {
return;
}
BoundingBox rasterBox = new BoundingBox(
southEast.latitude,
northWest.longitude,
northWest.latitude,
southEast.longitude
);
if (!boundingBox.intersects(rasterBox)) {
return;
}
long mapSize = MercatorProjection.getMapSize(zoomLevel, TILE_SIZE);
int left = (int) Math.round(
MercatorProjection.longitudeToPixelX(northWest.longitude, mapSize) - topLeftPoint.x);
int top = (int) Math.round(
MercatorProjection.latitudeToPixelY(northWest.latitude, mapSize) - topLeftPoint.y);
int right = (int) Math.round(
MercatorProjection.longitudeToPixelX(southEast.longitude, mapSize) - topLeftPoint.x);
int bottom = (int) Math.round(
MercatorProjection.latitudeToPixelY(southEast.latitude, mapSize) - topLeftPoint.y);
int width = right - left;
int height = bottom - top;
if (width <= 0 || height <= 0) {
return;
}
if (mapsforgeBitmap == null) {
mapsforgeBitmap = new AndroidBitmap(sourceBitmap);
}
canvas.drawBitmap(
mapsforgeBitmap,
0, 0, sourceBitmap.getWidth(), sourceBitmap.getHeight(),
left, top, right, bottom);
}
}
@@ -0,0 +1,125 @@
package com.grigowashere.loratester.ui;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
import com.grigowashere.loratester.R;
/** Color scale for elevation heatmap (relative to GPS center). */
public class ElevationHeatmapLegendView extends View {
private static final double DELTA_MIN = -10.0;
private static final double DELTA_MAX = 10.0;
private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF barRect = new RectF();
private final RectF bgRect = new RectF();
public ElevationHeatmapLegendView(Context context) {
super(context);
init();
}
public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
bgPaint.setColor(0xCC0F3460);
labelPaint.setColor(0xFFEEEEEE);
labelPaint.setTextSize(sp(9));
titlePaint.setColor(0xFFFFFFFF);
titlePaint.setTextSize(sp(9));
titlePaint.setFakeBoldText(true);
setLayerType(LAYER_TYPE_SOFTWARE, null);
}
private float sp(float value) {
return value * getResources().getDisplayMetrics().scaledDensity;
}
private float dp(float value) {
return value * getResources().getDisplayMetrics().density;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int w = (int) dp(118);
int h = (int) dp(118);
setMeasuredDimension(
resolveSize(w, widthMeasureSpec),
resolveSize(h, heightMeasureSpec));
}
@Override
protected void onDraw(Canvas canvas) {
float pad = dp(6);
bgRect.set(pad, pad, getWidth() - pad, getHeight() - pad);
canvas.drawRoundRect(bgRect, dp(4), dp(4), bgPaint);
float titleY = bgRect.top + dp(12);
canvas.drawText(getContext().getString(R.string.map_heatmap_legend_title),
bgRect.left + dp(6), titleY, titlePaint);
float barLeft = bgRect.left + dp(8);
float barTop = titleY + dp(6);
float barBottom = bgRect.bottom - dp(8);
float barRight = barLeft + dp(14);
barRect.set(barLeft, barTop, barRight, barBottom);
int[] colors = sampleRampColors(24);
float[] positions = new float[colors.length];
for (int i = 0; i < colors.length; i++) {
positions[i] = i / (float) (colors.length - 1);
}
barPaint.setShader(new LinearGradient(
barRect.left, barRect.top, barRect.left, barRect.bottom,
colors, positions, Shader.TileMode.CLAMP));
canvas.drawRoundRect(barRect, dp(2), dp(2), barPaint);
barPaint.setShader(null);
float textX = barRect.right + dp(6);
drawLegendRow(canvas, textX, barRect.top + dp(4),
getContext().getString(R.string.map_heatmap_legend_high),
"+8 m");
drawLegendRow(canvas, textX, (barRect.top + barRect.bottom) / 2f,
getContext().getString(R.string.map_heatmap_legend_level),
"0 m");
drawLegendRow(canvas, textX, barRect.bottom - dp(4),
getContext().getString(R.string.map_heatmap_legend_low),
"-8 m");
}
private void drawLegendRow(Canvas canvas, float x, float centerY, String label, String meters) {
float lineH = labelPaint.getTextSize();
canvas.drawText(label, x, centerY - dp(1), labelPaint);
canvas.drawText(meters, x, centerY + lineH - dp(2), labelPaint);
}
private static int[] sampleRampColors(int steps) {
int[] colors = new int[steps];
for (int i = 0; i < steps; i++) {
double t = i / (double) (steps - 1);
double delta = DELTA_MAX + t * (DELTA_MIN - DELTA_MAX);
colors[i] = ElevationColorRamp.deltaToArgb(delta) | 0xFF000000;
}
return colors;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,189 @@
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, "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", fmtSuffixInt(tx.bwKhz, " kHz"), fmtSuffixInt(rx.bwKhz, " kHz"), "bw", changedTx, changedRx);
addRow(table, "Мощн.", fmtDbm(tx.powerDbm), fmtDbm(rx.powerDbm), "power", 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 fmtSuffixInt(Integer v, String suffix) {
return v != null ? v + suffix : "";
}
}
@@ -23,15 +23,11 @@ import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo; import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.api.TelemetryHistoryItem; import com.grigowashere.loratester.api.TelemetryHistoryItem;
import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.telnet.StatsExtractor;
import java.text.DateFormat;
import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -41,32 +37,25 @@ public class StatsFragment extends Fragment {
private static final long SERVER_POLL_MS = 1000; private static final long SERVER_POLL_MS = 1000;
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault());
private FragmentPollHelper pollHelper; private FragmentPollHelper pollHelper;
private TelemetryUploader uploader; private TelemetryUploader uploader;
private CommandPoller commandPoller; private CommandPoller commandPoller;
private PeerStatsCache peerStatsCache; private PeerStatsCache peerStatsCache;
private TextView statsStatus; private TextView statsStatus;
private TextView statsPeerWarning; private TextView statsPeerWarning;
private TextView statsLocalDetails; private RadioComparePanel radioComparePanel;
private TextView statsPeerDetails;
private RecyclerView statsHistoryList; private RecyclerView statsHistoryList;
private final HistoryAdapter historyAdapter = new HistoryAdapter(); private final HistoryAdapter historyAdapter = new HistoryAdapter();
private StatsExtractor.ExtractedStats cachedLocal; private RadioSnapshot prevLocal = RadioSnapshot.empty();
private DeviceInfo cachedServer; private RadioSnapshot prevPeer = RadioSnapshot.empty();
private DeviceInfo cachedPeer; private RadioSnapshot snapLocal = RadioSnapshot.empty();
private int cachedDeviceCount; private RadioSnapshot snapPeer = RadioSnapshot.empty();
private String cachedPeerId; private String cachedPeerId;
private String cachedPeerError; private String cachedPeerError;
private String cachedError; private int cachedDeviceCount;
private final TelemetryUploader.StatsListener statsListener = stats -> { private final TelemetryUploader.StatsListener statsListener = stats -> postRender();
cachedLocal = stats;
cachedError = null;
postRender();
};
@Override @Override
public void onAttach(@NonNull Context context) { public void onAttach(@NonNull Context context) {
@@ -91,15 +80,12 @@ public class StatsFragment extends Fragment {
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
statsStatus = view.findViewById(R.id.statsStatus); statsStatus = view.findViewById(R.id.statsStatus);
statsPeerWarning = view.findViewById(R.id.statsPeerWarning); statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
statsLocalDetails = view.findViewById(R.id.statsLocalDetails); radioComparePanel = view.findViewById(R.id.radioComparePanel);
statsPeerDetails = view.findViewById(R.id.statsPeerDetails);
statsHistoryList = view.findViewById(R.id.statsHistoryList); statsHistoryList = view.findViewById(R.id.statsHistoryList);
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext())); statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
statsHistoryList.setAdapter(historyAdapter); statsHistoryList.setAdapter(historyAdapter);
Button btnSimulate = view.findViewById(R.id.btnSimulate); Button btnSimulate = view.findViewById(R.id.btnSimulate);
Button btnPushStats = view.findViewById(R.id.btnPushStats); Button btnPushStats = view.findViewById(R.id.btnPushStats);
Button btnModeTx = view.findViewById(R.id.btnModeTxPeer);
Button btnModeRx = view.findViewById(R.id.btnModeRxPeer);
pollHelper = new FragmentPollHelper(this, this::refresh); pollHelper = new FragmentPollHelper(this, this::refresh);
btnSimulate.setOnClickListener(v -> { btnSimulate.setOnClickListener(v -> {
@@ -107,18 +93,14 @@ public class StatsFragment extends Fragment {
SEND SEND
Frequency: 433000000 Hz Frequency: 433000000 Hz
Power: 22 dBm Power: 22 dBm
Spreading Factor: 7
Bandwidth: 125 kHz
Packet: 1 Packet: 1
Payload: Sim TX Payload: Sim TX
\u001b[2J"""; \u001b[2J""";
uploader.simulateChunk(chunk); uploader.simulateChunk(chunk);
if (pollHelper.canRun()) {
statsStatus.setText(R.string.simulate_sent);
}
}); });
btnPushStats.setOnClickListener(v -> pushStatsToPeer()); btnPushStats.setOnClickListener(v -> pushStatsToPeer());
btnModeTx.setOnClickListener(v -> sendModeToPeer("TX"));
btnModeRx.setOnClickListener(v -> sendModeToPeer("RX"));
} }
private void pushStatsToPeer() { private void pushStatsToPeer() {
@@ -127,34 +109,18 @@ public class StatsFragment extends Fragment {
return; return;
} }
Map<String, Object> payload = new HashMap<>(); Map<String, Object> payload = new HashMap<>();
String meta = pickMetaJson(true); StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
if (meta != null) { if (localStats != null && localStats.metaJson != null) {
payload.put("meta", meta); payload.put("meta", localStats.metaJson);
}
Double rssi = pickRssi(true);
if (rssi != null) {
payload.put("rssi", rssi);
}
if (cachedLocal != null && cachedLocal.role != null) {
payload.put("role", cachedLocal.role);
} else if (cachedServer != null && cachedServer.role != null) {
payload.put("role", cachedServer.role);
} }
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); commandPoller.postCommandToPeer(cachedPeerId, "stats_push", payload);
toast(R.string.stats_pushed); toast(R.string.stats_pushed);
} }
private void sendModeToPeer(String role) {
if (commandPoller == null || cachedPeerId == null) {
toast(R.string.at_peer_unavailable);
return;
}
Map<String, Object> payload = new HashMap<>();
payload.put("role", role);
commandPoller.postCommandToPeer(cachedPeerId, "mode", payload);
toast(R.string.stats_pushed);
}
private void toast(int resId) { private void toast(int resId) {
if (isAdded()) { if (isAdded()) {
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show(); Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
@@ -166,7 +132,6 @@ public class StatsFragment extends Fragment {
super.onResume(); super.onResume();
if (uploader != null) { if (uploader != null) {
uploader.setStatsListener(statsListener); uploader.setStatsListener(statsListener);
cachedLocal = uploader.getLastStats();
postRender(); postRender();
} }
if (pollHelper != null) { if (pollHelper != null) {
@@ -192,8 +157,7 @@ public class StatsFragment extends Fragment {
} }
statsStatus = null; statsStatus = null;
statsPeerWarning = null; statsPeerWarning = null;
statsLocalDetails = null; radioComparePanel = null;
statsPeerDetails = null;
statsHistoryList = null; statsHistoryList = null;
pollHelper = null; pollHelper = null;
super.onDestroyView(); super.onDestroyView();
@@ -206,10 +170,10 @@ public class StatsFragment extends Fragment {
} }
private void postRender() { private void postRender() {
if (!isAdded() || statsLocalDetails == null) { if (!isAdded() || radioComparePanel == null) {
return; return;
} }
requireActivity().runOnUiThread(this::renderDetails); requireActivity().runOnUiThread(this::render);
} }
private void refresh() { private void refresh() {
@@ -217,11 +181,11 @@ public class StatsFragment extends Fragment {
return; return;
} }
String deviceId = uploader.getDeviceId(); String deviceId = uploader.getDeviceId();
boolean telnet = uploader.isTelnetConnected();
statsStatus.setText(getString( statsStatus.setText(getString(
R.string.stats_status, R.string.stats_status,
deviceId, deviceId,
telnet ? getString(R.string.connected) : getString(R.string.disconnected) uploader.isTelnetConnected()
? getString(R.string.connected) : getString(R.string.disconnected)
)); ));
executor.execute(() -> { executor.execute(() -> {
@@ -232,19 +196,37 @@ public class StatsFragment extends Fragment {
PeerDevices.Result peer = PeerDevices.resolve(devices, deviceId); PeerDevices.Result peer = PeerDevices.resolve(devices, deviceId);
cachedPeerId = peer.peerId; cachedPeerId = peer.peerId;
cachedPeerError = peer.error; cachedPeerError = peer.error;
cachedPeer = null;
cachedServer = null; DeviceInfo self = null;
DeviceInfo peerDev = null;
for (DeviceInfo d : devices) { for (DeviceInfo d : devices) {
if (deviceId.equals(d.device_id)) { if (deviceId.equals(d.device_id)) {
cachedServer = d; self = d;
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) { } else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
cachedPeer = d; peerDev = d;
} }
} }
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); history = uploader.getServerApi().getTelemetryHistory(deviceId, 30);
} catch (Exception e) { } catch (Exception ignored) {
cachedError = e.getMessage() != null ? e.getMessage() : "error";
} }
List<TelemetryHistoryItem> finalHistory = history; List<TelemetryHistoryItem> finalHistory = history;
if (isAdded()) { if (isAdded()) {
@@ -261,11 +243,10 @@ public class StatsFragment extends Fragment {
}); });
} }
private void renderDetails() { private void render() {
if (!isAdded() || statsLocalDetails == null || uploader == null) { if (!isAdded() || radioComparePanel == null || uploader == null) {
return; return;
} }
if (statsPeerWarning != null) { if (statsPeerWarning != null) {
if (cachedPeerError != null) { if (cachedPeerError != null) {
statsPeerWarning.setVisibility(View.VISIBLE); statsPeerWarning.setVisibility(View.VISIBLE);
@@ -275,105 +256,18 @@ public class StatsFragment extends Fragment {
statsPeerWarning.setVisibility(View.GONE); statsPeerWarning.setVisibility(View.GONE);
} }
} }
var chLocal = snapLocal.diff(prevLocal);
statsLocalDetails.setText(formatDeviceBlock(true)); var chPeer = snapPeer.diff(prevPeer);
statsPeerDetails.setText(formatDeviceBlock(false)); RadioComparePanel.bindByRole(
} radioComparePanel,
snapLocal,
private CharSequence formatDeviceBlock(boolean local) { snapPeer,
StringBuilder sb = new StringBuilder(); uploader.getDeviceId(),
if (local) { cachedPeerId,
sb.append(uploader.isTelnetConnected() chLocal,
? getString(R.string.telnet_connected) chPeer
: getString(R.string.telnet_disconnected)); );
long at = uploader.getLastStatsAtMs(); prevLocal = snapLocal;
if (at > 0) { prevPeer = snapPeer;
sb.append(" · ").append(timeFormat.format(new Date(at)));
}
sb.append("\n\n");
appendStatsBody(sb, pickMetaJson(true), pickRssi(true), cachedServer, true);
} else {
if (cachedPeerId == null) {
sb.append(getString(R.string.stats_peer_absent));
return sb;
}
sb.append(cachedPeerId);
if (cachedPeer != null && cachedPeer.role != null) {
sb.append(" · ").append(cachedPeer.role);
}
sb.append("\n\n");
PeerStatsCache.Snapshot push = peerStatsCache != null ? peerStatsCache.get() : null;
if (push != null && push.meta != null) {
appendStatsBody(sb, push.meta, push.rssi, cachedPeer, false);
} else if (cachedPeer != null) {
appendStatsBody(sb, cachedPeer.meta, cachedPeer.rssi, cachedPeer, false);
} else {
sb.append(getString(R.string.no_telemetry_yet));
}
}
return sb;
}
private void appendStatsBody(
StringBuilder sb,
String meta,
Double rssi,
DeviceInfo gpsSource,
boolean local
) {
if (meta != null && !meta.isEmpty()) {
String fields = LoraStatsFormatter.formatMeta(meta);
if (!fields.isEmpty()) {
sb.append(fields).append("\n");
}
} else if (local && cachedError != null) {
sb.append(getString(R.string.stats_error, cachedError)).append("\n");
} else if (local) {
sb.append(getString(R.string.no_telemetry_yet)).append("\n");
}
sb.append("\nСигнал (dBm): ").append(rssi != null ? rssi : "").append("\n");
Double lat = null;
Double lon = null;
if (gpsSource != null) {
lat = gpsSource.lat;
lon = gpsSource.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");
}
}
private String pickMetaJson(boolean local) {
if (local) {
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 local) {
if (local) {
boolean telnet = uploader.isTelnetConnected();
if (telnet && cachedLocal != null && cachedLocal.rssi != null) {
return cachedLocal.rssi;
}
if (cachedServer != null && cachedServer.rssi != null) {
return cachedServer.rssi;
}
if (cachedLocal != null) {
return cachedLocal.rssi;
}
}
return null;
} }
} }
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#00FF88" android:state_activated="true" />
<item android:color="#FFFFFF" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#CC0F3460" />
<corners android:radius="12dp" />
</shape>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,17h2v-3H3v3zM3,12h2V9H3v3zM7,17h2v-5H7v5zM7,7h2V5H7v2zM11,17h2V7h-2v10zM15,17h2v-3h-2v3zM15,12h2V9h-2v3zM19,17h2v-7h-2v7zM19,8h2V5h-2v3z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M14,6l-3.75,5 2.75,3.5L9,18H3l8.5,-10.5L14,6zM17.5,10.5L14,15h6l-2.5,-4.5z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,3C7.03,3 3,7.03 3,12h2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7h2c0,-4.97 -4.03,-9 -9,-9zM12,7c-2.76,0 -5,2.24 -5,5h2c0,-1.66 1.34,-3 3,-3s3,1.34 3,3h2c0,-2.76 -2.24,-5 -5,-5zM12,11c-0.55,0 -1,0.45 -1,1h2c0,-0.55 -0.45,-1 -1,-1zM4.5,14.5L2,17l2.5,2.5 1.4,-1.4L4.8,17l1.1,-1.1 -1.4,-1.4zM19.5,14.5l-1.4,1.4 1.1,1.1 -1.1,1.1 1.4,1.4L22,17l-2.5,-2.5z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M4,18h16v2H4v-2zM6,15h2v2H6v-2zM16,15h2v2h-2v-2zM8,12h8v2H8v-2zM10,9h4v2h-4V9zM12,6c-2.2,0 -4,1.8 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2h2c0,-2.2 -1.8,-4 -4,-4z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,14c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,20c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5V19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45V19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>
+187 -68
View File
@@ -12,90 +12,209 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="14sp" /> android:textSize="14sp" />
<com.google.android.material.button.MaterialButtonToggleGroup <ScrollView
android:id="@+id/atTargetGroup"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="0dp"
android:layout_marginTop="6dp" android:layout_weight="1"
app:singleSelection="true" android:fillViewport="true">
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton <LinearLayout
android:id="@+id/atTargetLocal" android:layout_width="match_parent"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:orientation="vertical">
android:text="@string/at_target_local"
android:checked="true" />
<com.google.android.material.button.MaterialButton <TextView
android:id="@+id/atTargetPeer" android:layout_width="match_parent"
style="@style/Widget.Material3.Button.OutlinedButton" android:layout_height="wrap_content"
android:layout_width="0dp" android:layout_marginTop="8dp"
android:layout_height="wrap_content" android:text="@string/at_current_values"
android:layout_weight="1" android:textStyle="bold" />
android:text="@string/at_target_peer" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<com.google.android.material.chip.ChipGroup <TextView
android:id="@+id/atQuickChips" android:id="@+id/atCurrentSnapshot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/at_command_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atCommandInput"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:fontFamily="monospace" android:fontFamily="monospace"
android:inputType="text" android:textSize="11sp" />
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<Button <com.google.android.material.textfield.TextInputLayout
android:id="@+id/atSendBtn" android:layout_width="match_parent"
android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="8dp"
android:layout_gravity="center_vertical" android:hint="@string/at_hint_fq_mhz">
android:layout_marginStart="8dp"
android:text="@string/send" />
</LinearLayout>
<LinearLayout <com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent" 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_hint_power">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputPw"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberSigned" />
</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="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/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:layout_height="wrap_content"
android:layout_marginTop="4dp" android:text="@string/at_console_toggle" />
android:orientation="horizontal">
<Button
android:id="@+id/atClearLog"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/at_clear_log" />
</LinearLayout>
<ScrollView <ScrollView
android:id="@+id/atConsoleScroll" android:id="@+id/atConsoleScroll"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="120dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:background="#0D1117" android:background="#0D1117"
android:padding="8dp"> android:padding="8dp"
android:visibility="gone">
<TextView <TextView
android:id="@+id/atConsole" android:id="@+id/atConsole"
+248 -34
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@@ -8,57 +9,265 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<LinearLayout
android:id="@+id/mapStatusChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="56dp"
android:background="@drawable/bg_map_panel"
android:elevation="6dp"
android:orientation="vertical"
android:paddingStart="8dp"
android:paddingTop="6dp"
android:paddingEnd="8dp"
android:paddingBottom="6dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iconServer"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/status_server"
android:src="@drawable/ic_link_server" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="3dp"
android:text="@string/status_server_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
<ImageView
android:id="@+id/iconLora"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/status_lora"
android:src="@drawable/ic_link_lora" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="3dp"
android:text="@string/status_lora_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
</LinearLayout>
<TextView
android:id="@+id/mapStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:maxLines="2"
android:textColor="#FFFFFF"
android:textSize="10sp" />
<TextView
android:id="@+id/mapDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#00FF88"
android:textSize="9sp"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
android:id="@+id/mapToolRail"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="6dp"
android:background="@drawable/bg_map_panel"
android:elevation="6dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageButton
android:id="@+id/btnToolCenter"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_center"
android:src="@drawable/ic_center"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnFindHill"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_find_hill"
android:src="@drawable/ic_hill"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnHeatmap"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_heatmap"
android:src="@drawable/ic_heatmap"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnTrack"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_track"
android:src="@drawable/ic_track"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnPairedTrack"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_paired"
android:src="@drawable/ic_paired_track"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnToolMore"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_more"
android:src="@drawable/ic_more_vert"
app:tint="@color/map_tool_icon_tint" />
</LinearLayout>
<ScrollView <ScrollView
android:id="@+id/mapSidePanel" android:id="@+id/mapToolDrawer"
android:layout_width="152dp" android:layout_width="148dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="end|top" android:layout_gravity="end|top"
android:layout_margin="6dp" android:layout_marginTop="8dp"
android:background="#CC0F3460" android:layout_marginEnd="56dp"
android:elevation="4dp" android:background="@drawable/bg_map_panel"
android:fillViewport="false" android:elevation="6dp"
android:scrollbars="none"> android:scrollbars="none"
android:visibility="gone">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:padding="6dp"> android:padding="8dp">
<TextView <TextView
android:id="@+id/mapStatus"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="#FFFFFF" android:text="@string/map_center_mode"
android:textSize="10sp" /> 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_heatmap_radius"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/mapHeatmapRadius"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/mapHeatmapStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#AAAAAA"
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapHillStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#FFC107"
android:textSize="9sp"
android:visibility="gone" />
<TextView <TextView
android:id="@+id/mapLegend" android:id="@+id/mapLegend"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:layout_marginTop="6dp"
android:text="@string/map_legend" android:text="@string/map_legend"
android:textColor="#CCCCCC" android:textColor="#CCCCCC"
android:textSize="9sp" /> android:textSize="9sp" />
<Button <Spinner
android:id="@+id/btnTrack" android:id="@+id/trackSpinner"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="6dp" />
android:minHeight="36dp"
android:text="@string/track_start"
android:textSize="11sp" />
<Button
android:id="@+id/btnPairedTrack"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:minHeight="36dp"
android:text="@string/track_paired_start"
android:textSize="11sp" />
<TextView <TextView
android:id="@+id/trackStatus" android:id="@+id/trackStatus"
@@ -67,13 +276,18 @@
android:layout_marginTop="2dp" android:layout_marginTop="2dp"
android:textColor="#CCCCCC" android:textColor="#CCCCCC"
android:textSize="9sp" /> android:textSize="9sp" />
<Spinner
android:id="@+id/trackSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
<com.grigowashere.loratester.ui.ElevationHeatmapLegendView
android:id="@+id/mapHeatmapLegend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="10dp"
android:elevation="4dp"
android:visibility="gone" />
</FrameLayout> </FrameLayout>
+14 -84
View File
@@ -24,6 +24,20 @@
android:textSize="12sp" android:textSize="12sp"
android:visibility="gone" /> android:visibility="gone" />
<com.grigowashere.loratester.ui.RadioComparePanel
android:id="@+id/radioComparePanel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp" />
<Button
android:id="@+id/btnPushStats"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/stats_push_peer" />
<Button <Button
android:id="@+id/btnSimulate" android:id="@+id/btnSimulate"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -31,90 +45,6 @@
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="@string/simulate_telnet" /> android:text="@string/simulate_telnet" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingEnd="6dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/stats_local_title"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statsLocalDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:fontFamily="monospace"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingStart="6dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/stats_peer_title"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statsPeerDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:fontFamily="monospace"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/btnPushStats"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:text="@string/stats_push_peer" />
<Button
android:id="@+id/btnModeTxPeer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TX→" />
<Button
android:id="@+id/btnModeRxPeer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="RX→" />
</LinearLayout>
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
+41 -4
View File
@@ -1,7 +1,44 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/chatItemText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="6dp" android:paddingStart="8dp"
android:textSize="13sp" /> 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>
+3
View File
@@ -2,4 +2,7 @@
<resources> <resources>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<color name="status_ok">#FF00FF88</color>
<color name="status_warn">#FFFFC107</color>
<color name="status_off">#FF888888</color>
</resources> </resources>
+46
View File
@@ -63,4 +63,50 @@
<string name="track_paired_start">Старт трека (оба)</string> <string name="track_paired_start">Старт трека (оба)</string>
<string name="track_paired_started">Синхронный старт запланирован</string> <string name="track_paired_started">Синхронный старт запланирован</string>
<string name="track_paired_need_two">Нужны 2 устройства online</string> <string name="track_paired_need_two">Нужны 2 устройства online</string>
<string name="stats_static_toggle">▼ Статика</string>
<string name="stats_static_hide">▲ Статика</string>
<string name="at_current_values">Текущие значения</string>
<string name="at_apply">Применить</string>
<string name="at_hint_fq_mhz">Частота MHz (430470)</string>
<string name="at_hint_power">Мощность dBm (-9…22)</string>
<string name="at_hint_bw">Bandwidth kHz</string>
<string name="at_hint_cr">Code rate</string>
<string name="at_hint_pl">Preamble (164)</string>
<string name="at_hint_tm">Timeout ms (060000)</string>
<string name="at_hint_role">Роль после настройки</string>
<string name="at_console_toggle">▼ Консоль</string>
<string name="at_console_hide">▲ Консоль</string>
<string name="map_center_me">Я</string>
<string name="map_center_tx">TX</string>
<string name="map_center_rx">RX</string>
<string name="map_center_both">Оба</string>
<string name="map_center_mode">Центр карты</string>
<string name="map_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>
</resources> </resources>
@@ -86,6 +86,16 @@ public class LoraFrameExtractTest {
assertTrue(stats.metaJson.contains("\"fields\"")); assertTrue(stats.metaJson.contains("\"fields\""));
} }
@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 @Test
public void splitsTwoFramesByReceiveHeaderWithoutEsc() { public void splitsTwoFramesByReceiveHeaderWithoutEsc() {
List<String> frames = new ArrayList<>(); List<String> frames = new ArrayList<>();
@@ -0,0 +1,24 @@
package com.grigowashere.loratester;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class SettingsRepositoryTest {
@Test
public void detectsLegacyServerUrls() {
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru:7634"));
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru:7634/"));
assertTrue(SettingsRepository.isLegacyServerUrl("http://grigowashere.ru"));
assertTrue(SettingsRepository.isLegacyServerUrl("https://grigowashere.ru:7634"));
}
@Test
public void ignoresCurrentServerUrl() {
assertFalse(SettingsRepository.isLegacyServerUrl(SettingsRepository.DEFAULT_SERVER));
assertFalse(SettingsRepository.isLegacyServerUrl("https://example.com"));
assertFalse(SettingsRepository.isLegacyServerUrl(null));
}
}
+8
View File
@@ -0,0 +1,8 @@
.venv
__pycache__
*.pyc
*.pyo
*.db
.pytest_cache
.git
*.md
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
LORATESTER_HOST=0.0.0.0 \
LORATESTER_PORT=7634 \
LORATESTER_DB=/data/loratester.db
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 7634
CMD ["uvicorn", "fastapi_app:app", "--host", "0.0.0.0", "--port", "7634"]
+50 -9
View File
@@ -23,14 +23,47 @@ python flask_app.py
| `LORATESTER_DB` | `./loratester.db` | | `LORATESTER_DB` | `./loratester.db` |
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) | | `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) | | `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) |
| `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` |
| `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
## Деплой (grigowashere.ru:7634) ## Docker Compose
```bash ```bash
cd /srv/storage/disk2/services/LoraTester cd server
docker compose up -d --build
```
Проверка:
```bash
curl http://127.0.0.1:7634/api/health | jq
```
Ожидается `"elevation_ok": true` если локальный Open-Meteo доступен с хоста/контейнера.
Переопределить URL высот (`.env` рядом с `docker-compose.yml`):
```env
LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
```
БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера).
## Деплой (lora.grigowashere.ru)
```bash
cd /srv/storage/disk2/services/LoraTester/server
docker compose up -d --build
```
Или без Docker:
```bash
cd /srv/storage/disk2/services/LoraTester/server
pip install -r requirements.txt pip install -r requirements.txt
# один путь БД для всех воркеров:
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
export LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634 uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
``` ```
@@ -42,7 +75,7 @@ uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
curl http://127.0.0.1:7634/api/health curl http://127.0.0.1:7634/api/health
``` ```
Ожидается `"db_ok": true`, `"schema_version": 4`. Ожидается `"db_ok": true`, `"schema_version": 4`, `"elevation_ok": true`.
Если БД создана вручную и схема битая (`no such table: devices` / `no such column: t.meta`): Если БД создана вручную и схема битая (`no such table: devices` / `no such column: t.meta`):
@@ -66,13 +99,21 @@ curl http://127.0.0.1:7634/api/health
- `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}` - `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
- `POST /api/tracks/{id}/finish` - `POST /api/tracks/{id}/finish`
- `GET /api/tracks?device_id=` - `GET /api/tracks?device_id=`
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через Open-Meteo) - `GET /api/tracks/{id}` — метаданные + точки (высота terrain через локальный Open-Meteo)
### Команды (очередь на устройство) ### Команды (очередь на устройство)
- `POST /api/commands``{from_device_id, to_device_id, kind, payload?}` - `POST /api/commands``{from_device_id, to_device_id, kind, payload?}`
`kind`: `at` (`payload.line`), `mode` (`payload.role`: TX/RX), `stats_push` (снимок meta/rssi/role) `kind`: `at` (`payload.line` — одна строка, или `payload.lines` — массив макроса), `mode` (`payload.role`: TX/RX), `stats_push` (снимок meta/rssi/role/sf/bw)
`from_device_id`: `web` или `android-xxxxxxxx` `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/pending?device_id=` — Android, доставка + `delivered_at`
- `GET /api/commands?to_device_id=&limit=` — история (веб) - `GET /api/commands?to_device_id=&limit=` — история (веб)
@@ -87,7 +128,7 @@ curl http://127.0.0.1:7634/api/health
- `POST /api/chat``{device_id, text}` - `POST /api/chat``{device_id, text}`
- `GET /api/chat?since=0` - `GET /api/chat?since=0`
- `GET /api/health``{ok, db_ok, schema_version, database_path}` - `GET /api/health``{ok, db_ok, schema_version, database_path, elevation_ok, elevation_url, elevation_error}`
## FastAPI (прод) ## FastAPI (прод)
@@ -107,6 +148,6 @@ python -m pytest tests/ -v
## Android ## Android
URL: `http://grigowashere.ru:7634`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (Open-Meteo на сервере). Вкладка **Статистика** — история с сервера. URL: `https://lora.grigowashere.ru`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (локальный Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
Telnet: `127.0.0.1:2727` — мост COM→telnet на устройстве. Telnet: `127.0.0.1:2727` — мост COM→telnet на устройстве.
Binary file not shown.
Binary file not shown.
+10
View File
@@ -9,3 +9,13 @@ HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
PORT = int(os.environ.get("LORATESTER_PORT", "7634")) PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000")) TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000")) TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
ELEVATION_API_URL = os.environ.get(
"LORATESTER_ELEVATION_URL",
"http://192.168.1.109:8085/v1/elevation",
).rstrip("/")
ELEVATION_PROBE_TTL_SEC = float(
os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60")
)
ELEVATION_CONNECT_TIMEOUT = float(
os.environ.get("LORATESTER_ELEVATION_TIMEOUT", "8")
)
+574 -19
View File
@@ -1,40 +1,595 @@
"""Terrain elevation via Open-Meteo (cached per coordinate).""" """Terrain elevation via self-hosted Open-Meteo-compatible API."""
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Optional import math
import time
from typing import Any, Optional
import httpx import httpx
from .config import (
ELEVATION_API_URL,
ELEVATION_CONNECT_TIMEOUT,
ELEVATION_PROBE_TTL_SEC,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_BATCH_SIZE = 100
_MAX_PROFILE_POINTS = 500
_CACHE: dict[tuple[float, float], Optional[float]] = {} _CACHE: dict[tuple[float, float], Optional[float]] = {}
_TIMEOUT = 3.0 _probe_checked_at = 0.0
_probe_ok = False
_probe_error: Optional[str] = None
def _cache_key(lat: float, lon: float) -> tuple[float, float]: def _cache_key(lat: float, lon: float) -> tuple[float, float]:
return (round(lat, 4), round(lon, 4)) return (round(lat, 6), round(lon, 6))
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]: def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
key = _cache_key(lat, lon) r = 6_371_000.0
if key in _CACHE: d_lat = math.radians(lat2 - lat1)
return _CACHE[key] d_lon = math.radians(lon2 - lon1)
a = (
math.sin(d_lat / 2) ** 2
+ math.cos(math.radians(lat1))
* math.cos(math.radians(lat2))
* math.sin(d_lon / 2) ** 2
)
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
"""Ping elevation service before batch requests (cached for TTL)."""
global _probe_checked_at, _probe_ok, _probe_error
now = time.monotonic()
if (
not force
and _probe_checked_at > 0
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
):
return {
"ok": _probe_ok,
"url": ELEVATION_API_URL,
"error": _probe_error,
}
try: try:
with httpx.Client(timeout=_TIMEOUT) as client: with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get( r = client.get(
"https://api.open-meteo.com/v1/elevation", ELEVATION_API_URL,
params={"latitude": lat, "longitude": lon}, params={"latitude": "0.000000", "longitude": "0.000000"},
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
elevations = data.get("elevation") or [] if "elevation" not in data:
if elevations: raise ValueError("response has no elevation field")
val = float(elevations[0]) _probe_checked_at = now
_CACHE[key] = val _probe_ok = True
return val _probe_error = None
logger.info("elevation API ok: %s", ELEVATION_API_URL)
except Exception as e: except Exception as e:
logger.warning("open-meteo elevation failed for %s,%s: %s", lat, lon, e) _probe_checked_at = now
_CACHE[key] = None _probe_ok = False
return None _probe_error = str(e)
logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e)
return {
"ok": _probe_ok,
"url": ELEVATION_API_URL,
"error": _probe_error,
}
def elevation_status(force: bool = False) -> dict[str, Any]:
probe = probe_elevation_api(force=force)
return {
"elevation_ok": probe["ok"],
"elevation_url": probe["url"],
"elevation_error": probe["error"],
}
def _fetch_elevation_batch(
batch_lat: list[float], batch_lon: list[float]
) -> list[Optional[float]]:
if not batch_lat:
return []
params = {
"latitude": ",".join(f"{lat:.6f}" for lat in batch_lat),
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
}
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
r = client.get(ELEVATION_API_URL, params=params)
r.raise_for_status()
data = r.json()
elevations = data.get("elevation") or []
out: list[Optional[float]] = []
for j, elev in enumerate(elevations):
if j >= len(batch_lat):
break
if elev is None:
out.append(None)
else:
out.append(float(elev))
while len(out) < len(batch_lat):
out.append(None)
return out
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
vals = fetch_elevations_batch([lat], [lon])
return vals[0] if vals else None
def fetch_elevations_batch(
lats: list[float], lons: list[float]
) -> list[Optional[float]]:
if not lats or len(lats) != len(lons):
return []
probe = probe_elevation_api()
if not probe["ok"]:
logger.warning(
"skip elevation fetch: API unreachable (%s)",
probe.get("error"),
)
return [None] * len(lats)
out: list[Optional[float]] = [None] * len(lats)
pending_idx: list[int] = []
pending_lat: list[float] = []
pending_lon: list[float] = []
for i, (lat, lon) in enumerate(zip(lats, lons)):
key = _cache_key(lat, lon)
if key in _CACHE:
out[i] = _CACHE[key]
else:
pending_idx.append(i)
pending_lat.append(float(lat))
pending_lon.append(float(lon))
for start in range(0, len(pending_lat), _BATCH_SIZE):
batch_i = pending_idx[start : start + _BATCH_SIZE]
batch_lat = pending_lat[start : start + _BATCH_SIZE]
batch_lon = pending_lon[start : start + _BATCH_SIZE]
try:
batch_vals = _fetch_elevation_batch(batch_lat, batch_lon)
for j, val in enumerate(batch_vals):
lat = batch_lat[j]
lon = batch_lon[j]
_CACHE[_cache_key(lat, lon)] = val
out[batch_i[j]] = val
logger.info(
"elevation ok: %s points, sample=%s",
len(batch_lat),
batch_vals[0] if batch_vals else None,
)
except Exception as e:
logger.warning(
"elevation batch failed (%s points): %s",
len(batch_lat),
e,
)
for j in range(len(batch_lat)):
try:
single = _fetch_elevation_batch(
[batch_lat[j]], [batch_lon[j]]
)
val = single[0] if single else None
except Exception as e2:
logger.warning(
"elevation single failed %.6f,%.6f: %s",
batch_lat[j],
batch_lon[j],
e2,
)
val = None
_CACHE[_cache_key(batch_lat[j], batch_lon[j])] = val
out[batch_i[j]] = val
return out
def _interp_at_dist(
cleaned: list[tuple[float, float]], cum: list[float], dist_m: float
) -> tuple[float, float]:
if dist_m <= 0:
return cleaned[0]
if dist_m >= cum[-1]:
return cleaned[-1]
for i in range(1, len(cum)):
if dist_m <= cum[i]:
seg = cum[i] - cum[i - 1]
t = 0.0 if seg <= 0 else (dist_m - cum[i - 1]) / seg
lat1, lon1 = cleaned[i - 1]
lat2, lon2 = cleaned[i]
return lat1 + (lat2 - lat1) * t, lon1 + (lon2 - lon1) * t
return cleaned[-1]
def resample_track_path(
points: list[dict[str, Any]], step_m: float = 10.0
) -> list[dict[str, float]]:
"""Sample (lat, lon, dist_m) along polyline every ~step_m meters."""
if not points or step_m <= 0:
return []
cleaned: list[tuple[float, float]] = []
for p in points:
lat = p.get("lat")
lon = p.get("lon")
if lat is None or lon is None:
continue
lat_f, lon_f = float(lat), float(lon)
if not cleaned or haversine_m(cleaned[-1][0], cleaned[-1][1], lat_f, lon_f) > 0.5:
cleaned.append((lat_f, lon_f))
if not cleaned:
return []
if len(cleaned) == 1:
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
cum = [0.0]
for i in range(1, len(cleaned)):
cum.append(
cum[-1]
+ haversine_m(
cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]
)
)
total = cum[-1]
samples: list[dict[str, float]] = []
dist = 0.0
while dist <= total + 1e-6:
lat, lon = _interp_at_dist(cleaned, cum, dist)
samples.append({"lat": lat, "lon": lon, "dist_m": round(dist, 1)})
if dist >= total:
break
dist += step_m
return samples
def resample_track_path_count(
points: list[dict[str, Any]], count: int
) -> list[dict[str, float]]:
"""Sample exactly `count` points evenly spaced along polyline."""
if not points or count < 2:
return []
cleaned: list[tuple[float, float]] = []
for p in points:
lat = p.get("lat")
lon = p.get("lon")
if lat is None or lon is None:
continue
lat_f, lon_f = float(lat), float(lon)
if not cleaned or haversine_m(cleaned[-1][0], cleaned[-1][1], lat_f, lon_f) > 0.5:
cleaned.append((lat_f, lon_f))
if not cleaned:
return []
if len(cleaned) == 1:
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
cum = [0.0]
for i in range(1, len(cleaned)):
cum.append(
cum[-1]
+ haversine_m(
cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]
)
)
total = cum[-1]
if total < 1e-6:
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
n = max(2, min(_MAX_PROFILE_POINTS, int(count)))
samples: list[dict[str, float]] = []
for i in range(n):
dist = (total * i) / (n - 1)
lat, lon = _interp_at_dist(cleaned, cum, dist)
samples.append({"lat": lat, "lon": lon, "dist_m": round(dist, 1)})
return samples
def build_elevation_profile(
points: list[dict[str, Any]],
step_m: float = 10.0,
target_points: int | None = None,
) -> dict[str, Any]:
"""Resample track and fetch terrain elevations."""
if target_points is not None:
n = max(2, min(_MAX_PROFILE_POINTS, int(target_points)))
samples = resample_track_path_count(points, n)
if len(samples) > 1:
step_m = round(
(samples[-1]["dist_m"] - samples[0]["dist_m"]) / (len(samples) - 1),
2,
)
else:
step_m = 0.0
else:
step_m = max(5.0, min(10.0, float(step_m)))
samples = resample_track_path(points, step_m)
if not samples:
return {
"step_m": step_m,
"points": [],
"total_m": 0.0,
"api_source": "elevation",
"api_error": "no samples",
}
probe = probe_elevation_api()
if not probe["ok"]:
return {
"step_m": step_m,
"points": [],
"total_m": 0.0,
"api_source": "elevation",
"api_error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL,
}
lats = [s["lat"] for s in samples]
lons = [s["lon"] for s in samples]
elevations = fetch_elevations_batch(lats, lons)
profile: list[dict[str, Any]] = []
elev_vals: list[float] = []
for s, elev in zip(samples, elevations):
item = {
"dist_m": round(s["dist_m"], 1),
"lat": round(s["lat"], 6),
"lon": round(s["lon"], 6),
"elevation_m": elev,
}
profile.append(item)
if elev is not None:
elev_vals.append(elev)
total_m = profile[-1]["dist_m"] if profile else 0.0
result: dict[str, Any] = {
"step_m": step_m,
"total_m": total_m,
"min_elevation_m": min(elev_vals) if elev_vals else None,
"max_elevation_m": max(elev_vals) if elev_vals else None,
"points": profile,
"api_source": "elevation",
"elevation_url": ELEVATION_API_URL,
}
if not elev_vals:
result["api_error"] = "elevation API returned no values"
return result
def _offset_m(lat: float, lon: float, north_m: float, east_m: float) -> tuple[float, float]:
dlat = north_m / 111_320.0
dlon = east_m / (111_320.0 * max(math.cos(math.radians(lat)), 1e-6))
return lat + dlat, lon + dlon
_MAX_GRID_POINTS = 2500
_MAX_GRID_POINTS_FINE = 12000
def _auto_step_m(radius_m: float) -> float:
if radius_m <= 150:
return 10.0
if radius_m <= 300:
return 15.0
return 20.0
def _sample_circular_grid(
lat: float,
lon: float,
radius_m: float,
step_m: float,
) -> list[tuple[int, int, float, float, float]]:
steps = int(radius_m / step_m)
cells: list[tuple[int, int, float, float, float]] = []
for i in range(-steps, steps + 1):
for j in range(-steps, steps + 1):
north = i * step_m
east = j * step_m
dist = math.hypot(north, east)
if dist > radius_m:
continue
la, lo = _offset_m(lat, lon, north, east)
cells.append((i, j, la, lo, dist))
return cells
def _resolve_grid_step(
lat: float, lon: float, radius_m: float, step_m: float
) -> float:
if step_m <= 0:
step_m = _auto_step_m(radius_m)
min_step = 1.0 if radius_m <= 100.0 else 5.0
step_m = max(min_step, min(float(step_m), 100.0))
max_points = _MAX_GRID_POINTS_FINE if radius_m <= 100.0 and step_m <= 1.0 else _MAX_GRID_POINTS
while len(_sample_circular_grid(lat, lon, radius_m, step_m)) > max_points:
step_m = math.ceil(step_m * 1.25)
if step_m >= radius_m:
break
return step_m
def build_elevation_grid(
lat: float,
lon: float,
radius_m: float = 200.0,
step_m: float = 0.0,
) -> dict[str, Any]:
"""Circular elevation grid for heatmap (delta relative to center)."""
probe = probe_elevation_api()
if not probe["ok"]:
return {
"ok": False,
"error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL,
}
radius_m = max(50.0, min(float(radius_m), 500.0))
step_m = _resolve_grid_step(lat, lon, radius_m, step_m)
center_elev = fetch_elevation_m(lat, lon)
if center_elev is None:
return {"ok": False, "error": "no elevation at center"}
grid_cells = _sample_circular_grid(lat, lon, radius_m, step_m)
if not grid_cells:
return {"ok": False, "error": "empty search grid"}
lats = [c[2] for c in grid_cells]
lons = [c[3] for c in grid_cells]
elevations = fetch_elevations_batch(lats, lons)
points: list[dict[str, Any]] = []
deltas: list[float] = []
for (i, j, la, lo, dist), elev in zip(grid_cells, elevations):
if elev is None:
continue
delta = float(elev) - center_elev
deltas.append(delta)
points.append(
{
"i": i,
"j": j,
"lat": round(la, 6),
"lon": round(lo, 6),
"dist_m": round(dist, 1),
"elevation_m": float(elev),
"delta_m": round(delta, 1),
}
)
if not points:
return {"ok": False, "error": "no elevation values in grid"}
return {
"ok": True,
"center": {
"lat": round(lat, 6),
"lon": round(lon, 6),
"elevation_m": center_elev,
},
"radius_m": radius_m,
"step_m": step_m,
"points": points,
"min_delta_m": round(min(deltas), 1),
"max_delta_m": round(max(deltas), 1),
"api_source": "elevation",
"elevation_url": ELEVATION_API_URL,
}
def find_nearest_hill(
lat: float,
lon: float,
radius_m: float = 5000.0,
step_m: float = 300.0,
min_prominence_m: float = 8.0,
) -> dict[str, Any]:
"""Find nearest local elevation maximum around a point."""
probe = probe_elevation_api()
if not probe["ok"]:
return {
"ok": False,
"error": f"elevation API unreachable: {probe['error']}",
"elevation_url": ELEVATION_API_URL,
}
radius_m = max(500.0, min(float(radius_m), 15_000.0))
step_m = max(100.0, min(float(step_m), 500.0))
min_prominence_m = max(3.0, min(float(min_prominence_m), 100.0))
center_elev = fetch_elevation_m(lat, lon)
if center_elev is None:
return {"ok": False, "error": "no elevation at center"}
grid_cells = _sample_circular_grid(lat, lon, radius_m, step_m)
if not grid_cells:
return {"ok": False, "error": "empty search grid"}
lats = [c[2] for c in grid_cells]
lons = [c[3] for c in grid_cells]
elevations = fetch_elevations_batch(lats, lons)
grid: dict[tuple[int, int], dict[str, Any]] = {}
for (i, j, la, lo, dist), elev in zip(grid_cells, elevations):
grid[(i, j)] = {
"lat": round(la, 6),
"lon": round(lo, 6),
"dist_m": round(dist, 1),
"elevation_m": elev,
}
def is_local_max(i: int, j: int, elev: float) -> bool:
for di in (-1, 0, 1):
for dj in (-1, 0, 1):
if di == 0 and dj == 0:
continue
n = grid.get((i + di, j + dj))
if n and n["elevation_m"] is not None and n["elevation_m"] >= elev:
return False
return True
candidates: list[dict[str, Any]] = []
for (i, j), cell in grid.items():
elev = cell.get("elevation_m")
if elev is None:
continue
prominence = float(elev) - center_elev
if prominence < min_prominence_m:
continue
if is_local_max(i, j, float(elev)):
candidates.append({**cell, "prominence_m": round(prominence, 1)})
if not candidates:
best = None
for cell in grid.values():
elev = cell.get("elevation_m")
if elev is None:
continue
prominence = float(elev) - center_elev
if prominence < min_prominence_m * 0.5:
continue
if best is None or cell["dist_m"] < best["dist_m"]:
best = {
**cell,
"prominence_m": round(prominence, 1),
"is_local_max": False,
}
if best is None:
return {
"ok": False,
"error": "no hill found in radius",
"center": {
"lat": round(lat, 6),
"lon": round(lon, 6),
"elevation_m": center_elev,
},
"radius_m": radius_m,
}
hill = best
else:
candidates.sort(key=lambda c: c["dist_m"])
hill = {**candidates[0], "is_local_max": True}
return {
"ok": True,
"center": {
"lat": round(lat, 6),
"lon": round(lon, 6),
"elevation_m": center_elev,
},
"hill": hill,
"candidates": len(candidates),
"radius_m": radius_m,
"step_m": step_m,
"api_source": "elevation",
"elevation_url": ELEVATION_API_URL,
}
+20
View File
@@ -0,0 +1,20 @@
services:
loratester:
build: .
container_name: loratester
restart: unless-stopped
ports:
- "${LORATESTER_PORT:-7634}:7634"
volumes:
- loratester-data:/data
environment:
LORATESTER_DB: /data/loratester.db
LORATESTER_PORT: "7634"
LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation}
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000}
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-10000}
volumes:
loratester-data:
+71 -1
View File
@@ -7,6 +7,7 @@ from typing import Any, Optional
from fastapi import FastAPI, Header, HTTPException, Query from fastapi import FastAPI, Header, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from core.auth import ANDROID_CLIENT_HEADER, ANDROID_CLIENT_VALUE from core.auth import ANDROID_CLIENT_HEADER, ANDROID_CLIENT_VALUE
@@ -298,10 +299,79 @@ def paired_tracks_cancel(body: PairedTrackCancelBody):
raise HTTPException(400, detail=str(e)) from e raise HTTPException(400, detail=str(e)) from e
class ElevationPoint(BaseModel):
lat: float
lon: float
ts: Optional[float] = None
class ElevationProfileBody(BaseModel):
points: list[ElevationPoint] = Field(default_factory=list)
step_m: float = 10.0
target_points: Optional[int] = Field(None, ge=2, le=500)
@app.post("/api/elevation/profile")
def elevation_profile(body: ElevationProfileBody):
from core.elevation import build_elevation_profile
pts = [p.model_dump(exclude_none=True) for p in body.points]
return build_elevation_profile(pts, body.step_m, body.target_points)
@app.get("/api/tracks/{track_id}/elevation-profile")
def track_elevation_profile(
track_id: int,
step_m: float = Query(10.0, ge=5.0, le=10.0),
):
from core.elevation import build_elevation_profile
try:
track = storage.get_track(track_id)
except ValueError as e:
raise HTTPException(404, detail=str(e)) from e
return build_elevation_profile(track.get("points") or [], step_m)
@app.get("/api/elevation/nearest-hill")
def elevation_nearest_hill(
lat: float = Query(..., ge=-90.0, le=90.0),
lon: float = Query(..., ge=-180.0, le=180.0),
radius_m: float = Query(5000.0, ge=500.0, le=15000.0),
step_m: float = Query(300.0, ge=100.0, le=500.0),
min_prominence_m: float = Query(8.0, ge=3.0, le=100.0),
):
from core.elevation import find_nearest_hill
return find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m)
@app.get("/api/elevation/grid")
def elevation_grid(
lat: float = Query(..., ge=-90.0, le=90.0),
lon: float = Query(..., ge=-180.0, le=180.0),
radius_m: float = Query(200.0, ge=50.0, le=500.0),
step_m: float = Query(0.0, ge=1.0, le=100.0),
):
from core.elevation import build_elevation_grid
return build_elevation_grid(lat, lon, radius_m, step_m)
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
from core.elevation import elevation_status
status = storage.db_status() status = storage.db_status()
return {"ok": status["db_ok"], "ts": time.time(), **status} return {
"ok": status["db_ok"],
"ts": time.time(),
**status,
**elevation_status(),
}
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
if __name__ == "__main__": if __name__ == "__main__":
+64 -1
View File
@@ -237,10 +237,73 @@ def paired_tracks_cancel():
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
@app.post("/api/elevation/profile")
def elevation_profile():
from core.elevation import build_elevation_profile
body = request.get_json(force=True, silent=True) or {}
points = body.get("points") or []
step_m = body.get("step_m", 10)
try:
step = float(step_m)
except (TypeError, ValueError):
step = 10.0
target_points = body.get("target_points")
try:
tp = int(target_points) if target_points is not None else None
except (TypeError, ValueError):
tp = None
return jsonify(build_elevation_profile(points, step, tp))
@app.get("/api/tracks/<int:track_id>/elevation-profile")
def track_elevation_profile(track_id: int):
from core.elevation import build_elevation_profile
step_m = request.args.get("step_m", 10, type=float)
try:
track = storage.get_track(track_id)
except ValueError as e:
return jsonify({"error": str(e)}), 404
points = track.get("points") or []
return jsonify(build_elevation_profile(points, step_m or 10.0))
@app.get("/api/elevation/nearest-hill")
def elevation_nearest_hill():
from core.elevation import find_nearest_hill
lat = request.args.get("lat", type=float)
lon = request.args.get("lon", type=float)
if lat is None or lon is None:
return jsonify({"ok": False, "error": "lat and lon required"}), 400
radius_m = request.args.get("radius_m", 5000, type=float)
step_m = request.args.get("step_m", 300, type=float)
min_prominence_m = request.args.get("min_prominence_m", 8, type=float)
return jsonify(find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m))
@app.get("/api/elevation/grid")
def elevation_grid():
from core.elevation import build_elevation_grid
lat = request.args.get("lat", type=float)
lon = request.args.get("lon", type=float)
if lat is None or lon is None:
return jsonify({"ok": False, "error": "lat and lon required"}), 400
radius_m = request.args.get("radius_m", 200, type=float)
step_m = request.args.get("step_m", 0, type=float)
return jsonify(build_elevation_grid(lat, lon, radius_m, step_m))
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
from core.elevation import elevation_status
status = storage.db_status() status = storage.db_status()
return jsonify({"ok": status["db_ok"], "ts": time.time(), **status}) return jsonify(
{"ok": status["db_ok"], "ts": time.time(), **status, **elevation_status()}
)
def _float_or_none(value): def _float_or_none(value):
+1380 -107
View File
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
/** 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',
'on air', 'tx speed', 'rx speed', 'per', 'rx quality'
]);
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 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,
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.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 = ['role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent',
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm'];
const map = { role: 'role', rssiDbm: 'rssi', snrDb: 'snr', rxQualityPercent: 'rxQuality',
packet: 'packet',
payload: 'payload', perPercent: 'per', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed',
frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power' };
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: '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: '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 => s.bwKhz != null ? `${s.bwKhz} kHz` : '—' },
{ key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' },
{ key: 'onAir', label: 'On Air', fmt: s => s.onAirMs != null ? `${s.onAirMs} ms` : '—' }
];
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) {
let html = '<div class="radio-compare-grid">';
html += `<div class="radio-compare-head"><span class="legend-tx">TX</span> ${escapeHtml(txId || '—')}`;
html += `<span class="legend-rx">RX</span> ${escapeHtml(rxId || '—')}</div>`;
for (const row of DYNAMIC_ROWS) {
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
html += `<div class="radio-row"><span class="radio-label">${row.label}</span>`;
html += `<span class="radio-tx${txCls}">${escapeHtml(row.fmt(txSnap))}</span>`;
html += `<span class="radio-rx${rxCls}">${escapeHtml(row.fmt(rxSnap))}</span></div>`;
}
html += `<details class="radio-static"${staticOpen ? ' open' : ''}><summary>Статика</summary>`;
for (const row of STATIC_ROWS) {
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
html += `<div class="radio-row"><span class="radio-label">${row.label}</span>`;
html += `<span class="radio-tx${txCls}">${escapeHtml(row.fmt(txSnap))}</span>`;
html += `<span class="radio-rx${rxCls}">${escapeHtml(row.fmt(rxSnap))}</span></div>`;
}
html += '</details></div>';
return html;
}
function formatRadioPanel(snap, changed, staticOpen) {
if (!snap) return '—';
const ch = changed || new Set();
let html = '';
for (const row of DYNAMIC_ROWS) {
const cls = ch.has(row.key) ? ' class="changed"' : '';
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
}
for (const [label, value] of Object.entries(snap.extraFields || {})) {
html += `<div><b>${escapeHtml(label)}:</b> ${escapeHtml(value)}</div>`;
}
html += `<details class="radio-static"${staticOpen ? ' open' : ''}><summary>Статика</summary>`;
for (const row of STATIC_ROWS) {
const cls = ch.has(row.key) ? ' class="changed"' : '';
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
}
html += '</details>';
return html;
}
global.RadioUI = {
roleLabel,
parseRadioSnapshot,
diffSnapshots,
renderCompareGrid,
formatRadioPanel,
DYNAMIC_ROWS,
STATIC_ROWS
};
})(typeof window !== 'undefined' ? window : globalThis);
+172
View File
@@ -0,0 +1,172 @@
import core.elevation as elev
class _FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class _FakeClient:
def __init__(self, **kwargs):
self.kwargs = kwargs
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, url, params=None):
return _FakeResponse({"elevation": [152.0]})
def test_probe_elevation_api_ok(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev.httpx, "Client", _FakeClient)
status = elev.probe_elevation_api(force=True)
assert status["ok"] is True
assert status["error"] is None
def test_fetch_skips_when_unreachable(monkeypatch):
monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
)
vals = elev.fetch_elevations_batch([55.75], [37.62])
assert vals == [None]
def test_build_profile_reports_unreachable(monkeypatch):
monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
)
profile = elev.build_elevation_profile(
[{"lat": 55.75, "lon": 37.62}, {"lat": 55.76, "lon": 37.63}],
10,
)
assert profile["points"] == []
assert "unreachable" in profile["api_error"]
def test_resample_track_path_count_even_spacing():
pts = [{"lat": 55.0, "lon": 37.0}, {"lat": 55.01, "lon": 37.0}]
samples = elev.resample_track_path_count(pts, 50)
assert len(samples) == 50
assert samples[0]["dist_m"] == 0.0
assert samples[-1]["dist_m"] > samples[0]["dist_m"]
gaps = [samples[i]["dist_m"] - samples[i - 1]["dist_m"] for i in range(1, len(samples))]
assert max(gaps) - min(gaps) < 1.0
def test_build_profile_target_points(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
monkeypatch.setattr(
elev,
"fetch_elevations_batch",
lambda lats, lons: [100.0 + i for i in range(len(lats))],
)
profile = elev.build_elevation_profile(
[{"lat": 55.0, "lon": 37.0}, {"lat": 55.01, "lon": 37.0}],
target_points=120,
)
assert len(profile["points"]) == 120
assert profile["step_m"] > 0
def test_find_nearest_hill_unreachable(monkeypatch):
monkeypatch.setattr(
elev,
"probe_elevation_api",
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
)
result = elev.find_nearest_hill(55.75, 37.62)
assert result["ok"] is False
def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
def fake_batch(lats, lons):
out = []
for la, lo in zip(lats, lons):
if abs(la - 55.75) < 1e-4 and abs(lo - 37.62) < 1e-4:
out.append(100.0)
elif la > 55.75:
out.append(130.0)
else:
out.append(95.0)
return out
monkeypatch.setattr(elev, "fetch_elevations_batch", fake_batch)
result = elev.find_nearest_hill(55.75, 37.62, radius_m=2000, step_m=300, min_prominence_m=8)
assert result["ok"] is True
assert result["hill"]["elevation_m"] >= 120.0
def test_build_elevation_grid_delta(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
def fake_batch(lats, lons):
return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(lats, lons)]
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 100.0)
monkeypatch.setattr(elev, "fetch_elevations_batch", fake_batch)
result = elev.build_elevation_grid(55.75, 37.62, radius_m=100, step_m=10)
assert result["ok"] is True
assert result["step_m"] == 10
assert len(result["points"]) > 0
assert result["min_delta_m"] <= 0 <= result["max_delta_m"]
assert all("delta_m" in p for p in result["points"])
def test_build_elevation_grid_fine_step_small_radius(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 120.0)
monkeypatch.setattr(
elev,
"fetch_elevations_batch",
lambda lats, lons: [120.0 + i * 0.1 for i in range(len(lats))],
)
result = elev.build_elevation_grid(55.75, 37.62, radius_m=50, step_m=1)
assert result["ok"] is True
assert result["step_m"] == 1
assert len(result["points"]) > 1000
def test_build_elevation_grid_limits_points(monkeypatch):
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 50.0)
monkeypatch.setattr(
elev,
"fetch_elevations_batch",
lambda lats, lons: [50.0] * len(lats),
)
step = elev._resolve_grid_step(55.75, 37.62, 500.0, 5.0)
cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step)
assert len(cells) <= elev._MAX_GRID_POINTS