generated from Grigo/AndroidTemplate
Initial commit: LoraTester Android + server
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.grigowashere.loratester.api.DeviceCommand;
|
||||
import com.grigowashere.loratester.api.PairedTrackSession;
|
||||
import com.grigowashere.loratester.api.ServerApi;
|
||||
import com.grigowashere.loratester.telnet.AtCommands;
|
||||
import com.grigowashere.loratester.telnet.TelnetClient;
|
||||
import com.grigowashere.loratester.track.TrackRecorder;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class CommandPoller {
|
||||
|
||||
private static final String TAG = "CommandPoller";
|
||||
private static final long COMMAND_POLL_MS = 2000;
|
||||
private static final long PAIRED_POLL_MS = 1500;
|
||||
|
||||
private final ServerApi serverApi;
|
||||
private final String deviceId;
|
||||
private final TelemetryUploader uploader;
|
||||
private final TrackRecorder trackRecorder;
|
||||
private final PeerStatsCache peerStatsCache;
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
private volatile long pendingAckSessionId = -1;
|
||||
private volatile long startedSessionId = -1;
|
||||
|
||||
public CommandPoller(
|
||||
ServerApi serverApi,
|
||||
String deviceId,
|
||||
TelemetryUploader uploader,
|
||||
TrackRecorder trackRecorder,
|
||||
PeerStatsCache peerStatsCache
|
||||
) {
|
||||
this.serverApi = serverApi;
|
||||
this.deviceId = deviceId;
|
||||
this.uploader = uploader;
|
||||
this.trackRecorder = trackRecorder;
|
||||
this.peerStatsCache = peerStatsCache;
|
||||
trackRecorder.setPairedListener(new TrackRecorder.Listener() {
|
||||
@Override
|
||||
public void onStateChanged(boolean recording, int pointCount, long trackId) {
|
||||
if (recording && trackId > 0 && pendingAckSessionId > 0) {
|
||||
long sid = pendingAckSessionId;
|
||||
pendingAckSessionId = -1;
|
||||
executor.execute(() -> ackSession(sid, trackId));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
Log.w(TAG, "track: " + message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public PeerStatsCache getPeerStatsCache() {
|
||||
return peerStatsCache;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
scheduleCommandPoll();
|
||||
schedulePairedPoll();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running.set(false);
|
||||
}
|
||||
|
||||
private void scheduleCommandPoll() {
|
||||
executor.execute(() -> {
|
||||
if (running.get()) {
|
||||
pollCommands();
|
||||
}
|
||||
if (running.get()) {
|
||||
mainHandler.postDelayed(this::scheduleCommandPoll, COMMAND_POLL_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void schedulePairedPoll() {
|
||||
executor.execute(() -> {
|
||||
if (running.get()) {
|
||||
pollPairedSession();
|
||||
}
|
||||
if (running.get()) {
|
||||
mainHandler.postDelayed(this::schedulePairedPoll, PAIRED_POLL_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void pollCommands() {
|
||||
try {
|
||||
List<DeviceCommand> cmds = serverApi.pollPendingCommands(deviceId);
|
||||
for (DeviceCommand cmd : cmds) {
|
||||
execute(cmd);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "command poll failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void execute(DeviceCommand cmd) {
|
||||
if (cmd == null || cmd.kind == null) {
|
||||
return;
|
||||
}
|
||||
switch (cmd.kind) {
|
||||
case "at" -> {
|
||||
String line = cmd.payload != null && cmd.payload.get("line") != null
|
||||
? String.valueOf(cmd.payload.get("line")) : null;
|
||||
if (line != null) {
|
||||
uploader.sendAtCommand(line, r ->
|
||||
Log.i(TAG, "remote AT " + line + " -> " + r));
|
||||
}
|
||||
}
|
||||
case "mode" -> {
|
||||
String role = cmd.payload != null && cmd.payload.get("role") != null
|
||||
? String.valueOf(cmd.payload.get("role")) : null;
|
||||
if ("TX".equalsIgnoreCase(role)) {
|
||||
uploader.sendAtCommand(AtCommands.TRANSMIT, r -> {});
|
||||
} else if ("RX".equalsIgnoreCase(role)) {
|
||||
uploader.sendAtCommand(AtCommands.RECEIVE, r -> {});
|
||||
}
|
||||
}
|
||||
case "stats_push" -> peerStatsCache.updateFromPayload(cmd.payload);
|
||||
default -> Log.w(TAG, "unknown kind " + cmd.kind);
|
||||
}
|
||||
}
|
||||
|
||||
private void pollPairedSession() {
|
||||
try {
|
||||
Map<String, Object> resp = serverApi.getActivePairedTrack();
|
||||
Object sessionObj = resp.get("session");
|
||||
if (!(sessionObj instanceof Map)) {
|
||||
return;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> m = (Map<String, Object>) sessionObj;
|
||||
PairedTrackSession session = mapSession(m);
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
boolean inSession = deviceId.equals(session.device_a)
|
||||
|| deviceId.equals(session.device_b);
|
||||
if (!inSession) {
|
||||
return;
|
||||
}
|
||||
if (!session.ready || trackRecorder.isRecording()) {
|
||||
return;
|
||||
}
|
||||
if (startedSessionId == session.id) {
|
||||
return;
|
||||
}
|
||||
Long myTrack = deviceId.equals(session.device_a)
|
||||
? session.track_id_a : session.track_id_b;
|
||||
if (myTrack != null && myTrack > 0) {
|
||||
startedSessionId = session.id;
|
||||
return;
|
||||
}
|
||||
startedSessionId = session.id;
|
||||
pendingAckSessionId = session.id;
|
||||
mainHandler.post(trackRecorder::start);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "paired poll failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void ackSession(long sessionId, long trackId) {
|
||||
try {
|
||||
serverApi.ackPairedTrack(sessionId, deviceId, trackId);
|
||||
Log.i(TAG, "paired ack session=" + sessionId + " track=" + trackId);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "paired ack failed", e);
|
||||
pendingAckSessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
private static PairedTrackSession mapSession(Map<String, Object> m) {
|
||||
if (m == null) {
|
||||
return null;
|
||||
}
|
||||
PairedTrackSession s = new PairedTrackSession();
|
||||
Object id = m.get("id");
|
||||
if (id instanceof Number) {
|
||||
s.id = ((Number) id).longValue();
|
||||
}
|
||||
s.device_a = str(m.get("device_a"));
|
||||
s.device_b = str(m.get("device_b"));
|
||||
s.initiator = str(m.get("initiator"));
|
||||
s.status = str(m.get("status"));
|
||||
s.start_at = num(m.get("start_at"));
|
||||
s.created_at = num(m.get("created_at"));
|
||||
s.server_time = num(m.get("server_time"));
|
||||
Object ready = m.get("ready");
|
||||
s.ready = ready instanceof Boolean && (Boolean) ready;
|
||||
s.track_id_a = longOrNull(m.get("track_id_a"));
|
||||
s.track_id_b = longOrNull(m.get("track_id_b"));
|
||||
return s;
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o != null ? String.valueOf(o) : null;
|
||||
}
|
||||
|
||||
private static double num(Object o) {
|
||||
return o instanceof Number ? ((Number) o).doubleValue() : 0;
|
||||
}
|
||||
|
||||
private static Long longOrNull(Object o) {
|
||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||
}
|
||||
|
||||
public void postCommandToPeer(String peerId, String kind, Map<String, Object> payload) {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
serverApi.postCommand(deviceId, peerId, kind, payload);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "post command failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void startPairedTrack(Runnable onDone, java.util.function.Consumer<String> onError) {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("device_id", deviceId);
|
||||
serverApi.startPairedTrack(body);
|
||||
startedSessionId = -1;
|
||||
pendingAckSessionId = -1;
|
||||
if (onDone != null) {
|
||||
mainHandler.post(onDone);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "start paired failed", e);
|
||||
if (onError != null) {
|
||||
mainHandler.post(() -> onError.accept(
|
||||
e.getMessage() != null ? e.getMessage() : "error"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ public class LoraApp extends Application {
|
||||
private SettingsRepository settingsRepository;
|
||||
private TrackRecorder trackRecorder;
|
||||
private NetworkMonitor networkMonitor;
|
||||
private PeerStatsCache peerStatsCache;
|
||||
private CommandPoller commandPoller;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
@@ -23,12 +25,23 @@ public class LoraApp extends Application {
|
||||
networkMonitor = new NetworkMonitor(this);
|
||||
networkMonitor.start();
|
||||
telemetryUploader = new TelemetryUploader(this, settingsRepository, networkMonitor);
|
||||
peerStatsCache = new PeerStatsCache();
|
||||
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
|
||||
String deviceId = settingsRepository.getOrCreateDeviceId();
|
||||
trackRecorder = new TrackRecorder(
|
||||
new ServerApi(settingsRepository.getServerUrl()),
|
||||
serverApi,
|
||||
telemetryUploader,
|
||||
settingsRepository.getOrCreateDeviceId(),
|
||||
deviceId,
|
||||
networkMonitor
|
||||
);
|
||||
commandPoller = new CommandPoller(
|
||||
serverApi,
|
||||
deviceId,
|
||||
telemetryUploader,
|
||||
trackRecorder,
|
||||
peerStatsCache
|
||||
);
|
||||
commandPoller.start();
|
||||
}
|
||||
|
||||
public NetworkMonitor getNetworkMonitor() {
|
||||
@@ -47,12 +60,35 @@ public class LoraApp extends Application {
|
||||
return trackRecorder;
|
||||
}
|
||||
|
||||
public PeerStatsCache getPeerStatsCache() {
|
||||
return peerStatsCache;
|
||||
}
|
||||
|
||||
public CommandPoller getCommandPoller() {
|
||||
return commandPoller;
|
||||
}
|
||||
|
||||
public void refreshTrackRecorder() {
|
||||
if (commandPoller != null) {
|
||||
commandPoller.stop();
|
||||
}
|
||||
if (peerStatsCache == null) {
|
||||
peerStatsCache = new PeerStatsCache();
|
||||
}
|
||||
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
|
||||
trackRecorder = new TrackRecorder(
|
||||
new ServerApi(settingsRepository.getServerUrl()),
|
||||
serverApi,
|
||||
telemetryUploader,
|
||||
settingsRepository.getOrCreateDeviceId(),
|
||||
networkMonitor
|
||||
);
|
||||
commandPoller = new CommandPoller(
|
||||
serverApi,
|
||||
settingsRepository.getOrCreateDeviceId(),
|
||||
telemetryUploader,
|
||||
trackRecorder,
|
||||
peerStatsCache
|
||||
);
|
||||
commandPoller.start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import com.grigowashere.loratester.api.DeviceInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class PeerDevices {
|
||||
|
||||
private static final long ONLINE_MS = 30_000;
|
||||
|
||||
private PeerDevices() {
|
||||
}
|
||||
|
||||
public static Result resolve(List<DeviceInfo> devices, String selfId) {
|
||||
if (devices == null || selfId == null) {
|
||||
return Result.error("no_devices");
|
||||
}
|
||||
List<DeviceInfo> android = new ArrayList<>();
|
||||
long now = System.currentTimeMillis();
|
||||
for (DeviceInfo d : devices) {
|
||||
if (d.device_id != null && d.device_id.startsWith("android-")) {
|
||||
android.add(d);
|
||||
}
|
||||
}
|
||||
if (android.size() != 2) {
|
||||
return Result.error("expected_two");
|
||||
}
|
||||
String peer = null;
|
||||
int online = 0;
|
||||
for (DeviceInfo d : android) {
|
||||
if (d.last_seen > 0 && (now / 1000.0 - d.last_seen) <= 30) {
|
||||
online++;
|
||||
}
|
||||
if (!selfId.equals(d.device_id)) {
|
||||
peer = d.device_id;
|
||||
}
|
||||
}
|
||||
if (peer == null) {
|
||||
return Result.error("peer_missing");
|
||||
}
|
||||
return new Result(peer, android.size(), online);
|
||||
}
|
||||
|
||||
public static final class Result {
|
||||
public final String peerId;
|
||||
public final int deviceCount;
|
||||
public final int onlineCount;
|
||||
public final String error;
|
||||
|
||||
private Result(String peerId, int deviceCount, int onlineCount) {
|
||||
this.peerId = peerId;
|
||||
this.deviceCount = deviceCount;
|
||||
this.onlineCount = onlineCount;
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
private Result(String error) {
|
||||
this.peerId = null;
|
||||
this.deviceCount = 0;
|
||||
this.onlineCount = 0;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
static Result error(String code) {
|
||||
return new Result(code);
|
||||
}
|
||||
|
||||
public boolean ok() {
|
||||
return peerId != null;
|
||||
}
|
||||
|
||||
public boolean bothOnline() {
|
||||
return ok() && onlineCount >= 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class PeerStatsCache {
|
||||
|
||||
public static final class Snapshot {
|
||||
public final String meta;
|
||||
public final String role;
|
||||
public final Double rssi;
|
||||
public final long atMs;
|
||||
|
||||
public Snapshot(String meta, String role, Double rssi, long atMs) {
|
||||
this.meta = meta;
|
||||
this.role = role;
|
||||
this.rssi = rssi;
|
||||
this.atMs = atMs;
|
||||
}
|
||||
}
|
||||
|
||||
private final AtomicReference<Snapshot> snapshot = new AtomicReference<>();
|
||||
|
||||
public void updateFromPayload(Map<String, Object> payload) {
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
String meta = payload.get("meta") != null ? String.valueOf(payload.get("meta")) : null;
|
||||
String role = payload.get("role") != null ? String.valueOf(payload.get("role")) : null;
|
||||
Double rssi = null;
|
||||
Object r = payload.get("rssi");
|
||||
if (r instanceof Number) {
|
||||
rssi = ((Number) r).doubleValue();
|
||||
}
|
||||
snapshot.set(new Snapshot(meta, role, rssi, System.currentTimeMillis()));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Snapshot get() {
|
||||
return snapshot.get();
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
snapshot.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class DeviceCommand {
|
||||
public long id;
|
||||
public String from_device_id;
|
||||
public String to_device_id;
|
||||
public String kind;
|
||||
public Map<String, Object> payload;
|
||||
public double created_at;
|
||||
public Double delivered_at;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
public class PairedTrackSession {
|
||||
public long id;
|
||||
public String device_a;
|
||||
public String device_b;
|
||||
public String initiator;
|
||||
public String status;
|
||||
public double start_at;
|
||||
public Long track_id_a;
|
||||
public Long track_id_b;
|
||||
public double created_at;
|
||||
public double server_time;
|
||||
public boolean ready;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ public class ServerApi {
|
||||
private static final Type TELEMETRY_HISTORY =
|
||||
new TypeToken<List<TelemetryHistoryItem>>() {}.getType();
|
||||
private static final Type TRACK_LIST = new TypeToken<List<TrackInfo>>() {}.getType();
|
||||
private static final Type COMMAND_LIST = new TypeToken<List<DeviceCommand>>() {}.getType();
|
||||
|
||||
private final String baseUrl;
|
||||
private final OkHttpClient client;
|
||||
@@ -114,6 +115,73 @@ public class ServerApi {
|
||||
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
|
||||
}
|
||||
|
||||
public void postCommand(
|
||||
String fromDeviceId,
|
||||
String toDeviceId,
|
||||
String kind,
|
||||
Map<String, Object> payload
|
||||
) throws IOException {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("from_device_id", fromDeviceId);
|
||||
body.put("to_device_id", toDeviceId);
|
||||
body.put("kind", kind);
|
||||
if (payload != null) {
|
||||
body.put("payload", payload);
|
||||
}
|
||||
postJson("/api/commands", body, true);
|
||||
}
|
||||
|
||||
public List<DeviceCommand> pollPendingCommands(String deviceId) throws IOException {
|
||||
String path = "/api/commands/pending?device_id="
|
||||
+ java.net.URLEncoder.encode(deviceId, "UTF-8") + "&limit=20";
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.header(HEADER_LORA_CLIENT, CLIENT_ANDROID)
|
||||
.get()
|
||||
.build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
return GSON.fromJson(response.body().string(), COMMAND_LIST);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> startPairedTrack(Map<String, Object> body) throws IOException {
|
||||
return postJsonMap("/api/paired-tracks/start", body, true);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getActivePairedTrack() throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + "/api/paired-tracks/active")
|
||||
.get()
|
||||
.build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
return GSON.fromJson(response.body().string(), Map.class);
|
||||
}
|
||||
}
|
||||
|
||||
public void ackPairedTrack(long sessionId, String deviceId, long trackId) throws IOException {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("session_id", sessionId);
|
||||
body.put("device_id", deviceId);
|
||||
body.put("track_id", trackId);
|
||||
postJson("/api/paired-tracks/ack", body, true);
|
||||
}
|
||||
|
||||
public void cancelPairedTrack(Long sessionId) throws IOException {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
if (sessionId != null) {
|
||||
body.put("session_id", sessionId);
|
||||
}
|
||||
postJson("/api/paired-tracks/cancel", body, false);
|
||||
}
|
||||
|
||||
public TrackDetail getTrack(long trackId) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + "/api/tracks/" + trackId)
|
||||
|
||||
@@ -55,6 +55,7 @@ public class TrackRecorder {
|
||||
private ScheduledFuture<?> sampleTask;
|
||||
private ScheduledFuture<?> flushTask;
|
||||
private Listener listener;
|
||||
private Listener pairedListener;
|
||||
|
||||
public TrackRecorder(
|
||||
ServerApi serverApi,
|
||||
@@ -77,6 +78,10 @@ public class TrackRecorder {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void setPairedListener(Listener pairedListener) {
|
||||
this.pairedListener = pairedListener;
|
||||
}
|
||||
|
||||
public void updateLocation(double lat, double lon, double altitude) {
|
||||
if (GeoUtils.isValidCoordinate(lat, lon)) {
|
||||
this.lat = lat;
|
||||
@@ -230,16 +235,24 @@ public class TrackRecorder {
|
||||
}
|
||||
|
||||
private void notifyState() {
|
||||
if (listener == null) {
|
||||
return;
|
||||
}
|
||||
mainHandler.post(() -> listener.onStateChanged(recording, totalPoints, trackId));
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) {
|
||||
listener.onStateChanged(recording, totalPoints, trackId);
|
||||
}
|
||||
if (pairedListener != null) {
|
||||
pairedListener.onStateChanged(recording, totalPoints, trackId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void notifyError(String msg) {
|
||||
if (listener == null) {
|
||||
return;
|
||||
}
|
||||
mainHandler.post(() -> listener.onError(msg));
|
||||
mainHandler.post(() -> {
|
||||
if (listener != null) {
|
||||
listener.onError(msg);
|
||||
}
|
||||
if (pairedListener != null) {
|
||||
pairedListener.onError(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,29 +14,44 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
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.grigowashere.loratester.CommandPoller;
|
||||
import com.grigowashere.loratester.LoraApp;
|
||||
import com.grigowashere.loratester.PeerDevices;
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
import com.grigowashere.loratester.api.DeviceInfo;
|
||||
import com.grigowashere.loratester.telnet.AtCommands;
|
||||
import com.grigowashere.loratester.telnet.TelnetClient;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class AtFragment extends Fragment {
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private FragmentPollHelper pollHelper;
|
||||
private TelemetryUploader uploader;
|
||||
private CommandPoller commandPoller;
|
||||
private TextView atStatus;
|
||||
private TextView atConsole;
|
||||
private ScrollView atConsoleScroll;
|
||||
private TextInputEditText atCommandInput;
|
||||
private MaterialButtonToggleGroup atTargetGroup;
|
||||
private String lastConsole = "";
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
|
||||
LoraApp app = (LoraApp) context.getApplicationContext();
|
||||
uploader = app.getTelemetryUploader();
|
||||
commandPoller = app.getCommandPoller();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -55,11 +70,16 @@ public class AtFragment extends Fragment {
|
||||
atConsole = view.findViewById(R.id.atConsole);
|
||||
atConsoleScroll = view.findViewById(R.id.atConsoleScroll);
|
||||
atCommandInput = view.findViewById(R.id.atCommandInput);
|
||||
atTargetGroup = view.findViewById(R.id.atTargetGroup);
|
||||
ChipGroup chips = view.findViewById(R.id.atQuickChips);
|
||||
Button sendBtn = view.findViewById(R.id.atSendBtn);
|
||||
Button clearLog = view.findViewById(R.id.atClearLog);
|
||||
pollHelper = new FragmentPollHelper(this, this::refreshConsole);
|
||||
|
||||
if (atTargetGroup != null) {
|
||||
atTargetGroup.check(R.id.atTargetLocal);
|
||||
}
|
||||
|
||||
addQuickChip(chips, "AT+H", AtCommands.HELP);
|
||||
addQuickChip(chips, "AT+TX", AtCommands.TRANSMIT);
|
||||
addQuickChip(chips, "AT+RX", AtCommands.RECEIVE);
|
||||
@@ -85,6 +105,10 @@ public class AtFragment extends Fragment {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPeerTarget() {
|
||||
return atTargetGroup != null && atTargetGroup.getCheckedButtonId() == R.id.atTargetPeer;
|
||||
}
|
||||
|
||||
private void addQuickChip(ChipGroup group, String label, String command) {
|
||||
Chip chip = new Chip(requireContext());
|
||||
chip.setText(label);
|
||||
@@ -109,6 +133,10 @@ public class AtFragment extends Fragment {
|
||||
if (uploader == null || !isAdded()) {
|
||||
return;
|
||||
}
|
||||
if (isPeerTarget()) {
|
||||
sendToPeer(command);
|
||||
return;
|
||||
}
|
||||
uploader.sendAtCommand(command, result -> {
|
||||
if (!isAdded()) {
|
||||
return;
|
||||
@@ -126,6 +154,43 @@ public class AtFragment extends Fragment {
|
||||
});
|
||||
}
|
||||
|
||||
private void sendToPeer(String command) {
|
||||
if (commandPoller == null) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||
PeerDevices.Result peer = PeerDevices.resolve(
|
||||
devices, uploader.getDeviceId());
|
||||
if (!peer.ok()) {
|
||||
showToast(R.string.at_peer_unavailable);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("line", command);
|
||||
commandPoller.postCommandToPeer(peer.peerId, "at", payload);
|
||||
showToast(getString(R.string.at_sent_to_peer, peer.peerId));
|
||||
} catch (Exception e) {
|
||||
showToast(R.string.stats_push_failed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showToast(int resId) {
|
||||
if (isAdded()) {
|
||||
requireActivity().runOnUiThread(() ->
|
||||
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show());
|
||||
}
|
||||
}
|
||||
|
||||
private void showToast(String msg) {
|
||||
if (isAdded()) {
|
||||
requireActivity().runOnUiThread(() ->
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show());
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshConsole() {
|
||||
if (!isAdded() || uploader == null || atStatus == null) {
|
||||
return;
|
||||
@@ -182,7 +247,14 @@ public class AtFragment extends Fragment {
|
||||
atConsole = null;
|
||||
atConsoleScroll = null;
|
||||
atCommandInput = null;
|
||||
atTargetGroup = null;
|
||||
pollHelper = null;
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,15 @@ import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import com.grigowashere.loratester.CommandPoller;
|
||||
import com.grigowashere.loratester.LoraApp;
|
||||
import com.grigowashere.loratester.PeerDevices;
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
import com.grigowashere.loratester.api.DeviceInfo;
|
||||
@@ -77,12 +80,14 @@ public class MapFragment extends Fragment {
|
||||
private FragmentPollHelper pollHelper;
|
||||
private TelemetryUploader uploader;
|
||||
private TrackRecorder trackRecorder;
|
||||
private CommandPoller commandPoller;
|
||||
private MapView mapView;
|
||||
private TileDownloadLayer downloadLayer;
|
||||
private TileCache tileCache;
|
||||
private TextView mapStatus;
|
||||
private TextView trackStatus;
|
||||
private Button btnTrack;
|
||||
private Button btnPairedTrack;
|
||||
private Spinner trackSpinner;
|
||||
private List<TrackInfo> savedTracks = new ArrayList<>();
|
||||
private boolean mapResumed;
|
||||
@@ -105,6 +110,7 @@ public class MapFragment extends Fragment {
|
||||
LoraApp app = (LoraApp) context.getApplicationContext();
|
||||
uploader = app.getTelemetryUploader();
|
||||
trackRecorder = app.getTrackRecorder();
|
||||
commandPoller = app.getCommandPoller();
|
||||
networkMonitor = app.getNetworkMonitor();
|
||||
}
|
||||
|
||||
@@ -124,6 +130,7 @@ public class MapFragment extends Fragment {
|
||||
mapStatus = view.findViewById(R.id.mapStatus);
|
||||
trackStatus = view.findViewById(R.id.trackStatus);
|
||||
btnTrack = view.findViewById(R.id.btnTrack);
|
||||
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
|
||||
trackSpinner = view.findViewById(R.id.trackSpinner);
|
||||
pollHelper = new FragmentPollHelper(this, this::refreshDevices);
|
||||
|
||||
@@ -143,6 +150,9 @@ public class MapFragment extends Fragment {
|
||||
|
||||
setupMapView();
|
||||
btnTrack.setOnClickListener(v -> toggleTracking());
|
||||
if (btnPairedTrack != null) {
|
||||
btnPairedTrack.setOnClickListener(v -> startPairedTracking());
|
||||
}
|
||||
setupTrackRecorderListener();
|
||||
setupTrackSpinnerListener();
|
||||
}
|
||||
@@ -344,6 +354,42 @@ public class MapFragment extends Fragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void startPairedTracking() {
|
||||
if (commandPoller == null || uploader == null || !isAdded()) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||
PeerDevices.Result peer = PeerDevices.resolve(devices, uploader.getDeviceId());
|
||||
if (!peer.bothOnline()) {
|
||||
requireActivity().runOnUiThread(() ->
|
||||
Toast.makeText(requireContext(), R.string.track_paired_need_two,
|
||||
Toast.LENGTH_SHORT).show());
|
||||
return;
|
||||
}
|
||||
commandPoller.startPairedTrack(
|
||||
() -> {
|
||||
if (isAdded()) {
|
||||
Toast.makeText(requireContext(), R.string.track_paired_started,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
},
|
||||
msg -> {
|
||||
if (isAdded()) {
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
if (isAdded()) {
|
||||
requireActivity().runOnUiThread(() ->
|
||||
Toast.makeText(requireContext(), R.string.track_paired_need_two,
|
||||
Toast.LENGTH_SHORT).show());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void loadTrackList() {
|
||||
if (uploader == null || !mapResumed) {
|
||||
return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -14,7 +15,10 @@ import androidx.fragment.app.Fragment;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.grigowashere.loratester.CommandPoller;
|
||||
import com.grigowashere.loratester.LoraApp;
|
||||
import com.grigowashere.loratester.PeerDevices;
|
||||
import com.grigowashere.loratester.PeerStatsCache;
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
import com.grigowashere.loratester.api.DeviceInfo;
|
||||
@@ -25,8 +29,10 @@ import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@@ -39,14 +45,21 @@ public class StatsFragment extends Fragment {
|
||||
DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault());
|
||||
private FragmentPollHelper pollHelper;
|
||||
private TelemetryUploader uploader;
|
||||
private CommandPoller commandPoller;
|
||||
private PeerStatsCache peerStatsCache;
|
||||
private TextView statsStatus;
|
||||
private TextView statsDetails;
|
||||
private TextView statsPeerWarning;
|
||||
private TextView statsLocalDetails;
|
||||
private TextView statsPeerDetails;
|
||||
private RecyclerView statsHistoryList;
|
||||
private final HistoryAdapter historyAdapter = new HistoryAdapter();
|
||||
|
||||
private StatsExtractor.ExtractedStats cachedLocal;
|
||||
private DeviceInfo cachedServer;
|
||||
private DeviceInfo cachedPeer;
|
||||
private int cachedDeviceCount;
|
||||
private String cachedPeerId;
|
||||
private String cachedPeerError;
|
||||
private String cachedError;
|
||||
|
||||
private final TelemetryUploader.StatsListener statsListener = stats -> {
|
||||
@@ -58,7 +71,10 @@ public class StatsFragment extends Fragment {
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
|
||||
LoraApp app = (LoraApp) context.getApplicationContext();
|
||||
uploader = app.getTelemetryUploader();
|
||||
commandPoller = app.getCommandPoller();
|
||||
peerStatsCache = app.getPeerStatsCache();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -74,11 +90,16 @@ public class StatsFragment extends Fragment {
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
statsStatus = view.findViewById(R.id.statsStatus);
|
||||
statsDetails = view.findViewById(R.id.statsDetails);
|
||||
statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
|
||||
statsLocalDetails = view.findViewById(R.id.statsLocalDetails);
|
||||
statsPeerDetails = view.findViewById(R.id.statsPeerDetails);
|
||||
statsHistoryList = view.findViewById(R.id.statsHistoryList);
|
||||
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
statsHistoryList.setAdapter(historyAdapter);
|
||||
Button btnSimulate = view.findViewById(R.id.btnSimulate);
|
||||
Button btnPushStats = view.findViewById(R.id.btnPushStats);
|
||||
Button btnModeTx = view.findViewById(R.id.btnModeTxPeer);
|
||||
Button btnModeRx = view.findViewById(R.id.btnModeRxPeer);
|
||||
pollHelper = new FragmentPollHelper(this, this::refresh);
|
||||
|
||||
btnSimulate.setOnClickListener(v -> {
|
||||
@@ -94,6 +115,50 @@ public class StatsFragment extends Fragment {
|
||||
statsStatus.setText(R.string.simulate_sent);
|
||||
}
|
||||
});
|
||||
|
||||
btnPushStats.setOnClickListener(v -> pushStatsToPeer());
|
||||
btnModeTx.setOnClickListener(v -> sendModeToPeer("TX"));
|
||||
btnModeRx.setOnClickListener(v -> sendModeToPeer("RX"));
|
||||
}
|
||||
|
||||
private void pushStatsToPeer() {
|
||||
if (commandPoller == null || cachedPeerId == null) {
|
||||
toast(R.string.at_peer_unavailable);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
String meta = pickMetaJson(true);
|
||||
if (meta != null) {
|
||||
payload.put("meta", meta);
|
||||
}
|
||||
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);
|
||||
}
|
||||
commandPoller.postCommandToPeer(cachedPeerId, "stats_push", payload);
|
||||
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) {
|
||||
if (isAdded()) {
|
||||
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -126,7 +191,9 @@ public class StatsFragment extends Fragment {
|
||||
pollHelper.stop();
|
||||
}
|
||||
statsStatus = null;
|
||||
statsDetails = null;
|
||||
statsPeerWarning = null;
|
||||
statsLocalDetails = null;
|
||||
statsPeerDetails = null;
|
||||
statsHistoryList = null;
|
||||
pollHelper = null;
|
||||
super.onDestroyView();
|
||||
@@ -139,7 +206,7 @@ public class StatsFragment extends Fragment {
|
||||
}
|
||||
|
||||
private void postRender() {
|
||||
if (!isAdded() || statsDetails == null) {
|
||||
if (!isAdded() || statsLocalDetails == null) {
|
||||
return;
|
||||
}
|
||||
requireActivity().runOnUiThread(this::renderDetails);
|
||||
@@ -161,15 +228,19 @@ public class StatsFragment extends Fragment {
|
||||
List<TelemetryHistoryItem> history = null;
|
||||
try {
|
||||
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||
DeviceInfo self = null;
|
||||
cachedDeviceCount = devices.size();
|
||||
PeerDevices.Result peer = PeerDevices.resolve(devices, deviceId);
|
||||
cachedPeerId = peer.peerId;
|
||||
cachedPeerError = peer.error;
|
||||
cachedPeer = null;
|
||||
cachedServer = null;
|
||||
for (DeviceInfo d : devices) {
|
||||
if (deviceId.equals(d.device_id)) {
|
||||
self = d;
|
||||
break;
|
||||
cachedServer = d;
|
||||
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
|
||||
cachedPeer = d;
|
||||
}
|
||||
}
|
||||
cachedServer = self;
|
||||
cachedDeviceCount = devices.size();
|
||||
cachedError = null;
|
||||
history = uploader.getServerApi().getTelemetryHistory(deviceId, 30);
|
||||
} catch (Exception e) {
|
||||
@@ -191,75 +262,117 @@ public class StatsFragment extends Fragment {
|
||||
}
|
||||
|
||||
private void renderDetails() {
|
||||
if (!isAdded() || statsDetails == null || uploader == null) {
|
||||
if (!isAdded() || statsLocalDetails == null || uploader == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(getString(R.string.devices_on_server, cachedDeviceCount)).append("\n");
|
||||
sb.append(getString(
|
||||
uploader.isTelnetConnected() ? R.string.telnet_connected : R.string.telnet_disconnected
|
||||
));
|
||||
long at = uploader.getLastStatsAtMs();
|
||||
if (at > 0) {
|
||||
sb.append(" · ").append(getString(R.string.stats_updated_at, timeFormat.format(new Date(at))));
|
||||
if (statsPeerWarning != null) {
|
||||
if (cachedPeerError != null) {
|
||||
statsPeerWarning.setVisibility(View.VISIBLE);
|
||||
statsPeerWarning.setText(
|
||||
getString(R.string.stats_two_devices_required, cachedDeviceCount));
|
||||
} else {
|
||||
statsPeerWarning.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
sb.append("\n\n");
|
||||
|
||||
String meta = pickMetaJson();
|
||||
statsLocalDetails.setText(formatDeviceBlock(true));
|
||||
statsPeerDetails.setText(formatDeviceBlock(false));
|
||||
}
|
||||
|
||||
private CharSequence formatDeviceBlock(boolean local) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (local) {
|
||||
sb.append(uploader.isTelnetConnected()
|
||||
? getString(R.string.telnet_connected)
|
||||
: getString(R.string.telnet_disconnected));
|
||||
long at = uploader.getLastStatsAtMs();
|
||||
if (at > 0) {
|
||||
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 (cachedError != null) {
|
||||
} else if (local && cachedError != null) {
|
||||
sb.append(getString(R.string.stats_error, cachedError)).append("\n");
|
||||
} else {
|
||||
} else if (local) {
|
||||
sb.append(getString(R.string.no_telemetry_yet)).append("\n");
|
||||
}
|
||||
|
||||
Double rssi = pickRssi();
|
||||
sb.append("\nСигнал (dBm): ").append(rssi != null ? rssi : "—").append("\n");
|
||||
|
||||
Double lat = null;
|
||||
Double lon = null;
|
||||
if (cachedServer != null) {
|
||||
lat = cachedServer.lat;
|
||||
lon = cachedServer.lon;
|
||||
if (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");
|
||||
}
|
||||
|
||||
statsDetails.setText(sb.toString());
|
||||
}
|
||||
|
||||
private String pickMetaJson() {
|
||||
boolean telnet = uploader.isTelnetConnected();
|
||||
if (telnet && cachedLocal != null && cachedLocal.metaJson != null) {
|
||||
return cachedLocal.metaJson;
|
||||
}
|
||||
if (cachedServer != null && cachedServer.meta != null && !cachedServer.meta.isEmpty()) {
|
||||
return cachedServer.meta;
|
||||
}
|
||||
if (cachedLocal != null && cachedLocal.metaJson != null) {
|
||||
return cachedLocal.metaJson;
|
||||
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 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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
@@ -11,6 +12,32 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<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:singleSelection="true"
|
||||
app:selectionRequired="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"
|
||||
android:checked="true" />
|
||||
|
||||
<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>
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/atQuickChips"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -50,6 +50,16 @@
|
||||
android:text="@string/track_start"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPairedTrack"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:minHeight="36dp"
|
||||
android:text="@string/track_paired_start"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/trackStatus"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -15,20 +15,105 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statsPeerWarning"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="#FF9800"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSimulate"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/simulate_telnet" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statsDetails"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="12sp" />
|
||||
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
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -49,4 +49,18 @@
|
||||
<string name="track_error">Трек: %1$s</string>
|
||||
<string name="track_spinner_hint">Сохранённые треки</string>
|
||||
<string name="track_none">— нет треков —</string>
|
||||
<string name="stats_local_title">Это устройство</string>
|
||||
<string name="stats_peer_title">Другое устройство</string>
|
||||
<string name="stats_peer_absent">Нет данных (ожидается 2 устройства online)</string>
|
||||
<string name="stats_two_devices_required">Нужно ровно 2 устройства на сервере (сейчас %1$d)</string>
|
||||
<string name="stats_push_peer">Отправить статистику на другое</string>
|
||||
<string name="stats_pushed">Статистика отправлена</string>
|
||||
<string name="stats_push_failed">Не удалось отправить</string>
|
||||
<string name="at_target_local">Локально</string>
|
||||
<string name="at_target_peer">На другое устройство</string>
|
||||
<string name="at_sent_to_peer">Команда отправлена на %1$s</string>
|
||||
<string name="at_peer_unavailable">Нет второго устройства</string>
|
||||
<string name="track_paired_start">Старт трека (оба)</string>
|
||||
<string name="track_paired_started">Синхронный старт запланирован</string>
|
||||
<string name="track_paired_need_two">Нужны 2 устройства online</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user