added wardrive coverage toggle

This commit is contained in:
chrisdavis2110 2026-01-10 13:32:45 -08:00
parent 079d9946e1
commit d3ca194894
7 changed files with 287 additions and 6 deletions

View file

@ -2,7 +2,7 @@
Live MeshCore traffic map that renders nodes, routes, and activity in real time on a Leaflet map. The backend subscribes to MQTT over WebSockets + TLS, decodes MeshCore packets with `@michaelhart/meshcore-decoder`, and streams updates to the browser via WebSockets.
Live example sites:
Live example sites:
- https://live.bostonme.sh/ - Greater Boston Mesh Map
- https://map.eastmesh.au/ - Aus Eastern Mesh Live Map
- https://mesh-map.e-l33t.org/ - NSW Mesh - Live Mesh Traffic Map
@ -92,7 +92,10 @@ MQTT:
- `MQTT_TRANSPORT` (`websockets`)
- `MQTT_WS_PATH` (usually `/` or `/mqtt`)
- `MQTT_TLS` (`true`)
- `MQTT_TOPIC` (e.g. `meshcore/#`)
- `MQTT_TOPIC` (e.g. `meshcore/#` or `meshcore/#,other/topic/+` for multiple topics)
Coverage layer:
- `COVERAGE_API_URL` (URL to coverage map API, e.g. `http://localhost:3000` or `https://coverage.example.com`)
Device + route tuning:
- `DEVICE_TTL_SECONDS` (node expiry)

View file

@ -7,6 +7,7 @@ from datetime import datetime, timezone
from dataclasses import asdict
from typing import Any, Dict, Optional, Set, List
import httpx
import paho.mqtt.client as mqtt
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
@ -52,6 +53,7 @@ from config import (
MQTT_USERNAME,
MQTT_PASSWORD,
MQTT_TOPIC,
MQTT_TOPICS,
MQTT_TLS,
MQTT_TLS_INSECURE,
MQTT_CA_CERT,
@ -112,6 +114,7 @@ from config import (
LOS_SAMPLE_STEP_METERS,
ELEVATION_CACHE_TTL,
LOS_PEAKS_MAX,
COVERAGE_API_URL,
APP_DIR,
NODE_SCRIPT_PATH,
)
@ -484,8 +487,10 @@ async def _state_saver() -> None:
def mqtt_on_connect(client, userdata, flags, reason_code, properties=None):
print(f"[mqtt] connected reason_code={reason_code} subscribing topic={MQTT_TOPIC}")
client.subscribe(MQTT_TOPIC, qos=0)
topics_str = ", ".join(MQTT_TOPICS)
print(f"[mqtt] connected reason_code={reason_code} subscribing topics={topics_str}")
for topic in MQTT_TOPICS:
client.subscribe(topic, qos=0)
def mqtt_on_disconnect(client, userdata, reason_code, properties=None, *args, **kwargs):
@ -1278,6 +1283,33 @@ def line_of_sight(lat1: float, lon1: float, lat2: float, lon2: float, profile: b
return response
@app.get("/coverage")
async def get_coverage():
if not COVERAGE_API_URL:
raise HTTPException(status_code=503, detail="coverage_api_not_configured: Set COVERAGE_API_URL in .env (e.g., http://localhost:3000)")
try:
url = f"{COVERAGE_API_URL}/get-samples"
print(f"[coverage] Fetching from {url}")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
# /get-samples returns { keys: [...] }, extract the keys array
samples = data.get("keys", []) if isinstance(data, dict) else (data if isinstance(data, list) else [])
print(f"[coverage] Received {len(samples) if isinstance(samples, list) else 'non-list'} items from coverage API")
if isinstance(samples, list) and len(samples) > 0:
print(f"[coverage] Sample item keys: {list(samples[0].keys()) if samples[0] else 'N/A'}")
return samples
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="coverage_api_timeout")
except httpx.HTTPStatusError as e:
raise HTTPException(status_code=502, detail=f"coverage_api_error: HTTP {e.response.status_code} from {COVERAGE_API_URL}")
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"coverage_api_error: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"coverage_fetch_error: {str(e)}")
@app.get("/debug/last")
def debug_last_entries():
if PROD_MODE:
@ -1344,8 +1376,9 @@ async def startup():
loop = asyncio.get_event_loop()
transport = "websockets" if MQTT_TRANSPORT == "websockets" else "tcp"
topics_str = ", ".join(MQTT_TOPICS)
print(
f"[mqtt] connecting host={MQTT_HOST} port={MQTT_PORT} tls={MQTT_TLS} transport={transport} ws_path={MQTT_WS_PATH if transport=='websockets' else '-'} topic={MQTT_TOPIC}"
f"[mqtt] connecting host={MQTT_HOST} port={MQTT_PORT} tls={MQTT_TLS} transport={transport} ws_path={MQTT_WS_PATH if transport=='websockets' else '-'} topics={topics_str}"
)
mqtt_client = mqtt.Client(

View file

@ -9,6 +9,7 @@ MQTT_USERNAME = os.getenv("MQTT_USERNAME", "")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "")
MQTT_TOPIC = os.getenv("MQTT_TOPIC", "meshcore/#")
MQTT_TOPICS = [t.strip() for t in MQTT_TOPIC.split(",") if t.strip()]
MQTT_TLS = os.getenv("MQTT_TLS", "false").lower() == "true"
MQTT_TLS_INSECURE = os.getenv("MQTT_TLS_INSECURE", "false").lower() == "true"
@ -111,5 +112,7 @@ LOS_SAMPLE_STEP_METERS = int(os.getenv("LOS_SAMPLE_STEP_METERS", "250"))
ELEVATION_CACHE_TTL = int(os.getenv("ELEVATION_CACHE_TTL", "21600"))
LOS_PEAKS_MAX = int(os.getenv("LOS_PEAKS_MAX", "4"))
COVERAGE_API_URL = os.getenv("COVERAGE_API_URL", "").strip()
APP_DIR = os.path.dirname(os.path.abspath(__file__))
NODE_SCRIPT_PATH = os.path.join(APP_DIR, "meshcore_decode.mjs")

View file

@ -1,3 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
paho-mqtt==2.1.0
httpx==0.27.2

View file

@ -139,6 +139,9 @@
const heatPoints = [];
const HEAT_TTL_MS = 10 * 60 * 1000;
const losLayer = L.layerGroup().addTo(map);
const coverageLayer = L.layerGroup();
let coverageVisible = false;
let coverageData = null;
let losActive = false;
let losPoints = [];
let losLine = null;
@ -346,6 +349,222 @@
return `${minutes} min`;
}
// Geohash decoder (simple implementation)
function geohashDecode(geohash) {
const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';
const BASE32_DICT = {};
for (let i = 0; i < BASE32.length; i++) {
BASE32_DICT[BASE32[i]] = i;
}
let even = true;
let lat = [-90.0, 90.0];
let lon = [-180.0, 180.0];
let lat_err = 90.0;
let lon_err = 180.0;
for (let i = 0; i < geohash.length; i++) {
const c = geohash[i];
const cd = BASE32_DICT[c];
for (let j = 0; j < 5; j++) {
if (even) {
lon_err /= 2;
if ((cd & (16 >> j)) > 0) {
lon[0] = (lon[0] + lon[1]) / 2;
} else {
lon[1] = (lon[0] + lon[1]) / 2;
}
} else {
lat_err /= 2;
if ((cd & (16 >> j)) > 0) {
lat[0] = (lat[0] + lat[1]) / 2;
} else {
lat[1] = (lat[0] + lat[1]) / 2;
}
}
even = !even;
}
}
return {
latitude: (lat[0] + lat[1]) / 2,
longitude: (lon[0] + lon[1]) / 2,
error: { latitude: lat_err, longitude: lon_err }
};
}
function geohashDecodeBbox(geohash) {
const decoded = geohashDecode(geohash);
const latErr = decoded.error.latitude;
const lonErr = decoded.error.longitude;
return [
decoded.latitude - latErr,
decoded.longitude - lonErr,
decoded.latitude + latErr,
decoded.longitude + lonErr
];
}
function successRateToColor(rate) {
const clampedRate = Math.max(0, Math.min(1, rate));
let red, green, blue;
if (clampedRate >= 0.8) {
const t = (clampedRate - 0.8) / 0.2;
red = Math.round(0 + (50 - 0) * t);
green = Math.round(100 + (150 - 100) * t);
blue = Math.round(0 + (50 - 0) * t);
} else if (clampedRate >= 0.6) {
const t = (clampedRate - 0.6) / 0.2;
red = Math.round(50 + (255 - 50) * t);
green = Math.round(150 + (165 - 150) * t);
blue = Math.round(50 - 50 * t);
} else if (clampedRate >= 0.4) {
const t = (clampedRate - 0.4) / 0.2;
red = 255;
green = Math.round(165 + (100 - 165) * t);
blue = 0;
} else if (clampedRate >= 0.2) {
const t = (clampedRate - 0.2) / 0.2;
red = 255;
green = Math.round(100 - 100 * t);
blue = 0;
} else {
red = 255;
green = 0;
blue = 0;
}
const toHex = (n) => {
const hex = n.toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`;
}
async function fetchCoverageData() {
try {
const response = await fetch(withToken('/coverage'));
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const data = await response.json();
return data;
} catch (err) {
const errorMsg = err && err.message ? err.message : String(err);
reportError(`Failed to fetch coverage data: ${errorMsg}`);
return null;
}
}
function renderCoverage(data) {
coverageLayer.clearLayers();
if (!data || !Array.isArray(data)) {
return;
}
// Aggregate samples by 6-char geohash prefix (coverage tile level)
const tileMap = new Map(); // 6-char prefix -> { heard: count, lost: count, samples: [...] }
for (const sample of data) {
const hash = sample.hash || sample.name || sample.id;
if (!hash) continue;
const tileHash = hash.substring(0, 6); // Use 6-char prefix for coverage tiles
if (!tileMap.has(tileHash)) {
tileMap.set(tileHash, { heard: 0, lost: 0, samples: [], latestTime: 0, snr: null, rssi: null, paths: new Set() });
}
const tile = tileMap.get(tileHash);
const observed = sample.observed !== undefined ? sample.observed : (sample.metadata?.observed !== undefined ? sample.metadata.observed : ((sample.path || sample.metadata?.path || []).length > 0));
if (observed) {
tile.heard++;
} else {
tile.lost++;
}
tile.samples.push(sample);
const time = sample.time || sample.metadata?.time || 0;
// Convert to number if it's a string, and handle milliseconds vs seconds
let timeValue = typeof time === 'string' ? parseInt(time, 10) : (typeof time === 'number' ? time : 0);
// If time looks like seconds (less than year 2000 in milliseconds), convert to milliseconds
if (timeValue > 0 && timeValue < 946684800000) {
timeValue = timeValue * 1000;
}
if (timeValue > tile.latestTime) {
tile.latestTime = timeValue;
tile.snr = sample.snr !== null && sample.snr !== undefined ? sample.snr : (sample.metadata?.snr !== null && sample.metadata?.snr !== undefined ? sample.metadata.snr : tile.snr);
tile.rssi = sample.rssi !== null && sample.rssi !== undefined ? sample.rssi : (sample.metadata?.rssi !== null && sample.metadata?.rssi !== undefined ? sample.metadata.rssi : tile.rssi);
}
const path = sample.path || sample.metadata?.path || [];
path.forEach(p => tile.paths.add(p));
}
let rendered = 0;
for (const [tileHash, tile] of tileMap.entries()) {
try {
const [minLat, minLon, maxLat, maxLon] = geohashDecodeBbox(tileHash);
const totalSamples = tile.heard + tile.lost;
if (totalSamples === 0) continue;
const heardRatio = totalSamples > 0 ? tile.heard / totalSamples : 0;
const color = successRateToColor(heardRatio);
const baseOpacity = 0.75 * Math.min(1, totalSamples / 10);
const opacity = heardRatio > 0 ? baseOpacity * heardRatio : Math.max(baseOpacity, 0.4);
const rect = L.rectangle([[minLat, minLon], [maxLat, maxLon]], {
color: color,
weight: 1,
fillOpacity: Math.max(opacity, 0.2),
fillColor: color
});
let details = `Heard: ${tile.heard} Lost: ${tile.lost} (${(100 * heardRatio).toFixed(0)}%)`;
if (tile.paths.size > 0) {
const repeaters = Array.from(tile.paths).slice(0, 5).map(r => r.toUpperCase());
details += `<br/>Repeaters: ${repeaters.join(', ')}${tile.paths.size > 5 ? '...' : ''}`;
}
if (tile.snr !== null && tile.snr !== undefined) {
details += `<br/>SNR: ${tile.snr} dB`;
}
if (tile.rssi !== null && tile.rssi !== undefined) {
details += `<br/>RSSI: ${tile.rssi} dBm`;
}
rect.bindPopup(details, { maxWidth: 320 });
coverageLayer.addLayer(rect);
rendered++;
} catch (err) {
// Silently skip invalid tiles
}
}
}
function setCoverageVisible(visible) {
coverageVisible = visible;
const btn = document.getElementById('coverage-toggle');
if (btn) {
btn.classList.toggle('active', visible);
btn.textContent = visible ? 'Hide coverage' : 'Coverage';
}
if (!nodesVisible) {
if (coverageLayer && map.hasLayer(coverageLayer)) {
map.removeLayer(coverageLayer);
}
return;
}
if (visible) {
if (!map.hasLayer(coverageLayer)) {
coverageLayer.addTo(map);
}
if (!coverageData) {
fetchCoverageData().then(data => {
if (data && Array.isArray(data)) {
coverageData = data;
if (data.length === 0) {
reportError('Coverage database appears to be empty. Add coverage data to your coverage map server.');
}
renderCoverage(data);
} else {
reportError('Coverage API returned invalid data format');
}
});
} else {
renderCoverage(coverageData);
}
} else {
if (map.hasLayer(coverageLayer)) {
map.removeLayer(coverageLayer);
}
}
}
function updateNodeSizeUi() {
if (nodeSizeInput) {
nodeSizeInput.value = String(nodeMarkerRadius);
@ -390,6 +609,9 @@
if (heatVisible && heatLayer && !map.hasLayer(heatLayer)) {
heatLayer.addTo(map);
}
if (coverageVisible && !map.hasLayer(coverageLayer)) {
coverageLayer.addTo(map);
}
} else if (map.hasLayer(markerLayer)) {
map.removeLayer(markerLayer);
if (map.hasLayer(trailLayer)) {
@ -405,6 +627,9 @@
if (heatLayer && map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
}
if (coverageLayer && map.hasLayer(coverageLayer)) {
map.removeLayer(coverageLayer);
}
} else if (map.hasLayer(trailLayer)) {
map.removeLayer(trailLayer);
} else {
@ -3362,6 +3587,21 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
});
}
const coverageToggle = document.getElementById('coverage-toggle');
if (coverageToggle) {
const storedCoverageVisible = localStorage.getItem('meshmapShowCoverage');
let initialCoverage = storedCoverageVisible !== null ? storedCoverageVisible === 'true' : false;
setCoverageVisible(initialCoverage);
coverageToggle.addEventListener('click', () => {
try {
setCoverageVisible(!coverageVisible);
localStorage.setItem('meshmapShowCoverage', coverageVisible ? 'true' : 'false');
} catch (err) {
reportError(`Coverage toggle failed: ${err && err.message ? err.message : err}`);
}
});
}
const propToggle = document.getElementById('prop-toggle');
const propTxInput = document.getElementById('prop-txpower');
const propOpacityInput = document.getElementById('prop-opacity');
@ -3638,4 +3878,3 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
setPropagationOrigin(ev.latlng);
}
});

View file

@ -89,6 +89,7 @@
<button class="map-toggle" id="labels-toggle" type="button">Labels Off</button>
<button class="map-toggle" id="nodes-toggle" type="button">Hide nodes</button>
<button class="map-toggle" id="heat-toggle" type="button">Hide heat</button>
<button class="map-toggle" id="coverage-toggle" type="button">Coverage</button>
<button class="map-toggle" id="history-toggle" type="button">History tool</button>
<button class="map-toggle" id="los-toggle" type="button">LOS tool</button>
<button class="map-toggle" id="prop-toggle" type="button">Propagation</button>

View file

@ -40,6 +40,7 @@ services:
MAP_RADIUS_KM: "${MAP_RADIUS_KM:-0}"
MAP_RADIUS_SHOW: "${MAP_RADIUS_SHOW:-false}"
MAP_DEFAULT_LAYER: "${MAP_DEFAULT_LAYER:-light}"
COVERAGE_API_URL: "${COVERAGE_API_URL:-}"
restart: unless-stopped
volumes:
- ./data:/data