Compare commits

...
5 changed files with 462 additions and 214 deletions

No files matched your search

+17 -36
View File
@@ -1,45 +1,26 @@
# Look4Sat: Satellite tracker # Look4Sat-BA7OPF
[![Look4Sat CI](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml/badge.svg)](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml) [![Look4Sat CI](https://github.com/atsunatsu/Look4Sat/actions/workflows/release.yml/badge.svg)](https://github.com/atsunatsu/Look4Sat/actions/workflows/release.yml)
[<img src="https://play.google.com/intl/en_gb/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.rtbishop.look4sat) **BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.rtbishop.look4sat/)
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict ## 本仓库特色功能
<p float="left"> - **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/> - **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/> - **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/> - **中文界面优化** — 翻译修正、UI 布局调整
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
</p>
### Track satellite passes with ease! ## 上游仓库
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\ 本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
You can search the entire database by NORAD Catalog Number or the satellite's name.
Orbital positions and passes are calculated relative to your location.\ - 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
To get reliable data make sure to set the station position via the app Settings. - SGP4/SDP4 轨道预测,10 天过境预报
- 极坐标雷达图、地面轨迹图
- SSTV 图像解码
- 无广告、无跟踪、完全离线
The application is built using Kotlin, Coroutines, Jetpack Compose and Navigation.\ ## 许可证
It is now and always will be completely ad-free and open-source.
## Main features: GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
* Predicting satellite positions and passes for up to 10 days
* Showing the list of currently active and upcoming satellite passes
* Showing the active pass progress, polar trajectory and transceivers info
* Showing the satellite positional data, footprint and ground track on the map
* Custom TLE satellite data import is available via Three Line Element .txt files
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
## Star History
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
</picture>
</a>
+16 -4
View File
@@ -3,10 +3,22 @@ plugins {
} }
android { android {
// PR #1 CW decoder uses the Morse Expert native decoder, which ships only armeabi-v7a. namespace = libs.versions.packageName.get()
defaultConfig { defaultConfig {
ndk { applicationId = "cn.ba7opf.look4sat"
abiFilters += listOf("armeabi-v7a") ndk { abiFilters.add("armeabi-v7a") }
}
signingConfigs {
create("release") {
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
storePassword = "look4sat123"
keyAlias = "look4sat"
keyPassword = "look4sat123"
} }
} }
} buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
@@ -16,20 +16,28 @@ import java.util.Locale
/** /**
* Computes Doppler-corrected reciprocal frequencies for linear transponders. * Computes Doppler-corrected reciprocal frequencies for linear transponders.
* *
* For a linear (passband) transponder, uplink and downlink frequencies are * The full physical path:
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, we compute the other:
* *
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler) * TX→RX (uplink → downlink):
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler) * ① 地面发射 f_tx
* ② 卫星收到 f_tx × (c - v) / c (上行多普勒)
* ③ 卫星转发 = passband映射(②) (在卫星上做映射)
* ④ 地面听到 ③ × (c - v) / c (下行多普勒)
*
* RX→TX (downlink → uplink):
* ④ 地面听到 f_rx
* ③ 卫星转发 = f_rx × (c + v) / c (逆下行多普勒)
* ② 卫星收到 = 逆passband映射(③)
* ① 地面应发射 = ② × (c + v) / c (逆上行多普勒)
* *
* Addresses GitHub issue #91 (Custom frequency Doppler correction). * Addresses GitHub issue #91 (Custom frequency Doppler correction).
*/ */
object DopplerFrequencyCalculator { object DopplerFrequencyCalculator {
/** /**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency. * Given a downlink frequency (what the user hears), compute the
* Returns null if the transponder is not a linear passband type. * uplink frequency the user should transmit.
* Full path: ④→③→②→①
*/ */
fun computeUplinkFromDownlink( fun computeUplinkFromDownlink(
downlinkHz: Long, downlinkHz: Long,
@@ -37,17 +45,22 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos orbitalPos: OrbitalPos
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null // ④→③ 逆下行多普勒:卫星转发的频率
return orbitalPos.getUplinkFreq(baseUplink) val satTx = orbitalPos.getUplinkFreq(downlinkHz)
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
} }
/** /**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency * Given a downlink frequency (what the user hears), compute the
* with an offset applied to the downlink (in Hz). * uplink frequency the user should transmit, with an offset applied
* Returns null if the transponder is not a linear passband type. * to the downlink (in Hz).
* Full path: ④→③→②→①
* *
* The user-entered downlink frequency already includes the offset, so subtract * The user-entered downlink frequency already includes the offset, so subtract
* it before mapping the downlink passband position back to the uplink. * it before the inverse downlink Doppler.
*/ */
fun computeUplinkFromDownlinkWithOffset( fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long, downlinkHz: Long,
@@ -56,13 +69,20 @@ object DopplerFrequencyCalculator {
offsetHz: Long offsetHz: Long
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null // ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
return orbitalPos.getUplinkFreq(baseUplink) val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz)
// ③ 去掉 offset(offset 在卫星本地频率域)
val satTx = satTxWithOffset - offsetHz
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
} }
/** /**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency. * Given an uplink frequency (what the user transmits), compute the
* Returns null if the transponder is not a linear passband type. * downlink frequency the user will hear.
* Full path: ①→②→③→④
*/ */
fun computeDownlinkFromUplink( fun computeDownlinkFromUplink(
uplinkHz: Long, uplinkHz: Long,
@@ -70,14 +90,19 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos orbitalPos: OrbitalPos
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null // ①→② 上行多普勒:卫星收到的频率
return orbitalPos.getDownlinkFreq(baseDownlink) val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx)
} }
/** /**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency * Given an uplink frequency (what the user transmits), compute the
* with an offset applied to the downlink (in Hz). * downlink frequency the user will hear, with an offset applied
* Returns null if the transponder is not a linear passband type. * to the downlink (in Hz).
* Full path: ①→②→③→④
*/ */
fun computeDownlinkFromUplinkWithOffset( fun computeDownlinkFromUplinkWithOffset(
uplinkHz: Long, uplinkHz: Long,
@@ -86,8 +111,13 @@ object DopplerFrequencyCalculator {
offsetHz: Long offsetHz: Long
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null // ①→② 上行多普勒:卫星收到的频率
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz) val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③ 加上 offset(offset 在卫星本地频率域)
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx + offsetHz)
} }
/** True if this transponder supports linear passband mapping. */ /** True if this transponder supports linear passband mapping. */
@@ -92,7 +92,7 @@
<string name="prefs_updated_title">更新:%s</string> <string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string> <string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_loc_title">站位</string> <string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string> <string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string> <string name="prefs_loc_gps_error">检查您的位置权限</string>
<string name="prefs_loc_input_title">输入位置</string> <string name="prefs_loc_input_title">输入位置</string>
@@ -100,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH 网格</string> <string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string> <string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string> <string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站位设置</string> <string name="prefs_station_title">站点位置设置</string>
<string name="prefs_station_lat_text">输入站位的纬度</string> <string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站位的经度</string> <string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string> <string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string> <string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string> <string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string> <string name="prefs_data_entries">卫星:%s</string>
@@ -23,14 +23,18 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -41,6 +45,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
@@ -49,22 +54,30 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -76,6 +89,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -146,9 +160,7 @@ fun CalculatorPage(
} }
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier.fillMaxSize(),
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
if (calculatorTransceivers.size > 1) { if (calculatorTransceivers.size > 1) {
@@ -532,6 +544,8 @@ private fun ExpandedRadioControl(
} }
} }
private enum class EditedField { TX, PASSBAND, RX }
@Composable @Composable
private fun DopplerFrequencyCalculator( private fun DopplerFrequencyCalculator(
transponder: SatRadio, transponder: SatRadio,
@@ -540,144 +554,351 @@ private fun DopplerFrequencyCalculator(
) { ) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var txInputMHz by remember { mutableStateOf("") } var lastEditedField by remember { mutableStateOf(EditedField.TX) }
var rxInputMHz by remember { mutableStateOf("") } var txFrequencyHz by remember { mutableStateOf(0L) }
var rxFrequencyHz by remember { mutableStateOf(0L) }
var passbandPosition by remember { mutableStateOf(0.5f) }
var offsetKHz by remember { mutableStateOf("") } var offsetKHz by remember { mutableStateOf("") }
var lastEditedBy by remember { mutableStateOf(EditedField.TX) } var stepSizeKHz by remember { mutableIntStateOf(1) }
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
// Real-time refresh: when orbitalPos changes (1Hz), recompute the opposite field val txLow = transponder.uplinkLow ?: return
val txHigh = transponder.uplinkHigh ?: return
val rxLow = transponder.downlinkLow ?: return
val rxHigh = transponder.downlinkHigh ?: return
val txRange = txHigh - txLow
val rxRange = rxHigh - rxLow
if (txRange <= 0L || rxRange <= 0L) return
// 原始转发器值(逆转多普勒),用于 passband 映射计算
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
val rawTxRange = rawTxHigh - rawTxLow
val rawRxRange = rawRxHigh - rawRxLow
val rawTransponder = transponder.copy(
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
)
// 初始化
LaunchedEffect(Unit) {
if (txFrequencyHz == 0L) {
txFrequencyHz = txLow + txRange / 2
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: (rxLow + rxRange / 2)
}
}
// 实时重算:卫星移动时,锚点侧不变,重算另一侧
LaunchedEffect(orbitalPos) { LaunchedEffect(orbitalPos) {
if (lastEditedBy == EditedField.TX) { if (lastEditedField == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull() // TX 是锚点,不变;重算 RX
if (txMHz != null && txMHz > 0) { rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
val txHz = (txMHz * 1_000_000).toLong() txFrequencyHz, rawTransponder, orbitalPos, offsetHz
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( ) ?: rxFrequencyHz
txHz, transponder, orbitalPos, offsetHz } else if (lastEditedField == EditedField.RX) {
) // RX 是锚点,不变;重算 TX
if (rxHz != null) { txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
} ) ?: txFrequencyHz
} else if (lastEditedField == EditedField.PASSBAND) {
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
passbandPosition
} else {
((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
}
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
} else {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
}
fun updateTx(newTxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
passbandPosition = pos
txFrequencyHz = txLow + (pos * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
} }
} else { else -> {
val rxMHz = rxInputMHz.toDoubleOrNull() txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
if (rxMHz != null && rxMHz > 0) { rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
val rxHz = (rxMHz * 1_000_000).toLong() txFrequencyHz, rawTransponder, orbitalPos, offsetHz
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( ) ?: rxFrequencyHz
rxHz, transponder, orbitalPos, offsetHz }
) }
if (txHz != null) { }
txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0)
} fun updateRx(newRxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
passbandPosition = if (txFromRx != null) {
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
} else pos
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
} }
} }
} }
Column( Column(
modifier = modifier, modifier = modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Row(
text = stringResource(R.string.radar_doppler_calc), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
fontSize = 14.sp, verticalAlignment = Alignment.CenterVertically,
fontWeight = FontWeight.Medium, horizontalArrangement = Arrangement.spacedBy(6.dp)
color = MaterialTheme.colorScheme.primary ) {
) // 左半边: TX + RX 按钮
Text( Row(
text = stringResource(R.string.radar_doppler_info), modifier = Modifier.weight(1f),
fontSize = 12.sp, horizontalArrangement = Arrangement.spacedBy(4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant verticalAlignment = Alignment.CenterVertically
) ) {
FilterChip(
// Offset input selected = lastEditedField == EditedField.TX,
OutlinedTextField( onClick = {
value = offsetKHz, when (lastEditedField) {
onValueChange = { newVal -> EditedField.TX -> {
offsetKHz = newVal passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
// Trigger recompute based on last edited field lastEditedField = EditedField.PASSBAND
if (lastEditedBy == EditedField.TX) { rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
val txMHz = txInputMHz.toDoubleOrNull() txFrequencyHz, rawTransponder, orbitalPos, offsetHz
if (txMHz != null && txMHz > 0) { ) ?: rxFrequencyHz
val txHz = (txMHz * 1_000_000).toLong() }
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
txHz, transponder, orbitalPos, offsetHz else -> { lastEditedField = EditedField.TX }
) }
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else "" },
} label = {
} else { Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
val rxMHz = rxInputMHz.toDoubleOrNull() modifier = Modifier.fillMaxWidth())
if (rxMHz != null && rxMHz > 0) { },
val rxHz = (rxMHz * 1_000_000).toLong() modifier = Modifier.weight(1f).fillMaxHeight()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( )
rxHz, transponder, orbitalPos, offsetHz FilterChip(
) selected = lastEditedField == EditedField.RX,
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else "" onClick = {
} when (lastEditedField) {
EditedField.RX -> {
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
}
EditedField.PASSBAND -> {
lastEditedField = EditedField.RX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
else -> { lastEditedField = EditedField.RX }
}
},
label = {
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
} }
}, // 右半边: Offset 输入框(带细线边框)
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, Box(
singleLine = true, modifier = Modifier
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), .weight(1f)
modifier = Modifier.fillMaxWidth() .height(48.dp)
) .border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
BasicTextField(
value = offsetKHz,
onValueChange = { offsetKHz = it },
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) {
Text(
text = "Offset (kHz)",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
)
}
}
// TX and RX inputs on the same row // TX 行
Row( Column(modifier = Modifier.fillMaxWidth()) {
horizontalArrangement = Arrangement.spacedBy(8.dp), Row(verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier.fillMaxWidth() OutlinedButton(
) { onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
// TX input → compute RX modifier = Modifier.width(32.dp).height(28.dp),
OutlinedTextField( contentPadding = PaddingValues(0.dp),
value = txInputMHz, shape = MaterialTheme.shapes.extraSmall
onValueChange = { newVal -> ) { Text("−", fontSize = 14.sp) }
txInputMHz = newVal
lastEditedBy = EditedField.TX Slider(
val mhz = newVal.toDoubleOrNull() value = txSliderValue,
if (mhz != null && mhz > 0) { onValueChange = { norm ->
val txHz = (mhz * 1_000_000).toLong() val rawFreq = txLow + (txRange * norm).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( val snapped = ((rawFreq + 500) / 1000L) * 1000L
txHz, transponder, orbitalPos, offsetHz updateTx(snapped.coerceIn(txLow, txHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
) )
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
rxInputMHz = ""
} }
}, Text(
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) }, text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
singleLine = true, fontSize = 12.sp,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
) )
// RX input → compute TX // RX 行
OutlinedTextField( Column(modifier = Modifier.fillMaxWidth()) {
value = rxInputMHz, Row(verticalAlignment = Alignment.CenterVertically) {
onValueChange = { newVal -> OutlinedButton(
rxInputMHz = newVal onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
lastEditedBy = EditedField.RX modifier = Modifier.width(32.dp).height(28.dp),
val mhz = newVal.toDoubleOrNull() contentPadding = PaddingValues(0.dp),
if (mhz != null && mhz > 0) { shape = MaterialTheme.shapes.extraSmall
val rxHz = (mhz * 1_000_000).toLong() ) { Text("−", fontSize = 14.sp) }
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz Slider(
value = rxSliderValue,
onValueChange = { norm ->
val rawFreq = rxLow + (rxRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateRx(snapped.coerceIn(rxLow, rxHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
) )
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
txInputMHz = ""
} }
}, Text(
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) }, text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
singleLine = true, fontSize = 12.sp,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) textAlign = TextAlign.End
)
}
}
} }
}
} }
private enum class EditedField { TX, RX }
@Composable @Composable
private fun CwDecoderPanel( private fun CwDecoderPanel(
cw: CwSubState, cw: CwSubState,
@@ -717,30 +938,36 @@ private fun CwDecoderPanel(
// PR #1 Morse Expert engine: mini layout with waterfall + decoded text. // PR #1 Morse Expert engine: mini layout with waterfall + decoded text.
// Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it. // Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it.
val context = LocalContext.current val context = LocalContext.current
val activity = remember { context as? Activity } val activity = remember(context) {
context as? Activity ?: error("CwDecoderPanel must be hosted in an Activity")
}
val controller = remember { MainActivity() } val controller = remember { MainActivity() }
val rootView = remember { val rootView = remember(context) {
LayoutInflater.from(context).inflate(CwR.layout.cw_panel_main, null) as ConstraintLayout LayoutInflater.from(context).inflate(CwR.layout.activity_main, null) as ConstraintLayout
} }
var initialized by remember { mutableStateOf(false) } var initialized by remember { mutableStateOf(false) }
var listening by remember { mutableStateOf(false) } var listening by remember { mutableStateOf(false) }
DisposableEffect(Unit) { // 重要: 不在展开时初始化控制器(避免"一展开就崩溃")。
if (activity != null) { // controller.onCreate/onResume/onPermissionGranted 全部推迟到用户点 Start 才执行。
controller.onCreate(activity, rootView, false) fun startDecoding() {
} if (!initialized) {
onDispose { controller.onCreate(activity, rootView)
controller.onPause()
controller.onDestroy()
}
}
LaunchedEffect(cw.hasPermission) {
if (cw.hasPermission && !initialized) {
controller.onPermissionGranted() controller.onPermissionGranted()
controller.onResume() controller.onResume()
initialized = true initialized = true
listening = true } else {
controller.onResume()
}
listening = true
}
DisposableEffect(Unit) {
onDispose {
if (initialized) {
controller.onPause()
controller.onDestroy()
}
} }
} }
@@ -754,14 +981,8 @@ private fun CwDecoderPanel(
onClick = { onClick = {
if (!cw.hasPermission) { if (!cw.hasPermission) {
requestMicPermission() requestMicPermission()
} else if (!initialized) {
controller.onPermissionGranted()
controller.onResume()
initialized = true
listening = true
} else { } else {
controller.onResume() startDecoding()
listening = true
} }
}, },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -780,19 +1001,23 @@ private fun CwDecoderPanel(
} }
} }
OutlinedButton( OutlinedButton(
onClick = { controller.clearDecoded() }, onClick = { if (initialized) controller.clearDecoded() },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text(stringResource(R.string.radar_cw_reset)) Text(stringResource(R.string.radar_cw_reset))
} }
} }
AndroidView( Box(
factory = { rootView },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(150.dp) .height(150.dp)
) ) {
AndroidView(
factory = { rootView },
modifier = Modifier.fillMaxSize()
)
}
} }
} }
} }