diff --git a/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java b/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java index 46e9ba7..207aa32 100644 --- a/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java +++ b/app/src/main/java/com/grigowashere/loratester/api/TrackInfo.java @@ -7,4 +7,5 @@ public class TrackInfo { public Double ended_at; public String label; public int point_count; + public String modem_mode; } diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java index f4fa3c6..e1b53cf 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -46,6 +46,8 @@ import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.map.EsriWorldImagery; import com.grigowashere.loratester.model.RadioSnapshot; +import com.grigowashere.loratester.telnet.LoraStatsFormatter; +import com.grigowashere.loratester.telnet.ModemMode; import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.track.TrackRecorder; @@ -776,7 +778,8 @@ public class MapFragment extends Fragment { String title = info.label != null && !info.label.isBlank() ? info.label : ("Репер #" + info.id); - labels[i] = "#" + info.id + " · " + title; + String mode = modemModeSuffix(modemModeFromReference(info)); + labels[i] = "#" + info.id + mode + " · " + title; checked[i] = selected.contains(info.id); } new AlertDialog.Builder(requireContext()) @@ -1146,7 +1149,8 @@ public class MapFragment extends Fragment { labels.add(getString(R.string.track_spinner_hint)); for (TrackInfo t : savedTracks) { String start = timeFormat.format(new Date((long) (t.started_at * 1000))); - labels.add("#" + t.id + " " + start + " (" + t.point_count + " pts)"); + String mode = modemModeSuffix(modemModeFromTrack(t)); + labels.add("#" + t.id + mode + " " + start + " (" + t.point_count + " pts)"); } if (savedTracks.isEmpty()) { labels.set(0, getString(R.string.track_none)); @@ -1214,6 +1218,78 @@ public class MapFragment extends Fragment { } fitBoundsOnce(boundsPoints, detail.points.size() == 1, true); + String mode = modemModeFromTrackDetail(detail); + if (mapStatus != null && mode != null) { + mapStatus.setText(getString( + R.string.track_loaded_mode, + detail.id, + LoraStatsFormatter.modemModeLabel(mode) + )); + } + } + + private static String modemModeSuffix(String mode) { + if (mode == null || mode.isBlank()) { + return ""; + } + return " · " + LoraStatsFormatter.modemModeLabel(mode); + } + + @SuppressWarnings("unchecked") + private static String modemModeFromTxParams(Map txParams) { + if (txParams == null) { + return null; + } + Object mode = txParams.get("modem_mode"); + if (mode != null) { + return ModemMode.normalize(String.valueOf(mode)); + } + if (txParams.get("bitrate_bps") != null) { + return ModemMode.GFSK; + } + if (txParams.get("spreading_factor") != null) { + return ModemMode.LORA; + } + return null; + } + + @SuppressWarnings("unchecked") + private static String modemModeFromReference(ReferencePointInfo info) { + if (info == null || info.reference_context == null) { + return null; + } + Object txParams = info.reference_context.get("tx_params"); + if (txParams instanceof Map) { + return modemModeFromTxParams((Map) txParams); + } + return null; + } + + private static String modemModeFromTrack(TrackInfo track) { + if (track != null && track.modem_mode != null && !track.modem_mode.isBlank()) { + return ModemMode.normalize(track.modem_mode); + } + return null; + } + + private static String modemModeFromTrackDetail(TrackDetail detail) { + if (detail == null || detail.points == null) { + return null; + } + for (int i = detail.points.size() - 1; i >= 0; i--) { + TrackDetail.TrackPoint p = detail.points.get(i); + RadioSnapshot snap = RadioSnapshot.fromMeta(p.meta, null, null); + if (snap.modemMode != null) { + return snap.modemMode; + } + if (snap.bitrateBps != null) { + return ModemMode.GFSK; + } + if (snap.sf != null) { + return ModemMode.LORA; + } + } + return null; } private static boolean isRxTrack(TrackDetail detail) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index efed0ff..e02217a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,7 @@ Реперов на карте: %1$d RX: %1$.6f, %2$.6f · дальность %3$s м Выберите реперную точку + Трек #%1$d · %2$s Сохранённые треки — нет треков — Это устройство diff --git a/server/core/__pycache__/storage.cpython-313.pyc b/server/core/__pycache__/storage.cpython-313.pyc index 5c122e9..260a6a8 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 293d251..62c9d8b 100644 --- a/server/core/storage.py +++ b/server/core/storage.py @@ -435,11 +435,24 @@ 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) """ + modem_mode_sub = """ + (SELECT COALESCE( + json_extract(p.meta, '$.modem_mode'), + CASE + WHEN json_extract(p.meta, '$.bitrate_bps') IS NOT NULL THEN 'GFSK' + WHEN json_extract(p.meta, '$.spreading_factor') IS NOT NULL THEN 'LORA' + ELSE NULL + END + ) FROM track_points p + WHERE p.track_id = t.id AND p.meta IS NOT NULL AND p.meta != '' + ORDER BY p.ts DESC LIMIT 1) + """ 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 + {role_sub} AS role, + {modem_mode_sub} AS modem_mode FROM tracks t LEFT JOIN devices d ON d.device_id = t.device_id """ diff --git a/server/static/app.js b/server/static/app.js index 98b28ba..08e5f28 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -1028,6 +1028,37 @@ function referenceSessionById(id) { return referencePointSessions.find(s => String(s.id) === String(id)) || null; } +function modemModeFromTxParams(params) { + if (!params) return null; + if (params.modem_mode) return RadioUI.normalizeModemMode(params.modem_mode); + if (params.bitrate_bps != null) return RadioUI.MODEM_GFSK; + if (params.spreading_factor != null) return RadioUI.MODEM_LORA; + return null; +} + +function modemModeFromTrack(track) { + if (!track) return null; + if (track.modem_mode) return RadioUI.normalizeModemMode(track.modem_mode); + const points = track.points; + if (!points?.length) return null; + for (let i = points.length - 1; i >= 0; i--) { + let meta = points[i].meta; + if (!meta) continue; + if (typeof meta === 'string') { + try { meta = JSON.parse(meta); } catch (e) { continue; } + } + if (meta.modem_mode) return RadioUI.normalizeModemMode(meta.modem_mode); + if (meta.bitrate_bps != null) return RadioUI.MODEM_GFSK; + if (meta.spreading_factor != null) return RadioUI.MODEM_LORA; + } + return null; +} + +function modemModeSuffix(mode) { + const label = mode ? RadioUI.modemModeLabel(mode) : ''; + return label && label !== '—' ? ` · ${label}` : ''; +} + function renderReferencePointList() { const list = document.getElementById('referencePointList'); if (!list) return; @@ -1043,9 +1074,10 @@ function renderReferencePointList() { if (checked) row.classList.add('selected'); const ctx = session.reference_context || {}; const dist = ctx.distance_m != null ? ` · ${Number(ctx.distance_m).toFixed(0)} м` : ''; + const mode = modemModeSuffix(modemModeFromTxParams(ctx.tx_params)); row.innerHTML = ` ${escapeHtml(session.label || 'Репер #' + session.id)} - #${session.id} · RX${dist}`; + #${session.id} · RX${mode}${dist}`; const checkbox = row.querySelector('input'); checkbox.addEventListener('change', () => { const sid = String(session.id); @@ -1093,13 +1125,14 @@ function showReferencePointsOnMap() { if (!rxPos || rxPos.lat == null || rxPos.lon == null) return; const rxLatLng = [Number(rxPos.lat), Number(rxPos.lon)]; bounds.push(rxLatLng); + const refMode = modemModeSuffix(modemModeFromTxParams(ctx.tx_params)); const rxMarker = L.circleMarker(rxLatLng, { radius: 9, color: '#00ff88', weight: 2, fillColor: '#00ff88', fillOpacity: 0.25 - }).addTo(map).bindPopup(`RX репер #${session.id}`); + }).addTo(map).bindPopup(`RX репер #${session.id}${refMode}`); let txMarker = null; let line = null; if (txPos && txPos.lat != null && txPos.lon != null) { @@ -1259,8 +1292,9 @@ async function showReferencePoint(sessionId) { const data = await res.json(); const stats = data.stats || {}; const ctx = data.reference_context || {}; + const refMode = modemModeSuffix(modemModeFromTxParams(ctx.tx_params)); if (summary) { - summary.innerHTML = `
${escapeHtml(data.label || 'Реперная точка')} #${data.id} · RX
+ summary.innerHTML = `
${escapeHtml(data.label || 'Реперная точка')} #${data.id} · RX${refMode}
${escapeHtml(deviceDisplayName(data))} · samples: ${data.sample_count || 0} · ${escapeHtml(data.status || '')}
Контекст
${escapeHtml(formatReferenceTxParams(ctx.tx_params))}
@@ -4329,8 +4363,9 @@ async function refreshTimelineTelemetry() { function trackOptionLabel(t) { const start = new Date(t.started_at * 1000).toLocaleString(); const role = t.role ? ` · ${t.role}` : ''; + const mode = modemModeSuffix(modemModeFromTrack(t)); const dev = t.device_id ? ` · ${deviceDisplayName(t)}` : ''; - return `#${t.id}${role}${dev} · ${start} (${t.point_count})`; + return `#${t.id}${mode}${role}${dev} · ${start} (${t.point_count})`; } async function loadAllTracks() { @@ -4408,7 +4443,7 @@ async function showSingleTrack() { updateTimelineAtSingle(timelineCursor()); } document.getElementById('trackInfo').textContent = - `Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`; + `Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)${modemModeSuffix(modemModeFromTrack(loadedSingleTrack))}`; updateTrackButtons(); } @@ -4470,8 +4505,10 @@ async function showDualTracks() { rebuildQualityViz(); const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : ''; + const txMode = modemModeSuffix(modemModeFromTrack(loadedTxTrack)); + const rxMode = modemModeSuffix(modemModeFromTrack(loadedRxTrack)); document.getElementById('trackInfo').textContent = - `TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`; + `TX #${loadedTxTrack.id} (${loadedTxTrack.points.length})${txMode} + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${rxMode}${modeHint}`; updateTrackButtons(); } diff --git a/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc b/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc index 9e8fc02..9fc0b3a 100644 Binary files a/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc and b/server/tests/__pycache__/test_schema.cpython-313-pytest-9.0.3.pyc differ diff --git a/server/tests/test_schema.py b/server/tests/test_schema.py index 88e2377..9bacf46 100644 --- a/server/tests/test_schema.py +++ b/server/tests/test_schema.py @@ -91,6 +91,33 @@ def test_sync_track_create_offline(temp_db, monkeypatch): def test_tracks_crud(temp_db): + storage.init_db() + start = storage.start_track("android-12345678") + tid = start["track_id"] + storage.add_track_points( + tid, + [ + { + "ts": 1.0, + "lat": 55.75, + "lon": 37.62, + "rssi": -70.0, + "role": "TX", + "meta": '{"modem_mode":"GFSK","bitrate_bps":20000}', + } + ], + ) + fin = storage.finish_track(tid) + assert fin["point_count"] == 1 + listed = storage.list_tracks(limit=10) + gfsk = next((t for t in listed if t["id"] == tid), None) + assert gfsk is not None + assert gfsk["modem_mode"] == "GFSK" + track = storage.get_track(tid) + assert len(track["points"]) == 1 + + +def test_tracks_crud_legacy(temp_db): storage.init_db() start = storage.start_track("android-12345678") tid = start["track_id"]