diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 1d693f7268d..11f2cf06d24 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -238,6 +238,7 @@ jobs:
tasks: >-
:core:service:allTests
:feature:connections:allTests
+ :feature:coverage:allTests
:feature:discovery:allTests
:feature:docs:allTests
:feature:firmware:allTests
@@ -252,6 +253,7 @@ jobs:
kover: >-
:core:service:koverXmlReport
:feature:connections:koverXmlReport
+ :feature:coverage:koverXmlReport
:feature:discovery:koverXmlReport
:feature:docs:koverXmlReport
:feature:firmware:koverXmlReport
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index e25c7e70d6e..d1390a2e567 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1719,15 +1719,15 @@ signal_quality
site_planner
site_planner_antenna_gain_dbi
site_planner_antenna_height_meters
-site_planner_browser_note
site_planner_color_scale
site_planner_estimate
site_planner_estimating
site_planner_failed
site_planner_frequency_mhz
-site_planner_high_resolution
+site_planner_invalid_frequency
site_planner_invalid_latitude
site_planner_invalid_longitude
+site_planner_invalid_max_range
site_planner_invalid_positive
site_planner_invalid_rx_sensitivity
site_planner_max_range_km
@@ -1742,7 +1742,6 @@ site_planner_tx_power_watts
site_planner_use_current_location
site_planner_use_map_center
site_planner_use_node_location
-site_planner_webview_updating
skip
slot
snr
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 2bd4338f48d..3c081dca660 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -240,6 +240,7 @@ dependencies {
implementation(projects.feature.messaging)
implementation(projects.feature.connections)
implementation(projects.feature.map)
+ implementation(projects.feature.coverage)
implementation(projects.feature.node)
implementation(projects.feature.settings)
implementation(projects.feature.discovery)
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt
index ade30250690..6a04879a5e0 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt
@@ -22,19 +22,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.maplibre.spatialk.geojson.Position
import org.meshtastic.app.map.MapViewModel
-import org.meshtastic.app.map.SitePlannerHost
+import org.meshtastic.feature.coverage.rememberCoverageEstimate
import org.meshtastic.feature.map.SharedMapViewModel
+import org.meshtastic.feature.map.component.SitePlannerHost
import org.meshtastic.feature.map.component.toSitePlannerParams
import org.meshtastic.feature.map.maplibre.SitePlannerSession
/**
- * Runs the hosted Site Planner for the F-Droid map.
- *
- * The planner lives in `androidApp`, so the flavor supplies it rather than the shared map module. Imported coverage
- * becomes a GeoJSON map layer (see #6138) and the map recentres on the transmitter so it is on screen.
+ * Runs the Site Planner for the F-Droid map. The estimate becomes a GeoJSON map layer and the map moves to the
+ * transmitter so it's on screen.
*
* No phone-GPS shortcut is offered: the Google flavor fills that from Play Services' fused location, which must not
- * enter an F-Droid build. The coordinate fields stay manual, with the map centre and the node's own position as
+ * enter an F-Droid build. The coordinate fields stay manual, with the map center and the node's own position as
* shortcuts.
*/
@Composable
@@ -51,6 +50,7 @@ fun SitePlannerSlot(session: SitePlannerSession) {
SitePlannerHost(
initialParams = subject.toSitePlannerParams(channelSet),
+ estimate = rememberCoverageEstimate(),
onDismiss = session.onDismiss,
onImport = { name, geoJson, latitude, longitude ->
mapViewModel.addGeoJsonLayer(name, geoJson)
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index e16b2594d42..1cb20c30305 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -193,6 +193,7 @@ import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.core.ui.util.showToast
+import org.meshtastic.feature.coverage.rememberCoverageEstimate
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.MapBounds
import org.meshtastic.feature.map.MapNodePolicy
@@ -209,6 +210,7 @@ import org.meshtastic.feature.map.component.MeshMapFitPadding
import org.meshtastic.feature.map.component.NodeTrackFilterMenu
import org.meshtastic.feature.map.component.OfflineStatusBanner
import org.meshtastic.feature.map.component.RasterOverlayToggles
+import org.meshtastic.feature.map.component.SitePlannerHost
import org.meshtastic.feature.map.component.SitePlannerLaunch
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.feature.map.component.mapFilterActions
@@ -1233,6 +1235,7 @@ fun MapView(
launch.nodeLocation(ourNodeInfo)?.let { location -> { location } }
SitePlannerHost(
initialParams = launch.initialParams,
+ estimate = rememberCoverageEstimate(),
onDismiss = { sitePlannerLaunch = null },
onImport = { name, geoJson, latitude, longitude ->
mapViewModel.addGeoJsonLayer(name, geoJson)
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt
deleted file mode 100644
index 8b95b95ff2f..00000000000
--- a/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt
+++ /dev/null
@@ -1,317 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.res.Resources
-import android.os.Handler
-import android.os.Looper
-import android.util.AndroidRuntimeException
-import android.webkit.JavascriptInterface
-import android.webkit.WebResourceError
-import android.webkit.WebResourceRequest
-import android.webkit.WebView
-import android.webkit.WebViewClient
-import android.widget.FrameLayout
-import android.widget.Toast
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.CircularWavyProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.viewinterop.AndroidView
-import androidx.compose.ui.window.Dialog
-import androidx.core.net.toUri
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.cancel
-import org.meshtastic.core.resources.site_planner_estimating
-import org.meshtastic.core.resources.site_planner_failed
-import org.meshtastic.core.resources.site_planner_webview_updating
-import org.meshtastic.feature.map.component.SITE_PLANNER_URL
-import org.meshtastic.feature.map.component.SitePlannerParams
-import org.meshtastic.feature.map.component.SitePlannerSheet
-
-// The official hosted Site Planner (static PWA on GitHub Pages). The estimate flow loads it headless with
-// run=1&bridge=1; the native bridge + full flat query contract it relies on shipped in site-planner #74.
-private const val SITE_PLANNER_TIMEOUT_MS = 45_000L
-
-/**
- * Site Planner coverage-estimate flow: an editable [SitePlannerSheet] pre-filled with [initialParams], then a hidden
- * headless WebView that loads the planner with `run=1&bridge=1`, waits for it to hand back the styled GeoJSON, and
- * imports it via [onImport]. Location shortcuts re-seed the coordinates from the device GPS
- * ([onRequestCurrentLocation], when permission is granted), this node ([onUseNodeLocation]), or the map center
- * ([onUseMapCenter]). Google-flavor affordance; targets the official hosted planner.
- */
-@Suppress("LongMethod", "LambdaParameterInRestartableEffect")
-@Composable
-fun SitePlannerHost(
- initialParams: SitePlannerParams,
- onDismiss: () -> Unit,
- onImport: (name: String, geoJson: String, latitude: Double, longitude: Double) -> Unit,
- onRequestCurrentLocation: (suspend () -> Pair?)? = null,
- onUseNodeLocation: (() -> Pair)? = null,
- onUseMapCenter: (() -> Pair)? = null,
-) {
- var params by remember(initialParams) { mutableStateOf(initialParams) }
- var running by remember { mutableStateOf(null) }
- val scope = rememberCoroutineScope()
- val context = LocalContext.current
- val failedText = stringResource(Res.string.site_planner_failed)
- val estimatingText = stringResource(Res.string.site_planner_estimating)
- val webViewUpdatingText = stringResource(Res.string.site_planner_webview_updating)
-
- val current = running
- if (current == null) {
- SitePlannerSheet(
- initial = params,
- onSubmit = { running = it },
- onDismiss = onDismiss,
- onUseCurrentLocation =
- onRequestCurrentLocation?.let { fetch ->
- {
- scope.launch {
- fetch()?.let { (lat, lon) -> params = params.copy(latitude = lat, longitude = lon) }
- }
- }
- },
- onUseNodeLocation =
- onUseNodeLocation?.let { node ->
- {
- val (lat, lon) = node()
- params = params.copy(latitude = lat, longitude = lon)
- }
- },
- onUseMapCenter =
- onUseMapCenter?.let { center ->
- {
- val (lat, lon) = center()
- params = params.copy(latitude = lat, longitude = lon)
- }
- },
- )
- } else {
- Dialog(onDismissRequest = { onDismiss() }) {
- // The WebView fills the card and runs the sim; the opaque spinner Surface on top hides it, so the flow
- // reads as "Estimating coverage…" rather than a browser. It must be attached and non-trivially sized —
- // a detached or 0-size WebView can't get a WebGL context, and the planner's autorun waits on map load.
- Box(modifier = Modifier.size(280.dp), contentAlignment = Alignment.Center) {
- SitePlannerRunner(
- url = current.toQueryUrl(SITE_PLANNER_URL),
- onResult = { geoJson ->
- onImport(current.name, geoJson, current.latitude, current.longitude)
- onDismiss()
- },
- onError = { detail ->
- Logger.withTag("SitePlanner").e { "Coverage estimate failed: $detail" }
- Toast.makeText(context, failedText, Toast.LENGTH_SHORT).show()
- onDismiss()
- },
- onWebViewUnavailable = {
- Toast.makeText(context, webViewUpdatingText, Toast.LENGTH_LONG).show()
- onDismiss()
- },
- // Headless: fully transparent so the WebGL first-paint frame never flashes through, but still
- // attached + sized (280dp) so the sim gets a WebGL context. alpha(0) is a compositor property —
- // the view stays VISIBLE, so the page's requestAnimationFrame/autorun keep running.
- modifier = Modifier.matchParentSize().alpha(0f),
- )
- Surface(
- modifier = Modifier.matchParentSize(),
- shape = MaterialTheme.shapes.large,
- color = MaterialTheme.colorScheme.surface,
- ) {
- Column(
- modifier = Modifier.padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
- ) {
- CircularWavyProgressIndicator()
- Text(estimatingText)
- TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) }
- }
- }
- }
- }
- LaunchedEffect(current) {
- delay(SITE_PLANNER_TIMEOUT_MS)
- Logger.withTag("SitePlanner").w { "Coverage estimate timed out after ${SITE_PLANNER_TIMEOUT_MS}ms" }
- Toast.makeText(context, failedText, Toast.LENGTH_SHORT).show()
- onDismiss()
- }
- }
-}
-
-/**
- * Headless WebView that loads [url] (the planner with `run=1&bridge=1`), exposes a `window.__meshtasticNative` bridge,
- * and reports the coverage GeoJSON via [onResult] once the planner posts it. Main-frame load failures go to [onError].
- * Construction rides out the system WebView provider-update race with one deferred retry, then [onWebViewUnavailable].
- */
-@Composable
-private fun SitePlannerRunner(
- url: String,
- onResult: (String) -> Unit,
- onError: (String) -> Unit,
- onWebViewUnavailable: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- val onResultState by rememberUpdatedState(onResult)
- val onErrorState by rememberUpdatedState(onError)
- val onWebViewUnavailableState by rememberUpdatedState(onWebViewUnavailable)
- AndroidView(
- modifier = modifier,
- factory = { context ->
- SitePlannerWebViewContainer(context).apply {
- attachPlannerWebView(
- url = url,
- retriesLeft = 1,
- onResult = { onResultState(it) },
- onError = { onErrorState(it) },
- onUnavailable = { onWebViewUnavailableState() },
- )
- }
- },
- onRelease = SitePlannerWebViewContainer::release,
- )
-}
-
-// One deferred retry bridges the moment the system WebView provider package finishes updating.
-private const val WEBVIEW_RETRY_DELAY_MS = 250L
-
-/**
- * True for the exceptions WebView construction throws while the system WebView provider package updates underneath a
- * running app: the AOSP ResourcesImpl redirect race and the AndroidRuntimeException wrapper around provider failures.
- */
-internal fun isWebViewProviderUpdateException(throwable: Throwable): Boolean =
- throwable is Resources.NotFoundException ||
- (throwable is AndroidRuntimeException && throwable.message?.contains("WebView", ignoreCase = true) == true)
-
-/** Hosts the planner WebView; owns the retry/bridge handler so [release] cancels anything still pending. */
-private class SitePlannerWebViewContainer(context: Context) : FrameLayout(context) {
- val main = Handler(Looper.getMainLooper())
-
- fun release() {
- main.removeCallbacksAndMessages(null)
- val webView = getChildAt(0) as? WebView
- removeAllViews() // detach from the view system before destroy(), per the WebView docs
- webView?.destroy()
- }
-}
-
-/** Builds and attaches the planner WebView, absorbing the provider-update race with a single deferred retry. */
-private fun SitePlannerWebViewContainer.attachPlannerWebView(
- url: String,
- retriesLeft: Int,
- onResult: (String) -> Unit,
- onError: (String) -> Unit,
- onUnavailable: () -> Unit,
-) {
- // Only bare construction races the provider update; configure outside the try so no half-built view leaks.
- val webView =
- try {
- WebView(context)
- } catch (e: Resources.NotFoundException) {
- if (!isWebViewProviderUpdateException(e)) throw e
- retryOrFailSoft(e, retriesLeft, onUnavailable) {
- attachPlannerWebView(url, retriesLeft - 1, onResult, onError, onUnavailable)
- }
- return
- } catch (e: AndroidRuntimeException) {
- if (!isWebViewProviderUpdateException(e)) throw e
- retryOrFailSoft(e, retriesLeft, onUnavailable) {
- attachPlannerWebView(url, retriesLeft - 1, onResult, onError, onUnavailable)
- }
- return
- }
- addView(configurePlannerWebView(webView, main, url, onResult, onError))
-}
-
-private fun SitePlannerWebViewContainer.retryOrFailSoft(
- cause: Throwable,
- retriesLeft: Int,
- onUnavailable: () -> Unit,
- retry: () -> Unit,
-) {
- if (retriesLeft > 0) {
- Logger.withTag("SitePlanner").w(cause) { "WebView provider is updating; retrying construction once" }
- main.postDelayed({ retry() }, WEBVIEW_RETRY_DELAY_MS)
- } else {
- Logger.withTag("SitePlanner").e(cause) { "WebView unavailable after retry; failing soft" }
- onUnavailable()
- }
-}
-
-@SuppressLint("SetJavaScriptEnabled")
-private fun configurePlannerWebView(
- webView: WebView,
- main: Handler,
- url: String,
- onResult: (String) -> Unit,
- onError: (String) -> Unit,
-): WebView = webView.apply {
- setBackgroundColor(android.graphics.Color.TRANSPARENT) // no opaque black backing before first paint
- settings.javaScriptEnabled = true
- settings.domStorageEnabled = true
- addJavascriptInterface(
- object {
- // Called from the planner's JS thread; hop to main before touching Compose state.
- @JavascriptInterface fun onCoverage(geoJson: String) = main.post { onResult(geoJson) }
- },
- "__meshtasticNative",
- )
- webViewClient =
- object : WebViewClient() {
- // Keep the __meshtasticNative bridge exclusive to the planner's own origin: block any navigation
- // elsewhere (redirect/compromise/open-redirect) so foreign content can never reach onCoverage().
- // Sub-resource fetches (tiles, XHR) aren't navigations, so this doesn't affect the sim itself.
- override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
- val target = request?.url ?: return false
- val trusted = SITE_PLANNER_URL.toUri()
- return target.scheme != trusted.scheme || target.host != trusted.host
- }
-
- override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
- // Only a failed main-frame load is fatal; a stray tile/asset 404 is not.
- if (request?.isForMainFrame == true) {
- main.post { onError("${error?.errorCode} ${error?.description}") }
- }
- }
- }
- loadUrl(url)
-}
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.kt
deleted file mode 100644
index 943d927f7cf..00000000000
--- a/androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map
-
-import android.app.Application
-import android.content.res.Resources
-import android.util.AndroidRuntimeException
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-import kotlin.test.Test
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-
-/**
- * Classifier guard for the Site Planner WebView provider-update mitigation: only the two exception shapes the AOSP
- * provider-update race produces may be absorbed and retried; anything else must keep propagating.
- */
-@RunWith(RobolectricTestRunner::class)
-// Bare Application: booting the real MeshUtilApplication leaks its scopes across tests (#6644).
-@Config(application = Application::class, sdk = [34])
-class SitePlannerWebViewRecoveryTest {
-
- @Test
- fun `resources redirect race is recoverable`() {
- val race = Resources.NotFoundException("failed to redirect ResourcesImpl")
- assertTrue(isWebViewProviderUpdateException(race))
- }
-
- @Test
- fun `webview provider load failure wrapper is recoverable`() {
- val wrapper =
- AndroidRuntimeException(
- "android.webkit.WebViewFactory\$MissingWebViewPackageException: Failed to load WebView provider: No WebView installed",
- )
- assertTrue(isWebViewProviderUpdateException(wrapper))
- }
-
- @Test
- fun `unrelated AndroidRuntimeException is not swallowed`() {
- val unrelated = AndroidRuntimeException("Calling startActivity() from outside of an Activity context")
- assertFalse(isWebViewProviderUpdateException(unrelated))
- }
-
- @Test
- fun `unrelated RuntimeException is not swallowed`() {
- assertFalse(isWebViewProviderUpdateException(IllegalStateException("boom")))
- }
-}
diff --git a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt
index fa98d8a4ba5..7328547d60e 100644
--- a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt
@@ -109,6 +109,7 @@ private val ALL_MODULES_FULL =
":feature:intro",
":feature:messaging",
":feature:connections",
+ ":feature:coverage",
":feature:discovery",
":feature:docs",
":feature:map",
diff --git a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CoroutineScopeConstructionTest.kt b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CoroutineScopeConstructionTest.kt
index 6d987e29b1a..a7fd13fa973 100644
--- a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CoroutineScopeConstructionTest.kt
+++ b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CoroutineScopeConstructionTest.kt
@@ -74,6 +74,7 @@ class CoroutineScopeConstructionTest {
"desktopApp/main/DesktopMessageQueue.kt",
"desktopApp/main/DesktopPreferencesDataSource.kt",
"desktopApp/main/NoopStubs.kt",
+ "feature/coverage/commonMain/MapterhornElevation.kt",
"feature/discovery/commonMain/DiscoveryScanEngine.kt",
"feature/docs/commonMain/ChirpySessionHolder.kt",
"feature/firmware/commonMain/BleOtaTransport.kt",
diff --git a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt
index 339b0e54192..74b51044b43 100644
--- a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt
+++ b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt
@@ -27,8 +27,12 @@ import kotlin.test.assertTrue
*/
class ModuleBoundaryTest {
- /** The MapLibre renderer draws feature:map's shared map model and map-terrain's elevation tiles. */
- private val allowedFeatureImports = mapOf("map-maplibre" to setOf("map", "map-terrain"))
+ /**
+ * The MapLibre renderer draws feature:map's shared map model and map-terrain's elevation tiles. Coverage samples
+ * the same elevation tiles and computes the estimate feature:map's Site Planner form describes.
+ */
+ private val allowedFeatureImports =
+ mapOf("map-maplibre" to setOf("map", "map-terrain"), "coverage" to setOf("map", "map-terrain"))
private val sourceFiles = Konsist.scopeFromProject().files.filterNot { it.isNestedAgentWorktree() }
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index e9180b6db25..f067b297dc8 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1779,15 +1779,15 @@
Site Planner
Antenna gain (dBi)
Antenna height (m)
- The estimate opens in your browser. To bring it onto this map, click the transmitter pin there, choose Export › GeoJSON, then add the downloaded file under Manage Map Layers › Add Layer. Use the GeoJSON export — the KML one is a ground-overlay image this map cannot draw.
Color palette
Estimate coverage
Estimating coverage…
- Coverage estimate failed
+ Couldn't estimate coverage. The terrain may not have downloaded, so check your connection and try again.
Frequency (MHz)
- High-resolution terrain
+ Enter 30 to 6000 MHz
Enter −90 to 90
Enter −180 to 180
+ Enter 1 to 150 km
Must be greater than 0
Enter −150 to −30 dBm
Max range (km)
@@ -1802,7 +1802,6 @@
Use current location
Use map center
Use current node location
- The system WebView is updating. Please try again in a moment.
Skip
Slot
SNR
diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts
index 640b57d8bf5..0eceb633ce8 100644
--- a/desktopApp/build.gradle.kts
+++ b/desktopApp/build.gradle.kts
@@ -372,6 +372,7 @@ dependencies {
implementation(projects.feature.messaging)
implementation(projects.feature.connections)
implementation(projects.feature.map)
+ implementation(projects.feature.coverage)
implementation(projects.feature.discovery)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt
index f4f4daa2930..040d8cbb5a8 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt
@@ -19,27 +19,21 @@ package org.meshtastic.desktop.map
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
+import org.maplibre.spatialk.geojson.Position
+import org.meshtastic.feature.coverage.rememberCoverageEstimate
import org.meshtastic.feature.map.SharedMapViewModel
-import org.meshtastic.feature.map.component.SitePlannerBrowserSheet
+import org.meshtastic.feature.map.component.SitePlannerHost
import org.meshtastic.feature.map.component.toSitePlannerParams
+import org.meshtastic.feature.map.layers.MapLayersManager
import org.meshtastic.feature.map.maplibre.SitePlannerSession
-/**
- * Site Planner on the desktop: the same configuration form the Android maps show, handed off to the browser.
- *
- * The Android hosts run the planner in an embedded WebView and take the coverage estimate back through a JavaScript
- * bridge, which lands it on the map as a GeoJSON layer. Desktop has no embedded browser: the JetBrains Runtime the app
- * builds against does ship JCEF, but jlink strips it out of the packaged runtime, and putting it back measured at
- * roughly three and a half times the size of the whole application.
- *
- * So the estimate is produced and viewed in the browser rather than drawn here. The form is not skipped — the
- * transmitter is still seeded from the connected radio and the map centre is still offered as a shortcut, so the
- * planner opens with everything already filled in.
- */
+/** Runs the Site Planner for the desktop map; the estimate becomes a GeoJSON map layer and the map moves to it. */
@Composable
fun DesktopSitePlannerSlot(session: SitePlannerSession) {
val sharedViewModel: SharedMapViewModel = koinViewModel()
+ val layersManager: MapLayersManager = koinInject()
val ourNode by sharedViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val channelSet by sharedViewModel.channelSet.collectAsStateWithLifecycle()
@@ -48,9 +42,14 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) {
// A deep link names the node to plan for; a toolbar launch plans for whatever we are connected to.
val subject = session.nodeNum?.let { num -> nodes.firstOrNull { it.num == num } } ?: ourNode
- SitePlannerBrowserSheet(
- initial = subject.toSitePlannerParams(channelSet),
+ SitePlannerHost(
+ initialParams = subject.toSitePlannerParams(channelSet),
+ estimate = rememberCoverageEstimate(),
onDismiss = session.onDismiss,
+ onImport = { name, geoJson, latitude, longitude ->
+ layersManager.addGeoJsonLayer(name, geoJson)
+ session.moveTo(Position(longitude = longitude, latitude = latitude))
+ },
onUseNodeLocation =
subject?.takeIf { it.validPosition != null }?.let { node -> { node.latitude to node.longitude } },
onUseMapCenter = { session.mapCenter().let { it.latitude to it.longitude } },
diff --git a/feature/coverage/README.md b/feature/coverage/README.md
new file mode 100644
index 00000000000..74c161784f4
--- /dev/null
+++ b/feature/coverage/README.md
@@ -0,0 +1,47 @@
+# feature:coverage
+
+Computes RF coverage in-process with
+[`org.meshtastic:kp1812`](https://github.com/meshtastic/kp1812), an implementation of
+Recommendation ITU-R P.1812, over Mapterhorn terrain. It works offline once the terrain is
+cached.
+
+## Where it runs
+
+Every map host runs the same flow. `feature:map`'s `SitePlannerHost` shows the form, then calls
+`rememberCoverageEstimate()` from this module. The host adds the resulting GeoJSON as a map layer
+and moves the map to the transmitter.
+
+| Host | Caller |
+| --- | --- |
+| Android, Google flavor | `MapView` |
+| Android, F-Droid flavor | `SitePlannerSlot` |
+| Desktop | `DesktopSitePlannerSlot` |
+
+## Shape
+
+```text
+SitePlannerParams -> Site + ElevationSource -> LocalCoverage.sweepGrid() -> CoverageGrid -> toGeoJson(CoverageStyle)
+ |
+ +-- org.meshtastic:kp1812 (ITU-R P.1812)
+```
+
+The model, the `ElevationSource` seam, and the GeoJSON export live in `commonMain`. Only the
+`java.awt` demo renderer is in `jvmMain`. Terrain is cached under `terrain/coverage` in the app's
+storage, apart from downloaded offline map regions. `ElevationSource` is a single suspend method: the app
+backs it with Mapterhorn tiles, and tests back it with a lambda, so the suite needs no network,
+`WebView`, or terrain download.
+
+P.1812 is a different model from the hosted planner's SPLAT! ITM, so predictions don't match it
+pixel for pixel.
+
+## Tests
+
+The tests are behavioral. `kp1812` checks the model against the ITU reference itself, so these
+check that this module drives it correctly:
+
+- Signal decays with distance over flat ground.
+- A 400 m ridge shadows what's behind it.
+- More transmit power reaches at least as far.
+- `reachable` agrees with the receiver sensitivity.
+- Every bearing contributes equally to the sweep.
+- The geodesy round-trips.
diff --git a/feature/coverage/build.gradle.kts b/feature/coverage/build.gradle.kts
new file mode 100644
index 00000000000..40bf9aa44cb
--- /dev/null
+++ b/feature/coverage/build.gradle.kts
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+plugins { alias(libs.plugins.meshtastic.kmp.feature) }
+
+// Site Planner coverage computed on the device with org.meshtastic:kp1812 (ITU-R P.1812) over
+// Mapterhorn terrain, returned as GeoJSON for the map's layer list.
+kotlin {
+ jvm()
+
+ // kp1812 has no Android target; Android resolves its jvm artifact.
+ @Suppress("UnstableApiUsage")
+ android {
+ namespace = "org.meshtastic.feature.coverage"
+ androidResources.enable = false
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.kp1812)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(projects.feature.map)
+ implementation(projects.feature.mapTerrain)
+ implementation(projects.core.common)
+ implementation(projects.core.di)
+ implementation(libs.okio)
+ implementation(libs.ktor.client.core)
+ }
+
+ jvmMain.dependencies { implementation(libs.ktor.client.java) }
+ androidMain.dependencies { implementation(libs.ktor.client.okhttp) }
+
+ commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
+ jvmTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
+ }
+}
+
+// Runs a real prediction against Mapterhorn terrain and writes a PNG and the GeoJSON.
+// ./gradlew :feature:coverage:coverageDemo -PuseMavenLocal
+tasks.register("coverageDemo") {
+ group = "verification"
+ description = "Compute real coverage from Mapterhorn terrain via kp1812 and render it."
+ val jvmMain = kotlin.targets.getByName("jvm").compilations.getByName("main")
+ classpath = jvmMain.output.allOutputs + jvmMain.runtimeDependencyFiles!!
+ mainClass.set("org.meshtastic.feature.coverage.CoverageDemo")
+ args = (providers.gradleProperty("demoArgs").orNull ?: "").split(" ").filter { it.isNotBlank() }
+}
diff --git a/feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.android.kt b/feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.android.kt
new file mode 100644
index 00000000000..c06b5a77c1b
--- /dev/null
+++ b/feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.android.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import okio.Path
+import okio.Path.Companion.toPath
+import org.meshtastic.core.common.ContextServices
+import java.io.File
+
+/** App-internal storage, so cached terrain is private to the app and removed with it. */
+internal actual fun coverageTerrainDirectory(): Path =
+ File(ContextServices.app.filesDir, "terrain/coverage").absolutePath.toPath()
diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt
new file mode 100644
index 00000000000..e8dffe8b5b2
--- /dev/null
+++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import org.meshtastic.feature.map.kml.jsonString
+import kotlin.math.abs
+import kotlin.math.roundToInt
+import kotlin.math.roundToLong
+
+/**
+ * Turns a [CoverageGrid] into filled iso-bands ("signal ≥ X dBm" polygons) as GeoJSON.
+ *
+ * This is the shape a map can actually draw coverage with, and the shape the hosted Site Planner exports. Emitting the
+ * sample points instead produces a swarm of markers that the map clusters, which is what the first version did.
+ *
+ * Marching squares, tracing each band's boundary cell by cell. Deliberately simple: cells at or above the threshold
+ * contribute their own square to the band, so the result is a union of cell rectangles rather than a smoothed isoline.
+ * At ~100 m cells that reads as a solid coverage area, and it avoids the ambiguous-saddle handling a true isoline
+ * tracer needs.
+ */
+internal fun CoverageGrid.bands(ranges: List>): List = ranges
+ .map { range ->
+ val rings = mutableListOf>>()
+ // Greedily merge horizontal runs of in-band cells into rectangles: far fewer polygons than
+ // one per cell, and the map renders a handful of rings rather than thousands.
+ val taken = BooleanArray(width * height)
+ for (y in 0 until height - 1) {
+ var x = 0
+ while (x < width - 1) {
+ if (taken[y * width + x] || !inBand(x, y, range)) {
+ x++
+ continue
+ }
+ var runEnd = x
+ while (runEnd + 1 < width - 1 && !taken[y * width + runEnd + 1] && inBand(runEnd + 1, y, range)) {
+ runEnd++
+ }
+ // Extend downwards while the whole run stays in band.
+ var runBottom = y
+ while (
+ runBottom + 1 < height - 1 &&
+ (x..runEnd).all { inBand(it, runBottom + 1, range) } &&
+ (x..runEnd).none { taken[(runBottom + 1) * width + it] }
+ ) {
+ runBottom++
+ }
+ for (yy in y..runBottom) {
+ for (xx in x..runEnd) taken[yy * width + xx] = true
+ }
+ rings.add(
+ listOf(
+ lonAt(x.toDouble()) to latAt(y.toDouble()),
+ lonAt(runEnd + 1.0) to latAt(y.toDouble()),
+ lonAt(runEnd + 1.0) to latAt(runBottom + 1.0),
+ lonAt(x.toDouble()) to latAt(runBottom + 1.0),
+ lonAt(x.toDouble()) to latAt(y.toDouble()),
+ ),
+ )
+ x = runEnd + 1
+ }
+ }
+ CoverageBand(range.start, rings)
+ }
+ .filter { it.rings.isNotEmpty() }
+
+/**
+ * Bands do not overlap: each cell belongs to exactly one. Nested fills would paint over each other and hide the chosen
+ * colors, and a single overlay transparency is only meaningful when each pixel is painted once.
+ */
+private fun CoverageGrid.inBand(x: Int, y: Int, range: ClosedFloatingPointRange): Boolean {
+ val v = at(x, y)
+ return !v.isNaN() && v >= range.start && v < range.endInclusive
+}
+
+/** One iso-band, labeled by the weakest signal it contains. */
+internal class CoverageBand(val thresholdDbm: Double, val rings: List>>)
+
+/**
+ * The coverage as GeoJSON polygons with simplestyle-spec fills: what the map draws as a layer.
+ *
+ * Bands run from the receiver's sensitivity upward, so the outermost polygon is "a node here can hear this site at all"
+ * and the inner ones are progressively stronger signal.
+ */
+fun CoverageGrid.toGeoJson(style: CoverageStyle = CoverageStyle(), bandCount: Int = DEFAULT_BANDS): String {
+ val step = (style.maxDbm - style.minDbm) / bandCount
+ // The strongest band runs to infinity: a cell above the picker's ceiling is still coverage, and
+ // dropping it would punch a hole through the middle of the plot.
+ val ranges =
+ (0 until bandCount).map { index ->
+ val lower = style.minDbm + step * index
+ val upper = if (index == bandCount - 1) Double.POSITIVE_INFINITY else lower + step
+ lower..upper
+ }
+
+ val features =
+ bands(ranges)
+ .map { band ->
+ // Spread across the band INDEX, not its lower bound. Coloring a band by where its
+ // floor sits samples the last one at 5/6, so the palette's brightest end never appears.
+ val index = ((band.thresholdDbm - style.minDbm) / step).roundToInt()
+ val t = (index.toDouble() / (bandCount - 1).coerceAtLeast(1)).coerceIn(0.0, 1.0)
+ val color = style.palette.colorAt(t)
+ // GeoJSON MultiPolygon nests coordinates[polygon][ring][position]. Emitting the rings
+ // one level flatter makes a Polygon-with-holes wearing a MultiPolygon label, which MapLibre
+ // silently drops - the layer is added and nothing draws.
+ val polygons =
+ band.rings.joinToString(",") { ring ->
+ "[[" + ring.joinToString(",") { (lon, lat) -> "[$lon,$lat]" } + "]]"
+ }
+ """ {"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[$polygons]},""" +
+ """"properties":{"title":"\u2265 ${band.thresholdDbm.toFixed1()} dBm",""" +
+ """"dbm":${band.thresholdDbm.toFixed1()},"fill":"$color",""" +
+ """"fill-opacity":${style.opacity.toFixed1()},""" +
+ """"stroke":"$color","stroke-opacity":0.0,"stroke-width":0}}"""
+ }
+ .joinToString(",\n")
+
+ if (features.isEmpty()) return EMPTY_FEATURE_COLLECTION
+
+ return """{
+ "type": "FeatureCollection",
+ "properties": {"generator": "meshtastic-kp1812", "name": ${site.name.jsonString()}, "model": "ITU-R P.1812",
+ "palette": "${style.palette.key}", "min_dbm": ${style.minDbm.toFixed1()}, "max_dbm": ${style.maxDbm.toFixed1()}},
+ "features": [
+$features
+ ]
+}
+"""
+}
+
+private const val DEFAULT_BANDS = 6
+private const val EMPTY_FEATURE_COLLECTION =
+ """{"type":"FeatureCollection","properties":{"generator":"meshtastic-kp1812"},"features":[]}"""
+
+/** One decimal place, without `String.format`, which is JVM-only and does not exist on Kotlin/Native or wasm. */
+internal fun Double.toFixed1(): String {
+ val scaled = (this * TENTHS).roundToLong()
+ val sign = if (scaled < 0) "-" else ""
+ val magnitude = abs(scaled)
+ return "$sign${magnitude / TENTHS}.${magnitude % TENTHS}"
+}
+
+private const val TENTHS = 10
diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt
new file mode 100644
index 00000000000..a84118ab97a
--- /dev/null
+++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+/**
+ * Received signal strength on a regular lat/lon grid.
+ *
+ * Coverage is drawn as **filled iso-bands**, which need a regular grid. Polar samples exported as GeoJSON `Point`
+ * features would be clustered by the map and render as a swarm of identical node markers.
+ *
+ * `NaN` marks a cell that was not computed.
+ */
+@Suppress("LongParameterList") // a grid is its site, shape, bounds and samples; none of them groups
+class CoverageGrid(
+ val site: Site,
+ val width: Int,
+ val height: Int,
+ val north: Double,
+ val south: Double,
+ val east: Double,
+ val west: Double,
+ val dbm: DoubleArray,
+) {
+ init {
+ require(dbm.size == width * height) { "dbm must be width*height, got ${dbm.size} for $width x $height" }
+ }
+
+ fun at(x: Int, y: Int): Double = dbm[y * width + x]
+
+ /** Longitude of grid column [x]. */
+ fun lonAt(x: Double): Double = west + (east - west) * x / (width - 1)
+
+ /** Latitude of grid row [y]; row 0 is north. */
+ fun latAt(y: Double): Double = north - (north - south) * y / (height - 1)
+
+ /** Fraction of computed cells at or above the receiver's sensitivity. */
+ val reachableFraction: Double
+ get() {
+ var computed = 0
+ var reachable = 0
+ for (v in dbm) {
+ if (v.isNaN()) continue
+ computed++
+ if (v >= site.rxSensitivityDbm) reachable++
+ }
+ return if (computed == 0) 0.0 else reachable.toDouble() / computed
+ }
+
+ /** Greatest distance from the site at which any cell is reachable, km. */
+ val maxRangeKm: Double
+ get() {
+ var best = 0.0
+ for (y in 0 until height) {
+ for (x in 0 until width) {
+ val v = at(x, y)
+ if (v.isNaN() || v < site.rxSensitivityDbm) continue
+ val km = haversineKm(site.latitude, site.longitude, latAt(y.toDouble()), lonAt(x.toDouble()))
+ if (km > best) best = km
+ }
+ }
+ return best
+ }
+}
+
+/**
+ * Compute coverage onto a regular grid centered on [site].
+ *
+ * Swept in polar and resampled: see [sweepPolar]. The grid resolution is therefore free of the prediction budget, so it
+ * can be fine enough for the contour tracer without costing anything.
+ */
+suspend fun LocalCoverage.sweepGrid(
+ site: Site,
+ resolution: Int = DEFAULT_GRID,
+ profileStepKm: Double = DEFAULT_PROFILE_STEP_KM,
+): CoverageGrid = sweepPolar(site, profileStepKm = profileStepKm).toGrid(resolution)
+
+/** 256 cells across the coverage box is ~195 m per cell at a 25 km radius. */
+internal const val DEFAULT_GRID = 256
diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt
new file mode 100644
index 00000000000..3a86b1b1ff8
--- /dev/null
+++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import kotlin.math.roundToInt
+
+/**
+ * The color ramps the Site Planner's Display section offers, by the names it uses.
+ *
+ * These are the matplotlib colormaps the hosted planner renders with, carried here so the local engine honors the same
+ * picker rather than inventing its own colors. Each is stored as evenly spaced anchors and interpolated between: a
+ * handful of stops per ramp reproduces the perceptual shape closely enough for six discrete coverage bands, without
+ * embedding a 256-entry table each.
+ */
+@Suppress("MagicNumber") // Color anchors are data; naming forty hex literals would obscure the ramps.
+enum class CoveragePalette(val key: String, private val anchors: List) {
+ PLASMA("plasma", listOf(0x0D0887, 0x6A00A8, 0xB12A90, 0xE16462, 0xFCA636, 0xF0F921)),
+ VIRIDIS("viridis", listOf(0x440154, 0x414487, 0x2A788E, 0x22A884, 0x7AD151, 0xFDE725)),
+ CMR_MAP("CMRmap", listOf(0x000000, 0x3B2277, 0xA1417F, 0xE1663A, 0xE6AE3E, 0xD7D7A0, 0xFFFFFF)),
+ COOL("cool", listOf(0x00FFFF, 0xFF00FF)),
+ TURBO(
+ "turbo",
+ listOf(0x30123B, 0x4145AB, 0x4675ED, 0x39A2FC, 0x1BCFD4, 0x62FC6B, 0xD2E935, 0xFDA007, 0xF05B12, 0x7A0403),
+ ),
+ JET("jet", listOf(0x000080, 0x0000FF, 0x00FFFF, 0x7FFF7F, 0xFFFF00, 0xFF0000, 0x800000)),
+ ;
+
+ /** The ramp sampled at [t] in 0..1, as a `#rrggbb` string. */
+ fun colorAt(t: Double): String {
+ val clamped = t.coerceIn(0.0, 1.0)
+ if (anchors.size == 1) return hex(anchors[0])
+
+ val position = clamped * (anchors.size - 1)
+ val lower = position.toInt().coerceAtMost(anchors.size - 2)
+ val fraction = position - lower
+ return hex(mix(anchors[lower], anchors[lower + 1], fraction))
+ }
+
+ private fun mix(from: Int, to: Int, fraction: Double): Int {
+ val r = channel(from, RED_SHIFT) + (channel(to, RED_SHIFT) - channel(from, RED_SHIFT)) * fraction
+ val g = channel(from, GREEN_SHIFT) + (channel(to, GREEN_SHIFT) - channel(from, GREEN_SHIFT)) * fraction
+ val b = channel(from, BLUE_SHIFT) + (channel(to, BLUE_SHIFT) - channel(from, BLUE_SHIFT)) * fraction
+ return (r.roundToInt() shl RED_SHIFT) or (g.roundToInt() shl GREEN_SHIFT) or b.roundToInt()
+ }
+
+ private fun channel(color: Int, shift: Int): Double = ((color shr shift) and CHANNEL_MASK).toDouble()
+
+ private fun hex(color: Int): String = buildString {
+ append('#')
+ for (shift in listOf(RED_SHIFT, GREEN_SHIFT, BLUE_SHIFT)) {
+ val value = (color shr shift) and CHANNEL_MASK
+ append(HEX_DIGITS[value shr NIBBLE_BITS])
+ append(HEX_DIGITS[value and NIBBLE_MASK])
+ }
+ }
+
+ companion object {
+ /** The palette the planner names, or [PLASMA] (its own default) when the name is unknown. */
+ fun forKey(key: String?): CoveragePalette =
+ entries.firstOrNull { it.key.equals(key, ignoreCase = true) } ?: PLASMA
+
+ private const val RED_SHIFT = 16
+ private const val GREEN_SHIFT = 8
+ private const val BLUE_SHIFT = 0
+ private const val CHANNEL_MASK = 0xFF
+ private const val NIBBLE_BITS = 4
+ private const val NIBBLE_MASK = 0xF
+ private val HEX_DIGITS = "0123456789abcdef".toCharArray()
+ }
+}
+
+/**
+ * How a [CoverageGrid] is drawn: the form's Display section.
+ *
+ * [minDbm] and [maxDbm] fix the ends of the ramp rather than letting the strongest cell define them, so two sites are
+ * comparable and the picker means what it says.
+ */
+class CoverageStyle(
+ val palette: CoveragePalette = CoveragePalette.PLASMA,
+ val minDbm: Double = DEFAULT_MIN_DBM,
+ val maxDbm: Double = DEFAULT_MAX_DBM,
+ /** 0 is invisible, 1 is solid: the planner's transparency percentage inverted. */
+ val opacity: Double = DEFAULT_OPACITY,
+) {
+ init {
+ require(maxDbm > minDbm) { "maxDbm must exceed minDbm, got $minDbm..$maxDbm" }
+ }
+
+ companion object {
+ const val DEFAULT_MIN_DBM = -130.0
+ const val DEFAULT_MAX_DBM = -80.0
+ const val DEFAULT_OPACITY = 0.5
+
+ /** From the planner's 0–100 transparency, where 0 is opaque. */
+ fun fromTransparency(
+ palette: String?,
+ minDbm: Double,
+ maxDbm: Double,
+ transparencyPercent: Int,
+ ): CoverageStyle = CoverageStyle(
+ palette = CoveragePalette.forKey(palette),
+ minDbm = minDbm,
+ maxDbm = maxDbm,
+ opacity = 1.0 - (transparencyPercent.coerceIn(0, PERCENT) / PERCENT.toDouble()),
+ )
+
+ private const val PERCENT = 100
+ }
+}
diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt
new file mode 100644
index 00000000000..5759d4d449f
--- /dev/null
+++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt
@@ -0,0 +1,271 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import org.meshtastic.kp1812.Atmosphere
+import org.meshtastic.kp1812.P1812
+import org.meshtastic.kp1812.Polarization
+import org.meshtastic.kp1812.TerrainPath
+import kotlin.math.PI
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.max
+import kotlin.math.sin
+
+/**
+ * RF coverage computed on the device from `org.meshtastic:kp1812` (ITU-R P.1812) and an [ElevationSource]. It works
+ * offline once terrain is cached. P.1812 is a different model from the hosted planner's SPLAT! ITM, so predictions
+ * don't match it pixel for pixel.
+ */
+class LocalCoverage(private val elevation: ElevationSource, private val atmosphere: Atmosphere = Atmosphere()) {
+
+ /**
+ * Sweep radials out from [site] and return received signal strength at every sample point.
+ *
+ * Terrain profile resolution and receiver spacing are deliberately **decoupled**. P.1812 evaluates diffraction
+ * across the whole profile between transmitter and receiver, so the profile has to be sampled near the terrain's
+ * own resolution (Mapterhorn at z11 is ~75 m/px). Sampling it at the receiver spacing instead (900 m over a 30 km
+ * radius) makes the model see a jagged, aliased profile and invent diffraction loss that is not there: visible as
+ * spurious rings and spokes in the plot, and a badly depressed reachable fraction.
+ *
+ * The expensive part is the number of [P1812.predict] calls, not the profile length, so a dense profile with sparse
+ * receivers is both more accurate and no more costly.
+ *
+ * @param site the transmitter
+ * @param radials how many bearings to sweep
+ * @param receiversPerRadial how many receiver positions to evaluate along each radial
+ * @param profileStepKm spacing of the terrain profile itself
+ */
+ suspend fun sweep(
+ site: Site,
+ radials: Int = DEFAULT_RADIALS,
+ receiversPerRadial: Int = DEFAULT_RECEIVERS,
+ profileStepKm: Double = DEFAULT_PROFILE_STEP_KM,
+ ): Coverage {
+ require(radials >= MIN_RADIALS) { "radials must be >= $MIN_RADIALS, got $radials" }
+ require(receiversPerRadial >= MIN_RECEIVERS) {
+ "receiversPerRadial must be >= $MIN_RECEIVERS, got $receiversPerRadial"
+ }
+ require(profileStepKm > 0) { "profileStepKm must be > 0, got $profileStepKm" }
+
+ // Profile points, at terrain resolution.
+ val profilePoints = maxOf((site.radiusKm / profileStepKm).toInt() + 1, MIN_PROFILE_POINTS)
+ val stepKm = site.radiusKm / (profilePoints - 1)
+ // Which profile indices carry a receiver. Never the first two: P.1812 needs interior points.
+ val firstReceiver = MIN_PROFILE_POINTS - 1
+ val receiverStride = maxOf((profilePoints - firstReceiver) / receiversPerRadial, 1)
+
+ val points = ArrayList(radials * receiversPerRadial)
+
+ for (i in 0 until radials) {
+ val bearing = FULL_CIRCLE_DEG * i / radials
+ val lats = DoubleArray(profilePoints)
+ val lons = DoubleArray(profilePoints)
+ val heights = DoubleArray(profilePoints)
+ for (s in 0 until profilePoints) {
+ val (lat, lon) = destination(site.latitude, site.longitude, bearing, stepKm * s)
+ lats[s] = lat
+ lons[s] = lon
+ heights[s] = elevation.elevationMeters(lat, lon)
+ }
+
+ var end = firstReceiver
+ while (end < profilePoints) {
+ points.add(CoveragePoint(lats[end], lons[end], predictAlong(site, heights, lats[end], stepKm, end)))
+ end += receiverStride
+ }
+ }
+ return Coverage(site, points)
+ }
+
+ /**
+ * Predict received power at one receiver position, building the terrain profile along the way.
+ *
+ * The profile is sampled at [profileStepKm] regardless of how far apart receivers are: P.1812 integrates
+ * diffraction across the whole profile, so sampling it at receiver spacing makes the model see an aliased ridge
+ * line and invent loss that is not there.
+ */
+ internal suspend fun predictAt(
+ site: Site,
+ rxLat: Double,
+ rxLon: Double,
+ distanceKm: Double,
+ profileStepKm: Double,
+ ): Double {
+ val n = maxOf((distanceKm / profileStepKm).toInt() + 1, MIN_PROFILE_POINTS)
+ val step = distanceKm / (n - 1)
+ val bearing = bearingDeg(site.latitude, site.longitude, rxLat, rxLon)
+ val d = DoubleArray(n) { step * it }
+ val h =
+ DoubleArray(n) { i ->
+ val (lat, lon) = destination(site.latitude, site.longitude, bearing, step * i)
+ elevation.elevationMeters(lat, lon)
+ }
+ val prediction =
+ P1812.predict(
+ path = TerrainPath(d, h, DoubleArray(n) { site.clutterHeightM }, IntArray(n) { INLAND }),
+ frequencyGhz = site.frequencyMhz / MHZ_PER_GHZ,
+ txHeightM = site.txHeightM,
+ rxHeightM = site.rxHeightM,
+ timePercent = site.timePercent,
+ pathCenterLatitudeDeg = (site.latitude + rxLat) / 2.0,
+ polarization = Polarization.VERTICAL,
+ atmosphere = atmosphere,
+ )
+ return P1812.receivedPower(
+ prediction,
+ txPowerDbm = site.txPowerDbm,
+ txGainDbi = site.txGainDbi,
+ rxGainDbi = site.rxGainDbi,
+ )
+ .value
+ }
+
+ /** Terrain height at one position, through this coverage's elevation source. */
+ internal suspend fun elevationAt(latitude: Double, longitude: Double): Double =
+ elevation.elevationMeters(latitude, longitude)
+
+ /**
+ * Predict received power at profile index [end] of an already-walked radial.
+ *
+ * [heights] is the whole radial; the receiver takes the prefix through [end], which is what lets one walk serve
+ * every ring on a bearing.
+ */
+ internal fun predictAlong(site: Site, heights: DoubleArray, rxLat: Double, stepKm: Double, end: Int): Double {
+ val n = end + 1
+ val prediction =
+ P1812.predict(
+ path =
+ TerrainPath(
+ DoubleArray(n) { stepKm * it },
+ DoubleArray(n) { heights[it] },
+ DoubleArray(n) { site.clutterHeightM },
+ IntArray(n) { INLAND },
+ ),
+ frequencyGhz = site.frequencyMhz / MHZ_PER_GHZ,
+ txHeightM = site.txHeightM,
+ rxHeightM = site.rxHeightM,
+ timePercent = site.timePercent,
+ pathCenterLatitudeDeg = (site.latitude + rxLat) / 2.0,
+ polarization = Polarization.VERTICAL,
+ atmosphere = atmosphere,
+ )
+ return P1812.receivedPower(
+ prediction,
+ txPowerDbm = site.txPowerDbm,
+ txGainDbi = site.txGainDbi,
+ rxGainDbi = site.rxGainDbi,
+ )
+ .value
+ }
+
+ private companion object {
+ const val DEFAULT_RADIALS = 180
+ const val DEFAULT_RECEIVERS = 40
+
+ /** ~100 m, close to Mapterhorn z11's ~75 m/px. */
+ const val DEFAULT_PROFILE_STEP_KM = 0.1
+ const val MIN_RADIALS = 4
+ const val MIN_RECEIVERS = 2
+ const val MIN_PROFILE_POINTS = 3
+ const val MHZ_PER_GHZ = 1000.0
+ const val INLAND = 4
+ }
+}
+
+/** Elevation above mean sea level, meters. Backed by Mapterhorn tiles in the app; trivially fakeable in tests. */
+fun interface ElevationSource {
+ suspend fun elevationMeters(latitude: Double, longitude: Double): Double
+}
+
+/** The transmitter and the radio configuration to model. */
+data class Site(
+ val name: String,
+ val latitude: Double,
+ val longitude: Double,
+ val frequencyMhz: Double,
+ val txPowerDbm: Double,
+ val rxSensitivityDbm: Double,
+ val txHeightM: Double = 2.0,
+ val rxHeightM: Double = 1.0,
+ val txGainDbi: Double = 2.0,
+ val rxGainDbi: Double = 0.0,
+ val radiusKm: Double = 15.0,
+ val clutterHeightM: Double = 1.0,
+ /** Percentage of time the prediction holds. P.1812 accepts 1..50. */
+ val timePercent: Double = 50.0,
+)
+
+/** One sampled receiver position and the signal predicted there. */
+data class CoveragePoint(val latitude: Double, val longitude: Double, val rxDbm: Double)
+
+/** The result of a sweep. */
+data class Coverage(val site: Site, val points: List) {
+ /** Points at or above the receiver's sensitivity: where a node would actually hear this site. */
+ val reachable: List
+ get() = points.filter { it.rxDbm >= site.rxSensitivityDbm }
+
+ /** Fraction of sampled points that are reachable. */
+ val reachableFraction: Double
+ get() = if (points.isEmpty()) 0.0 else reachable.size.toDouble() / points.size
+
+ /** Greatest distance at which any sampled point is reachable, km. */
+ val maxRangeKm: Double
+ get() = reachable.maxOfOrNull { haversineKm(site.latitude, site.longitude, it.latitude, it.longitude) } ?: 0.0
+}
+
+private const val EARTH_RADIUS_KM = 6371.0
+
+/** Great-circle destination from a start point along [bearingDeg] for [distanceKm]. */
+fun destination(latDeg: Double, lonDeg: Double, bearingDeg: Double, distanceKm: Double): Pair {
+ val ang = distanceKm / EARTH_RADIUS_KM
+ val lat1 = latDeg.toRadians()
+ val lon1 = lonDeg.toRadians()
+ val brg = bearingDeg.toRadians()
+ val lat2 = asin(sin(lat1) * cos(ang) + cos(lat1) * sin(ang) * cos(brg))
+ val lon2 = lon1 + atan2(sin(brg) * sin(ang) * cos(lat1), cos(ang) - sin(lat1) * sin(lat2))
+ return lat2.toDegrees() to lon2.toDegrees()
+}
+
+/** Initial great-circle bearing from one point to another, degrees. */
+fun bearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val p1 = lat1.toRadians()
+ val p2 = lat2.toRadians()
+ val dl = (lon2 - lon1).toRadians()
+ val y = sin(dl) * cos(p2)
+ val x = cos(p1) * sin(p2) - sin(p1) * cos(p2) * cos(dl)
+ return (atan2(y, x).toDegrees() + FULL_CIRCLE_DEG) % FULL_CIRCLE_DEG
+}
+
+/** Great-circle distance between two points, km. Public: a consumer plotting a [Coverage] needs it. */
+fun haversineKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val dLat = (lat2 - lat1).toRadians()
+ val dLon = (lon2 - lon1).toRadians()
+ val a =
+ sin(dLat / HALF) * sin(dLat / HALF) +
+ cos(lat1.toRadians()) * cos(lat2.toRadians()) * sin(dLon / HALF) * sin(dLon / HALF)
+ return HALF * EARTH_RADIUS_KM * asin(max(-1.0, kotlin.math.min(1.0, kotlin.math.sqrt(a))))
+}
+
+private fun Double.toRadians(): Double = this * PI / STRAIGHT_ANGLE_DEG
+
+private fun Double.toDegrees(): Double = this * STRAIGHT_ANGLE_DEG / PI
+
+private const val FULL_CIRCLE_DEG = 360.0
+private const val STRAIGHT_ANGLE_DEG = 180.0
+private const val HALF = 2.0
diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt
new file mode 100644
index 00000000000..9c9c6b32b59
--- /dev/null
+++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.coroutineScope
+import org.meshtastic.feature.map.terrain.ElevationTile
+import org.meshtastic.feature.map.terrain.GeoBounds
+import org.meshtastic.feature.map.terrain.MapterhornEndpoints
+import org.meshtastic.feature.map.terrain.TerrainTileMath
+import org.meshtastic.feature.map.terrain.TerrainTileStore
+import org.meshtastic.feature.map.terrain.decodeTerrariumTile
+import kotlin.concurrent.Volatile
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.ln
+import kotlin.math.tan
+
+/**
+ * Real elevation from the same Mapterhorn terrain the map already uses for hillshade and contours.
+ *
+ * Common, not desktop-only: tile bytes arrive over ktor and `decodeTerrariumTile` is already expect/actual with android
+ * and jvm actuals. The native actual of the decoder throws by design (see its doc comment in feature:map-terrain),
+ * which is the same trade that module already makes.
+ *
+ * This is what makes a coverage plot mean anything: over flat synthetic ground it is a bullseye and proves nothing.
+ * Against real terrain the prediction has to show ridges shadowing valleys, which is the entire reason the planner
+ * exists.
+ *
+ * Decoded tiles are shared process-wide and, given a [store], kept on disk between launches.
+ */
+class MapterhornElevation(
+ /** The area the caller will sample, which is what [prefetch] warms when given no box of its own. */
+ private val bounds: GeoBounds? = null,
+ /** Where to keep tiles between launches. Without one, every launch re-downloads its terrain. */
+ store: TerrainTileStore? = null,
+ zoom: Int = DEFAULT_ZOOM,
+ http: HttpClient? = null,
+) : ElevationSource,
+ AutoCloseable {
+
+ private val zoom = zoomFitting(zoom.coerceAtMost(MapterhornEndpoints.TILES_MAX_ZOOM), bounds)
+
+ // Shared, and never closed here: a caller's client is theirs, and the shared one outlives us.
+ private val tiles = MapterhornTiles(http ?: SharedTerrain.http, store)
+
+ @Suppress("InjectDispatcher") // tile decoding is CPU work, and callers build this outside DI
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ // Decoded terrain outlives this instance - see TerrainCache. Memoised after the first suspend
+ // acquisition; two callers racing here both get the same cache, so the race is harmless.
+ @Volatile private var cache: TerrainCache? = null
+
+ /** The zoom actually in use, for reporting. */
+ val zoomLevel: Int
+ get() = zoom
+
+ private suspend fun cache(): TerrainCache = cache ?: SharedTerrain.forArchive(ARCHIVE, zoom).also { cache = it }
+
+ override suspend fun elevationMeters(latitude: Double, longitude: Double): Double {
+ val tile = TerrainTileMath.tileAt(zoom, latitude, longitude)
+ val key = tileKey(tile.x, tile.y)
+ val snapshot = cache().snapshot()
+ // Ocean, or outside the endpoint's data, decodes to null: sea level, as the planner assumes too.
+ val decoded = (if (snapshot.containsKey(key)) snapshot[key] else awaitTile(key, tile.x, tile.y)) ?: return 0.0
+
+ // Fractional position of this coordinate within its tile.
+ val n = 1 shl zoom
+ val fx = (longitude + STRAIGHT_ANGLE_DEG) / FULL_CIRCLE_DEG * n - tile.x
+ val latRad = latitude * PI / STRAIGHT_ANGLE_DEG
+ val fy = (1.0 - ln(tan(latRad) + 1.0 / cos(latRad)) / PI) / HALF * n - tile.y
+ val px = (fx * decoded.width).toInt().coerceIn(0, decoded.width - 1)
+ val py = (fy * decoded.height).toInt().coerceIn(0, decoded.height - 1)
+ return decoded.elevationAt(px, py).toDouble()
+ }
+
+ /** Warm every tile a sweep of [site] will touch. */
+ suspend fun prefetch(site: Site): Int = prefetch(site.coverageBounds())
+
+ /**
+ * Warm every tile inside [area] before anything asks for one, many at a time.
+ *
+ * Optional (sampling fetches on demand without it), and returns how many tiles it warmed. Worth calling anyway:
+ * on-demand misses arrive one radial at a time, while this issues the whole disc at once and lets the requests
+ * overlap.
+ */
+ suspend fun prefetch(area: GeoBounds? = bounds): Int {
+ val cache = cache()
+ val resident = cache.snapshot()
+ val wanted =
+ area
+ ?.let { box ->
+ val nw = TerrainTileMath.tileAt(zoom, box.north, box.west)
+ val se = TerrainTileMath.tileAt(zoom, box.south, box.east)
+ buildList {
+ for (x in nw.x..se.x) {
+ for (y in nw.y..se.y) {
+ val key = tileKey(x, y)
+ if (!resident.containsKey(key)) add(TileRequest(key, x, y))
+ }
+ }
+ }
+ }
+ .orEmpty()
+ if (wanted.isEmpty()) return 0
+
+ val warmed = coroutineScope {
+ wanted.map { request -> async { request.key to decode(request.x, request.y) } }.awaitAll()
+ }
+ cache.publish(warmed)
+ return warmed.size
+ }
+
+ /** How many tiles are decoded and resident, useful when reporting a run. */
+ val tilesResident: Int
+ get() = cache?.size ?: 0
+
+ private suspend fun awaitTile(key: Long, x: Int, y: Int): ElevationTile? =
+ cache().getOrFetch(key) { scope.async { decode(x, y) } }
+
+ // Null means the endpoint has no tile there, so it must never stand in for a failed decode: a corrupt cached
+ // tile gets one fresh download, and bytes that still don't decode fail the estimate.
+ private suspend fun decode(x: Int, y: Int): ElevationTile? {
+ val cached = tiles.bytes(zoom, x, y) ?: return null
+ return runCatching { decodeTerrariumTile(cached) }.getOrNull()
+ ?: tiles.bytes(zoom, x, y, fresh = true)?.let { decodeTerrariumTile(it) }
+ }
+
+ override fun close() = scope.cancel()
+
+ /** One tile a prefetch still has to fetch. */
+ private class TileRequest(val key: Long, val x: Int, val y: Int)
+
+ companion object {
+ /** Below this one tile spans a continent; the guard never needs to go further. */
+ const val MIN_ZOOM = 6
+
+ /**
+ * ~37 m per pixel at mid latitudes, which matches the 50 m terrain profile step.
+ *
+ * Measured against z13 on the Seattle demo: identical reachable fraction and max range, and an rx range of
+ * -147.8..-41.3 against -148.2..-41.3, for 72 tiles instead of 256. The extra detail lands below the step the
+ * model samples at. The endpoint serves up to [MapterhornEndpoints.TILES_MAX_ZOOM] for anyone who wants it.
+ */
+ const val DEFAULT_ZOOM = 12
+ }
+}
+
+/** The cache is keyed by endpoint, so a second terrain source wouldn't collide with this one. */
+private const val ARCHIVE = "tiles.mapterhorn.com"
+
+/** Pack a tile's coordinates into one cache key. */
+private fun tileKey(x: Int, y: Int): Long = (x.toLong() shl TILE_KEY_SHIFT) or y.toLong()
+
+/**
+ * The lat/lon box a sweep of this site samples.
+ *
+ * Pass it to [MapterhornElevation] so a prefetch knows what ground to warm.
+ */
+internal fun Site.coverageBounds(): GeoBounds {
+ val latSpan = radiusKm / KM_PER_DEG_LAT
+ val lonSpan = latSpan / cos(latitude * DEG_TO_RAD)
+ return GeoBounds(
+ south = latitude - latSpan,
+ west = longitude - lonSpan,
+ north = latitude + latSpan,
+ east = longitude + lonSpan,
+ )
+}
+
+private const val TILE_KEY_SHIFT = 32
+private const val FULL_CIRCLE_DEG = 360.0
+private const val STRAIGHT_ANGLE_DEG = 180.0
+private const val HALF = 2.0
+private const val KM_PER_DEG_LAT = 111.32
+private const val DEG_TO_RAD = 0.017453292519943295
+
+/**
+ * The deepest zoom at or below [wanted] whose tiles for [bounds] still fit the shared cache.
+ *
+ * Tiles scale with the square of the sampled radius and with 1/cos(latitude), so a fixed zoom is only ever right for
+ * one area. At z12 a 30 km disc is ~120 tiles at mid latitudes but ~360 above 65°N, and the radius is a free-text
+ * field: 70 km asks for 500 to 1800. Past the cache's capacity the failure is not graceful: eviction is insertion
+ * order, so the sweep evicts the very tiles it is about to read and re-decodes the whole disc on every pass. Measured
+ * once at ~1800 tiles: 133 MB downloaded and seconds per estimate instead of hundreds of milliseconds.
+ *
+ * Dropping a zoom quarters the tile count, so this converges immediately and leaves the common case untouched: a 30 km
+ * disc at mid latitudes still samples at z12.
+ */
+internal fun zoomFitting(wanted: Int, bounds: GeoBounds?): Int {
+ if (bounds == null) return wanted
+ var zoom = wanted
+ while (zoom > MapterhornElevation.MIN_ZOOM && tilesSpanning(bounds, zoom) > TerrainCache.DEFAULT_CAPACITY) {
+ zoom--
+ }
+ return zoom
+}
+
+/** How many tiles [bounds] covers at [zoom]. */
+internal fun tilesSpanning(bounds: GeoBounds, zoom: Int): Int {
+ val nw = TerrainTileMath.tileAt(zoom, bounds.north, bounds.west)
+ val se = TerrainTileMath.tileAt(zoom, bounds.south, bounds.east)
+ return (se.x - nw.x + 1) * (se.y - nw.y + 1)
+}
diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt
new file mode 100644
index 00000000000..3960c06b9d5
--- /dev/null
+++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsBytes
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.isSuccess
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
+import org.meshtastic.feature.map.terrain.MapterhornEndpoints
+import org.meshtastic.feature.map.terrain.TerrainSource
+import org.meshtastic.feature.map.terrain.TerrainTileStore
+import org.meshtastic.feature.map.terrain.TileIndex
+
+/**
+ * Terrarium tile bytes, from local storage if they are there and from Mapterhorn's XYZ endpoint if they are not.
+ *
+ * Plain per-tile requests rather than range reads into `planet.pmtiles`. The archive is one seekable channel, so its
+ * tiles come back strictly one at a time, measured at ~300 ms each, which for a 25 km disc was the whole cost of a
+ * coverage sweep. These are independent, Cloudflare-cached, and suspend rather than block, so the only limit is how
+ * many we choose to have in flight.
+ *
+ * Anything fetched is written back to [store] when there is one, so the next run (or the next launch) pays nothing for
+ * the same ground.
+ */
+internal class MapterhornTiles(
+ private val http: HttpClient,
+ private val store: TerrainTileStore?,
+ concurrency: Int = DEFAULT_CONCURRENCY,
+) {
+ private val gate = Semaphore(concurrency)
+
+ /**
+ * Raw Terrarium WebP for one tile, or null where the endpoint has no data (ocean, out of range). [fresh] skips the
+ * stored copy and overwrites it, for a stored tile that didn't decode.
+ */
+ suspend fun bytes(zoom: Int, x: Int, y: Int, fresh: Boolean = false): ByteArray? {
+ val tile = TileIndex(zoom, x, y)
+ val source = sourceFor(zoom)
+ val stored = if (fresh) null else readLocal(source, tile)
+ return stored ?: download(zoom, x, y)?.also { writeLocal(source, tile, it) }
+ }
+
+ /**
+ * Only 404 means "no tile here" (ocean, or past the endpoint's deepest zoom), and only 404 is allowed to return
+ * null, because null is cached as flat ground for the rest of the process. A throttled or failed request that
+ * returned null would quietly turn a mountain into sea level, so it throws and the caller surfaces a failed
+ * estimate instead.
+ */
+ @Suppress("SuspendFunSwallowedCancellation") // the retry's delay() rethrows a cancellation the first get swallowed
+ private suspend fun download(zoom: Int, x: Int, y: Int): ByteArray? = gate.withPermit {
+ val url = MapterhornEndpoints.tileUrl(zoom, x, y)
+ val response = runCatching {
+ http.get(url)
+ }
+ .getOrElse {
+ // One retry: a sweep asks for its whole disc at once, and losing an estimate to a single
+ // transient blip is worse than the second the retry costs.
+ delay(RETRY_DELAY_MS)
+ http.get(url)
+ }
+ when {
+ response.status.isSuccess() -> response.bodyAsBytes()
+ response.status == HttpStatusCode.NotFound -> null
+ else -> error("terrain tile $zoom/$x/$y: ${response.status}")
+ }
+ }
+
+ /** Okio is blocking, so file access goes to IO rather than stalling a compute thread. */
+ private suspend fun readLocal(source: TerrainSource, tile: TileIndex): ByteArray? {
+ val local = store ?: return null
+ return withContext(ioDispatcher) { runCatching { local.readTile(source, tile) }.getOrNull() }
+ }
+
+ private suspend fun writeLocal(source: TerrainSource, tile: TileIndex, webpBytes: ByteArray) {
+ val local = store ?: return
+ // A failed write is a slower next run, never a failed prediction.
+ withContext(ioDispatcher) { runCatching { local.writeTile(source, tile, webpBytes) } }
+ }
+
+ /** The store splits tiles by tier, and the app's own terrain download writes them the same way. */
+ private fun sourceFor(zoom: Int): TerrainSource =
+ if (zoom > MapterhornEndpoints.GLOBAL_MAX_ZOOM) TerrainSource.REGIONAL else TerrainSource.GLOBAL
+
+ private companion object {
+ /**
+ * In-flight requests. HTTP/2 multiplexes these over one connection and each is ~130 ms, so this is about how
+ * much of the round-trip latency we hide, not about sockets.
+ */
+ const val DEFAULT_CONCURRENCY = 24
+
+ /** Long enough for a momentary throttle to clear, short enough not to be felt. */
+ const val RETRY_DELAY_MS = 500L
+ }
+}
diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt
new file mode 100644
index 00000000000..a0d9dc90be9
--- /dev/null
+++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlin.math.cos
+import kotlin.math.floor
+
+/**
+ * Received power on a polar lattice centered on the site: [radials] bearings by [rings] ranges.
+ *
+ * Coverage is computed in polar and only then rasterised, because a receiver's prediction needs the whole terrain
+ * profile back to the transmitter. On a radial every receiver shares one profile (the far one is the near one plus a
+ * few points), so a bearing's worth of predictions costs a single walk. Sampling the cartesian grid directly re-walks a
+ * profile per cell and re-reads the same terrain thousands of times over.
+ *
+ * `dbm` is indexed `bearing * rings + ring`; ring `r` sits at `firstKm + r * ringStepKm`.
+ */
+class PolarCoverage(
+ val site: Site,
+ val radials: Int,
+ val rings: Int,
+ val firstKm: Double,
+ val ringStepKm: Double,
+ val dbm: DoubleArray,
+) {
+ init {
+ require(dbm.size == radials * rings) { "dbm must be radials*rings, got ${dbm.size} for $radials x $rings" }
+ }
+
+ /** Received power at ring [r] of bearing index [b], which wraps. */
+ fun at(b: Int, r: Int): Double {
+ val bb = ((b % radials) + radials) % radials
+ return dbm[bb * rings + r]
+ }
+
+ /** Distance of ring [r] from the site, km. */
+ fun ringKm(r: Int): Double = firstKm + r * ringStepKm
+}
+
+/**
+ * Sweep coverage in polar form, one shared terrain profile per bearing.
+ *
+ * [profileStepKm] is the terrain sampling interval and should track the elevation source's own resolution; [rings] is
+ * how many of those steps carry a receiver. They are independent because the cost is the [P1812.predict] calls, not the
+ * profile length: a dense profile with sparse receivers is both cheaper and more accurate than matching the two.
+ */
+suspend fun LocalCoverage.sweepPolar(
+ site: Site,
+ radials: Int = DEFAULT_RADIALS,
+ rings: Int = DEFAULT_RINGS,
+ profileStepKm: Double = DEFAULT_PROFILE_STEP_KM,
+): PolarCoverage {
+ require(radials >= MIN_RADIALS) { "radials must be >= $MIN_RADIALS, got $radials" }
+ require(rings >= MIN_RINGS) { "rings must be >= $MIN_RINGS, got $rings" }
+ require(profileStepKm > 0) { "profileStepKm must be > 0, got $profileStepKm" }
+
+ val profilePoints = maxOf((site.radiusKm / profileStepKm).toInt() + 1, MIN_PROFILE_POINTS)
+ val stepKm = site.radiusKm / (profilePoints - 1)
+ // P.1812 needs interior profile points, so the innermost receiver is never the first or second.
+ val firstIndex = MIN_PROFILE_POINTS - 1
+ val lastIndex = profilePoints - 1
+ val indexStride = (lastIndex - firstIndex).toDouble() / (rings - 1)
+
+ val out = DoubleArray(radials * rings)
+ // Radials are independent, and a sweep is pure arithmetic once the terrain is cached, so this
+ // scales with cores. Each writes its own disjoint slice of `out`, so there is nothing to guard.
+ // The dispatcher is explicit: a caller on runBlocking or the main thread would otherwise hand
+ // every radial to that one confined thread and the sweep would stay serial.
+ @Suppress("InjectDispatcher")
+ coroutineScope {
+ (0 until radials)
+ .map { b ->
+ async(Dispatchers.Default) {
+ val bearing = FULL_CIRCLE * b / radials
+ // One walk per bearing: every ring on this radial is a prefix of this profile.
+ val lats = DoubleArray(profilePoints)
+ val heights = DoubleArray(profilePoints)
+ for (s in 0 until profilePoints) {
+ val (lat, lon) = destination(site.latitude, site.longitude, bearing, stepKm * s)
+ lats[s] = lat
+ heights[s] = elevationAt(lat, lon)
+ }
+ for (r in 0 until rings) {
+ val end = firstIndex + (r * indexStride).toInt()
+ out[b * rings + r] = predictAlong(site, heights, lats[end], stepKm, end)
+ }
+ }
+ }
+ .awaitAll()
+ }
+ return PolarCoverage(
+ site = site,
+ radials = radials,
+ rings = rings,
+ firstKm = stepKm * firstIndex,
+ ringStepKm = stepKm * indexStride,
+ dbm = out,
+ )
+}
+
+/**
+ * Resample the polar lattice onto the regular grid the contour tracer needs.
+ *
+ * Bilinear in (bearing, range), so the grid is smooth rather than showing the lattice's own spokes. The grid can be far
+ * finer than the lattice for free (it costs no predictions), which is what lets the bands follow terrain instead of the
+ * sampling pattern.
+ */
+fun PolarCoverage.toGrid(resolution: Int = DEFAULT_GRID): CoverageGrid {
+ require(resolution >= MIN_GRID) { "resolution must be >= $MIN_GRID, got $resolution" }
+
+ val latSpanDeg = site.radiusKm / KM_PER_DEG_LAT
+ val lonSpanDeg = latSpanDeg / cos(site.latitude * DEG_TO_RAD)
+ val north = site.latitude + latSpanDeg
+ val south = site.latitude - latSpanDeg
+ val east = site.longitude + lonSpanDeg
+ val west = site.longitude - lonSpanDeg
+
+ val out = DoubleArray(resolution * resolution) { Double.NaN }
+ for (y in 0 until resolution) {
+ val lat = north - (north - south) * y / (resolution - 1)
+ for (x in 0 until resolution) {
+ val lon = west + (east - west) * x / (resolution - 1)
+ val km = haversineKm(site.latitude, site.longitude, lat, lon)
+ if (km > site.radiusKm) continue
+
+ // Inside the innermost ring and outside the outermost one both clamp: the lattice covers
+ // the whole disc, and clamping is a nearer neighbor than any extrapolation would be.
+ val rPos = ((km - firstKm) / ringStepKm).coerceIn(0.0, (rings - 1).toDouble())
+ val r0 = floor(rPos).toInt().coerceAtMost(rings - 1)
+ val r1 = (r0 + 1).coerceAtMost(rings - 1)
+ val rf = rPos - r0
+
+ // Bearings wrap, so no clamping here - PolarCoverage.at takes the index modulo.
+ val bPos = bearingDeg(site.latitude, site.longitude, lat, lon) / FULL_CIRCLE * radials
+ val b0 = floor(bPos).toInt()
+ val bf = bPos - b0
+
+ val lower = at(b0, r0) + (at(b0 + 1, r0) - at(b0, r0)) * bf
+ val upper = at(b0, r1) + (at(b0 + 1, r1) - at(b0, r1)) * bf
+ out[y * resolution + x] = lower + (upper - lower) * rf
+ }
+ }
+ return CoverageGrid(site, resolution, resolution, north, south, east, west, out)
+}
+
+/**
+ * Matched to a 256-cell grid over a 25 km radius: 720 bearings put ~220 m between radials at the rim and 128 rings put
+ * ~195 m between ranges, both about one grid cell. Denser buys nothing the grid can show; sparser is what leaves the
+ * sampling pattern visible as spokes.
+ */
+internal const val DEFAULT_RADIALS = 720
+internal const val DEFAULT_RINGS = 128
+
+/**
+ * Terrain sampling along a radial, at the global archive's own z12 resolution (~37 m at mid latitudes). Cheap: the
+ * profile is walked once per bearing, and the predictions that consume it dominate. Stepping at 100 m instead
+ * measurably under-reports shadowing.
+ */
+internal const val DEFAULT_PROFILE_STEP_KM = 0.05
+private const val MIN_RADIALS = 8
+private const val MIN_RINGS = 4
+private const val MIN_PROFILE_POINTS = 3
+private const val MIN_GRID = 8
+private const val FULL_CIRCLE = 360.0
+private const val KM_PER_DEG_LAT = 111.32
+private const val DEG_TO_RAD = 0.017453292519943295
diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt
new file mode 100644
index 00000000000..8ff24c81a8f
--- /dev/null
+++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import okio.FileSystem
+import okio.Path
+import org.koin.compose.koinInject
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.feature.map.component.SitePlannerParams
+import org.meshtastic.feature.map.terrain.TerrainTileStore
+import kotlin.math.log10
+
+/**
+ * Computes a Site Planner estimate on this device: Mapterhorn terrain for the coverage disc, cached under [store], then
+ * the ITU-R P.1812 sweep, returned as the styled GeoJSON iso-bands a map layer takes.
+ *
+ * Throws when terrain can't be fetched or a value is outside what P.1812 accepts. Cancellation propagates.
+ */
+suspend fun estimateCoverageGeoJson(
+ params: SitePlannerParams,
+ store: TerrainTileStore,
+ dispatcher: CoroutineDispatcher,
+): String = withContext(dispatcher) {
+ val site = params.toSite()
+ // The bounds let the zoom drop for a wide disc, so its tiles fit the decoded cache.
+ MapterhornElevation(bounds = site.coverageBounds(), store = store).use { source ->
+ source.prefetch(site)
+ LocalCoverage(source).sweepGrid(site).toGeoJson(params.toCoverageStyle())
+ }
+}
+
+/** [estimateCoverageGeoJson] bound to the app's compute dispatcher and its coverage terrain cache. */
+@Composable
+fun rememberCoverageEstimate(): suspend (SitePlannerParams) -> String {
+ val dispatchers: CoroutineDispatchers = koinInject()
+ return remember(dispatchers) {
+ val store = TerrainTileStore(FileSystem.SYSTEM, coverageTerrainDirectory())
+ val estimate: suspend (SitePlannerParams) -> String = { params ->
+ estimateCoverageGeoJson(params, store, dispatchers.default)
+ }
+ estimate
+ }
+}
+
+/**
+ * Where coverage terrain is cached between launches. It's kept apart from downloaded offline map regions so a coverage
+ * estimate never changes the size and tile count a region reports.
+ */
+internal expect fun coverageTerrainDirectory(): Path
+
+/** The planner form's transmitter as the coverage model's site. */
+internal fun SitePlannerParams.toSite(): Site = Site(
+ name = name,
+ latitude = latitude,
+ longitude = longitude,
+ frequencyMhz = txFreqMhz,
+ txPowerDbm = wattsToDbm(txPowerWatts),
+ rxSensitivityDbm = rxSensitivityDbm,
+ txHeightM = txHeightMeters,
+ rxHeightM = rxHeightMeters,
+ txGainDbi = txGainDbi,
+ radiusKm = maxRangeKm,
+)
+
+/** The form's Display section: palette, the dBm range the ramp spans, and overlay transparency. */
+internal fun SitePlannerParams.toCoverageStyle(): CoverageStyle = CoverageStyle.fromTransparency(
+ palette = colorScale,
+ minDbm = minDbm,
+ maxDbm = maxDbm,
+ transparencyPercent = overlayTransparency,
+)
+
+internal fun wattsToDbm(watts: Double): Double {
+ require(watts > 0.0) { "transmit power must be positive, got $watts W" }
+ return DBM_PER_DECADE * log10(watts * MILLIWATTS_PER_WATT)
+}
+
+private const val DBM_PER_DECADE = 10.0
+private const val MILLIWATTS_PER_WATT = 1000.0
diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt
new file mode 100644
index 00000000000..98cbe8883b2
--- /dev/null
+++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.meshtastic.feature.map.terrain.ElevationTile
+import kotlin.concurrent.Volatile
+
+/**
+ * Decoded terrain for one archive at one zoom, shared by every [MapterhornElevation] that reads it.
+ *
+ * A tile is immutable data keyed by archive, zoom and position, so there is nothing to scope it to a single instance,
+ * and scoping it to one was measurably wrong. The desktop planner's composable is disposed when its sheet closes, so a
+ * second estimate paid the full terrain download again for tiles it had already decoded seconds earlier: 8.8 s where
+ * the sweep itself is 0.2 s.
+ *
+ * Bounded, because a decoded tile is ~256 KB and a 25 km disc is ~70 of them. Eviction is insertion order rather than
+ * least-recently-used: a sweep's working set is one contiguous disc, so the oldest entries are from a disc the user has
+ * already moved away from, and tracking access order would cost a write on the hot read path for no better answer.
+ */
+internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) {
+
+ private val lock = Mutex()
+
+ // The in-flight fetch, not the result, so concurrent radials landing on the same tile await one
+ // download instead of starting one each.
+ private val inFlight = LinkedHashMap>()
+
+ // A read-only snapshot of what has decoded. Sampling is overwhelmingly hits, and taking the
+ // mutex for each would serialize a parallel sweep on this one lock.
+ @Volatile private var resolved: Map = emptyMap()
+
+ /** How many tiles are decoded and resident. */
+ val size: Int
+ get() = resolved.size
+
+ /**
+ * What is resident right now.
+ *
+ * Handed out as one map rather than as `contains` plus `get`, because between two calls an eviction could drop the
+ * key and the second would answer null, which a sampler reads as ocean.
+ */
+ fun snapshot(): Map = resolved
+
+ /**
+ * The decoded tile, starting [produce] only if nobody else already has.
+ *
+ * [produce] returns a [Deferred] rather than the tile so that the caller owns the scope the fetch runs in. The
+ * cache outlives any one [MapterhornElevation] and must not hold its scope.
+ */
+ @Suppress("SuspendFunSwallowedCancellation") // ensureActive() rethrows this caller's own cancellation
+ suspend fun getOrFetch(key: Long, produce: () -> Deferred): ElevationTile? {
+ while (true) {
+ // A canceled fetch must not be handed to the next caller: the scope producing it belongs to
+ // one MapterhornElevation, and canceling that (the planner sheet being dismissed mid-run)
+ // would otherwise leave a dead Deferred that fails every later sweep touching this tile.
+ var started = false
+ val pending = lock.withLock {
+ inFlight[key]?.takeUnless { it.isCancelled }
+ ?: produce().also {
+ inFlight[key] = it
+ started = true
+ }
+ }
+ val tile =
+ try {
+ pending.await()
+ } catch (e: CancellationException) {
+ // Another instance's fetch can be canceled under a caller that is still running; that caller
+ // starts its own. Its own cancellation, or its own fetch being canceled, still ends the wait.
+ currentCoroutineContext().ensureActive()
+ if (started) throw e
+ lock.withLock { if (inFlight[key] === pending) inFlight.remove(key) }
+ continue
+ }
+ publish(listOf(key to tile))
+ return tile
+ }
+ }
+
+ /** Record tiles fetched in bulk, as a prefetch does. */
+ suspend fun publish(entries: List>) = lock.withLock {
+ val merged = LinkedHashMap(resolved)
+ for ((key, tile) in entries) {
+ merged.remove(key)
+ merged[key] = tile
+ }
+ while (merged.size > capacity) {
+ val oldest = merged.keys.first()
+ merged.remove(oldest)
+ inFlight.remove(oldest)
+ }
+ resolved = merged
+ }
+
+ companion object {
+ /**
+ * A 25 km disc is ~70 tiles at z12 and a decoded tile is ~256 KB, so this holds a working set with room to pan.
+ * Asking for a deeper zoom will evict (256 tiles at z13), which the disk cache makes a decode rather than a
+ * download, but it is why z12 is the default.
+ */
+ const val DEFAULT_CAPACITY = 192
+ }
+}
+
+/**
+ * The shared caches, one per archive and zoom.
+ *
+ * Process-wide on purpose: terrain does not belong to a screen. The map keeps at most a couple of archives open, so
+ * this never needs eviction of its own.
+ */
+internal object SharedTerrain {
+
+ private val caches = HashMap()
+ private val lock = Mutex()
+
+ suspend fun forArchive(url: String, zoom: Int): TerrainCache = lock.withLock {
+ caches.getOrPut("$url@$zoom") { TerrainCache() }
+ }
+
+ /**
+ * One HTTP client for the process.
+ *
+ * Building one per estimate cost seconds (the engine brings up its own selector threads) for a client that is
+ * stateless once connected. Never closed: it is shared, and an estimate that closed it would break the next one.
+ */
+ val http: HttpClient by lazy { HttpClient() }
+}
diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt
new file mode 100644
index 00000000000..b93bbb5e6a8
--- /dev/null
+++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+class CoverageContoursTest {
+
+ @Test
+ fun aSiteNameWithQuotesAndBackslashesStaysOneJsonString() {
+ val site =
+ Site(
+ name = "Tom's \"Hill\" \\ relay",
+ latitude = 47.6,
+ longitude = -122.2,
+ frequencyMhz = 915.0,
+ txPowerDbm = 30.0,
+ rxSensitivityDbm = -130.0,
+ )
+ val grid =
+ CoverageGrid(
+ site = site,
+ width = 2,
+ height = 2,
+ north = 47.61,
+ south = 47.59,
+ east = -122.19,
+ west = -122.21,
+ dbm = DoubleArray(4) { -90.0 },
+ )
+
+ val geoJson = grid.toGeoJson()
+
+ assertTrue("\"name\": \"Tom's \\\"Hill\\\" \\\\ relay\"" in geoJson, geoJson.take(300))
+ }
+}
diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt
new file mode 100644
index 00000000000..1694aadeaba
--- /dev/null
+++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
+
+/**
+ * The Display section's palette picker: every palette the form offers resolves, and the chosen one colors the bands.
+ */
+class CoveragePaletteTest {
+
+ @Test
+ fun everyPaletteThePlannerOffersResolves() {
+ // Mirrors SitePlannerParams.COLOR_SCALES; a name the sheet can produce must not fall back.
+ for (key in listOf("plasma", "viridis", "CMRmap", "cool", "turbo", "jet")) {
+ assertEquals(key, CoveragePalette.forKey(key).key, "planner offers '$key'")
+ }
+ }
+
+ @Test
+ fun anUnknownOrAbsentNameFallsBackToThePlannersOwnDefault() {
+ assertEquals(CoveragePalette.PLASMA, CoveragePalette.forKey(null))
+ assertEquals(CoveragePalette.PLASMA, CoveragePalette.forKey("nonesuch"))
+ }
+
+ @Test
+ fun theRampSpansItsEndpointsAndStaysWellFormed() {
+ for (palette in CoveragePalette.entries) {
+ for (t in listOf(0.0, 0.25, 0.5, 0.75, 1.0)) {
+ val color = palette.colorAt(t)
+ assertTrue(Regex("^#[0-9a-f]{6}$").matches(color), "${palette.key} at $t produced '$color'")
+ }
+ assertNotEquals(
+ palette.colorAt(0.0),
+ palette.colorAt(1.0),
+ "${palette.key} should not start and end on the same color",
+ )
+ }
+ }
+
+ @Test
+ fun outOfRangePositionsClampRatherThanWrap() {
+ for (palette in CoveragePalette.entries) {
+ assertEquals(palette.colorAt(0.0), palette.colorAt(-1.0))
+ assertEquals(palette.colorAt(1.0), palette.colorAt(2.0))
+ }
+ }
+
+ @Test
+ fun knownAnchorsComeBackExactly() {
+ // The ends are anchors, so no interpolation should touch them.
+ assertEquals("#0d0887", CoveragePalette.PLASMA.colorAt(0.0))
+ assertEquals("#f0f921", CoveragePalette.PLASMA.colorAt(1.0))
+ assertEquals("#440154", CoveragePalette.VIRIDIS.colorAt(0.0))
+ assertEquals("#fde725", CoveragePalette.VIRIDIS.colorAt(1.0))
+ // cool is a straight two-stop ramp #00ffff -> #ff00ff, so its midpoint is exactly halfway.
+ assertEquals("#8080ff", CoveragePalette.COOL.colorAt(0.5))
+ }
+
+ @Test
+ fun transparencyBecomesOpacityTheRightWayRound() {
+ // The planner calls it transparency: 0 means solid, 100 means invisible.
+ assertEquals(1.0, CoverageStyle.fromTransparency("plasma", -130.0, -80.0, 0).opacity)
+ assertEquals(0.5, CoverageStyle.fromTransparency("plasma", -130.0, -80.0, 50).opacity)
+ assertEquals(0.0, CoverageStyle.fromTransparency("plasma", -130.0, -80.0, 100).opacity)
+ }
+
+ @Test
+ fun theStyleCarriesThePickedPaletteAndRange() {
+ val style = CoverageStyle.fromTransparency("turbo", minDbm = -120.0, maxDbm = -70.0, transparencyPercent = 25)
+ assertEquals(CoveragePalette.TURBO, style.palette)
+ assertEquals(-120.0, style.minDbm)
+ assertEquals(-70.0, style.maxDbm)
+ assertEquals(0.75, style.opacity)
+ }
+
+ @Test
+ fun bandsSpanTheWholeRampIncludingItsBrightestEnd() {
+ // Coloring each band by its lower bound leaves the top band at (n-1)/n, so the palette's
+ // final color is never drawn and plasma never reaches its yellow.
+ val bandCount = 6
+ val palette = CoveragePalette.PLASMA
+ val colors = (0 until bandCount).map { index -> palette.colorAt(index.toDouble() / (bandCount - 1)) }
+
+ assertEquals(palette.colorAt(0.0), colors.first())
+ assertEquals(palette.colorAt(1.0), colors.last())
+ assertEquals(bandCount, colors.distinct().size, "each band should get its own color: $colors")
+ }
+}
diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt
new file mode 100644
index 00000000000..4d2bfd62ded
--- /dev/null
+++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import kotlinx.coroutines.test.runTest
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * The local model behaves the way a coverage prediction must, without a network or a terrain download.
+ *
+ * These are behavioral assertions, not conformance ones: `kp1812`'s own suite checks the model against the ITU
+ * reference. What matters here is that this module drives it correctly.
+ */
+class LocalCoverageTest {
+
+ private val flat = ElevationSource { _, _ -> 0.0 }
+
+ private fun site(radiusKm: Double = 10.0, txPowerDbm: Double = 30.0) = Site(
+ name = "Test",
+ latitude = 47.6,
+ longitude = -122.2,
+ frequencyMhz = 915.0,
+ txPowerDbm = txPowerDbm,
+ rxSensitivityDbm = -130.0,
+ txHeightM = 10.0,
+ radiusKm = radiusKm,
+ )
+
+ @Test
+ fun signalFallsOffWithDistance() = runTest {
+ val coverage = LocalCoverage(flat).sweep(site(), radials = 4, receiversPerRadial = 20)
+ // Take one radial's worth of points and check monotone decay over flat ground.
+ val radial = coverage.points.take(18)
+ val distances = radial.map { haversineKm(47.6, -122.2, it.latitude, it.longitude) }
+ assertTrue(distances.zipWithNext().all { (a, b) -> b > a }, "samples should step outward")
+ val strengths = radial.map { it.rxDbm }
+ assertTrue(
+ strengths.first() > strengths.last(),
+ "signal should be weaker far away: ${strengths.first()} -> ${strengths.last()}",
+ )
+ }
+
+ @Test
+ fun aHillBlocksSignalBehindIt() = runTest {
+ // A ridge 5 km north of the site, 400 m tall. Everything beyond it should be worse than
+ // the same distance to the south, where the ground is flat.
+ val ridged = ElevationSource { lat, _ ->
+ val northKm = (lat - 47.6) * 111.0
+ if (northKm in 4.5..5.5) 400.0 else 0.0
+ }
+ val coverage = LocalCoverage(ridged).sweep(site(), radials = 4, receiversPerRadial = 24)
+ val far = 8.0
+ fun strengthTowards(bearingIndex: Int): Double {
+ val perRadial = coverage.points.size / 4
+ return coverage.points
+ .drop(bearingIndex * perRadial)
+ .take(perRadial)
+ .minByOrNull { abs(haversineKm(47.6, -122.2, it.latitude, it.longitude) - far) }!!
+ .rxDbm
+ }
+ val north = strengthTowards(0) // bearing 0 = north, over the ridge
+ val south = strengthTowards(2) // bearing 180 = south, flat
+ assertTrue(north < south, "signal past a 400 m ridge ($north dBm) should be weaker than flat ($south dBm)")
+ }
+
+ @Test
+ fun higherTransmitPowerReachesFurther() = runTest {
+ val low = LocalCoverage(flat).sweep(site(txPowerDbm = 17.0), radials = 4, receiversPerRadial = 20)
+ val high = LocalCoverage(flat).sweep(site(txPowerDbm = 30.0), radials = 4, receiversPerRadial = 20)
+ assertTrue(
+ high.maxRangeKm >= low.maxRangeKm,
+ "30 dBm (${high.maxRangeKm} km) should reach at least as far as 17 dBm (${low.maxRangeKm} km)",
+ )
+ assertTrue(high.reachableFraction >= low.reachableFraction)
+ }
+
+ @Test
+ fun reachableRespectsReceiverSensitivity() = runTest {
+ val coverage = LocalCoverage(flat).sweep(site(), radials = 4, receiversPerRadial = 12)
+ assertTrue(coverage.points.isNotEmpty())
+ assertTrue(coverage.reachable.all { it.rxDbm >= coverage.site.rxSensitivityDbm })
+ assertTrue(coverage.points.none { it.rxDbm >= coverage.site.rxSensitivityDbm && it !in coverage.reachable })
+ }
+
+ @Test
+ fun sweepCoversEveryBearing() = runTest {
+ val coverage = LocalCoverage(flat).sweep(site(), radials = 8, receiversPerRadial = 6)
+ // Receivers are strided along a dense profile, so integer stride rounding can yield a few
+ // more per radial than requested. What must hold is that every bearing contributes equally.
+ assertEquals(0, coverage.points.size % 8, "each bearing should contribute the same count")
+ assertTrue(coverage.points.size >= 8 * 6, "at least the requested receivers per bearing")
+ }
+
+ @Test
+ fun destinationAndHaversineAgree() {
+ val (lat, lon) = destination(47.6, -122.2, bearingDeg = 45.0, distanceKm = 25.0)
+ val back = haversineKm(47.6, -122.2, lat, lon)
+ assertTrue(abs(back - 25.0) < 0.01, "round trip was $back km, expected 25")
+ }
+}
+
+/**
+ * The one-decimal helper that replaced `String.format`, which does not exist off the JVM.
+ *
+ * Exact halves are deliberately not asserted: `roundToLong` breaks ties toward positive infinity, and whether a decimal
+ * like -76.15 even *is* a tie depends on its binary representation (-76.15 * 10 is -761.4999999999999, so it rounds to
+ * -76.1). Immaterial for displaying dBm, and a test that pinned it would be asserting floating-point trivia rather than
+ * behavior.
+ */
+class ToFixed1Test {
+ @Test
+ fun formatsToOneDecimalPlace() {
+ assertEquals("-76.1", (-76.14).toFixed1())
+ assertEquals("-76.2", (-76.16).toFixed1())
+ assertEquals("0.0", 0.0.toFixed1())
+ assertEquals("130.0", 130.0.toFixed1())
+ assertEquals("-130.0", (-129.999).toFixed1())
+ assertEquals("7.5", 7.45001.toFixed1())
+ }
+
+ @Test
+ fun roundTripsThroughGeoJson() {
+ // The value that actually matters: what lands in the exported feature properties.
+ assertEquals("-76.1", (-76.14).toFixed1())
+ assertEquals("-145.4", (-145.44).toFixed1())
+ assertEquals("-61.7", (-61.72).toFixed1())
+ }
+}
diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt
new file mode 100644
index 00000000000..f569c4d45cb
--- /dev/null
+++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import kotlinx.coroutines.test.runTest
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * The polar sweep and the grid it is resampled onto.
+ *
+ * Coverage is computed per bearing and only then rasterised, so these cover the seam between the two: that the lattice
+ * is swept where it claims to be, and that resampling it preserves the prediction rather than inventing structure of
+ * its own.
+ */
+class PolarCoverageTest {
+
+ private val flat = ElevationSource { _, _ -> 0.0 }
+
+ private fun site(radiusKm: Double = 10.0) = Site(
+ name = "Test",
+ latitude = 47.6,
+ longitude = -122.2,
+ frequencyMhz = 915.0,
+ txPowerDbm = 30.0,
+ rxSensitivityDbm = -130.0,
+ txHeightM = 10.0,
+ radiusKm = radiusKm,
+ )
+
+ @Test
+ fun ringsStepOutwardsAndStayInsideTheRadius() = runTest {
+ val polar = LocalCoverage(flat).sweepPolar(site(), radials = 16, rings = 12)
+ val distances = (0 until polar.rings).map { polar.ringKm(it) }
+ assertTrue(distances.zipWithNext().all { (a, b) -> b > a }, "rings should step outward: $distances")
+ assertTrue(distances.first() > 0.0, "the innermost ring cannot sit on the transmitter")
+ assertTrue(distances.last() <= site().radiusKm + 1e-9, "outermost ring ${distances.last()} exceeds the radius")
+ }
+
+ @Test
+ fun overFlatGroundStrengthDependsOnRangeAlone() = runTest {
+ // No terrain means no bearing can differ from any other; anything that does is the sweep's
+ // own geometry leaking in.
+ val polar = LocalCoverage(flat).sweepPolar(site(), radials = 24, rings = 10)
+ for (r in 0 until polar.rings) {
+ val onRing = (0 until polar.radials).map { polar.at(it, r) }
+ val spread = onRing.max() - onRing.min()
+ assertTrue(spread < 0.5, "ring $r varies by $spread dB across bearings over flat ground")
+ }
+ }
+
+ @Test
+ fun signalFallsOffWithRange() = runTest {
+ val polar = LocalCoverage(flat).sweepPolar(site(), radials = 8, rings = 16)
+ val alongOneBearing = (0 until polar.rings).map { polar.at(0, it) }
+ assertTrue(
+ alongOneBearing.first() > alongOneBearing.last(),
+ "signal should be weaker far away: ${alongOneBearing.first()} -> ${alongOneBearing.last()}",
+ )
+ }
+
+ @Test
+ fun gridCoversTheDiscAndNothingOutsideIt() = runTest {
+ val grid = LocalCoverage(flat).sweepPolar(site(), radials = 16, rings = 12).toGrid(resolution = 48)
+ assertEquals(48, grid.width)
+ assertEquals(48, grid.height)
+ for (y in 0 until grid.height) {
+ for (x in 0 until grid.width) {
+ val km =
+ haversineKm(site().latitude, site().longitude, grid.latAt(y.toDouble()), grid.lonAt(x.toDouble()))
+ val computed = !grid.at(x, y).isNaN()
+ // The corners of a square box are outside a disc inscribed in it; nothing else is.
+ assertEquals(km <= site().radiusKm, computed, "cell ($x,$y) at ${km}km computed=$computed")
+ }
+ }
+ }
+
+ @Test
+ fun resamplingFinerDoesNotMoveThePrediction() = runTest {
+ // The grid is free of the prediction budget, so a finer one has to show the same coverage.
+ // If it did not, the extra cells would be the resampler inventing detail.
+ val polar = LocalCoverage(flat).sweepPolar(site(), radials = 32, rings = 16)
+ val coarse = polar.toGrid(resolution = 32).reachableFraction
+ val fine = polar.toGrid(resolution = 192).reachableFraction
+ assertTrue(abs(coarse - fine) < 0.02, "reachable fraction moved with resolution: $coarse vs $fine")
+ }
+
+ @Test
+ fun gridIsSymmetricAboutTheSiteOverFlatGround() = runTest {
+ val grid = LocalCoverage(flat).sweepPolar(site(), radials = 32, rings = 16).toGrid(resolution = 65)
+ val mid = 32 // the center cell of an odd-sized grid sits on the site
+ for (d in 1..mid) {
+ val west = grid.at(mid - d, mid)
+ val east = grid.at(mid + d, mid)
+ if (west.isNaN() || east.isNaN()) continue
+ assertTrue(abs(west - east) < 0.5, "cells $d west and east differ: $west vs $east")
+ }
+ }
+}
diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainCacheTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainCacheTest.kt
new file mode 100644
index 00000000000..105a1452b62
--- /dev/null
+++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainCacheTest.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.feature.map.terrain.ElevationTile
+import kotlin.test.Test
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+class TerrainCacheTest {
+
+ private val tile = ElevationTile(1, 1, floatArrayOf(5f))
+
+ @Test
+ fun aFetchCanceledUnderAStillActiveCallerIsStartedAgain() = runTest {
+ val cache = TerrainCache()
+ val owner = CoroutineScope(Job())
+ val ownerFetch = owner.async { awaitCancellation() }
+
+ // The owner starts the fetch, then a second caller joins it rather than starting its own.
+ val first = launch { runCatching { cache.getOrFetch(KEY) { ownerFetch } } }
+ runCurrent()
+ val second = async { cache.getOrFetch(KEY) { CompletableDeferred(tile) } }
+ runCurrent()
+
+ owner.cancel()
+
+ assertSame(tile, second.await())
+ first.join()
+ assertTrue(ownerFetch.isCancelled)
+ }
+
+ @Test
+ fun aCallerWhoseOwnFetchIsCanceledStopsWaiting() = runTest {
+ val cache = TerrainCache()
+ val owner = CoroutineScope(Job())
+ val outcome = async { runCatching { cache.getOrFetch(KEY) { owner.async { awaitCancellation() } } } }
+ runCurrent()
+
+ owner.cancel()
+
+ assertTrue(outcome.await().isFailure)
+ }
+
+ private companion object {
+ const val KEY = 42L
+ }
+}
diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainZoomGuardTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainZoomGuardTest.kt
new file mode 100644
index 00000000000..10347e89aeb
--- /dev/null
+++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainZoomGuardTest.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import org.meshtastic.feature.map.terrain.GeoBounds
+import kotlin.math.cos
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * The guard that keeps a sweep's terrain inside the shared cache.
+ *
+ * Tiles scale with the square of the radius and with 1/cos(latitude), so a fixed zoom is only right for one area. Past
+ * the cache's capacity the failure is not graceful: eviction is insertion order, so the sweep evicts the tiles it is
+ * about to read and re-decodes the disc on every pass.
+ */
+class TerrainZoomGuardTest {
+
+ private fun boundsAround(latitudeDeg: Double, radiusKm: Double): GeoBounds {
+ val latSpan = radiusKm / KM_PER_DEG_LAT
+ val lonSpan = latSpan / cos(latitudeDeg * DEG_TO_RAD)
+ return GeoBounds(
+ south = latitudeDeg - latSpan,
+ west = LONGITUDE - lonSpan,
+ north = latitudeDeg + latSpan,
+ east = LONGITUDE + lonSpan,
+ )
+ }
+
+ @Test
+ fun theOrdinaryCaseIsLeftAlone() {
+ // A default-radius disc at mid latitudes already fits, so the guard must not cost it detail.
+ val bounds = boundsAround(latitudeDeg = 47.6, radiusKm = 30.0)
+ assertEquals(
+ MapterhornElevation.DEFAULT_ZOOM,
+ zoomFitting(MapterhornElevation.DEFAULT_ZOOM, bounds),
+ "a 30 km disc at 47.6°N needs ${tilesSpanning(bounds, MapterhornElevation.DEFAULT_ZOOM)} tiles",
+ )
+ }
+
+ @Test
+ fun noRadiusOrLatitudeCanOverrunTheCache() {
+ // The radius is a free-text field and the app runs well north of the mid latitudes, so both
+ // of these are reachable without anyone doing anything unusual.
+ for (latitudeDeg in listOf(0.0, 30.0, 45.0, 51.0, 60.0, 70.0, 78.0)) {
+ for (radiusKm in listOf(5.0, 30.0, 50.0, 70.0, 100.0, 150.0, 300.0)) {
+ val bounds = boundsAround(latitudeDeg, radiusKm)
+ val zoom = zoomFitting(MapterhornElevation.DEFAULT_ZOOM, bounds)
+ val tiles = tilesSpanning(bounds, zoom)
+ assertTrue(
+ tiles <= TerrainCache.DEFAULT_CAPACITY,
+ "$latitudeDeg°N at $radiusKm km resolved to z$zoom, still $tiles tiles",
+ )
+ }
+ }
+ }
+
+ @Test
+ fun itNeverGoesDeeperThanAsked() {
+ val tiny = boundsAround(latitudeDeg = 47.6, radiusKm = 1.0)
+ assertTrue(zoomFitting(MapterhornElevation.DEFAULT_ZOOM, tiny) <= MapterhornElevation.DEFAULT_ZOOM)
+ }
+
+ @Test
+ fun withoutBoundsThereIsNothingToFit() {
+ assertEquals(MapterhornElevation.DEFAULT_ZOOM, zoomFitting(MapterhornElevation.DEFAULT_ZOOM, null))
+ }
+
+ private companion object {
+ const val LONGITUDE = -122.3
+ const val KM_PER_DEG_LAT = 111.32
+ const val DEG_TO_RAD = 0.017453292519943295
+ }
+}
diff --git a/feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.ios.kt b/feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.ios.kt
new file mode 100644
index 00000000000..5584dafbd47
--- /dev/null
+++ b/feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.ios.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import okio.Path
+import okio.Path.Companion.toPath
+import platform.Foundation.NSDocumentDirectory
+import platform.Foundation.NSSearchPathForDirectoriesInDomains
+import platform.Foundation.NSUserDomainMask
+
+/** The app's Documents directory. Compiled for target validation; no iOS host calls it. */
+internal actual fun coverageTerrainDirectory(): Path {
+ val documents = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).first() as String
+ return "$documents/terrain/coverage".toPath()
+}
diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt
new file mode 100644
index 00000000000..9cce9815662
--- /dev/null
+++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import kotlinx.coroutines.runBlocking
+import okio.FileSystem
+import okio.Path.Companion.toOkioPath
+import org.meshtastic.feature.map.terrain.TerrainTileStore
+import java.awt.image.BufferedImage
+import java.io.File
+import java.util.Locale
+import javax.imageio.ImageIO
+import kotlin.math.roundToInt
+import kotlin.system.measureTimeMillis
+
+/**
+ * Runs a real coverage prediction end to end and writes a PNG plus the GeoJSON the app imports.
+ *
+ * It runs the same `LocalCoverage` the apps call, over real Mapterhorn terrain, so a change can be seen rather than
+ * only asserted.
+ *
+ * `./gradlew :feature:coverage:coverageDemo -PuseMavenLocal`
+ */
+object CoverageDemo {
+
+ @Suppress("MagicNumber")
+ @JvmStatic
+ fun main(args: Array) {
+ val lat = args.getOrNull(0)?.toDoubleOrNull() ?: DEFAULT_LAT
+ val lon = args.getOrNull(1)?.toDoubleOrNull() ?: DEFAULT_LON
+ val outDir = File(args.getOrNull(2) ?: "build/coverage-demo").apply { mkdirs() }
+ val radials = args.getOrNull(3)?.toIntOrNull() ?: DEFAULT_RADIALS
+ val rings = args.getOrNull(4)?.toIntOrNull() ?: DEFAULT_RINGS
+ val profileStepKm = args.getOrNull(5)?.toDoubleOrNull() ?: DEFAULT_PROFILE_STEP_KM
+ val cacheDir = File(args.getOrNull(6) ?: "build/terrain-cache").apply { mkdirs() }
+ val zoom = args.getOrNull(7)?.toIntOrNull() ?: MapterhornElevation.DEFAULT_ZOOM
+ val style = CoverageStyle(palette = CoveragePalette.forKey(args.getOrNull(8)))
+
+ val site =
+ Site(
+ name = "Demo",
+ latitude = lat,
+ longitude = lon,
+ frequencyMhz = 906.875, // Meshtastic US LongFast
+ txPowerDbm = 30.0,
+ rxSensitivityDbm = -130.0, // LONG_FAST
+ txHeightM = 10.0,
+ rxHeightM = 1.5,
+ radiusKm = 25.0,
+ )
+
+ println("site ${site.latitude}, ${site.longitude} ${site.frequencyMhz} MHz ${site.txPowerDbm} dBm")
+ // A real on-disk cache, so a second run of this task pays nothing for terrain.
+ val store = TerrainTileStore(FileSystem.SYSTEM, cacheDir.toOkioPath())
+ MapterhornElevation(site.coverageBounds(), store, zoom).use { elevation ->
+ println("terrain: z${elevation.zoomLevel} XYZ tiles, cache ${cacheDir.absolutePath}")
+ var warmed = 0
+ val prefetchMs = measureTimeMillis { warmed = runBlocking { elevation.prefetch() } }
+ println("prefetched $warmed terrain tiles in ${prefetchMs}ms")
+ lateinit var coverage: CoverageGrid
+ val ms = measureTimeMillis {
+ coverage = runBlocking {
+ LocalCoverage(elevation)
+ .sweepPolar(site, radials = radials, rings = rings, profileStepKm = profileStepKm)
+ .toGrid(GRID)
+ }
+ }
+ val computed = coverage.dbm.count { !it.isNaN() }
+ println("computed $computed grid cells in ${ms}ms from ${elevation.tilesResident} terrain tiles")
+ println(
+ "predictions: ${radials * rings} ($radials radials x $rings rings, " +
+ "profile step ${profileStepKm * 1000} m), cores ${Runtime.getRuntime().availableProcessors()}",
+ )
+ println("reachable: ${(coverage.reachableFraction * 100).roundToInt()}%")
+ println("max range: ${"%.1f".format(Locale.ROOT, coverage.maxRangeKm)} km")
+ val finite = coverage.dbm.filter { !it.isNaN() }
+ println(
+ "rx dBm range: ${"%.1f".format(Locale.ROOT, finite.min())} .. " +
+ "%.1f".format(Locale.ROOT, finite.max()),
+ )
+
+ reportWarmSweeps(elevation, store, site, radials, rings, profileStepKm)
+
+ println("palette: ${style.palette.key}")
+ File(outDir, "coverage.geojson").writeText(coverage.toGeoJson(style))
+ renderPng(coverage, File(outDir, "coverage.png"))
+ println("wrote ${outDir.absolutePath}/coverage.{png,geojson}")
+ }
+ }
+
+ /**
+ * What a repeat estimate costs once terrain is decoded: the number that decides whether the planner can update
+ * interactively.
+ *
+ * The second run builds a *fresh* source, because that is the app's real shape: the planner's composable is
+ * disposed when its sheet closes, so nothing survives in the instance itself.
+ */
+ private fun reportWarmSweeps(
+ elevation: MapterhornElevation,
+ store: TerrainTileStore,
+ site: Site,
+ radials: Int,
+ rings: Int,
+ profileStepKm: Double,
+ ) {
+ val again = measureTimeMillis {
+ runBlocking {
+ LocalCoverage(elevation)
+ .sweepPolar(site, radials = radials, rings = rings, profileStepKm = profileStepKm)
+ .toGrid(GRID)
+ }
+ }
+ println("re-swept with terrain already decoded in ${again}ms")
+
+ val fresh = measureTimeMillis {
+ runBlocking {
+ MapterhornElevation(store = store, zoom = elevation.zoomLevel).use { second ->
+ second.prefetch(site)
+ LocalCoverage(second)
+ .sweepPolar(site, radials = radials, rings = rings, profileStepKm = profileStepKm)
+ .toGrid(GRID)
+ }
+ }
+ }
+ println("re-swept through a brand new elevation source in ${fresh}ms")
+ }
+
+ /** Plot the grid as a top-down image, colored by signal strength. */
+ @Suppress("MagicNumber")
+ private fun renderPng(coverage: CoverageGrid, dest: File) {
+ val size = 700
+ val img = BufferedImage(size, size, BufferedImage.TYPE_INT_RGB)
+ val g = img.createGraphics()
+ g.color = java.awt.Color(0x14, 0x15, 0x1c)
+ g.fillRect(0, 0, size, size)
+
+ val sensitivity = coverage.site.rxSensitivityDbm
+ val strongest = coverage.dbm.filter { !it.isNaN() }.maxOrNull() ?: sensitivity
+ val cw = size.toDouble() / coverage.width
+ val ch = size.toDouble() / coverage.height
+ for (y in 0 until coverage.height) {
+ for (x in 0 until coverage.width) {
+ val v = coverage.at(x, y)
+ if (v.isNaN() || v < sensitivity) continue
+ g.color = colorFor(v, sensitivity, strongest)
+ g.fillRect((x * cw).toInt(), (y * ch).toInt(), (cw + 1).toInt(), (ch + 1).toInt())
+ }
+ }
+ g.color = java.awt.Color(0x67, 0xEA, 0x94)
+ g.fillOval(size / 2 - 5, size / 2 - 5, 10, 10)
+ g.dispose()
+ ImageIO.write(img, "png", dest)
+ }
+
+ /** Below sensitivity is left dark; above it ramps red → yellow → green. */
+ @Suppress("MagicNumber")
+ private fun colorFor(dbm: Double, sensitivity: Double, strongest: Double): java.awt.Color {
+ if (dbm < sensitivity) return java.awt.Color(0x22, 0x23, 0x2c)
+ val t = ((dbm - sensitivity) / (strongest - sensitivity)).coerceIn(0.0, 1.0)
+ val r = if (t < 0.5) 255 else (255 * (1 - (t - 0.5) * 2)).toInt().coerceIn(0, 255)
+ val gc = if (t < 0.5) (255 * t * 2).toInt().coerceIn(0, 255) else 255
+ return java.awt.Color(r, gc, 60)
+ }
+
+ private const val DEFAULT_LAT = 47.6062 // Seattle, real relief nearby
+ private const val DEFAULT_LON = -122.3321
+ private const val GRID = 256
+}
diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.jvm.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.jvm.kt
new file mode 100644
index 00000000000..f8d816579cd
--- /dev/null
+++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.jvm.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.coverage
+
+import okio.Path
+import okio.Path.Companion.toPath
+
+/** Beside the desktop's offline terrain in `~/.meshtastic/terrain`. */
+internal actual fun coverageTerrainDirectory(): Path =
+ "${System.getProperty("user.home")}/.meshtastic/terrain/coverage".toPath()
diff --git a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt
index 75df81378bb..47eb87855db 100644
--- a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt
+++ b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt
@@ -28,6 +28,19 @@ object MapterhornEndpoints {
const val GLOBAL_PMTILES_URL = "https://download.mapterhorn.com/planet.pmtiles"
+ /**
+ * Plain XYZ tiles, the endpoint Mapterhorn's own migration guide documents.
+ *
+ * Probed 2026-09-16 at Seattle: z0–16 all 200 (~56–95 KB each, ~130 ms, Cloudflare-cached for a week,
+ * `access-control-allow-origin: *`), z17+ 404. So it serves the regional detail the per-z6-tile archives were meant
+ * to, and does it as independent cacheable requests rather than range reads into one seekable channel, which is
+ * what made bulk terrain sampling serial.
+ */
+ fun tileUrl(zoom: Int, x: Int, y: Int): String = "https://tiles.mapterhorn.com/$zoom/$x/$y.webp"
+
+ /** Deepest zoom [tileUrl] serves. Deeper requests 404. */
+ const val TILES_MAX_ZOOM = 16
+
/** Global coverage tops out at this zoom; deeper detail (if any) only exists in a regional archive. */
const val GLOBAL_MAX_ZOOM = 12
@@ -43,6 +56,10 @@ object MapterhornEndpoints {
* The regional archive URL for [bounds], or `null` if [bounds] doesn't fit inside one z6 tile — matching iOS's
* "global-only terrain is normal, never an error" rule: a region spanning more than one z6 tile just gets no
* regional detail, rather than trying to stitch multiple regional archives together.
+ *
+ * **Every URL this builds 404s as of 2026-09-16.** Five z6 tiles were probed plus the bare host, while
+ * `planet.pmtiles` still serves; whether the naming changed or Mapterhorn stopped publishing per-region archives is
+ * unverified. [tileUrl] reaches the same detail and deeper, so new callers should prefer it.
*/
fun regionalUrlFor(bounds: GeoBounds): String? {
if (!TerrainTileMath.fitsInSingleTile(REGIONAL_ARCHIVE_ZOOM, bounds)) return null
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.kt
deleted file mode 100644
index 03868c49fe4..00000000000
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.kt
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.feature.map.component
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.platform.LocalUriHandler
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.site_planner_browser_note
-
-/** The hosted Site Planner. One definition, shared by the embedded host and the browser hand-off. */
-const val SITE_PLANNER_URL: String = "https://site.meshtastic.org"
-
-/**
- * The Site Planner as a form that hands the finished parameters to the system browser.
- *
- * Used where there is no embedded browser to run the planner in. The whole configuration step is the same
- * [SitePlannerSheet] the Android hosts show, so the transmitter is still seeded from the connected radio and the
- * shortcuts still work; only the last step differs — instead of loading the planner in place, the parameters are
- * encoded into its URL and opened outside the app.
- *
- * The coverage estimate therefore stays in the browser rather than returning as a map layer. Bringing it back
- * automatically needs a JavaScript bridge, which needs an embedded browser: on Desktop that meant bundling Chromium,
- * which measured at roughly three and a half times the size of the whole application.
- *
- * It comes back by hand instead, which the sheet's note spells out: the planner's own export writes a `.geojson`, and
- * the layers sheet on this same host imports it. The note names the format on purpose — the planner's KML export is a
- * bare `` over a sibling PNG that arrives without its image, so a user who reached for it would import a
- * file that draws nothing.
- */
-@Composable
-fun SitePlannerBrowserSheet(
- initial: SitePlannerParams,
- onDismiss: () -> Unit,
- onUseNodeLocation: (() -> Pair)? = null,
- onUseMapCenter: (() -> Pair)? = null,
-) {
- val uriHandler = LocalUriHandler.current
- // Held here rather than passed straight through: the sheet re-seeds its coordinate fields from `initial`, so a
- // location shortcut only shows up if the caller actually moves the value it was given.
- var params by remember(initial) { mutableStateOf(initial) }
-
- SitePlannerSheet(
- initial = params,
- onSubmit = { submitted ->
- uriHandler.openUri(submitted.toQueryUrl(SITE_PLANNER_URL, useHostBridge = false))
- onDismiss()
- },
- onDismiss = onDismiss,
- note = stringResource(Res.string.site_planner_browser_note),
- onUseNodeLocation =
- onUseNodeLocation?.let { node ->
- {
- val (latitude, longitude) = node()
- params = params.copy(latitude = latitude, longitude = longitude)
- }
- },
- onUseMapCenter =
- onUseMapCenter?.let { center ->
- {
- val (latitude, longitude) = center()
- params = params.copy(latitude = latitude, longitude = longitude)
- }
- },
- )
-}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt
new file mode 100644
index 00000000000..bac92e833cc
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.cancel
+import org.meshtastic.core.resources.site_planner_estimating
+import org.meshtastic.core.resources.site_planner_failed
+
+/**
+ * The Site Planner flow every map host shares: the [SitePlannerSheet] form, then [estimate] behind a progress dialog,
+ * then [onImport] with the estimate's GeoJSON and the transmitter position so the host can add a map layer and move the
+ * camera to it.
+ *
+ * Canceling the progress dialog stops the estimate and returns to the form with the same values. A failed estimate
+ * returns there too, with an error note. The location shortcuts re-seed the coordinates from the device
+ * ([onRequestCurrentLocation]), the node ([onUseNodeLocation]), or the map ([onUseMapCenter]) when a host offers them.
+ */
+@Composable
+fun SitePlannerHost(
+ initialParams: SitePlannerParams,
+ estimate: suspend (SitePlannerParams) -> String,
+ onDismiss: () -> Unit,
+ onImport: (name: String, geoJson: String, latitude: Double, longitude: Double) -> Unit,
+ onRequestCurrentLocation: (suspend () -> Pair?)? = null,
+ onUseNodeLocation: (() -> Pair)? = null,
+ onUseMapCenter: (() -> Pair)? = null,
+) {
+ // Seeded once per launch: hosts recompute initialParams on every node update, and a position fix must not
+ // overwrite what the user has typed or submitted.
+ var params by remember { mutableStateOf(initialParams) }
+ var running by remember { mutableStateOf(null) }
+ var failed by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ val currentEstimate by rememberUpdatedState(estimate)
+ val currentOnImport by rememberUpdatedState(onImport)
+ val currentOnDismiss by rememberUpdatedState(onDismiss)
+
+ val current = running
+ if (current == null) {
+ SitePlannerSheet(
+ initial = params,
+ onSubmit = { submitted ->
+ params = submitted
+ failed = false
+ running = submitted
+ },
+ onDismiss = onDismiss,
+ onUseCurrentLocation =
+ onRequestCurrentLocation?.let { fetch ->
+ {
+ scope.launch {
+ fetch()?.let { (lat, lon) -> params = params.copy(latitude = lat, longitude = lon) }
+ }
+ }
+ },
+ onUseNodeLocation =
+ onUseNodeLocation?.let { node ->
+ {
+ val (lat, lon) = node()
+ params = params.copy(latitude = lat, longitude = lon)
+ }
+ },
+ onUseMapCenter =
+ onUseMapCenter?.let { center ->
+ {
+ val (lat, lon) = center()
+ params = params.copy(latitude = lat, longitude = lon)
+ }
+ },
+ note = if (failed) stringResource(Res.string.site_planner_failed) else null,
+ )
+ } else {
+ EstimatingDialog(onCancel = { running = null })
+ LaunchedEffect(current) {
+ val geoJson =
+ try {
+ currentEstimate(current)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.withTag("SitePlanner").e(e) { "Coverage estimate failed" }
+ null
+ }
+ if (geoJson == null) {
+ failed = true
+ running = null
+ } else {
+ currentOnImport(current.name, geoJson, current.latitude, current.longitude)
+ currentOnDismiss()
+ }
+ }
+ }
+}
+
+@Composable
+private fun EstimatingDialog(onCancel: () -> Unit) {
+ Dialog(onDismissRequest = onCancel) {
+ Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) {
+ Column(
+ modifier = Modifier.size(280.dp).padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
+ ) {
+ CircularWavyProgressIndicator()
+ Text(stringResource(Res.string.site_planner_estimating))
+ TextButton(onClick = onCancel) { Text(stringResource(Res.string.cancel)) }
+ }
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt
index 7346488e12f..8a087704f87 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt
@@ -16,11 +16,7 @@
*/
package org.meshtastic.feature.map.component
-/**
- * Transmitter parameters for a Meshtastic Site Planner coverage estimate. Maps onto the planner's flat query contract
- * (`?lat=&lon=&name=&tx_power=&tx_freq=&tx_height=&tx_gain=`), so [toQueryUrl] can hand a configured, auto-running view
- * to the planner without knowing its internal parameter schema.
- */
+/** Transmitter, receiver, and display settings for a Site Planner coverage estimate. */
data class SitePlannerParams(
val name: String,
val latitude: Double,
@@ -30,52 +26,15 @@ data class SitePlannerParams(
val txHeightMeters: Double = DEFAULT_TX_HEIGHT_METERS,
val txGainDbi: Double = DEFAULT_TX_GAIN_DBI,
val colorScale: String = DEFAULT_COLOR_SCALE,
- // Advanced fields — default to the planner's own values, so sending them is behaviorally identical to omitting
- // them (they just make the hand-off explicit). Surfaced behind an "Advanced" section in the form.
val rxSensitivityDbm: Double = DEFAULT_RX_SENSITIVITY_DBM,
val rxHeightMeters: Double = DEFAULT_RX_HEIGHT_METERS,
val maxRangeKm: Double = DEFAULT_MAX_RANGE_KM,
- val highResolution: Boolean = false,
val minDbm: Double = DEFAULT_MIN_DBM,
val maxDbm: Double = DEFAULT_MAX_DBM,
val overlayTransparency: Int = DEFAULT_OVERLAY_TRANSPARENCY,
) {
- /**
- * Build the planner URL that prefills these params and auto-runs the simulation (`run=1`).
- *
- * [useHostBridge] asks the planner to hand its result to a native host bridge rather than to its own share sheet.
- * Only a host that actually provides one should set it: opened in an ordinary browser, `bridge=1` points the
- * finished estimate at something that is not there, and the user's own way of saving it is what they need instead.
- *
- * Advanced params map onto the planner's flat query contract (receiver / simulation / display sections).
- */
- fun toQueryUrl(baseUrl: String, useHostBridge: Boolean = true): String {
- val query = buildString {
- append("lat=").append(latitude)
- append("&lon=").append(longitude)
- append("&name=").append(encodeQueryComponent(name))
- append("&tx_power=").append(txPowerWatts)
- append("&tx_freq=").append(txFreqMhz)
- append("&tx_height=").append(txHeightMeters)
- append("&tx_gain=").append(txGainDbi)
- append("&color_scale=").append(encodeQueryComponent(colorScale))
- append("&rx_sensitivity=").append(rxSensitivityDbm)
- append("&rx_height=").append(rxHeightMeters)
- append("&max_range=").append(maxRangeKm)
- if (highResolution) append("&high_res=1")
- append("&min_dbm=").append(minDbm)
- append("&max_dbm=").append(maxDbm)
- append("&overlay_transparency=").append(overlayTransparency)
- append("&run=1")
- if (useHostBridge) append("&bridge=1")
- }
- val separator = if (baseUrl.endsWith("/")) "" else "/"
- return "$baseUrl$separator?$query"
- }
-
companion object {
- // Meshtastic-typical stock defaults; all editable in the form before submission. Values mirror the planner's
- // own factory defaults (src/store.ts defaultParams()) so an untouched form matches a fresh planner session.
+ // Meshtastic-typical stock defaults; all editable in the form before submission.
const val DEFAULT_TX_POWER_WATTS: Double = 0.1
const val DEFAULT_TX_FREQ_MHZ: Double = 907.0
const val DEFAULT_TX_HEIGHT_METERS: Double = 2.0
@@ -88,17 +47,17 @@ data class SitePlannerParams(
const val DEFAULT_MAX_DBM: Double = -80.0
const val DEFAULT_OVERLAY_TRANSPARENCY: Int = 50
- // Validation ranges mirroring the planner's input constraints (src/components/*.vue).
- const val MIN_FREQ_MHZ: Double = 20.0
- const val MAX_FREQ_MHZ: Double = 20_000.0
+ // ITU-R P.1812 is defined from 30 MHz to 6 GHz; the model rejects anything outside it.
+ const val MIN_FREQ_MHZ: Double = 30.0
+ const val MAX_FREQ_MHZ: Double = 6_000.0
const val MIN_RX_SENSITIVITY_DBM: Double = -150.0
const val MAX_RX_SENSITIVITY_DBM: Double = -30.0
- const val MAX_RANGE_STANDARD_KM: Double = 150.0
- const val MAX_RANGE_HIGH_RES_KM: Double = 70.0
+ const val MIN_RANGE_KM: Double = 1.0
+ const val MAX_RANGE_KM: Double = 150.0
const val MIN_TRANSPARENCY: Int = 0
const val MAX_TRANSPARENCY: Int = 100
- /** Coverage palettes the planner ships (value → label), mirroring its Display.vue picker. */
+ /** Coverage palettes (value → label), matching the hosted Site Planner's picker. */
val COLOR_SCALES: List> =
listOf(
"plasma" to "Plasma",
@@ -110,22 +69,3 @@ data class SitePlannerParams(
)
}
}
-
-private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
-private val HEX = "0123456789ABCDEF".toCharArray()
-private const val BYTE_MASK = 0xFF
-private const val ASCII_LIMIT = 0x80
-private const val NIBBLE_BITS = 4
-private const val NIBBLE_MASK = 0xF
-
-/** Percent-encode a query-component value (UTF-8), so names with spaces/non-ASCII survive the round trip. */
-internal fun encodeQueryComponent(value: String): String = buildString {
- for (byte in value.encodeToByteArray()) {
- val code = byte.toInt() and BYTE_MASK
- if (code < ASCII_LIMIT && code.toChar() in UNRESERVED) {
- append(code.toChar())
- } else {
- append('%').append(HEX[code shr NIBBLE_BITS]).append(HEX[code and NIBBLE_MASK])
- }
- }
-}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt
index acab5b67d5d..dab7ee1f315 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt
@@ -51,7 +51,6 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Surface
-import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
@@ -84,9 +83,10 @@ import org.meshtastic.core.resources.site_planner_antenna_height_meters
import org.meshtastic.core.resources.site_planner_color_scale
import org.meshtastic.core.resources.site_planner_estimate
import org.meshtastic.core.resources.site_planner_frequency_mhz
-import org.meshtastic.core.resources.site_planner_high_resolution
+import org.meshtastic.core.resources.site_planner_invalid_frequency
import org.meshtastic.core.resources.site_planner_invalid_latitude
import org.meshtastic.core.resources.site_planner_invalid_longitude
+import org.meshtastic.core.resources.site_planner_invalid_max_range
import org.meshtastic.core.resources.site_planner_invalid_positive
import org.meshtastic.core.resources.site_planner_invalid_rx_sensitivity
import org.meshtastic.core.resources.site_planner_max_range_km
@@ -129,7 +129,6 @@ private class SiteFormState(initial: SitePlannerParams) {
var rxSensitivity by mutableStateOf(initial.rxSensitivityDbm.toString())
var rxHeight by mutableStateOf(initial.rxHeightMeters.toString())
var maxRange by mutableStateOf(initial.maxRangeKm.toString())
- var highResolution by mutableStateOf(initial.highResolution)
// Validation — computed from the (observable) string fields, so callers just read the booleans.
private val latValue
@@ -148,7 +147,16 @@ private class SiteFormState(initial: SitePlannerParams) {
get() = (NumberFormatter.parseDecimalOrNull(power) ?: 0.0) <= 0.0
val freqBad
- get() = (NumberFormatter.parseDecimalOrNull(freq) ?: 0.0) <= 0.0
+ get() =
+ NumberFormatter.parseDecimalOrNull(freq).let {
+ it == null || it !in SitePlannerParams.MIN_FREQ_MHZ..SitePlannerParams.MAX_FREQ_MHZ
+ }
+
+ val maxRangeBad
+ get() =
+ NumberFormatter.parseDecimalOrNull(maxRange).let {
+ it == null || it !in SitePlannerParams.MIN_RANGE_KM..SitePlannerParams.MAX_RANGE_KM
+ }
val rxSensBad
get() =
@@ -158,7 +166,14 @@ private class SiteFormState(initial: SitePlannerParams) {
// Guard the null-island (0,0) case so an empty ocean run can't be submitted.
val canSubmit
- get() = !latBad && !lonBad && !powerBad && !freqBad && !rxSensBad && (latValue != 0.0 || lonValue != 0.0)
+ get() =
+ !latBad &&
+ !lonBad &&
+ !powerBad &&
+ !freqBad &&
+ !rxSensBad &&
+ !maxRangeBad &&
+ (latValue != 0.0 || lonValue != 0.0)
}
/**
@@ -166,8 +181,7 @@ private class SiteFormState(initial: SitePlannerParams) {
* params once they validate. Location shortcut chips ([onUseCurrentLocation]/[onUseNodeLocation]/[onUseMapCenter])
* re-seed the coordinate fields when provided, preserving edits to the other fields.
*
- * [note] is shown above the submit button. A host whose planner runs somewhere this app cannot read the result back
- * from uses it to say so, and to say what the user should do instead.
+ * [note] is shown above the submit button, such as the reason the last estimate didn't complete.
*/
@Composable
fun SitePlannerSheet(
@@ -286,7 +300,7 @@ private fun TransmitterSection(
latError = if (state.latBad) stringResource(Res.string.site_planner_invalid_latitude) else null,
lonError = if (state.lonBad) stringResource(Res.string.site_planner_invalid_longitude) else null,
powerError = if (state.powerBad) posMsg else null,
- freqError = if (state.freqBad) posMsg else null,
+ freqError = if (state.freqBad) stringResource(Res.string.site_planner_invalid_frequency) else null,
)
}
}
@@ -309,15 +323,12 @@ private fun ReceiverSection(state: SiteFormState) {
@Composable
private fun SimulationSection(state: SiteFormState) {
FormSection(stringResource(Res.string.site_planner_section_simulation), defaultExpanded = false) {
- SiteField(state.maxRange, { state.maxRange = it }, Res.string.site_planner_max_range_km)
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(stringResource(Res.string.site_planner_high_resolution))
- Switch(checked = state.highResolution, onCheckedChange = { state.highResolution = it })
- }
+ SiteField(
+ state.maxRange,
+ { state.maxRange = it },
+ Res.string.site_planner_max_range_km,
+ error = if (state.maxRangeBad) stringResource(Res.string.site_planner_invalid_max_range) else null,
+ )
}
}
@@ -478,7 +489,6 @@ private fun buildSubmitParams(state: SiteFormState, initial: SitePlannerParams):
rxSensitivityDbm = NumberFormatter.parseDecimalOrNull(state.rxSensitivity) ?: initial.rxSensitivityDbm,
rxHeightMeters = NumberFormatter.parseDecimalOrNull(state.rxHeight) ?: initial.rxHeightMeters,
maxRangeKm = NumberFormatter.parseDecimalOrNull(state.maxRange) ?: initial.maxRangeKm,
- highResolution = state.highResolution,
minDbm = initial.minDbm,
maxDbm = initial.maxDbm,
overlayTransparency = initial.overlayTransparency,
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt
index ab7bd7a9625..534076d30db 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt
@@ -72,7 +72,7 @@ internal fun String.toCssColor(): Pair? {
}
/** Minimal JSON string escaping — KML descriptions routinely carry quotes, newlines and CDATA-wrapped HTML. */
-internal fun String.jsonString(): String {
+fun String.jsonString(): String {
val escaped = StringBuilder("\"")
forEach { character ->
when {
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt
deleted file mode 100644
index 26a2c9ec1a9..00000000000
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.feature.map.component
-
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-
-class SitePlannerParamsTest {
-
- @Test
- fun `toQueryUrl emits the planner's flat contract with advanced defaults and run and bridge flags`() {
- val url =
- SitePlannerParams(
- name = "Tower A",
- latitude = 51.05,
- longitude = -114.07,
- txPowerWatts = 0.5,
- txFreqMhz = 915.0,
- txHeightMeters = 12.0,
- txGainDbi = 5.5,
- colorScale = "turbo",
- )
- .toQueryUrl("http://localhost:5173")
-
- // Advanced params carry their (planner-matching) defaults; high_res is omitted while false.
- assertEquals(
- "http://localhost:5173/?lat=51.05&lon=-114.07&name=Tower%20A" +
- "&tx_power=0.5&tx_freq=915.0&tx_height=12.0&tx_gain=5.5&color_scale=turbo" +
- "&rx_sensitivity=-130.0&rx_height=1.0&max_range=30.0" +
- "&min_dbm=-130.0&max_dbm=-80.0&overlay_transparency=50&run=1&bridge=1",
- url,
- )
- }
-
- @Test
- fun `toQueryUrl emits advanced overrides and high_res only when enabled`() {
- val url =
- SitePlannerParams(
- name = "N",
- latitude = 1.0,
- longitude = 2.0,
- rxSensitivityDbm = -139.0,
- maxRangeKm = 60.0,
- highResolution = true,
- )
- .toQueryUrl("http://localhost:5173")
-
- assertTrue(url.contains("&rx_sensitivity=-139.0"), url)
- assertTrue(url.contains("&max_range=60.0"), url)
- assertTrue(url.contains("&high_res=1"), url)
- }
-
- @Test
- fun `the host bridge is asked for only when a host offers one`() {
- // Opened in an ordinary browser there is no native bridge to hand the estimate to, and pointing the planner at
- // one that is not there loses the result the user just waited for.
- val params = SitePlannerParams("N", 1.0, 2.0)
-
- assertTrue(params.toQueryUrl("https://planner.example").contains("bridge=1"))
- assertFalse(params.toQueryUrl("https://planner.example", useHostBridge = false).contains("bridge=1"))
- // The simulation still runs either way.
- assertTrue(params.toQueryUrl("https://planner.example", useHostBridge = false).contains("run=1"))
- }
-
- @Test
- fun `toQueryUrl does not double the slash when the base already ends in one`() {
- val url = SitePlannerParams("N", 1.0, 2.0).toQueryUrl("https://planner.example/")
- assertTrue(url.startsWith("https://planner.example/?lat=1.0&lon=2.0&name=N"))
- }
-
- @Test
- fun `encodeQueryComponent percent-encodes spaces and non-ASCII`() {
- assertEquals("Tower%20%C3%91%C3%B6r%C3%B0", encodeQueryComponent("Tower Ñörð"))
- assertEquals("plain-name_1.0~", encodeQueryComponent("plain-name_1.0~"))
- }
-}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d74f6ae637a..a6226c28cb6 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -363,6 +363,9 @@ takpacket-sdk-kmp = { module = "org.meshtastic:takpacket-sdk", version.ref = "ta
meshtastic-protobufs = { module = "org.meshtastic:protobufs", version.ref = "meshtastic-protobufs" }
+# SPIKE: unpublished; resolved from mavenLocal via -PuseMavenLocal.
+kp1812 = { module = "org.meshtastic:kp1812", version = "0.1.0" }
+
[bundles]
# Datadog RUM/telemetry stack — always consumed together (androidApp google flavor)
dd-sdk-android = [
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8a8976a2992..5f7a581177c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -112,6 +112,7 @@ include(
":feature:connections",
":feature:map",
":feature:map-maplibre",
+ ":feature:coverage",
":feature:map-terrain",
":feature:node",
":feature:settings",
@@ -129,3 +130,4 @@ include(
":baselineprofile",
":store-screenshots",
)
+