diff --git a/.idea/vcs.xml b/.idea/vcs.xml index d843f34..94a25f7 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,4 +1,6 @@ - + + + \ No newline at end of file diff --git a/app/src/main/java/com/grigowashere/loratester/CommandPoller.java b/app/src/main/java/com/grigowashere/loratester/CommandPoller.java new file mode 100644 index 0000000..3cb377d --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/CommandPoller.java @@ -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 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 resp = serverApi.getActivePairedTrack(); + Object sessionObj = resp.get("session"); + if (!(sessionObj instanceof Map)) { + return; + } + @SuppressWarnings("unchecked") + Map m = (Map) 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 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 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 onError) { + executor.execute(() -> { + try { + Map 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")); + } + } + }); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/LoraApp.java b/app/src/main/java/com/grigowashere/loratester/LoraApp.java index 14307d8..e86ac79 100644 --- a/app/src/main/java/com/grigowashere/loratester/LoraApp.java +++ b/app/src/main/java/com/grigowashere/loratester/LoraApp.java @@ -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(); } } diff --git a/app/src/main/java/com/grigowashere/loratester/PeerDevices.java b/app/src/main/java/com/grigowashere/loratester/PeerDevices.java new file mode 100644 index 0000000..5d4767e --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/PeerDevices.java @@ -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 devices, String selfId) { + if (devices == null || selfId == null) { + return Result.error("no_devices"); + } + List 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; + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/PeerStatsCache.java b/app/src/main/java/com/grigowashere/loratester/PeerStatsCache.java new file mode 100644 index 0000000..67cf594 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/PeerStatsCache.java @@ -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 = new AtomicReference<>(); + + public void updateFromPayload(Map 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); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/DeviceCommand.java b/app/src/main/java/com/grigowashere/loratester/api/DeviceCommand.java new file mode 100644 index 0000000..c85fa63 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/DeviceCommand.java @@ -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 payload; + public double created_at; + public Double delivered_at; +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/PairedTrackSession.java b/app/src/main/java/com/grigowashere/loratester/api/PairedTrackSession.java new file mode 100644 index 0000000..12a9772 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/PairedTrackSession.java @@ -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; +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java index 6cec023..f8ec297 100644 --- a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java +++ b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java @@ -30,6 +30,7 @@ public class ServerApi { private static final Type TELEMETRY_HISTORY = new TypeToken>() {}.getType(); private static final Type TRACK_LIST = new TypeToken>() {}.getType(); + private static final Type COMMAND_LIST = new TypeToken>() {}.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 payload + ) throws IOException { + Map 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 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 startPairedTrack(Map body) throws IOException { + return postJsonMap("/api/paired-tracks/start", body, true); + } + + @SuppressWarnings("unchecked") + public Map 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 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 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) diff --git a/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java b/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java index 1480335..e9c44f0 100644 --- a/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java +++ b/app/src/main/java/com/grigowashere/loratester/track/TrackRecorder.java @@ -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); + } + }); } } diff --git a/app/src/main/java/com/grigowashere/loratester/ui/AtFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/AtFragment.java index 6217775..6227f8d 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/AtFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/AtFragment.java @@ -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 devices = uploader.getServerApi().getDevices(); + PeerDevices.Result peer = PeerDevices.resolve( + devices, uploader.getDeviceId()); + if (!peer.ok()) { + showToast(R.string.at_peer_unavailable); + return; + } + Map 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(); + } } diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java index 175056c..069e63d 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -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 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 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; diff --git a/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java index 444ef31..85c340c 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/StatsFragment.java @@ -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 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 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 history = null; try { List 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; } diff --git a/app/src/main/res/layout/fragment_at.xml b/app/src/main/res/layout/fragment_at.xml index 482b5a7..c3f75cc 100644 --- a/app/src/main/res/layout/fragment_at.xml +++ b/app/src/main/res/layout/fragment_at.xml @@ -1,5 +1,6 @@ + + + + + + + + + + + + +
+
-

Сравнение треков

-
- - +

Треки

+
+ +
-
- - -
- -
Выберите TX и RX треки
-
-

Время теста

-
-
- - - +
+
+ +
- +
+ +
+ + +
+
Выберите трек
+
+

Статистика по времени

+
Расстояние GPS: —
-

TX

+

TX

RX

-
@@ -131,6 +178,20 @@
+
+
+
+ Время теста + +
+
+ + + +
+ +
+
@@ -163,14 +224,19 @@ let loadedTxTrack = null; let loadedRxTrack = null; + let loadedSingleTrack = null; let telemetryTx = []; let telemetryRx = []; + let telemetrySingle = []; let overlapMin = 0; let overlapMax = 0; let playTimer = null; let pollTimer = null; let pollTick = 0; + let trackViewMode = 'single'; let dualTracksActive = false; + let singleTrackActive = false; + let lastDevices = []; const DEVICE_POLL_MS = 1000; const CHAT_POLL_MS = 2500; @@ -440,6 +506,36 @@ if (ghostRx) { map.removeLayer(ghostRx); ghostRx = null; } } + function updateTrackButtons() { + const active = dualTracksActive || singleTrackActive; + const hideBtn = document.getElementById('btnHideTracks'); + if (hideBtn) hideBtn.disabled = !active; + } + + function exitTrackMode() { + clearTrackLayers(); + dualTracksActive = false; + singleTrackActive = false; + loadedTxTrack = null; + loadedRxTrack = null; + loadedSingleTrack = null; + telemetryTx = []; + telemetryRx = []; + telemetrySingle = []; + if (playTimer) { + clearInterval(playTimer); + playTimer = null; + document.getElementById('btnPlay').textContent = '▶ Play'; + } + setTimelineVisible(false); + if (isModalOpen() && modalMode === 'timeline') { + closeMapModal(); + } + document.getElementById('trackInfo').textContent = + trackViewMode === 'dual' ? 'Выберите TX и RX треки' : 'Выберите трек'; + updateTrackButtons(); + } + function drawTrackLine(track, color, store) { const latlngs = track.points.map(p => [p.lat, p.lon]); const layer = L.polyline(latlngs, { color, weight: 4, opacity: 0.85 }).addTo(map); @@ -478,8 +574,17 @@ return html; } + function singleTrackRange(points) { + if (!points || !points.length) return null; + return { min: points[0].ts, max: points[points.length - 1].ts, mode: 'single' }; + } + function updateTimelineAt(t, opts) { const openModal = opts && opts.openModal; + if (singleTrackActive && loadedSingleTrack) { + updateTimelineAtSingle(t, openModal); + return; + } if (!loadedTxTrack || !loadedRxTrack) return; const txPos = positionAt(loadedTxTrack.points, t); const rxPos = positionAt(loadedRxTrack.points, t); @@ -521,12 +626,76 @@ ? formatTelemetryRow(rxTel) : 'нет данных'; } + function updateTimelineAtSingle(t, openModal) { + const track = loadedSingleTrack; + if (!track) return; + const pos = positionAt(track.points, t); + document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString(); + if (ghostTx) map.removeLayer(ghostTx); + if (ghostRx) map.removeLayer(ghostRx); + if (linkLine) map.removeLayer(linkLine); + ghostTx = null; + ghostRx = null; + linkLine = null; + if (pos) { + const color = track.role === 'RX' ? RX_COLOR : TX_COLOR; + ghostTx = L.circleMarker([pos.lat, pos.lon], { + radius: 10, color, fillColor: color, fillOpacity: 0.9, weight: 3 + }).addTo(map); + let html = `${new Date(t * 1000).toLocaleTimeString()}
`; + html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}
`; + html += formatMeta(pos.meta); + const tel = nearestTelemetry(telemetrySingle, t); + if (tel) html += '
' + formatTelemetryRow(tel); + if (openModal || (isModalOpen() && modalMode === 'timeline')) { + openMapModal(html, 'timeline'); + } + } + const tel = nearestTelemetry(telemetrySingle, t); + document.getElementById('statsTx').innerHTML = tel + ? formatTelemetryRow(tel) : 'нет данных'; + } + + function setTimelineVisible(visible) { + document.getElementById('trackTimeline').classList.toggle('visible', visible); + document.getElementById('timelineStatsPanel').classList.toggle('visible', visible); + } + + function setTimelineMode(single) { + const statsPanel = document.getElementById('timelineStatsPanel'); + statsPanel.classList.toggle('timeline-single', single); + } + + function setupTimelineSingle() { + const range = singleTrackRange(loadedSingleTrack.points); + const note = document.getElementById('timelineNote'); + setTimelineMode(true); + document.getElementById('timelineCol1Label').textContent = + loadedSingleTrack.role === 'RX' ? 'RX' : 'TX'; + if (!range) { + setTimelineVisible(false); + return; + } + overlapMin = range.min; + overlapMax = range.max; + const span = Math.max(1, Math.round(overlapMax - overlapMin)); + const slider = document.getElementById('timeSlider'); + slider.min = 0; + slider.max = span; + slider.value = 0; + document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString(); + document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString(); + note.textContent = `Трек #${loadedSingleTrack.id} · ${loadedSingleTrack.device_id || ''}`; + setTimelineVisible(true); + updateTimelineAtSingle(overlapMin); + } + function setupTimeline() { + setTimelineMode(false); const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points); - const panel = document.getElementById('trackTimeline'); const note = document.getElementById('timelineNote'); if (!range) { - panel.classList.remove('visible'); + setTimelineVisible(false); return; } overlapMin = range.min; @@ -544,12 +713,23 @@ } else { note.textContent = 'Общий интервал записи обоих треков.'; } - panel.classList.add('visible'); - panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + setTimelineVisible(true); updateTimelineAt(overlapMin); } async function refreshTimelineTelemetry() { + if (singleTrackActive && loadedSingleTrack) { + const range = singleTrackRange(loadedSingleTrack.points); + if (!range) return; + const res = await fetch( + `/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, + { cache: 'no-store' } + ); + if (res.ok) telemetrySingle = await res.json(); + const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10); + updateTimelineAtSingle(t); + return; + } if (!dualTracksActive || !loadedTxTrack || !loadedRxTrack) return; const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points); if (!range) return; @@ -563,11 +743,20 @@ updateTimelineAt(t); } + function trackOptionLabel(t) { + const start = new Date(t.started_at * 1000).toLocaleString(); + const role = t.role ? ` · ${t.role}` : ''; + const dev = t.device_id ? ` · ${t.device_id.slice(0, 12)}` : ''; + return `#${t.id}${role}${dev} · ${start} (${t.point_count})`; + } + async function loadAllTracks() { const txSel = document.getElementById('trackTxSelect'); const rxSel = document.getElementById('trackRxSelect'); + const singleSel = document.getElementById('trackSingleSelect'); const prevTx = txSel.value; const prevRx = rxSel.value; + const prevSingle = singleSel.value; const res = await fetch('/api/tracks?limit=100', { cache: 'no-store' }); if (!res.ok) throw new Error('tracks ' + res.status); const tracks = await res.json(); @@ -576,19 +765,67 @@ tracks.forEach(t => { const opt = document.createElement('option'); opt.value = t.id; - const start = new Date(t.started_at * 1000).toLocaleString(); - const role = t.role ? ` · ${t.role}` : ''; - const dev = t.device_id ? ` · ${t.device_id.slice(0, 12)}` : ''; - opt.textContent = `#${t.id}${role}${dev} · ${start} (${t.point_count})`; + opt.textContent = trackOptionLabel(t); sel.appendChild(opt); }); }; + fill(singleSel, '— трек —'); fill(txSel, '— TX трек —'); fill(rxSel, '— RX трек —'); + if (prevSingle) singleSel.value = prevSingle; if (prevTx) txSel.value = prevTx; if (prevRx) rxSel.value = prevRx; + if (!singleTrackActive && !dualTracksActive) { + document.getElementById('trackInfo').textContent = + tracks.length ? `${tracks.length} трек(ов) на сервере` : 'Треки записываются с телефона'; + } + } + + async function showSingleTrack() { + const id = document.getElementById('trackSingleSelect').value; + if (!id) { + document.getElementById('trackInfo').textContent = 'Выберите трек'; + return; + } + clearTrackLayers(); + dualTracksActive = false; + singleTrackActive = false; + loadedTxTrack = null; + loadedRxTrack = null; + if (playTimer) { clearInterval(playTimer); playTimer = null; } + const res = await fetch(`/api/tracks/${id}`, { cache: 'no-store' }); + loadedSingleTrack = await res.json(); + if (!loadedSingleTrack.role && loadedSingleTrack.points) { + const p = loadedSingleTrack.points.find(x => x.role); + if (p) loadedSingleTrack.role = p.role; + } + if (!loadedSingleTrack.points?.length) { + document.getElementById('trackInfo').textContent = 'Пустой трек'; + return; + } + const color = loadedSingleTrack.role === 'RX' ? RX_COLOR : TX_COLOR; + drawTrackLine(loadedSingleTrack, color, 'tx'); + const bounds = L.latLngBounds(loadedSingleTrack.points.map(p => [p.lat, p.lon])); + setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50] })); + singleTrackActive = true; + setupTimelineSingle(); + const range = singleTrackRange(loadedSingleTrack.points); + if (range && loadedSingleTrack.device_id) { + const telRes = await fetch( + `/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, + { cache: 'no-store' } + ); + if (telRes.ok) telemetrySingle = await telRes.json(); + updateTimelineAtSingle(overlapMin); + } document.getElementById('trackInfo').textContent = - tracks.length ? `${tracks.length} трек(ов) на сервере` : 'Треки записываются с телефона'; + `Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`; + updateTrackButtons(); + } + + function showTracksOnMap() { + if (trackViewMode === 'single') showSingleTrack(); + else showDualTracks(); } async function showDualTracks() { @@ -603,6 +840,8 @@ return; } clearTrackLayers(); + singleTrackActive = false; + loadedSingleTrack = null; if (playTimer) { clearInterval(playTimer); playTimer = null; } const [txRes, rxRes] = await Promise.all([ @@ -640,9 +879,90 @@ const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : ''; document.getElementById('trackInfo').textContent = `TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`; + updateTrackButtons(); } - document.getElementById('btnShowTracks').onclick = showDualTracks; + document.getElementById('btnShowTracks').onclick = showTracksOnMap; + document.getElementById('btnHideTracks').onclick = exitTrackMode; + document.getElementById('btnModeSingle').onclick = () => { + trackViewMode = 'single'; + document.getElementById('btnModeSingle').classList.add('active'); + document.getElementById('btnModeDual').classList.remove('active'); + document.getElementById('trackPanelSingle').style.display = ''; + document.getElementById('trackPanelDual').style.display = 'none'; + document.getElementById('trackInfo').textContent = 'Выберите трек'; + if (singleTrackActive || dualTracksActive) exitTrackMode(); + }; + document.getElementById('btnModeDual').onclick = () => { + trackViewMode = 'dual'; + document.getElementById('btnModeDual').classList.add('active'); + document.getElementById('btnModeSingle').classList.remove('active'); + document.getElementById('trackPanelSingle').style.display = 'none'; + document.getElementById('trackPanelDual').style.display = ''; + document.getElementById('trackInfo').textContent = 'Выберите TX и RX треки'; + if (singleTrackActive || dualTracksActive) exitTrackMode(); + }; + + async function postCommand(toDeviceId, kind, payload) { + if (!toDeviceId) { + alert('Выберите устройство'); + return; + } + const res = await fetch('/api/commands', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from_device_id: 'web', to_device_id: toDeviceId, kind, payload }) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Ошибка команды'); + } + } + + document.getElementById('btnCmdAt').onclick = () => { + const line = document.getElementById('cmdAtInput').value.trim(); + if (!line) return; + postCommand(document.getElementById('cmdTargetSelect').value, 'at', { line }); + }; + document.getElementById('btnCmdTx').onclick = () => { + postCommand(document.getElementById('cmdTargetSelect').value, 'mode', { role: 'TX' }); + }; + document.getElementById('btnCmdRx').onclick = () => { + postCommand(document.getElementById('cmdTargetSelect').value, 'mode', { role: 'RX' }); + }; + + async function refreshPairedStatus() { + try { + const res = await fetch('/api/paired-tracks/active', { cache: 'no-store' }); + if (!res.ok) return; + const data = await res.json(); + const el = document.getElementById('pairedStatus'); + if (!data.active || !data.session) { + el.textContent = 'Синхр. трек: нет активной сессии'; + return; + } + const s = data.session; + el.textContent = `Сессия #${s.id} · ${s.status} · старт ${new Date(s.start_at * 1000).toLocaleTimeString()}`; + } catch (e) { + console.warn('paired status', e); + } + } + + document.getElementById('btnPairedStart').onclick = async () => { + const ids = lastDevices.filter(d => d.device_id && d.device_id.startsWith('android-')).map(d => d.device_id); + const body = ids.length === 2 ? { device_ids: ids, initiator: 'web' } : { initiator: 'web' }; + const res = await fetch('/api/paired-tracks/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || 'Не удалось запустить'); + return; + } + refreshPairedStatus(); + }; document.getElementById('timeSlider').oninput = e => { modalMode = 'timeline'; updateTimelineAt(overlapMin + parseInt(e.target.value, 10), { openModal: true }); @@ -740,6 +1060,7 @@ } }); if (!mapInitialFitDone && bounds.length) fitAllMarkers(bounds); + updateCmdTargetSelect(devices); if (selectedId) { const sel = devices.find(d => d.device_id === selectedId); if (sel) { @@ -852,13 +1173,36 @@ console.warn('poll tracks', e); } } - if (dualTracksActive && pollTick % Math.round(TELEMETRY_POLL_MS / DEVICE_POLL_MS) === 0) { + if ((dualTracksActive || singleTrackActive) && pollTick % Math.round(TELEMETRY_POLL_MS / DEVICE_POLL_MS) === 0) { try { await refreshTimelineTelemetry(); } catch (e) { console.warn('poll timeline telemetry', e); } } + if (pollTick % Math.round(2000 / DEVICE_POLL_MS) === 0) { + try { + await refreshPairedStatus(); + } catch (e) { + console.warn('poll paired', e); + } + } + } + + function updateCmdTargetSelect(devices) { + lastDevices = devices; + const sel = document.getElementById('cmdTargetSelect'); + const prev = sel.value; + sel.innerHTML = ''; + devices.forEach(d => { + const opt = document.createElement('option'); + opt.value = d.device_id; + let label = d.device_id; + if (d.role) label += ` · ${d.role}`; + opt.textContent = label; + sel.appendChild(opt); + }); + if (prev) sel.value = prev; } function schedulePoll() { @@ -877,6 +1221,7 @@ schedulePoll(); loadAllTracks(); + refreshPairedStatus();