diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ed56135..385b941d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { namespace = "com.rtbishop.look4sat" compileSdk = 35 defaultConfig { - applicationId = "com.rtbishop.look4sat" - minSdk = 24 + applicationId = "com.rtbishop.look4satic705" + minSdk = 32 targetSdk = 35 versionCode = 400 versionName = "4.0.0" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d2b8fb80..6db38f39 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + diff --git a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt b/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt index 392d7a06..eb822950 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt @@ -28,7 +28,6 @@ import com.rtbishop.look4sat.presentation.MainTheme import com.rtbishop.look4sat.presentation.MainScreen class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge() @@ -40,3 +39,5 @@ class MainActivity : ComponentActivity() { } } } + +// Check and Request BT Permission? diff --git a/app/src/main/java/com/rtbishop/look4sat/presentation/radar/RadarScreen.kt b/app/src/main/java/com/rtbishop/look4sat/presentation/radar/RadarScreen.kt index 7bb63041..8f9abc46 100644 --- a/app/src/main/java/com/rtbishop/look4sat/presentation/radar/RadarScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/presentation/radar/RadarScreen.kt @@ -1,5 +1,6 @@ package com.rtbishop.look4sat.presentation.radar +import android.util.Log import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -10,6 +11,7 @@ import androidx.compose.foundation.MarqueeSpacing import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,11 +33,13 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.rotate 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 @@ -51,6 +55,7 @@ import androidx.navigation.navArgument import com.rtbishop.look4sat.R import com.rtbishop.look4sat.domain.model.SatRadio import com.rtbishop.look4sat.domain.utility.toDegrees +import com.rtbishop.look4sat.framework.BluetoothCIV import com.rtbishop.look4sat.presentation.MainTheme import com.rtbishop.look4sat.presentation.Screen import com.rtbishop.look4sat.presentation.components.CardIcon @@ -74,6 +79,8 @@ fun NavGraphBuilder.radarDestination(navigateBack: () -> Unit) { @Composable private fun RadarScreen(uiState: RadarState, navigateBack: () -> Unit) { + BluetoothCIV.init(LocalContext.current) + val addToCalendar: () -> Unit = { uiState.currentPass?.let { pass -> uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime)) @@ -250,7 +257,20 @@ private fun EclipsedIndicator() { @Composable private fun TransmitterItem(radio: SatRadio) { - Surface(color = MaterialTheme.colorScheme.background) { + LaunchedEffect(radio) { + BluetoothCIV.updateOnce(radio) + } + + Surface(color = MaterialTheme.colorScheme.background, + modifier = Modifier.clickable(onClick = { + Log.d("BluetoothCivManager", radio.toString()) + if(radio.uuid == BluetoothCIV.selected) BluetoothCIV.selected = "NONE" else + { + BluetoothCIV.connect(radio) + BluetoothCIV.updateOnce(radio) + } + }) + ) { Surface(modifier = Modifier.padding(bottom = 2.dp)) { Column( verticalArrangement = Arrangement.spacedBy(4.dp), diff --git a/app/src/main/java/com/rtbishop/look4sat/presentation/radar/RadarViewModel.kt b/app/src/main/java/com/rtbishop/look4sat/presentation/radar/RadarViewModel.kt index 168e910b..895f07c6 100644 --- a/app/src/main/java/com/rtbishop/look4sat/presentation/radar/RadarViewModel.kt +++ b/app/src/main/java/com/rtbishop/look4sat/presentation/radar/RadarViewModel.kt @@ -105,7 +105,7 @@ class RadarViewModel( sendPassData(satPass, pos, satPass.orbitalObject) sendPassDataBT(pos) processRadios(transmitters, satPass.orbitalObject, timeNow) - delay(1000) + delay(1000) //TODO: Change me maybe if smaller freq steps are better?? maybe dynamically? } } } diff --git a/data/src/main/java/com/rtbishop/look4sat/data/framework/BluetoothCIV.kt b/data/src/main/java/com/rtbishop/look4sat/data/framework/BluetoothCIV.kt new file mode 100644 index 00000000..08c08d0b --- /dev/null +++ b/data/src/main/java/com/rtbishop/look4sat/data/framework/BluetoothCIV.kt @@ -0,0 +1,285 @@ +package com.rtbishop.look4sat.framework + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothSocket +import android.util.Log +import java.io.OutputStream +import java.util.UUID +import java.io.IOException +import com.rtbishop.look4sat.domain.model.SatRadio +import android.content.Context +import android.annotation.SuppressLint +import android.bluetooth.BluetoothManager +import java.io.InputStream + +enum class Mode(val value: Int) { + LSB(0), + USB(1), + AM(2), + CW(3), + FMN(5), + FM(5), + DSTAR(17); + + companion object { + fun fromString(name: String): Int { + return values().find { it.name.equals(name, ignoreCase = true) }?.value ?: 1 + } + } +} + +fun getBluetoothAdapter(context: Context): BluetoothAdapter? { + val bluetoothManager = context.getSystemService(BluetoothManager::class.java) + return bluetoothManager?.adapter +} + +public object BluetoothCIV { + + // UUID for Serial Port Service + private val SERIAL_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") + // Bluetooth adapter + private var bluetoothAdapter: BluetoothAdapter? = null + private var bluetoothSocket: BluetoothSocket? = null + // CI-V Address of the ICOM IC-705 (verify for your radio) + private val CIV_ADDRESS = 0xA4 + private var outputStream: OutputStream? = null + private var inputStream: InputStream? = null + + public fun init(context: Context) { + bluetoothAdapter= getBluetoothAdapter(context) + } + + private fun ensureConnected():Boolean { + if( bluetoothSocket?.isConnected != true) { + if (bluetoothSocket != null) { + try { + bluetoothSocket?.close() + } catch (e: IOException) { + Log.e("BluetoothCivManager", "Fehler beim Schließen des alten Sockets", e) + } + bluetoothSocket = null + } + val dev = bluetoothAdapter?.bondedDevices?.firstOrNull { device -> device.name == "ICOM BT(IC-705)"} + if(dev==null) { + Log.e("BluetoothCivManager", "Gerät nicht gefunden") + return false + } else { + try{ + val localSocket = dev.createRfcommSocketToServiceRecord(SERIAL_UUID) + localSocket.connect() + outputStream = localSocket.outputStream + inputStream = localSocket.inputStream + bluetoothSocket = localSocket + // delay(100); + Thread.sleep(100); + Log.d("BluetoothCivManager", "Verbindung aufgebaut") + } catch (e: IOException) { + Log.e("BluetoothCivManager", "Verbindung nicht aufgebaut") + bluetoothSocket = null // Falls fehlgeschlagen, Socket auf null setzen + return false + } + } + } + return true + } + + private fun sendCommand(cmd: Int, sub: Int? = null, data: ByteArray? = null) { + outputStream!!.write(0xfe); + outputStream!!.write(0xfe); + outputStream!!.write(CIV_ADDRESS); + outputStream!!.write(0xe0); + outputStream!!.write(cmd); + if (sub != null) { + outputStream!!.write(sub) + } + if (data != null) { + outputStream!!.write(data) + } + outputStream!!.write(0xfd) + outputStream!!.flush(); + } + + data class RMsg(val broadcast: Boolean, val cmd: Int, val payload: ByteArray) + private fun receiveCommand():RMsg? { + if (inputStream!!.read()!=0xFE) { + return null; + } + if (inputStream!!.read()!=0xFE) { + return null; + } + val to = inputStream!!.read(); + if (inputStream!!.read()!= CIV_ADDRESS) { + return null; + } + val cmd = inputStream!!.read(); + + val buffer = mutableListOf() + var byte: Int + + while (inputStream!!.read().also { byte = it } != -1) { + if (byte == 0xFD) break + buffer.add(byte.toByte()) + } + + return RMsg(to==0x00, cmd, buffer.toByteArray()) + } + + private fun receiveSpecificCommand():RMsg? { + while (true) { + val msg = receiveCommand() ?: return null + if (!msg.broadcast) { + return msg + } + } + } + + private fun callProcedure(cmd: Int, sub: Int? = null, data: ByteArray? = null):Boolean { + if(ensureConnected()){ + sendCommand(cmd, sub, data) + val res = receiveSpecificCommand(); + return res?.cmd == 0xFB; + } else { + return false; + } + } + + data class Result(val subcmd: Int?, val data: ByteArray) + private fun callFunction(cmd: Int, sub: Int? = null, retsub: Boolean): Result{ + if(ensureConnected()){ + sendCommand(cmd, sub) + val res = receiveSpecificCommand(); + if (res!=null){ + if (cmd!=res.cmd) { + Log.d("BluetoothCivManager", "Wrong command $cmd != $res.cmd") + return Result(null, byteArrayOf()); + } + val subcmd = if (retsub) res.payload[0].toInt() else null; + val data = res.payload.copyOfRange((if (retsub) 1 else 0), res.payload.size); + return Result(subcmd, data) + }else { + Log.d("BluetoothCivManager", "No Reply") + return Result(null, byteArrayOf()); + } + } else { + Log.d("BluetoothCivManager", "NotSure") + return Result(null, byteArrayOf()); + } + } + + public var selected: String = "NONE"; + public var isTransponder: Boolean = false; + public var lastDownlinkLow: Long? = null; + public var lastDownlinkHigh: Long? = null; + + + private fun frequencyToBCD(frequency: Long): ByteArray { + var n = frequency + val bcd = ByteArray(5) + // Process each two-digit group, starting from the rightmost group. + for (i in 4 downTo 0) { + // Extract the last two digits (a value between 0 and 99) + val twoDigits = (n % 100).toInt() + // Pack the tens digit into the high nibble and the ones digit into the low nibble + bcd[i] = (((twoDigits / 10) shl 4) or (twoDigits % 10)).toByte() + // Remove the two digits we just processed + n /= 100 + } + return bcd + } + + // Function to convert BCD format back to a frequency (Long) + private fun bcdToFrequency(bcd: ByteArray): Long { + var frequency = 0L + for (byte in bcd) { + // Extract the high nibble (first decimal digit) + val high = (byte.toInt() shr 4) and 0x0F + // Extract the low nibble (second decimal digit) + val low = byte.toInt() and 0x0F + // Combine them into the frequency + frequency = frequency * 100 + high * 10 + low + } + return frequency + } + + private fun setVFO(A: Boolean): Boolean { + return callProcedure(0x07, if (A) 0x00 else 0x01); + } + + private fun setSplit(On: Boolean): Boolean { + return callProcedure(0x0F, if (On) 0x01 else 0x00); + } + + private fun setFrequency(Main: Boolean, Frequency: Long): Boolean { + return callProcedure(0x25, if (Main) 0x00 else 0x01, frequencyToBCD(Frequency).reversedArray()); + } + + private fun getFrequency(Main: Boolean): Long { + val res = callFunction(0x25, if (Main) 0x00 else 0x01, true) + return if (res.data.isNotEmpty()){ + bcdToFrequency(res.data.reversedArray()); + } else { + -1 + } + } + + private fun setMode(Main: Boolean, mode: String): Boolean { + return callProcedure(0x26, if (Main) 0x00 else 0x01, byteArrayOf(Mode.fromString(mode).toByte(), 0x00.toByte(), 0x01.toByte())); + } + + @SuppressLint("MissingPermission") + public fun connect(radio: SatRadio) { + setVFO(true) + setSplit(true) + setMode(true, radio.downlinkMode?:"USB") + setMode(false, radio.uplinkMode?:"USB") + selected = radio.uuid; + isTransponder = radio.downlinkHigh != null; + lastDownlinkLow = null; + lastDownlinkHigh = null; + // Info on tone? + } + + fun Double.clamp(min: Double, max: Double): Double { + return when { + this < min -> min + this > max -> max + else -> this + } + } + + // Function to send the CI-V command to the ICOM radio over Bluetooth + public fun updateOnce(radio: SatRadio) { + if(radio.uuid != selected) return; + + if(isTransponder) { + if (lastDownlinkLow != null && lastDownlinkHigh != null) { + val actual = getFrequency(true) + val pos = ((actual - lastDownlinkLow!!).toDouble() / (lastDownlinkHigh!! - lastDownlinkLow!!)).clamp(0.0,1.0); + Log.d("BluetoothCivManager", "Pos: $pos"); + radio.downlinkLow?.let{ + setFrequency(true, (radio.downlinkLow!! + pos * (radio.downlinkHigh!!-radio.downlinkLow!!)).toLong()); + } + radio.uplinkLow?.let { + setFrequency(false, (radio.uplinkLow!! + pos * (radio.uplinkHigh!!-radio.uplinkLow!!)).toLong()) + } + } else { + radio.downlinkLow?.let{ + setFrequency(true, it); + } + radio.uplinkLow?.let { + setFrequency(false, it) + } + } + lastDownlinkLow = radio.downlinkLow; + lastDownlinkHigh = radio.downlinkHigh; + } + else { + radio.downlinkLow?.let{ + setFrequency(true, it); + } + radio.uplinkLow?.let { + setFrequency(false, it) + } + } + } +} diff --git a/data/src/main/java/com/rtbishop/look4sat/data/repository/SatelliteRepo.kt b/data/src/main/java/com/rtbishop/look4sat/data/repository/SatelliteRepo.kt index 44c0969f..2607f8e8 100644 --- a/data/src/main/java/com/rtbishop/look4sat/data/repository/SatelliteRepo.kt +++ b/data/src/main/java/com/rtbishop/look4sat/data/repository/SatelliteRepo.kt @@ -82,7 +82,9 @@ class SatelliteRepo( val copiedList = radios.map { it.copy() } copiedList.forEach { transmitter -> transmitter.downlinkLow?.let { transmitter.downlinkLow = satPos.getDownlinkFreq(it) } - transmitter.uplinkLow?.let { transmitter.uplinkLow = satPos.getUplinkFreq(it) } + transmitter.downlinkHigh?.let { transmitter.downlinkHigh = satPos.getDownlinkFreq(it) } + transmitter.uplinkLow?.let { transmitter.uplinkLow = satPos.getUplinkFreq(it) } + transmitter.uplinkHigh?.let { transmitter.uplinkHigh = satPos.getUplinkFreq(it) } } copiedList.map { it.copy() } }