3 Commits

Author SHA1 Message Date
Grigo 81eaa95df3 Initial commit: LoraTester Android + server 2026-06-04 14:39:14 +03:00
Grigo 253a7d74ca Initial commit: LoraTester Android + server 2026-06-04 13:19:28 +03:00
Grigo ab7c214966 Initial commit: LoraTester Android + server 2026-06-04 13:15:42 +03:00
27 changed files with 1904 additions and 204 deletions
Generated
+3 -1
View File
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings" defaultProject="true" />
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
Vendored
+6 -98
View File
@@ -7,10 +7,8 @@ pipeline {
ANDROID_SDK_ROOT = '/opt/android-sdk'
PATH = "/usr/lib/jvm/java-21-openjdk-amd64/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${env.PATH}"
TAIGA_PROJECT_ID = '2'
TAIGA_URL = 'https://taiga.grigowashere.ru'
GITEA_OWNER = 'Grigo'
GITEA_REPO = 'TestingAndroidBuild' // Замените на нужный репозиторий
GITEA_REPO = 'LoraMapTester' // Замените на нужный репозиторий
GITEA_URL = 'https://git.grigowashere.ru' // Базовый URL Gitea
GITEA_API_URL = "${GITEA_URL}/api/v1"
GITEA_TOKEN_CREDENTIALS_ID = 'Gitea_Credentials' // ID ваших креденшлов для Gitea в Jenkins
@@ -40,7 +38,11 @@ pipeline {
writeFile file: 'gitea-release.sh', text: '''
#!/bin/bash
apkPath="build/outputs/apk/debug/app-debug.apk"
apkPath=$(find . -path '*/build/outputs/apk/debug/*.apk' -type f | head -1)
if [ -z "$apkPath" ]; then
echo "APK not found under */build/outputs/apk/debug/"
exit 1
fi
headers="Authorization: token $GITEA_TOKEN"
# Создаем релиз на Gitea
@@ -80,98 +82,4 @@ fi
}
}
}
post {
always {
script {
def result = currentBuild.currentResult ?: 'UNKNOWN'
withCredentials([string(credentialsId: 'TAIGA_TOKEN', variable: 'TAIGA_TOKEN')]) {
sh(returnStatus: true, script: """
set +e
REF=\$(git log -1 --pretty=%B | grep -oE 'TG-[0-9]+' | head -1 | cut -d- -f2 || true)
if [ -z "\$REF" ]; then
echo "No TG-* reference found"
exit 0
fi
export REF
export BUILD_RESULT="${result}"
python3 - <<'PY'
import json
import os
import urllib.request
taiga_url = os.environ["TAIGA_URL"]
project_id = os.environ["TAIGA_PROJECT_ID"]
token = os.environ["TAIGA_TOKEN"]
ref = os.environ["REF"]
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def get_json(path):
url = f"{taiga_url}{path}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode("utf-8"))
except Exception:
return None
targets = [
("userstories", "User Story"),
("issues", "Issue"),
("tasks", "Task"),
]
found = None
for endpoint, label in targets:
data = get_json(f"/api/v1/{endpoint}/by_ref?project={project_id}&ref={ref}")
if data and "id" in data:
found = (endpoint, label, data)
break
if not found:
print(f"Taiga TG-{ref} not found")
raise SystemExit(0)
endpoint, label, data = found
comment = (
f"Jenkins Android build #{os.environ['BUILD_NUMBER']}: {os.environ['BUILD_RESULT']}\\n"
f"{os.environ['BUILD_URL']}"
)
payload = json.dumps({
"comment": comment,
"version": data["version"],
}).encode("utf-8")
url = f"{taiga_url}/api/v1/{endpoint}/{data['id']}"
req = urllib.request.Request(
url,
data=payload,
headers=headers,
method="PATCH",
)
try:
with urllib.request.urlopen(req) as r:
print(f"Commented Taiga TG-{ref} ({label}), HTTP {r.status}")
except Exception as e:
print(f"Taiga comment warning: {e}")
raise SystemExit(0)
PY
""".stripIndent())
}
}
}
}
}
@@ -0,0 +1,257 @@
package com.grigowashere.loratester;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.grigowashere.loratester.api.DeviceCommand;
import com.grigowashere.loratester.api.PairedTrackSession;
import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.telnet.AtCommands;
import com.grigowashere.loratester.telnet.TelnetClient;
import com.grigowashere.loratester.track.TrackRecorder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class CommandPoller {
private static final String TAG = "CommandPoller";
private static final long COMMAND_POLL_MS = 2000;
private static final long PAIRED_POLL_MS = 1500;
private final ServerApi serverApi;
private final String deviceId;
private final TelemetryUploader uploader;
private final TrackRecorder trackRecorder;
private final PeerStatsCache peerStatsCache;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final AtomicBoolean running = new AtomicBoolean(false);
private volatile long pendingAckSessionId = -1;
private volatile long startedSessionId = -1;
public CommandPoller(
ServerApi serverApi,
String deviceId,
TelemetryUploader uploader,
TrackRecorder trackRecorder,
PeerStatsCache peerStatsCache
) {
this.serverApi = serverApi;
this.deviceId = deviceId;
this.uploader = uploader;
this.trackRecorder = trackRecorder;
this.peerStatsCache = peerStatsCache;
trackRecorder.setPairedListener(new TrackRecorder.Listener() {
@Override
public void onStateChanged(boolean recording, int pointCount, long trackId) {
if (recording && trackId > 0 && pendingAckSessionId > 0) {
long sid = pendingAckSessionId;
pendingAckSessionId = -1;
executor.execute(() -> ackSession(sid, trackId));
}
}
@Override
public void onError(String message) {
Log.w(TAG, "track: " + message);
}
});
}
public PeerStatsCache getPeerStatsCache() {
return peerStatsCache;
}
public void start() {
if (!running.compareAndSet(false, true)) {
return;
}
scheduleCommandPoll();
schedulePairedPoll();
}
public void stop() {
running.set(false);
}
private void scheduleCommandPoll() {
executor.execute(() -> {
if (running.get()) {
pollCommands();
}
if (running.get()) {
mainHandler.postDelayed(this::scheduleCommandPoll, COMMAND_POLL_MS);
}
});
}
private void schedulePairedPoll() {
executor.execute(() -> {
if (running.get()) {
pollPairedSession();
}
if (running.get()) {
mainHandler.postDelayed(this::schedulePairedPoll, PAIRED_POLL_MS);
}
});
}
private void pollCommands() {
try {
List<DeviceCommand> cmds = serverApi.pollPendingCommands(deviceId);
for (DeviceCommand cmd : cmds) {
execute(cmd);
}
} catch (Exception e) {
Log.w(TAG, "command poll failed", e);
}
}
private void execute(DeviceCommand cmd) {
if (cmd == null || cmd.kind == null) {
return;
}
switch (cmd.kind) {
case "at" -> {
String line = cmd.payload != null && cmd.payload.get("line") != null
? String.valueOf(cmd.payload.get("line")) : null;
if (line != null) {
uploader.sendAtCommand(line, r ->
Log.i(TAG, "remote AT " + line + " -> " + r));
}
}
case "mode" -> {
String role = cmd.payload != null && cmd.payload.get("role") != null
? String.valueOf(cmd.payload.get("role")) : null;
if ("TX".equalsIgnoreCase(role)) {
uploader.sendAtCommand(AtCommands.TRANSMIT, r -> {});
} else if ("RX".equalsIgnoreCase(role)) {
uploader.sendAtCommand(AtCommands.RECEIVE, r -> {});
}
}
case "stats_push" -> peerStatsCache.updateFromPayload(cmd.payload);
default -> Log.w(TAG, "unknown kind " + cmd.kind);
}
}
private void pollPairedSession() {
try {
Map<String, Object> resp = serverApi.getActivePairedTrack();
Object sessionObj = resp.get("session");
if (!(sessionObj instanceof Map)) {
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> m = (Map<String, Object>) sessionObj;
PairedTrackSession session = mapSession(m);
if (session == null) {
return;
}
boolean inSession = deviceId.equals(session.device_a)
|| deviceId.equals(session.device_b);
if (!inSession) {
return;
}
if (!session.ready || trackRecorder.isRecording()) {
return;
}
if (startedSessionId == session.id) {
return;
}
Long myTrack = deviceId.equals(session.device_a)
? session.track_id_a : session.track_id_b;
if (myTrack != null && myTrack > 0) {
startedSessionId = session.id;
return;
}
startedSessionId = session.id;
pendingAckSessionId = session.id;
mainHandler.post(trackRecorder::start);
} catch (Exception e) {
Log.w(TAG, "paired poll failed", e);
}
}
private void ackSession(long sessionId, long trackId) {
try {
serverApi.ackPairedTrack(sessionId, deviceId, trackId);
Log.i(TAG, "paired ack session=" + sessionId + " track=" + trackId);
} catch (Exception e) {
Log.e(TAG, "paired ack failed", e);
pendingAckSessionId = sessionId;
}
}
private static PairedTrackSession mapSession(Map<String, Object> m) {
if (m == null) {
return null;
}
PairedTrackSession s = new PairedTrackSession();
Object id = m.get("id");
if (id instanceof Number) {
s.id = ((Number) id).longValue();
}
s.device_a = str(m.get("device_a"));
s.device_b = str(m.get("device_b"));
s.initiator = str(m.get("initiator"));
s.status = str(m.get("status"));
s.start_at = num(m.get("start_at"));
s.created_at = num(m.get("created_at"));
s.server_time = num(m.get("server_time"));
Object ready = m.get("ready");
s.ready = ready instanceof Boolean && (Boolean) ready;
s.track_id_a = longOrNull(m.get("track_id_a"));
s.track_id_b = longOrNull(m.get("track_id_b"));
return s;
}
private static String str(Object o) {
return o != null ? String.valueOf(o) : null;
}
private static double num(Object o) {
return o instanceof Number ? ((Number) o).doubleValue() : 0;
}
private static Long longOrNull(Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
public void postCommandToPeer(String peerId, String kind, Map<String, Object> payload) {
executor.execute(() -> {
try {
serverApi.postCommand(deviceId, peerId, kind, payload);
} catch (Exception e) {
Log.e(TAG, "post command failed", e);
}
});
}
public void startPairedTrack(Runnable onDone, java.util.function.Consumer<String> onError) {
executor.execute(() -> {
try {
Map<String, Object> body = new HashMap<>();
body.put("device_id", deviceId);
serverApi.startPairedTrack(body);
startedSessionId = -1;
pendingAckSessionId = -1;
if (onDone != null) {
mainHandler.post(onDone);
}
} catch (Exception e) {
Log.e(TAG, "start paired failed", e);
if (onError != null) {
mainHandler.post(() -> onError.accept(
e.getMessage() != null ? e.getMessage() : "error"));
}
}
});
}
}
@@ -14,6 +14,8 @@ public class LoraApp extends Application {
private SettingsRepository settingsRepository;
private TrackRecorder trackRecorder;
private NetworkMonitor networkMonitor;
private PeerStatsCache peerStatsCache;
private CommandPoller commandPoller;
@Override
public void onCreate() {
@@ -23,12 +25,23 @@ public class LoraApp extends Application {
networkMonitor = new NetworkMonitor(this);
networkMonitor.start();
telemetryUploader = new TelemetryUploader(this, settingsRepository, networkMonitor);
peerStatsCache = new PeerStatsCache();
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
String deviceId = settingsRepository.getOrCreateDeviceId();
trackRecorder = new TrackRecorder(
new ServerApi(settingsRepository.getServerUrl()),
serverApi,
telemetryUploader,
settingsRepository.getOrCreateDeviceId(),
deviceId,
networkMonitor
);
commandPoller = new CommandPoller(
serverApi,
deviceId,
telemetryUploader,
trackRecorder,
peerStatsCache
);
commandPoller.start();
}
public NetworkMonitor getNetworkMonitor() {
@@ -47,12 +60,35 @@ public class LoraApp extends Application {
return trackRecorder;
}
public PeerStatsCache getPeerStatsCache() {
return peerStatsCache;
}
public CommandPoller getCommandPoller() {
return commandPoller;
}
public void refreshTrackRecorder() {
if (commandPoller != null) {
commandPoller.stop();
}
if (peerStatsCache == null) {
peerStatsCache = new PeerStatsCache();
}
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
trackRecorder = new TrackRecorder(
new ServerApi(settingsRepository.getServerUrl()),
serverApi,
telemetryUploader,
settingsRepository.getOrCreateDeviceId(),
networkMonitor
);
commandPoller = new CommandPoller(
serverApi,
settingsRepository.getOrCreateDeviceId(),
telemetryUploader,
trackRecorder,
peerStatsCache
);
commandPoller.start();
}
}
@@ -0,0 +1,77 @@
package com.grigowashere.loratester;
import com.grigowashere.loratester.api.DeviceInfo;
import java.util.ArrayList;
import java.util.List;
public final class PeerDevices {
private static final long ONLINE_MS = 30_000;
private PeerDevices() {
}
public static Result resolve(List<DeviceInfo> devices, String selfId) {
if (devices == null || selfId == null) {
return Result.error("no_devices");
}
List<DeviceInfo> android = new ArrayList<>();
long now = System.currentTimeMillis();
for (DeviceInfo d : devices) {
if (d.device_id != null && d.device_id.startsWith("android-")) {
android.add(d);
}
}
if (android.size() != 2) {
return Result.error("expected_two");
}
String peer = null;
int online = 0;
for (DeviceInfo d : android) {
if (d.last_seen > 0 && (now / 1000.0 - d.last_seen) <= 30) {
online++;
}
if (!selfId.equals(d.device_id)) {
peer = d.device_id;
}
}
if (peer == null) {
return Result.error("peer_missing");
}
return new Result(peer, android.size(), online);
}
public static final class Result {
public final String peerId;
public final int deviceCount;
public final int onlineCount;
public final String error;
private Result(String peerId, int deviceCount, int onlineCount) {
this.peerId = peerId;
this.deviceCount = deviceCount;
this.onlineCount = onlineCount;
this.error = null;
}
private Result(String error) {
this.peerId = null;
this.deviceCount = 0;
this.onlineCount = 0;
this.error = error;
}
static Result error(String code) {
return new Result(code);
}
public boolean ok() {
return peerId != null;
}
public boolean bothOnline() {
return ok() && onlineCount >= 2;
}
}
}
@@ -0,0 +1,48 @@
package com.grigowashere.loratester;
import androidx.annotation.Nullable;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
public class PeerStatsCache {
public static final class Snapshot {
public final String meta;
public final String role;
public final Double rssi;
public final long atMs;
public Snapshot(String meta, String role, Double rssi, long atMs) {
this.meta = meta;
this.role = role;
this.rssi = rssi;
this.atMs = atMs;
}
}
private final AtomicReference<Snapshot> snapshot = new AtomicReference<>();
public void updateFromPayload(Map<String, Object> payload) {
if (payload == null) {
return;
}
String meta = payload.get("meta") != null ? String.valueOf(payload.get("meta")) : null;
String role = payload.get("role") != null ? String.valueOf(payload.get("role")) : null;
Double rssi = null;
Object r = payload.get("rssi");
if (r instanceof Number) {
rssi = ((Number) r).doubleValue();
}
snapshot.set(new Snapshot(meta, role, rssi, System.currentTimeMillis()));
}
@Nullable
public Snapshot get() {
return snapshot.get();
}
public void clear() {
snapshot.set(null);
}
}
@@ -0,0 +1,13 @@
package com.grigowashere.loratester.api;
import java.util.Map;
public class DeviceCommand {
public long id;
public String from_device_id;
public String to_device_id;
public String kind;
public Map<String, Object> payload;
public double created_at;
public Double delivered_at;
}
@@ -0,0 +1,15 @@
package com.grigowashere.loratester.api;
public class PairedTrackSession {
public long id;
public String device_a;
public String device_b;
public String initiator;
public String status;
public double start_at;
public Long track_id_a;
public Long track_id_b;
public double created_at;
public double server_time;
public boolean ready;
}
@@ -30,6 +30,7 @@ public class ServerApi {
private static final Type TELEMETRY_HISTORY =
new TypeToken<List<TelemetryHistoryItem>>() {}.getType();
private static final Type TRACK_LIST = new TypeToken<List<TrackInfo>>() {}.getType();
private static final Type COMMAND_LIST = new TypeToken<List<DeviceCommand>>() {}.getType();
private final String baseUrl;
private final OkHttpClient client;
@@ -114,6 +115,73 @@ public class ServerApi {
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
}
public void postCommand(
String fromDeviceId,
String toDeviceId,
String kind,
Map<String, Object> payload
) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("from_device_id", fromDeviceId);
body.put("to_device_id", toDeviceId);
body.put("kind", kind);
if (payload != null) {
body.put("payload", payload);
}
postJson("/api/commands", body, true);
}
public List<DeviceCommand> pollPendingCommands(String deviceId) throws IOException {
String path = "/api/commands/pending?device_id="
+ java.net.URLEncoder.encode(deviceId, "UTF-8") + "&limit=20";
Request request = new Request.Builder()
.url(baseUrl + path)
.header(HEADER_LORA_CLIENT, CLIENT_ANDROID)
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), COMMAND_LIST);
}
}
@SuppressWarnings("unchecked")
public Map<String, Object> startPairedTrack(Map<String, Object> body) throws IOException {
return postJsonMap("/api/paired-tracks/start", body, true);
}
@SuppressWarnings("unchecked")
public Map<String, Object> getActivePairedTrack() throws IOException {
Request request = new Request.Builder()
.url(baseUrl + "/api/paired-tracks/active")
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), Map.class);
}
}
public void ackPairedTrack(long sessionId, String deviceId, long trackId) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("session_id", sessionId);
body.put("device_id", deviceId);
body.put("track_id", trackId);
postJson("/api/paired-tracks/ack", body, true);
}
public void cancelPairedTrack(Long sessionId) throws IOException {
Map<String, Object> body = new HashMap<>();
if (sessionId != null) {
body.put("session_id", sessionId);
}
postJson("/api/paired-tracks/cancel", body, false);
}
public TrackDetail getTrack(long trackId) throws IOException {
Request request = new Request.Builder()
.url(baseUrl + "/api/tracks/" + trackId)
@@ -55,6 +55,7 @@ public class TrackRecorder {
private ScheduledFuture<?> sampleTask;
private ScheduledFuture<?> flushTask;
private Listener listener;
private Listener pairedListener;
public TrackRecorder(
ServerApi serverApi,
@@ -77,6 +78,10 @@ public class TrackRecorder {
this.listener = listener;
}
public void setPairedListener(Listener pairedListener) {
this.pairedListener = pairedListener;
}
public void updateLocation(double lat, double lon, double altitude) {
if (GeoUtils.isValidCoordinate(lat, lon)) {
this.lat = lat;
@@ -230,16 +235,24 @@ public class TrackRecorder {
}
private void notifyState() {
if (listener == null) {
return;
}
mainHandler.post(() -> listener.onStateChanged(recording, totalPoints, trackId));
mainHandler.post(() -> {
if (listener != null) {
listener.onStateChanged(recording, totalPoints, trackId);
}
if (pairedListener != null) {
pairedListener.onStateChanged(recording, totalPoints, trackId);
}
});
}
private void notifyError(String msg) {
if (listener == null) {
return;
}
mainHandler.post(() -> listener.onError(msg));
mainHandler.post(() -> {
if (listener != null) {
listener.onError(msg);
}
if (pairedListener != null) {
pairedListener.onError(msg);
}
});
}
}
@@ -14,29 +14,44 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.google.android.material.button.MaterialButtonToggleGroup;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.textfield.TextInputEditText;
import com.grigowashere.loratester.CommandPoller;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.PeerDevices;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.telnet.AtCommands;
import com.grigowashere.loratester.telnet.TelnetClient;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class AtFragment extends Fragment {
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private FragmentPollHelper pollHelper;
private TelemetryUploader uploader;
private CommandPoller commandPoller;
private TextView atStatus;
private TextView atConsole;
private ScrollView atConsoleScroll;
private TextInputEditText atCommandInput;
private MaterialButtonToggleGroup atTargetGroup;
private String lastConsole = "";
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
LoraApp app = (LoraApp) context.getApplicationContext();
uploader = app.getTelemetryUploader();
commandPoller = app.getCommandPoller();
}
@Nullable
@@ -55,11 +70,16 @@ public class AtFragment extends Fragment {
atConsole = view.findViewById(R.id.atConsole);
atConsoleScroll = view.findViewById(R.id.atConsoleScroll);
atCommandInput = view.findViewById(R.id.atCommandInput);
atTargetGroup = view.findViewById(R.id.atTargetGroup);
ChipGroup chips = view.findViewById(R.id.atQuickChips);
Button sendBtn = view.findViewById(R.id.atSendBtn);
Button clearLog = view.findViewById(R.id.atClearLog);
pollHelper = new FragmentPollHelper(this, this::refreshConsole);
if (atTargetGroup != null) {
atTargetGroup.check(R.id.atTargetLocal);
}
addQuickChip(chips, "AT+H", AtCommands.HELP);
addQuickChip(chips, "AT+TX", AtCommands.TRANSMIT);
addQuickChip(chips, "AT+RX", AtCommands.RECEIVE);
@@ -85,6 +105,10 @@ public class AtFragment extends Fragment {
}
}
private boolean isPeerTarget() {
return atTargetGroup != null && atTargetGroup.getCheckedButtonId() == R.id.atTargetPeer;
}
private void addQuickChip(ChipGroup group, String label, String command) {
Chip chip = new Chip(requireContext());
chip.setText(label);
@@ -109,6 +133,10 @@ public class AtFragment extends Fragment {
if (uploader == null || !isAdded()) {
return;
}
if (isPeerTarget()) {
sendToPeer(command);
return;
}
uploader.sendAtCommand(command, result -> {
if (!isAdded()) {
return;
@@ -126,6 +154,43 @@ public class AtFragment extends Fragment {
});
}
private void sendToPeer(String command) {
if (commandPoller == null) {
return;
}
executor.execute(() -> {
try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
PeerDevices.Result peer = PeerDevices.resolve(
devices, uploader.getDeviceId());
if (!peer.ok()) {
showToast(R.string.at_peer_unavailable);
return;
}
Map<String, Object> payload = new HashMap<>();
payload.put("line", command);
commandPoller.postCommandToPeer(peer.peerId, "at", payload);
showToast(getString(R.string.at_sent_to_peer, peer.peerId));
} catch (Exception e) {
showToast(R.string.stats_push_failed);
}
});
}
private void showToast(int resId) {
if (isAdded()) {
requireActivity().runOnUiThread(() ->
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show());
}
}
private void showToast(String msg) {
if (isAdded()) {
requireActivity().runOnUiThread(() ->
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show());
}
}
private void refreshConsole() {
if (!isAdded() || uploader == null || atStatus == null) {
return;
@@ -182,7 +247,14 @@ public class AtFragment extends Fragment {
atConsole = null;
atConsoleScroll = null;
atCommandInput = null;
atTargetGroup = null;
pollHelper = null;
super.onDestroyView();
}
@Override
public void onDestroy() {
executor.shutdownNow();
super.onDestroy();
}
}
@@ -11,12 +11,15 @@ import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.Spinner;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.grigowashere.loratester.CommandPoller;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.PeerDevices;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo;
@@ -77,12 +80,14 @@ public class MapFragment extends Fragment {
private FragmentPollHelper pollHelper;
private TelemetryUploader uploader;
private TrackRecorder trackRecorder;
private CommandPoller commandPoller;
private MapView mapView;
private TileDownloadLayer downloadLayer;
private TileCache tileCache;
private TextView mapStatus;
private TextView trackStatus;
private Button btnTrack;
private Button btnPairedTrack;
private Spinner trackSpinner;
private List<TrackInfo> savedTracks = new ArrayList<>();
private boolean mapResumed;
@@ -105,6 +110,7 @@ public class MapFragment extends Fragment {
LoraApp app = (LoraApp) context.getApplicationContext();
uploader = app.getTelemetryUploader();
trackRecorder = app.getTrackRecorder();
commandPoller = app.getCommandPoller();
networkMonitor = app.getNetworkMonitor();
}
@@ -124,6 +130,7 @@ public class MapFragment extends Fragment {
mapStatus = view.findViewById(R.id.mapStatus);
trackStatus = view.findViewById(R.id.trackStatus);
btnTrack = view.findViewById(R.id.btnTrack);
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
trackSpinner = view.findViewById(R.id.trackSpinner);
pollHelper = new FragmentPollHelper(this, this::refreshDevices);
@@ -143,6 +150,9 @@ public class MapFragment extends Fragment {
setupMapView();
btnTrack.setOnClickListener(v -> toggleTracking());
if (btnPairedTrack != null) {
btnPairedTrack.setOnClickListener(v -> startPairedTracking());
}
setupTrackRecorderListener();
setupTrackSpinnerListener();
}
@@ -344,6 +354,42 @@ public class MapFragment extends Fragment {
}
}
private void startPairedTracking() {
if (commandPoller == null || uploader == null || !isAdded()) {
return;
}
executor.execute(() -> {
try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
PeerDevices.Result peer = PeerDevices.resolve(devices, uploader.getDeviceId());
if (!peer.bothOnline()) {
requireActivity().runOnUiThread(() ->
Toast.makeText(requireContext(), R.string.track_paired_need_two,
Toast.LENGTH_SHORT).show());
return;
}
commandPoller.startPairedTrack(
() -> {
if (isAdded()) {
Toast.makeText(requireContext(), R.string.track_paired_started,
Toast.LENGTH_SHORT).show();
}
},
msg -> {
if (isAdded()) {
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
}
});
} catch (Exception e) {
if (isAdded()) {
requireActivity().runOnUiThread(() ->
Toast.makeText(requireContext(), R.string.track_paired_need_two,
Toast.LENGTH_SHORT).show());
}
}
});
}
private void loadTrackList() {
if (uploader == null || !mapResumed) {
return;
@@ -7,6 +7,7 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -14,7 +15,10 @@ import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.grigowashere.loratester.CommandPoller;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.PeerDevices;
import com.grigowashere.loratester.PeerStatsCache;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo;
@@ -25,8 +29,10 @@ import com.grigowashere.loratester.telnet.StatsExtractor;
import java.text.DateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -39,14 +45,21 @@ public class StatsFragment extends Fragment {
DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault());
private FragmentPollHelper pollHelper;
private TelemetryUploader uploader;
private CommandPoller commandPoller;
private PeerStatsCache peerStatsCache;
private TextView statsStatus;
private TextView statsDetails;
private TextView statsPeerWarning;
private TextView statsLocalDetails;
private TextView statsPeerDetails;
private RecyclerView statsHistoryList;
private final HistoryAdapter historyAdapter = new HistoryAdapter();
private StatsExtractor.ExtractedStats cachedLocal;
private DeviceInfo cachedServer;
private DeviceInfo cachedPeer;
private int cachedDeviceCount;
private String cachedPeerId;
private String cachedPeerError;
private String cachedError;
private final TelemetryUploader.StatsListener statsListener = stats -> {
@@ -58,7 +71,10 @@ public class StatsFragment extends Fragment {
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
LoraApp app = (LoraApp) context.getApplicationContext();
uploader = app.getTelemetryUploader();
commandPoller = app.getCommandPoller();
peerStatsCache = app.getPeerStatsCache();
}
@Nullable
@@ -74,11 +90,16 @@ public class StatsFragment extends Fragment {
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
statsStatus = view.findViewById(R.id.statsStatus);
statsDetails = view.findViewById(R.id.statsDetails);
statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
statsLocalDetails = view.findViewById(R.id.statsLocalDetails);
statsPeerDetails = view.findViewById(R.id.statsPeerDetails);
statsHistoryList = view.findViewById(R.id.statsHistoryList);
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
statsHistoryList.setAdapter(historyAdapter);
Button btnSimulate = view.findViewById(R.id.btnSimulate);
Button btnPushStats = view.findViewById(R.id.btnPushStats);
Button btnModeTx = view.findViewById(R.id.btnModeTxPeer);
Button btnModeRx = view.findViewById(R.id.btnModeRxPeer);
pollHelper = new FragmentPollHelper(this, this::refresh);
btnSimulate.setOnClickListener(v -> {
@@ -94,6 +115,50 @@ public class StatsFragment extends Fragment {
statsStatus.setText(R.string.simulate_sent);
}
});
btnPushStats.setOnClickListener(v -> pushStatsToPeer());
btnModeTx.setOnClickListener(v -> sendModeToPeer("TX"));
btnModeRx.setOnClickListener(v -> sendModeToPeer("RX"));
}
private void pushStatsToPeer() {
if (commandPoller == null || cachedPeerId == null) {
toast(R.string.at_peer_unavailable);
return;
}
Map<String, Object> payload = new HashMap<>();
String meta = pickMetaJson(true);
if (meta != null) {
payload.put("meta", meta);
}
Double rssi = pickRssi(true);
if (rssi != null) {
payload.put("rssi", rssi);
}
if (cachedLocal != null && cachedLocal.role != null) {
payload.put("role", cachedLocal.role);
} else if (cachedServer != null && cachedServer.role != null) {
payload.put("role", cachedServer.role);
}
commandPoller.postCommandToPeer(cachedPeerId, "stats_push", payload);
toast(R.string.stats_pushed);
}
private void sendModeToPeer(String role) {
if (commandPoller == null || cachedPeerId == null) {
toast(R.string.at_peer_unavailable);
return;
}
Map<String, Object> payload = new HashMap<>();
payload.put("role", role);
commandPoller.postCommandToPeer(cachedPeerId, "mode", payload);
toast(R.string.stats_pushed);
}
private void toast(int resId) {
if (isAdded()) {
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
}
}
@Override
@@ -126,7 +191,9 @@ public class StatsFragment extends Fragment {
pollHelper.stop();
}
statsStatus = null;
statsDetails = null;
statsPeerWarning = null;
statsLocalDetails = null;
statsPeerDetails = null;
statsHistoryList = null;
pollHelper = null;
super.onDestroyView();
@@ -139,7 +206,7 @@ public class StatsFragment extends Fragment {
}
private void postRender() {
if (!isAdded() || statsDetails == null) {
if (!isAdded() || statsLocalDetails == null) {
return;
}
requireActivity().runOnUiThread(this::renderDetails);
@@ -161,15 +228,19 @@ public class StatsFragment extends Fragment {
List<TelemetryHistoryItem> history = null;
try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
DeviceInfo self = null;
cachedDeviceCount = devices.size();
PeerDevices.Result peer = PeerDevices.resolve(devices, deviceId);
cachedPeerId = peer.peerId;
cachedPeerError = peer.error;
cachedPeer = null;
cachedServer = null;
for (DeviceInfo d : devices) {
if (deviceId.equals(d.device_id)) {
self = d;
break;
cachedServer = d;
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
cachedPeer = d;
}
}
cachedServer = self;
cachedDeviceCount = devices.size();
cachedError = null;
history = uploader.getServerApi().getTelemetryHistory(deviceId, 30);
} catch (Exception e) {
@@ -191,75 +262,117 @@ public class StatsFragment extends Fragment {
}
private void renderDetails() {
if (!isAdded() || statsDetails == null || uploader == null) {
if (!isAdded() || statsLocalDetails == null || uploader == null) {
return;
}
StringBuilder sb = new StringBuilder();
sb.append(getString(R.string.devices_on_server, cachedDeviceCount)).append("\n");
sb.append(getString(
uploader.isTelnetConnected() ? R.string.telnet_connected : R.string.telnet_disconnected
));
long at = uploader.getLastStatsAtMs();
if (at > 0) {
sb.append(" · ").append(getString(R.string.stats_updated_at, timeFormat.format(new Date(at))));
if (statsPeerWarning != null) {
if (cachedPeerError != null) {
statsPeerWarning.setVisibility(View.VISIBLE);
statsPeerWarning.setText(
getString(R.string.stats_two_devices_required, cachedDeviceCount));
} else {
statsPeerWarning.setVisibility(View.GONE);
}
}
sb.append("\n\n");
String meta = pickMetaJson();
statsLocalDetails.setText(formatDeviceBlock(true));
statsPeerDetails.setText(formatDeviceBlock(false));
}
private CharSequence formatDeviceBlock(boolean local) {
StringBuilder sb = new StringBuilder();
if (local) {
sb.append(uploader.isTelnetConnected()
? getString(R.string.telnet_connected)
: getString(R.string.telnet_disconnected));
long at = uploader.getLastStatsAtMs();
if (at > 0) {
sb.append(" · ").append(timeFormat.format(new Date(at)));
}
sb.append("\n\n");
appendStatsBody(sb, pickMetaJson(true), pickRssi(true), cachedServer, true);
} else {
if (cachedPeerId == null) {
sb.append(getString(R.string.stats_peer_absent));
return sb;
}
sb.append(cachedPeerId);
if (cachedPeer != null && cachedPeer.role != null) {
sb.append(" · ").append(cachedPeer.role);
}
sb.append("\n\n");
PeerStatsCache.Snapshot push = peerStatsCache != null ? peerStatsCache.get() : null;
if (push != null && push.meta != null) {
appendStatsBody(sb, push.meta, push.rssi, cachedPeer, false);
} else if (cachedPeer != null) {
appendStatsBody(sb, cachedPeer.meta, cachedPeer.rssi, cachedPeer, false);
} else {
sb.append(getString(R.string.no_telemetry_yet));
}
}
return sb;
}
private void appendStatsBody(
StringBuilder sb,
String meta,
Double rssi,
DeviceInfo gpsSource,
boolean local
) {
if (meta != null && !meta.isEmpty()) {
String fields = LoraStatsFormatter.formatMeta(meta);
if (!fields.isEmpty()) {
sb.append(fields).append("\n");
}
} else if (cachedError != null) {
} else if (local && cachedError != null) {
sb.append(getString(R.string.stats_error, cachedError)).append("\n");
} else {
} else if (local) {
sb.append(getString(R.string.no_telemetry_yet)).append("\n");
}
Double rssi = pickRssi();
sb.append("\nСигнал (dBm): ").append(rssi != null ? rssi : "").append("\n");
Double lat = null;
Double lon = null;
if (cachedServer != null) {
lat = cachedServer.lat;
lon = cachedServer.lon;
if (gpsSource != null) {
lat = gpsSource.lat;
lon = gpsSource.lon;
}
if (GeoUtils.isValidCoordinate(lat, lon)) {
sb.append("GPS: ").append(lat).append(", ").append(lon).append("\n");
} else {
sb.append(getString(R.string.gps_waiting)).append("\n");
}
statsDetails.setText(sb.toString());
}
private String pickMetaJson() {
boolean telnet = uploader.isTelnetConnected();
if (telnet && cachedLocal != null && cachedLocal.metaJson != null) {
return cachedLocal.metaJson;
}
if (cachedServer != null && cachedServer.meta != null && !cachedServer.meta.isEmpty()) {
return cachedServer.meta;
}
if (cachedLocal != null && cachedLocal.metaJson != null) {
return cachedLocal.metaJson;
private String pickMetaJson(boolean local) {
if (local) {
boolean telnet = uploader.isTelnetConnected();
if (telnet && cachedLocal != null && cachedLocal.metaJson != null) {
return cachedLocal.metaJson;
}
if (cachedServer != null && cachedServer.meta != null && !cachedServer.meta.isEmpty()) {
return cachedServer.meta;
}
if (cachedLocal != null && cachedLocal.metaJson != null) {
return cachedLocal.metaJson;
}
}
return null;
}
private Double pickRssi() {
boolean telnet = uploader.isTelnetConnected();
if (telnet && cachedLocal != null && cachedLocal.rssi != null) {
return cachedLocal.rssi;
}
if (cachedServer != null && cachedServer.rssi != null) {
return cachedServer.rssi;
}
if (cachedLocal != null) {
return cachedLocal.rssi;
private Double pickRssi(boolean local) {
if (local) {
boolean telnet = uploader.isTelnetConnected();
if (telnet && cachedLocal != null && cachedLocal.rssi != null) {
return cachedLocal.rssi;
}
if (cachedServer != null && cachedServer.rssi != null) {
return cachedServer.rssi;
}
if (cachedLocal != null) {
return cachedLocal.rssi;
}
}
return null;
}
+27
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
@@ -11,6 +12,32 @@
android:layout_height="wrap_content"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/atTargetGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/atTargetLocal"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_target_local"
android:checked="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/atTargetPeer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_target_peer" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<com.google.android.material.chip.ChipGroup
android:id="@+id/atQuickChips"
android:layout_width="match_parent"
+10
View File
@@ -50,6 +50,16 @@
android:text="@string/track_start"
android:textSize="11sp" />
<Button
android:id="@+id/btnPairedTrack"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:minHeight="36dp"
android:text="@string/track_paired_start"
android:textSize="11sp" />
<TextView
android:id="@+id/trackStatus"
android:layout_width="match_parent"
+90 -5
View File
@@ -15,20 +15,105 @@
android:layout_height="wrap_content"
android:textSize="14sp" />
<TextView
android:id="@+id/statsPeerWarning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#FF9800"
android:textSize="12sp"
android:visibility="gone" />
<Button
android:id="@+id/btnSimulate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginTop="8dp"
android:text="@string/simulate_telnet" />
<TextView
android:id="@+id/statsDetails"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="monospace"
android:textSize="12sp" />
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingEnd="6dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/stats_local_title"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statsLocalDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:fontFamily="monospace"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingStart="6dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/stats_peer_title"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statsPeerDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:fontFamily="monospace"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/btnPushStats"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:text="@string/stats_push_peer" />
<Button
android:id="@+id/btnModeTxPeer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TX→" />
<Button
android:id="@+id/btnModeRxPeer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="RX→" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
+14
View File
@@ -49,4 +49,18 @@
<string name="track_error">Трек: %1$s</string>
<string name="track_spinner_hint">Сохранённые треки</string>
<string name="track_none">— нет треков —</string>
<string name="stats_local_title">Это устройство</string>
<string name="stats_peer_title">Другое устройство</string>
<string name="stats_peer_absent">Нет данных (ожидается 2 устройства online)</string>
<string name="stats_two_devices_required">Нужно ровно 2 устройства на сервере (сейчас %1$d)</string>
<string name="stats_push_peer">Отправить статистику на другое</string>
<string name="stats_pushed">Статистика отправлена</string>
<string name="stats_push_failed">Не удалось отправить</string>
<string name="at_target_local">Локально</string>
<string name="at_target_peer">На другое устройство</string>
<string name="at_sent_to_peer">Команда отправлена на %1$s</string>
<string name="at_peer_unavailable">Нет второго устройства</string>
<string name="track_paired_start">Старт трека (оба)</string>
<string name="track_paired_started">Синхронный старт запланирован</string>
<string name="track_paired_need_two">Нужны 2 устройства online</string>
</resources>
+16 -1
View File
@@ -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}`
Binary file not shown.
Binary file not shown.
Binary file not shown.
+41 -1
View File
@@ -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):
+300
View File
@@ -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()}
+104
View File
@@ -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()
+92
View File
@@ -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()
Binary file not shown.
+380 -35
View File
@@ -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 @@
<div id="stats">Выберите устройство</div>
<div id="history" class="muted" style="margin-top:8px"></div>
</div>
<div class="panel" id="controlPanel">
<h2>Управление</h2>
<label class="muted">Целевое устройство</label>
<select id="cmdTargetSelect"><option value=""></option></select>
<input type="text" id="cmdAtInput" placeholder="AT+SF=7 …" />
<div class="cmd-row">
<button type="button" id="btnCmdAt">AT</button>
<button type="button" id="btnCmdTx">AT+TX</button>
<button type="button" id="btnCmdRx">AT+RX</button>
</div>
<button type="button" id="btnPairedStart" style="width:100%;margin-top:8px;padding:6px;background:#00ff88;color:#111;border:none;border-radius:4px;font-weight:600;cursor:pointer">Старт трека (оба)</button>
<div id="pairedStatus"></div>
</div>
<div class="panel">
<h2>Сравнение треков</h2>
<div class="track-row">
<label class="legend-tx">Трек TX</label>
<select id="trackTxSelect"><option value=""></option></select>
<h2>Треки</h2>
<div class="track-mode">
<button type="button" id="btnModeSingle" class="active">Один трек</button>
<button type="button" id="btnModeDual">Сравнение TX+RX</button>
</div>
<div class="track-row">
<label class="legend-rx">Трек RX</label>
<select id="trackRxSelect"><option value=""></option></select>
</div>
<button type="button" id="btnShowTracks">Показать на карте</button>
<div id="trackInfo" class="muted" style="margin-top:6px">Выберите TX и RX треки</div>
<div id="trackTimeline">
<h3 style="margin:0 0 6px;font-size:0.9rem">Время теста</h3>
<div id="timelineNote"></div>
<div class="timeline-labels">
<span id="timeStart"></span>
<span id="timeCurrent"></span>
<span id="timeEnd"></span>
<div id="trackPanelSingle">
<div class="track-row">
<label>Трек</label>
<select id="trackSingleSelect"><option value=""></option></select>
</div>
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1" />
</div>
<div id="trackPanelDual" style="display:none">
<div class="track-row">
<label class="legend-tx">Трек TX</label>
<select id="trackTxSelect"><option value=""></option></select>
</div>
<div class="track-row">
<label class="legend-rx">Трек RX</label>
<select id="trackRxSelect"><option value=""></option></select>
</div>
</div>
<div class="track-actions">
<button type="button" id="btnShowTracks">Показать на карте</button>
<button type="button" id="btnHideTracks" disabled>Скрыть треки</button>
</div>
<div id="trackInfo" class="muted" style="margin-top:6px">Выберите трек</div>
<div id="timelineStatsPanel">
<h3 style="margin:0 0 6px;font-size:0.9rem">Статистика по времени</h3>
<div id="timelineNote"></div>
<div id="distanceNow">Расстояние GPS: —</div>
<div id="timelineStats">
<div class="timeline-col tx"><h3>TX</h3><div id="statsTx"></div></div>
<div class="timeline-col tx"><h3 id="timelineCol1Label">TX</h3><div id="statsTx"></div></div>
<div class="timeline-col rx"><h3>RX</h3><div id="statsRx"></div></div>
</div>
<button type="button" id="btnPlay" class="muted" style="margin-top:6px;padding:4px 10px;border:none;border-radius:4px;cursor:pointer;background:#0a0a14;color:#eee">▶ Play</button>
</div>
</div>
<div class="panel" style="flex:1;display:flex;flex-direction:column">
@@ -131,6 +178,20 @@
</form>
</div>
</aside>
<div id="trackTimeline">
<div class="timeline-bar">
<div class="timeline-bar-header">
<span class="timeline-bar-title">Время теста</span>
<button type="button" id="btnPlay" class="muted" style="padding:4px 10px;border:none;border-radius:4px;cursor:pointer;background:#0a0a14;color:#eee">▶ Play</button>
</div>
<div class="timeline-labels">
<span id="timeStart"></span>
<span id="timeCurrent"></span>
<span id="timeEnd"></span>
</div>
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1" />
</div>
</div>
</main>
<div id="mapModal">
<div id="mapModalHeader">
@@ -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) : '<span class="muted">нет данных</span>';
}
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 = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
html += formatMeta(pos.meta);
const tel = nearestTelemetry(telemetrySingle, t);
if (tel) html += '<br>' + formatTelemetryRow(tel);
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
openMapModal(html, 'timeline');
}
}
const tel = nearestTelemetry(telemetrySingle, t);
document.getElementById('statsTx').innerHTML = tel
? formatTelemetryRow(tel) : '<span class="muted">нет данных</span>';
}
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 = '<option value="">— устройство —</option>';
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();
</script>
</body>
</html>