mirror of
https://github.com/fr33n0w/rtcom
synced 2026-08-12 18:35:44 -04:00
1025 lines
38 KiB
Python
1025 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
rtcom.py - Range Test Termux Companion App WebUI for LXMF-CLI rangetest plugin
|
|
Exposes the range test HTML map via Flask on localhost:8033
|
|
Features:
|
|
- Live HTML map with auto-refresh
|
|
- Current GPS position tracking (red arrow like a navigator)
|
|
- Real-time point updates
|
|
- Mobile-optimized interface
|
|
|
|
Usage:
|
|
python3 rtcom.py
|
|
|
|
Then open: http://localhost:8033 on a phone web browser
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
import shutil
|
|
import subprocess
|
|
from datetime import datetime
|
|
from flask import Flask, render_template_string, jsonify, send_file, request
|
|
from threading import Thread, Lock
|
|
|
|
app = Flask(__name__)
|
|
|
|
# Configuration
|
|
PORT = 8033
|
|
HOST = '0.0.0.0' # Listen on all interfaces for LAN access
|
|
GPS_UPDATE_INTERVAL = 1 # seconds between GPS updates
|
|
HTML_REFRESH_INTERVAL = 5 # seconds between HTML file checks
|
|
|
|
# Global state
|
|
current_gps = {
|
|
'latitude': None,
|
|
'longitude': None,
|
|
'accuracy': None,
|
|
'speed': None,
|
|
'altitude': None,
|
|
'bearing': None,
|
|
'provider': None,
|
|
'timestamp': None,
|
|
'available': False
|
|
}
|
|
gps_lock = Lock()
|
|
|
|
# File paths (matching rangetest_client.py)
|
|
STORAGE_DIR = os.path.expanduser('/data/data/com.termux/files/home/lxmf-cli/lxmf_client_storage')
|
|
HTML_FILE = os.path.join(STORAGE_DIR, 'rangetest.html')
|
|
JSON_FILE = os.path.join(STORAGE_DIR, 'rangetest.json')
|
|
|
|
def is_termux():
|
|
"""Check if running on Termux"""
|
|
return os.path.exists('/data/data/com.termux')
|
|
|
|
def get_current_gps():
|
|
"""Get current GPS position (non-blocking)"""
|
|
if not is_termux():
|
|
return None
|
|
|
|
try:
|
|
# Try GPS first (quick 2s timeout)
|
|
result = subprocess.run(
|
|
['termux-location', '-p', 'gps', '-r', 'once'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
env=os.environ.copy()
|
|
)
|
|
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
data = json.loads(result.stdout.strip())
|
|
if 'latitude' in data and 'longitude' in data:
|
|
lat = data.get('latitude')
|
|
lon = data.get('longitude')
|
|
if lat and lon and (abs(lat) > 0.001 or abs(lon) > 0.001):
|
|
return data
|
|
|
|
# Fallback to network (1s timeout)
|
|
result = subprocess.run(
|
|
['termux-location', '-p', 'network', '-r', 'once'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=1,
|
|
env=os.environ.copy()
|
|
)
|
|
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
data = json.loads(result.stdout.strip())
|
|
if 'latitude' in data and 'longitude' in data:
|
|
lat = data.get('latitude')
|
|
lon = data.get('longitude')
|
|
if lat and lon and (abs(lat) > 0.001 or abs(lon) > 0.001):
|
|
return data
|
|
|
|
except Exception as e:
|
|
print(f"[GPS] Error: {e}")
|
|
|
|
return None
|
|
|
|
def gps_updater():
|
|
"""Background thread to continuously update GPS position"""
|
|
global current_gps
|
|
|
|
print(f"[GPS Updater] Started (interval: {GPS_UPDATE_INTERVAL}s)")
|
|
|
|
while True:
|
|
try:
|
|
gps_data = get_current_gps()
|
|
|
|
with gps_lock:
|
|
if gps_data:
|
|
current_gps = {
|
|
'latitude': gps_data.get('latitude'),
|
|
'longitude': gps_data.get('longitude'),
|
|
'accuracy': gps_data.get('accuracy', 0),
|
|
'speed': gps_data.get('speed', 0),
|
|
'altitude': gps_data.get('altitude', 0),
|
|
'bearing': gps_data.get('bearing', None),
|
|
'provider': gps_data.get('provider', 'unknown'),
|
|
'timestamp': datetime.now().isoformat(),
|
|
'available': True
|
|
}
|
|
else:
|
|
current_gps['available'] = False
|
|
|
|
except Exception as e:
|
|
print(f"[GPS Updater] Error: {e}")
|
|
|
|
time.sleep(GPS_UPDATE_INTERVAL)
|
|
|
|
def get_logged_points():
|
|
"""Get logged points from JSON file"""
|
|
try:
|
|
if os.path.exists(JSON_FILE):
|
|
with open(JSON_FILE, 'r') as f:
|
|
data = json.load(f)
|
|
return data.get('points', [])
|
|
except Exception as e:
|
|
print(f"[Points] Error reading JSON: {e}")
|
|
|
|
return []
|
|
|
|
# Enhanced HTML template with live GPS tracking
|
|
NAVIGATOR_TEMPLATE = '''<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Range Test Navigator - Live</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
font-family: Arial, sans-serif;
|
|
overflow: hidden;
|
|
}
|
|
#map {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 250px;
|
|
width: 100%;
|
|
}
|
|
#info {
|
|
position: absolute;
|
|
bottom: 0;
|
|
width: 100%;
|
|
height: 250px;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
padding: 15px;
|
|
box-sizing: border-box;
|
|
border-top: 3px solid #0078d4;
|
|
overflow-y: auto;
|
|
}
|
|
.gps-status {
|
|
background: #e8f5e9;
|
|
border-left: 4px solid #4caf50;
|
|
padding: 10px;
|
|
margin-bottom: 10px;
|
|
border-radius: 4px;
|
|
}
|
|
.gps-status.offline {
|
|
background: #ffebee;
|
|
border-left-color: #f44336;
|
|
}
|
|
.current-position {
|
|
font-weight: bold;
|
|
color: #2e7d32;
|
|
font-size: 16px;
|
|
margin-bottom: 5px;
|
|
}
|
|
.stat {
|
|
display: inline-block;
|
|
margin-right: 15px;
|
|
margin-bottom: 5px;
|
|
font-size: 13px;
|
|
}
|
|
.stat-label {
|
|
font-weight: bold;
|
|
color: #0078d4;
|
|
}
|
|
.pulse {
|
|
animation: pulse 2s infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
@keyframes pulse-smooth {
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
50% { opacity: 0.7; transform: scale(1.1); }
|
|
}
|
|
.legend {
|
|
background: white;
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
|
font-size: 12px;
|
|
}
|
|
.legend-item {
|
|
margin: 4px 0;
|
|
}
|
|
@media (max-width: 600px) {
|
|
#info { height: 300px; }
|
|
#map { bottom: 300px; }
|
|
.stat { display: block; margin: 3px 0; }
|
|
}
|
|
.orientation-btn {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
left: 20px;
|
|
z-index: 1000;
|
|
width: 50px;
|
|
height: 50px;
|
|
background: #ff9800;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 24px;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.3s;
|
|
}
|
|
.orientation-btn:hover {
|
|
background: #f57c00;
|
|
}
|
|
.orientation-btn.active {
|
|
background: #ff5722;
|
|
}
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
z-index: 2000;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: rgba(0,0,0,0.5);
|
|
}
|
|
.modal-content {
|
|
background-color: white;
|
|
margin: 15% auto;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
width: 90%;
|
|
max-width: 400px;
|
|
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
|
}
|
|
.modal-header {
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
margin-bottom: 15px;
|
|
color: #0078d4;
|
|
}
|
|
.modal-input {
|
|
width: 100%;
|
|
padding: 8px;
|
|
margin: 8px 0;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
box-sizing: border-box;
|
|
font-size: 14px;
|
|
}
|
|
.modal-label {
|
|
display: block;
|
|
margin-top: 10px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
}
|
|
.modal-buttons {
|
|
margin-top: 20px;
|
|
text-align: right;
|
|
}
|
|
.modal-btn {
|
|
padding: 8px 16px;
|
|
margin-left: 10px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
.modal-btn-primary {
|
|
background: #4caf50;
|
|
color: white;
|
|
}
|
|
.modal-btn-cancel {
|
|
background: #ddd;
|
|
color: #333;
|
|
}
|
|
.modal-btn-stop {
|
|
background: #f44336;
|
|
color: white;
|
|
}
|
|
</style>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="map">
|
|
<!-- Floating Orientation Button -->
|
|
<button id="orientationBtn" class="orientation-btn" onclick="toggleOrientation()" title="Toggle Map Orientation">
|
|
<i class="fas fa-compass"></i>
|
|
</button>
|
|
</div>
|
|
<div id="info">
|
|
<div id="gps-status" class="gps-status">
|
|
<div class="current-position">📍 Current Position: <span id="current-coords">Waiting for GPS...</span></div>
|
|
<div style="font-size: 12px; color: #666;">
|
|
<span id="gps-details"></span>
|
|
</div>
|
|
</div>
|
|
<div class="stat"><span class="stat-label">Logged Points:</span> <span id="points">0</span></div>
|
|
<div class="stat"><span class="stat-label">Distance:</span> <span id="distance">0 km</span></div>
|
|
<div class="stat"><span class="stat-label">Avg RSSI:</span> <span id="avgrssi">N/A</span></div>
|
|
<div class="stat"><span class="stat-label">Avg SNR:</span> <span id="avgsnr">N/A</span></div>
|
|
<div class="stat"><span class="stat-label">Last Update:</span> <span id="lastupdate" class="pulse">Live</span></div>
|
|
<div style="margin-top: 10px; border-top: 1px solid #ddd; padding-top: 10px;">
|
|
<button onclick="toggleFullscreen()" style="padding: 6px 12px; margin-right: 8px; background: #0078d4; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;">
|
|
<i class="fas fa-expand"></i> Fullscreen
|
|
</button>
|
|
<button onclick="exportMap()" style="padding: 6px 12px; margin-right: 8px; background: #4caf50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;">
|
|
<i class="fas fa-download"></i> Export
|
|
</button>
|
|
<button id="rangeTestBtn" onclick="openRangeTestModal()" style="padding: 6px 12px; background: #9c27b0; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;">
|
|
<i class="fas fa-broadcast-tower"></i> <span id="rangeTestText">Start Test</span>
|
|
</button>
|
|
<span id="export-status" style="margin-left: 10px; font-size: 12px; color: #666;"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Range Test Modal -->
|
|
<div id="rangeTestModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<i class="fas fa-broadcast-tower"></i> Configure Range Test
|
|
</div>
|
|
<label class="modal-label">Contact Index (#):</label>
|
|
<input type="number" id="contactIndex" class="modal-input" placeholder="e.g., 1" min="0" value="0">
|
|
|
|
<label class="modal-label">Number of Pings (N):</label>
|
|
<input type="number" id="pingCount" class="modal-input" placeholder="e.g., 10" min="1" value="10">
|
|
|
|
<label class="modal-label">Delay Between Pings (D) seconds:</label>
|
|
<input type="number" id="pingDelay" class="modal-input" placeholder="e.g., 5" min="1" value="5">
|
|
|
|
<div class="modal-buttons">
|
|
<button class="modal-btn modal-btn-cancel" onclick="closeRangeTestModal()">Cancel</button>
|
|
<button class="modal-btn modal-btn-primary" onclick="startRangeTest()">Start Test</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stop Range Test Modal -->
|
|
<div id="stopTestModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<i class="fas fa-stop-circle"></i> Stop Range Test
|
|
</div>
|
|
<p style="margin: 15px 0;">Are you sure you want to stop the range test?</p>
|
|
<div class="modal-buttons">
|
|
<button class="modal-btn modal-btn-cancel" onclick="closeStopTestModal()">Cancel</button>
|
|
<button class="modal-btn modal-btn-stop" onclick="stopRangeTest()">Stop Test</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
var map = L.map('map').setView([45.0, 7.0], 13);
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap | Range Test Navigator'
|
|
}).addTo(map);
|
|
|
|
var loggedMarkers = [];
|
|
var loggedPoints = [];
|
|
var polyline = null;
|
|
var currentMarker = null;
|
|
var currentCircle = null;
|
|
var rssiValues = [];
|
|
var snrValues = [];
|
|
var userSetZoom = false;
|
|
var currentZoom = 16;
|
|
var orientationEnabled = false;
|
|
var currentHeading = 0;
|
|
var rangeTestActive = false;
|
|
|
|
// Track user zoom changes
|
|
map.on('zoomend', function() {
|
|
userSetZoom = true;
|
|
currentZoom = map.getZoom();
|
|
});
|
|
|
|
// Current position marker (white crosshairs with blue outline, on top layer)
|
|
var currentIcon = L.divIcon({
|
|
className: 'current-marker',
|
|
html: '<i class="fa-solid fa-crosshairs" style="font-size: 32px; color: white; text-shadow: 0 0 3px #2196f3, 0 0 6px #2196f3, 0 0 10px #2196f3, 0 0 2px black; animation: pulse-smooth 3s infinite;"></i>',
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16]
|
|
});
|
|
|
|
function getSignalColor(rssi) {
|
|
if (rssi === null || rssi === undefined) return '#0078d4';
|
|
if (rssi >= -60) return '#00ff00';
|
|
if (rssi >= -70) return '#7fff00';
|
|
if (rssi >= -80) return '#ffff00';
|
|
if (rssi >= -90) return '#ffa500';
|
|
if (rssi >= -100) return '#ff6600';
|
|
return '#ff0000';
|
|
}
|
|
|
|
function createLoggedIcon(rssi, isStart, isEnd) {
|
|
if (isStart) {
|
|
return L.divIcon({
|
|
className: 'logged-marker',
|
|
html: '<div style="background-color: #00ff00; width: 14px; height: 14px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 6px rgba(0,0,0,0.6);"></div>',
|
|
iconSize: [14, 14],
|
|
iconAnchor: [7, 7]
|
|
});
|
|
}
|
|
if (isEnd) {
|
|
return L.divIcon({
|
|
className: 'logged-marker',
|
|
html: '<div style="background-color: #ff0000; width: 14px; height: 14px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 6px rgba(0,0,0,0.6);"></div>',
|
|
iconSize: [14, 14],
|
|
iconAnchor: [7, 7]
|
|
});
|
|
}
|
|
|
|
var color = getSignalColor(rssi);
|
|
return L.divIcon({
|
|
className: 'logged-marker',
|
|
html: '<div style="background-color: ' + color + '; width: 10px; height: 10px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 4px rgba(0,0,0,0.5);"></div>',
|
|
iconSize: [10, 10],
|
|
iconAnchor: [5, 5]
|
|
});
|
|
}
|
|
|
|
var legend = L.control({position: 'topright'});
|
|
legend.onAdd = function(map) {
|
|
var div = L.DomUtil.create('div', 'legend');
|
|
div.innerHTML = '<div style="font-weight: bold; margin-bottom: 5px;">🗺️ Live Navigator</div>' +
|
|
'<div class="legend-item"><i class="fa-solid fa-crosshairs" style="color: #2196f3;"></i> Current Position</div>' +
|
|
'<div class="legend-item">🟢 Start Point</div>' +
|
|
'<div class="legend-item">🔴 End Point</div>' +
|
|
'<div class="legend-item">━━ Path</div>' +
|
|
'<div style="margin-top: 8px; font-size: 11px; color: #666;">Auto-updates every 1s</div>';
|
|
return div;
|
|
};
|
|
legend.addTo(map);
|
|
|
|
function updateCurrentPosition(gps) {
|
|
if (!gps.available) {
|
|
document.getElementById('gps-status').className = 'gps-status offline';
|
|
document.getElementById('current-coords').textContent = 'GPS Unavailable';
|
|
document.getElementById('gps-details').textContent = '';
|
|
|
|
if (currentMarker) {
|
|
map.removeLayer(currentMarker);
|
|
currentMarker = null;
|
|
}
|
|
if (currentCircle) {
|
|
map.removeLayer(currentCircle);
|
|
currentCircle = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
document.getElementById('gps-status').className = 'gps-status';
|
|
document.getElementById('current-coords').textContent =
|
|
gps.latitude.toFixed(6) + ', ' + gps.longitude.toFixed(6);
|
|
document.getElementById('gps-details').textContent =
|
|
'Accuracy: ±' + gps.accuracy.toFixed(0) + 'm | ' +
|
|
'Speed: ' + gps.speed.toFixed(1) + ' km/h | ' +
|
|
'Provider: ' + gps.provider;
|
|
|
|
var latlng = [gps.latitude, gps.longitude];
|
|
|
|
// Update heading if available
|
|
if (gps.bearing !== null && gps.bearing !== undefined) {
|
|
currentHeading = gps.bearing;
|
|
}
|
|
|
|
// Note: Map rotation requires Leaflet.RotatedMarker plugin or similar
|
|
// For now, orientation just changes the crosshairs icon rotation
|
|
if (orientationEnabled && currentHeading !== null) {
|
|
// Rotate the arrow icon to show direction
|
|
var rotatedIcon = L.divIcon({
|
|
className: 'current-marker',
|
|
html: '<i class="fa-solid fa-circle-up" style="font-size: 32px; color: white; text-shadow: 0 0 3px #2196f3, 0 0 6px #2196f3, 0 0 10px #2196f3, 0 0 2px black; animation: pulse-smooth 3s infinite; transform: rotate(' + currentHeading + 'deg); transform-origin: center;"></i>',
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16]
|
|
});
|
|
if (currentMarker) {
|
|
currentMarker.setIcon(rotatedIcon);
|
|
}
|
|
} else if (!orientationEnabled && currentMarker) {
|
|
// Reset to crosshairs when orientation is disabled
|
|
currentMarker.setIcon(currentIcon);
|
|
}
|
|
|
|
// Update or create current position marker
|
|
if (currentMarker) {
|
|
currentMarker.setLatLng(latlng);
|
|
} else {
|
|
currentMarker = L.marker(latlng, {
|
|
icon: currentIcon,
|
|
zIndexOffset: 1000 // Put on top of all other markers
|
|
}).addTo(map);
|
|
currentMarker.bindPopup('<b><i class="fa-solid fa-crosshairs" style="color: #2196f3;"></i> Current Position</b><br>' +
|
|
'Live GPS tracking<br>' +
|
|
'Accuracy: ±' + gps.accuracy.toFixed(0) + 'm');
|
|
}
|
|
|
|
// Update or create accuracy circle (blue with white outline, on top)
|
|
if (currentCircle) {
|
|
currentCircle.setLatLng(latlng);
|
|
currentCircle.setRadius(gps.accuracy);
|
|
} else {
|
|
currentCircle = L.circle(latlng, {
|
|
radius: gps.accuracy,
|
|
color: 'white', // White outline
|
|
fillColor: '#2196f3', // Blue fill
|
|
fillOpacity: 0.15,
|
|
weight: 3,
|
|
opacity: 0.8,
|
|
className: 'accuracy-circle'
|
|
}).addTo(map);
|
|
currentCircle.bringToFront(); // Bring to front layer
|
|
}
|
|
|
|
// Center map on current position, preserving user's zoom level
|
|
if (!userSetZoom) {
|
|
// First time or no user interaction - set appropriate zoom
|
|
currentZoom = gps.accuracy > 100 ? 14 : gps.accuracy > 50 ? 15 : 16;
|
|
}
|
|
|
|
// Always center on current position, but keep user's zoom
|
|
map.setView(latlng, currentZoom, {animate: true, duration: 0.5});
|
|
}
|
|
|
|
function updateLoggedPoints(points) {
|
|
// Clear existing logged markers
|
|
loggedMarkers.forEach(m => map.removeLayer(m));
|
|
loggedMarkers = [];
|
|
loggedPoints = [];
|
|
rssiValues = [];
|
|
snrValues = [];
|
|
|
|
if (polyline) {
|
|
map.removeLayer(polyline);
|
|
polyline = null;
|
|
}
|
|
|
|
if (points.length === 0) return;
|
|
|
|
points.forEach((point, idx) => {
|
|
var latlng = [point.latitude, point.longitude];
|
|
loggedPoints.push(latlng);
|
|
|
|
if (point.rssi !== null) rssiValues.push(point.rssi);
|
|
if (point.snr !== null) snrValues.push(point.snr);
|
|
|
|
var isStart = (idx === 0);
|
|
var isEnd = (idx === points.length - 1);
|
|
var icon = createLoggedIcon(point.rssi, isStart, isEnd);
|
|
|
|
var marker = L.marker(latlng, {
|
|
icon: icon,
|
|
zIndexOffset: 0 // Logged points stay below current position
|
|
}).addTo(map);
|
|
|
|
var popup = '<b>Point #' + (idx + 1) + '</b><br>' +
|
|
'Time: ' + point.time + '<br>' +
|
|
'Lat: ' + point.latitude.toFixed(6) + '<br>' +
|
|
'Lon: ' + point.longitude.toFixed(6) + '<br>';
|
|
|
|
if (point.rssi !== null) {
|
|
popup += 'RSSI: ' + point.rssi.toFixed(1) + ' dBm<br>';
|
|
}
|
|
if (point.snr !== null) {
|
|
popup += 'SNR: ' + point.snr.toFixed(1) + ' dB<br>';
|
|
}
|
|
|
|
marker.bindPopup(popup);
|
|
loggedMarkers.push(marker);
|
|
});
|
|
|
|
// Draw path (below markers)
|
|
if (loggedPoints.length > 1) {
|
|
polyline = L.polyline(loggedPoints, {
|
|
color: 'red',
|
|
weight: 3,
|
|
opacity: 0.6
|
|
}).addTo(map);
|
|
}
|
|
|
|
// Bring current position elements to front
|
|
if (currentCircle) {
|
|
currentCircle.bringToFront();
|
|
}
|
|
if (currentMarker) {
|
|
currentMarker.bringToFront();
|
|
}
|
|
|
|
// Update stats
|
|
document.getElementById('points').textContent = points.length;
|
|
|
|
var avgRssi = rssiValues.length > 0 ?
|
|
rssiValues.reduce((a, b) => a + b, 0) / rssiValues.length : null;
|
|
var avgSnr = snrValues.length > 0 ?
|
|
snrValues.reduce((a, b) => a + b, 0) / snrValues.length : null;
|
|
|
|
document.getElementById('avgrssi').textContent =
|
|
avgRssi !== null ? avgRssi.toFixed(1) + ' dBm' : 'N/A';
|
|
document.getElementById('avgsnr').textContent =
|
|
avgSnr !== null ? avgSnr.toFixed(1) + ' dB' : 'N/A';
|
|
|
|
if (points.length > 0) {
|
|
document.getElementById('lastupdate').textContent =
|
|
points[points.length - 1].time;
|
|
}
|
|
}
|
|
|
|
// Fetch current GPS position
|
|
function fetchCurrentGPS() {
|
|
fetch('/api/current_gps')
|
|
.then(response => response.json())
|
|
.then(data => updateCurrentPosition(data))
|
|
.catch(err => console.error('GPS fetch error:', err));
|
|
}
|
|
|
|
// Fetch logged points
|
|
function fetchLoggedPoints() {
|
|
fetch('/api/logged_points')
|
|
.then(response => response.json())
|
|
.then(data => updateLoggedPoints(data.points))
|
|
.catch(err => console.error('Points fetch error:', err));
|
|
}
|
|
|
|
// Fullscreen toggle
|
|
function toggleFullscreen() {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement.requestFullscreen().catch(err => {
|
|
console.error('Fullscreen error:', err);
|
|
});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
|
|
// Orientation toggle
|
|
function toggleOrientation() {
|
|
orientationEnabled = !orientationEnabled;
|
|
const btnElement = document.getElementById('orientationBtn');
|
|
|
|
if (orientationEnabled) {
|
|
btnElement.classList.add('active');
|
|
// Icon will be updated on next GPS update to show direction
|
|
} else {
|
|
btnElement.classList.remove('active');
|
|
// Reset to crosshairs icon
|
|
if (currentMarker) {
|
|
currentMarker.setIcon(currentIcon);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Range Test Modal Functions
|
|
function openRangeTestModal() {
|
|
if (rangeTestActive) {
|
|
// Show stop confirmation
|
|
document.getElementById('stopTestModal').style.display = 'block';
|
|
} else {
|
|
// Show start configuration
|
|
document.getElementById('rangeTestModal').style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function closeRangeTestModal() {
|
|
document.getElementById('rangeTestModal').style.display = 'none';
|
|
}
|
|
|
|
function closeStopTestModal() {
|
|
document.getElementById('stopTestModal').style.display = 'none';
|
|
}
|
|
|
|
function startRangeTest() {
|
|
const contactIndex = document.getElementById('contactIndex').value;
|
|
const pingCount = document.getElementById('pingCount').value;
|
|
const pingDelay = document.getElementById('pingDelay').value;
|
|
|
|
if (!contactIndex || !pingCount || !pingDelay) {
|
|
alert('Please fill all fields');
|
|
return;
|
|
}
|
|
|
|
const statusEl = document.getElementById('export-status');
|
|
statusEl.textContent = 'Starting range test...';
|
|
statusEl.style.color = '#9c27b0';
|
|
|
|
fetch('/api/send_lxmf_command', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
contact_index: contactIndex,
|
|
command: 'rt',
|
|
ping_count: pingCount,
|
|
ping_delay: pingDelay
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
rangeTestActive = true;
|
|
document.getElementById('rangeTestText').textContent = 'Stop Test';
|
|
document.getElementById('rangeTestBtn').style.background = '#f44336';
|
|
statusEl.textContent = '✅ Range test started';
|
|
statusEl.style.color = '#4caf50';
|
|
closeRangeTestModal();
|
|
} else {
|
|
statusEl.textContent = '❌ ' + data.error;
|
|
statusEl.style.color = '#f44336';
|
|
}
|
|
setTimeout(() => { statusEl.textContent = ''; }, 5000);
|
|
})
|
|
.catch(err => {
|
|
statusEl.textContent = '❌ Command failed';
|
|
statusEl.style.color = '#f44336';
|
|
console.error('Command error:', err);
|
|
});
|
|
}
|
|
|
|
function stopRangeTest() {
|
|
const contactIndex = document.getElementById('contactIndex').value;
|
|
|
|
const statusEl = document.getElementById('export-status');
|
|
statusEl.textContent = 'Stopping range test...';
|
|
statusEl.style.color = '#f44336';
|
|
|
|
fetch('/api/send_lxmf_command', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
contact_index: contactIndex,
|
|
command: 'rs'
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
rangeTestActive = false;
|
|
document.getElementById('rangeTestText').textContent = 'Start Test';
|
|
document.getElementById('rangeTestBtn').style.background = '#9c27b0';
|
|
statusEl.textContent = '✅ Range test stopped';
|
|
statusEl.style.color = '#4caf50';
|
|
closeStopTestModal();
|
|
} else {
|
|
statusEl.textContent = '❌ ' + data.error;
|
|
statusEl.style.color = '#f44336';
|
|
}
|
|
setTimeout(() => { statusEl.textContent = ''; }, 5000);
|
|
})
|
|
.catch(err => {
|
|
statusEl.textContent = '❌ Stop command failed';
|
|
statusEl.style.color = '#f44336';
|
|
console.error('Command error:', err);
|
|
});
|
|
}
|
|
|
|
// Close modals when clicking outside
|
|
window.onclick = function(event) {
|
|
if (event.target.classList.contains('modal')) {
|
|
event.target.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Export map to /sdcard/Download
|
|
function toggleFullscreen() {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement.requestFullscreen().catch(err => {
|
|
console.error('Fullscreen error:', err);
|
|
});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
|
|
// Export map to /sdcard/Download
|
|
function exportMap() {
|
|
const statusEl = document.getElementById('export-status');
|
|
statusEl.textContent = 'Exporting...';
|
|
statusEl.style.color = '#0078d4';
|
|
|
|
fetch('/api/export_map', {
|
|
method: 'POST'
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
statusEl.textContent = '✅ ' + data.filename;
|
|
statusEl.style.color = '#4caf50';
|
|
setTimeout(() => {
|
|
statusEl.textContent = '';
|
|
}, 5000);
|
|
} else {
|
|
statusEl.textContent = '❌ ' + data.error;
|
|
statusEl.style.color = '#f44336';
|
|
}
|
|
})
|
|
.catch(err => {
|
|
statusEl.textContent = '❌ Export failed';
|
|
statusEl.style.color = '#f44336';
|
|
console.error('Export error:', err);
|
|
});
|
|
}
|
|
|
|
// Initial load
|
|
fetchCurrentGPS();
|
|
fetchLoggedPoints();
|
|
|
|
// Auto-refresh
|
|
setInterval(fetchCurrentGPS, {{ gps_interval }} * 1000);
|
|
setInterval(fetchLoggedPoints, {{ html_interval }} * 1000);
|
|
</script>
|
|
</body>
|
|
</html>'''
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""Serve the live navigator page"""
|
|
return render_template_string(
|
|
NAVIGATOR_TEMPLATE,
|
|
gps_interval=GPS_UPDATE_INTERVAL,
|
|
html_interval=HTML_REFRESH_INTERVAL
|
|
)
|
|
|
|
@app.route('/api/current_gps')
|
|
def api_current_gps():
|
|
"""API endpoint for current GPS position"""
|
|
with gps_lock:
|
|
return jsonify(current_gps)
|
|
|
|
@app.route('/api/logged_points')
|
|
def api_logged_points():
|
|
"""API endpoint for logged points"""
|
|
points = get_logged_points()
|
|
return jsonify({'points': points})
|
|
|
|
@app.route('/map')
|
|
def static_map():
|
|
"""Serve the original static HTML map"""
|
|
if os.path.exists(HTML_FILE):
|
|
return send_file(HTML_FILE)
|
|
else:
|
|
return "Range test map not found. Start logging points first!", 404
|
|
|
|
@app.route('/api/export_map', methods=['POST'])
|
|
def export_map():
|
|
"""Export current map HTML to /sdcard/Download with timestamp"""
|
|
try:
|
|
if not is_termux():
|
|
return jsonify({'success': False, 'error': 'Export only works on Termux/Android'})
|
|
|
|
download_dir = '/sdcard/Download'
|
|
|
|
if not os.path.exists(download_dir):
|
|
return jsonify({'success': False, 'error': '/sdcard/Download not found'})
|
|
|
|
# Generate timestamp filename
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
filename = f'rangetest_{timestamp}.html'
|
|
dest_path = os.path.join(download_dir, filename)
|
|
|
|
# Check if source HTML exists
|
|
if not os.path.exists(HTML_FILE):
|
|
return jsonify({'success': False, 'error': 'No range test data to export'})
|
|
|
|
# Copy HTML file to Download folder
|
|
import shutil
|
|
shutil.copy(HTML_FILE, dest_path)
|
|
|
|
return jsonify({'success': True, 'filename': filename, 'path': dest_path})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
@app.route('/api/send_lxmf_command', methods=['POST'])
|
|
def send_lxmf_command():
|
|
"""Send command to LXMF-CLI via command file"""
|
|
try:
|
|
data = request.get_json()
|
|
contact_index = data.get('contact_index')
|
|
command = data.get('command')
|
|
|
|
if not contact_index or not command:
|
|
return jsonify({'success': False, 'error': 'Missing required fields'})
|
|
|
|
# Build message based on command
|
|
if command == 'rt':
|
|
# Start range test: s # rt N D
|
|
ping_count = data.get('ping_count', 10)
|
|
ping_delay = data.get('ping_delay', 5)
|
|
message = f"s {contact_index} rt {ping_count} {ping_delay}"
|
|
elif command == 'rs':
|
|
# Stop range test: s # rs
|
|
message = f"s {contact_index} rs"
|
|
else:
|
|
return jsonify({'success': False, 'error': 'Unknown command'})
|
|
|
|
# Write command to file as a ONE-TIME TRIGGER
|
|
command_file = os.path.join(STORAGE_DIR, 'rtcom_command.txt')
|
|
|
|
# Write command
|
|
with open(command_file, 'w') as f:
|
|
f.write(message + '\n')
|
|
|
|
# Flush to ensure file is written
|
|
import time
|
|
time.sleep(0.1)
|
|
|
|
# Note: The bridge plugin will clear the file after reading it
|
|
# This ensures the command is only executed once
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': message,
|
|
'note': 'Command sent to LXMF-CLI via rtcom_bridge plugin'
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
def print_banner():
|
|
"""Print startup banner"""
|
|
print("\n" + "="*70)
|
|
print("🗺️ rtcom - Range Test Termux Companion App WebUI")
|
|
print("="*70)
|
|
print(f"📡 Listening on: http://{HOST}:{PORT}")
|
|
print(f"🌐 Local access: http://localhost:{PORT}")
|
|
|
|
# Try to get local IP
|
|
try:
|
|
import socket
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.connect(("8.8.8.8", 80))
|
|
local_ip = s.getsockname()[0]
|
|
s.close()
|
|
print(f"📱 LAN access: http://{local_ip}:{PORT}")
|
|
except:
|
|
pass
|
|
|
|
print(f"\n📂 Data directory: {STORAGE_DIR}")
|
|
print(f"🔄 GPS updates: every {GPS_UPDATE_INTERVAL}s")
|
|
print(f"🔄 Map refresh: every {HTML_REFRESH_INTERVAL}s")
|
|
|
|
if is_termux():
|
|
print(f"\n✅ Termux detected - GPS tracking enabled")
|
|
else:
|
|
print(f"\n⚠️ Not on Termux - GPS tracking disabled")
|
|
|
|
print(f"\n📍 Endpoints:")
|
|
print(f" / Live navigator with current position")
|
|
print(f" /map Original static map")
|
|
print(f" /api/current_gps Current GPS JSON")
|
|
print(f" /api/logged_points Logged points JSON")
|
|
print(f" /api/export_map Export map to /sdcard/Download (POST)")
|
|
|
|
print("\n💡 Press Ctrl+C to stop")
|
|
print("="*70 + "\n")
|
|
|
|
def main():
|
|
"""Main entry point"""
|
|
print_banner()
|
|
|
|
# Check if storage directory exists
|
|
if not os.path.exists(STORAGE_DIR):
|
|
print(f"⚠️ Warning: Storage directory not found: {STORAGE_DIR}")
|
|
print(f" Make sure LXMF-CLI is running from the correct directory")
|
|
print(f" Expected: /data/data/com.termux/files/home/lxmf-cli/")
|
|
|
|
# Start GPS updater thread (only on Termux)
|
|
if is_termux():
|
|
gps_thread = Thread(target=gps_updater, daemon=True)
|
|
gps_thread.start()
|
|
else:
|
|
print("[Info] GPS tracking disabled (not on Termux)")
|
|
|
|
# Start Flask server
|
|
try:
|
|
app.run(host=HOST, port=PORT, debug=False, threaded=True)
|
|
except KeyboardInterrupt:
|
|
print("\n\n🛑 Server stopped by user")
|
|
except Exception as e:
|
|
print(f"\n❌ Server error: {e}")
|
|
sys.exit(1)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|