diff --git a/app/src/main/java/com/grigowashere/loratester/LoraApp.java b/app/src/main/java/com/grigowashere/loratester/LoraApp.java
index e86ac79..e4b72e1 100644
--- a/app/src/main/java/com/grigowashere/loratester/LoraApp.java
+++ b/app/src/main/java/com/grigowashere/loratester/LoraApp.java
@@ -42,6 +42,14 @@ public class LoraApp extends Application {
peerStatsCache
);
commandPoller.start();
+ telemetryUploader.registerPresence();
+ if (networkMonitor != null) {
+ networkMonitor.addListener(online -> {
+ if (online) {
+ telemetryUploader.registerPresence();
+ }
+ });
+ }
}
public NetworkMonitor getNetworkMonitor() {
diff --git a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java
index 3cc8c61..675e92b 100644
--- a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java
+++ b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java
@@ -299,6 +299,24 @@ public class TelemetryUploader implements TelnetClient.Listener {
uploadExecutor.execute(() -> uploadTelemetry(payload));
}
+ public void registerPresence() {
+ uploadExecutor.execute(() -> {
+ TelemetryPayload payload = new TelemetryPayload(
+ settings.getOrCreateDeviceId(),
+ phoneLabel(),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ System.currentTimeMillis() / 1000.0
+ );
+ uploadTelemetry(payload);
+ });
+ }
+
private static String phoneLabel() {
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER : "";
String model = Build.MODEL != null ? Build.MODEL : "";
diff --git a/server/core/__pycache__/models.cpython-313.pyc b/server/core/__pycache__/models.cpython-313.pyc
index 40a7dcc..ab772e3 100644
Binary files a/server/core/__pycache__/models.cpython-313.pyc and b/server/core/__pycache__/models.cpython-313.pyc differ
diff --git a/server/core/__pycache__/storage.cpython-313.pyc b/server/core/__pycache__/storage.cpython-313.pyc
index 5d5ec94..0decb08 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/storage.py b/server/core/storage.py
index 4b72586..1646979 100644
--- a/server/core/storage.py
+++ b/server/core/storage.py
@@ -15,6 +15,8 @@ WEB_SENDER_ID = "web"
COMMAND_KINDS = frozenset({"at", "mode", "stats_push"})
PAIRED_ONLINE_SEC = 30.0
PAIRED_START_DELAY_SEC = 3.0
+# Hide devices on map/UI after this many seconds without telemetry.
+DEVICE_VISIBLE_SEC = 180.0
logger = logging.getLogger(__name__)
@@ -163,8 +165,13 @@ def list_devices() -> list[dict[str, Any]]:
ORDER BY d.last_seen DESC
"""
).fetchall()
+ cutoff = time.time() - DEVICE_VISIBLE_SEC
devices = [_row_to_device(r) for r in rows]
- return [d for d in devices if not _is_null_island(d)]
+ return [
+ d
+ for d in devices
+ if not _is_null_island(d) and d.get("last_seen", 0) >= cutoff
+ ]
def _is_null_island(device: dict[str, Any]) -> bool:
@@ -335,13 +342,18 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
WHERE p.track_id = t.id AND p.role IS NOT NULL AND p.role != ''
ORDER BY p.ts DESC LIMIT 1)
"""
- if device_id:
- rows = conn.execute(
- f"""
+ track_cols = f"""
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
+ d.label AS device_label,
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
{role_sub} AS role
FROM tracks t
+ LEFT JOIN devices d ON d.device_id = t.device_id
+ """
+ if device_id:
+ rows = conn.execute(
+ f"""
+ {track_cols}
WHERE t.device_id = ?
ORDER BY t.started_at DESC
LIMIT ?
@@ -351,10 +363,7 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
else:
rows = conn.execute(
f"""
- SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
- (SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
- {role_sub} AS role
- FROM tracks t
+ {track_cols}
ORDER BY t.started_at DESC
LIMIT ?
""",
@@ -367,7 +376,11 @@ def get_track(track_id: int) -> dict[str, Any]:
with _db() as conn:
track = conn.execute(
"""
- SELECT id, device_id, started_at, ended_at, label FROM tracks WHERE id = ?
+ SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
+ d.label AS device_label
+ FROM tracks t
+ LEFT JOIN devices d ON d.device_id = t.device_id
+ WHERE t.id = ?
""",
(track_id,),
).fetchone()
@@ -408,9 +421,11 @@ def get_chat(since: float = 0.0, limit: int = 200) -> list[dict[str, Any]]:
with _db() as conn:
rows = conn.execute(
"""
- SELECT id, device_id, text, ts FROM chat
- WHERE ts > ?
- ORDER BY ts ASC LIMIT ?
+ SELECT c.id, c.device_id, c.text, c.ts, d.label AS device_label
+ FROM chat c
+ LEFT JOIN devices d ON d.device_id = c.device_id
+ WHERE c.ts > ?
+ ORDER BY c.ts ASC LIMIT ?
""",
(since, limit),
).fetchall()
diff --git a/server/static/index.html b/server/static/index.html
index 9ff79c5..c8700bd 100644
--- a/server/static/index.html
+++ b/server/static/index.html
@@ -390,6 +390,8 @@
let dualTracksActive = false;
let singleTrackActive = false;
let lastDevices = [];
+ const deviceLabelCache = {};
+ let timelineSpanMs = 1000;
let elevProfileTx = null;
let elevProfileRx = null;
let elevProfileSingle = null;
@@ -433,17 +435,61 @@
return Math.abs(lat) < 1e-5 && Math.abs(lon) < 1e-5;
}
+ function rememberDeviceLabels(devices) {
+ (devices || []).forEach(d => {
+ const lbl = d.device_label || d.label;
+ if (lbl && lbl !== d.device_id) deviceLabelCache[d.device_id] = lbl;
+ });
+ }
+
function deviceDisplayName(d) {
if (!d) return '—';
- const dev = typeof d === 'string' ? lastDevices.find(x => x.device_id === d) : d;
+ if (typeof d === 'object') {
+ const direct = d.device_label || d.label;
+ if (direct && direct !== d.device_id) return direct;
+ }
const id = typeof d === 'string' ? d : d.device_id;
- const label = dev?.label;
+ if (deviceLabelCache[id]) return deviceLabelCache[id];
+ const dev = typeof d === 'string' ? lastDevices.find(x => x.device_id === id) : d;
+ const label = dev?.device_label || dev?.label;
if (label && label !== id) return label;
return id || '—';
}
function sliderTime() {
- return overlapMin + parseFloat(document.getElementById('timeSlider').value || '0');
+ const ms = parseInt(document.getElementById('timeSlider').value || '0', 10);
+ return overlapMin + ms / 1000;
+ }
+
+ function normalizeTrack(track) {
+ if (!track?.points?.length) return track;
+ const points = track.points.map(p => ({
+ ...p,
+ ts: Number(p.ts),
+ lat: Number(p.lat),
+ lon: Number(p.lon),
+ }));
+ const maxTs = Math.max(...points.map(p => p.ts));
+ if (maxTs > 1e11) {
+ points.forEach(p => { p.ts /= 1000; });
+ }
+ points.sort((a, b) => a.ts - b.ts);
+ return { ...track, points };
+ }
+
+ function snapAtTime(track, telemetryRows, t, roleFallback) {
+ const tel = telemetryAtTime(telemetryRows, t);
+ if (tel) return tel;
+ const pos = positionAt(track?.points, t);
+ if (!pos) return null;
+ return {
+ meta: pos.meta,
+ role: roleFallback || track?.role,
+ rssi: pos.rssi,
+ ts: t,
+ lat: pos.lat,
+ lon: pos.lon
+ };
}
function rxQualityFromMeta(meta) {
@@ -454,7 +500,7 @@
function qualityColor(pct) {
if (pct == null || Number.isNaN(pct)) return null;
- const p = Math.max(0, Math.min(100, pct));
+ const p = Math.max(0, Math.min(100, Number(pct)));
const r = p < 50 ? Math.round(255 * (p / 50)) : 255;
const g = p < 50 ? 255 : Math.round(255 * (1 - (p - 50) / 50));
return `rgb(${r},${g},0)`;
@@ -1635,34 +1681,40 @@
/* --- Track helpers --- */
function positionAt(points, t) {
if (!points || !points.length) return null;
+ const tNum = Number(t);
const first = points[0];
const last = points[points.length - 1];
- if (t <= first.ts) {
- return { lat: first.lat, lon: first.lon, meta: first.meta, rssi: first.rssi };
+ const t0 = Number(first.ts);
+ const t1 = Number(last.ts);
+ if (tNum <= t0) {
+ return { lat: Number(first.lat), lon: Number(first.lon), meta: first.meta, rssi: first.rssi };
}
- if (t >= last.ts) {
- return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
+ if (tNum >= t1) {
+ return { lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi };
}
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
- if (t >= a.ts && t <= b.ts) {
- const f = (t - a.ts) / (b.ts - a.ts);
+ const ta = Number(a.ts);
+ const tb = Number(b.ts);
+ if (tNum >= ta && tNum <= tb) {
+ const span = Math.max(tb - ta, 1e-9);
+ const f = (tNum - ta) / span;
return {
- lat: a.lat + (b.lat - a.lat) * f,
- lon: a.lon + (b.lon - a.lon) * f,
- meta: t - a.ts < b.ts - t ? a.meta : b.meta,
- rssi: t - a.ts < b.ts - t ? a.rssi : b.rssi
+ lat: Number(a.lat) + (Number(b.lat) - Number(a.lat)) * f,
+ lon: Number(a.lon) + (Number(b.lon) - Number(a.lon)) * f,
+ meta: tNum - ta < tb - tNum ? a.meta : b.meta,
+ rssi: tNum - ta < tb - tNum ? a.rssi : b.rssi
};
}
}
- return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
+ return { lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi };
}
function overlapRange(txPts, rxPts) {
if (!txPts.length || !rxPts.length) return null;
- const min = Math.max(txPts[0].ts, rxPts[0].ts);
- const max = Math.min(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
+ const min = Math.max(Number(txPts[0].ts), Number(rxPts[0].ts));
+ const max = Math.min(Number(txPts[txPts.length - 1].ts), Number(rxPts[rxPts.length - 1].ts));
if (min >= max) return null;
return { min, max, mode: 'overlap' };
}
@@ -1672,8 +1724,8 @@
if (!txPts.length || !rxPts.length) return null;
const overlap = overlapRange(txPts, rxPts);
if (overlap) return overlap;
- const min = Math.min(txPts[0].ts, rxPts[0].ts);
- const max = Math.max(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
+ const min = Math.min(Number(txPts[0].ts), Number(rxPts[0].ts));
+ const max = Math.max(Number(txPts[txPts.length - 1].ts), Number(rxPts[rxPts.length - 1].ts));
if (min >= max) return null;
return { min, max, mode: 'union' };
}
@@ -1802,7 +1854,7 @@
const qa = rxQualityFromMeta(a.meta);
const qb = rxQualityFromMeta(b.meta);
const q = qa != null && qb != null ? (qa + qb) / 2 : (qa ?? qb);
- const segColor = useQuality && q != null ? (qualityColor(q) || color) : color;
+ const segColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color;
const seg = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
color: segColor, weight: 4, opacity: 0.85
}).addTo(map);
@@ -1811,16 +1863,16 @@
pts.forEach(p => {
const q = rxQualityFromMeta(p.meta);
- const ptColor = useQuality && q != null ? (qualityColor(q) || color) : color;
+ const ptColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color;
const m = L.circleMarker([p.lat, p.lon], {
radius: 3, color: ptColor, fillColor: ptColor, fillOpacity: 0.8
});
m.addTo(map);
m.on('click', () => {
- const rel = Math.max(0, Math.min(p.ts - overlapMin, parseFloat(document.getElementById('timeSlider').max)));
- document.getElementById('timeSlider').value = String(rel);
+ const relMs = Math.max(0, Math.min(Math.round((p.ts - overlapMin) * 1000), timelineSpanMs));
+ document.getElementById('timeSlider').value = String(relMs);
modalMode = 'timeline';
- updateTimelineAt(p.ts, { openModal: true });
+ updateTimelineAt(Number(p.ts), { openModal: true });
});
markerList.push(m);
});
@@ -1846,7 +1898,7 @@
function singleTrackRange(points) {
if (!points || !points.length) return null;
- return { min: points[0].ts, max: points[points.length - 1].ts, mode: 'single' };
+ return { min: Number(points[0].ts), max: Number(points[points.length - 1].ts), mode: 'single' };
}
function updateTimelineAt(t, opts) {
@@ -1922,7 +1974,7 @@
}).addTo(map);
let html = `${new Date(t * 1000).toLocaleTimeString()}
`;
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}
`;
- const tel = nearestTelemetry(telemetrySingle, t);
+ const tel = snapAtTime(track, telemetrySingle, t, track.role);
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(pos.meta);
html += RadioUI.formatRadioPanel(snap, new Set(), isRadioStaticOpen(mapModalBody));
if (tel) html += '
' + formatTelemetryRow(tel, new Set());
@@ -1930,7 +1982,7 @@
openMapModal(html, 'timeline');
}
}
- const tel = nearestTelemetry(telemetrySingle, t);
+ const tel = snapAtTime(track, telemetrySingle, t, track.role);
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null);
const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml(
@@ -1960,14 +2012,16 @@
const note = document.getElementById('timelineNote');
overlapMin = range.min;
overlapMax = range.max;
- const span = Math.max(0.1, overlapMax - overlapMin);
+ const spanSec = Math.max(0.001, overlapMax - overlapMin);
+ timelineSpanMs = Math.max(1, Math.round(spanSec * 1000));
const slider = document.getElementById('timeSlider');
slider.min = 0;
- slider.max = String(span);
- slider.step = span > 300 ? '1' : (span > 60 ? '0.5' : '0.1');
+ slider.max = String(timelineSpanMs);
+ slider.step = timelineSpanMs > 300000 ? 1000 : (timelineSpanMs > 60000 ? 500 : 100);
slider.value = '0';
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
+ document.getElementById('timeCurrent').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
if (noteText != null) note.textContent = noteText;
}
@@ -2033,7 +2087,7 @@
function trackOptionLabel(t) {
const start = new Date(t.started_at * 1000).toLocaleString();
const role = t.role ? ` · ${t.role}` : '';
- const dev = t.device_id ? ` · ${deviceDisplayName(t.device_id)}` : '';
+ const dev = t.device_id ? ` · ${deviceDisplayName(t)}` : '';
return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
}
@@ -2047,6 +2101,7 @@
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();
+ rememberDeviceLabels(tracks);
const fill = (sel, hint) => {
sel.innerHTML = ``;
tracks.forEach(t => {
@@ -2079,9 +2134,14 @@
singleTrackActive = false;
loadedTxTrack = null;
loadedRxTrack = null;
- if (playTimer) { clearInterval(playTimer); playTimer = null; }
+ if (playTimer) {
+ clearInterval(playTimer);
+ playTimer = null;
+ document.getElementById('btnPlay').textContent = '▶ Play';
+ }
const res = await fetch(`/api/tracks/${id}`, { cache: 'no-store' });
- loadedSingleTrack = await res.json();
+ loadedSingleTrack = normalizeTrack(await res.json());
+ rememberDeviceLabels([loadedSingleTrack]);
if (!loadedSingleTrack.role && loadedSingleTrack.points) {
const p = loadedSingleTrack.points.find(x => x.role);
if (p) loadedSingleTrack.role = p.role;
@@ -2131,14 +2191,18 @@
clearTrackLayers();
singleTrackActive = false;
loadedSingleTrack = null;
- if (playTimer) { clearInterval(playTimer); playTimer = null; }
-
+ if (playTimer) {
+ clearInterval(playTimer);
+ playTimer = null;
+ document.getElementById('btnPlay').textContent = '▶ Play';
+ }
const [txRes, rxRes] = await Promise.all([
fetch(`/api/tracks/${txId}`),
fetch(`/api/tracks/${rxId}`)
]);
- loadedTxTrack = await txRes.json();
- loadedRxTrack = await rxRes.json();
+ loadedTxTrack = normalizeTrack(await txRes.json());
+ loadedRxTrack = normalizeTrack(await rxRes.json());
+ rememberDeviceLabels([loadedTxTrack, loadedRxTrack]);
if (!loadedTxTrack.points?.length || !loadedRxTrack.points?.length) {
document.getElementById('trackInfo').textContent = 'Пустой трек';
return;
@@ -2313,27 +2377,34 @@
}
refreshPairedStatus();
};
- document.getElementById('timeSlider').oninput = e => {
- modalMode = 'timeline';
- updateTimelineAt(overlapMin + parseFloat(e.target.value), { openModal: true });
- };
- document.getElementById('btnPlay').onclick = () => {
- if (playTimer) {
- clearInterval(playTimer);
- playTimer = null;
- document.getElementById('btnPlay').textContent = '▶ Play';
- return;
- }
+ function setupTimelineControls() {
const slider = document.getElementById('timeSlider');
- document.getElementById('btnPlay').textContent = '⏸ Pause';
- playTimer = setInterval(() => {
- const step = parseFloat(slider.step) || 1;
- let v = parseFloat(slider.value) + step;
- if (v > parseFloat(slider.max)) v = 0;
- slider.value = String(v);
- updateTimelineAt(overlapMin + v, { openModal: isModalOpen() && modalMode === 'timeline' });
- }, Math.max(200, Math.round(step * 1000)));
- };
+ const onSlider = () => {
+ if (!singleTrackActive && !dualTracksActive) return;
+ modalMode = 'timeline';
+ updateTimelineAt(sliderTime());
+ };
+ slider.addEventListener('input', onSlider);
+ slider.addEventListener('change', onSlider);
+
+ document.getElementById('btnPlay').onclick = () => {
+ if (!singleTrackActive && !dualTracksActive) return;
+ if (playTimer) {
+ clearInterval(playTimer);
+ playTimer = null;
+ document.getElementById('btnPlay').textContent = '▶ Play';
+ return;
+ }
+ document.getElementById('btnPlay').textContent = '⏸ Pause';
+ const step = parseInt(slider.step, 10) || 100;
+ playTimer = setInterval(() => {
+ let ms = parseInt(slider.value, 10) + step;
+ if (ms > timelineSpanMs) ms = 0;
+ slider.value = String(ms);
+ updateTimelineAt(sliderTime());
+ }, Math.max(100, step));
+ };
+ }
function buildDeviceStatsHtml(d) {
const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi);
@@ -2370,6 +2441,8 @@
const res = await fetch('/api/devices', { cache: 'no-store' });
if (!res.ok) throw new Error('devices ' + res.status);
const devices = await res.json();
+ rememberDeviceLabels(devices);
+ lastDevices = devices;
let tx = 0, rx = 0;
devices.forEach(d => { if (d.role === 'TX') tx++; else if (d.role === 'RX') rx++; });
document.getElementById('status').textContent =
@@ -2419,6 +2492,11 @@
updateGpsDistanceHeader(devices);
if (mapRulerOpen && mapRulerMode === 'auto') loadMapRulerProfileAuto();
updateCmdTargetSelect(devices);
+ if (selectedId && !devices.find(d => d.device_id === selectedId)) {
+ selectedId = null;
+ setPanelHtml(document.getElementById('stats'), '—');
+ document.getElementById('history').innerHTML = '';
+ }
if (selectedId) {
const sel = devices.find(d => d.device_id === selectedId);
if (sel) {
@@ -2491,7 +2569,7 @@
const isNew = m.ts > chatLastReadTs;
const div = document.createElement('div');
div.className = 'chat-msg ' + (self ? 'chat-self' : 'chat-other') + (isNew ? ' chat-new' : '');
- const author = self ? 'Вы' : escapeHtml(deviceDisplayName(m.device_id));
+ const author = self ? 'Вы' : escapeHtml(deviceDisplayName(m.device_label ? m : m.device_id));
div.innerHTML = `