diff --git a/README.md b/README.md
index 7d01d85..9e67084 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/backend/app.py b/backend/app.py
index 7aad00a..b8ca669 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -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(
diff --git a/backend/config.py b/backend/config.py
index 74812b1..cdfe556 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -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")
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 223627a..07fe508 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,3 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
paho-mqtt==2.1.0
+httpx==0.27.2
\ No newline at end of file
diff --git a/backend/static/app.js b/backend/static/app.js
index d04df1d..294470f 100644
--- a/backend/static/app.js
+++ b/backend/static/app.js
@@ -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 += `
Repeaters: ${repeaters.join(', ')}${tile.paths.size > 5 ? '...' : ''}`;
+ }
+ if (tile.snr !== null && tile.snr !== undefined) {
+ details += `
SNR: ${tile.snr} dB`;
+ }
+ if (tile.rssi !== null && tile.rssi !== undefined) {
+ details += `
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) {
});
}
+ 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) {
setPropagationOrigin(ev.latlng);
}
});
-
diff --git a/backend/static/index.html b/backend/static/index.html
index eeb21a8..b75a6f4 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -89,6 +89,7 @@
+
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 65679d1..eec3b72 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -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