diff --git a/docs/COVERAGE.md b/docs/COVERAGE.md
new file mode 100644
index 0000000..ccd2e7b
--- /dev/null
+++ b/docs/COVERAGE.md
@@ -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.
+
diff --git a/meshview/lang/en.json b/meshview/lang/en.json
index bff56f1..8b689fc 100644
--- a/meshview/lang/en.json
+++ b/meshview/lang/en.json
@@ -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!",
diff --git a/meshview/lang/es.json b/meshview/lang/es.json
index 735b7a6..1a64cd2 100644
--- a/meshview/lang/es.json
+++ b/meshview/lang/es.json
@@ -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!",
diff --git a/meshview/radio/coverage.py b/meshview/radio/coverage.py
new file mode 100644
index 0000000..cc1c8a9
--- /dev/null
+++ b/meshview/radio/coverage.py
@@ -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
diff --git a/meshview/templates/node.html b/meshview/templates/node.html
index d20fe58..51b6793 100644
--- a/meshview/templates/node.html
+++ b/meshview/templates/node.html
@@ -338,6 +338,12 @@
+
+
+ Coverage Help
+
@@ -517,6 +523,7 @@
+