diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index 07bfecdd..31fa967a 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -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 } } diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt index a75c2018..29c00ad9 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt @@ -34,6 +34,7 @@ internal class ApplicationPlugin : Plugin { 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) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ft817CatProtocol.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ft817CatProtocol.kt new file mode 100644 index 00000000..5597841e --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ft817CatProtocol.kt @@ -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 . + */ +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 = 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 = 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? { + 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) + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ft817Controller.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ft817Controller.kt new file mode 100644 index 00000000..717c7315 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ft817Controller.kt @@ -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 . + */ +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? = 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 + } + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt new file mode 100644 index 00000000..bdb41cbf --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt @@ -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 . + */ +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 = _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) } + } + +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 235e3396..b9f81bd9 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -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) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 494259e2..9a74e454 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -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 + + 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 } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/Ft817CatProtocolTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/Ft817CatProtocolTest.kt new file mode 100644 index 00000000..dd263ce1 --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/Ft817CatProtocolTest.kt @@ -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) + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index ed6fcb1f..2cb86239 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -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" + ) + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index d811f61d..c491ab86 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -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 { diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt new file mode 100644 index 00000000..d0bb4ef6 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt @@ -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 . + */ +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? + + suspend fun pttOn(): Boolean + + suspend fun pttOff(): Boolean +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioTrackingService.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioTrackingService.kt new file mode 100644 index 00000000..0e17d490 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioTrackingService.kt @@ -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 . + */ +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 + + 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) +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index 8233e1e5..23b726e2 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -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 fun updateDataSourcesSettings(settings: DataSourcesSettings) //endregion + + //region # Radio control settings + val radioControlSettings: StateFlow + fun updateRadioControlSettings(settings: RadioControlSettings) + //endregion } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/TransponderMapper.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/TransponderMapper.kt new file mode 100644 index 00000000..6ed6798d --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/TransponderMapper.kt @@ -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 . + */ +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 + } + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/TransponderMapperTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/TransponderMapperTest.kt new file mode 100644 index 00000000..cba6ce11 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/TransponderMapperTest.kt @@ -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)) + } +} diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt index d9dfeb46..d5bd9662 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt @@ -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) } diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 7bcc1c68..d0c69ff2 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -144,6 +144,17 @@ Data output Network Bluetooth + CAT + + + Radio Control + CAT Radio Control + Radio Model + TX Radio BT Address + RX Radio BT Address + TX Radio Name + RX Radio Name + Enable CAT Control Network data output Enable rotation output diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index f1bc0878..de2a401c 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -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) } } diff --git a/feature/radiocontrol/build.gradle.kts b/feature/radiocontrol/build.gradle.kts new file mode 100644 index 00000000..975cb77e --- /dev/null +++ b/feature/radiocontrol/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.convention.featurePlugin) +} + +android { + namespace = "com.rtbishop.look4sat.feature.radiocontrol" +} diff --git a/feature/radiocontrol/src/main/AndroidManifest.xml b/feature/radiocontrol/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e1000761 --- /dev/null +++ b/feature/radiocontrol/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlScreen.kt b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlScreen.kt new file mode 100644 index 00000000..77af477f --- /dev/null +++ b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlScreen.kt @@ -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 . + */ +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) + ) + } +} diff --git a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlState.kt b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlState.kt new file mode 100644 index 00000000..fd886144 --- /dev/null +++ b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlState.kt @@ -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 . + */ +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, + 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() +} diff --git a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlViewModel.kt b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlViewModel.kt new file mode 100644 index 00000000..926719dc --- /dev/null +++ b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlViewModel.kt @@ -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 . + */ +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 = 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 = _uiState + + init { + val catNum = savedStateHandle.get("catNum") ?: 0 + val aosTime = savedStateHandle.get("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 + ) + } + } + } +} diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index 1b4487a8..3482f46f 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -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 + ) + } + } + } + } + } +} diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 8f391290..002d119d 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -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) + ) } } } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index cb229eac..813a9b4c 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -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() +} diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 297fb523..78ba2b72 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -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() { diff --git a/settings.gradle.kts b/settings.gradle.kts index 667e5214..83a3417d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include( ":feature:map", ":feature:passes", ":feature:radar", + ":feature:radiocontrol", ":feature:satellites", ":feature:settings" )