This commit is contained in:
2026-06-16 11:10:15 +03:00
parent 0571291b69
commit 3399e81447
6 changed files with 190 additions and 70 deletions
@@ -42,6 +42,14 @@ public class LoraApp extends Application {
peerStatsCache peerStatsCache
); );
commandPoller.start(); commandPoller.start();
telemetryUploader.registerPresence();
if (networkMonitor != null) {
networkMonitor.addListener(online -> {
if (online) {
telemetryUploader.registerPresence();
}
});
}
} }
public NetworkMonitor getNetworkMonitor() { public NetworkMonitor getNetworkMonitor() {
@@ -299,6 +299,24 @@ public class TelemetryUploader implements TelnetClient.Listener {
uploadExecutor.execute(() -> uploadTelemetry(payload)); 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() { private static String phoneLabel() {
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 : "";
Binary file not shown.
Binary file not shown.
+27 -12
View File
@@ -15,6 +15,8 @@ WEB_SENDER_ID = "web"
COMMAND_KINDS = frozenset({"at", "mode", "stats_push"}) COMMAND_KINDS = frozenset({"at", "mode", "stats_push"})
PAIRED_ONLINE_SEC = 30.0 PAIRED_ONLINE_SEC = 30.0
PAIRED_START_DELAY_SEC = 3.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__) logger = logging.getLogger(__name__)
@@ -163,8 +165,13 @@ def list_devices() -> list[dict[str, Any]]:
ORDER BY d.last_seen DESC ORDER BY d.last_seen DESC
""" """
).fetchall() ).fetchall()
cutoff = time.time() - DEVICE_VISIBLE_SEC
devices = [_row_to_device(r) for r in rows] 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: 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 != '' WHERE p.track_id = t.id AND p.role IS NOT NULL AND p.role != ''
ORDER BY p.ts DESC LIMIT 1) ORDER BY p.ts DESC LIMIT 1)
""" """
if device_id: track_cols = f"""
rows = conn.execute(
f"""
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label, 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, (SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
{role_sub} AS role {role_sub} AS role
FROM tracks t 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 = ? WHERE t.device_id = ?
ORDER BY t.started_at DESC ORDER BY t.started_at DESC
LIMIT ? LIMIT ?
@@ -351,10 +363,7 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
else: else:
rows = conn.execute( rows = conn.execute(
f""" f"""
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label, {track_cols}
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
{role_sub} AS role
FROM tracks t
ORDER BY t.started_at DESC ORDER BY t.started_at DESC
LIMIT ? LIMIT ?
""", """,
@@ -367,7 +376,11 @@ def get_track(track_id: int) -> dict[str, Any]:
with _db() as conn: with _db() as conn:
track = conn.execute( 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,), (track_id,),
).fetchone() ).fetchone()
@@ -408,9 +421,11 @@ def get_chat(since: float = 0.0, limit: int = 200) -> list[dict[str, Any]]:
with _db() as conn: with _db() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, device_id, text, ts FROM chat SELECT c.id, c.device_id, c.text, c.ts, d.label AS device_label
WHERE ts > ? FROM chat c
ORDER BY ts ASC LIMIT ? LEFT JOIN devices d ON d.device_id = c.device_id
WHERE c.ts > ?
ORDER BY c.ts ASC LIMIT ?
""", """,
(since, limit), (since, limit),
).fetchall() ).fetchall()
+126 -47
View File
@@ -390,6 +390,8 @@
let dualTracksActive = false; let dualTracksActive = false;
let singleTrackActive = false; let singleTrackActive = false;
let lastDevices = []; let lastDevices = [];
const deviceLabelCache = {};
let timelineSpanMs = 1000;
let elevProfileTx = null; let elevProfileTx = null;
let elevProfileRx = null; let elevProfileRx = null;
let elevProfileSingle = null; let elevProfileSingle = null;
@@ -433,17 +435,61 @@
return Math.abs(lat) < 1e-5 && Math.abs(lon) < 1e-5; 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) { function deviceDisplayName(d) {
if (!d) return '—'; 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 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; if (label && label !== id) return label;
return id || '—'; return id || '—';
} }
function sliderTime() { 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) { function rxQualityFromMeta(meta) {
@@ -454,7 +500,7 @@
function qualityColor(pct) { function qualityColor(pct) {
if (pct == null || Number.isNaN(pct)) return null; 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 r = p < 50 ? Math.round(255 * (p / 50)) : 255;
const g = p < 50 ? 255 : Math.round(255 * (1 - (p - 50) / 50)); const g = p < 50 ? 255 : Math.round(255 * (1 - (p - 50) / 50));
return `rgb(${r},${g},0)`; return `rgb(${r},${g},0)`;
@@ -1635,34 +1681,40 @@
/* --- Track helpers --- */ /* --- Track helpers --- */
function positionAt(points, t) { function positionAt(points, t) {
if (!points || !points.length) return null; if (!points || !points.length) return null;
const tNum = Number(t);
const first = points[0]; const first = points[0];
const last = points[points.length - 1]; const last = points[points.length - 1];
if (t <= first.ts) { const t0 = Number(first.ts);
return { lat: first.lat, lon: first.lon, meta: first.meta, rssi: first.rssi }; 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) { if (tNum >= t1) {
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 };
} }
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < points.length - 1; i++) {
const a = points[i]; const a = points[i];
const b = points[i + 1]; const b = points[i + 1];
if (t >= a.ts && t <= b.ts) { const ta = Number(a.ts);
const f = (t - a.ts) / (b.ts - 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 { return {
lat: a.lat + (b.lat - a.lat) * f, lat: Number(a.lat) + (Number(b.lat) - Number(a.lat)) * f,
lon: a.lon + (b.lon - a.lon) * f, lon: Number(a.lon) + (Number(b.lon) - Number(a.lon)) * f,
meta: t - a.ts < b.ts - t ? a.meta : b.meta, meta: tNum - ta < tb - tNum ? a.meta : b.meta,
rssi: t - a.ts < b.ts - t ? a.rssi : b.rssi 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) { function overlapRange(txPts, rxPts) {
if (!txPts.length || !rxPts.length) return null; if (!txPts.length || !rxPts.length) return null;
const min = Math.max(txPts[0].ts, rxPts[0].ts); const min = Math.max(Number(txPts[0].ts), Number(rxPts[0].ts));
const max = Math.min(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts); const max = Math.min(Number(txPts[txPts.length - 1].ts), Number(rxPts[rxPts.length - 1].ts));
if (min >= max) return null; if (min >= max) return null;
return { min, max, mode: 'overlap' }; return { min, max, mode: 'overlap' };
} }
@@ -1672,8 +1724,8 @@
if (!txPts.length || !rxPts.length) return null; if (!txPts.length || !rxPts.length) return null;
const overlap = overlapRange(txPts, rxPts); const overlap = overlapRange(txPts, rxPts);
if (overlap) return overlap; if (overlap) return overlap;
const min = Math.min(txPts[0].ts, rxPts[0].ts); const min = Math.min(Number(txPts[0].ts), Number(rxPts[0].ts));
const max = Math.max(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts); const max = Math.max(Number(txPts[txPts.length - 1].ts), Number(rxPts[rxPts.length - 1].ts));
if (min >= max) return null; if (min >= max) return null;
return { min, max, mode: 'union' }; return { min, max, mode: 'union' };
} }
@@ -1802,7 +1854,7 @@
const qa = rxQualityFromMeta(a.meta); const qa = rxQualityFromMeta(a.meta);
const qb = rxQualityFromMeta(b.meta); const qb = rxQualityFromMeta(b.meta);
const q = qa != null && qb != null ? (qa + qb) / 2 : (qa ?? qb); 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]], { const seg = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
color: segColor, weight: 4, opacity: 0.85 color: segColor, weight: 4, opacity: 0.85
}).addTo(map); }).addTo(map);
@@ -1811,16 +1863,16 @@
pts.forEach(p => { pts.forEach(p => {
const q = rxQualityFromMeta(p.meta); 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], { const m = L.circleMarker([p.lat, p.lon], {
radius: 3, color: ptColor, fillColor: ptColor, fillOpacity: 0.8 radius: 3, color: ptColor, fillColor: ptColor, fillOpacity: 0.8
}); });
m.addTo(map); m.addTo(map);
m.on('click', () => { m.on('click', () => {
const rel = Math.max(0, Math.min(p.ts - overlapMin, parseFloat(document.getElementById('timeSlider').max))); const relMs = Math.max(0, Math.min(Math.round((p.ts - overlapMin) * 1000), timelineSpanMs));
document.getElementById('timeSlider').value = String(rel); document.getElementById('timeSlider').value = String(relMs);
modalMode = 'timeline'; modalMode = 'timeline';
updateTimelineAt(p.ts, { openModal: true }); updateTimelineAt(Number(p.ts), { openModal: true });
}); });
markerList.push(m); markerList.push(m);
}); });
@@ -1846,7 +1898,7 @@
function singleTrackRange(points) { function singleTrackRange(points) {
if (!points || !points.length) return null; 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) { function updateTimelineAt(t, opts) {
@@ -1922,7 +1974,7 @@
}).addTo(map); }).addTo(map);
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`; let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`; html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
const tel = nearestTelemetry(telemetrySingle, t); const tel = snapAtTime(track, telemetrySingle, t, 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());
@@ -1930,7 +1982,7 @@
openMapModal(html, 'timeline'); 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 snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null);
const timelineStatsEl = document.getElementById('timelineStats'); const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml( setPanelHtml(
@@ -1960,14 +2012,16 @@
const note = document.getElementById('timelineNote'); const note = document.getElementById('timelineNote');
overlapMin = range.min; overlapMin = range.min;
overlapMax = range.max; 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'); const slider = document.getElementById('timeSlider');
slider.min = 0; slider.min = 0;
slider.max = String(span); slider.max = String(timelineSpanMs);
slider.step = span > 300 ? '1' : (span > 60 ? '0.5' : '0.1'); slider.step = timelineSpanMs > 300000 ? 1000 : (timelineSpanMs > 60000 ? 500 : 100);
slider.value = '0'; slider.value = '0';
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString(); document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 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; if (noteText != null) note.textContent = noteText;
} }
@@ -2033,7 +2087,7 @@
function trackOptionLabel(t) { function trackOptionLabel(t) {
const start = new Date(t.started_at * 1000).toLocaleString(); const start = new Date(t.started_at * 1000).toLocaleString();
const role = t.role ? ` · ${t.role}` : ''; 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})`; return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
} }
@@ -2047,6 +2101,7 @@
const res = await fetch('/api/tracks?limit=100', { cache: 'no-store' }); const res = await fetch('/api/tracks?limit=100', { cache: 'no-store' });
if (!res.ok) throw new Error('tracks ' + res.status); if (!res.ok) throw new Error('tracks ' + res.status);
const tracks = await res.json(); const tracks = await res.json();
rememberDeviceLabels(tracks);
const fill = (sel, hint) => { const fill = (sel, hint) => {
sel.innerHTML = `<option value="">${hint}</option>`; sel.innerHTML = `<option value="">${hint}</option>`;
tracks.forEach(t => { tracks.forEach(t => {
@@ -2079,9 +2134,14 @@
singleTrackActive = false; singleTrackActive = false;
loadedTxTrack = null; loadedTxTrack = null;
loadedRxTrack = 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' }); 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) { if (!loadedSingleTrack.role && loadedSingleTrack.points) {
const p = loadedSingleTrack.points.find(x => x.role); const p = loadedSingleTrack.points.find(x => x.role);
if (p) loadedSingleTrack.role = p.role; if (p) loadedSingleTrack.role = p.role;
@@ -2131,14 +2191,18 @@
clearTrackLayers(); clearTrackLayers();
singleTrackActive = false; singleTrackActive = false;
loadedSingleTrack = null; 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([ const [txRes, rxRes] = await Promise.all([
fetch(`/api/tracks/${txId}`), fetch(`/api/tracks/${txId}`),
fetch(`/api/tracks/${rxId}`) fetch(`/api/tracks/${rxId}`)
]); ]);
loadedTxTrack = await txRes.json(); loadedTxTrack = normalizeTrack(await txRes.json());
loadedRxTrack = await rxRes.json(); loadedRxTrack = normalizeTrack(await rxRes.json());
rememberDeviceLabels([loadedTxTrack, loadedRxTrack]);
if (!loadedTxTrack.points?.length || !loadedRxTrack.points?.length) { if (!loadedTxTrack.points?.length || !loadedRxTrack.points?.length) {
document.getElementById('trackInfo').textContent = 'Пустой трек'; document.getElementById('trackInfo').textContent = 'Пустой трек';
return; return;
@@ -2313,27 +2377,34 @@
} }
refreshPairedStatus(); refreshPairedStatus();
}; };
document.getElementById('timeSlider').oninput = e => { function setupTimelineControls() {
const slider = document.getElementById('timeSlider');
const onSlider = () => {
if (!singleTrackActive && !dualTracksActive) return;
modalMode = 'timeline'; modalMode = 'timeline';
updateTimelineAt(overlapMin + parseFloat(e.target.value), { openModal: true }); updateTimelineAt(sliderTime());
}; };
slider.addEventListener('input', onSlider);
slider.addEventListener('change', onSlider);
document.getElementById('btnPlay').onclick = () => { document.getElementById('btnPlay').onclick = () => {
if (!singleTrackActive && !dualTracksActive) return;
if (playTimer) { if (playTimer) {
clearInterval(playTimer); clearInterval(playTimer);
playTimer = null; playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play'; document.getElementById('btnPlay').textContent = '▶ Play';
return; return;
} }
const slider = document.getElementById('timeSlider');
document.getElementById('btnPlay').textContent = '⏸ Pause'; document.getElementById('btnPlay').textContent = '⏸ Pause';
const step = parseInt(slider.step, 10) || 100;
playTimer = setInterval(() => { playTimer = setInterval(() => {
const step = parseFloat(slider.step) || 1; let ms = parseInt(slider.value, 10) + step;
let v = parseFloat(slider.value) + step; if (ms > timelineSpanMs) ms = 0;
if (v > parseFloat(slider.max)) v = 0; slider.value = String(ms);
slider.value = String(v); updateTimelineAt(sliderTime());
updateTimelineAt(overlapMin + v, { openModal: isModalOpen() && modalMode === 'timeline' }); }, Math.max(100, step));
}, Math.max(200, Math.round(step * 1000)));
}; };
}
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);
@@ -2370,6 +2441,8 @@
const res = await fetch('/api/devices', { cache: 'no-store' }); const res = await fetch('/api/devices', { cache: 'no-store' });
if (!res.ok) throw new Error('devices ' + res.status); if (!res.ok) throw new Error('devices ' + res.status);
const devices = await res.json(); const devices = await res.json();
rememberDeviceLabels(devices);
lastDevices = devices;
let tx = 0, rx = 0; let tx = 0, rx = 0;
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 =
@@ -2419,6 +2492,11 @@
updateGpsDistanceHeader(devices); updateGpsDistanceHeader(devices);
if (mapRulerOpen && mapRulerMode === 'auto') loadMapRulerProfileAuto(); if (mapRulerOpen && mapRulerMode === 'auto') loadMapRulerProfileAuto();
updateCmdTargetSelect(devices); updateCmdTargetSelect(devices);
if (selectedId && !devices.find(d => d.device_id === selectedId)) {
selectedId = null;
setPanelHtml(document.getElementById('stats'), '<span class="muted">—</span>');
document.getElementById('history').innerHTML = '';
}
if (selectedId) { if (selectedId) {
const sel = devices.find(d => d.device_id === selectedId); const sel = devices.find(d => d.device_id === selectedId);
if (sel) { if (sel) {
@@ -2491,7 +2569,7 @@
const isNew = m.ts > chatLastReadTs; const isNew = m.ts > chatLastReadTs;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'chat-msg ' + (self ? 'chat-self' : 'chat-other') + (isNew ? ' chat-new' : ''); 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 = `<div class="chat-meta">${new Date(m.ts*1000).toLocaleTimeString()} · ${author}</div>${escapeHtml(m.text)}`; div.innerHTML = `<div class="chat-meta">${new Date(m.ts*1000).toLocaleTimeString()} · ${author}</div>${escapeHtml(m.text)}`;
log.appendChild(div); log.appendChild(div);
if (isNew) { if (isNew) {
@@ -2606,6 +2684,7 @@
schedulePoll(); schedulePoll();
setupCmdFormDirtyTracking(); setupCmdFormDirtyTracking();
setupTimelineControls();
loadAllTracks(); loadAllTracks();
refreshPairedStatus(); refreshPairedStatus();
</script> </script>