From 20e3f9c1043a967c2a80cff0d380d176190c980d Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Tue, 10 Feb 2026 15:33:59 -0800 Subject: [PATCH] Added Observed coverage to the node.html page --- docs/COVERAGE.md | 35 +++++++-- meshview/lang/en.json | 3 +- meshview/lang/es.json | 3 +- meshview/templates/node.html | 62 +++++++++++++++- meshview/web_api/api.py | 134 +++++++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 8 deletions(-) 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, + } + )