mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-08-07 01:12:54 +02:00
Added Observed coverage to the node.html page
This commit is contained in:
+30
-5
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user