diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index b494598ca..9c0f3577c 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1450,6 +1450,32 @@ shutdown_on_power_loss
shutdown_warning
signal
signal_quality
+### SITE ###
+site_planner
+site_planner_antenna_gain_dbi
+site_planner_antenna_height_meters
+site_planner_color_scale
+site_planner_estimate
+site_planner_estimating
+site_planner_failed
+site_planner_frequency_mhz
+site_planner_high_resolution
+site_planner_invalid_latitude
+site_planner_invalid_longitude
+site_planner_invalid_positive
+site_planner_invalid_rx_sensitivity
+site_planner_max_range_km
+site_planner_rx_height_meters
+site_planner_rx_sensitivity_dbm
+site_planner_section_display
+site_planner_section_receiver
+site_planner_section_simulation
+site_planner_section_transmitter
+site_planner_subtitle
+site_planner_tx_power_watts
+site_planner_use_current_location
+site_planner_use_map_center
+site_planner_use_node_location
skip
slot
smart_position
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
index 48b1aa7fc..146164e84 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
@@ -19,3 +19,6 @@ package org.meshtastic.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()
+
+/** Site Planner (coverage-estimate) is Google-flavor only; the F-Droid map has no overlay-layer support. */
+fun sitePlannerAvailable(): Boolean = false
diff --git a/androidApp/src/google/AndroidManifest.xml b/androidApp/src/google/AndroidManifest.xml
index bab70fc2d..835daf559 100644
--- a/androidApp/src/google/AndroidManifest.xml
+++ b/androidApp/src/google/AndroidManifest.xml
@@ -29,6 +29,35 @@
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Site Planner
+ Antenna gain (dBi)
+ Antenna height (m)
+ Color palette
+ Estimate coverage
+ Estimating coverage…
+ Coverage estimate failed
+ Frequency (MHz)
+ High-resolution terrain
+ Enter −90 to 90
+ Enter −180 to 180
+ Must be greater than 0
+ Enter −150 to −30 dBm
+ Max range (km)
+ Receiver height (m)
+ Receiver sensitivity (dBm)
+ Display
+ Receiver
+ Simulation Options
+ Site / Transmitter
+ Estimate RF coverage for a transmitter
+ TX power (W)
+ Use current location
+ Use map center
+ Use current node location
Skip
Slot
Smart Position
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalSitePlannerAvailable.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalSitePlannerAvailable.kt
new file mode 100644
index 000000000..10293e343
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalSitePlannerAvailable.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.core.ui.util
+
+import androidx.compose.runtime.compositionLocalOf
+
+/**
+ * Whether the Site Planner coverage-estimate flow is available (Google flavor only, backed by the official hosted
+ * planner). Gates the "Estimate coverage" action on the node detail screen; the map's own control is gated equivalently
+ * in the Google MapView. Defaults to false so F-Droid/release never surface a dead action.
+ *
+ * Flavor-injected feature availability, mirroring the other Local*Provider seams in this package (all baselined for
+ * this rule); a plain boolean local is the lightest way to gate a commonMain UI action on a flavor capability.
+ */
+@Suppress("CompositionLocalAllowlist")
+val LocalSitePlannerAvailable = compositionLocalOf { false }
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
index f84647a77..aabba7f83 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
@@ -48,6 +48,7 @@ import org.meshtastic.core.resources.one_hour
import org.meshtastic.core.resources.two_days
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
+import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
@@ -79,6 +80,12 @@ open class BaseMapViewModel(
val ourNodeInfo = nodeRepository.ourNodeInfo
+ /**
+ * Connected radio's channel set (primary-channel frequency + LoRa config); used to prefill a Site Planner estimate.
+ */
+ val channelSet: StateFlow =
+ radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = null)
+
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
index 454bc72b2..b71585034 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
@@ -35,7 +35,9 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
+import org.meshtastic.core.resources.site_planner
import org.meshtastic.core.resources.toggle_my_position
+import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.LocationDisabled
import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -72,6 +74,7 @@ fun MapControlsOverlay(
filterDropdownContent: @Composable () -> Unit = {},
mapTypeContent: @Composable () -> Unit = {},
layersContent: @Composable () -> Unit = {},
+ onSitePlannerClick: (() -> Unit)? = null,
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
showRefresh: Boolean = false,
@@ -102,6 +105,15 @@ fun MapControlsOverlay(
// Layers button (flavor-specific)
layersContent()
+ // Site Planner coverage estimate (optional; provided by the Google flavor)
+ onSitePlannerClick?.let { onClick ->
+ MapButton(
+ icon = MeshtasticIcons.CellTower,
+ contentDescription = stringResource(Res.string.site_planner),
+ onClick = onClick,
+ )
+ }
+
// Refresh button (optional)
if (showRefresh) {
if (isRefreshing) {
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
new file mode 100644
index 000000000..b3e4c6069
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt
@@ -0,0 +1,126 @@
+/*
+ * 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
+
+/**
+ * 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.
+ */
+data class SitePlannerParams(
+ val name: String,
+ val latitude: Double,
+ val longitude: Double,
+ val txPowerWatts: Double = DEFAULT_TX_POWER_WATTS,
+ val txFreqMhz: Double = DEFAULT_TX_FREQ_MHZ,
+ 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`), asking the planner to
+ * hand the result to a native host bridge (`bridge=1`) rather than the share sheet. Advanced params map onto the
+ * planner's flat query contract (receiver / simulation / display sections).
+ */
+ fun toQueryUrl(baseUrl: String): 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&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.
+ 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
+ const val DEFAULT_TX_GAIN_DBI: Double = 2.0
+ const val DEFAULT_COLOR_SCALE: String = "plasma"
+ const val DEFAULT_RX_SENSITIVITY_DBM: Double = -130.0
+ const val DEFAULT_RX_HEIGHT_METERS: Double = 1.0
+ const val DEFAULT_MAX_RANGE_KM: Double = 30.0
+ const val DEFAULT_MIN_DBM: Double = -130.0
+ 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
+ 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_TRANSPARENCY: Int = 0
+ const val MAX_TRANSPARENCY: Int = 100
+
+ /** Coverage palettes the planner ships (value → label), mirroring its Display.vue picker. */
+ val COLOR_SCALES: List> =
+ listOf(
+ "plasma" to "Plasma",
+ "viridis" to "Viridis (colorblind-safe)",
+ "CMRmap" to "CMR map",
+ "cool" to "Cool",
+ "turbo" to "Turbo",
+ "jet" to "Jet",
+ )
+ }
+}
+
+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
new file mode 100644
index 000000000..aba76a149
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt
@@ -0,0 +1,438 @@
+/*
+ * 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 .
+ */
+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@file:Suppress("TooManyFunctions") // small, single-purpose section composables mirroring the planner's panels
+
+package org.meshtastic.feature.map.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.AssistChipDefaults
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.latitude
+import org.meshtastic.core.resources.longitude
+import org.meshtastic.core.resources.name
+import org.meshtastic.core.resources.site_planner
+import org.meshtastic.core.resources.site_planner_antenna_gain_dbi
+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_latitude
+import org.meshtastic.core.resources.site_planner_invalid_longitude
+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
+import org.meshtastic.core.resources.site_planner_rx_height_meters
+import org.meshtastic.core.resources.site_planner_rx_sensitivity_dbm
+import org.meshtastic.core.resources.site_planner_section_display
+import org.meshtastic.core.resources.site_planner_section_receiver
+import org.meshtastic.core.resources.site_planner_section_simulation
+import org.meshtastic.core.resources.site_planner_section_transmitter
+import org.meshtastic.core.resources.site_planner_subtitle
+import org.meshtastic.core.resources.site_planner_tx_power_watts
+import org.meshtastic.core.resources.site_planner_use_current_location
+import org.meshtastic.core.resources.site_planner_use_map_center
+import org.meshtastic.core.resources.site_planner_use_node_location
+import org.meshtastic.core.ui.icon.ExpandLess
+import org.meshtastic.core.ui.icon.ExpandMore
+import org.meshtastic.core.ui.icon.Map
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.MyLocation
+import org.meshtastic.core.ui.icon.PinDrop
+
+private const val LAT_LIMIT = 90.0
+private const val LON_LIMIT = 180.0
+private val SWATCH_WIDTH = 40.dp
+private val SWATCH_HEIGHT = 16.dp
+
+/** Editable form state; string-backed so partial/invalid input is preserved while typing. */
+@Stable
+private class SiteFormState(initial: SitePlannerParams) {
+ var name by mutableStateOf(initial.name)
+ var lat by mutableStateOf(initial.latitude.toString())
+ var lon by mutableStateOf(initial.longitude.toString())
+ var power by mutableStateOf(initial.txPowerWatts.toString())
+ var freq by mutableStateOf(initial.txFreqMhz.toString())
+ var height by mutableStateOf(initial.txHeightMeters.toString())
+ var gain by mutableStateOf(initial.txGainDbi.toString())
+ var colorScale by mutableStateOf(initial.colorScale)
+
+ // Advanced (coverage-affecting) fields.
+ 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
+ get() = lat.toDoubleOrNull()
+
+ private val lonValue
+ get() = lon.toDoubleOrNull()
+
+ val latBad
+ get() = latValue.let { it == null || it !in -LAT_LIMIT..LAT_LIMIT }
+
+ val lonBad
+ get() = lonValue.let { it == null || it !in -LON_LIMIT..LON_LIMIT }
+
+ val powerBad
+ get() = (power.toDoubleOrNull() ?: 0.0) <= 0.0
+
+ val freqBad
+ get() = (freq.toDoubleOrNull() ?: 0.0) <= 0.0
+
+ val rxSensBad
+ get() =
+ rxSensitivity.toDoubleOrNull()?.let {
+ it !in SitePlannerParams.MIN_RX_SENSITIVITY_DBM..SitePlannerParams.MAX_RX_SENSITIVITY_DBM
+ } ?: false
+
+ // 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)
+}
+
+/**
+ * Bottom-sheet form for a Site Planner coverage estimate, pre-filled with [initial]. [onSubmit] receives the edited
+ * params once they validate. Location shortcut chips ([onUseCurrentLocation]/[onUseNodeLocation]/[onUseMapCenter])
+ * re-seed the coordinate fields when provided, preserving edits to the other fields.
+ */
+@Composable
+fun SitePlannerSheet(
+ initial: SitePlannerParams,
+ onSubmit: (SitePlannerParams) -> Unit,
+ onDismiss: () -> Unit,
+ onUseCurrentLocation: (() -> Unit)? = null,
+ onUseNodeLocation: (() -> Unit)? = null,
+ onUseMapCenter: (() -> Unit)? = null,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val state = remember { SiteFormState(initial) }
+ // Re-seed only the coordinates when a shortcut changes them; other fields keep the user's edits.
+ LaunchedEffect(initial.latitude, initial.longitude) {
+ state.lat = initial.latitude.toString()
+ state.lon = initial.longitude.toString()
+ }
+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
+ SiteFormContent(state, initial, onSubmit, onUseCurrentLocation, onUseNodeLocation, onUseMapCenter)
+ }
+}
+
+@Composable
+private fun SiteFormContent(
+ state: SiteFormState,
+ initial: SitePlannerParams,
+ onSubmit: (SitePlannerParams) -> Unit,
+ onUseCurrentLocation: (() -> Unit)?,
+ onUseNodeLocation: (() -> Unit)?,
+ onUseMapCenter: (() -> Unit)?,
+) {
+ Column(
+ modifier =
+ Modifier.fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.site_planner),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.semantics { heading() },
+ )
+ Text(
+ text = stringResource(Res.string.site_planner_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ // Sections mirror the planner's own panel order + default-open state (Site Parameters drawer):
+ // Site / Transmitter (open) → Receiver → Simulation Options → Display (open).
+ TransmitterSection(state, onUseCurrentLocation, onUseNodeLocation, onUseMapCenter)
+ ReceiverSection(state)
+ SimulationSection(state)
+ DisplaySection(state)
+ Button(
+ onClick = { onSubmit(buildSubmitParams(state, initial)) },
+ enabled = state.canSubmit,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(Res.string.site_planner_estimate))
+ }
+ }
+}
+
+/** A collapsible titled section, matching the planner's `Section` panels. */
+@Composable
+private fun FormSection(title: String, defaultExpanded: Boolean, content: @Composable ColumnScope.() -> Unit) {
+ var expanded by remember { mutableStateOf(defaultExpanded) }
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.semantics { heading() })
+ Icon(if (expanded) MeshtasticIcons.ExpandLess else MeshtasticIcons.ExpandMore, contentDescription = null)
+ }
+ if (expanded) content()
+ }
+}
+
+@Composable
+private fun TransmitterSection(
+ state: SiteFormState,
+ onUseCurrentLocation: (() -> Unit)?,
+ onUseNodeLocation: (() -> Unit)?,
+ onUseMapCenter: (() -> Unit)?,
+) {
+ val posMsg = stringResource(Res.string.site_planner_invalid_positive)
+ FormSection(stringResource(Res.string.site_planner_section_transmitter), defaultExpanded = true) {
+ SiteField(state.name, { state.name = it }, Res.string.name, keyboardType = KeyboardType.Text)
+ LocationChips(onUseCurrentLocation, onUseNodeLocation, onUseMapCenter)
+ TransmitterFields(
+ state = state,
+ 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,
+ )
+ }
+}
+
+@Composable
+private fun ReceiverSection(state: SiteFormState) {
+ FormSection(stringResource(Res.string.site_planner_section_receiver), defaultExpanded = false) {
+ SiteField(
+ state.rxSensitivity,
+ { state.rxSensitivity = it },
+ Res.string.site_planner_rx_sensitivity_dbm,
+ error = if (state.rxSensBad) stringResource(Res.string.site_planner_invalid_rx_sensitivity) else null,
+ )
+ SiteField(state.rxHeight, { state.rxHeight = it }, Res.string.site_planner_rx_height_meters)
+ }
+}
+
+@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 })
+ }
+ }
+}
+
+@Composable
+private fun DisplaySection(state: SiteFormState) {
+ FormSection(stringResource(Res.string.site_planner_section_display), defaultExpanded = true) {
+ PalettePicker(state.colorScale) { state.colorScale = it }
+ }
+}
+
+/** Quick shortcuts (chips) that re-seed the coordinates from the device, this node, or the map center. */
+@Composable
+private fun LocationChips(
+ onUseCurrentLocation: (() -> Unit)?,
+ onUseNodeLocation: (() -> Unit)?,
+ onUseMapCenter: (() -> Unit)?,
+) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ onUseCurrentLocation?.let {
+ LocationChip(it, MeshtasticIcons.MyLocation, Res.string.site_planner_use_current_location)
+ }
+ onUseNodeLocation?.let { LocationChip(it, MeshtasticIcons.PinDrop, Res.string.site_planner_use_node_location) }
+ onUseMapCenter?.let { LocationChip(it, MeshtasticIcons.Map, Res.string.site_planner_use_map_center) }
+ }
+}
+
+@Composable
+private fun LocationChip(onClick: () -> Unit, icon: ImageVector, label: StringResource) {
+ AssistChip(
+ onClick = onClick,
+ leadingIcon = { Icon(icon, null, Modifier.size(AssistChipDefaults.IconSize)) },
+ label = { Text(stringResource(label)) },
+ )
+}
+
+@Composable
+private fun TransmitterFields(
+ state: SiteFormState,
+ latError: String?,
+ lonError: String?,
+ powerError: String?,
+ freqError: String?,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ SiteField(state.lat, { state.lat = it }, Res.string.latitude, error = latError)
+ SiteField(state.lon, { state.lon = it }, Res.string.longitude, error = lonError)
+ SiteField(state.power, { state.power = it }, Res.string.site_planner_tx_power_watts, error = powerError)
+ SiteField(state.freq, { state.freq = it }, Res.string.site_planner_frequency_mhz, error = freqError)
+ SiteField(state.height, { state.height = it }, Res.string.site_planner_antenna_height_meters)
+ SiteField(state.gain, { state.gain = it }, Res.string.site_planner_antenna_gain_dbi)
+ }
+}
+
+@Composable
+private fun SiteField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: StringResource,
+ modifier: Modifier = Modifier,
+ keyboardType: KeyboardType = KeyboardType.Number,
+ error: String? = null,
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = { Text(stringResource(label)) },
+ singleLine = true,
+ isError = error != null,
+ supportingText = error?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
+ modifier = modifier.fillMaxWidth(),
+ )
+}
+
+/** Color-scale picker with a gradient swatch for each palette (recognition over recall). */
+@Composable
+private fun PalettePicker(value: String, onChange: (String) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+ ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
+ OutlinedTextField(
+ value = SitePlannerParams.COLOR_SCALES.firstOrNull { it.first == value }?.second ?: value,
+ onValueChange = {},
+ readOnly = true,
+ label = { Text(stringResource(Res.string.site_planner_color_scale)) },
+ leadingIcon = { PaletteSwatch(value) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
+ )
+ ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ SitePlannerParams.COLOR_SCALES.forEach { (key, label) ->
+ DropdownMenuItem(
+ text = { Text(label) },
+ leadingIcon = { PaletteSwatch(key) },
+ onClick = {
+ onChange(key)
+ expanded = false
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PaletteSwatch(name: String) {
+ Box(
+ Modifier.width(SWATCH_WIDTH)
+ .height(SWATCH_HEIGHT)
+ .clip(RoundedCornerShape(4.dp))
+ .background(Brush.horizontalGradient(paletteStops(name))),
+ )
+}
+
+/** Representative gradient stops per planner palette (approximations of the matplotlib LUTs for a preview swatch). */
+@Suppress("MagicNumber")
+private fun paletteStops(name: String): List {
+ val argb =
+ when (name) {
+ "viridis" -> listOf(0xFF440154, 0xFF31688E, 0xFF35B779, 0xFFFDE725)
+ "CMRmap" -> listOf(0xFF000000, 0xFF3B0F70, 0xFF8C2981, 0xFFDE4968, 0xFFFE9F6D, 0xFFFFFFFF)
+ "cool" -> listOf(0xFF00FFFF, 0xFFFF00FF)
+ "turbo" -> listOf(0xFF30123B, 0xFF4585F9, 0xFF1BD0D5, 0xFFA4FC3B, 0xFFFABA39, 0xFFE5460A, 0xFF7A0403)
+ "jet" -> listOf(0xFF000080, 0xFF0000FF, 0xFF00FFFF, 0xFFFFFF00, 0xFFFF0000, 0xFF800000)
+ else -> listOf(0xFF0D0887, 0xFF7E03A8, 0xFFCC4778, 0xFFF89540, 0xFFF0F921)
+ }
+ return argb.map { Color(it) }
+}
+
+/** Build params from the (string) form fields, falling back to the matching [initial] value when a field is invalid. */
+private fun buildSubmitParams(state: SiteFormState, initial: SitePlannerParams): SitePlannerParams = SitePlannerParams(
+ name = state.name.ifBlank { initial.name },
+ latitude = state.lat.toDoubleOrNull() ?: initial.latitude,
+ longitude = state.lon.toDoubleOrNull() ?: initial.longitude,
+ txPowerWatts = state.power.toDoubleOrNull() ?: initial.txPowerWatts,
+ txFreqMhz = state.freq.toDoubleOrNull() ?: initial.txFreqMhz,
+ txHeightMeters = state.height.toDoubleOrNull() ?: initial.txHeightMeters,
+ txGainDbi = state.gain.toDoubleOrNull() ?: initial.txGainDbi,
+ colorScale = state.colorScale,
+ rxSensitivityDbm = state.rxSensitivity.toDoubleOrNull() ?: initial.rxSensitivityDbm,
+ rxHeightMeters = state.rxHeight.toDoubleOrNull() ?: initial.rxHeightMeters,
+ maxRangeKm = state.maxRange.toDoubleOrNull() ?: initial.maxRangeKm,
+ highResolution = state.highResolution,
+ minDbm = initial.minDbm,
+ maxDbm = initial.maxDbm,
+ overlayTransparency = initial.overlayTransparency,
+)
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
new file mode 100644
index 000000000..1dc447c11
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.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 `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/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index 13db96d5a..a21e81204 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -38,12 +38,16 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.open_compass
+import org.meshtastic.core.resources.site_planner_estimate
+import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.Distance
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.LocalInlineMapProvider
+import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.Config
@@ -79,6 +83,24 @@ internal fun PositionInlineContent(
overflow = TextOverflow.Ellipsis,
)
}
+ // Estimate RF coverage for this node in the Site Planner (Google flavor, position-gated).
+ if (LocalSitePlannerAvailable.current && node.validPosition != null) {
+ Spacer(Modifier.height(8.dp))
+ FilledTonalButton(
+ onClick = { onAction(NodeDetailAction.Navigate(MapRoute.Map(sitePlannerNodeNum = node.num))) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ ) {
+ Icon(MeshtasticIcons.CellTower, null, Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = stringResource(Res.string.site_planner_estimate),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
}
@Composable