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 @@ + + + + + + + + + + + - + android:orientation="horizontal"> + + + + + + + + + + + + + + + + + + + + + + + + Трек: %1$s Сохранённые треки — нет треков — + Это устройство + Другое устройство + Нет данных (ожидается 2 устройства online) + Нужно ровно 2 устройства на сервере (сейчас %1$d) + Отправить статистику на другое + Статистика отправлена + Не удалось отправить + Локально + На другое устройство + Команда отправлена на %1$s + Нет второго устройства + Старт трека (оба) + Синхронный старт запланирован + Нужны 2 устройства online diff --git a/server/README.md b/server/README.md index 11d1380..36a8245 100644 --- a/server/README.md +++ b/server/README.md @@ -42,7 +42,7 @@ uvicorn fastapi_app:app --host 0.0.0.0 --port 7634 curl http://127.0.0.1:7634/api/health ``` -Ожидается `"db_ok": true`, `"schema_version": 3`. +Ожидается `"db_ok": true`, `"schema_version": 4`. Если БД создана вручную и схема битая (`no such table: devices` / `no such column: t.meta`): @@ -68,6 +68,21 @@ curl http://127.0.0.1:7634/api/health - `GET /api/tracks?device_id=` - `GET /api/tracks/{id}` — метаданные + точки (высота terrain через Open-Meteo) +### Команды (очередь на устройство) + +- `POST /api/commands` — `{from_device_id, to_device_id, kind, payload?}` + `kind`: `at` (`payload.line`), `mode` (`payload.role`: TX/RX), `stats_push` (снимок meta/rssi/role) + `from_device_id`: `web` или `android-xxxxxxxx` +- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at` +- `GET /api/commands?to_device_id=&limit=` — история (веб) + +### Синхронный трек (два устройства) + +- `POST /api/paired-tracks/start` — `{device_ids?: [a,b], initiator?, device_id?}` → сессия `armed`, `start_at = now+3s` +- `GET /api/paired-tracks/active` — `{active, session?}` +- `POST /api/paired-tracks/ack` — Android: `{session_id, device_id, track_id}` +- `POST /api/paired-tracks/cancel` — `{session_id?}` + ### Прочее - `POST /api/chat` — `{device_id, text}` diff --git a/server/core/__pycache__/__init__.cpython-313.pyc b/server/core/__pycache__/__init__.cpython-313.pyc index b6c8f75..da0b144 100644 Binary files a/server/core/__pycache__/__init__.cpython-313.pyc and b/server/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/core/__pycache__/schema.cpython-313.pyc b/server/core/__pycache__/schema.cpython-313.pyc index 89c2e0e..22d7a64 100644 Binary files a/server/core/__pycache__/schema.cpython-313.pyc and b/server/core/__pycache__/schema.cpython-313.pyc differ diff --git a/server/core/__pycache__/storage.cpython-313.pyc b/server/core/__pycache__/storage.cpython-313.pyc index 974f710..0e9b158 100644 Binary files a/server/core/__pycache__/storage.cpython-313.pyc and b/server/core/__pycache__/storage.cpython-313.pyc differ diff --git a/server/core/schema.py b/server/core/schema.py index 7a50451..ed41f12 100644 --- a/server/core/schema.py +++ b/server/core/schema.py @@ -4,7 +4,7 @@ from __future__ import annotations import sqlite3 -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 def table_exists(conn: sqlite3.Connection, name: str) -> bool: @@ -121,6 +121,44 @@ def apply_migrations(conn: sqlite3.Connection) -> list[str]: ) log.append("CREATE track_points") + if not table_exists(conn, "device_commands"): + conn.executescript( + """ + CREATE TABLE device_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_device_id TEXT NOT NULL, + to_device_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT, + created_at REAL NOT NULL, + delivered_at REAL + ); + CREATE INDEX IF NOT EXISTS idx_commands_to_pending + ON device_commands(to_device_id, delivered_at, created_at); + """ + ) + log.append("CREATE device_commands") + + if not table_exists(conn, "paired_track_sessions"): + conn.executescript( + """ + CREATE TABLE paired_track_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_a TEXT NOT NULL, + device_b TEXT NOT NULL, + initiator TEXT NOT NULL, + status TEXT NOT NULL, + start_at REAL NOT NULL, + track_id_a INTEGER, + track_id_b INTEGER, + created_at REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_paired_status + ON paired_track_sessions(status, created_at DESC); + """ + ) + log.append("CREATE paired_track_sessions") + set_schema_version(conn, SCHEMA_VERSION) log.append(f"schema_version={SCHEMA_VERSION}") return log @@ -132,6 +170,8 @@ def check_db_ok(conn: sqlite3.Connection) -> bool: ("telemetry", "meta"), ("tracks", None), ("track_points", "elevation_m"), + ("device_commands", None), + ("paired_track_sessions", None), ] for table, col in required: if not table_exists(conn, table): diff --git a/server/core/storage.py b/server/core/storage.py index f083cb0..2605268 100644 --- a/server/core/storage.py +++ b/server/core/storage.py @@ -11,6 +11,11 @@ from .elevation import fetch_elevation_m from .models import ChatIn, TelemetryIn from .schema import SCHEMA_VERSION, apply_migrations, check_db_ok, get_schema_version +WEB_SENDER_ID = "web" +COMMAND_KINDS = frozenset({"at", "mode", "stats_push"}) +PAIRED_ONLINE_SEC = 30.0 +PAIRED_START_DELAY_SEC = 3.0 + logger = logging.getLogger(__name__) _HISTORY_COLUMNS = ( @@ -396,3 +401,298 @@ def get_chat(since: float = 0.0, limit: int = 200) -> list[dict[str, Any]]: (since, limit), ).fetchall() return [dict(r) for r in rows] + + +def _is_valid_sender(device_id: str) -> bool: + d = (device_id or "").strip() + return d == WEB_SENDER_ID or is_valid_device_id(d) + + +def _row_to_command(row: sqlite3.Row) -> dict[str, Any]: + payload = row["payload"] + if payload: + try: + payload = json.loads(payload) + except json.JSONDecodeError: + pass + return { + "id": row["id"], + "from_device_id": row["from_device_id"], + "to_device_id": row["to_device_id"], + "kind": row["kind"], + "payload": payload, + "created_at": row["created_at"], + "delivered_at": row["delivered_at"], + } + + +def enqueue_command( + from_device_id: str, + to_device_id: str, + kind: str, + payload: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + from_id = (from_device_id or "").strip() + to_id = (to_device_id or "").strip() + kind = (kind or "").strip().lower() + if not _is_valid_sender(from_id): + raise ValueError(f"invalid from_device_id '{from_id}'") + if not is_valid_device_id(to_id): + raise ValueError(f"invalid to_device_id '{to_id}'") + if kind not in COMMAND_KINDS: + raise ValueError(f"invalid kind '{kind}', expected at|mode|stats_push") + if from_id == to_id: + raise ValueError("from and to device must differ") + ts = time.time() + payload_json = json.dumps(payload or {}, ensure_ascii=False) + with _db() as conn: + cur = conn.execute( + """ + INSERT INTO device_commands + (from_device_id, to_device_id, kind, payload, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (from_id, to_id, kind, payload_json, ts), + ) + cmd_id = cur.lastrowid + return { + "ok": True, + "id": cmd_id, + "from_device_id": from_id, + "to_device_id": to_id, + "kind": kind, + "created_at": ts, + } + + +def poll_pending_commands(device_id: str, limit: int = 20) -> list[dict[str, Any]]: + if not is_valid_device_id(device_id): + raise ValueError(f"invalid device_id '{device_id}'") + limit = min(max(1, limit), 50) + now = time.time() + with _db() as conn: + rows = conn.execute( + """ + SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at + FROM device_commands + WHERE to_device_id = ? AND delivered_at IS NULL + ORDER BY created_at ASC + LIMIT ? + """, + (device_id, limit), + ).fetchall() + ids = [r["id"] for r in rows] + if ids: + placeholders = ",".join("?" * len(ids)) + conn.execute( + f"UPDATE device_commands SET delivered_at = ? WHERE id IN ({placeholders})", + [now, *ids], + ) + return [_row_to_command(r) for r in rows] + + +def list_commands( + to_device_id: Optional[str] = None, limit: int = 50 +) -> list[dict[str, Any]]: + limit = min(max(1, limit), 200) + with _db() as conn: + if to_device_id: + rows = conn.execute( + """ + SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at + FROM device_commands + WHERE to_device_id = ? + ORDER BY created_at DESC + LIMIT ? + """, + (to_device_id, limit), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at + FROM device_commands + ORDER BY created_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [_row_to_command(r) for r in rows] + + +def _online_android_devices(within_sec: float = PAIRED_ONLINE_SEC) -> list[str]: + cutoff = time.time() - within_sec + devices = list_devices() + return [ + d["device_id"] + for d in devices + if d.get("last_seen", 0) >= cutoff + ] + + +def _row_to_paired_session(row: sqlite3.Row) -> dict[str, Any]: + return { + "id": row["id"], + "device_a": row["device_a"], + "device_b": row["device_b"], + "initiator": row["initiator"], + "status": row["status"], + "start_at": row["start_at"], + "track_id_a": row["track_id_a"], + "track_id_b": row["track_id_b"], + "created_at": row["created_at"], + } + + +def _cancel_active_paired_sessions(conn: sqlite3.Connection) -> None: + conn.execute( + """ + UPDATE paired_track_sessions + SET status = 'cancelled' + WHERE status IN ('armed', 'recording') + """ + ) + + +def start_paired_track( + device_ids: Optional[list[str]] = None, + initiator: str = WEB_SENDER_ID, +) -> dict[str, Any]: + initiator = (initiator or WEB_SENDER_ID).strip() + if not _is_valid_sender(initiator): + raise ValueError(f"invalid initiator '{initiator}'") + + if device_ids and len(device_ids) == 2: + a, b = [str(x).strip() for x in device_ids] + if not is_valid_device_id(a) or not is_valid_device_id(b): + raise ValueError("device_ids must be two valid android-* ids") + if a == b: + raise ValueError("device_ids must differ") + else: + online = _online_android_devices() + if len(online) != 2: + raise ValueError( + f"expected exactly 2 online devices, found {len(online)}" + ) + a, b = sorted(online) + + now = time.time() + start_at = now + PAIRED_START_DELAY_SEC + with _db() as conn: + _cancel_active_paired_sessions(conn) + cur = conn.execute( + """ + INSERT INTO paired_track_sessions + (device_a, device_b, initiator, status, start_at, created_at) + VALUES (?, ?, ?, 'armed', ?, ?) + """, + (a, b, initiator, start_at, now), + ) + session_id = cur.lastrowid + return { + "ok": True, + "session": get_paired_track_session(session_id), + } + + +def get_active_paired_track() -> Optional[dict[str, Any]]: + with _db() as conn: + row = conn.execute( + """ + SELECT id, device_a, device_b, initiator, status, start_at, + track_id_a, track_id_b, created_at + FROM paired_track_sessions + WHERE status IN ('armed', 'recording') + ORDER BY id DESC + LIMIT 1 + """ + ).fetchone() + if not row: + return None + session = _row_to_paired_session(row) + now = time.time() + session["server_time"] = now + session["ready"] = session["status"] == "armed" and now >= session["start_at"] + return session + + +def get_paired_track_session(session_id: int) -> dict[str, Any]: + with _db() as conn: + row = conn.execute( + """ + SELECT id, device_a, device_b, initiator, status, start_at, + track_id_a, track_id_b, created_at + FROM paired_track_sessions WHERE id = ? + """, + (session_id,), + ).fetchone() + if not row: + raise ValueError(f"session {session_id} not found") + session = _row_to_paired_session(row) + now = time.time() + session["server_time"] = now + session["ready"] = session["status"] == "armed" and now >= session["start_at"] + return session + + +def ack_paired_track( + session_id: int, device_id: str, track_id: int +) -> dict[str, Any]: + if not is_valid_device_id(device_id): + raise ValueError(f"invalid device_id '{device_id}'") + with _db() as conn: + row = conn.execute( + """ + SELECT id, device_a, device_b, status, track_id_a, track_id_b + FROM paired_track_sessions WHERE id = ? + """, + (session_id,), + ).fetchone() + if not row: + raise ValueError(f"session {session_id} not found") + if row["status"] not in ("armed", "recording"): + raise ValueError(f"session {session_id} not active") + + col = None + if device_id == row["device_a"]: + col = "track_id_a" + elif device_id == row["device_b"]: + col = "track_id_b" + else: + raise ValueError(f"device {device_id} not in session") + + conn.execute( + f"UPDATE paired_track_sessions SET {col} = ? WHERE id = ?", + (track_id, session_id), + ) + updated = conn.execute( + """ + SELECT track_id_a, track_id_b, status FROM paired_track_sessions + WHERE id = ? + """, + (session_id,), + ).fetchone() + if updated["track_id_a"] and updated["track_id_b"]: + conn.execute( + "UPDATE paired_track_sessions SET status = 'recording' WHERE id = ?", + (session_id,), + ) + return {"ok": True, "session": get_paired_track_session(session_id)} + + +def cancel_paired_track(session_id: Optional[int] = None) -> dict[str, Any]: + with _db() as conn: + if session_id is not None: + cur = conn.execute( + """ + UPDATE paired_track_sessions + SET status = 'cancelled' + WHERE id = ? AND status IN ('armed', 'recording') + """, + (session_id,), + ) + if cur.rowcount == 0: + raise ValueError(f"session {session_id} not found or not active") + else: + _cancel_active_paired_sessions(conn) + return {"ok": True, "active": get_active_paired_track()} diff --git a/server/fastapi_app.py b/server/fastapi_app.py index 1987016..f18c358 100644 --- a/server/fastapi_app.py +++ b/server/fastapi_app.py @@ -66,6 +66,29 @@ class TrackPointsBody(BaseModel): points: list[TrackPoint] = Field(default_factory=list) +class CommandBody(BaseModel): + from_device_id: str + to_device_id: str + kind: str + payload: Optional[dict[str, Any]] = None + + +class PairedTrackStartBody(BaseModel): + device_ids: Optional[list[str]] = None + initiator: Optional[str] = None + device_id: Optional[str] = None + + +class PairedTrackAckBody(BaseModel): + session_id: int + device_id: str + track_id: int + + +class PairedTrackCancelBody(BaseModel): + session_id: Optional[int] = None + + @app.get("/") def index(): return FileResponse( @@ -194,6 +217,87 @@ def get_chat(since: float = 0, limit: int = Query(200, ge=1, le=500)): return storage.get_chat(since, limit) +@app.post("/api/commands") +def post_command(body: CommandBody): + try: + return storage.enqueue_command( + body.from_device_id, + body.to_device_id, + body.kind, + body.payload, + ) + except ValueError as e: + raise HTTPException(400, detail=str(e)) from e + + +@app.get("/api/commands/pending") +def commands_pending( + device_id: str = Query(...), + limit: int = Query(20, ge=1, le=50), + x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER), +): + _require_android(x_lora_client) + try: + return storage.poll_pending_commands(device_id, limit) + except ValueError as e: + raise HTTPException(400, detail=str(e)) from e + + +@app.get("/api/commands") +def commands_list( + to_device_id: Optional[str] = None, + limit: int = Query(50, ge=1, le=200), +): + return storage.list_commands(to_device_id, limit) + + +@app.post("/api/paired-tracks/start") +def paired_tracks_start( + body: PairedTrackStartBody, + x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER), +): + if body.initiator: + initiator = body.initiator + elif body.device_id: + initiator = body.device_id + elif (x_lora_client or "").strip().lower() == ANDROID_CLIENT_VALUE: + raise HTTPException(400, detail="initiator or device_id required") + else: + initiator = storage.WEB_SENDER_ID + try: + return storage.start_paired_track(body.device_ids, str(initiator)) + except ValueError as e: + raise HTTPException(400, detail=str(e)) from e + + +@app.get("/api/paired-tracks/active") +def paired_tracks_active(): + session = storage.get_active_paired_track() + return {"active": session is not None, "session": session} + + +@app.post("/api/paired-tracks/ack") +def paired_tracks_ack( + body: PairedTrackAckBody, + x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER), +): + _require_android(x_lora_client) + try: + return storage.ack_paired_track( + body.session_id, body.device_id, body.track_id + ) + except ValueError as e: + raise HTTPException(400, detail=str(e)) from e + + +@app.post("/api/paired-tracks/cancel") +def paired_tracks_cancel(body: PairedTrackCancelBody): + try: + return storage.cancel_paired_track(body.session_id) + except ValueError as e: + raise HTTPException(400, detail=str(e)) from e + + @app.get("/api/health") def health(): status = storage.db_status() diff --git a/server/flask_app.py b/server/flask_app.py index 8d9b673..3ba86ef 100644 --- a/server/flask_app.py +++ b/server/flask_app.py @@ -145,6 +145,98 @@ def get_chat(): return jsonify(storage.get_chat(since, limit)) +@app.post("/api/commands") +def post_command(): + body = request.get_json(force=True, silent=True) or {} + from_id = body.get("from_device_id") + to_id = body.get("to_device_id") + kind = body.get("kind") + if not from_id or not to_id or not kind: + return jsonify({"error": "from_device_id, to_device_id, kind required"}), 400 + try: + return jsonify( + storage.enqueue_command( + str(from_id), str(to_id), str(kind), body.get("payload") + ) + ) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + +@app.get("/api/commands/pending") +def commands_pending(): + if not is_android_client(request.headers): + return jsonify({"error": "Android only"}), 403 + device_id = request.args.get("device_id") + if not device_id: + return jsonify({"error": "device_id required"}), 400 + limit = int(request.args.get("limit", 20)) + try: + return jsonify(storage.poll_pending_commands(str(device_id), limit)) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + +@app.get("/api/commands") +def commands_list(): + to_device_id = request.args.get("to_device_id") + limit = int(request.args.get("limit", 50)) + return jsonify(storage.list_commands(to_device_id, limit)) + + +@app.post("/api/paired-tracks/start") +def paired_tracks_start(): + body = request.get_json(force=True, silent=True) or {} + initiator = body.get("initiator") or ( + body.get("device_id") if is_android_client(request.headers) else storage.WEB_SENDER_ID + ) + device_ids = body.get("device_ids") + try: + return jsonify( + storage.start_paired_track( + device_ids if isinstance(device_ids, list) else None, + str(initiator), + ) + ) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + +@app.get("/api/paired-tracks/active") +def paired_tracks_active(): + session = storage.get_active_paired_track() + return jsonify({"active": session is not None, "session": session}) + + +@app.post("/api/paired-tracks/ack") +def paired_tracks_ack(): + if not is_android_client(request.headers): + return jsonify({"error": "Android only"}), 403 + body = request.get_json(force=True, silent=True) or {} + session_id = body.get("session_id") + device_id = body.get("device_id") + track_id = body.get("track_id") + if session_id is None or not device_id or track_id is None: + return jsonify({"error": "session_id, device_id, track_id required"}), 400 + try: + return jsonify( + storage.ack_paired_track(int(session_id), str(device_id), int(track_id)) + ) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + +@app.post("/api/paired-tracks/cancel") +def paired_tracks_cancel(): + body = request.get_json(force=True, silent=True) or {} + session_id = body.get("session_id") + try: + sid = int(session_id) if session_id is not None else None + return jsonify(storage.cancel_paired_track(sid)) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + @app.get("/api/health") def health(): status = storage.db_status() diff --git a/server/loratester.db b/server/loratester.db index 6b1b824..627f470 100644 Binary files a/server/loratester.db and b/server/loratester.db differ diff --git a/server/static/index.html b/server/static/index.html index 0fd328a..513df39 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -10,14 +10,29 @@ body { margin: 0; font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; } header { padding: 12px 16px; background: #16213e; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } header h1 { margin: 0; font-size: 1.2rem; flex: 1; } - main { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: 1fr; height: calc(100vh - 52px); } + main { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: 1fr auto; height: calc(100vh - 52px); } @media (max-width: 900px) { - main { grid-template-columns: 1fr; grid-template-rows: 45vh minmax(200px, 1fr); } + main { grid-template-columns: 1fr; grid-template-rows: 45vh minmax(180px, 1fr) auto; } } #mapWrap { grid-column: 1; grid-row: 1; position: relative; min-height: 0; } #map { width: 100%; height: 100%; } - #trackTimeline { display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333; } + #trackTimeline { + display: none; grid-column: 1 / -1; grid-row: 2; + background: #16213e; padding: 8px 16px; border-top: 1px solid #333; + } #trackTimeline.visible { display: block; } + .timeline-bar { display: flex; flex-direction: column; gap: 6px; } + .timeline-bar-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; } + .timeline-bar-title { font-size: 0.85rem; font-weight: 600; } + .timeline-bar .timeline-labels { width: 100%; margin: 0; } + .timeline-bar #timeSlider { width: 100%; margin: 0; } + #timelineStatsPanel { + display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333; + } + #timelineStatsPanel.visible { display: block; } + #timelineStatsPanel.timeline-single #timelineStats { grid-template-columns: 1fr; } + #timelineStatsPanel.timeline-single .timeline-col.rx { display: none; } + #timelineStatsPanel.timeline-single #distanceNow { display: none; } #timelineNote { font-size: 0.75rem; color: #aaa; margin: 4px 0 8px; } #timeSlider { width: 100%; margin: 6px 0; } .timeline-labels { display: flex; justify-content: space-between; font-size: 0.75rem; color: #aaa; } @@ -49,7 +64,18 @@ .track-row { margin-bottom: 6px; } .track-row label { font-size: 0.75rem; color: #aaa; display: block; margin-bottom: 2px; } .track-row select { width: 100%; padding: 4px; } - #btnShowTracks { width: 100%; padding: 6px; margin-top: 4px; background: #00ff88; color: #111; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; } + .track-actions { display: flex; gap: 6px; margin-top: 4px; } + .track-actions button { flex: 1; padding: 6px; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.8rem; } + #btnShowTracks { background: #00ff88; color: #111; } + #btnHideTracks { background: #333; color: #eee; } + #btnHideTracks:disabled { opacity: 0.4; cursor: not-allowed; } + .track-mode { display: flex; gap: 4px; margin-bottom: 8px; } + .track-mode button { flex: 1; padding: 4px; font-size: 0.75rem; border: 1px solid #444; background: #0a0a14; color: #ccc; border-radius: 4px; cursor: pointer; } + .track-mode button.active { background: #e94560; color: #fff; border-color: #e94560; } + #controlPanel input, #controlPanel select { width: 100%; padding: 4px; margin-top: 2px; border-radius: 4px; border: none; font-size: 0.8rem; } + #controlPanel .cmd-row { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; } + #controlPanel .cmd-row button { padding: 4px 8px; font-size: 0.75rem; border: none; border-radius: 4px; cursor: pointer; background: #16213e; color: #eee; } + #pairedStatus { font-size: 0.75rem; color: #aaa; margin-top: 4px; } .muted { color: #aaa; font-size: 0.75rem; } .legend { font-size: 0.75rem; color: #ccc; } .legend-tx { color: #e94560; } @@ -93,33 +119,54 @@ Выберите устройство + + Управление + Целевое устройство + — + + + AT + AT+TX + AT+RX + + Старт трека (оба) + — + - Сравнение треков - - Трек TX - — + Треки + + Один трек + Сравнение TX+RX - - Трек RX - — - - Показать на карте - Выберите TX и RX треки - - Время теста - - - — - — - — + + + Трек + — - + + + + Трек TX + — + + + Трек RX + — + + + + Показать на карте + Скрыть треки + + Выберите трек + + Статистика по времени + Расстояние GPS: — - TX— + TX— RX— - ▶ Play @@ -131,6 +178,20 @@ + + + + Время теста + ▶ Play + + + — + — + — + + + + @@ -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();