Initial commit: LoraTester Android + server

This commit is contained in:
2026-06-04 14:39:14 +03:00
parent 253a7d74ca
commit 81eaa95df3
26 changed files with 1898 additions and 106 deletions
+380 -35
View File
@@ -10,14 +10,29 @@
body { margin: 0; font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; }
header { padding: 12px 16px; background: #16213e; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
header h1 { margin: 0; font-size: 1.2rem; flex: 1; }
main { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: 1fr; height: calc(100vh - 52px); }
main { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: 1fr auto; height: calc(100vh - 52px); }
@media (max-width: 900px) {
main { grid-template-columns: 1fr; grid-template-rows: 45vh minmax(200px, 1fr); }
main { grid-template-columns: 1fr; grid-template-rows: 45vh minmax(180px, 1fr) auto; }
}
#mapWrap { grid-column: 1; grid-row: 1; position: relative; min-height: 0; }
#map { width: 100%; height: 100%; }
#trackTimeline { display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333; }
#trackTimeline {
display: none; grid-column: 1 / -1; grid-row: 2;
background: #16213e; padding: 8px 16px; border-top: 1px solid #333;
}
#trackTimeline.visible { display: block; }
.timeline-bar { display: flex; flex-direction: column; gap: 6px; }
.timeline-bar-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.timeline-bar-title { font-size: 0.85rem; font-weight: 600; }
.timeline-bar .timeline-labels { width: 100%; margin: 0; }
.timeline-bar #timeSlider { width: 100%; margin: 0; }
#timelineStatsPanel {
display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333;
}
#timelineStatsPanel.visible { display: block; }
#timelineStatsPanel.timeline-single #timelineStats { grid-template-columns: 1fr; }
#timelineStatsPanel.timeline-single .timeline-col.rx { display: none; }
#timelineStatsPanel.timeline-single #distanceNow { display: none; }
#timelineNote { font-size: 0.75rem; color: #aaa; margin: 4px 0 8px; }
#timeSlider { width: 100%; margin: 6px 0; }
.timeline-labels { display: flex; justify-content: space-between; font-size: 0.75rem; color: #aaa; }
@@ -49,7 +64,18 @@
.track-row { margin-bottom: 6px; }
.track-row label { font-size: 0.75rem; color: #aaa; display: block; margin-bottom: 2px; }
.track-row select { width: 100%; padding: 4px; }
#btnShowTracks { width: 100%; padding: 6px; margin-top: 4px; background: #00ff88; color: #111; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
.track-actions { display: flex; gap: 6px; margin-top: 4px; }
.track-actions button { flex: 1; padding: 6px; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.8rem; }
#btnShowTracks { background: #00ff88; color: #111; }
#btnHideTracks { background: #333; color: #eee; }
#btnHideTracks:disabled { opacity: 0.4; cursor: not-allowed; }
.track-mode { display: flex; gap: 4px; margin-bottom: 8px; }
.track-mode button { flex: 1; padding: 4px; font-size: 0.75rem; border: 1px solid #444; background: #0a0a14; color: #ccc; border-radius: 4px; cursor: pointer; }
.track-mode button.active { background: #e94560; color: #fff; border-color: #e94560; }
#controlPanel input, #controlPanel select { width: 100%; padding: 4px; margin-top: 2px; border-radius: 4px; border: none; font-size: 0.8rem; }
#controlPanel .cmd-row { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
#controlPanel .cmd-row button { padding: 4px 8px; font-size: 0.75rem; border: none; border-radius: 4px; cursor: pointer; background: #16213e; color: #eee; }
#pairedStatus { font-size: 0.75rem; color: #aaa; margin-top: 4px; }
.muted { color: #aaa; font-size: 0.75rem; }
.legend { font-size: 0.75rem; color: #ccc; }
.legend-tx { color: #e94560; }
@@ -93,33 +119,54 @@
<div id="stats">Выберите устройство</div>
<div id="history" class="muted" style="margin-top:8px"></div>
</div>
<div class="panel" id="controlPanel">
<h2>Управление</h2>
<label class="muted">Целевое устройство</label>
<select id="cmdTargetSelect"><option value=""></option></select>
<input type="text" id="cmdAtInput" placeholder="AT+SF=7 …" />
<div class="cmd-row">
<button type="button" id="btnCmdAt">AT</button>
<button type="button" id="btnCmdTx">AT+TX</button>
<button type="button" id="btnCmdRx">AT+RX</button>
</div>
<button type="button" id="btnPairedStart" style="width:100%;margin-top:8px;padding:6px;background:#00ff88;color:#111;border:none;border-radius:4px;font-weight:600;cursor:pointer">Старт трека (оба)</button>
<div id="pairedStatus"></div>
</div>
<div class="panel">
<h2>Сравнение треков</h2>
<div class="track-row">
<label class="legend-tx">Трек TX</label>
<select id="trackTxSelect"><option value=""></option></select>
<h2>Треки</h2>
<div class="track-mode">
<button type="button" id="btnModeSingle" class="active">Один трек</button>
<button type="button" id="btnModeDual">Сравнение TX+RX</button>
</div>
<div class="track-row">
<label class="legend-rx">Трек RX</label>
<select id="trackRxSelect"><option value=""></option></select>
</div>
<button type="button" id="btnShowTracks">Показать на карте</button>
<div id="trackInfo" class="muted" style="margin-top:6px">Выберите TX и RX треки</div>
<div id="trackTimeline">
<h3 style="margin:0 0 6px;font-size:0.9rem">Время теста</h3>
<div id="timelineNote"></div>
<div class="timeline-labels">
<span id="timeStart"></span>
<span id="timeCurrent"></span>
<span id="timeEnd"></span>
<div id="trackPanelSingle">
<div class="track-row">
<label>Трек</label>
<select id="trackSingleSelect"><option value=""></option></select>
</div>
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1" />
</div>
<div id="trackPanelDual" style="display:none">
<div class="track-row">
<label class="legend-tx">Трек TX</label>
<select id="trackTxSelect"><option value=""></option></select>
</div>
<div class="track-row">
<label class="legend-rx">Трек RX</label>
<select id="trackRxSelect"><option value=""></option></select>
</div>
</div>
<div class="track-actions">
<button type="button" id="btnShowTracks">Показать на карте</button>
<button type="button" id="btnHideTracks" disabled>Скрыть треки</button>
</div>
<div id="trackInfo" class="muted" style="margin-top:6px">Выберите трек</div>
<div id="timelineStatsPanel">
<h3 style="margin:0 0 6px;font-size:0.9rem">Статистика по времени</h3>
<div id="timelineNote"></div>
<div id="distanceNow">Расстояние GPS: —</div>
<div id="timelineStats">
<div class="timeline-col tx"><h3>TX</h3><div id="statsTx"></div></div>
<div class="timeline-col tx"><h3 id="timelineCol1Label">TX</h3><div id="statsTx"></div></div>
<div class="timeline-col rx"><h3>RX</h3><div id="statsRx"></div></div>
</div>
<button type="button" id="btnPlay" class="muted" style="margin-top:6px;padding:4px 10px;border:none;border-radius:4px;cursor:pointer;background:#0a0a14;color:#eee">▶ Play</button>
</div>
</div>
<div class="panel" style="flex:1;display:flex;flex-direction:column">
@@ -131,6 +178,20 @@
</form>
</div>
</aside>
<div id="trackTimeline">
<div class="timeline-bar">
<div class="timeline-bar-header">
<span class="timeline-bar-title">Время теста</span>
<button type="button" id="btnPlay" class="muted" style="padding:4px 10px;border:none;border-radius:4px;cursor:pointer;background:#0a0a14;color:#eee">▶ Play</button>
</div>
<div class="timeline-labels">
<span id="timeStart"></span>
<span id="timeCurrent"></span>
<span id="timeEnd"></span>
</div>
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1" />
</div>
</div>
</main>
<div id="mapModal">
<div id="mapModalHeader">
@@ -163,14 +224,19 @@
let loadedTxTrack = null;
let loadedRxTrack = null;
let loadedSingleTrack = null;
let telemetryTx = [];
let telemetryRx = [];
let telemetrySingle = [];
let overlapMin = 0;
let overlapMax = 0;
let playTimer = null;
let pollTimer = null;
let pollTick = 0;
let trackViewMode = 'single';
let dualTracksActive = false;
let singleTrackActive = false;
let lastDevices = [];
const DEVICE_POLL_MS = 1000;
const CHAT_POLL_MS = 2500;
@@ -440,6 +506,36 @@
if (ghostRx) { map.removeLayer(ghostRx); ghostRx = null; }
}
function updateTrackButtons() {
const active = dualTracksActive || singleTrackActive;
const hideBtn = document.getElementById('btnHideTracks');
if (hideBtn) hideBtn.disabled = !active;
}
function exitTrackMode() {
clearTrackLayers();
dualTracksActive = false;
singleTrackActive = false;
loadedTxTrack = null;
loadedRxTrack = null;
loadedSingleTrack = null;
telemetryTx = [];
telemetryRx = [];
telemetrySingle = [];
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
}
setTimelineVisible(false);
if (isModalOpen() && modalMode === 'timeline') {
closeMapModal();
}
document.getElementById('trackInfo').textContent =
trackViewMode === 'dual' ? 'Выберите TX и RX треки' : 'Выберите трек';
updateTrackButtons();
}
function drawTrackLine(track, color, store) {
const latlngs = track.points.map(p => [p.lat, p.lon]);
const layer = L.polyline(latlngs, { color, weight: 4, opacity: 0.85 }).addTo(map);
@@ -478,8 +574,17 @@
return html;
}
function singleTrackRange(points) {
if (!points || !points.length) return null;
return { min: points[0].ts, max: points[points.length - 1].ts, mode: 'single' };
}
function updateTimelineAt(t, opts) {
const openModal = opts && opts.openModal;
if (singleTrackActive && loadedSingleTrack) {
updateTimelineAtSingle(t, openModal);
return;
}
if (!loadedTxTrack || !loadedRxTrack) return;
const txPos = positionAt(loadedTxTrack.points, t);
const rxPos = positionAt(loadedRxTrack.points, t);
@@ -521,12 +626,76 @@
? formatTelemetryRow(rxTel) : '<span class="muted">нет данных</span>';
}
function updateTimelineAtSingle(t, openModal) {
const track = loadedSingleTrack;
if (!track) return;
const pos = positionAt(track.points, t);
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString();
if (ghostTx) map.removeLayer(ghostTx);
if (ghostRx) map.removeLayer(ghostRx);
if (linkLine) map.removeLayer(linkLine);
ghostTx = null;
ghostRx = null;
linkLine = null;
if (pos) {
const color = track.role === 'RX' ? RX_COLOR : TX_COLOR;
ghostTx = L.circleMarker([pos.lat, pos.lon], {
radius: 10, color, fillColor: color, fillOpacity: 0.9, weight: 3
}).addTo(map);
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
html += formatMeta(pos.meta);
const tel = nearestTelemetry(telemetrySingle, t);
if (tel) html += '<br>' + formatTelemetryRow(tel);
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
openMapModal(html, 'timeline');
}
}
const tel = nearestTelemetry(telemetrySingle, t);
document.getElementById('statsTx').innerHTML = tel
? formatTelemetryRow(tel) : '<span class="muted">нет данных</span>';
}
function setTimelineVisible(visible) {
document.getElementById('trackTimeline').classList.toggle('visible', visible);
document.getElementById('timelineStatsPanel').classList.toggle('visible', visible);
}
function setTimelineMode(single) {
const statsPanel = document.getElementById('timelineStatsPanel');
statsPanel.classList.toggle('timeline-single', single);
}
function setupTimelineSingle() {
const range = singleTrackRange(loadedSingleTrack.points);
const note = document.getElementById('timelineNote');
setTimelineMode(true);
document.getElementById('timelineCol1Label').textContent =
loadedSingleTrack.role === 'RX' ? 'RX' : 'TX';
if (!range) {
setTimelineVisible(false);
return;
}
overlapMin = range.min;
overlapMax = range.max;
const span = Math.max(1, Math.round(overlapMax - overlapMin));
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = span;
slider.value = 0;
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
note.textContent = `Трек #${loadedSingleTrack.id} · ${loadedSingleTrack.device_id || ''}`;
setTimelineVisible(true);
updateTimelineAtSingle(overlapMin);
}
function setupTimeline() {
setTimelineMode(false);
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
const panel = document.getElementById('trackTimeline');
const note = document.getElementById('timelineNote');
if (!range) {
panel.classList.remove('visible');
setTimelineVisible(false);
return;
}
overlapMin = range.min;
@@ -544,12 +713,23 @@
} else {
note.textContent = 'Общий интервал записи обоих треков.';
}
panel.classList.add('visible');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setTimelineVisible(true);
updateTimelineAt(overlapMin);
}
async function refreshTimelineTelemetry() {
if (singleTrackActive && loadedSingleTrack) {
const range = singleTrackRange(loadedSingleTrack.points);
if (!range) return;
const res = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`,
{ cache: 'no-store' }
);
if (res.ok) telemetrySingle = await res.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
updateTimelineAtSingle(t);
return;
}
if (!dualTracksActive || !loadedTxTrack || !loadedRxTrack) return;
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (!range) return;
@@ -563,11 +743,20 @@
updateTimelineAt(t);
}
function trackOptionLabel(t) {
const start = new Date(t.started_at * 1000).toLocaleString();
const role = t.role ? ` · ${t.role}` : '';
const dev = t.device_id ? ` · ${t.device_id.slice(0, 12)}` : '';
return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
}
async function loadAllTracks() {
const txSel = document.getElementById('trackTxSelect');
const rxSel = document.getElementById('trackRxSelect');
const singleSel = document.getElementById('trackSingleSelect');
const prevTx = txSel.value;
const prevRx = rxSel.value;
const prevSingle = singleSel.value;
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();
@@ -576,19 +765,67 @@
tracks.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const start = new Date(t.started_at * 1000).toLocaleString();
const role = t.role ? ` · ${t.role}` : '';
const dev = t.device_id ? ` · ${t.device_id.slice(0, 12)}` : '';
opt.textContent = `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
opt.textContent = trackOptionLabel(t);
sel.appendChild(opt);
});
};
fill(singleSel, '— трек —');
fill(txSel, '— TX трек —');
fill(rxSel, '— RX трек —');
if (prevSingle) singleSel.value = prevSingle;
if (prevTx) txSel.value = prevTx;
if (prevRx) rxSel.value = prevRx;
if (!singleTrackActive && !dualTracksActive) {
document.getElementById('trackInfo').textContent =
tracks.length ? `${tracks.length} трек(ов) на сервере` : 'Треки записываются с телефона';
}
}
async function showSingleTrack() {
const id = document.getElementById('trackSingleSelect').value;
if (!id) {
document.getElementById('trackInfo').textContent = 'Выберите трек';
return;
}
clearTrackLayers();
dualTracksActive = false;
singleTrackActive = false;
loadedTxTrack = null;
loadedRxTrack = null;
if (playTimer) { clearInterval(playTimer); playTimer = null; }
const res = await fetch(`/api/tracks/${id}`, { cache: 'no-store' });
loadedSingleTrack = await res.json();
if (!loadedSingleTrack.role && loadedSingleTrack.points) {
const p = loadedSingleTrack.points.find(x => x.role);
if (p) loadedSingleTrack.role = p.role;
}
if (!loadedSingleTrack.points?.length) {
document.getElementById('trackInfo').textContent = 'Пустой трек';
return;
}
const color = loadedSingleTrack.role === 'RX' ? RX_COLOR : TX_COLOR;
drawTrackLine(loadedSingleTrack, color, 'tx');
const bounds = L.latLngBounds(loadedSingleTrack.points.map(p => [p.lat, p.lon]));
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50] }));
singleTrackActive = true;
setupTimelineSingle();
const range = singleTrackRange(loadedSingleTrack.points);
if (range && loadedSingleTrack.device_id) {
const telRes = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`,
{ cache: 'no-store' }
);
if (telRes.ok) telemetrySingle = await telRes.json();
updateTimelineAtSingle(overlapMin);
}
document.getElementById('trackInfo').textContent =
tracks.length ? `${tracks.length} трек(ов) на сервере` : 'Треки записываются с телефона';
`Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`;
updateTrackButtons();
}
function showTracksOnMap() {
if (trackViewMode === 'single') showSingleTrack();
else showDualTracks();
}
async function showDualTracks() {
@@ -603,6 +840,8 @@
return;
}
clearTrackLayers();
singleTrackActive = false;
loadedSingleTrack = null;
if (playTimer) { clearInterval(playTimer); playTimer = null; }
const [txRes, rxRes] = await Promise.all([
@@ -640,9 +879,90 @@
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
document.getElementById('trackInfo').textContent =
`TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`;
updateTrackButtons();
}
document.getElementById('btnShowTracks').onclick = showDualTracks;
document.getElementById('btnShowTracks').onclick = showTracksOnMap;
document.getElementById('btnHideTracks').onclick = exitTrackMode;
document.getElementById('btnModeSingle').onclick = () => {
trackViewMode = 'single';
document.getElementById('btnModeSingle').classList.add('active');
document.getElementById('btnModeDual').classList.remove('active');
document.getElementById('trackPanelSingle').style.display = '';
document.getElementById('trackPanelDual').style.display = 'none';
document.getElementById('trackInfo').textContent = 'Выберите трек';
if (singleTrackActive || dualTracksActive) exitTrackMode();
};
document.getElementById('btnModeDual').onclick = () => {
trackViewMode = 'dual';
document.getElementById('btnModeDual').classList.add('active');
document.getElementById('btnModeSingle').classList.remove('active');
document.getElementById('trackPanelSingle').style.display = 'none';
document.getElementById('trackPanelDual').style.display = '';
document.getElementById('trackInfo').textContent = 'Выберите TX и RX треки';
if (singleTrackActive || dualTracksActive) exitTrackMode();
};
async function postCommand(toDeviceId, kind, payload) {
if (!toDeviceId) {
alert('Выберите устройство');
return;
}
const res = await fetch('/api/commands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from_device_id: 'web', to_device_id: toDeviceId, kind, payload })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка команды');
}
}
document.getElementById('btnCmdAt').onclick = () => {
const line = document.getElementById('cmdAtInput').value.trim();
if (!line) return;
postCommand(document.getElementById('cmdTargetSelect').value, 'at', { line });
};
document.getElementById('btnCmdTx').onclick = () => {
postCommand(document.getElementById('cmdTargetSelect').value, 'mode', { role: 'TX' });
};
document.getElementById('btnCmdRx').onclick = () => {
postCommand(document.getElementById('cmdTargetSelect').value, 'mode', { role: 'RX' });
};
async function refreshPairedStatus() {
try {
const res = await fetch('/api/paired-tracks/active', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
const el = document.getElementById('pairedStatus');
if (!data.active || !data.session) {
el.textContent = 'Синхр. трек: нет активной сессии';
return;
}
const s = data.session;
el.textContent = `Сессия #${s.id} · ${s.status} · старт ${new Date(s.start_at * 1000).toLocaleTimeString()}`;
} catch (e) {
console.warn('paired status', e);
}
}
document.getElementById('btnPairedStart').onclick = async () => {
const ids = lastDevices.filter(d => d.device_id && d.device_id.startsWith('android-')).map(d => d.device_id);
const body = ids.length === 2 ? { device_ids: ids, initiator: 'web' } : { initiator: 'web' };
const res = await fetch('/api/paired-tracks/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Не удалось запустить');
return;
}
refreshPairedStatus();
};
document.getElementById('timeSlider').oninput = e => {
modalMode = 'timeline';
updateTimelineAt(overlapMin + parseInt(e.target.value, 10), { openModal: true });
@@ -740,6 +1060,7 @@
}
});
if (!mapInitialFitDone && bounds.length) fitAllMarkers(bounds);
updateCmdTargetSelect(devices);
if (selectedId) {
const sel = devices.find(d => d.device_id === selectedId);
if (sel) {
@@ -852,13 +1173,36 @@
console.warn('poll tracks', e);
}
}
if (dualTracksActive && pollTick % Math.round(TELEMETRY_POLL_MS / DEVICE_POLL_MS) === 0) {
if ((dualTracksActive || singleTrackActive) && pollTick % Math.round(TELEMETRY_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await refreshTimelineTelemetry();
} catch (e) {
console.warn('poll timeline telemetry', e);
}
}
if (pollTick % Math.round(2000 / DEVICE_POLL_MS) === 0) {
try {
await refreshPairedStatus();
} catch (e) {
console.warn('poll paired', e);
}
}
}
function updateCmdTargetSelect(devices) {
lastDevices = devices;
const sel = document.getElementById('cmdTargetSelect');
const prev = sel.value;
sel.innerHTML = '<option value="">— устройство —</option>';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.device_id;
let label = d.device_id;
if (d.role) label += ` · ${d.role}`;
opt.textContent = label;
sel.appendChild(opt);
});
if (prev) sel.value = prev;
}
function schedulePoll() {
@@ -877,6 +1221,7 @@
schedulePoll();
loadAllTracks();
refreshPairedStatus();
</script>
</body>
</html>