diff --git a/app/src/main/java/com/grigowashere/loratester/ReferencePointRecorder.java b/app/src/main/java/com/grigowashere/loratester/ReferencePointRecorder.java index 953e896..99b9a62 100644 --- a/app/src/main/java/com/grigowashere/loratester/ReferencePointRecorder.java +++ b/app/src/main/java/com/grigowashere/loratester/ReferencePointRecorder.java @@ -6,6 +6,7 @@ import android.util.Log; import com.grigowashere.loratester.api.ServerApi; import com.grigowashere.loratester.location.GeoUtils; +import com.grigowashere.loratester.model.RadioSnapshot; import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.telnet.StatsExtractor; @@ -46,8 +47,12 @@ public class ReferencePointRecorder { private final List> buffer = new ArrayList<>(); private volatile boolean recording; + private volatile boolean rotating; private volatile long sessionId = -1; private volatile int sampleCount; + private volatile int segmentIndex = 1; + private volatile String baseLabel; + private volatile String lastParamSignature; private ScheduledFuture sampleTask; private ScheduledFuture flushTask; private Listener listener; @@ -111,6 +116,10 @@ public class ReferencePointRecorder { } sessionId = id; sampleCount = 0; + segmentIndex = 1; + baseLabel = label; + lastParamSignature = null; + rotating = false; recording = true; startTimers(); notifyState(); @@ -173,6 +182,16 @@ public class ReferencePointRecorder { return; } StatsExtractor.ExtractedStats stats = uploader.getLastStats(); + String signature = paramSignature(stats); + if (signature != null) { + String prev = lastParamSignature; + if (prev == null) { + lastParamSignature = signature; + } else if (!prev.equals(signature)) { + requestRotate(signature); + return; + } + } Double lat = validLat(); Double lon = validLon(); if (stats == null && (lat == null || lon == null)) { @@ -194,6 +213,77 @@ public class ReferencePointRecorder { notifyState(); } + private void requestRotate(String newSignature) { + if (rotating || !recording) { + return; + } + rotating = true; + executor.execute(() -> rotateSession(newSignature)); + } + + private void rotateSession(String newSignature) { + if (!recording) { + rotating = false; + return; + } + flush(); + try { + if (sessionId > 0) { + serverApi.finishReferencePointSession(sessionId); + } + Double lat = validLat(); + Double lon = validLon(); + segmentIndex++; + String nextLabel = (baseLabel == null || baseLabel.isBlank() + ? "Репер" + : baseLabel) + " #" + segmentIndex; + long nextId = serverApi.startReferencePointSession(deviceId, nextLabel, lat, lon); + synchronized (buffer) { + buffer.clear(); + } + sessionId = nextId; + sampleCount = 0; + lastParamSignature = newSignature; + notifyState(); + } catch (Exception e) { + Log.w(TAG, "rotate reference point failed", e); + notifyError(e.getMessage() != null ? e.getMessage() : "reference rotate failed"); + } finally { + rotating = false; + } + } + + private static String paramSignature(StatsExtractor.ExtractedStats stats) { + if (stats == null || stats.metaJson == null) { + return null; + } + RadioSnapshot snap = RadioSnapshot.fromExtracted(stats); + if (snap.frequencyMhz == null + && snap.sf == null + && snap.bwKhz == null + && snap.powerDbm == null + && snap.codeRate == null + && snap.preambleLength == null + && snap.txTimeoutMs == null + && snap.crcEnabled == null + && snap.role == null) { + return null; + } + return "role=" + value(snap.role) + + "|fq=" + value(snap.frequencyMhz) + + "|sf=" + value(snap.sf) + + "|bw=" + value(snap.bwKhz) + + "|pw=" + value(snap.powerDbm) + + "|cr=" + value(snap.codeRate) + + "|pl=" + value(snap.preambleLength) + + "|tm=" + value(snap.txTimeoutMs) + + "|crc=" + value(snap.crcEnabled); + } + + private static String value(Object value) { + return value != null ? String.valueOf(value) : ""; + } + private void flush() { if (sessionId <= 0 || networkMonitor != null && !networkMonitor.isOnline()) { return; diff --git a/server/core/__pycache__/storage.cpython-313.pyc b/server/core/__pycache__/storage.cpython-313.pyc index a396fdc..1610730 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 8c333a9..857a04e 100644 --- a/server/core/storage.py +++ b/server/core/storage.py @@ -551,6 +551,68 @@ def _metric_stats(values: list[float]) -> dict[str, Any]: } +_TX_PARAM_KEYS = ( + "frequency_hz", + "power_dbm", + "spreading_factor", + "bandwidth_khz", + "code_rate", + "preamble_length", + "tx_timeout_ms", + "crc_enabled", +) + + +def _role_from_row(row: dict[str, Any]) -> Optional[str]: + role = row.get("role") + if role: + return str(role) + meta = _json_load(row.get("meta")) + if isinstance(meta, dict) and meta.get("role"): + return str(meta["role"]) + return None + + +def _tx_params_from_meta(meta: Any) -> dict[str, Any]: + data = _json_load(meta) + if not isinstance(data, dict): + return {} + return {key: data[key] for key in _TX_PARAM_KEYS if data.get(key) is not None} + + +def _reference_context(rows: list[dict[str, Any]]) -> dict[str, Any]: + tx_params = None + tx_device = None + rx_position = None + roles: set[str] = set() + devices: set[str] = set() + for row in sorted(rows, key=lambda r: float(r.get("ts") or 0)): + role = _role_from_row(row) + if role: + roles.add(role) + device_id = row.get("device_id") + if device_id: + devices.add(str(device_id)) + params = _tx_params_from_meta(row.get("meta")) + if params and (role == "TX" or tx_params is None): + tx_params = params + tx_device = device_id + if role == "RX" and row.get("lat") is not None and row.get("lon") is not None: + rx_position = { + "device_id": device_id, + "lat": row.get("lat"), + "lon": row.get("lon"), + "ts": row.get("ts"), + } + return { + "tx_params": tx_params, + "tx_device_id": tx_device, + "rx_position": rx_position, + "roles": sorted(roles), + "devices": sorted(devices), + } + + def _reference_stats(samples: list[dict[str, Any]]) -> dict[str, Any]: per = [_metric_from_meta(s.get("meta"), "per_percent") for s in samples] quality = [_metric_from_meta(s.get("meta"), "rx_quality_percent") for s in samples] @@ -634,13 +696,47 @@ def add_reference_point_samples( def finish_reference_point_session(session_id: int) -> dict[str, Any]: ts = time.time() with _db() as conn: + session = conn.execute( + """ + SELECT id, device_id, started_at, meta + FROM reference_point_sessions WHERE id = ? + """, + (session_id,), + ).fetchone() + if not session: + raise ValueError(f"reference point session {session_id} not found") + sample_rows = conn.execute( + """ + SELECT ? AS device_id, ts, lat, lon, rssi, role, meta + FROM reference_point_samples + WHERE session_id = ? + ORDER BY ts ASC + """, + (session["device_id"], session_id), + ).fetchall() + telemetry_rows = conn.execute( + """ + SELECT device_id, ts, lat, lon, rssi, role, meta + FROM telemetry + WHERE source = 'android' AND ts >= ? AND ts <= ? + ORDER BY ts ASC + LIMIT 1000 + """, + (session["started_at"], ts), + ).fetchall() + meta = _json_load(session["meta"]) if session["meta"] else {} + if not isinstance(meta, dict): + meta = {} + meta["reference_context"] = _reference_context( + [dict(r) for r in sample_rows] + [dict(r) for r in telemetry_rows] + ) cur = conn.execute( """ UPDATE reference_point_sessions - SET ended_at = ?, status = 'finished' + SET ended_at = ?, status = 'finished', meta = ? WHERE id = ? AND status = 'recording' """, - (ts, session_id), + (ts, _json_or_none(meta), session_id), ) if cur.rowcount == 0: raise ValueError(f"reference point session {session_id} not found or inactive") @@ -711,6 +807,34 @@ def get_reference_point_session(session_id: int) -> dict[str, Any]: result["sample_count"] = len(samples) result["samples"] = samples result["stats"] = _reference_stats(samples) + meta = _json_load(result.get("meta")) + if not isinstance(meta, dict): + meta = {} + context = meta.get("reference_context") + if not isinstance(context, dict): + telemetry_rows = [] + with _db() as conn: + telemetry_rows = conn.execute( + """ + SELECT device_id, ts, lat, lon, rssi, role, meta + FROM telemetry + WHERE source = 'android' AND ts >= ? AND ts <= ? + ORDER BY ts ASC + LIMIT 1000 + """, + ( + result["started_at"], + result["ended_at"] if result["ended_at"] is not None else time.time(), + ), + ).fetchall() + context = _reference_context( + [ + {"device_id": result["device_id"], **sample} + for sample in samples + ] + + [dict(r) for r in telemetry_rows] + ) + result["reference_context"] = context return result diff --git a/server/static/app.js b/server/static/app.js index 4c5d2e5..cb3d587 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -887,6 +887,26 @@ function metricCard(title, metric, suffix = '%') { `; } +function formatReferenceTxParams(params) { + if (!params || !Object.keys(params).length) return 'TX параметры: —'; + const parts = []; + if (params.frequency_hz != null) parts.push(`FQ ${(Number(params.frequency_hz) / 1e6).toFixed(3)} MHz`); + if (params.power_dbm != null) parts.push(`PW ${params.power_dbm} dBm`); + if (params.spreading_factor != null) parts.push(`SF ${params.spreading_factor}`); + if (params.bandwidth_khz != null) parts.push(`BW ${params.bandwidth_khz} kHz`); + if (params.code_rate != null) parts.push(`CR ${params.code_rate}`); + if (params.preamble_length != null) parts.push(`PL ${params.preamble_length}`); + if (params.tx_timeout_ms != null) parts.push(`TM ${params.tx_timeout_ms} ms`); + if (params.crc_enabled != null) parts.push(`CRC ${params.crc_enabled ? 'On' : 'Off'}`); + return parts.length ? `TX параметры: ${parts.join(' · ')}` : 'TX параметры: —'; +} + +function formatReferenceRxPosition(pos) { + if (!pos || pos.lat == null || pos.lon == null) return 'RX координаты: —'; + const device = pos.device_id ? `${deviceDisplayName(pos)} · ` : ''; + return `RX координаты: ${device}${Number(pos.lat).toFixed(6)}, ${Number(pos.lon).toFixed(6)}`; +} + function drawReferencePointChart(samples) { const canvas = document.getElementById('referencePointChart'); if (!canvas) return; @@ -991,9 +1011,15 @@ async function showReferencePoint(sessionId) { if (!res.ok) throw new Error('reference point ' + res.status); const data = await res.json(); const stats = data.stats || {}; + const ctx = data.reference_context || {}; if (summary) { summary.innerHTML = `
${escapeHtml(data.label || 'Реперная точка')} #${data.id}
${escapeHtml(deviceDisplayName(data))} · samples: ${data.sample_count || 0} · ${escapeHtml(data.status || '')}
+
Контекст +
${escapeHtml(formatReferenceTxParams(ctx.tx_params))}
+
${escapeHtml(formatReferenceRxPosition(ctx.rx_position))}
+
Роли: ${escapeHtml((ctx.roles || []).join(', ') || '—')}
+
${metricCard('PER', stats.per_percent)} ${metricCard('RX Quality', stats.rx_quality_percent)}`; } 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 b6478b3..fd7a843 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 2178662..3a22364 100644 --- a/server/tests/test_schema.py +++ b/server/tests/test_schema.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest from core import storage +from core.models import TelemetryIn from core.schema import SCHEMA_VERSION, apply_migrations, check_db_ok, column_exists @@ -118,6 +119,23 @@ def test_reference_point_session_stats(temp_db): "android-12345678", "yard", 55.75, 37.62 ) sid = start["session_id"] + storage.record_telemetry(TelemetryIn( + device_id="android-11111111", + lat=55.751, + lon=37.621, + role="TX", + ts=start["started_at"], + meta='{"role":"TX","frequency_hz":433000000,"power_dbm":17,' + '"spreading_factor":7,"bandwidth_khz":125,"code_rate":"4/5"}', + )) + storage.record_telemetry(TelemetryIn( + device_id="android-12345678", + lat=55.75, + lon=37.62, + role="RX", + ts=start["started_at"], + meta='{"role":"RX","rx_quality_percent":90}', + )) added = storage.add_reference_point_samples( sid, [ @@ -152,6 +170,10 @@ def test_reference_point_session_stats(temp_db): assert session["stats"]["per_percent"]["p50"] == 20 assert session["stats"]["per_percent"]["p75"] == 25 assert session["stats"]["rx_quality_percent"]["p90"] == 98 + assert session["reference_context"]["tx_params"]["frequency_hz"] == 433000000 + assert session["reference_context"]["tx_params"]["spreading_factor"] == 7 + assert session["reference_context"]["rx_position"]["device_id"] == "android-12345678" + assert session["reference_context"]["rx_position"]["lat"] == 55.75 def test_macro_presets_crud(temp_db):