diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt index b116b190..50143f68 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt @@ -17,8 +17,10 @@ */ package com.rtbishop.look4sat.core.data.framework +import com.rtbishop.look4sat.core.domain.model.Constants import com.rtbishop.look4sat.core.domain.repository.IReporter import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -31,18 +33,30 @@ class NetworkReporter( private val rotatorServer: String, private val rotatorPort: Int, private val frequencyServer: String, - private val frequencyPort: Int + private val frequencyPort: Int, + private val frequencyOffsetHz: Long = 0L ) : IReporter { private val writeMutex = Mutex() + private val connectionMutex = Mutex() + private val frequencyCommands = Channel(Channel.CONFLATED) private var rotatorSocket: SocketChannel? = null private var rotatorConnected = false - private var rotatorConnecting = false private var frequencySocket: SocketChannel? = null private var frequencyConnected = false - private var frequencyConnecting = false + + init { + // Keep only the latest frequency command to avoid stale backlog and effective lag. + reporterScope.launch { + for (command in frequencyCommands) { + ensureFrequencyConnected() + if (!frequencyConnected) continue + write(frequencySocket, command) { resetFrequencyConnection() } + } + } + } override fun reportRotation(format: String, azimuth: Double, elevation: Double) { reporterScope.launch { @@ -58,46 +72,43 @@ class NetworkReporter( } override fun reportFrequency(format: String, frequency: Long) { - reporterScope.launch { - ensureFrequencyConnected() - if (!frequencyConnected) return@launch - val command = format - .replace($$"$FREQ", frequency.toString()) - .unescapeControlChars() - write(frequencySocket, command) { frequencyConnected = false } - } + val clampedOffset = frequencyOffsetHz.coerceIn( + Constants.FREQ_OFFSET_MIN_HZ, + Constants.FREQ_OFFSET_MAX_HZ + ) + val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L) + val command = format + .replace($$"$FREQ", correctedFreq.toString()) + .unescapeControlChars() + frequencyCommands.trySend(command) } - private fun ensureRotatorConnected() { - if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return - reporterScope.launch { + private suspend fun ensureRotatorConnected() { + connectionMutex.withLock { + if (rotatorConnected || rotatorServer.isBlank()) return try { - rotatorConnecting = true + resetRotatorConnection() rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort)) rotatorConnected = true println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort") } catch (e: Exception) { println("NetworkReporter rotator connect error: ${e.message}") - rotatorConnected = false - } finally { - rotatorConnecting = false + resetRotatorConnection() } } } - private fun ensureFrequencyConnected() { - if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return - reporterScope.launch { + private suspend fun ensureFrequencyConnected() { + connectionMutex.withLock { + if (frequencyConnected || frequencyServer.isBlank()) return try { - frequencyConnecting = true + resetFrequencyConnection() frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort)) frequencyConnected = true println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort") } catch (e: Exception) { println("NetworkReporter frequency connect error: ${e.message}") - frequencyConnected = false - } finally { - frequencyConnecting = false + resetFrequencyConnection() } } } @@ -106,7 +117,9 @@ class NetworkReporter( try { writeMutex.withLock { val buffer = ByteBuffer.wrap("$command\n".toByteArray()) - socket?.write(buffer) + while (buffer.hasRemaining()) { + socket?.write(buffer) + } } } catch (e: Exception) { println("NetworkReporter write error: ${e.message}") @@ -114,6 +127,32 @@ class NetworkReporter( } } + private fun resetRotatorConnection() { + rotatorConnected = false + closeQuietly(rotatorSocket) + rotatorSocket = null + } + + private fun resetFrequencyConnection() { + frequencyConnected = false + closeQuietly(frequencySocket) + frequencySocket = null + } + + private fun closeQuietly(socket: SocketChannel?) { + try { + socket?.close() + } catch (_: Exception) {} + } + + private fun Long.safeAdd(delta: Long): Long { + return when { + delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE + delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE + else -> this + delta + } + } + private fun String.unescapeControlChars(): String = replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t") } 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 34e4a12c..05778db5 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 @@ -106,7 +106,8 @@ class MainContainer(private val context: Context) : IMainContainer { rc.rotatorAddress, rc.rotatorPort.toIntOrNull() ?: 0, rc.frequencyAddress, - rc.frequencyPort.toIntOrNull() ?: 0 + rc.frequencyPort.toIntOrNull() ?: 0, + rc.frequencyOffsetHz ) } 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 07542265..2fbe2cac 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 @@ -29,6 +29,7 @@ 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.model.Constants import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.source.Sources @@ -69,6 +70,7 @@ class SettingsRepo( private val keyFrequencyAddress = "frequencyAddress" private val keyFrequencyPort = "frequencyPort" private val keyFrequencyFormat = "frequencyFormat" + private val keyFrequencyOffsetHz = "frequencyOffsetHz" private val keySelectedIds = "selectedIds" private val keySelectedSatModes = "selectedSatModes" private val keyStateOfAutoUpdate = "stateOfAutoUpdate" @@ -277,6 +279,10 @@ class SettingsRepo( override val rcSettings: StateFlow = _rcSettings override fun updateRCSettings(settings: RCSettings) { + val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn( + Constants.FREQ_OFFSET_MIN_HZ, + Constants.FREQ_OFFSET_MAX_HZ + ) preferences.edit { putBoolean(keyRotatorState, settings.rotatorState) putString(keyRotatorAddress, settings.rotatorAddress) @@ -286,6 +292,7 @@ class SettingsRepo( putString(keyFrequencyAddress, settings.frequencyAddress) putString(keyFrequencyPort, settings.frequencyPort) putString(keyFrequencyFormat, settings.frequencyFormat) + putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz) putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState) putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat) putString(keyBluetoothRotatorName, settings.bluetoothRotatorName) @@ -294,7 +301,7 @@ class SettingsRepo( putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat) putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress) } - _rcSettings.value = settings + _rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz) } private fun getRCSettings(): RCSettings = RCSettings( @@ -306,6 +313,8 @@ class SettingsRepo( frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1", frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532", frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ", + frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L) + .coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ), bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false), bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL", bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default", @@ -425,7 +434,7 @@ class SettingsRepo( val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}" return try { JSONObject(json).optString(catnum.toString(), "") - } catch (e: Exception) { + } catch (_: Exception) { "" } } @@ -436,7 +445,7 @@ class SettingsRepo( val obj = JSONObject(json) if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset) obj.toString() - } catch (e: Exception) { + } catch (_: Exception) { """{"$catnum": "$offset"}""" } preferences.edit { putString(keySatelliteOffsets, updated) } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index 6041173b..5a8a1a9c 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -120,6 +120,10 @@ private class FakeRemoteSource : IRemoteSource { override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke() override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke() + + override suspend fun getAmSatCatalog(): String? = null + + override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null } private class FakeLocalSource : ILocalSource { @@ -172,7 +176,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou override val databaseState: MutableStateFlow = MutableStateFlow(DatabaseState(0, 0, 0L)) override val rcSettings: StateFlow = MutableStateFlow( - RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "") + RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "") ) override val otherSettings: StateFlow = MutableStateFlow( diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt index ebd521d1..aa0292da 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt @@ -127,7 +127,7 @@ class SelectionRepoTest { override val databaseState: MutableStateFlow = MutableStateFlow(DatabaseState(0, 0, 0L)) override val rcSettings: StateFlow = MutableStateFlow( - RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "") + RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "") ) override val otherSettings: StateFlow = MutableStateFlow( diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt new file mode 100644 index 00000000..679c1de5 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt @@ -0,0 +1,23 @@ +/* + * 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.model + +object Constants { + const val FREQ_OFFSET_MIN_HZ = -50_000L + const val FREQ_OFFSET_MAX_HZ = 50_000L +} 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 9fd68bd3..97e5018a 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 @@ -41,6 +41,7 @@ data class RCSettings( val frequencyAddress: String, val frequencyPort: String, val frequencyFormat: String, + val frequencyOffsetHz: Long = 0L, val bluetoothRotatorState: Boolean, val bluetoothRotatorFormat: String, val bluetoothRotatorName: String, diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt index 410e8bf7..30619675 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt @@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double { return kotlin.math.round(this * multiplier) / multiplier } +fun String.aprsPasscode(): Int { + val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID + var hash = 0x73E2 + var i = 0 + while (i < callsign.length) { + hash = hash xor (callsign[i].code shl 8) + i++ + if (i < callsign.length) { + hash = hash xor callsign[i].code + i++ + } + } + return hash and 0x7FFF +} + //fun String.getHash(type: String = "SHA-256"): String { // val hexChars = "0123456789ABCDEF" // val bytes = MessageDigest.getInstance(type).digest(this.toByteArray()) diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt index 5d7a5588..9e767cf7 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt @@ -18,6 +18,7 @@ package com.rtbishop.look4sat.core.domain import com.rtbishop.look4sat.core.domain.utility.DataParser +import com.rtbishop.look4sat.core.domain.utility.aprsPasscode import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest @@ -239,4 +240,10 @@ class DataParserTest { // Matches the CSV test data epoch: 2021-11-16 → day 320 assert(dataParser.getDayOfYear(2021, 11, 16) == 320) } + + @Test + fun `check APRS passcode calculation`() { + assert("M7LNB".aprsPasscode() == 12443) + assert("N0CALL".aprsPasscode() == 13023) + } } diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 5ebb70f3..5d2b8c44 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -196,6 +196,8 @@ Enable frequency output IP:Port Format + Frequency offset (Hz) + Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it. Bluetooth output Enable rotation output 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 38e7418b..41ff4791 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 @@ -39,6 +39,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf @@ -58,6 +59,7 @@ 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.domain.model.Constants import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.LocalSpacing @@ -295,6 +297,7 @@ fun PreviewNetworkOutputDialog() { frequencyAddress = "127.0.0.1", frequencyPort = "4532", frequencyFormat = $$"F $FREQ", + frequencyOffsetHz = 0L, bluetoothRotatorState = false, bluetoothRotatorFormat = $$"P $AZ $EL", bluetoothRotatorName = "Default", @@ -304,7 +307,7 @@ fun PreviewNetworkOutputDialog() { bluetoothFrequencyFormat = $$"F $FREQ" ), onDismiss = {}, - onSave = { _, _, _, _, _, _, _, _ -> } + onSave = { _, _, _, _, _, _, _, _, _ -> } ) } } @@ -315,7 +318,7 @@ fun NetworkOutputDialog( onDismiss: () -> Unit, onSave: ( Boolean, String, String, String, - Boolean, String, String, String + Boolean, String, String, String, Long ) -> Unit ) { val padding = LocalSpacing.current.large @@ -329,12 +332,15 @@ fun NetworkOutputDialog( mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}") } val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) } + val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) } val onAccept = { val (rotIp, rotPort) = splitAddress(rotatorAddress.value) val (freqIp, freqPort) = splitAddress(frequencyAddress.value) + val offsetHz = (frequencyOffsetHz.value.trim().toLongOrNull() ?: 0L) + .coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ) onSave( rotatorState.value, rotIp, rotPort, rotatorFormat.value, - frequencyState.value, freqIp, freqPort, frequencyFormat.value + frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz ) onDismiss() } @@ -367,6 +373,27 @@ fun NetworkOutputDialog( onFormatChange = { frequencyFormat.value = it }, formatLabel = stringResource(R.string.prefs_net_frequency_format_hint) ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = frequencyOffsetHz.value, + onValueChange = { frequencyOffsetHz.value = it }, + singleLine = true, + label = { Text(stringResource(R.string.prefs_net_frequency_offset_hint)) }, + supportingText = { Text(stringResource(R.string.prefs_net_frequency_offset_help)) }, + trailingIcon = { + IconButton( + onClick = { frequencyOffsetHz.value = "0" }, + enabled = frequencyState.value && frequencyOffsetHz.value != "0" + ) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = null + ) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = frequencyState.value + ) Spacer(modifier = Modifier.height(12.dp)) } } @@ -395,6 +422,7 @@ fun PreviewBluetoothOutputDialog() { frequencyAddress = "127.0.0.1", frequencyPort = "4532", frequencyFormat = $$"F $FREQ", + frequencyOffsetHz = 0L, bluetoothRotatorState = false, bluetoothRotatorFormat = $$"P $AZ $EL", bluetoothRotatorName = "Default", 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 3f828b21..356392e8 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 @@ -134,14 +134,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> NetworkOutputDialog( initialSettings = uiState.rcSettings, onDismiss = { dialogs.network = false }, - onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt -> + onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz -> onAction( SettingsAction.UpdateRC( uiState.rcSettings.copy( rotatorState = rotState, rotatorAddress = rotAddr, rotatorPort = rotPort, rotatorFormat = rotFmt, frequencyState = freqState, frequencyAddress = freqAddr, - frequencyPort = freqPort, frequencyFormat = freqFmt + frequencyPort = freqPort, frequencyFormat = freqFmt, + frequencyOffsetHz = freqOffsetHz ) ) )