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 = `
${new Date(m.ts*1000).toLocaleTimeString()} · ${author}
${escapeHtml(m.text)}`; log.appendChild(div); if (isNew) { @@ -2606,6 +2684,7 @@ schedulePoll(); setupCmdFormDirtyTracking(); + setupTimelineControls(); loadAllTracks(); refreshPairedStatus();