Add CAT radio control for full-duplex satellite operation (#207)

This commit is contained in:
jcmerg authored and GitHub committed 2026-04-10 11:21:52 +01:00
1 parent 868155c532
commit 2df0b9ba3d
28 files changed
+2126 -8

No files matched your search

@@ -63,3 +63,20 @@ data class DataSourcesSettings(
val tleUrl: String,
val transceiversUrl: String
)
data class RadioControlSettings(
val enabled: Boolean,
val radioModel: String,
val txRadioAddress: String,
val rxRadioAddress: String,
val txRadioName: String,
val rxRadioName: String,
val baudRate: Int
) {
companion object {
val SUPPORTED_RADIOS = listOf(
"Yaesu FT-817/818",
"Yaesu FT-857/897"
)
}
}
@@ -15,6 +15,9 @@ interface IMainContainer {
fun provideBluetoothReporter(): IReporter
fun provideNetworkReporter(): IReporter
fun provideSensorsRepo(): ISensorsRepo
fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController
val radioTrackingService: IRadioTrackingService
}
interface IContainerProvider {
@@ -0,0 +1,41 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
interface IRadioController {
val isConnected: Boolean
suspend fun connect(): Boolean
suspend fun disconnect()
suspend fun setFrequency(frequencyHz: Long): Boolean
suspend fun setMode(mode: String): Boolean
suspend fun setCtcssMode(enabled: Boolean): Boolean
suspend fun setCtcssTone(toneHz: Double): Boolean
suspend fun readFrequencyAndMode(): Pair<Long, String>?
suspend fun pttOn(): Boolean
suspend fun pttOff(): Boolean
}
@@ -0,0 +1,55 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import kotlinx.coroutines.flow.StateFlow
data class RadioTrackingState(
val isActive: Boolean = false,
val txConnected: Boolean = false,
val rxConnected: Boolean = false,
val txFrequencyHz: Long? = null,
val rxFrequencyHz: Long? = null,
val txMode: String? = null,
val rxMode: String? = null,
val ctcssTone: Double? = null,
val txBaseFrequencyHz: Long? = null,
val selectedTransponder: SatRadio? = null,
val currentPass: OrbitalPass? = null,
val azimuth: Double = 0.0,
val elevation: Double = 0.0,
val distance: Double = 0.0,
val errorMessage: String? = null
)
interface IRadioTrackingService {
val state: StateFlow<RadioTrackingState>
suspend fun connectRadios()
suspend fun disconnectRadios()
fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?)
fun stopTracking()
fun setTransponder(transponder: SatRadio)
fun setTxBaseFrequency(frequencyHz: Long)
fun adjustTxBaseFrequency(deltaHz: Long)
fun setCtcssTone(toneHz: Double?)
fun setMode(txMode: String, rxMode: String)
}
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
@@ -71,4 +72,9 @@ interface ISettingsRepo {
val dataSourcesSettings: StateFlow<DataSourcesSettings>
fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion
//region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion
}
@@ -0,0 +1,73 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
object TransponderMapper {
fun mapUplinkToDownlink(txFreqHz: Long, transponder: SatRadio): Long? {
val uplinkLow = transponder.uplinkLow ?: return null
val downlinkLow = transponder.downlinkLow ?: return null
// Single-frequency transponder (FM repeater) - just return the downlink freq
val uplinkHigh = transponder.uplinkHigh
val downlinkHigh = transponder.downlinkHigh
if (uplinkHigh == null || downlinkHigh == null
|| uplinkLow == uplinkHigh || downlinkLow == downlinkHigh
) {
return downlinkLow
}
// Passband transponder (linear) - map within the band
val offset = txFreqHz - uplinkLow
return if (transponder.isInverted) {
downlinkHigh - offset
} else {
downlinkLow + offset
}
}
fun mapDownlinkToUplink(rxFreqHz: Long, transponder: SatRadio): Long? {
val uplinkLow = transponder.uplinkLow ?: return null
val downlinkLow = transponder.downlinkLow ?: return null
val uplinkHigh = transponder.uplinkHigh
val downlinkHigh = transponder.downlinkHigh
if (uplinkHigh == null || downlinkHigh == null
|| uplinkLow == uplinkHigh || downlinkLow == downlinkHigh
) {
return uplinkLow
}
return if (transponder.isInverted) {
uplinkLow + (downlinkHigh - rxFreqHz)
} else {
uplinkLow + (rxFreqHz - downlinkLow)
}
}
fun mapUplinkModeToDownlinkMode(txMode: String, isInverted: Boolean): String {
if (!isInverted) return txMode
return when (txMode.uppercase()) {
"USB" -> "LSB"
"LSB" -> "USB"
else -> txMode
}
}
}
@@ -0,0 +1,91 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class TransponderMapperTest {
private fun makeRadio(
uplinkLow: Long? = 435000000L,
uplinkHigh: Long? = 435100000L,
downlinkLow: Long? = 145800000L,
downlinkHigh: Long? = 145900000L,
isInverted: Boolean = true
) = SatRadio(
uuid = "test",
info = "Test",
isAlive = true,
downlinkLow = downlinkLow,
downlinkHigh = downlinkHigh,
downlinkMode = "USB",
uplinkLow = uplinkLow,
uplinkHigh = uplinkHigh,
uplinkMode = "USB",
isInverted = isInverted,
catnum = 0
)
@Test
fun invertedTransponder_mapsFrequencyCorrectly() {
val radio = makeRadio(isInverted = true)
// TX at uplinkLow → RX should be downlinkHigh
assertEquals(145900000L, TransponderMapper.mapUplinkToDownlink(435000000L, radio))
// TX at uplinkHigh → RX should be downlinkLow
assertEquals(145800000L, TransponderMapper.mapUplinkToDownlink(435100000L, radio))
// TX at center → RX at center
assertEquals(145850000L, TransponderMapper.mapUplinkToDownlink(435050000L, radio))
}
@Test
fun nonInvertedTransponder_mapsFrequencyCorrectly() {
val radio = makeRadio(isInverted = false)
// TX at uplinkLow → RX should be downlinkLow
assertEquals(145800000L, TransponderMapper.mapUplinkToDownlink(435000000L, radio))
// TX at uplinkHigh → RX should be downlinkHigh
assertEquals(145900000L, TransponderMapper.mapUplinkToDownlink(435100000L, radio))
}
@Test
fun nullFrequencies_returnsNull() {
assertNull(TransponderMapper.mapUplinkToDownlink(435000000L, makeRadio(uplinkLow = null)))
assertNull(TransponderMapper.mapUplinkToDownlink(435000000L, makeRadio(downlinkLow = null)))
}
@Test
fun singleFrequencyTransponder_returnsDownlinkLow() {
// FM repeater style: no passband, just single frequencies
val radio = makeRadio(
uplinkLow = 145990000L, uplinkHigh = null,
downlinkLow = 436300000L, downlinkHigh = null,
isInverted = false
)
assertEquals(436300000L, TransponderMapper.mapUplinkToDownlink(145990000L, radio))
}
@Test
fun equalLowHighTransponder_returnsDownlinkLow() {
val radio = makeRadio(
uplinkLow = 145990000L, uplinkHigh = 145990000L,
downlinkLow = 436300000L, downlinkHigh = 436300000L,
isInverted = false
)
assertEquals(436300000L, TransponderMapper.mapUplinkToDownlink(145990000L, radio))
}
@Test
fun invertedMode_swapsUsbLsb() {
assertEquals("LSB", TransponderMapper.mapUplinkModeToDownlinkMode("USB", true))
assertEquals("USB", TransponderMapper.mapUplinkModeToDownlinkMode("LSB", true))
assertEquals("FM", TransponderMapper.mapUplinkModeToDownlinkMode("FM", true))
}
@Test
fun nonInvertedMode_keepsSame() {
assertEquals("USB", TransponderMapper.mapUplinkModeToDownlinkMode("USB", false))
assertEquals("LSB", TransponderMapper.mapUplinkModeToDownlinkMode("LSB", false))
assertEquals("FM", TransponderMapper.mapUplinkModeToDownlinkMode("FM", false))
}
}