Added Observed coverage to the node.html page

This commit is contained in:
pablorevilla-meshtastic
2026-02-10 15:33:59 -08:00
parent 17fa92d4cf
commit 20e3f9c104
5 changed files with 229 additions and 8 deletions
+30 -5
View File
@@ -1,23 +1,25 @@
# Coverage Prediction
# Coverage
## Predicted coverage
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
### 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
### 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
### Key parameters
- **Frequency**: default `907 MHz`
- **Transmit power**: default `20 dBm`
@@ -25,9 +27,32 @@ and the last point above the threshold forms the outline.
- **Reliability**: default `0.5` (median)
- **Terrain irregularity**: default `90 m` (average terrain)
## Limitations
### 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.
- Observed coverage depends on gateway locations and recent traffic volume.
## Observed coverage (real data)
Meshview can also draw an **observed coverage** perimeter based on real packet
sightings. This uses packets **from the node** and the gateways that heard them.
We filter to **direct/1-hop** sightings (`hop_start - hop_limit <= 1`) and then:
1. Compute distance + bearing from the sender to each gateway with location.
2. Bucket by bearing (default 5°).
3. Keep the **farthest** gateway in each bearing bucket.
4. Connect those points into a perimeter polygon.
This gives a **real-world envelope** that reflects terrain, antenna placement,
and environment. It improves over time as more packets are observed.
Tuning knobs:
- `max_hops` (default 1)
- `bearing_step` (default 10°)
- `packets_limit` (default 50 most recent packets)
+2 -1
View File
@@ -216,7 +216,8 @@
"times_seen": "Times seen",
"copy_import_url": "Copy Import URL",
"show_qr_code": "Show QR Code",
"toggle_coverage": "Toggle Coverage",
"toggle_coverage": "Predicted Coverage",
"toggle_observed_coverage": "Observed Coverage",
"location_required": "Location required for coverage",
"coverage_help": "Coverage Help",
"share_contact_qr": "Share Contact QR",
+2 -1
View File
@@ -202,7 +202,8 @@
"times_seen": "Veces visto",
"copy_import_url": "Copiar URL de importación",
"show_qr_code": "Mostrar código QR",
"toggle_coverage": "Alternar cobertura",
"toggle_coverage": "Cobertura predicha",
"toggle_observed_coverage": "Cobertura observada",
"location_required": "Se requiere ubicación para la cobertura",
"coverage_help": "Ayuda de cobertura",
"share_contact_qr": "Compartir contacto QR",
+61 -1
View File
@@ -339,7 +339,10 @@
<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>
<span>📡</span> <span data-translate-lang="toggle_coverage">Predicted Coverage</span>
</button>
<button onclick="toggleObservedCoverage()" id="toggleObservedCoverageBtn" disabled title="Location required for coverage">
<span>🛰</span> <span data-translate-lang="toggle_observed_coverage">Observed Coverage</span>
</button>
<a class="inline-link" id="coverageHelpLink" href="/docs/COVERAGE.md" target="_blank" rel="noopener" data-translate-lang="coverage_help">
Coverage Help
@@ -643,6 +646,7 @@ let currentPacketRows = [];
let map, markers = {};
let coverageLayer = null;
let observedCoverageLayer = null;
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
let fromNodeId = new URLSearchParams(window.location.search).get("from_node_id");
@@ -718,6 +722,7 @@ async function loadNodeInfo(){
node.last_long ? (node.last_long / 1e7).toFixed(6) : "—";
const coverageBtn = document.getElementById("toggleCoverageBtn");
const coverageHelp = document.getElementById("coverageHelpLink");
const observedCoverageBtn = document.getElementById("toggleObservedCoverageBtn");
if (coverageBtn) {
const hasLocation = Boolean(node.last_lat && node.last_long);
coverageBtn.disabled = !hasLocation;
@@ -726,6 +731,14 @@ async function loadNodeInfo(){
: (nodeTranslations.location_required || "Location required for coverage");
coverageBtn.style.display = hasLocation ? "" : "none";
}
if (observedCoverageBtn) {
const hasLocation = Boolean(node.last_lat && node.last_long);
observedCoverageBtn.disabled = !hasLocation;
observedCoverageBtn.title = hasLocation
? ""
: (nodeTranslations.location_required || "Location required for coverage");
observedCoverageBtn.style.display = hasLocation ? "" : "none";
}
if (coverageHelp) {
const hasLocation = Boolean(node.last_lat && node.last_long);
coverageHelp.style.display = hasLocation ? "" : "none";
@@ -829,6 +842,10 @@ async function toggleCoverage() {
coverageLayer = null;
return;
}
if (observedCoverageLayer) {
map.removeLayer(observedCoverageLayer);
observedCoverageLayer = null;
}
const nodeId = currentNode?.node_id || fromNodeId;
if (!nodeId) return;
@@ -858,6 +875,49 @@ async function toggleCoverage() {
}
}
async function toggleObservedCoverage() {
if (!map) initMap();
if (observedCoverageLayer) {
map.removeLayer(observedCoverageLayer);
observedCoverageLayer = null;
return;
}
if (coverageLayer) {
map.removeLayer(coverageLayer);
coverageLayer = null;
}
const nodeId = currentNode?.node_id || fromNodeId;
if (!nodeId) return;
try {
const res = await fetch(
`/api/coverage_observed/${encodeURIComponent(nodeId)}?max_hops=1&bearing_step=10&packets_limit=10`
);
if (!res.ok) {
console.error("Observed coverage request failed", res.status);
return;
}
const data = await res.json();
if (!data.perimeter || data.perimeter.length < 3) {
console.warn("Observed coverage perimeter missing or too small");
return;
}
observedCoverageLayer = L.polygon(data.perimeter, {
color: "#17a2b8",
weight: 3,
opacity: 1.0,
fillColor: "#000000",
fillOpacity: 0.1
}).addTo(map);
map.fitBounds(observedCoverageLayer.getBounds(), { padding: [20, 20] });
map.invalidateSize();
} catch (err) {
console.error("Observed coverage request failed", err);
}
}
function hideMap(){
const mapDiv = document.getElementById("map");
if (mapDiv) {
+134
View File
@@ -3,6 +3,7 @@
import datetime
import json
import logging
import math
import os
from aiohttp import web
@@ -37,6 +38,26 @@ _LANG_CACHE = {}
routes = web.RouteTableDef()
def _haversine_km(lat1, lon1, lat2, lon2):
r = 6371.0
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2.0) ** 2
return 2 * r * math.asin(math.sqrt(a))
def _bearing_deg(lat1, lon1, lat2, lon2):
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dlambda = math.radians(lon2 - lon1)
y = math.sin(dlambda) * math.cos(phi2)
x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlambda)
bearing = math.degrees(math.atan2(y, x))
return (bearing + 360.0) % 360.0
def init_api_module(packet_class, seq_regex, lang_dir):
"""Initialize API module with dependencies from main web module."""
global Packet, SEQ_REGEX, LANG_DIR
@@ -1128,3 +1149,116 @@ async def api_coverage(request):
return web.json_response(
{"mode": "heatmap", "min_dbm": min_dbm, "max_dbm": max_dbm, "points": points}
)
@routes.get("/api/coverage_observed/{node_id}")
async def api_coverage_observed(request):
try:
node_id = int(request.match_info["node_id"], 0)
except (KeyError, ValueError):
return web.json_response({"error": "Invalid node_id"}, status=400)
try:
max_hops = int(request.query.get("max_hops", "1"))
except ValueError:
return web.json_response({"error": "max_hops must be an integer"}, status=400)
try:
packets_limit = int(request.query.get("packets_limit", "50"))
if packets_limit <= 0:
raise ValueError
except ValueError:
return web.json_response({"error": "packets_limit must be a positive integer"}, status=400)
try:
bearing_step = int(request.query.get("bearing_step", "5"))
if bearing_step <= 0 or bearing_step > 90:
raise ValueError
except ValueError:
return web.json_response({"error": "bearing_step must be 1-90"}, status=400)
since_days = request.query.get("since_days")
since_us = None
if since_days:
try:
since_days = int(since_days)
if since_days > 0:
since_us = int(
(datetime.datetime.now(datetime.UTC).timestamp() - since_days * 86400)
* 1_000_000
)
except ValueError:
return web.json_response({"error": "since_days must be an integer"}, status=400)
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)
src_lat = node.last_lat * 1e-7
src_lon = node.last_long * 1e-7
bearings = {}
point_count = 0
async with database.async_session() as session:
pkt_stmt = (
select(PacketModel.id)
.where(PacketModel.from_node_id == node_id)
.order_by(PacketModel.import_time_us.desc())
.limit(packets_limit)
)
pkt_ids = [row[0] for row in (await session.execute(pkt_stmt)).all()]
if not pkt_ids:
return web.json_response(
{
"mode": "observed",
"max_hops": max_hops,
"bearing_step": bearing_step,
"packets_limit": packets_limit,
"points_seen": 0,
"perimeter": [],
}
)
stmt = (
select(PacketSeenModel, Node)
.join(Node, Node.node_id == PacketSeenModel.node_id)
.where(PacketSeenModel.packet_id.in_(pkt_ids))
.where(Node.last_lat.isnot(None), Node.last_long.isnot(None))
)
if since_us is not None:
stmt = stmt.where(PacketSeenModel.import_time_us > since_us)
result = await session.execute(stmt)
for seen, gw in result.all():
if seen.hop_start is None or seen.hop_limit is None:
continue
hop_count = seen.hop_start - seen.hop_limit
if hop_count < 0 or hop_count > max_hops:
continue
gw_lat = gw.last_lat * 1e-7
gw_lon = gw.last_long * 1e-7
dist_km = _haversine_km(src_lat, src_lon, gw_lat, gw_lon)
bearing = _bearing_deg(src_lat, src_lon, gw_lat, gw_lon)
bucket = int(bearing // bearing_step) * bearing_step
prev = bearings.get(bucket)
if prev is None or dist_km > prev["dist_km"]:
bearings[bucket] = {"lat": gw_lat, "lon": gw_lon, "dist_km": dist_km}
point_count += 1
perimeter = [
[v["lat"], v["lon"]] for _, v in sorted(bearings.items(), key=lambda item: item[0])
]
return web.json_response(
{
"mode": "observed",
"max_hops": max_hops,
"bearing_step": bearing_step,
"packets_limit": packets_limit,
"points_seen": point_count,
"perimeter": perimeter,
}
)