generated from Grigo/AndroidTemplate
added local api
This commit is contained in:
Generated
+1
@@ -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>
|
||||||
|
|||||||
Generated
-1
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -168,6 +170,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");
|
||||||
|
|||||||
@@ -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,195 @@
|
|||||||
|
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 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,
|
||||||
|
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.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, 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, 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"),
|
||||||
|
extra
|
||||||
|
);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return new RadioSnapshot(roleFallback, null, null, null, null, null,
|
||||||
|
rssiFallback, 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, "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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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,56 @@ 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, "Пакет", fmtInt(s.packet), "packet", changed);
|
||||||
|
appendLine(sb, "Payload", s.payload, "payload", changed);
|
||||||
appendFieldsBlock(sb, o.get("fields"), shown);
|
appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed);
|
||||||
|
appendLine(sb, "TX Speed", fmtSuffix(s.txPktPerS, " pkt/s"), "txSpeed", changed);
|
||||||
String role = text(o, "role");
|
appendLine(sb, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed);
|
||||||
if (role != null) {
|
for (Map.Entry<String, String> e : s.extraFields.entrySet()) {
|
||||||
append(sb, "Роль", roleLabel(role));
|
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 +73,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 +102,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,10 +68,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);
|
||||||
@@ -103,7 +99,9 @@ public class StatsExtractor {
|
|||||||
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));
|
||||||
|
|
||||||
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 +126,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");
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ import java.util.concurrent.Executors;
|
|||||||
|
|
||||||
public class MapFragment extends Fragment {
|
public class MapFragment extends Fragment {
|
||||||
|
|
||||||
|
private static final class MapSessionState {
|
||||||
|
static boolean initialFitDone;
|
||||||
|
}
|
||||||
|
|
||||||
private static final int TILE_SIZE_PX = 256;
|
private static final int TILE_SIZE_PX = 256;
|
||||||
private static final long DEVICE_POLL_MS = 5000;
|
private static final long DEVICE_POLL_MS = 5000;
|
||||||
/** Ignore GPS jitter smaller than ~11 m. */
|
/** Ignore GPS jitter smaller than ~11 m. */
|
||||||
@@ -76,6 +80,7 @@ public class MapFragment extends Fragment {
|
|||||||
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
|
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
|
||||||
private final Map<String, Marker> deviceMarkers = new HashMap<>();
|
private final Map<String, Marker> deviceMarkers = new HashMap<>();
|
||||||
private final List<Layer> trackLayers = new ArrayList<>();
|
private final List<Layer> trackLayers = new ArrayList<>();
|
||||||
|
private final List<LatLong> liveTrackPoints = new ArrayList<>();
|
||||||
|
|
||||||
private FragmentPollHelper pollHelper;
|
private FragmentPollHelper pollHelper;
|
||||||
private TelemetryUploader uploader;
|
private TelemetryUploader uploader;
|
||||||
@@ -85,9 +90,14 @@ public class MapFragment extends Fragment {
|
|||||||
private TileDownloadLayer downloadLayer;
|
private TileDownloadLayer downloadLayer;
|
||||||
private TileCache tileCache;
|
private TileCache tileCache;
|
||||||
private TextView mapStatus;
|
private TextView mapStatus;
|
||||||
|
private TextView mapDistance;
|
||||||
private TextView trackStatus;
|
private TextView trackStatus;
|
||||||
private Button btnTrack;
|
private Button btnTrack;
|
||||||
private Button btnPairedTrack;
|
private Button btnPairedTrack;
|
||||||
|
private Button btnCenterMe;
|
||||||
|
private Button btnCenterTx;
|
||||||
|
private Button btnCenterRx;
|
||||||
|
private Button btnCenterBoth;
|
||||||
private Spinner trackSpinner;
|
private Spinner trackSpinner;
|
||||||
private List<TrackInfo> savedTracks = new ArrayList<>();
|
private List<TrackInfo> savedTracks = new ArrayList<>();
|
||||||
private boolean mapResumed;
|
private boolean mapResumed;
|
||||||
@@ -95,8 +105,10 @@ public class MapFragment extends Fragment {
|
|||||||
private NetworkMonitor networkMonitor;
|
private NetworkMonitor networkMonitor;
|
||||||
private NetworkMonitor.Listener networkListener;
|
private NetworkMonitor.Listener networkListener;
|
||||||
private boolean networkOnline = true;
|
private boolean networkOnline = true;
|
||||||
private boolean initialFitDone;
|
|
||||||
private boolean userMovedMap;
|
private boolean userMovedMap;
|
||||||
|
private List<DeviceInfo> lastDevices = new ArrayList<>();
|
||||||
|
private Polyline liveTrackPolyline;
|
||||||
|
private Marker liveTrackMarker;
|
||||||
private boolean suppressTrackSpinner;
|
private boolean suppressTrackSpinner;
|
||||||
private Bitmap bitmapTx;
|
private Bitmap bitmapTx;
|
||||||
private Bitmap bitmapRx;
|
private Bitmap bitmapRx;
|
||||||
@@ -128,12 +140,30 @@ public class MapFragment extends Fragment {
|
|||||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
mapView = view.findViewById(R.id.mapView);
|
mapView = view.findViewById(R.id.mapView);
|
||||||
mapStatus = view.findViewById(R.id.mapStatus);
|
mapStatus = view.findViewById(R.id.mapStatus);
|
||||||
|
mapDistance = view.findViewById(R.id.mapDistance);
|
||||||
trackStatus = view.findViewById(R.id.trackStatus);
|
trackStatus = view.findViewById(R.id.trackStatus);
|
||||||
btnTrack = view.findViewById(R.id.btnTrack);
|
btnTrack = view.findViewById(R.id.btnTrack);
|
||||||
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
|
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
|
||||||
|
btnCenterMe = view.findViewById(R.id.btnCenterMe);
|
||||||
|
btnCenterTx = view.findViewById(R.id.btnCenterTx);
|
||||||
|
btnCenterRx = view.findViewById(R.id.btnCenterRx);
|
||||||
|
btnCenterBoth = view.findViewById(R.id.btnCenterBoth);
|
||||||
trackSpinner = view.findViewById(R.id.trackSpinner);
|
trackSpinner = view.findViewById(R.id.trackSpinner);
|
||||||
pollHelper = new FragmentPollHelper(this, this::refreshDevices);
|
pollHelper = new FragmentPollHelper(this, this::refreshDevices);
|
||||||
|
|
||||||
|
if (btnCenterMe != null) {
|
||||||
|
btnCenterMe.setOnClickListener(v -> centerOnSelf());
|
||||||
|
}
|
||||||
|
if (btnCenterTx != null) {
|
||||||
|
btnCenterTx.setOnClickListener(v -> centerOnRole(StatsExtractor.ROLE_TX));
|
||||||
|
}
|
||||||
|
if (btnCenterRx != null) {
|
||||||
|
btnCenterRx.setOnClickListener(v -> centerOnRole(StatsExtractor.ROLE_RX));
|
||||||
|
}
|
||||||
|
if (btnCenterBoth != null) {
|
||||||
|
btnCenterBoth.setOnClickListener(v -> centerOnBoth());
|
||||||
|
}
|
||||||
|
|
||||||
networkOnline = networkMonitor != null && networkMonitor.isOnline();
|
networkOnline = networkMonitor != null && networkMonitor.isOnline();
|
||||||
networkListener = online -> {
|
networkListener = online -> {
|
||||||
networkOnline = online;
|
networkOnline = online;
|
||||||
@@ -263,7 +293,6 @@ public class MapFragment extends Fragment {
|
|||||||
public void onDestroyView() {
|
public void onDestroyView() {
|
||||||
mapResumed = false;
|
mapResumed = false;
|
||||||
mapInitialized = false;
|
mapInitialized = false;
|
||||||
initialFitDone = false;
|
|
||||||
if (pollHelper != null) {
|
if (pollHelper != null) {
|
||||||
pollHelper.stop();
|
pollHelper.stop();
|
||||||
}
|
}
|
||||||
@@ -276,6 +305,7 @@ public class MapFragment extends Fragment {
|
|||||||
}
|
}
|
||||||
removeAllDeviceMarkers();
|
removeAllDeviceMarkers();
|
||||||
clearTrackLayers();
|
clearTrackLayers();
|
||||||
|
clearLiveTrackLayers();
|
||||||
deviceMarkers.clear();
|
deviceMarkers.clear();
|
||||||
if (downloadLayer != null) {
|
if (downloadLayer != null) {
|
||||||
downloadLayer.onDestroy();
|
downloadLayer.onDestroy();
|
||||||
@@ -290,7 +320,12 @@ public class MapFragment extends Fragment {
|
|||||||
bitmapRx = null;
|
bitmapRx = null;
|
||||||
bitmapTrackPoint = null;
|
bitmapTrackPoint = null;
|
||||||
mapStatus = null;
|
mapStatus = null;
|
||||||
|
mapDistance = null;
|
||||||
trackStatus = null;
|
trackStatus = null;
|
||||||
|
btnCenterMe = null;
|
||||||
|
btnCenterTx = null;
|
||||||
|
btnCenterRx = null;
|
||||||
|
btnCenterBoth = null;
|
||||||
btnTrack = null;
|
btnTrack = null;
|
||||||
trackSpinner = null;
|
trackSpinner = null;
|
||||||
pollHelper = null;
|
pollHelper = null;
|
||||||
@@ -332,8 +367,14 @@ public class MapFragment extends Fragment {
|
|||||||
if (trackStatus != null) {
|
if (trackStatus != null) {
|
||||||
trackStatus.setText(getString(R.string.track_status, pointCount));
|
trackStatus.setText(getString(R.string.track_status, pointCount));
|
||||||
}
|
}
|
||||||
if (!recording && trackId > 0) {
|
if (recording && pointCount <= 1) {
|
||||||
loadTrackList();
|
clearLiveTrackLayers();
|
||||||
|
}
|
||||||
|
if (!recording) {
|
||||||
|
clearLiveTrackLayers();
|
||||||
|
if (trackId > 0) {
|
||||||
|
loadTrackList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,9 +384,62 @@ public class MapFragment extends Fragment {
|
|||||||
trackStatus.setText(getString(R.string.track_error, message));
|
trackStatus.setText(getString(R.string.track_error, message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPointRecorded(double lat, double lon) {
|
||||||
|
if (!isAdded() || !mapResumed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireActivity().runOnUiThread(() ->
|
||||||
|
runWhenMapReady(() -> appendLiveTrackPoint(lat, lon)));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void appendLiveTrackPoint(double lat, double lon) {
|
||||||
|
if (!isMapReady() || !GeoUtils.isValidCoordinate(lat, lon)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LatLong pos = new LatLong(lat, lon);
|
||||||
|
if (!liveTrackPoints.isEmpty()) {
|
||||||
|
LatLong last = liveTrackPoints.get(liveTrackPoints.size() - 1);
|
||||||
|
if (samePosition(last, pos)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
liveTrackPoints.add(pos);
|
||||||
|
if (liveTrackPolyline == null) {
|
||||||
|
liveTrackPolyline = new Polyline(
|
||||||
|
MapsforgeBitmaps.linePaint(Color.GREEN, 4f),
|
||||||
|
AndroidGraphicFactory.INSTANCE
|
||||||
|
);
|
||||||
|
mapView.getLayerManager().getLayers().add(liveTrackPolyline);
|
||||||
|
}
|
||||||
|
liveTrackPolyline.getLatLongs().clear();
|
||||||
|
liveTrackPolyline.getLatLongs().addAll(liveTrackPoints);
|
||||||
|
if (liveTrackMarker == null) {
|
||||||
|
liveTrackMarker = new Marker(pos, bitmapTrackPoint, 0, 0);
|
||||||
|
mapView.getLayerManager().getLayers().add(liveTrackMarker);
|
||||||
|
} else {
|
||||||
|
liveTrackMarker.setLatLong(pos);
|
||||||
|
}
|
||||||
|
mapView.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearLiveTrackLayers() {
|
||||||
|
liveTrackPoints.clear();
|
||||||
|
if (mapView != null) {
|
||||||
|
if (liveTrackPolyline != null) {
|
||||||
|
mapView.getLayerManager().getLayers().remove(liveTrackPolyline);
|
||||||
|
}
|
||||||
|
if (liveTrackMarker != null) {
|
||||||
|
mapView.getLayerManager().getLayers().remove(liveTrackMarker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
liveTrackPolyline = null;
|
||||||
|
liveTrackMarker = null;
|
||||||
|
}
|
||||||
|
|
||||||
private void toggleTracking() {
|
private void toggleTracking() {
|
||||||
if (trackRecorder.isRecording()) {
|
if (trackRecorder.isRecording()) {
|
||||||
trackRecorder.stop();
|
trackRecorder.stop();
|
||||||
@@ -562,6 +656,7 @@ public class MapFragment extends Fragment {
|
|||||||
if (!isMapReady()) {
|
if (!isMapReady()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
lastDevices = devices != null ? devices : new ArrayList<>();
|
||||||
|
|
||||||
int txCount = 0;
|
int txCount = 0;
|
||||||
int rxCount = 0;
|
int rxCount = 0;
|
||||||
@@ -611,13 +706,84 @@ public class MapFragment extends Fragment {
|
|||||||
networkStatusSuffix()
|
networkStatusSuffix()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
updateGpsDistance();
|
||||||
|
|
||||||
if (!boundsPoints.isEmpty() && !userMovedMap && !initialFitDone) {
|
if (!boundsPoints.isEmpty() && !userMovedMap && !MapSessionState.initialFitDone) {
|
||||||
fitBoundsOnce(boundsPoints, onMap == 1, false);
|
fitBoundsOnce(boundsPoints, onMap == 1, false);
|
||||||
initialFitDone = true;
|
MapSessionState.initialFitDone = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateGpsDistance() {
|
||||||
|
if (mapDistance == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DeviceInfo tx = null;
|
||||||
|
DeviceInfo rx = null;
|
||||||
|
for (DeviceInfo d : lastDevices) {
|
||||||
|
if (!GeoUtils.isValidCoordinate(d.lat, d.lon)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (StatsExtractor.ROLE_TX.equals(d.role)) {
|
||||||
|
tx = d;
|
||||||
|
} else if (StatsExtractor.ROLE_RX.equals(d.role)) {
|
||||||
|
rx = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tx != null && rx != null) {
|
||||||
|
double dist = GeoUtils.haversineMeters(tx.lat, tx.lon, rx.lat, rx.lon);
|
||||||
|
mapDistance.setVisibility(View.VISIBLE);
|
||||||
|
mapDistance.setText(getString(R.string.map_gps_distance, String.format(Locale.US, "%.0f", dist)));
|
||||||
|
} else {
|
||||||
|
mapDistance.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void centerOnSelf() {
|
||||||
|
if (uploader == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String myId = uploader.getDeviceId();
|
||||||
|
for (DeviceInfo d : lastDevices) {
|
||||||
|
if (myId.equals(d.device_id) && GeoUtils.isValidCoordinate(d.lat, d.lon)) {
|
||||||
|
centerOnPoint(new LatLong(d.lat, d.lon), (byte) 14);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void centerOnRole(String role) {
|
||||||
|
for (DeviceInfo d : lastDevices) {
|
||||||
|
if (role.equals(d.role) && GeoUtils.isValidCoordinate(d.lat, d.lon)) {
|
||||||
|
centerOnPoint(new LatLong(d.lat, d.lon), (byte) 14);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void centerOnBoth() {
|
||||||
|
List<LatLong> points = new ArrayList<>();
|
||||||
|
for (DeviceInfo d : lastDevices) {
|
||||||
|
if (GeoUtils.isValidCoordinate(d.lat, d.lon)) {
|
||||||
|
points.add(new LatLong(d.lat, d.lon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!points.isEmpty()) {
|
||||||
|
userMovedMap = false;
|
||||||
|
fitBoundsOnce(points, points.size() == 1, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void centerOnPoint(LatLong point, byte zoom) {
|
||||||
|
if (!isMapReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition;
|
||||||
|
position.setCenter(point);
|
||||||
|
position.setZoomLevel(zoom);
|
||||||
|
mapView.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
/** Adjust camera only on first device load or when user picks a saved track. */
|
/** Adjust camera only on first device load or when user picks a saved track. */
|
||||||
private void fitBoundsOnce(List<LatLong> points, boolean singlePoint, boolean force) {
|
private void fitBoundsOnce(List<LatLong> points, boolean singlePoint, boolean force) {
|
||||||
if (!isMapReady() || points.isEmpty() || (!force && userMovedMap)) {
|
if (!isMapReady() || points.isEmpty() || (!force && userMovedMap)) {
|
||||||
@@ -630,7 +796,7 @@ public class MapFragment extends Fragment {
|
|||||||
}
|
}
|
||||||
if (singlePoint) {
|
if (singlePoint) {
|
||||||
position.setCenter(points.get(0));
|
position.setCenter(points.get(0));
|
||||||
position.setZoomLevel((byte) 13);
|
position.setZoomLevel((byte) 14);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
double minLat = Double.MAX_VALUE;
|
double minLat = Double.MAX_VALUE;
|
||||||
@@ -654,7 +820,7 @@ public class MapFragment extends Fragment {
|
|||||||
double lonSpan = Math.max(box.maxLongitude - box.minLongitude, 0.001);
|
double lonSpan = Math.max(box.maxLongitude - box.minLongitude, 0.001);
|
||||||
double latZoom = Math.log(h / (double) TILE_SIZE_PX / latSpan) / Math.log(2);
|
double latZoom = Math.log(h / (double) TILE_SIZE_PX / latSpan) / Math.log(2);
|
||||||
double lonZoom = Math.log(w / (double) TILE_SIZE_PX / lonSpan) / Math.log(2);
|
double lonZoom = Math.log(w / (double) TILE_SIZE_PX / lonSpan) / Math.log(2);
|
||||||
byte zoom = (byte) Math.max(8, Math.min(15, Math.floor(Math.min(latZoom, lonZoom))));
|
byte zoom = (byte) Math.max(12, Math.min(16, Math.floor(Math.min(latZoom, lonZoom))));
|
||||||
position.setZoomLevel(zoom);
|
position.setZoomLevel(zoom);
|
||||||
};
|
};
|
||||||
if (mapView.getWidth() > 0 && mapView.getHeight() > 0) {
|
if (mapView.getWidth() > 0 && mapView.getHeight() > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
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, "Пакет", 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -32,6 +32,65 @@
|
|||||||
android:textColor="#FFFFFF"
|
android:textColor="#FFFFFF"
|
||||||
android:textSize="10sp" />
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/mapDistance"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textColor="#00FF88"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCenterMe"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:minHeight="32dp"
|
||||||
|
android:text="@string/map_center_me"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCenterTx"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="2dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:minHeight="32dp"
|
||||||
|
android:text="@string/map_center_tx"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCenterRx"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="2dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:minHeight="32dp"
|
||||||
|
android:text="@string/map_center_rx"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCenterBoth"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="2dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:minHeight="32dp"
|
||||||
|
android:text="@string/map_center_both"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/mapLegend"
|
android:id="@+id/mapLegend"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -63,4 +63,23 @@
|
|||||||
<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 (430–470)</string>
|
||||||
|
<string name="at_hint_power">Мощность dBm (-9…22)</string>
|
||||||
|
<string name="at_hint_bw">Bandwidth kHz</string>
|
||||||
|
<string name="at_hint_cr">Code rate</string>
|
||||||
|
<string name="at_hint_pl">Preamble (1–64)</string>
|
||||||
|
<string name="at_hint_tm">Timeout ms (0–60000)</string>
|
||||||
|
<string name="at_hint_role">Роль после настройки</string>
|
||||||
|
<string name="at_console_toggle">▼ Консоль</string>
|
||||||
|
<string name="at_console_hide">▲ Консоль</string>
|
||||||
|
<string name="map_center_me">Я</string>
|
||||||
|
<string name="map_center_tx">TX</string>
|
||||||
|
<string name="map_center_rx">RX</string>
|
||||||
|
<string name="map_center_both">Оба</string>
|
||||||
|
<string name="map_gps_distance">GPS между устройствами: %1$s m</string>
|
||||||
|
<string name="chat_self_label">Вы</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.db
|
||||||
|
.pytest_cache
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
LORATESTER_HOST=0.0.0.0 \
|
||||||
|
LORATESTER_PORT=7634 \
|
||||||
|
LORATESTER_DB=/data/loratester.db
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 7634
|
||||||
|
|
||||||
|
CMD ["uvicorn", "fastapi_app:app", "--host", "0.0.0.0", "--port", "7634"]
|
||||||
+46
-7
@@ -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 к сервису высот) |
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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` внутри контейнера).
|
||||||
|
|
||||||
## Деплой (grigowashere.ru:7634)
|
## Деплой (grigowashere.ru:7634)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /srv/storage/disk2/services/LoraTester
|
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,19 @@ 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/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 +126,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 +146,6 @@ python -m pytest tests/ -v
|
|||||||
|
|
||||||
## Android
|
## Android
|
||||||
|
|
||||||
URL: `http://grigowashere.ru:7634`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
|
URL: `http://grigowashere.ru:7634`. На карте: **Начать/Остановить трекинг пути** — точки с 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.
@@ -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")
|
||||||
|
)
|
||||||
|
|||||||
+288
-19
@@ -1,40 +1,309 @@
|
|||||||
"""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
|
||||||
_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 build_elevation_profile(
|
||||||
|
points: list[dict[str, Any]], step_m: float = 10.0
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Resample track and fetch terrain elevations."""
|
||||||
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
+41
-1
@@ -298,10 +298,50 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
|
@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/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(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+32
-1
@@ -237,10 +237,41 @@ 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
|
||||||
|
return jsonify(build_elevation_profile(points, step))
|
||||||
|
|
||||||
|
|
||||||
|
@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/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):
|
||||||
|
|||||||
+965
-100
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
|||||||
|
/** 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'
|
||||||
|
]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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.fields && typeof o.fields === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(o.fields)) {
|
||||||
|
if (!isKnownLabel(k)) snap.extraFields[k] = String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffSnapshots(a, b) {
|
||||||
|
const changed = new Set();
|
||||||
|
if (!a || !b) return changed;
|
||||||
|
const keys = ['role', 'rssiDbm', 'snrDb', 'packet', 'payload', 'perPercent',
|
||||||
|
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm'];
|
||||||
|
const map = { role: 'role', rssiDbm: 'rssi', snrDb: 'snr', 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: '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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx) {
|
||||||
|
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"><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) {
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
html += '<details><summary>Статика</summary>';
|
||||||
|
for (const row of STATIC_ROWS) {
|
||||||
|
const cls = ch.has(row.key) ? ' class="changed"' : '';
|
||||||
|
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
|
||||||
|
}
|
||||||
|
html += '</details>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
global.RadioUI = {
|
||||||
|
roleLabel,
|
||||||
|
parseRadioSnapshot,
|
||||||
|
diffSnapshots,
|
||||||
|
renderCompareGrid,
|
||||||
|
formatRadioPanel,
|
||||||
|
DYNAMIC_ROWS,
|
||||||
|
STATIC_ROWS
|
||||||
|
};
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
Binary file not shown.
@@ -0,0 +1,64 @@
|
|||||||
|
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"]
|
||||||
Reference in New Issue
Block a user