From 3399e814479c04dba4cd227076b746e5491c22ec Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 16 Jun 2026 11:10:15 +0300 Subject: [PATCH] fix --- .../com/grigowashere/loratester/LoraApp.java | 8 + .../loratester/TelemetryUploader.java | 18 ++ .../core/__pycache__/models.cpython-313.pyc | Bin 1345 -> 1345 bytes .../core/__pycache__/storage.cpython-313.pyc | Bin 31245 -> 31887 bytes server/core/storage.py | 39 ++-- server/static/index.html | 195 ++++++++++++------ 6 files changed, 190 insertions(+), 70 deletions(-) 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 40a7dcc0f66bb69930e9045beafc7b52ab475cf3..ab772e32839e86a0b16b68ea76beffc9fb35d41d 100644 GIT binary patch delta 20 acmX@eb&!kuGcPX}0}x!gVz80hj1>Sq;su5P delta 20 acmX@eb&!kuGcPX}0}vQJ)!)c%#tHyA;{^5q diff --git a/server/core/__pycache__/storage.cpython-313.pyc b/server/core/__pycache__/storage.cpython-313.pyc index 5d5ec94df12358edad23d520ebc8de16db2d919e..0decb0828e1ebca9f3ef75a875702f22f29abe1b 100644 GIT binary patch delta 7072 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 J4`Rku{0|khbLId5 delta 6420 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 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();