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