forked from iarv/meshview
Add basic coverege support.
This commit is contained in:
33
docs/COVERAGE.md
Normal file
33
docs/COVERAGE.md
Normal 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.
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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
136
meshview/radio/coverage.py
Normal 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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ MarkupSafe~=3.0.2
|
||||
|
||||
# Graphs / diagrams
|
||||
pydot~=3.0.4
|
||||
pyitm~=0.3
|
||||
|
||||
|
||||
#############################
|
||||
|
||||
Reference in New Issue
Block a user