Add basic coverege support.

This commit is contained in:
pablorevilla-meshtastic committed 2026-02-09 21:17:22 -08:00
1 parent 29da1487d4
commit 9622092c17
8 files changed
+340 -5

No files matched your search

+33
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.
+3
View File
@@ -189,6 +189,9 @@
"times_seen": "Times seen", "times_seen": "Times seen",
"copy_import_url": "Copy Import URL", "copy_import_url": "Copy Import URL",
"show_qr_code": "Show QR Code", "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", "share_contact_qr": "Share Contact QR",
"copy_url": "Copy URL", "copy_url": "Copy URL",
"copied": "Copied!", "copied": "Copied!",
+3
View File
@@ -175,6 +175,9 @@
"times_seen": "Veces visto", "times_seen": "Veces visto",
"copy_import_url": "Copiar URL de importación", "copy_import_url": "Copiar URL de importación",
"show_qr_code": "Mostrar código QR", "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", "share_contact_qr": "Compartir contacto QR",
"copy_url": "Copiar URL", "copy_url": "Copiar URL",
"copied": "¡Copiado!", "copied": "¡Copiado!",
+136
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
+59
View File
@@ -338,6 +338,12 @@
<button onclick="showQrCode()" id="showQrBtn"> <button onclick="showQrCode()" id="showQrBtn">
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span> <span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
</button> </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> </div>
<!-- Impersonation Warning --> <!-- 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://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://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 src="/static/portmaps.js"></script>
<script> <script>
@@ -635,6 +642,7 @@ let currentNode = null;
let currentPacketRows = []; let currentPacketRows = [];
let map, markers = {}; let map, markers = {};
let coverageLayer = null;
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] }; let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
let fromNodeId = new URLSearchParams(window.location.search).get("from_node_id"); 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) : "—"; node.last_lat ? (node.last_lat / 1e7).toFixed(6) : "—";
document.getElementById("info-lon").textContent = document.getElementById("info-lon").textContent =
node.last_long ? (node.last_long / 1e7).toFixed(6) : "—"; 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 = "—"; let lastSeen = "—";
if (node.last_seen_us) { if (node.last_seen_us) {
@@ -799,6 +821,43 @@ function initMap(){
}).addTo(map); }).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(){ function hideMap(){
const mapDiv = document.getElementById("map"); const mapDiv = document.getElementById("map");
if (mapDiv) { if (mapDiv) {
+14
View File
@@ -229,6 +229,20 @@ async def serve_page(request):
return web.Response(text=content, content_type="text/html") 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") @routes.get("/net")
async def net(request): async def net(request):
return web.Response( return web.Response(
+90 -4
View File
@@ -6,7 +6,7 @@ import logging
import os import os
from aiohttp import web from aiohttp import web
from sqlalchemy import func, select, text from sqlalchemy import func, select
from meshtastic.protobuf.portnums_pb2 import PortNum from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import database, decode_payload, store 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 Node, NodePublicKey
from meshview.models import Packet as PacketModel from meshview.models import Packet as PacketModel
from meshview.models import PacketSeen as PacketSeenModel 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__) logger = logging.getLogger(__name__)
@@ -636,9 +644,7 @@ async def health_check(request):
# Check database connectivity # Check database connectivity
try: try:
async with database.async_session() as session: async with database.async_session() as session:
result = await session.execute( result = await session.execute(select(func.max(PacketModel.import_time_us)))
select(func.max(PacketModel.import_time_us))
)
last_import_time_us = result.scalar() last_import_time_us = result.scalar()
health_status["database"] = "connected" health_status["database"] = "connected"
if last_import_time_us is not None: if last_import_time_us is not None:
@@ -1035,3 +1041,83 @@ async def api_node_impersonation_check(request):
except Exception as e: except Exception as e:
logger.error(f"Error checking impersonation for node {node_id}: {e}") logger.error(f"Error checking impersonation for node {node_id}: {e}")
return web.json_response({"error": "Failed to check impersonation"}, status=500) 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}
)
+2 -1
View File
@@ -24,6 +24,7 @@ MarkupSafe~=3.0.2
# Graphs / diagrams # Graphs / diagrams
pydot~=3.0.4 pydot~=3.0.4
pyitm~=0.3
############################# #############################
@@ -47,4 +48,4 @@ objgraph~=3.6.2
# Testing # Testing
pytest~=8.3.4 pytest~=8.3.4
pytest-aiohttp~=1.0.5 pytest-aiohttp~=1.0.5
pytest-asyncio~=0.24.0 pytest-asyncio~=0.24.0