Add basic coverege support.

This commit is contained in:
pablorevilla-meshtastic
2026-02-09 21:17:22 -08:00
parent 29da1487d4
commit 9622092c17
8 changed files with 340 additions and 5 deletions

33
docs/COVERAGE.md Normal file
View File

@@ -0,0 +1,33 @@
# Coverage Prediction
Meshview can display a predicted coverage boundary for a node. This is a **model**
estimate, not a guarantee of real-world performance.
## How it works
The coverage boundary is computed using the Longley-Rice / ITM **area mode**
propagation model. Area mode estimates average path loss over generic terrain
and does not use a terrain profile. This means it captures general distance
effects, but **does not** account for terrain shadows, buildings, or foliage.
## What you are seeing
The UI draws a **perimeter** (not a heatmap) that represents the furthest
distance where predicted signal strength is above a threshold (default
`-120 dBm`). The model is run radially from the node in multiple directions,
and the last point above the threshold forms the outline.
## Key parameters
- **Frequency**: default `907 MHz`
- **Transmit power**: default `20 dBm`
- **Antenna heights**: default `5 m` (TX) and `1.5 m` (RX)
- **Reliability**: default `0.5` (median)
- **Terrain irregularity**: default `90 m` (average terrain)
## Limitations
- No terrain or building data is used (area mode only).
- Results are sensitive to power, height, and threshold.
- Environmental factors can cause large real-world deviations.

View File

@@ -189,6 +189,9 @@
"times_seen": "Times seen",
"copy_import_url": "Copy Import URL",
"show_qr_code": "Show QR Code",
"toggle_coverage": "Toggle Coverage",
"location_required": "Location required for coverage",
"coverage_help": "Coverage Help",
"share_contact_qr": "Share Contact QR",
"copy_url": "Copy URL",
"copied": "Copied!",

View File

@@ -175,6 +175,9 @@
"times_seen": "Veces visto",
"copy_import_url": "Copiar URL de importación",
"show_qr_code": "Mostrar código QR",
"toggle_coverage": "Alternar cobertura",
"location_required": "Se requiere ubicación para la cobertura",
"coverage_help": "Ayuda de cobertura",
"share_contact_qr": "Compartir contacto QR",
"copy_url": "Copiar URL",
"copied": "¡Copiado!",

136
meshview/radio/coverage.py Normal file
View File

@@ -0,0 +1,136 @@
import math
from functools import lru_cache
from pyitm import itm
DEFAULT_CLIMATE = 5 # Continental temperate
DEFAULT_GROUND = 0.005 # Average ground conductivity
DEFAULT_EPS_DIELECT = 15.0
DEFAULT_DELTA_H = 90.0
DEFAULT_RELIABILITY = 0.5
DEFAULT_MIN_DBM = -130.0
DEFAULT_MAX_DBM = -80.0
DEFAULT_THRESHOLD_DBM = -120.0
EARTH_RADIUS_KM = 6371.0
BEARING_STEP_DEG = 5
def destination_point(
lat: float, lon: float, bearing_deg: float, distance_km: float
) -> tuple[float, float]:
lat1 = math.radians(lat)
lon1 = math.radians(lon)
bearing = math.radians(bearing_deg)
d = distance_km / EARTH_RADIUS_KM
lat2 = math.asin(
math.sin(lat1) * math.cos(d) + math.cos(lat1) * math.sin(d) * math.cos(bearing)
)
lon2 = lon1 + math.atan2(
math.sin(bearing) * math.sin(d) * math.cos(lat1),
math.cos(d) - math.sin(lat1) * math.sin(lat2),
)
return math.degrees(lat2), math.degrees(lon2)
@lru_cache(maxsize=512)
def compute_coverage(
lat: float,
lon: float,
freq_mhz: float,
tx_dbm: float,
tx_height_m: float,
rx_height_m: float,
radius_km: float,
step_km: float,
reliability: float,
) -> list[tuple[float, float, float]]:
points = []
distance = max(step_km, 1.0)
while distance <= radius_km:
for bearing in range(0, 360, BEARING_STEP_DEG):
rx_lat, rx_lon = destination_point(lat, lon, bearing, distance)
try:
loss_db, _ = itm.area(
ModVar=2, # Mobile: pctTime=reliability, pctConf=confidence
deltaH=DEFAULT_DELTA_H,
tht_m=tx_height_m,
rht_m=rx_height_m,
dist_km=distance,
TSiteCriteria=0,
RSiteCriteria=0,
eps_dielect=DEFAULT_EPS_DIELECT,
sgm_conductivity=DEFAULT_GROUND,
eno_ns_surfref=301,
frq_mhz=freq_mhz,
radio_climate=DEFAULT_CLIMATE,
pol=1,
pctTime=reliability,
pctLoc=0.5,
pctConf=0.5,
)
rx_dbm = tx_dbm - loss_db
points.append((rx_lat, rx_lon, rx_dbm))
except itm.InputError:
continue
distance += step_km
return points
@lru_cache(maxsize=512)
def compute_perimeter(
lat: float,
lon: float,
freq_mhz: float,
tx_dbm: float,
tx_height_m: float,
rx_height_m: float,
radius_km: float,
step_km: float,
reliability: float,
threshold_dbm: float,
) -> list[tuple[float, float]]:
perimeter = []
distance = max(step_km, 1.0)
for bearing in range(0, 360, BEARING_STEP_DEG):
last_point = None
last_rx_dbm = None
dist = distance
while dist <= radius_km:
try:
loss_db, _ = itm.area(
ModVar=2,
deltaH=DEFAULT_DELTA_H,
tht_m=tx_height_m,
rht_m=rx_height_m,
dist_km=dist,
TSiteCriteria=0,
RSiteCriteria=0,
eps_dielect=DEFAULT_EPS_DIELECT,
sgm_conductivity=DEFAULT_GROUND,
eno_ns_surfref=301,
frq_mhz=freq_mhz,
radio_climate=DEFAULT_CLIMATE,
pol=1,
pctTime=reliability,
pctLoc=0.5,
pctConf=0.5,
)
except itm.InputError:
dist += step_km
continue
rx_dbm = tx_dbm - loss_db
if rx_dbm >= threshold_dbm:
last_point = destination_point(lat, lon, bearing, dist)
last_rx_dbm = rx_dbm
dist += step_km
if last_point and last_rx_dbm is not None:
perimeter.append(last_point)
return perimeter

View File

@@ -338,6 +338,12 @@
<button onclick="showQrCode()" id="showQrBtn">
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
</button>
<button onclick="toggleCoverage()" id="toggleCoverageBtn" disabled title="Location required for coverage">
<span>📡</span> <span data-translate-lang="toggle_coverage">Toggle Coverage</span>
</button>
<a class="inline-link" id="coverageHelpLink" href="/docs/COVERAGE.md" target="_blank" rel="noopener" data-translate-lang="coverage_help">
Coverage Help
</a>
</div>
<!-- Impersonation Warning -->
@@ -517,6 +523,7 @@
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="https://unpkg.com/leaflet.heat/dist/leaflet-heat.js"></script>
<script src="/static/portmaps.js"></script>
<script>
@@ -635,6 +642,7 @@ let currentNode = null;
let currentPacketRows = [];
let map, markers = {};
let coverageLayer = null;
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
let fromNodeId = new URLSearchParams(window.location.search).get("from_node_id");
@@ -708,6 +716,20 @@ async function loadNodeInfo(){
node.last_lat ? (node.last_lat / 1e7).toFixed(6) : "—";
document.getElementById("info-lon").textContent =
node.last_long ? (node.last_long / 1e7).toFixed(6) : "—";
const coverageBtn = document.getElementById("toggleCoverageBtn");
const coverageHelp = document.getElementById("coverageHelpLink");
if (coverageBtn) {
const hasLocation = Boolean(node.last_lat && node.last_long);
coverageBtn.disabled = !hasLocation;
coverageBtn.title = hasLocation
? ""
: (nodeTranslations.location_required || "Location required for coverage");
coverageBtn.style.display = hasLocation ? "" : "none";
}
if (coverageHelp) {
const hasLocation = Boolean(node.last_lat && node.last_long);
coverageHelp.style.display = hasLocation ? "" : "none";
}
let lastSeen = "—";
if (node.last_seen_us) {
@@ -799,6 +821,43 @@ function initMap(){
}).addTo(map);
}
async function toggleCoverage() {
if (!map) initMap();
if (coverageLayer) {
map.removeLayer(coverageLayer);
coverageLayer = null;
return;
}
const nodeId = currentNode?.node_id || fromNodeId;
if (!nodeId) return;
try {
const res = await fetch(`/api/coverage/${encodeURIComponent(nodeId)}?mode=perimeter`);
if (!res.ok) {
console.error("Coverage request failed", res.status);
return;
}
const data = await res.json();
if (!data.perimeter || data.perimeter.length < 3) {
console.warn("Coverage perimeter missing or too small");
return;
}
coverageLayer = L.polygon(data.perimeter, {
color: "#6f42c1",
weight: 2,
opacity: 0.7,
fillColor: "#000000",
fillOpacity: 0.10
}).addTo(map);
map.fitBounds(coverageLayer.getBounds(), { padding: [20, 20] });
map.invalidateSize();
} catch (err) {
console.error("Coverage request failed", err);
}
}
function hideMap(){
const mapDiv = document.getElementById("map");
if (mapDiv) {

View File

@@ -229,6 +229,20 @@ async def serve_page(request):
return web.Response(text=content, content_type="text/html")
@routes.get("/docs/{doc}")
async def serve_doc(request):
"""Serve documentation files from docs/ (markdown)."""
doc = request.match_info["doc"]
docs_root = pathlib.Path(__file__).parent.parent / "docs"
doc_path = (docs_root / doc).resolve()
if not doc_path.is_file() or docs_root not in doc_path.parents:
raise web.HTTPNotFound(text="Document not found")
content = doc_path.read_text(encoding="utf-8")
return web.Response(text=content, content_type="text/markdown")
@routes.get("/net")
async def net(request):
return web.Response(

View File

@@ -6,7 +6,7 @@ import logging
import os
from aiohttp import web
from sqlalchemy import func, select, text
from sqlalchemy import func, select
from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import database, decode_payload, store
@@ -15,6 +15,14 @@ from meshview.config import CONFIG
from meshview.models import Node, NodePublicKey
from meshview.models import Packet as PacketModel
from meshview.models import PacketSeen as PacketSeenModel
from meshview.radio.coverage import (
DEFAULT_MAX_DBM,
DEFAULT_MIN_DBM,
DEFAULT_RELIABILITY,
DEFAULT_THRESHOLD_DBM,
compute_coverage,
compute_perimeter,
)
logger = logging.getLogger(__name__)
@@ -636,9 +644,7 @@ async def health_check(request):
# Check database connectivity
try:
async with database.async_session() as session:
result = await session.execute(
select(func.max(PacketModel.import_time_us))
)
result = await session.execute(select(func.max(PacketModel.import_time_us)))
last_import_time_us = result.scalar()
health_status["database"] = "connected"
if last_import_time_us is not None:
@@ -1035,3 +1041,83 @@ async def api_node_impersonation_check(request):
except Exception as e:
logger.error(f"Error checking impersonation for node {node_id}: {e}")
return web.json_response({"error": "Failed to check impersonation"}, status=500)
@routes.get("/api/coverage/{node_id}")
async def api_coverage(request):
try:
node_id = int(request.match_info["node_id"], 0)
except (KeyError, ValueError):
return web.json_response({"error": "Invalid node_id"}, status=400)
def parse_float(name, default):
value = request.query.get(name)
if value is None:
return default
try:
return float(value)
except ValueError as exc:
raise web.HTTPBadRequest(
text=json.dumps({"error": f"{name} must be a number"}),
content_type="application/json",
) from exc
try:
freq_mhz = parse_float("freq_mhz", 907.0)
tx_dbm = parse_float("tx_dbm", 20.0)
tx_height_m = parse_float("tx_height_m", 5.0)
rx_height_m = parse_float("rx_height_m", 1.5)
radius_km = parse_float("radius_km", 40.0)
step_km = parse_float("step_km", 0.25)
reliability = parse_float("reliability", DEFAULT_RELIABILITY)
threshold_dbm = parse_float("threshold_dbm", DEFAULT_THRESHOLD_DBM)
except web.HTTPBadRequest as exc:
raise exc
node = await store.get_node(node_id)
if not node or not node.last_lat or not node.last_long:
return web.json_response({"error": "Node not found or missing location"}, status=404)
lat = node.last_lat * 1e-7
lon = node.last_long * 1e-7
mode = request.query.get("mode", "perimeter")
if mode == "perimeter":
perimeter = compute_perimeter(
lat=round(lat, 7),
lon=round(lon, 7),
freq_mhz=round(freq_mhz, 3),
tx_dbm=round(tx_dbm, 2),
tx_height_m=round(tx_height_m, 2),
rx_height_m=round(rx_height_m, 2),
radius_km=round(radius_km, 2),
step_km=round(step_km, 3),
reliability=round(reliability, 3),
threshold_dbm=round(threshold_dbm, 1),
)
return web.json_response(
{"mode": "perimeter", "threshold_dbm": threshold_dbm, "perimeter": perimeter}
)
points = compute_coverage(
lat=round(lat, 7),
lon=round(lon, 7),
freq_mhz=round(freq_mhz, 3),
tx_dbm=round(tx_dbm, 2),
tx_height_m=round(tx_height_m, 2),
rx_height_m=round(rx_height_m, 2),
radius_km=round(radius_km, 2),
step_km=round(step_km, 3),
reliability=round(reliability, 3),
)
min_dbm = DEFAULT_MIN_DBM
max_dbm = DEFAULT_MAX_DBM
if points:
vals = [p[2] for p in points]
min_dbm = min(min_dbm, min(vals))
max_dbm = max(max_dbm, max(vals))
return web.json_response(
{"mode": "heatmap", "min_dbm": min_dbm, "max_dbm": max_dbm, "points": points}
)

View File

@@ -24,6 +24,7 @@ MarkupSafe~=3.0.2
# Graphs / diagrams
pydot~=3.0.4
pyitm~=0.3
#############################