added local api

This commit is contained in:
2026-06-11 08:38:08 +03:00
parent 81eaa95df3
commit 8fd7e85c83
39 changed files with 3224 additions and 723 deletions
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
@@ -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;
} }
} }
+187 -68
View File
@@ -12,90 +12,209 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="14sp" /> android:textSize="14sp" />
<com.google.android.material.button.MaterialButtonToggleGroup <ScrollView
android:id="@+id/atTargetGroup"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="0dp"
android:layout_marginTop="6dp" android:layout_weight="1"
app:singleSelection="true" android:fillViewport="true">
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton <LinearLayout
android:id="@+id/atTargetLocal" android:layout_width="match_parent"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:orientation="vertical">
android:text="@string/at_target_local"
android:checked="true" />
<com.google.android.material.button.MaterialButton <TextView
android:id="@+id/atTargetPeer" android:layout_width="match_parent"
style="@style/Widget.Material3.Button.OutlinedButton" android:layout_height="wrap_content"
android:layout_width="0dp" android:layout_marginTop="8dp"
android:layout_height="wrap_content" android:text="@string/at_current_values"
android:layout_weight="1" android:textStyle="bold" />
android:text="@string/at_target_peer" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<com.google.android.material.chip.ChipGroup <TextView
android:id="@+id/atQuickChips" android:id="@+id/atCurrentSnapshot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/at_command_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atCommandInput"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:fontFamily="monospace" android:fontFamily="monospace"
android:inputType="text" android:textSize="11sp" />
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<Button <com.google.android.material.textfield.TextInputLayout
android:id="@+id/atSendBtn" android:layout_width="match_parent"
android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="8dp"
android:layout_gravity="center_vertical" android:hint="@string/at_hint_fq_mhz">
android:layout_marginStart="8dp"
android:text="@string/send" />
</LinearLayout>
<LinearLayout <com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent" android:id="@+id/atInputFq"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/at_hint_power">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputPw"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberSigned" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:hint="SF">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputSf"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/at_hint_bw"
android:textSize="12sp" />
<Spinner
android:id="@+id/atBwSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/at_hint_cr"
android:textSize="12sp" />
<Spinner
android:id="@+id/atCrSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/at_hint_pl">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputPl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:hint="@string/at_hint_tm">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputTm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/at_hint_role"
android:textSize="12sp" />
<Spinner
android:id="@+id/atRoleSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/atTargetGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/atTargetLocal"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_target_local" />
<com.google.android.material.button.MaterialButton
android:id="@+id/atTargetPeer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_target_peer" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<Button
android:id="@+id/atApplyBtn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_apply" />
<Button
android:id="@+id/atStopBtn"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text="S" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<com.google.android.material.button.MaterialButton
android:id="@+id/atConsoleToggle"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:text="@string/at_console_toggle" />
android:orientation="horizontal">
<Button
android:id="@+id/atClearLog"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/at_clear_log" />
</LinearLayout>
<ScrollView <ScrollView
android:id="@+id/atConsoleScroll" android:id="@+id/atConsoleScroll"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="120dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:background="#0D1117" android:background="#0D1117"
android:padding="8dp"> android:padding="8dp"
android:visibility="gone">
<TextView <TextView
android:id="@+id/atConsole" android:id="@+id/atConsole"
+59
View File
@@ -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"
+14 -84
View File
@@ -24,6 +24,20 @@
android:textSize="12sp" android:textSize="12sp"
android:visibility="gone" /> android:visibility="gone" />
<com.grigowashere.loratester.ui.RadioComparePanel
android:id="@+id/radioComparePanel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp" />
<Button
android:id="@+id/btnPushStats"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/stats_push_peer" />
<Button <Button
android:id="@+id/btnSimulate" android:id="@+id/btnSimulate"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -31,90 +45,6 @@
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="@string/simulate_telnet" /> android:text="@string/simulate_telnet" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingEnd="6dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/stats_local_title"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statsLocalDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:fontFamily="monospace"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingStart="6dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/stats_peer_title"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statsPeerDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:fontFamily="monospace"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/btnPushStats"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:text="@string/stats_push_peer" />
<Button
android:id="@+id/btnModeTxPeer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TX→" />
<Button
android:id="@+id/btnModeRxPeer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="RX→" />
</LinearLayout>
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
+41 -4
View File
@@ -1,7 +1,44 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/chatItemText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="6dp" android:paddingStart="8dp"
android:textSize="13sp" /> android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<LinearLayout
android:id="@+id/chatBubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:background="#1A4A6E"
android:maxWidth="280dp"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/chatAuthor"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#AAAAAA"
android:textSize="10sp"
android:textStyle="bold" />
<TextView
android:id="@+id/chatText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#EEEEEE"
android:textSize="13sp" />
<TextView
android:id="@+id/chatTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#888888"
android:textSize="9sp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="4dp">
<TextView
android:id="@+id/compareTxHeader"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#E94560"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/compareRxHeader"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#4FC3F7"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<TableLayout
android:id="@+id/compareDynamicTable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stretchColumns="1,2" />
<com.google.android.material.button.MaterialButton
android:id="@+id/compareStaticToggle"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:padding="0dp"
android:text="@string/stats_static_toggle"
android:textSize="12sp" />
<TableLayout
android:id="@+id/compareStaticTable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stretchColumns="1,2"
android:visibility="gone" />
</LinearLayout>
+19
View File
@@ -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 (430470)</string>
<string name="at_hint_power">Мощность dBm (-9…22)</string>
<string name="at_hint_bw">Bandwidth kHz</string>
<string name="at_hint_cr">Code rate</string>
<string name="at_hint_pl">Preamble (164)</string>
<string name="at_hint_tm">Timeout ms (060000)</string>
<string name="at_hint_role">Роль после настройки</string>
<string name="at_console_toggle">▼ Консоль</string>
<string name="at_console_hide">▲ Консоль</string>
<string name="map_center_me">Я</string>
<string name="map_center_tx">TX</string>
<string name="map_center_rx">RX</string>
<string name="map_center_both">Оба</string>
<string name="map_gps_distance">GPS между устройствами: %1$s m</string>
<string name="chat_self_label">Вы</string>
</resources> </resources>
+8
View File
@@ -0,0 +1,8 @@
.venv
__pycache__
*.pyc
*.pyo
*.db
.pytest_cache
.git
*.md
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
LORATESTER_HOST=0.0.0.0 \
LORATESTER_PORT=7634 \
LORATESTER_DB=/data/loratester.db
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 7634
CMD ["uvicorn", "fastapi_app:app", "--host", "0.0.0.0", "--port", "7634"]
+47 -8
View File
@@ -23,14 +23,47 @@ python flask_app.py
| `LORATESTER_DB` | `./loratester.db` | | `LORATESTER_DB` | `./loratester.db` |
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) | | `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) | | `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) |
| `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` |
| `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
## 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.
+10
View File
@@ -9,3 +9,13 @@ HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
PORT = int(os.environ.get("LORATESTER_PORT", "7634")) PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000")) TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000")) TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
ELEVATION_API_URL = os.environ.get(
"LORATESTER_ELEVATION_URL",
"http://192.168.1.109:8085/v1/elevation",
).rstrip("/")
ELEVATION_PROBE_TTL_SEC = float(
os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60")
)
ELEVATION_CONNECT_TIMEOUT = float(
os.environ.get("LORATESTER_ELEVATION_TIMEOUT", "8")
)
+288 -19
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
services:
loratester:
build: .
container_name: loratester
restart: unless-stopped
ports:
- "${LORATESTER_PORT:-7634}:7634"
volumes:
- loratester-data:/data
environment:
LORATESTER_DB: /data/loratester.db
LORATESTER_PORT: "7634"
LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation}
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000}
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-10000}
volumes:
loratester-data:
+41 -1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+157
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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);
+64
View File
@@ -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"]