diff --git a/docs/COVERAGE.md b/docs/COVERAGE.md
index ccd2e7b..6aa6858 100644
--- a/docs/COVERAGE.md
+++ b/docs/COVERAGE.md
@@ -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)
+
diff --git a/meshview/lang/en.json b/meshview/lang/en.json
index 1a402ad..62a5cc1 100644
--- a/meshview/lang/en.json
+++ b/meshview/lang/en.json
@@ -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",
diff --git a/meshview/lang/es.json b/meshview/lang/es.json
index 61b7ee5..442f4aa 100644
--- a/meshview/lang/es.json
+++ b/meshview/lang/es.json
@@ -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",
diff --git a/meshview/templates/node.html b/meshview/templates/node.html
index 51b6793..802a7b8 100644
--- a/meshview/templates/node.html
+++ b/meshview/templates/node.html
@@ -339,7 +339,10 @@
🔳 Show QR Code
+
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) {
diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py
index 6bd7b17..0146832 100644
--- a/meshview/web_api/api.py
+++ b/meshview/web_api/api.py
@@ -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,
+ }
+ )