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", ) +