mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Add CAT radio control for full-duplex satellite operation (#207)
This commit is contained in:
1 parent
868155c532
commit
2df0b9ba3d
28 files changed
+2126
-8
No files matched your search
@@ -17,9 +17,28 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -27,8 +46,18 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaul
|
|||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
@@ -36,9 +65,11 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.feature.map.mapDestination
|
import com.rtbishop.look4sat.feature.map.mapDestination
|
||||||
import com.rtbishop.look4sat.feature.passes.passesDestination
|
import com.rtbishop.look4sat.feature.passes.passesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.radarDestination
|
import com.rtbishop.look4sat.feature.radar.radarDestination
|
||||||
|
import com.rtbishop.look4sat.feature.radiocontrol.radioControlDestination
|
||||||
import com.rtbishop.look4sat.feature.satellites.satellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.satellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.settingsDestination
|
import com.rtbishop.look4sat.feature.settings.settingsDestination
|
||||||
|
|
||||||
@@ -47,6 +78,12 @@ fun MainScreen(navController: NavHostController = rememberNavController()) {
|
|||||||
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
||||||
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route
|
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route
|
||||||
val startDestination = Screen.Passes.route
|
val startDestination = Screen.Passes.route
|
||||||
|
|
||||||
|
// Observe radio tracking state for the status bar
|
||||||
|
val context = LocalContext.current
|
||||||
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
NavigationSuiteScaffold(
|
NavigationSuiteScaffold(
|
||||||
navigationSuiteItems = {
|
navigationSuiteItems = {
|
||||||
items.forEach {
|
items.forEach {
|
||||||
@@ -71,20 +108,78 @@ fun MainScreen(navController: NavHostController = rememberNavController()) {
|
|||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
Column {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination,
|
startDestination = startDestination,
|
||||||
enterTransition = { fadeIn(animationSpec = tween(350)) },
|
enterTransition = { fadeIn(animationSpec = tween(350)) },
|
||||||
exitTransition = { fadeOut(animationSpec = tween(350)) }
|
exitTransition = { fadeOut(animationSpec = tween(350)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
satellitesDestination { navController.navigateUp() }
|
satellitesDestination { navController.navigateUp() }
|
||||||
passesDestination { catNum: Int, aosTime: Long ->
|
passesDestination { catNum: Int, aosTime: Long ->
|
||||||
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
|
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
|
||||||
navController.navigate(radarRoute)
|
navController.navigate(radarRoute)
|
||||||
}
|
}
|
||||||
radarDestination { navController.navigateUp() }
|
radarDestination(
|
||||||
|
navigateUp = { navController.navigateUp() },
|
||||||
|
navigateToRadioControl = { catNum, aosTime ->
|
||||||
|
val route = "${Screen.RadioControl.route}?catNum=$catNum&aosTime=$aosTime"
|
||||||
|
navController.navigate(route)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
radioControlDestination { navController.navigateUp() }
|
||||||
mapDestination()
|
mapDestination()
|
||||||
settingsDestination()
|
settingsDestination()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Radio tracking status banner (above bottom navigation)
|
||||||
|
if (trackingState.isActive) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "pulseAlpha"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.clickable {
|
||||||
|
val pass = trackingState.currentPass
|
||||||
|
if (pass != null) {
|
||||||
|
val route = "${Screen.RadioControl.route}?catNum=${pass.catNum}&aosTime=${pass.aosTime}"
|
||||||
|
navController.navigate(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||||
|
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||||
|
Text(
|
||||||
|
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // end Column
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
@@ -34,6 +34,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
|
implementation(project(":feature:radiocontrol"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* 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.data.framework
|
||||||
|
|
||||||
|
object Ft817CatProtocol {
|
||||||
|
|
||||||
|
const val CMD_SET_FREQ: Byte = 0x01
|
||||||
|
const val CMD_READ_FREQ_MODE: Byte = 0x03
|
||||||
|
const val CMD_SET_MODE: Byte = 0x07
|
||||||
|
const val CMD_PTT_ON: Byte = 0x08
|
||||||
|
const val CMD_PTT_OFF: Byte = 0x88.toByte()
|
||||||
|
const val CMD_CTCSS_MODE: Byte = 0x0A
|
||||||
|
const val CMD_CTCSS_TONE: Byte = 0x0B
|
||||||
|
|
||||||
|
const val CTCSS_ENC_ON: Byte = 0x2A
|
||||||
|
const val CTCSS_OFF: Byte = 0x8A.toByte()
|
||||||
|
|
||||||
|
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
|
||||||
|
"LSB" to 0x00,
|
||||||
|
"USB" to 0x01,
|
||||||
|
"CW" to 0x02,
|
||||||
|
"CW-R" to 0x03,
|
||||||
|
"AM" to 0x04,
|
||||||
|
"FM" to 0x08,
|
||||||
|
"DIG" to 0x0A,
|
||||||
|
"PKT" to 0x0C
|
||||||
|
)
|
||||||
|
|
||||||
|
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode frequency in Hz to 4-byte BCD with 10 Hz resolution.
|
||||||
|
* Example: 145500000 Hz → [0x14, 0x55, 0x00, 0x00]
|
||||||
|
*/
|
||||||
|
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||||
|
val freq10Hz = frequencyHz / 10
|
||||||
|
val bcd = ByteArray(4)
|
||||||
|
val digits = String.format("%08d", freq10Hz)
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
val high = digits[i * 2] - '0'
|
||||||
|
val low = digits[i * 2 + 1] - '0'
|
||||||
|
bcd[i] = ((high shl 4) or low).toByte()
|
||||||
|
}
|
||||||
|
return bcd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode 4-byte BCD frequency to Hz.
|
||||||
|
*/
|
||||||
|
fun decodeFrequencyBcd(bcd: ByteArray): Long {
|
||||||
|
var freq10Hz = 0L
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
val b = bcd[i].toInt() and 0xFF
|
||||||
|
val high = b shr 4
|
||||||
|
val low = b and 0x0F
|
||||||
|
freq10Hz = freq10Hz * 100 + high * 10 + low
|
||||||
|
}
|
||||||
|
return freq10Hz * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode CTCSS tone frequency (in Hz, e.g. 67.0) to 2-byte BCD.
|
||||||
|
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
||||||
|
*/
|
||||||
|
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||||
|
val tone01Hz = (toneHz * 10).toLong()
|
||||||
|
val digits = String.format("%04d", tone01Hz)
|
||||||
|
val bcd = ByteArray(2)
|
||||||
|
for (i in 0 until 2) {
|
||||||
|
val high = digits[i * 2] - '0'
|
||||||
|
val low = digits[i * 2 + 1] - '0'
|
||||||
|
bcd[i] = ((high shl 4) or low).toByte()
|
||||||
|
}
|
||||||
|
return bcd
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
val bcd = encodeFrequencyBcd(frequencyHz)
|
||||||
|
return byteArrayOf(bcd[0], bcd[1], bcd[2], bcd[3], CMD_SET_FREQ)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||||
|
val modeByte = MODE_TO_BYTE[mode.uppercase()] ?: return null
|
||||||
|
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildReadFreqModeCommand(): ByteArray {
|
||||||
|
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_READ_FREQ_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildPttOnCommand(): ByteArray {
|
||||||
|
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_ON)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildPttOffCommand(): ByteArray {
|
||||||
|
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_OFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
|
||||||
|
val sub = if (enabled) CTCSS_ENC_ON else CTCSS_OFF
|
||||||
|
return byteArrayOf(sub, 0x00, 0x00, 0x00, CMD_CTCSS_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
|
||||||
|
val bcd = encodeCtcssToneBcd(toneHz)
|
||||||
|
return byteArrayOf(bcd[0], bcd[1], 0x00, 0x00, CMD_CTCSS_TONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the 5-byte response from a READ FREQ+MODE command.
|
||||||
|
* Returns (frequencyHz, modeString) or null if parsing fails.
|
||||||
|
*/
|
||||||
|
fun parseReadResponse(response: ByteArray): Pair<Long, String>? {
|
||||||
|
if (response.size < 5) return null
|
||||||
|
val freqBcd = response.copyOfRange(0, 4)
|
||||||
|
val frequencyHz = decodeFrequencyBcd(freqBcd)
|
||||||
|
val modeByte = response[4]
|
||||||
|
val mode = BYTE_TO_MODE[modeByte] ?: return null
|
||||||
|
return Pair(frequencyHz, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
* 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.data.framework
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class Ft817Controller(
|
||||||
|
private val bluetoothManager: BluetoothManager,
|
||||||
|
private val deviceAddress: String
|
||||||
|
) : IRadioController {
|
||||||
|
|
||||||
|
private val tag = "FT817"
|
||||||
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
|
private val ioMutex = Mutex()
|
||||||
|
private val commandDelayMs = 200L
|
||||||
|
|
||||||
|
private var socket: BluetoothSocket? = null
|
||||||
|
private var outputStream: OutputStream? = null
|
||||||
|
private var inputStream: InputStream? = null
|
||||||
|
|
||||||
|
override var isConnected: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
if (isConnected) return@withContext true
|
||||||
|
if (deviceAddress.isBlank()) return@withContext false
|
||||||
|
try {
|
||||||
|
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||||
|
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
btSocket.connect()
|
||||||
|
socket = btSocket
|
||||||
|
outputStream = btSocket.outputStream
|
||||||
|
inputStream = btSocket.inputStream
|
||||||
|
isConnected = true
|
||||||
|
Log.i(tag, "Connected to $deviceAddress")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Connect error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnect() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
inputStream?.close()
|
||||||
|
outputStream?.close()
|
||||||
|
socket?.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Disconnect error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
inputStream = null
|
||||||
|
outputStream = null
|
||||||
|
socket = null
|
||||||
|
isConnected = false
|
||||||
|
Log.i(tag, "Disconnected from $deviceAddress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetModeCommand(mode) ?: return@withContext false
|
||||||
|
ioMutex.withLock { sendCommandWithAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
sendCommandWithAck(Ft817CatProtocol.buildCtcssModeCommand(enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
sendCommandWithAck(Ft817CatProtocol.buildSetCtcssToneCommand(toneHz))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
||||||
|
if (!sent) return@withContext null
|
||||||
|
delay(commandDelayMs)
|
||||||
|
val response = readResponse(5) ?: return@withContext null
|
||||||
|
Ft817CatProtocol.parseReadResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOnCommand()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOffCommand()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
||||||
|
return try {
|
||||||
|
outputStream?.write(bytes) ?: return false
|
||||||
|
outputStream?.flush()
|
||||||
|
delay(commandDelayMs)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Send error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
||||||
|
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
||||||
|
if (!sendCommand(bytes)) return false
|
||||||
|
return try {
|
||||||
|
val ack = inputStream?.read() ?: return false
|
||||||
|
ack == 0x00
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "ACK read error: ${e.message}")
|
||||||
|
true // command was sent, ACK read failed - continue anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readResponse(length: Int): ByteArray? {
|
||||||
|
return try {
|
||||||
|
val buffer = ByteArray(length)
|
||||||
|
var read = 0
|
||||||
|
while (read < length) {
|
||||||
|
val count = inputStream?.read(buffer, read, length - read) ?: return null
|
||||||
|
if (count < 0) return null
|
||||||
|
read += count
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Read error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+357
@@ -0,0 +1,357 @@
|
|||||||
|
/*
|
||||||
|
* 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.data.framework
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class RadioTrackingService(
|
||||||
|
private val appScope: CoroutineScope,
|
||||||
|
private val bluetoothManager: BluetoothManager,
|
||||||
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
|
) : IRadioTrackingService {
|
||||||
|
|
||||||
|
private val tag = "RadioTracking"
|
||||||
|
private val _state = MutableStateFlow(RadioTrackingState())
|
||||||
|
override val state: StateFlow<RadioTrackingState> = _state
|
||||||
|
|
||||||
|
private var txController: IRadioController? = null
|
||||||
|
private var rxController: IRadioController? = null
|
||||||
|
private var trackingJob: Job? = null
|
||||||
|
|
||||||
|
override suspend fun connectRadios() {
|
||||||
|
// Disconnect old controllers if any
|
||||||
|
txController?.disconnect()
|
||||||
|
rxController?.disconnect()
|
||||||
|
|
||||||
|
// Read current addresses from settings
|
||||||
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
|
val txAddr = rcSettings.txRadioAddress
|
||||||
|
val rxAddr = rcSettings.rxRadioAddress
|
||||||
|
|
||||||
|
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
||||||
|
|
||||||
|
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||||
|
_state.update { it.copy(errorMessage = "No radio addresses configured. Set them in Settings → FT-817.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val tx = Ft817Controller(bluetoothManager, txAddr)
|
||||||
|
val rx = Ft817Controller(bluetoothManager, rxAddr)
|
||||||
|
txController = tx
|
||||||
|
rxController = rx
|
||||||
|
|
||||||
|
_state.update { it.copy(errorMessage = null) }
|
||||||
|
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
||||||
|
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = txOk,
|
||||||
|
rxConnected = rxOk,
|
||||||
|
errorMessage = when {
|
||||||
|
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
||||||
|
!txOk -> "Could not connect to TX radio ($txAddr)"
|
||||||
|
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnectRadios() {
|
||||||
|
stopTracking()
|
||||||
|
txController?.disconnect()
|
||||||
|
rxController?.disconnect()
|
||||||
|
txController = null
|
||||||
|
rxController = null
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = false,
|
||||||
|
rxConnected = false,
|
||||||
|
isActive = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isActive = true,
|
||||||
|
currentPass = pass,
|
||||||
|
selectedTransponder = transponder,
|
||||||
|
txBaseFrequencyHz = txBaseFreqHz
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trackingJob?.cancel()
|
||||||
|
trackingJob = appScope.launch {
|
||||||
|
// Set modes on both radios at tracking start
|
||||||
|
val tx = txController
|
||||||
|
val rx = rxController
|
||||||
|
val txMode = transponder.uplinkMode
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
if (tx != null && tx.isConnected && txMode != null) {
|
||||||
|
tx.setMode(txMode)
|
||||||
|
Log.i(tag, "TX mode set to $txMode")
|
||||||
|
}
|
||||||
|
if (rx != null && rx.isConnected && rxMode != null) {
|
||||||
|
rx.setMode(rxMode)
|
||||||
|
Log.i(tag, "RX mode set to $rxMode")
|
||||||
|
}
|
||||||
|
// Set CTCSS if FM
|
||||||
|
if (txMode?.uppercase() == "FM") {
|
||||||
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
tx?.setCtcssTone(tone)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
|
|
||||||
|
var lastSetTxFreq = 0.0
|
||||||
|
var lastSetRxFreq = 0.0
|
||||||
|
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
|
||||||
|
var lastReadFreq = 0L
|
||||||
|
var stableCount = 0
|
||||||
|
|
||||||
|
while (isActive) {
|
||||||
|
val currentState = _state.value
|
||||||
|
if (!currentState.isActive) break
|
||||||
|
|
||||||
|
val satPass = currentState.currentPass ?: break
|
||||||
|
val xpdr = currentState.selectedTransponder ?: break
|
||||||
|
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||||
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
|
val timeNow = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
||||||
|
val tx = txController
|
||||||
|
val rx = rxController
|
||||||
|
val hasUplink = txBaseFreq != null
|
||||||
|
val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
||||||
|
val v = pos.distanceRate * 1000.0
|
||||||
|
|
||||||
|
if (tuningRadio.isNotEmpty()) {
|
||||||
|
// --- User is tuning: keep reading, wait for stabilization ---
|
||||||
|
val radio = if (tuningRadio == "tx") tx else rx
|
||||||
|
if (radio != null && radio.isConnected) {
|
||||||
|
val readResult = radio.readFrequencyAndMode()
|
||||||
|
if (readResult != null) {
|
||||||
|
val (freq, _) = readResult
|
||||||
|
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
|
||||||
|
stableCount++
|
||||||
|
} else {
|
||||||
|
stableCount = 0
|
||||||
|
lastReadFreq = freq
|
||||||
|
}
|
||||||
|
// Stable for 2 reads → user stopped turning
|
||||||
|
if (stableCount >= 2) {
|
||||||
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
|
val newBase = (freq.toDouble() * c / (c + v)).toLong()
|
||||||
|
if (newBase > 0) {
|
||||||
|
txBaseFreq = newBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
|
Log.i(tag, "TX tuning done → base=$newBase")
|
||||||
|
}
|
||||||
|
} else if (tuningRadio == "rx") {
|
||||||
|
val rxNominal = (freq.toDouble() * c / (c - v)).toLong()
|
||||||
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
|
txBaseFreq = newTxBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||||
|
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tuningRadio = ""
|
||||||
|
stableCount = 0
|
||||||
|
lastSetTxFreq = 0.0
|
||||||
|
lastSetRxFreq = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// --- Normal tracking: read, detect changes, command ---
|
||||||
|
|
||||||
|
// TX dial feedback
|
||||||
|
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
|
||||||
|
val readResult = tx.readFrequencyAndMode()
|
||||||
|
if (readResult != null) {
|
||||||
|
val (actualTxFreq, _) = readResult
|
||||||
|
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "tx"
|
||||||
|
lastReadFreq = actualTxFreq
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RX dial feedback (only if TX not tuning)
|
||||||
|
if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) {
|
||||||
|
val readResult = rx.readFrequencyAndMode()
|
||||||
|
if (readResult != null) {
|
||||||
|
val (actualRxFreq, _) = readResult
|
||||||
|
if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "rx"
|
||||||
|
lastReadFreq = actualRxFreq
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute Doppler-corrected frequencies
|
||||||
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
|
val rxBaseFreq = if (txBaseFreq != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
|
} else {
|
||||||
|
xpdr.downlinkLow
|
||||||
|
}
|
||||||
|
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
|
// Command radios (only when not tuning)
|
||||||
|
if (tuningRadio.isEmpty()) {
|
||||||
|
if (tx != null && tx.isConnected && txRadioFreq != null) {
|
||||||
|
tx.setFrequency(txRadioFreq)
|
||||||
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
if (rx != null && rx.isConnected && rxRadioFreq != null) {
|
||||||
|
rx.setFrequency(rxRadioFreq)
|
||||||
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = tx?.isConnected ?: false,
|
||||||
|
rxConnected = rx?.isConnected ?: false,
|
||||||
|
txFrequencyHz = txRadioFreq,
|
||||||
|
rxFrequencyHz = rxRadioFreq,
|
||||||
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
|
elevation = Math.toDegrees(pos.elevation),
|
||||||
|
distance = pos.distance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopTracking() {
|
||||||
|
trackingJob?.cancel()
|
||||||
|
trackingJob = null
|
||||||
|
_state.update { it.copy(isActive = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTransponder(transponder: SatRadio) {
|
||||||
|
appScope.launch {
|
||||||
|
val tx = txController
|
||||||
|
val rx = rxController
|
||||||
|
transponder.uplinkMode?.let { tx?.setMode(it) }
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
rxMode?.let { rx?.setMode(it) }
|
||||||
|
|
||||||
|
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||||
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
tx?.setCtcssTone(tone)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val txCenter = when {
|
||||||
|
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
|
||||||
|
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
|
||||||
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
// Show nominal frequencies immediately
|
||||||
|
val rxNominal = if (txCenter != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
|
} else {
|
||||||
|
// Downlink-only transponder (beacon etc.) - use downlink directly
|
||||||
|
transponder.downlinkLow
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedTransponder = transponder,
|
||||||
|
txBaseFrequencyHz = txCenter,
|
||||||
|
txFrequencyHz = txCenter,
|
||||||
|
rxFrequencyHz = rxNominal,
|
||||||
|
txMode = transponder.uplinkMode,
|
||||||
|
rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let { m ->
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTxBaseFrequency(frequencyHz: Long) {
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = frequencyHz) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun adjustTxBaseFrequency(deltaHz: Long) {
|
||||||
|
val current = _state.value.txBaseFrequencyHz ?: return
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = current + deltaHz) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCtcssTone(toneHz: Double?) {
|
||||||
|
_state.update { it.copy(ctcssTone = toneHz) }
|
||||||
|
appScope.launch {
|
||||||
|
val tx = txController
|
||||||
|
if (toneHz != null) {
|
||||||
|
tx?.setCtcssTone(toneHz)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
} else {
|
||||||
|
tx?.setCtcssMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setMode(txMode: String, rxMode: String) {
|
||||||
|
appScope.launch {
|
||||||
|
txController?.setMode(txMode)
|
||||||
|
rxController?.setMode(rxMode)
|
||||||
|
}
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ import android.view.WindowManager
|
|||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
@@ -21,6 +23,8 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
|||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
@@ -46,6 +50,10 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
|
}
|
||||||
|
|
||||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||||
|
|
||||||
@@ -73,6 +81,18 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun provideTxRadioController(): IRadioController {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val address = settingsRepo.radioControlSettings.value.txRadioAddress
|
||||||
|
return Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun provideRxRadioController(): IRadioController {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val address = settingsRepo.radioControlSettings.value.rxRadioAddress
|
||||||
|
return Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
|
|
||||||
override fun provideSensorsRepo(): ISensorsRepo {
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
|||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
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 com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
@@ -364,4 +365,40 @@ class SettingsRepo(
|
|||||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Radio control settings
|
||||||
|
private val keyRadioControlEnabled = "radioControlEnabled"
|
||||||
|
private val keyRadioModel = "radioModel"
|
||||||
|
private val keyTxRadioAddress = "txRadioAddress"
|
||||||
|
private val keyRxRadioAddress = "rxRadioAddress"
|
||||||
|
private val keyTxRadioName = "txRadioName"
|
||||||
|
private val keyRxRadioName = "rxRadioName"
|
||||||
|
private val keyRadioBaudRate = "radioBaudRate"
|
||||||
|
|
||||||
|
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
||||||
|
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) {
|
||||||
|
preferences.edit {
|
||||||
|
putBoolean(keyRadioControlEnabled, settings.enabled)
|
||||||
|
putString(keyRadioModel, settings.radioModel)
|
||||||
|
putString(keyTxRadioAddress, settings.txRadioAddress)
|
||||||
|
putString(keyRxRadioAddress, settings.rxRadioAddress)
|
||||||
|
putString(keyTxRadioName, settings.txRadioName)
|
||||||
|
putString(keyRxRadioName, settings.rxRadioName)
|
||||||
|
putInt(keyRadioBaudRate, settings.baudRate)
|
||||||
|
}
|
||||||
|
_radioControlSettings.value = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
||||||
|
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
||||||
|
radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818",
|
||||||
|
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
||||||
|
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
||||||
|
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
||||||
|
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
||||||
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800)
|
||||||
|
)
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.data.framework.Ft817CatProtocol
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class Ft817CatProtocolTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeFrequencyBcd_145500000() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
||||||
|
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeFrequencyBcd_435100000() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
||||||
|
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeFrequencyBcd_7074000() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
||||||
|
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decodeFrequencyBcd_roundTrips() {
|
||||||
|
val testFreqs = listOf(145500000L, 435100000L, 7074000L, 14200000L, 28500000L)
|
||||||
|
for (freq in testFreqs) {
|
||||||
|
val rounded = (freq / 10) * 10
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(freq)
|
||||||
|
assertEquals(rounded, Ft817CatProtocol.decodeFrequencyBcd(bcd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetFreqCommand_correctFormat() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
||||||
|
assertEquals(5, cmd.size)
|
||||||
|
assertEquals(0x01.toByte(), cmd[4])
|
||||||
|
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetModeCommand_usb() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
||||||
|
assertNotNull(cmd)
|
||||||
|
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetModeCommand_fm() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
||||||
|
assertNotNull(cmd)
|
||||||
|
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetModeCommand_unknownReturnsNull() {
|
||||||
|
assertNull(Ft817CatProtocol.buildSetModeCommand("INVALID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeCtcssTone_67_0() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
||||||
|
assertContentEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeCtcssTone_74_4() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
||||||
|
assertContentEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeCtcssTone_141_3() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
||||||
|
assertContentEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetCtcssToneCommand_correctFormat() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
||||||
|
assertEquals(5, cmd.size)
|
||||||
|
assertEquals(0x0B.toByte(), cmd[4])
|
||||||
|
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildCtcssModeCommand_enable() {
|
||||||
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
||||||
|
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildCtcssModeCommand_disable() {
|
||||||
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(false)
|
||||||
|
assertEquals(0x8A.toByte(), cmd[0])
|
||||||
|
assertEquals(0x0A.toByte(), cmd[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_validResponse() {
|
||||||
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
||||||
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(145500000L, result.first)
|
||||||
|
assertEquals("USB", result.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_fmMode() {
|
||||||
|
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
||||||
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(146000000L, result.first)
|
||||||
|
assertEquals("FM", result.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_tooShort() {
|
||||||
|
assertNull(Ft817CatProtocol.parseReadResponse(byteArrayOf(0x14, 0x55, 0x00)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_unknownMode() {
|
||||||
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x0F)
|
||||||
|
assertNull(Ft817CatProtocol.parseReadResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildPttCommands() {
|
||||||
|
val on = Ft817CatProtocol.buildPttOnCommand()
|
||||||
|
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||||
|
|
||||||
|
val off = Ft817CatProtocol.buildPttOffCommand()
|
||||||
|
assertEquals(0x88.toByte(), off[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildReadCommand() {
|
||||||
|
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
||||||
|
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,3 +63,20 @@ data class DataSourcesSettings(
|
|||||||
val tleUrl: String,
|
val tleUrl: String,
|
||||||
val transceiversUrl: 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -15,6 +15,9 @@ interface IMainContainer {
|
|||||||
fun provideBluetoothReporter(): IReporter
|
fun provideBluetoothReporter(): IReporter
|
||||||
fun provideNetworkReporter(): IReporter
|
fun provideNetworkReporter(): IReporter
|
||||||
fun provideSensorsRepo(): ISensorsRepo
|
fun provideSensorsRepo(): ISensorsRepo
|
||||||
|
fun provideTxRadioController(): IRadioController
|
||||||
|
fun provideRxRadioController(): IRadioController
|
||||||
|
val radioTrackingService: IRadioTrackingService
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
|
|||||||
+41
@@ -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
|
||||||
|
}
|
||||||
+55
@@ -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)
|
||||||
|
}
|
||||||
+6
@@ -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.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
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 com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -71,4 +72,9 @@ interface ISettingsRepo {
|
|||||||
val dataSourcesSettings: StateFlow<DataSourcesSettings>
|
val dataSourcesSettings: StateFlow<DataSourcesSettings>
|
||||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Radio control settings
|
||||||
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
+73
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,5 @@ sealed class Screen(val route: String, val iconResId: Int, val titleResId: Int)
|
|||||||
data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar)
|
data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar)
|
||||||
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
|
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
|
||||||
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
|
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
|
||||||
|
data object RadioControl : Screen("radiocontrol", R.drawable.ic_radios, R.string.nav_radiocontrol)
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,17 @@
|
|||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
|
<!-- Radio Control -->
|
||||||
|
<string name="nav_radiocontrol">Radio Control</string>
|
||||||
|
<string name="rc_settings_title">CAT Radio Control</string>
|
||||||
|
<string name="rc_radio_model">Radio Model</string>
|
||||||
|
<string name="rc_tx_device_hint">TX Radio BT Address</string>
|
||||||
|
<string name="rc_rx_device_hint">RX Radio BT Address</string>
|
||||||
|
<string name="rc_tx_name_hint">TX Radio Name</string>
|
||||||
|
<string name="rc_rx_name_hint">RX Radio Name</string>
|
||||||
|
<string name="rc_enable_switch">Enable CAT Control</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Network data output</string>
|
<string name="prefs_net_title">Network data output</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
|||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
|
||||||
fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
|
fun NavGraphBuilder.radarDestination(
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
navigateToRadioControl: (Int, Long) -> Unit = { _, _ -> }
|
||||||
|
) {
|
||||||
val radarRoute = "${Screen.Radar.route}?catNum={catNum}&aosTime={aosTime}"
|
val radarRoute = "${Screen.Radar.route}?catNum={catNum}&aosTime={aosTime}"
|
||||||
val radarArgs = listOf(
|
val radarArgs = listOf(
|
||||||
navArgument("catNum") { defaultValue = 0 },
|
navArgument("catNum") { defaultValue = 0 },
|
||||||
@@ -86,17 +89,26 @@ fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
|
|||||||
composable(radarRoute, radarArgs) {
|
composable(radarRoute, radarArgs) {
|
||||||
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
|
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
RadarScreen(uiState, navigateUp)
|
RadarScreen(uiState, navigateUp, navigateToRadioControl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
private fun RadarScreen(
|
||||||
|
uiState: RadarState,
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
navigateToRadioControl: (Int, Long) -> Unit
|
||||||
|
) {
|
||||||
val addToCalendar: () -> Unit = {
|
val addToCalendar: () -> Unit = {
|
||||||
uiState.currentPass?.let { pass ->
|
uiState.currentPass?.let { pass ->
|
||||||
uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
|
uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val openRadioControl: () -> Unit = {
|
||||||
|
uiState.currentPass?.let { pass ->
|
||||||
|
navigateToRadioControl(pass.catNum, pass.aosTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp()
|
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp()
|
||||||
Column(
|
Column(
|
||||||
@@ -110,6 +122,7 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
|||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
||||||
|
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||||
}
|
}
|
||||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||||
@@ -118,6 +131,7 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
|||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
||||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||||
|
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rtbishop.look4sat.feature.radiocontrol"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
+373
@@ -0,0 +1,373 @@
|
|||||||
|
/*
|
||||||
|
* 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.feature.radiocontrol
|
||||||
|
|
||||||
|
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.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.keepScreenOn
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavGraphBuilder
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
|
||||||
|
fun NavGraphBuilder.radioControlDestination(navigateUp: () -> Unit) {
|
||||||
|
val route = "${Screen.RadioControl.route}?catNum={catNum}&aosTime={aosTime}"
|
||||||
|
val args = listOf(
|
||||||
|
navArgument("catNum") { defaultValue = 0 },
|
||||||
|
navArgument("aosTime") { defaultValue = 0L }
|
||||||
|
)
|
||||||
|
composable(route, args) {
|
||||||
|
val viewModel = viewModel(RadioControlViewModel::class.java, factory = RadioControlViewModel.Factory)
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
RadioControlScreen(uiState, navigateUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RadioControlScreen(uiState: RadioControlState, navigateUp: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.layoutPadding()
|
||||||
|
.keepScreenOn(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
TopBar {
|
||||||
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
||||||
|
uiState.currentPass?.let { pass ->
|
||||||
|
Text(
|
||||||
|
text = pass.name,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RadioPanel(panel = uiState.txPanel)
|
||||||
|
RadioPanel(panel = uiState.rxPanel)
|
||||||
|
|
||||||
|
PositionRow(
|
||||||
|
azimuth = uiState.azimuth,
|
||||||
|
elevation = uiState.elevation,
|
||||||
|
distance = uiState.distance
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
item { TransponderSelector(uiState) }
|
||||||
|
|
||||||
|
val selectedTransponder = uiState.transponders.find {
|
||||||
|
it.uuid == uiState.selectedTransponderUuid
|
||||||
|
}
|
||||||
|
if (selectedTransponder?.uplinkMode?.uppercase() == "FM") {
|
||||||
|
item { CtcssSelector(uiState) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.txBaseFrequencyHz != null) {
|
||||||
|
item { FrequencyTuner(uiState) }
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.errorMessage?.let { msg ->
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = msg,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlButtons(uiState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RadioPanel(panel: RadioPanelState) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (panel.isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(text = panel.label, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = panel.frequencyDisplay,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = panel.mode ?: "--",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PositionRow(azimuth: String, elevation: String, distance: String) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(text = "Az", fontSize = 12.sp)
|
||||||
|
Text(text = "$azimuth°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(text = "El", fontSize = 12.sp)
|
||||||
|
Text(text = "$elevation°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(text = "Dist", fontSize = 12.sp)
|
||||||
|
Text(text = "$distance km", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TransponderSelector(uiState: RadioControlState) {
|
||||||
|
if (uiState.transponders.isEmpty()) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "No transponders with uplink+downlink available",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
uiState.transponders.forEach { radio ->
|
||||||
|
TransponderItem(
|
||||||
|
radio = radio,
|
||||||
|
isSelected = radio.uuid == uiState.selectedTransponderUuid,
|
||||||
|
onSelect = { uiState.sendAction(RadioControlAction.SelectTransponder(radio.uuid)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TransponderItem(radio: SatRadio, isSelected: Boolean, onSelect: () -> Unit) {
|
||||||
|
val invLabel = if (radio.isInverted) " INV" else ""
|
||||||
|
val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
|
||||||
|
Surface(
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surface,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelect() }
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(text = radio.info, modifier = Modifier.weight(1f))
|
||||||
|
Text(text = modeLabel, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun CtcssSelector(uiState: RadioControlState) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
|
||||||
|
Text(text = "CTCSS Tone", color = MaterialTheme.colorScheme.primary)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = uiState.ctcssTone == null,
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.SetCtcssTone(null)) },
|
||||||
|
label = { Text("Off") }
|
||||||
|
)
|
||||||
|
RadioControlViewModel.CTCSS_TONES.forEach { tone ->
|
||||||
|
FilterChip(
|
||||||
|
selected = uiState.ctcssTone == tone,
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.SetCtcssTone(tone)) },
|
||||||
|
label = { Text(String.format("%.1f", tone)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FrequencyTuner(uiState: RadioControlState) {
|
||||||
|
val freq = uiState.txBaseFrequencyHz ?: return
|
||||||
|
val transponder = uiState.transponders.find { it.uuid == uiState.selectedTransponderUuid }
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Text(text = "TX Base Frequency", color = MaterialTheme.colorScheme.primary)
|
||||||
|
if (transponder != null) {
|
||||||
|
val upLow = transponder.uplinkLow
|
||||||
|
val upHigh = transponder.uplinkHigh
|
||||||
|
val dnLow = transponder.downlinkLow
|
||||||
|
val dnHigh = transponder.downlinkHigh
|
||||||
|
if (upLow != null && upHigh != null && upLow != upHigh) {
|
||||||
|
Text(
|
||||||
|
text = "UP: ${RadioControlViewModel.formatFrequency(upLow)} - ${RadioControlViewModel.formatFrequency(upHigh)}",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (dnLow != null && dnHigh != null && dnLow != dnHigh) {
|
||||||
|
Text(
|
||||||
|
text = "DN: ${RadioControlViewModel.formatFrequency(dnLow)} - ${RadioControlViewModel.formatFrequency(dnHigh)}",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "${RadioControlViewModel.formatFrequency(freq)} MHz",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
CardButton(
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(-10_000)) },
|
||||||
|
text = "-10k",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
CardButton(
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(-1_000)) },
|
||||||
|
text = "-1k",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
CardButton(
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(-100)) },
|
||||||
|
text = "-100",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
CardButton(
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(100)) },
|
||||||
|
text = "+100",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
CardButton(
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(1_000)) },
|
||||||
|
text = "+1k",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
CardButton(
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(10_000)) },
|
||||||
|
text = "+10k",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ControlButtons(uiState: RadioControlState) {
|
||||||
|
val isConnected = uiState.txPanel.isConnected || uiState.rxPanel.isConnected
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
if (!isConnected) {
|
||||||
|
CardButton(
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.ConnectRadios) },
|
||||||
|
text = "Connect",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CardButton(
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.DisconnectRadios) },
|
||||||
|
text = "Disconnect",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CardButton(
|
||||||
|
onClick = { uiState.sendAction(RadioControlAction.ToggleTracking) },
|
||||||
|
text = if (uiState.isTracking) "Stop Tracking" else "Start Tracking",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* 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.feature.radiocontrol
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
|
||||||
|
data class RadioPanelState(
|
||||||
|
val label: String,
|
||||||
|
val isConnected: Boolean,
|
||||||
|
val frequencyHz: Long?,
|
||||||
|
val frequencyDisplay: String,
|
||||||
|
val mode: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RadioControlState(
|
||||||
|
val currentPass: OrbitalPass?,
|
||||||
|
val currentTime: String,
|
||||||
|
val isCurrentTimeAos: Boolean,
|
||||||
|
val azimuth: String,
|
||||||
|
val elevation: String,
|
||||||
|
val distance: String,
|
||||||
|
val txPanel: RadioPanelState,
|
||||||
|
val rxPanel: RadioPanelState,
|
||||||
|
val transponders: List<SatRadio>,
|
||||||
|
val selectedTransponderUuid: String?,
|
||||||
|
val txBaseFrequencyHz: Long?,
|
||||||
|
val ctcssTone: Double?,
|
||||||
|
val isTracking: Boolean,
|
||||||
|
val errorMessage: String?,
|
||||||
|
val sendAction: (RadioControlAction) -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class RadioControlAction {
|
||||||
|
data class SelectTransponder(val uuid: String) : RadioControlAction()
|
||||||
|
data class SetTxFrequency(val frequencyHz: Long) : RadioControlAction()
|
||||||
|
data class AdjustTxFrequency(val deltaHz: Long) : RadioControlAction()
|
||||||
|
data class SetCtcssTone(val toneHz: Double?) : RadioControlAction()
|
||||||
|
data object ToggleTracking : RadioControlAction()
|
||||||
|
data object ConnectRadios : RadioControlAction()
|
||||||
|
data object DisconnectRadios : RadioControlAction()
|
||||||
|
}
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* 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.feature.radiocontrol
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.createSavedStateHandle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class RadioControlViewModel(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val trackingService: IRadioTrackingService,
|
||||||
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private var currentPass: OrbitalPass? = null
|
||||||
|
private var transponders: List<SatRadio> = emptyList()
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(
|
||||||
|
RadioControlState(
|
||||||
|
currentPass = null,
|
||||||
|
currentTime = "00:00:00",
|
||||||
|
isCurrentTimeAos = true,
|
||||||
|
azimuth = "0.0",
|
||||||
|
elevation = "0.0",
|
||||||
|
distance = "0.0",
|
||||||
|
txPanel = RadioPanelState("TX (Uplink)", false, null, "---", null),
|
||||||
|
rxPanel = RadioPanelState("RX (Downlink)", false, null, "---", null),
|
||||||
|
transponders = emptyList(),
|
||||||
|
selectedTransponderUuid = null,
|
||||||
|
txBaseFrequencyHz = null,
|
||||||
|
ctcssTone = null,
|
||||||
|
isTracking = false,
|
||||||
|
errorMessage = null,
|
||||||
|
sendAction = ::handleAction
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val uiState: StateFlow<RadioControlState> = _uiState
|
||||||
|
|
||||||
|
init {
|
||||||
|
val catNum = savedStateHandle.get<Int>("catNum") ?: 0
|
||||||
|
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val passes = satelliteRepo.passes.value
|
||||||
|
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||||
|
?: passes.firstOrNull()
|
||||||
|
currentPass = pass
|
||||||
|
pass?.let { satPass ->
|
||||||
|
val allRadios = satelliteRepo.getRadiosWithId(satPass.catNum)
|
||||||
|
transponders = allRadios.filter { it.downlinkLow != null }
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(currentPass = satPass, transponders = transponders)
|
||||||
|
}
|
||||||
|
// If service is already tracking this pass, sync the selected transponder
|
||||||
|
val svcState = trackingService.state.value
|
||||||
|
if (svcState.isActive && svcState.currentPass?.catNum == satPass.catNum) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe service state and map to UI state
|
||||||
|
viewModelScope.launch {
|
||||||
|
trackingService.state.collect { svc ->
|
||||||
|
val timeNow = System.currentTimeMillis()
|
||||||
|
val pass = currentPass
|
||||||
|
val timeStr = when {
|
||||||
|
pass == null -> "00:00:00"
|
||||||
|
pass.isDeepSpace -> 0L.toTimerString()
|
||||||
|
pass.aosTime > timeNow -> pass.aosTime.minus(timeNow).toTimerString()
|
||||||
|
else -> pass.losTime.minus(timeNow).toTimerString()
|
||||||
|
}
|
||||||
|
val isAos = pass != null && !pass.isDeepSpace && pass.aosTime > timeNow
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
currentTime = timeStr,
|
||||||
|
isCurrentTimeAos = isAos,
|
||||||
|
azimuth = String.format(Locale.ENGLISH, "%.1f", svc.azimuth),
|
||||||
|
elevation = String.format(Locale.ENGLISH, "%.1f", svc.elevation),
|
||||||
|
distance = String.format(Locale.ENGLISH, "%.0f", svc.distance),
|
||||||
|
txPanel = RadioPanelState(
|
||||||
|
label = "TX (Uplink)",
|
||||||
|
isConnected = svc.txConnected,
|
||||||
|
frequencyHz = svc.txFrequencyHz,
|
||||||
|
frequencyDisplay = svc.txFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||||
|
mode = svc.txMode
|
||||||
|
),
|
||||||
|
rxPanel = RadioPanelState(
|
||||||
|
label = "RX (Downlink)",
|
||||||
|
isConnected = svc.rxConnected,
|
||||||
|
frequencyHz = svc.rxFrequencyHz,
|
||||||
|
frequencyDisplay = svc.rxFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||||
|
mode = svc.rxMode
|
||||||
|
),
|
||||||
|
txBaseFrequencyHz = svc.txBaseFrequencyHz,
|
||||||
|
ctcssTone = svc.ctcssTone,
|
||||||
|
isTracking = svc.isActive,
|
||||||
|
selectedTransponderUuid = svc.selectedTransponder?.uuid,
|
||||||
|
errorMessage = svc.errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleAction(action: RadioControlAction) {
|
||||||
|
when (action) {
|
||||||
|
is RadioControlAction.SelectTransponder -> {
|
||||||
|
val transponder = transponders.find { it.uuid == action.uuid } ?: return
|
||||||
|
trackingService.setTransponder(transponder)
|
||||||
|
}
|
||||||
|
is RadioControlAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
||||||
|
is RadioControlAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
||||||
|
is RadioControlAction.SetCtcssTone -> trackingService.setCtcssTone(action.toneHz)
|
||||||
|
RadioControlAction.ToggleTracking -> {
|
||||||
|
val svc = trackingService.state.value
|
||||||
|
if (svc.isActive) {
|
||||||
|
trackingService.stopTracking()
|
||||||
|
} else {
|
||||||
|
val pass = currentPass ?: return
|
||||||
|
val transponder = svc.selectedTransponder ?: return
|
||||||
|
trackingService.startTracking(pass, transponder, svc.txBaseFrequencyHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RadioControlAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
|
||||||
|
RadioControlAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val CTCSS_TONES = listOf(67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
||||||
|
94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5,
|
||||||
|
141.3, 146.2, 151.4, 156.7, 162.2, 167.9, 173.8, 179.9, 186.2, 192.8, 203.5, 210.7,
|
||||||
|
218.1, 225.7, 233.6, 241.8, 250.3)
|
||||||
|
|
||||||
|
fun formatFrequency(frequencyHz: Long): String {
|
||||||
|
if (frequencyHz <= 0) return "---"
|
||||||
|
val mhz = frequencyHz / 1_000_000
|
||||||
|
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||||
|
val hz = frequencyHz % 1_000
|
||||||
|
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||||
|
}
|
||||||
|
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
|
||||||
|
initializer {
|
||||||
|
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
|
||||||
|
RadioControlViewModel(
|
||||||
|
createSavedStateHandle(),
|
||||||
|
container.radioTrackingService,
|
||||||
|
container.satelliteRepo,
|
||||||
|
container.settingsRepo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.settings
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -29,13 +30,16 @@ import androidx.compose.material3.Switch
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
@@ -477,3 +481,161 @@ fun BluetoothOutputDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RadioControlDialog(
|
||||||
|
initialSettings: RadioControlSettings,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (RadioControlSettings) -> Unit
|
||||||
|
) {
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val padding = LocalSpacing.current.large
|
||||||
|
val baudRates = listOf(4800, 9600, 38400)
|
||||||
|
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
||||||
|
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
||||||
|
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
||||||
|
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
||||||
|
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
||||||
|
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
|
||||||
|
val baudRate = rememberSaveable { mutableStateOf(initialSettings.baudRate) }
|
||||||
|
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
||||||
|
|
||||||
|
val pairedDevices = remember {
|
||||||
|
try {
|
||||||
|
val manager = context.getSystemService(android.content.Context.BLUETOOTH_SERVICE) as android.bluetooth.BluetoothManager
|
||||||
|
manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList()
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onAccept = {
|
||||||
|
onSave(
|
||||||
|
RadioControlSettings(
|
||||||
|
enabled = enabled.value,
|
||||||
|
radioModel = radioModel.value,
|
||||||
|
txRadioAddress = txAddress.value,
|
||||||
|
rxRadioAddress = rxAddress.value,
|
||||||
|
txRadioName = txName.value,
|
||||||
|
rxRadioName = rxName.value,
|
||||||
|
baudRate = baudRate.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
SharedDialog(
|
||||||
|
title = stringResource(R.string.rc_settings_title),
|
||||||
|
onCancel = onDismiss,
|
||||||
|
onAccept = onAccept
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = padding)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.rc_enable_switch))
|
||||||
|
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// Radio model selection
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.rc_radio_model),
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
||||||
|
androidx.compose.material3.FilterChip(
|
||||||
|
selected = radioModel.value == model,
|
||||||
|
onClick = { radioModel.value = model },
|
||||||
|
label = { Text(model, fontSize = 12.sp) },
|
||||||
|
enabled = enabled.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// TX Radio selection
|
||||||
|
Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
||||||
|
if (txAddress.value.isNotBlank()) {
|
||||||
|
Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
CardButton(
|
||||||
|
onClick = { selectingFor.value = "tx" },
|
||||||
|
text = "Select TX Device",
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// RX Radio selection
|
||||||
|
Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
||||||
|
if (rxAddress.value.isNotBlank()) {
|
||||||
|
Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
CardButton(
|
||||||
|
onClick = { selectingFor.value = "rx" },
|
||||||
|
text = "Select RX Device",
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Paired devices list (shown when selecting)
|
||||||
|
if (selectingFor.value.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Paired Bluetooth Devices:",
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
if (pairedDevices.isEmpty()) {
|
||||||
|
Text("No paired devices found. Pair your BT adapter in Android Bluetooth settings first.")
|
||||||
|
} else {
|
||||||
|
pairedDevices.forEach { (name, address) ->
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
if (selectingFor.value == "tx") {
|
||||||
|
txAddress.value = address
|
||||||
|
txName.value = name
|
||||||
|
} else {
|
||||||
|
rxAddress.value = address
|
||||||
|
rxName.value = name
|
||||||
|
}
|
||||||
|
selectingFor.value = ""
|
||||||
|
}
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(name, modifier = Modifier.weight(1f))
|
||||||
|
Text(address, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Baud Rate:")
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
baudRates.forEach { rate ->
|
||||||
|
CardButton(
|
||||||
|
onClick = { baudRate.value = rate },
|
||||||
|
text = if (rate == baudRate.value) "[$rate]" else rate.toString(),
|
||||||
|
modifier = Modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-3
@@ -167,6 +167,20 @@ private fun SettingsScreen(uiState: SettingsState) {
|
|||||||
// RC settings
|
// RC settings
|
||||||
val rcSettings = uiState.rcSettings
|
val rcSettings = uiState.rcSettings
|
||||||
|
|
||||||
|
// Radio control (FT-817) settings
|
||||||
|
val radioControlDialogState = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val showRadioControlDialog = { radioControlDialogState.value = true }
|
||||||
|
val dismissRadioControlDialog = { radioControlDialogState.value = false }
|
||||||
|
if (radioControlDialogState.value) {
|
||||||
|
RadioControlDialog(
|
||||||
|
initialSettings = uiState.radioControlSettings,
|
||||||
|
onDismiss = dismissRadioControlDialog,
|
||||||
|
onSave = { settings ->
|
||||||
|
uiState.sendRadioControlAction(RadioControlSettingsAction.Update(settings))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Network data output
|
// Network data output
|
||||||
val networkDialogState = rememberSaveable { mutableStateOf(false) }
|
val networkDialogState = rememberSaveable { mutableStateOf(false) }
|
||||||
val showNetworkDialog = { networkDialogState.value = true }
|
val showNetworkDialog = { networkDialogState.value = true }
|
||||||
@@ -291,7 +305,9 @@ private fun SettingsScreen(uiState: SettingsState) {
|
|||||||
LocationCard(posSettings, setGpsPos, showPosDialog, showLocDialog, dismissPos, uiState.sendSystemAction)
|
LocationCard(posSettings, setGpsPos, showPosDialog, showLocDialog, dismissPos, uiState.sendSystemAction)
|
||||||
}
|
}
|
||||||
item { DataCard(dataSettings, updateFromWeb, clearAllData, showDataSourcesDialog) }
|
item { DataCard(dataSettings, updateFromWeb, clearAllData, showDataSourcesDialog) }
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) { OutputCard({ showNetworkDialog() }, { showBluetoothDialog() }) }
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
OutputCard({ showNetworkDialog() }, { showBluetoothDialog() }, { showRadioControlDialog() })
|
||||||
|
}
|
||||||
item { OtherCard(otherSettings, uiState.sendAction) }
|
item { OtherCard(otherSettings, uiState.sendAction) }
|
||||||
item { CardCredits() }
|
item { CardCredits() }
|
||||||
}
|
}
|
||||||
@@ -423,12 +439,13 @@ private fun DataCard(
|
|||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun OutputCardPreview() = MainTheme { OutputCard({}, {}) }
|
private fun OutputCardPreview() = MainTheme { OutputCard({}, {}, {}) }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OutputCard(
|
private fun OutputCard(
|
||||||
onNetworkClick: () -> Unit,
|
onNetworkClick: () -> Unit,
|
||||||
onBluetoothClick: () -> Unit
|
onBluetoothClick: () -> Unit,
|
||||||
|
onRadioControlClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
@@ -448,6 +465,11 @@ private fun OutputCard(
|
|||||||
text = stringResource(id = R.string.prefs_bt_output),
|
text = stringResource(id = R.string.prefs_bt_output),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
CardButton(
|
||||||
|
onClick = onRadioControlClick,
|
||||||
|
text = stringResource(id = R.string.prefs_cat_output),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ package com.rtbishop.look4sat.feature.settings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
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 com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
|
||||||
data class PositionSettings(
|
data class PositionSettings(
|
||||||
@@ -36,9 +37,11 @@ data class SettingsState(
|
|||||||
val dataSettings: DataSettings,
|
val dataSettings: DataSettings,
|
||||||
val otherSettings: OtherSettings,
|
val otherSettings: OtherSettings,
|
||||||
val rcSettings: RCSettings,
|
val rcSettings: RCSettings,
|
||||||
|
val radioControlSettings: RadioControlSettings,
|
||||||
val dataSourcesSettings: DataSourcesSettings,
|
val dataSourcesSettings: DataSourcesSettings,
|
||||||
val sendAction: (SettingsAction) -> Unit,
|
val sendAction: (SettingsAction) -> Unit,
|
||||||
val sendRCAction: (RCAction) -> Unit,
|
val sendRCAction: (RCAction) -> Unit,
|
||||||
|
val sendRadioControlAction: (RadioControlSettingsAction) -> Unit,
|
||||||
val sendSystemAction: (SystemAction) -> Unit,
|
val sendSystemAction: (SystemAction) -> Unit,
|
||||||
val sendDataSourcesAction: (DataSourcesAction) -> Unit
|
val sendDataSourcesAction: (DataSourcesAction) -> Unit
|
||||||
)
|
)
|
||||||
@@ -70,3 +73,7 @@ sealed class RCAction {
|
|||||||
sealed class DataSourcesAction {
|
sealed class DataSourcesAction {
|
||||||
data class Update(val settings: DataSourcesSettings) : DataSourcesAction()
|
data class Update(val settings: DataSourcesSettings) : DataSourcesAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed class RadioControlSettingsAction {
|
||||||
|
data class Update(val settings: RadioControlSettings) : RadioControlSettingsAction()
|
||||||
|
}
|
||||||
+13
@@ -47,9 +47,11 @@ class SettingsViewModel(
|
|||||||
dataSettings = defaultDataSettings,
|
dataSettings = defaultDataSettings,
|
||||||
otherSettings = settingsRepo.otherSettings.value,
|
otherSettings = settingsRepo.otherSettings.value,
|
||||||
rcSettings = settingsRepo.rcSettings.value,
|
rcSettings = settingsRepo.rcSettings.value,
|
||||||
|
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||||
sendAction = ::handleAction,
|
sendAction = ::handleAction,
|
||||||
sendRCAction = ::handleAction,
|
sendRCAction = ::handleAction,
|
||||||
|
sendRadioControlAction = ::handleAction,
|
||||||
sendSystemAction = ::handleAction,
|
sendSystemAction = ::handleAction,
|
||||||
sendDataSourcesAction = ::handleAction
|
sendDataSourcesAction = ::handleAction
|
||||||
)
|
)
|
||||||
@@ -94,6 +96,11 @@ class SettingsViewModel(
|
|||||||
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.radioControlSettings.collect { settings ->
|
||||||
|
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -138,6 +145,12 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleAction(action: RadioControlSettingsAction) {
|
||||||
|
when (action) {
|
||||||
|
is RadioControlSettingsAction.Update -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// region Position helpers — consolidated from 3 near-identical functions
|
// region Position helpers — consolidated from 3 near-identical functions
|
||||||
|
|
||||||
private fun setGpsPosition() {
|
private fun setGpsPosition() {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ include(
|
|||||||
":feature:map",
|
":feature:map",
|
||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
|
":feature:radiocontrol",
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
":feature:settings"
|
":feature:settings"
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user