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
|
||||
|
||||
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.fadeIn
|
||||
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.MaterialTheme
|
||||
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.NavigationSuiteType
|
||||
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.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.compose.NavHost
|
||||
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.hasEnoughHeight
|
||||
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.passes.passesDestination
|
||||
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.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 currentDestination = navController.currentBackStackEntryAsState().value?.destination?.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(
|
||||
navigationSuiteItems = {
|
||||
items.forEach {
|
||||
@@ -71,20 +108,78 @@ fun MainScreen(navController: NavHostController = rememberNavController()) {
|
||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
enterTransition = { fadeIn(animationSpec = tween(350)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(350)) }
|
||||
exitTransition = { fadeOut(animationSpec = tween(350)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
satellitesDestination { navController.navigateUp() }
|
||||
passesDestination { catNum: Int, aosTime: Long ->
|
||||
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
|
||||
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()
|
||||
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:passes"))
|
||||
implementation(project(":feature:radar"))
|
||||
implementation(project(":feature:radiocontrol"))
|
||||
implementation(project(":feature:satellites"))
|
||||
implementation(project(":feature:settings"))
|
||||
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 com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
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.RadioTrackingService
|
||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||
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.domain.repository.IDatabaseRepo
|
||||
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.ISatelliteRepo
|
||||
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 satelliteRepo = provideSatelliteRepo()
|
||||
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)
|
||||
|
||||
@@ -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 {
|
||||
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
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.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
@@ -364,4 +365,40 @@ class SettingsRepo(
|
||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
||||
)
|
||||
//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 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 provideNetworkReporter(): IReporter
|
||||
fun provideSensorsRepo(): ISensorsRepo
|
||||
fun provideTxRadioController(): IRadioController
|
||||
fun provideRxRadioController(): IRadioController
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
}
|
||||
|
||||
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.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -71,4 +72,9 @@ interface ISettingsRepo {
|
||||
val dataSourcesSettings: StateFlow<DataSourcesSettings>
|
||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
//endregion
|
||||
}
|
||||
+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 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 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_net_output">Network</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_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.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 radarArgs = listOf(
|
||||
navArgument("catNum") { defaultValue = 0 },
|
||||
@@ -86,17 +89,26 @@ fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
|
||||
composable(radarRoute, radarArgs) {
|
||||
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
RadarScreen(uiState, navigateUp)
|
||||
RadarScreen(uiState, navigateUp, navigateToRadioControl)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
||||
private fun RadarScreen(
|
||||
uiState: RadarState,
|
||||
navigateUp: () -> Unit,
|
||||
navigateToRadioControl: (Int, Long) -> Unit
|
||||
) {
|
||||
val addToCalendar: () -> Unit = {
|
||||
uiState.currentPass?.let { pass ->
|
||||
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()
|
||||
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp()
|
||||
Column(
|
||||
@@ -110,6 +122,7 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
||||
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
}
|
||||
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)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -29,13 +30,16 @@ import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
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
|
||||
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
|
||||
val networkDialogState = rememberSaveable { mutableStateOf(false) }
|
||||
val showNetworkDialog = { networkDialogState.value = true }
|
||||
@@ -291,7 +305,9 @@ private fun SettingsScreen(uiState: SettingsState) {
|
||||
LocationCard(posSettings, setGpsPos, showPosDialog, showLocDialog, dismissPos, uiState.sendSystemAction)
|
||||
}
|
||||
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 { CardCredits() }
|
||||
}
|
||||
@@ -423,12 +439,13 @@ private fun DataCard(
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun OutputCardPreview() = MainTheme { OutputCard({}, {}) }
|
||||
private fun OutputCardPreview() = MainTheme { OutputCard({}, {}, {}) }
|
||||
|
||||
@Composable
|
||||
private fun OutputCard(
|
||||
onNetworkClick: () -> Unit,
|
||||
onBluetoothClick: () -> Unit
|
||||
onBluetoothClick: () -> Unit,
|
||||
onRadioControlClick: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
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),
|
||||
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.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
|
||||
data class PositionSettings(
|
||||
@@ -36,9 +37,11 @@ data class SettingsState(
|
||||
val dataSettings: DataSettings,
|
||||
val otherSettings: OtherSettings,
|
||||
val rcSettings: RCSettings,
|
||||
val radioControlSettings: RadioControlSettings,
|
||||
val dataSourcesSettings: DataSourcesSettings,
|
||||
val sendAction: (SettingsAction) -> Unit,
|
||||
val sendRCAction: (RCAction) -> Unit,
|
||||
val sendRadioControlAction: (RadioControlSettingsAction) -> Unit,
|
||||
val sendSystemAction: (SystemAction) -> Unit,
|
||||
val sendDataSourcesAction: (DataSourcesAction) -> Unit
|
||||
)
|
||||
@@ -70,3 +73,7 @@ sealed class RCAction {
|
||||
sealed class 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,
|
||||
otherSettings = settingsRepo.otherSettings.value,
|
||||
rcSettings = settingsRepo.rcSettings.value,
|
||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
sendAction = ::handleAction,
|
||||
sendRCAction = ::handleAction,
|
||||
sendRadioControlAction = ::handleAction,
|
||||
sendSystemAction = ::handleAction,
|
||||
sendDataSourcesAction = ::handleAction
|
||||
)
|
||||
@@ -94,6 +96,11 @@ class SettingsViewModel(
|
||||
_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
|
||||
|
||||
private fun setGpsPosition() {
|
||||
|
||||
@@ -24,6 +24,7 @@ include(
|
||||
":feature:map",
|
||||
":feature:passes",
|
||||
":feature:radar",
|
||||
":feature:radiocontrol",
|
||||
":feature:satellites",
|
||||
":feature:settings"
|
||||
)
|
||||
Reference in new issue
Block a user