This commit is contained in:
2026-06-16 11:24:21 +03:00
parent 3399e81447
commit 64607def4a
9 changed files with 346 additions and 72 deletions
@@ -13,6 +13,7 @@ public class SettingsRepository {
private static final String KEY_RANGE_REGEX = "range_regex"; private static final String KEY_RANGE_REGEX = "range_regex";
private static final String KEY_TELNET_ENABLED = "telnet_enabled"; private static final String KEY_TELNET_ENABLED = "telnet_enabled";
private static final String KEY_DEVICE_ID = "device_id"; private static final String KEY_DEVICE_ID = "device_id";
private static final String KEY_DEVICE_LABEL = "device_label";
public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru"; public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru";
private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634"; private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634";
@@ -106,4 +107,16 @@ public class SettingsRepository {
} }
return id; return id;
} }
public String getDeviceLabel() {
return prefs.getString(KEY_DEVICE_LABEL, null);
}
public void setDeviceLabel(String label) {
if (label == null) {
prefs.edit().remove(KEY_DEVICE_LABEL).apply();
} else {
prefs.edit().putString(KEY_DEVICE_LABEL, label.trim()).apply();
}
}
} }
@@ -317,7 +317,11 @@ public class TelemetryUploader implements TelnetClient.Listener {
}); });
} }
private static String phoneLabel() { private String phoneLabel() {
String custom = settings.getDeviceLabel();
if (custom != null && !custom.isBlank()) {
return custom.trim();
}
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER : ""; String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER : "";
String model = Build.MODEL != null ? Build.MODEL : ""; String model = Build.MODEL != null ? Build.MODEL : "";
String label = (manufacturer + " " + model).trim(); String label = (manufacturer + " " + model).trim();
@@ -42,6 +42,7 @@ public class SettingsFragment extends Fragment {
TextInputEditText editPort = view.findViewById(R.id.editTelnetPort); TextInputEditText editPort = view.findViewById(R.id.editTelnetPort);
TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex); TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex);
TextInputEditText editRange = view.findViewById(R.id.editRangeRegex); TextInputEditText editRange = view.findViewById(R.id.editRangeRegex);
TextInputEditText editDeviceLabel = view.findViewById(R.id.editDeviceLabel);
SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet); SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet);
TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel); TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel);
Button save = view.findViewById(R.id.btnSaveSettings); Button save = view.findViewById(R.id.btnSaveSettings);
@@ -51,6 +52,10 @@ public class SettingsFragment extends Fragment {
editPort.setText(String.valueOf(settings.getTelnetPort())); editPort.setText(String.valueOf(settings.getTelnetPort()));
editRssi.setText(settings.getRssiRegex()); editRssi.setText(settings.getRssiRegex());
editRange.setText(settings.getRangeRegex()); editRange.setText(settings.getRangeRegex());
String savedLabel = settings.getDeviceLabel();
if (savedLabel != null) {
editDeviceLabel.setText(savedLabel);
}
switchTelnet.setChecked(settings.isTelnetEnabled()); switchTelnet.setChecked(settings.isTelnetEnabled());
deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId())); deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId()));
@@ -64,8 +69,10 @@ public class SettingsFragment extends Fragment {
} }
settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX)); settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX));
settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX)); settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX));
settings.setDeviceLabel(textOf(editDeviceLabel, ""));
settings.setTelnetEnabled(switchTelnet.isChecked()); settings.setTelnetEnabled(switchTelnet.isChecked());
uploader.refreshApi(); uploader.refreshApi();
uploader.registerPresence();
if (switchTelnet.isChecked()) { if (switchTelnet.isChecked()) {
uploader.startTelnet(); uploader.startTelnet();
} else { } else {
@@ -73,6 +73,19 @@
android:inputType="text" /> android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/device_display_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editDeviceLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchTelnet" android:id="@+id/switchTelnet"
android:layout_width="match_parent" android:layout_width="match_parent"
+1
View File
@@ -17,6 +17,7 @@
<string name="range_regex">Range regex</string> <string name="range_regex">Range regex</string>
<string name="telnet_enabled">Подключить telnet</string> <string name="telnet_enabled">Подключить telnet</string>
<string name="device_id_label">ID устройства: %1$s</string> <string name="device_id_label">ID устройства: %1$s</string>
<string name="device_display_name">Имя на карте (realme, OPPO…)</string>
<string name="save">Сохранить</string> <string name="save">Сохранить</string>
<string name="saved">Сохранено</string> <string name="saved">Сохранено</string>
<string name="chat_hint">Сообщение…</string> <string name="chat_hint">Сообщение…</string>
Binary file not shown.
+19
View File
@@ -149,6 +149,25 @@ def _trim_telemetry(conn: sqlite3.Connection, device_id: str) -> None:
) )
def update_device_label(device_id: str, label: str) -> dict[str, Any]:
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
clean = (label or "").strip()
if not clean:
raise ValueError("label required")
ts = time.time()
with _db() as conn:
conn.execute(
"""
INSERT INTO devices (device_id, label, last_seen)
VALUES (?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET label = excluded.label
""",
(device_id, clean, ts),
)
return {"ok": True, "device_id": device_id, "label": clean}
def list_devices() -> list[dict[str, Any]]: def list_devices() -> list[dict[str, Any]]:
with _db() as conn: with _db() as conn:
rows = conn.execute( rows = conn.execute(
+14
View File
@@ -31,6 +31,7 @@ storage.init_db()
class TelemetryBody(BaseModel): class TelemetryBody(BaseModel):
device_id: str device_id: str
device_label: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
rssi: Optional[float] = None rssi: Optional[float] = None
@@ -53,6 +54,10 @@ class TrackStartBody(BaseModel):
label: Optional[str] = None label: Optional[str] = None
class DeviceLabelBody(BaseModel):
label: str
class TrackPoint(BaseModel): class TrackPoint(BaseModel):
ts: Optional[float] = None ts: Optional[float] = None
lat: float lat: float
@@ -120,6 +125,14 @@ def get_devices():
return storage.list_devices() return storage.list_devices()
@app.patch("/api/devices/{device_id}/label")
def patch_device_label(device_id: str, body: DeviceLabelBody):
try:
return storage.update_device_label(device_id, body.label)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/telemetry") @app.get("/api/telemetry")
def get_telemetry_history( def get_telemetry_history(
device_id: Optional[str] = None, device_id: Optional[str] = None,
@@ -366,6 +379,7 @@ def health():
return { return {
"ok": status["db_ok"], "ok": status["db_ok"],
"ts": time.time(), "ts": time.time(),
"api_build": "2026-06-16c",
**status, **status,
**elevation_status(), **elevation_status(),
} }
+245 -42
View File
@@ -128,6 +128,14 @@
display: flex; gap: 4px; flex-wrap: wrap; display: flex; gap: 4px; flex-wrap: wrap;
pointer-events: auto; pointer-events: auto;
} }
#mapWrap .leaflet-top.leaflet-right {
top: 46px;
right: 10px;
margin-top: 0;
}
#mapWrap .leaflet-control-layers-toggle {
width: 30px; height: 30px; line-height: 30px;
}
#mapCenterBar button { #mapCenterBar button {
padding: 5px 10px; font-size: 0.75rem; border: 1px solid #444; border-radius: 4px; padding: 5px 10px; font-size: 0.75rem; border: 1px solid #444; border-radius: 4px;
background: #16213ee6; color: #eee; cursor: pointer; background: #16213ee6; color: #eee; cursor: pointer;
@@ -353,6 +361,8 @@
{ position: 'topright', collapsed: true } { position: 'topright', collapsed: true }
).addTo(map); ).addTo(map);
const API_BUILD = '2026-06-16c';
const markers = {}; const markers = {};
let selectedId = null; let selectedId = null;
let chatSince = 0; let chatSince = 0;
@@ -392,6 +402,7 @@
let lastDevices = []; let lastDevices = [];
const deviceLabelCache = {}; const deviceLabelCache = {};
let timelineSpanMs = 1000; let timelineSpanMs = 1000;
let timelineUseProgress = false;
let elevProfileTx = null; let elevProfileTx = null;
let elevProfileRx = null; let elevProfileRx = null;
let elevProfileSingle = null; let elevProfileSingle = null;
@@ -458,9 +469,113 @@
function sliderTime() { function sliderTime() {
const ms = parseInt(document.getElementById('timeSlider').value || '0', 10); const ms = parseInt(document.getElementById('timeSlider').value || '0', 10);
if (timelineUseProgress) return ms / timelineSpanMs;
return overlapMin + ms / 1000; return overlapMin + ms / 1000;
} }
function timelineCursor() {
const ms = parseInt(document.getElementById('timeSlider').value || '0', 10);
if (timelineUseProgress) return { progress: ms / timelineSpanMs };
const t = overlapMin + ms / 1000;
return { t };
}
function analyzeTrackTiming(points) {
if (!points || points.length < 2) {
return { useProgress: true, issues: ['мало точек'], withMeta: 0, total: points?.length || 0 };
}
const ts = points.map(p => Number(p.ts));
const unique = new Set(ts.map(v => Math.round(v * 1000))).size;
const span = Math.max(...ts) - Math.min(...ts);
const withMeta = points.filter(p => p.meta && String(p.meta).length > 2).length;
const issues = [];
if (!Number.isFinite(span) || span < 0.05) issues.push('одинаковое время у точек');
if (unique < 2) issues.push('нет различимых меток времени');
if (withMeta < Math.max(1, Math.floor(points.length * 0.15))) {
issues.push(`радио-meta только у ${withMeta}/${points.length} точек`);
}
return {
useProgress: issues.some(x => x.includes('время') || x.includes('меток')),
issues,
withMeta,
total: points.length,
span,
unique,
};
}
function positionAtProgress(points, progress) {
if (!points?.length) return null;
const p = Math.max(0, Math.min(1, progress));
if (points.length === 1) {
const one = points[0];
return { lat: Number(one.lat), lon: Number(one.lon), meta: one.meta, rssi: one.rssi };
}
const f = p * (points.length - 1);
const i = Math.floor(f);
const j = Math.min(i + 1, points.length - 1);
const frac = f - i;
const a = points[i];
const b = points[j];
if (frac <= 0 || i === j) {
return { lat: Number(a.lat), lon: Number(a.lon), meta: a.meta, rssi: a.rssi };
}
return {
lat: Number(a.lat) + (Number(b.lat) - Number(a.lat)) * frac,
lon: Number(a.lon) + (Number(b.lon) - Number(a.lon)) * frac,
meta: frac < 0.5 ? a.meta : b.meta,
rssi: frac < 0.5 ? a.rssi : b.rssi,
};
}
function positionAtCursor(points, cursor) {
if (timelineUseProgress) return positionAtProgress(points, cursor.progress);
return positionAt(points, cursor.t);
}
function trackDistanceAtCursor(track, cursor) {
if (timelineUseProgress) return trackDistanceAtProgress(track, cursor.progress);
return trackDistanceAtTime(track, cursor.t);
}
function trackDistanceAtProgress(track, progress) {
if (!track?.points?.length) return 0;
const pts = track.points;
if (pts.length === 1) return 0;
const f = Math.max(0, Math.min(1, progress)) * (pts.length - 1);
const idx = Math.floor(f);
const frac = f - idx;
let dist = 0;
for (let i = 1; i <= idx && i < pts.length; i++) {
dist += haversineM(pts[i - 1].lat, pts[i - 1].lon, pts[i].lat, pts[i].lon);
}
if (frac > 0 && idx + 1 < pts.length) {
const a = pts[idx];
const b = pts[idx + 1];
const lat = Number(a.lat) + (Number(b.lat) - Number(a.lat)) * frac;
const lon = Number(a.lon) + (Number(b.lon) - Number(a.lon)) * frac;
dist += haversineM(a.lat, a.lon, lat, lon);
}
return dist;
}
function snapAtCursor(track, telemetryRows, cursor, roleFallback) {
if (timelineUseProgress) {
const pos = positionAtProgress(track?.points, cursor.progress);
if (!pos) return null;
return { meta: pos.meta, role: roleFallback, rssi: pos.rssi, ts: cursor.progress };
}
return snapAtTime(track, telemetryRows, cursor.t, roleFallback);
}
function formatTimelineClock(cursor) {
if (timelineUseProgress) {
const pct = Math.round(cursor.progress * 100);
return `${pct}% пути`;
}
return new Date(cursor.t * 1000).toLocaleTimeString();
}
function normalizeTrack(track) { function normalizeTrack(track) {
if (!track?.points?.length) return track; if (!track?.points?.length) return track;
const points = track.points.map(p => ({ const points = track.points.map(p => ({
@@ -1554,11 +1669,11 @@
drawElevationChart(); drawElevationChart();
requestAnimationFrame(() => drawElevationChart( requestAnimationFrame(() => drawElevationChart(
singleTrackActive singleTrackActive
? { single: trackDistanceAtTime(loadedSingleTrack, sliderTime()) } ? { single: trackDistanceAtCursor(loadedSingleTrack, timelineCursor()) }
: dualTracksActive : dualTracksActive
? { ? {
tx: trackDistanceAtTime(loadedTxTrack, sliderTime()), tx: trackDistanceAtCursor(loadedTxTrack, timelineCursor()),
rx: trackDistanceAtTime(loadedRxTrack, sliderTime()) rx: trackDistanceAtCursor(loadedRxTrack, timelineCursor())
} }
: null : null
)); ));
@@ -1861,7 +1976,7 @@
layerList.push(seg); layerList.push(seg);
} }
pts.forEach(p => { pts.forEach((p, pointIdx) => {
const q = rxQualityFromMeta(p.meta); const q = rxQualityFromMeta(p.meta);
const ptColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color; const ptColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color;
const m = L.circleMarker([p.lat, p.lon], { const m = L.circleMarker([p.lat, p.lon], {
@@ -1869,24 +1984,29 @@
}); });
m.addTo(map); m.addTo(map);
m.on('click', () => { m.on('click', () => {
const relMs = Math.max(0, Math.min(Math.round((p.ts - overlapMin) * 1000), timelineSpanMs)); if (timelineUseProgress) {
const relMs = Math.round((pointIdx / Math.max(1, pts.length - 1)) * timelineSpanMs);
document.getElementById('timeSlider').value = String(relMs); document.getElementById('timeSlider').value = String(relMs);
updateTimelineAt(timelineCursor(), { openModal: true });
} else {
const relMs = Math.max(0, Math.min(Math.round((Number(p.ts) - overlapMin) * 1000), timelineSpanMs));
document.getElementById('timeSlider').value = String(relMs);
updateTimelineAt(timelineCursor(), { openModal: true });
}
modalMode = 'timeline'; modalMode = 'timeline';
updateTimelineAt(Number(p.ts), { openModal: true });
}); });
markerList.push(m); markerList.push(m);
}); });
} }
function buildTimelineModalHtml(t, txPos, rxPos) { function buildTimelineModalHtml(cursor, txPos, rxPos) {
if (!txPos || !rxPos) return ''; if (!txPos || !rxPos) return '';
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon); const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`; let html = `<b>${formatTimelineClock(cursor)}</b><br>`;
html += `Расстояние: ${dist.toFixed(0)} m (GPS)<br><br>`; html += `Расстояние: ${dist.toFixed(0)} m (GPS)<br><br>`;
html += `<span class="legend-tx">TX</span> ${txPos.lat.toFixed(5)}, ${txPos.lon.toFixed(5)}<br>`; html += `<span class="legend-tx">TX</span> ${txPos.lat.toFixed(5)}, ${txPos.lon.toFixed(5)}<br>`;
const { txTel, rxTel } = pairedTelemetryAtTime( const txTel = snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX');
loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t const rxTel = snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX');
);
html += renderTimelineCompare( html += renderTimelineCompare(
txTel || { meta: txPos.meta, role: 'TX', rssi: null }, txTel || { meta: txPos.meta, role: 'TX', rssi: null },
rxTel || { meta: rxPos.meta, role: 'RX', rssi: null }, rxTel || { meta: rxPos.meta, role: 'RX', rssi: null },
@@ -1901,16 +2021,19 @@
return { min: Number(points[0].ts), max: Number(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) { function updateTimelineAt(tOrCursor, opts) {
const openModal = opts && opts.openModal; const openModal = opts && opts.openModal;
const cursor = (tOrCursor && typeof tOrCursor === 'object')
? tOrCursor
: (timelineUseProgress ? { progress: Number(tOrCursor) } : { t: Number(tOrCursor) });
if (singleTrackActive && loadedSingleTrack) { if (singleTrackActive && loadedSingleTrack) {
updateTimelineAtSingle(t, openModal); updateTimelineAtSingle(cursor, openModal);
return; return;
} }
if (!loadedTxTrack || !loadedRxTrack) return; if (!loadedTxTrack || !loadedRxTrack) return;
const txPos = positionAt(loadedTxTrack.points, t); const txPos = positionAtCursor(loadedTxTrack.points, cursor);
const rxPos = positionAt(loadedRxTrack.points, t); const rxPos = positionAtCursor(loadedRxTrack.points, cursor);
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString(); document.getElementById('timeCurrent').textContent = formatTimelineClock(cursor);
if (ghostTx) map.removeLayer(ghostTx); if (ghostTx) map.removeLayer(ghostTx);
if (ghostRx) map.removeLayer(ghostRx); if (ghostRx) map.removeLayer(ghostRx);
@@ -1934,15 +2057,14 @@
[[txPos.lat, txPos.lon], [rxPos.lat, rxPos.lon]], [[txPos.lat, txPos.lon], [rxPos.lat, rxPos.lon]],
{ color: '#00ff88', weight: 3, dashArray: '6,6' } { color: '#00ff88', weight: 3, dashArray: '6,6' }
).addTo(map); ).addTo(map);
const modalHtml = buildTimelineModalHtml(t, txPos, rxPos); const modalHtml = buildTimelineModalHtml(cursor, txPos, rxPos);
if (openModal || (isModalOpen() && modalMode === 'timeline')) { if (openModal || (isModalOpen() && modalMode === 'timeline')) {
openMapModal(modalHtml, 'timeline'); openMapModal(modalHtml, 'timeline');
} }
} }
const { txTel, rxTel } = pairedTelemetryAtTime( const txTel = snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX');
loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t const rxTel = snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX');
);
const timelineStatsEl = document.getElementById('timelineStats'); const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml(timelineStatsEl, renderTimelineCompare( setPanelHtml(timelineStatsEl, renderTimelineCompare(
txTel, txTel,
@@ -1951,16 +2073,16 @@
deviceDisplayName(loadedRxTrack?.device_id) deviceDisplayName(loadedRxTrack?.device_id)
)); ));
drawElevationChart({ drawElevationChart({
tx: trackDistanceAtTime(loadedTxTrack, t), tx: trackDistanceAtCursor(loadedTxTrack, cursor),
rx: trackDistanceAtTime(loadedRxTrack, t) rx: trackDistanceAtCursor(loadedRxTrack, cursor)
}); });
} }
function updateTimelineAtSingle(t, openModal) { function updateTimelineAtSingle(cursor, openModal) {
const track = loadedSingleTrack; const track = loadedSingleTrack;
if (!track) return; if (!track) return;
const pos = positionAt(track.points, t); const pos = positionAtCursor(track.points, cursor);
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString(); document.getElementById('timeCurrent').textContent = formatTimelineClock(cursor);
if (ghostTx) map.removeLayer(ghostTx); if (ghostTx) map.removeLayer(ghostTx);
if (ghostRx) map.removeLayer(ghostRx); if (ghostRx) map.removeLayer(ghostRx);
if (linkLine) map.removeLayer(linkLine); if (linkLine) map.removeLayer(linkLine);
@@ -1972,9 +2094,9 @@
ghostTx = L.circleMarker([pos.lat, pos.lon], { ghostTx = L.circleMarker([pos.lat, pos.lon], {
radius: 10, color, fillColor: color, fillOpacity: 0.9, weight: 3 radius: 10, color, fillColor: color, fillOpacity: 0.9, weight: 3
}).addTo(map); }).addTo(map);
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`; let html = `<b>${formatTimelineClock(cursor)}</b><br>`;
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`; html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
const tel = snapAtTime(track, telemetrySingle, t, track.role); const tel = snapAtCursor(track, telemetrySingle, cursor, track.role);
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(pos.meta); const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(pos.meta);
html += RadioUI.formatRadioPanel(snap, new Set(), isRadioStaticOpen(mapModalBody)); html += RadioUI.formatRadioPanel(snap, new Set(), isRadioStaticOpen(mapModalBody));
if (tel) html += '<br>' + formatTelemetryRow(tel, new Set()); if (tel) html += '<br>' + formatTelemetryRow(tel, new Set());
@@ -1982,7 +2104,7 @@
openMapModal(html, 'timeline'); openMapModal(html, 'timeline');
} }
} }
const tel = snapAtTime(track, telemetrySingle, t, track.role); const tel = snapAtCursor(track, telemetrySingle, cursor, track.role);
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null); const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null);
const timelineStatsEl = document.getElementById('timelineStats'); const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml( setPanelHtml(
@@ -2008,8 +2130,31 @@
statsPanel.classList.toggle('timeline-single', single); statsPanel.classList.toggle('timeline-single', single);
} }
function applyProgressTimeline(diagnosis, extraNote) {
const note = document.getElementById('timelineNote');
timelineUseProgress = true;
overlapMin = 0;
overlapMax = 1;
timelineSpanMs = 1000;
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = String(timelineSpanMs);
slider.step = 10;
slider.value = '0';
document.getElementById('timeStart').textContent = '0%';
document.getElementById('timeEnd').textContent = '100%';
document.getElementById('timeCurrent').textContent = '0% пути';
const issues = (diagnosis?.issues || []).join('; ');
note.textContent = (extraNote ? extraNote + ' ' : '')
+ 'Старый/битый трек: время записи ненадёжно — шкала по прогрессу пути.'
+ (issues ? ` (${issues})` : '');
note.style.color = '#ffb74d';
}
function applyTimelineRange(range, noteText) { function applyTimelineRange(range, noteText) {
const note = document.getElementById('timelineNote'); const note = document.getElementById('timelineNote');
timelineUseProgress = false;
note.style.color = '#aaa';
overlapMin = range.min; overlapMin = range.min;
overlapMax = range.max; overlapMax = range.max;
const spanSec = Math.max(0.001, overlapMax - overlapMin); const spanSec = Math.max(0.001, overlapMax - overlapMin);
@@ -2026,23 +2171,36 @@
} }
function setupTimelineSingle() { function setupTimelineSingle() {
const range = singleTrackRange(loadedSingleTrack.points); const diag = analyzeTrackTiming(loadedSingleTrack.points);
setTimelineMode(true); setTimelineMode(true);
if (!range) { if (!loadedSingleTrack.points?.length) {
setTimelineVisible(false); setTimelineVisible(false);
return; return;
} }
if (diag.useProgress) {
applyProgressTimeline(diag, `Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack)}`);
} else {
const range = singleTrackRange(loadedSingleTrack.points);
applyTimelineRange( applyTimelineRange(
range, range,
`Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack.device_id)}` `Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack)}`
); );
}
setTimelineVisible(true); setTimelineVisible(true);
updateTimelineAtSingle(overlapMin); updateTimelineAtSingle(timelineCursor());
loadElevationProfiles(); loadElevationProfiles();
} }
function setupTimeline() { function setupTimeline() {
setTimelineMode(false); setTimelineMode(false);
const txDiag = analyzeTrackTiming(loadedTxTrack.points);
const rxDiag = analyzeTrackTiming(loadedRxTrack.points);
if (txDiag.useProgress || rxDiag.useProgress) {
applyProgressTimeline(
{ issues: [...new Set([...(txDiag.issues || []), ...(rxDiag.issues || [])])] },
'Сравнение TX/RX по прогрессу пути.'
);
} else {
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points); const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (!range) { if (!range) {
setTimelineVisible(false); setTimelineVisible(false);
@@ -2054,13 +2212,15 @@
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.'; 'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
} }
applyTimelineRange(range, noteText); applyTimelineRange(range, noteText);
}
setTimelineVisible(true); setTimelineVisible(true);
updateTimelineAt(overlapMin); updateTimelineAt(timelineCursor());
loadElevationProfiles(); loadElevationProfiles();
} }
async function refreshTimelineTelemetry() { async function refreshTimelineTelemetry() {
if (singleTrackActive && loadedSingleTrack) { if (singleTrackActive && loadedSingleTrack) {
if (!timelineUseProgress) {
const range = singleTrackRange(loadedSingleTrack.points); const range = singleTrackRange(loadedSingleTrack.points);
if (!range) return; if (!range) return;
const res = await fetch( const res = await fetch(
@@ -2068,11 +2228,12 @@
{ cache: 'no-store' } { cache: 'no-store' }
); );
if (res.ok) telemetrySingle = await res.json(); if (res.ok) telemetrySingle = await res.json();
const t = sliderTime(); }
updateTimelineAtSingle(t); updateTimelineAtSingle(timelineCursor());
return; return;
} }
if (!dualTracksActive || !loadedTxTrack || !loadedRxTrack) return; if (!dualTracksActive || !loadedTxTrack || !loadedRxTrack) return;
if (!timelineUseProgress) {
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points); const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (!range) return; if (!range) return;
const [telTx, telRx] = await Promise.all([ const [telTx, telRx] = await Promise.all([
@@ -2081,7 +2242,8 @@
]); ]);
if (telTx.ok) telemetryTx = await telTx.json(); if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json(); if (telRx.ok) telemetryRx = await telRx.json();
updateTimelineAt(sliderTime()); }
updateTimelineAt(timelineCursor());
} }
function trackOptionLabel(t) { function trackOptionLabel(t) {
@@ -2165,7 +2327,7 @@
{ cache: 'no-store' } { cache: 'no-store' }
); );
if (telRes.ok) telemetrySingle = await telRes.json(); if (telRes.ok) telemetrySingle = await telRes.json();
updateTimelineAtSingle(overlapMin); updateTimelineAtSingle(timelineCursor());
} }
document.getElementById('trackInfo').textContent = document.getElementById('trackInfo').textContent =
`Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`; `Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`;
@@ -2227,8 +2389,7 @@
]); ]);
if (telTx.ok) telemetryTx = await telTx.json(); if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json(); if (telRx.ok) telemetryRx = await telRx.json();
const t = sliderTime(); updateTimelineAt(timelineCursor());
updateTimelineAt(t);
} }
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : ''; const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
@@ -2382,7 +2543,7 @@
const onSlider = () => { const onSlider = () => {
if (!singleTrackActive && !dualTracksActive) return; if (!singleTrackActive && !dualTracksActive) return;
modalMode = 'timeline'; modalMode = 'timeline';
updateTimelineAt(sliderTime()); updateTimelineAt(timelineCursor());
}; };
slider.addEventListener('input', onSlider); slider.addEventListener('input', onSlider);
slider.addEventListener('change', onSlider); slider.addEventListener('change', onSlider);
@@ -2401,18 +2562,48 @@
let ms = parseInt(slider.value, 10) + step; let ms = parseInt(slider.value, 10) + step;
if (ms > timelineSpanMs) ms = 0; if (ms > timelineSpanMs) ms = 0;
slider.value = String(ms); slider.value = String(ms);
updateTimelineAt(sliderTime()); updateTimelineAt(timelineCursor());
}, Math.max(100, step)); }, Math.max(100, step));
}; };
} }
async function saveDeviceLabel(deviceId) {
const input = document.getElementById('deviceLabelInput');
const label = input ? input.value.trim() : '';
if (!deviceId || !label) return;
const res = await fetch(`/api/devices/${encodeURIComponent(deviceId)}/label`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.detail || data.error || 'Не удалось сохранить имя');
return;
}
deviceLabelCache[deviceId] = label;
await fetchDevices();
}
document.getElementById('stats').addEventListener('click', e => {
if (e.target && e.target.id === 'btnSaveDeviceLabel' && selectedId) {
saveDeviceLabel(selectedId);
}
});
function buildDeviceStatsHtml(d) { function buildDeviceStatsHtml(d) {
const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi); const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi);
const changed = RadioUI.diffSnapshots(prevDeviceSnap, snap); const changed = RadioUI.diffSnapshots(prevDeviceSnap, snap);
prevDeviceSnap = snap; prevDeviceSnap = snap;
const statsEl = document.getElementById('stats'); const statsEl = document.getElementById('stats');
let html = RadioUI.formatRadioPanel(snap, changed, isRadioStaticOpen(statsEl)); let html = RadioUI.formatRadioPanel(snap, changed, isRadioStaticOpen(statsEl));
html += `<b>${escapeHtml(deviceDisplayName(d))}</b><br>Range: ${d.range_m ?? '—'} m<br>`; html += `<b>${escapeHtml(deviceDisplayName(d))}</b>`;
html += `<div class="muted" style="font-size:0.75rem">${escapeHtml(d.device_id)}</div>`;
html += `<div style="margin-top:6px;display:flex;gap:4px">`;
html += `<input id="deviceLabelInput" type="text" value="${escapeHtml(deviceDisplayName(d))}" placeholder="Имя устройства" style="flex:1;padding:4px 6px;border-radius:4px;border:1px solid #444;background:#0a0a14;color:#eee;font-size:0.8rem" />`;
html += `<button type="button" id="btnSaveDeviceLabel" style="padding:4px 8px;border-radius:4px;border:1px solid #444;background:#16213e;color:#eee;cursor:pointer;font-size:0.75rem">OK</button>`;
html += `</div>`;
html += `<br>Range: ${d.range_m ?? '—'} m<br>`;
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) { if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}<br>`; html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}<br>`;
Object.keys(markers).forEach(id => { Object.keys(markers).forEach(id => {
@@ -2447,6 +2638,18 @@
devices.forEach(d => { if (d.role === 'TX') tx++; else if (d.role === 'RX') rx++; }); devices.forEach(d => { if (d.role === 'TX') tx++; else if (d.role === 'RX') rx++; });
document.getElementById('status').textContent = document.getElementById('status').textContent =
`${devices.length} устр. · TX:${tx} RX:${rx} · ${new Date().toLocaleTimeString()}`; `${devices.length} устр. · TX:${tx} RX:${rx} · ${new Date().toLocaleTimeString()}`;
try {
const hres = await fetch('/api/health', { cache: 'no-store' });
if (hres.ok) {
const h = await hres.json();
if (h.api_build && h.api_build !== API_BUILD) {
document.getElementById('status').title =
`UI ${API_BUILD} · сервер ${h.api_build} — обновите образ Docker`;
} else if (h.api_build) {
document.getElementById('status').title = `build ${h.api_build}`;
}
}
} catch (e) {}
const list = document.getElementById('deviceList'); const list = document.getElementById('deviceList');
list.innerHTML = ''; list.innerHTML = '';
const bounds = []; const bounds = [];