mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
feat(cw): add built-in CW Morse code decoder for linear transponders
- Add CwDsp with FIR bandpass filter, envelope detection, Goertzel tone detector - Add CwDecoder with real-time Morse timing analysis and character lookup - Add CW state/actions to RadarState, wire into RadarViewModel - Add collapsible CW decoder panel to transceivers page - 19 unit tests covering DSP, Morse table, and decoder state - Shares IAudioCapture with SSTV, auto-stops SSTV when CW starts
This commit is contained in:
1 parent
eac1e6e273
commit
33712c29fc
9 files changed
+1516
-7
No files matched your search
@@ -171,12 +171,13 @@ private fun PagerCard(
|
||||
) { pageIndex ->
|
||||
when (pages[pageIndex]) {
|
||||
RadarPage.Transceivers -> TransceiversPage(
|
||||
transceivers = uiState.transceivers.transmitters,
|
||||
selectedUuid = uiState.transceivers.selectedUuid,
|
||||
orbitalPos = uiState.orbitalPos,
|
||||
radioControl = uiState.radioControl,
|
||||
onAction = onAction
|
||||
)
|
||||
transceivers = uiState.transceivers.transmitters,
|
||||
selectedUuid = uiState.transceivers.selectedUuid,
|
||||
orbitalPos = uiState.orbitalPos,
|
||||
cw = uiState.cw,
|
||||
radioControl = uiState.radioControl,
|
||||
onAction = onAction
|
||||
)
|
||||
RadarPage.Sstv -> SstvPage(
|
||||
sstv = uiState.sstv,
|
||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||
|
||||
@@ -62,7 +62,8 @@ data class RadarState(
|
||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||
val sstv: SstvSubState = SstvSubState()
|
||||
val sstv: SstvSubState = SstvSubState(),
|
||||
val cw: CwSubState = CwSubState()
|
||||
)
|
||||
|
||||
enum class SstvStatus { Idle, Recording }
|
||||
@@ -77,6 +78,19 @@ data class SstvSubState(
|
||||
val diagnosticsMetrics: SstvQualityMetrics? = null
|
||||
)
|
||||
|
||||
// --- CW Decoder ---
|
||||
|
||||
enum class CwStatus { Idle, Listening }
|
||||
|
||||
data class CwSubState(
|
||||
val status: CwStatus = CwStatus.Idle,
|
||||
val hasPermission: Boolean = false,
|
||||
val decodedText: String = "",
|
||||
val cwToneFreq: Float = 700f,
|
||||
val isExpanded: Boolean = false,
|
||||
val signalStrength: Float = 0f
|
||||
)
|
||||
|
||||
sealed interface RadarAction {
|
||||
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
|
||||
data class SelectTransmitter(val uuid: String) : RadarAction
|
||||
@@ -96,4 +110,12 @@ sealed interface RadarAction {
|
||||
data object SstvReset : RadarAction
|
||||
data class SstvSelectMode(val modeName: String) : RadarAction
|
||||
data class SstvPermissionResult(val granted: Boolean) : RadarAction
|
||||
|
||||
// CW actions
|
||||
data object CwStartListening : RadarAction
|
||||
data object CwStopListening : RadarAction
|
||||
data object CwReset : RadarAction
|
||||
data class CwSetToneFreq(val freq: Float) : RadarAction
|
||||
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
|
||||
data class CwPermissionResult(val granted: Boolean) : RadarAction
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.sstv.LineRecoveryStrategy
|
||||
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDecoder
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
@@ -70,6 +71,8 @@ class RadarViewModel(
|
||||
private var transponders: List<SatRadio> = emptyList()
|
||||
private var sstvDecoder: SstvDecoder? = null
|
||||
private var sstvRecordingJob: Job? = null
|
||||
private var cwDecoder: CwDecoder? = null
|
||||
private var cwListeningJob: Job? = null
|
||||
|
||||
// Celestial positions change slowly, recompute at most once per minute
|
||||
private var lastCelestialUpdateMs = 0L
|
||||
@@ -272,6 +275,25 @@ class RadarViewModel(
|
||||
sstvDecoder?.clearPixels()
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||
}
|
||||
|
||||
// CW actions
|
||||
is RadarAction.CwPermissionResult -> {
|
||||
_uiState.update { it.copy(cw = it.cw.copy(hasPermission = action.granted)) }
|
||||
if (action.granted) initCwDecoder()
|
||||
}
|
||||
RadarAction.CwStartListening -> startCwListening()
|
||||
RadarAction.CwStopListening -> stopCwListening()
|
||||
RadarAction.CwReset -> {
|
||||
cwDecoder?.resetDecoder()
|
||||
_uiState.update { it.copy(cw = it.cw.copy(decodedText = "")) }
|
||||
}
|
||||
is RadarAction.CwSetToneFreq -> {
|
||||
_uiState.update { it.copy(cw = it.cw.copy(cwToneFreq = action.freq)) }
|
||||
cwDecoder = CwDecoder(sampleRate = audioCapture.sampleRate, cwToneFreq = action.freq)
|
||||
}
|
||||
is RadarAction.CwToggleExpanded -> {
|
||||
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +414,48 @@ class RadarViewModel(
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
|
||||
}
|
||||
|
||||
private fun initCwDecoder() {
|
||||
if (cwDecoder == null) {
|
||||
cwDecoder = CwDecoder(
|
||||
sampleRate = audioCapture.sampleRate,
|
||||
cwToneFreq = _uiState.value.cw.cwToneFreq
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCwListening() {
|
||||
if (cwListeningJob?.isActive == true) return
|
||||
// Stop SSTV if running (audio capture is shared)
|
||||
stopSstvRecording()
|
||||
initCwDecoder()
|
||||
cwDecoder?.resetDecoder()
|
||||
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Listening)) }
|
||||
cwListeningJob = viewModelScope.launch {
|
||||
// Collect decoded text flow
|
||||
launch {
|
||||
cwDecoder?.decodedTextFlow?.collect { text ->
|
||||
_uiState.update { it.copy(cw = it.cw.copy(decodedText = text)) }
|
||||
}
|
||||
}
|
||||
// Collect signal strength
|
||||
launch {
|
||||
cwDecoder?.signalStrength?.collect { strength ->
|
||||
_uiState.update { it.copy(cw = it.cw.copy(signalStrength = strength)) }
|
||||
}
|
||||
}
|
||||
// Capture audio and feed to decoder
|
||||
audioCapture.audioFlow().collect { buffer ->
|
||||
cwDecoder?.processBuffer(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopCwListening() {
|
||||
cwListeningJob?.cancel()
|
||||
cwListeningJob = null
|
||||
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Idle)) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
// SSTV Decoder Tuning Parameters
|
||||
// ==============================
|
||||
|
||||
@@ -40,10 +40,13 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -80,6 +83,7 @@ fun TransceiversPage(
|
||||
transceivers: List<SatRadio>,
|
||||
selectedUuid: String?,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -105,6 +109,7 @@ fun TransceiversPage(
|
||||
radio = radio,
|
||||
isExpanded = isExpanded,
|
||||
orbitalPos = orbitalPos,
|
||||
cw = cw,
|
||||
radioControl = radioControl,
|
||||
onAction = onAction,
|
||||
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
||||
@@ -142,6 +147,7 @@ private fun TransceiverItem(
|
||||
radio: SatRadio,
|
||||
isExpanded: Boolean,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
onToggle: () -> Unit
|
||||
@@ -237,6 +243,7 @@ private fun TransceiverItem(
|
||||
ExpandedRadioControl(
|
||||
radio = radio,
|
||||
orbitalPos = orbitalPos,
|
||||
cw = cw,
|
||||
radioControl = radioControl,
|
||||
onAction = onAction
|
||||
)
|
||||
@@ -308,6 +315,7 @@ private fun UnifiedFrequencyRow(
|
||||
private fun ExpandedRadioControl(
|
||||
radio: SatRadio,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit
|
||||
) {
|
||||
@@ -425,6 +433,15 @@ private fun ExpandedRadioControl(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// CW decoder panel (linear transponders only)
|
||||
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
|
||||
CwDecoderPanel(
|
||||
cw = cw,
|
||||
onAction = onAction,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Control buttons
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||
@@ -602,6 +619,113 @@ private fun DopplerFrequencyCalculator(
|
||||
|
||||
private enum class EditedField { TX, RX }
|
||||
|
||||
@Composable
|
||||
private fun CwDecoderPanel(
|
||||
cw: CwSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// Header row: expand/collapse toggle
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onAction(RadarAction.CwToggleExpanded(!cw.isExpanded)) },
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.radar_cw_decoder),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_arrow),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.rotate(if (cw.isExpanded) 270f else 90f)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = cw.isExpanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Control buttons row
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (cw.status == CwStatus.Idle) {
|
||||
Button(
|
||||
onClick = { onAction(RadarAction.CwStartListening) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.radar_cw_start))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { onAction(RadarAction.CwStopListening) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.radar_cw_stop))
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onAction(RadarAction.CwReset) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.radar_cw_reset))
|
||||
}
|
||||
}
|
||||
|
||||
// Signal strength indicator
|
||||
if (cw.status == CwStatus.Listening && cw.signalStrength > 0f) {
|
||||
val strengthPct = (cw.signalStrength * 100).toInt()
|
||||
Text(
|
||||
text = "Signal: $strengthPct%",
|
||||
fontSize = 12.sp,
|
||||
color = if (cw.signalStrength > 0.5f) Color(0xFF4CAF50)
|
||||
else if (cw.signalStrength > 0.2f) Color(0xFFFFC107)
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Decoded text output
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Decoded:",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = cw.decodedText.ifEmpty { "Waiting for CW signal..." },
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
||||
val text = frequency?.let {
|
||||
|
||||
Reference in new issue
Block a user