From c706c4c56aa01e724d597c31db3c2babe66bf50f Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 12:59:46 -0500 Subject: [PATCH 1/8] Add pull-down meters HUD (ALC/SWR) from top edge A top-anchored HUD, opened by a swipe-down from the top edge anywhere in the app, showing the two meters every supported CAT rig reports back over the existing link: ALC and SWR. These are only measurable while keyed, so the readout is live during TX and labelled "LAST TX" otherwise (and "no data" until the rig has ever reported, distinguished from a legitimate 0 reading via a new meterDataReceived flag on MeterProtectionController). Reuses the existing meter plumbing: MetersSheet observes lastAlc/lastSwr and reuses normalizedSwrToRatio for the readout; the ALC target window and SWR halt threshold from GeneralVariables drive the green/amber/red zones so the HUD and the protection controller agree on what "good" means. Decision/geometry logic (bar fraction, ALC/SWR zones, freshness, edge-open commit rule) is extracted into pure functions in MetersDisplay.kt and unit tested; the top-sheet scaffold mirrors FT8AFBottomSheet (slide from top, drag-up/scrim/Back to dismiss) without touching the tested bottom sheet. Co-Authored-By: Claude Opus 4.8 --- .../MeterProtectionController.java | 10 + .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 22 ++ .../ft8af/ui/components/MetersDisplay.kt | 84 +++++ .../ks3ckc/ft8af/ui/components/MetersSheet.kt | 345 ++++++++++++++++++ .../src/main/res/values/strings_compose.xml | 8 + .../ft8af/ui/components/MetersDisplayTest.kt | 145 ++++++++ 6 files changed, 614 insertions(+) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java index 85dffc182..3d7274e0a 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java @@ -44,6 +44,12 @@ public class MeterProtectionController { public final MutableLiveData swrLockout = new MutableLiveData<>(false); public final MutableLiveData lastAlc = new MutableLiveData<>(0); public final MutableLiveData lastSwr = new MutableLiveData<>(0); + // True once the rig has reported at least one valid meter reading. Lets the + // meters HUD tell "no data yet / unsupported rig" apart from a legitimate 0 + // reading (lastAlc/lastSwr start at 0, and 0 is a real value — ALC at no + // drive, SWR at 1.0:1). Never reset: once a rig has reported, the last values + // remain meaningful as the "last TX" readout. + public final MutableLiveData meterDataReceived = new MutableLiveData<>(false); // Holds the SWR ratio string (e.g. "3.2:1") that triggered lockout, for the banner. public final MutableLiveData lockoutSwrRatio = new MutableLiveData<>(""); @@ -72,6 +78,10 @@ public void onMeterUpdate(int normalizedAlc, int normalizedSwr) { // Publish for optional UI display if (normalizedAlc >= 0) lastAlc.postValue(normalizedAlc); if (normalizedSwr >= 0) lastSwr.postValue(normalizedSwr); + if ((normalizedAlc >= 0 || normalizedSwr >= 0) + && !Boolean.TRUE.equals(meterDataReceived.getValue())) { + meterDataReceived.postValue(true); + } // Diagnostics: when SWR protection is on, record each SWR reading we actually // receive so the debug.log shows whether meter data even reaches here during TX, the diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 083802af2..274a71de3 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -49,6 +49,8 @@ import radio.ks3ckc.ft8af.ui.components.shouldPersistSection import radio.ks3ckc.ft8af.ui.components.FT8AFTab import radio.ks3ckc.ft8af.ui.components.FrequencyPickerSheet import radio.ks3ckc.ft8af.ui.components.HoundSetupSheet +import radio.ks3ckc.ft8af.ui.components.MetersSheet +import radio.ks3ckc.ft8af.ui.components.TopEdgeMetersTrigger import radio.ks3ckc.ft8af.ui.components.formatMhz import radio.ks3ckc.ft8af.ui.components.QsoCelebration import radio.ks3ckc.ft8af.ui.components.SlotTimerBar @@ -159,6 +161,9 @@ fun FT8AFApp(mainViewModel: MainViewModel) { // Frequency picker sheet state var showFrequencyPicker by rememberSaveable { mutableStateOf(false) } + // Meters HUD (ALC/SWR pull-down) state — opened by a top-edge swipe-down. + var showMeters by rememberSaveable { mutableStateOf(false) } + // A tapped Needed-DX notification asks us to jump to the Decode tab (DecodeScreen // then scrolls to + highlights the alerted station). val preselectCallsign by mainViewModel.mutablePreselectCallsign.observeAsState() @@ -640,5 +645,22 @@ fun FT8AFApp(mainViewModel: MainViewModel) { } }, ) + + // Top-edge swipe-down opens the meters HUD from anywhere. Disabled while + // the HUD is already open so its own drag-to-dismiss isn't fought. + TopEdgeMetersTrigger( + enabled = !showMeters, + onOpen = { showMeters = true }, + modifier = Modifier.align(Alignment.TopCenter), + ) + + // Meters HUD (ALC/SWR) — sibling overlay so its scrim and panel sit above + // the tab bar and TxStrip, like the other sheets. + MetersSheet( + visible = showMeters, + mainViewModel = mainViewModel, + isTransmitting = isTransmitting, + onDismiss = { showMeters = false }, + ) } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt new file mode 100644 index 000000000..df22522d2 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt @@ -0,0 +1,84 @@ +package radio.ks3ckc.ft8af.ui.components + +/** + * Pure decision/geometry logic for the meters HUD, extracted so it can be unit + * tested without Compose. The [MetersSheet] composable is a thin wrapper that + * maps these results to bars, colors, and labels. + * + * Meter values are the normalized 0-255 scale that every CAT rig's meter path + * reports (see MeterProtectionController). The rig only reports ALC/SWR while + * keyed, so these values are live during TX and hold the last TX's reading + * afterwards — [meterFreshness] captures that distinction. + */ + +/** Visual zone for a meter reading, mapped to a concrete color in the UI layer. */ +enum class MeterZone { IDLE, GOOD, CAUTION, DANGER } + +/** Whether a displayed value is live (TX in progress), held from the last TX, or absent. */ +enum class MeterFreshness { LIVE, LAST_TX, NONE } + +internal const val METER_MAX = 255 + +/** Fraction 0f..1f of a full-scale bar for a normalized 0..255 meter value. */ +internal fun meterBarFraction(normalized: Int): Float = + normalized.coerceIn(0, METER_MAX) / METER_MAX.toFloat() + +/** ALC as a 0..100 percent for the readout. */ +internal fun alcPercent(normalized: Int): Int = + Math.round(meterBarFraction(normalized) * 100f) + +/** + * ALC zone. Below the target window the rig is under-driven (CAUTION — there is + * headroom to push harder); inside the window is ideal (GOOD); above it the + * stage is overdriven and the signal distorts (DANGER). A non-positive reading + * means no drive at all (IDLE). The window matches the auto-volume controller's + * target so the HUD and the protection logic agree on what "good" means. + */ +internal fun alcZone(normalized: Int, targetLow: Int, targetHigh: Int): MeterZone { + if (normalized <= 0) return MeterZone.IDLE + return when { + normalized > targetHigh -> MeterZone.DANGER + normalized < targetLow -> MeterZone.CAUTION + else -> MeterZone.GOOD + } +} + +/** + * SWR zone relative to the user's halt threshold. Comfortable below ~60% of the + * threshold (GOOD), rising caution up to it (CAUTION), and danger at/above it — + * the same point [com.k1af.ft8af.ft8transmit.MeterProtectionController] halts TX + * (DANGER). A non-positive reading (≈1.0:1, a flat match) is IDLE. A zero/invalid + * threshold can't define danger, so anything reads GOOD rather than false-alarm. + */ +internal fun swrZone(normalized: Int, haltThreshold: Int): MeterZone { + if (normalized <= 0) return MeterZone.IDLE + if (haltThreshold <= 0) return MeterZone.GOOD + val frac = normalized.toFloat() / haltThreshold + return when { + frac >= 1.0f -> MeterZone.DANGER + frac >= 0.6f -> MeterZone.CAUTION + else -> MeterZone.GOOD + } +} + +/** + * Live while transmitting and the rig is reporting meters; otherwise the shown + * value is the last TX's reading if we have ever received meter data, else the + * rig has reported nothing (unsupported rig, or no TX yet). Distinguishing + * "never reported" from a legitimate 0 reading needs [hasData] — a normalized 0 + * is a valid value (ALC at no drive, SWR at 1.0:1), not "no data". + */ +internal fun meterFreshness(isTransmitting: Boolean, hasData: Boolean): MeterFreshness = when { + isTransmitting && hasData -> MeterFreshness.LIVE + hasData -> MeterFreshness.LAST_TX + else -> MeterFreshness.NONE +} + +/** + * Whether a top-edge drag should open the HUD: a downward drag ([totalDy] > 0 in + * Compose's y-down coordinates) that has accumulated past [thresholdPx]. Pulled + * out so the open gesture's commit rule is testable independent of pointer + * plumbing. + */ +internal fun shouldOpenFromEdgeDrag(totalDy: Float, thresholdPx: Float): Boolean = + totalDy >= thresholdPx diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt new file mode 100644 index 000000000..e73929bf9 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt @@ -0,0 +1,345 @@ +package radio.ks3ckc.ft8af.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.runtime.livedata.observeAsState +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.MainViewModel +import com.k1af.ft8af.R +import com.k1af.ft8af.ft8transmit.MeterProtectionController +import radio.ks3ckc.ft8af.theme.* + +/** Maps a [MeterZone] to its bar/readout color. */ +private fun zoneColor(zone: MeterZone): Color = when (zone) { + MeterZone.IDLE -> TextFaint + MeterZone.GOOD -> StatusConfirmed + MeterZone.CAUTION -> StatusWarn + MeterZone.DANGER -> StatusBad +} + +/** + * Pull-down meters HUD: ALC and SWR read back from the rig over CAT. These are + * the two meters every supported CAT rig reports, and only while keyed — so the + * readout is live during TX and labelled "last TX" otherwise. Opened by a + * top-edge swipe-down (see [TopEdgeMetersTrigger]) from anywhere in the app. + */ +@Composable +fun MetersSheet( + visible: Boolean, + mainViewModel: MainViewModel, + isTransmitting: Boolean, + onDismiss: () -> Unit, +) { + val controller = mainViewModel.meterProtectionController + val alc by controller.lastAlc.observeAsState(0) + val swr by controller.lastSwr.observeAsState(0) + val hasData by controller.meterDataReceived.observeAsState(false) + + MetersTopSheet(visible = visible, onDismiss = onDismiss) { + val freshness = meterFreshness(isTransmitting, hasData) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 14.dp, bottom = 6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.meters_title), + color = TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.06.sp, + ) + Spacer(modifier = Modifier.width(10.dp)) + FreshnessBadge(freshness) + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (freshness == MeterFreshness.NONE) { + Text( + text = stringResource(R.string.meters_no_data), + color = TextMuted, + fontSize = 11.sp, + fontFamily = GeistMonoFamily, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + MeterRow( + label = stringResource(R.string.meters_alc), + valueText = "${alcPercent(alc)}%", + fraction = meterBarFraction(alc), + zone = alcZone(alc, GeneralVariables.alcTargetLow, GeneralVariables.alcTargetHigh), + dim = freshness == MeterFreshness.NONE, + ) + + Spacer(modifier = Modifier.height(14.dp)) + + MeterRow( + label = stringResource(R.string.meters_swr), + valueText = MeterProtectionController.normalizedSwrToRatio(swr), + fraction = meterBarFraction(swr), + zone = swrZone(swr, GeneralVariables.swrHaltThreshold), + dim = freshness == MeterFreshness.NONE, + ) + } + } +} + +@Composable +private fun FreshnessBadge(freshness: MeterFreshness) { + val (text, color) = when (freshness) { + MeterFreshness.LIVE -> stringResource(R.string.meters_live) to StatusConfirmed + MeterFreshness.LAST_TX -> stringResource(R.string.meters_last_tx) to TextMuted + MeterFreshness.NONE -> return + } + Text( + text = text, + color = color, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.10.sp, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color.copy(alpha = 0.12f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} + +@Composable +private fun MeterRow( + label: String, + valueText: String, + fraction: Float, + zone: MeterZone, + dim: Boolean, +) { + val barColor = if (dim) TextDim else zoneColor(zone) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + color = if (dim) TextFaint else TextMuted, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.08.sp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = valueText, + color = if (dim) TextFaint else TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + ) + } + Spacer(modifier = Modifier.height(6.dp)) + // Bar track + fill + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(99.dp)) + .background(BgSurface3), + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction.coerceIn(0f, 1f)) + .height(8.dp) + .clip(RoundedCornerShape(99.dp)) + .background(barColor), + ) + } + } +} + +/** + * Top-anchored sheet scaffold — the mirror of [FT8AFBottomSheet]: it slides in + * from the top edge, rounds its bottom corners, and is dismissed by a drag UP, + * a scrim tap, or Back. Kept local to the meters HUD so the well-tested bottom + * sheet stays untouched. + */ +@Composable +private fun MetersTopSheet( + visible: Boolean, + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) { + val sheetState = remember { MutableTransitionState(visible) } + sheetState.targetState = visible + + // Keep Back captured through the exit animation, same rationale as the bottom + // sheet (#201): currentState stays true until slide-out completes. + BackHandler(enabled = sheetBackHandlerActive(sheetState.currentState, sheetState.targetState)) { + onDismiss() + } + + val density = LocalDensity.current + val dismissThresholdPx = with(density) { 80.dp.toPx() } + var dragOffset by remember { mutableFloatStateOf(0f) } + var sheetHeightPx by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(visible) { dragOffset = 0f } + + val animatedOffset by animateFloatAsState( + targetValue = dragOffset, + label = "meters-sheet-drag-offset", + ) + + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xB805080E)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, + ) + } + + AnimatedVisibility( + visibleState = sheetState, + // Slide from the TOP (negative offset) instead of the bottom. + enter = slideInVertically(initialOffsetY = { -it }), + exit = slideOutVertically(targetOffsetY = { -it }), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(0, animatedOffset.toInt()) } + .onSizeChanged { sheetHeightPx = it.height.toFloat() } + .clip(RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp)) + .background(BgSurface2) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { /* consume click */ }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content() + + // Drag handle at the BOTTOM edge — a drag UP dismisses the sheet. + Box( + modifier = Modifier + .padding(top = 4.dp, bottom = 10.dp) + .width(72.dp) + .height(20.dp) + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragEnd = { + if (dragOffset < -dismissThresholdPx) { + onDismiss() + } else { + dragOffset = 0f + } + }, + onDragCancel = { dragOffset = 0f }, + onVerticalDrag = { _, dy -> + val maxUp = if (sheetHeightPx > 0f) sheetHeightPx else Float.MAX_VALUE + dragOffset = (dragOffset + dy).coerceIn(-maxUp, 0f) + }, + ) + }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .width(36.dp) + .height(4.dp) + .clip(RoundedCornerShape(99.dp)) + .background(Color(0x6694A3B8)), + ) + } + } + } + } +} + +/** + * Invisible top-edge strip that opens the meters HUD on a downward swipe. Sized + * thin so it only claims the very top edge (like the Android status-bar pull), + * leaving the rest of the screen's gestures untouched. Tracks cumulative drag + * and commits via [shouldOpenFromEdgeDrag]. + */ +@Composable +fun TopEdgeMetersTrigger( + enabled: Boolean, + onOpen: () -> Unit, + modifier: Modifier = Modifier, +) { + if (!enabled) return + val density = LocalDensity.current + val openThresholdPx = with(density) { 36.dp.toPx() } + var totalDy by remember { mutableFloatStateOf(0f) } + Box( + modifier = modifier + .fillMaxWidth() + .height(24.dp) + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragStart = { totalDy = 0f }, + onDragEnd = { + if (shouldOpenFromEdgeDrag(totalDy, openThresholdPx)) onOpen() + totalDy = 0f + }, + onDragCancel = { totalDy = 0f }, + onVerticalDrag = { _, dy -> totalDy += dy }, + ) + }, + ) +} diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 23145e7bd..84b12bd4a 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -134,6 +134,14 @@ DISMISS TX blocked — SWR lockout active. Dismiss the lockout banner first. + + METERS + ALC + SWR + LIVE + LAST TX + No meter data yet — read back from the rig during transmit. + Calling CQ Searching... diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt new file mode 100644 index 000000000..fc1e73507 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt @@ -0,0 +1,145 @@ +package radio.ks3ckc.ft8af.ui.components + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for the pure meters-HUD logic in MetersDisplay.kt — bar geometry, + * ALC/SWR zone classification, freshness (live / last-TX / none), and the + * top-edge open-gesture commit rule. No Compose/Android types, so these run as + * plain JUnit with no Robolectric runner. + */ +class MetersDisplayTest { + + // ---- meterBarFraction ---- + + @Test + fun barFraction_spansZeroToOne() { + assertThat(meterBarFraction(0)).isEqualTo(0f) + assertThat(meterBarFraction(255)).isEqualTo(1f) + } + + @Test + fun barFraction_midScale() { + // 128/255 ≈ 0.502 + assertThat(meterBarFraction(128)).isWithin(0.001f).of(128f / 255f) + } + + @Test + fun barFraction_clampsOutOfRange() { + // Defensive: a stray negative or over-255 value can't produce a bar + // outside the track. + assertThat(meterBarFraction(-10)).isEqualTo(0f) + assertThat(meterBarFraction(999)).isEqualTo(1f) + } + + // ---- alcPercent ---- + + @Test + fun alcPercent_roundsToWholePercent() { + assertThat(alcPercent(0)).isEqualTo(0) + assertThat(alcPercent(255)).isEqualTo(100) + assertThat(alcPercent(128)).isEqualTo(50) // 128/255*100 = 50.2 -> 50 + } + + // ---- alcZone ---- + + @Test + fun alcZone_zeroIsIdle() { + assertThat(alcZone(0, targetLow = 60, targetHigh = 120)).isEqualTo(MeterZone.IDLE) + assertThat(alcZone(-1, targetLow = 60, targetHigh = 120)).isEqualTo(MeterZone.IDLE) + } + + @Test + fun alcZone_belowWindowIsCaution() { + // Under-driven: there's headroom to push harder. + assertThat(alcZone(40, targetLow = 60, targetHigh = 120)).isEqualTo(MeterZone.CAUTION) + } + + @Test + fun alcZone_insideWindowIsGood() { + assertThat(alcZone(60, targetLow = 60, targetHigh = 120)).isEqualTo(MeterZone.GOOD) + assertThat(alcZone(90, targetLow = 60, targetHigh = 120)).isEqualTo(MeterZone.GOOD) + assertThat(alcZone(120, targetLow = 60, targetHigh = 120)).isEqualTo(MeterZone.GOOD) + } + + @Test + fun alcZone_aboveWindowIsDanger() { + // Overdriven distorts the signal. + assertThat(alcZone(150, targetLow = 60, targetHigh = 120)).isEqualTo(MeterZone.DANGER) + } + + // ---- swrZone ---- + + @Test + fun swrZone_zeroIsIdle() { + assertThat(swrZone(0, haltThreshold = 120)).isEqualTo(MeterZone.IDLE) + } + + @Test + fun swrZone_wellUnderThresholdIsGood() { + // 60/120 = 0.5 < 0.6 + assertThat(swrZone(60, haltThreshold = 120)).isEqualTo(MeterZone.GOOD) + } + + @Test + fun swrZone_approachingThresholdIsCaution() { + // 96/120 = 0.8, between 0.6 and 1.0 + assertThat(swrZone(96, haltThreshold = 120)).isEqualTo(MeterZone.CAUTION) + } + + @Test + fun swrZone_atOrOverThresholdIsDanger() { + // At the threshold and beyond — the same point protection halts TX. + assertThat(swrZone(120, haltThreshold = 120)).isEqualTo(MeterZone.DANGER) + assertThat(swrZone(200, haltThreshold = 120)).isEqualTo(MeterZone.DANGER) + } + + @Test + fun swrZone_invalidThresholdNeverFalseAlarms() { + // A zero/garbage threshold can't define "danger"; show GOOD rather than + // flashing red on every reading. + assertThat(swrZone(200, haltThreshold = 0)).isEqualTo(MeterZone.GOOD) + } + + // ---- meterFreshness ---- + + @Test + fun freshness_liveWhileTransmittingWithData() { + assertThat(meterFreshness(isTransmitting = true, hasData = true)) + .isEqualTo(MeterFreshness.LIVE) + } + + @Test + fun freshness_lastTxWhenIdleButHaveData() { + assertThat(meterFreshness(isTransmitting = false, hasData = true)) + .isEqualTo(MeterFreshness.LAST_TX) + } + + @Test + fun freshness_noneWhenNoDataEver() { + // Unsupported rig, or no TX yet: a 0 reading would otherwise masquerade + // as a real value, so we gate on hasData. + assertThat(meterFreshness(isTransmitting = false, hasData = false)) + .isEqualTo(MeterFreshness.NONE) + // Even mid-TX, if the rig has reported nothing it's still NONE. + assertThat(meterFreshness(isTransmitting = true, hasData = false)) + .isEqualTo(MeterFreshness.NONE) + } + + // ---- shouldOpenFromEdgeDrag ---- + + @Test + fun edgeDrag_opensPastThreshold() { + assertThat(shouldOpenFromEdgeDrag(totalDy = 40f, thresholdPx = 36f)).isTrue() + assertThat(shouldOpenFromEdgeDrag(totalDy = 36f, thresholdPx = 36f)).isTrue() + } + + @Test + fun edgeDrag_ignoresShortOrUpwardDrag() { + // A small downward nudge shouldn't open. + assertThat(shouldOpenFromEdgeDrag(totalDy = 10f, thresholdPx = 36f)).isFalse() + // An upward drag (negative dy) must never open. + assertThat(shouldOpenFromEdgeDrag(totalDy = -50f, thresholdPx = 36f)).isFalse() + } +} From 32ef256b168706c528b49ab18c11c260f3008ee3 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 13:33:17 -0500 Subject: [PATCH 2/8] Meters HUD: adapt per rig + configurable meter set (fix Flex blank HUD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HUD only read MeterProtectionController.lastAlc/lastSwr, which is fed solely by the serial-CAT meter path (Yaesu RM4/RM6, Kenwood, Icom, serial Xiegu). On a network rig the HUD sat blank: a FlexRadio streams its meters over UDP into FlexConnector.mutableMeterList, and Xiegu network into X6100Radio.mutableMeters — neither ever reached that controller. Make the HUD source-agnostic and adaptive: - rememberRigMeterSamples observes whichever stream the connected rig produces — Flex (SWR ratio, ALC dB, power W, S-meter dBm, PA temp), Xiegu (SWR, ALC, power, S-meter, voltage), or the serial controller (ALC, SWR) — and returns the rig's available meters. - Pure per-source converters (MetersDisplay.kt) normalize each rig's native units into a common MeterSample (bar fraction + readout + zone), so the HUD renders uniformly. Flex ALC is dB (low = good) vs the serial 0-255 window; SWR converts from ratio or the normalized scale. - A configurable meter set: Settings → Meters toggles each meter (SWR + ALC on by default, power/S-meter/voltage/temp off). The HUD shows the intersection of enabled and rig-available meters ("adapt per rig"), so an enabled meter the rig doesn't report is dropped, not shown empty. All converters + the enabled/available filter are unit-tested. Co-Authored-By: Claude Opus 4.8 --- .../java/com/k1af/ft8af/GeneralVariables.java | 12 + .../com/k1af/ft8af/database/DatabaseOpr.java | 19 ++ .../ft8af/ui/components/MetersDisplay.kt | 212 ++++++++++++- .../ks3ckc/ft8af/ui/components/MetersSheet.kt | 278 ++++++++++-------- .../ft8af/ui/components/MetersSource.kt | 85 ++++++ .../ft8af/ui/settings/MetersSettings.kt | 133 +++++++++ .../ft8af/ui/settings/SettingsScreen.kt | 10 + .../src/main/res/values/strings_compose.xml | 20 +- .../ft8af/ui/components/MetersDisplayTest.kt | 165 ++++++++++- 9 files changed, 794 insertions(+), 140 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSource.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/MetersSettings.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 401b53892..12787a6b6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -366,6 +366,18 @@ public static Context getMainContext() { public static int alcTargetLow = 60; // ALC target window low (0-255) public static int alcTargetHigh = 100; // ALC target window high (0-255) + // Meters HUD: which meters the pull-down HUD shows. The HUD adapts per rig — + // only meters the connected rig actually reports are shown — and these flags + // gate which of the available ones the user wants. SWR + ALC are the two + // universal across CAT rigs and default on; the richer meters (only some + // rigs, e.g. FlexRadio/Xiegu network, report them) default off. + public static boolean meterShowSwr = true; + public static boolean meterShowAlc = true; + public static boolean meterShowPower = false; + public static boolean meterShowSMeter = false; + public static boolean meterShowVoltage = false; + public static boolean meterShowTemp = false; + public static MutableLiveData mutableBaseFrequency = new MutableLiveData<>(); private static int spectrumWidth = 3500;//Spectrum display width in Hz diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 07d846c4b..1faa85156 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2667,6 +2667,25 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("alcTargetHigh")) { GeneralVariables.alcTargetHigh = result.equals("") ? 100 : Integer.parseInt(result); } + // Meters HUD enabled-meters (SWR/ALC default on, rest default off) + if (name.equalsIgnoreCase("meterShowSwr")) { + GeneralVariables.meterShowSwr = result.equals("1"); + } + if (name.equalsIgnoreCase("meterShowAlc")) { + GeneralVariables.meterShowAlc = result.equals("1"); + } + if (name.equalsIgnoreCase("meterShowPower")) { + GeneralVariables.meterShowPower = result.equals("1"); + } + if (name.equalsIgnoreCase("meterShowSMeter")) { + GeneralVariables.meterShowSMeter = result.equals("1"); + } + if (name.equalsIgnoreCase("meterShowVoltage")) { + GeneralVariables.meterShowVoltage = result.equals("1"); + } + if (name.equalsIgnoreCase("meterShowTemp")) { + GeneralVariables.meterShowTemp = result.equals("1"); + } if (name.equalsIgnoreCase("spectrumWidth")) { GeneralVariables.setSpectrumWidth(result.equals("") ? 3500 : Integer.parseInt(result)); } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt index df22522d2..0be2af3ba 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt @@ -11,8 +11,31 @@ package radio.ks3ckc.ft8af.ui.components * afterwards — [meterFreshness] captures that distinction. */ -/** Visual zone for a meter reading, mapped to a concrete color in the UI layer. */ -enum class MeterZone { IDLE, GOOD, CAUTION, DANGER } +/** + * Visual zone for a meter reading, mapped to a concrete color in the UI layer. + * NEUTRAL is for purely informational meters (power, S-meter) that have no + * good/bad judgement — they're shown in an accent color, not green/red. + */ +enum class MeterZone { IDLE, NEUTRAL, GOOD, CAUTION, DANGER } + +/** + * The meters the HUD can show. Ordinal order is the display order. Not every rig + * reports every meter — the HUD adapts per rig (see the source adapters), and + * [visibleMeters] intersects what's available with what the user enabled. + */ +enum class MeterType { SWR, ALC, POWER, SMETER, VOLTAGE, TEMP } + +/** + * One meter ready to render: a 0..1 bar [fraction], a formatted [text] readout, + * and a [zone] for coloring. Built by the pure converters below from each rig's + * native units, so the HUD itself stays source-agnostic. + */ +data class MeterSample( + val type: MeterType, + val fraction: Float, + val text: String, + val zone: MeterZone, +) /** Whether a displayed value is live (TX in progress), held from the last TX, or absent. */ enum class MeterFreshness { LIVE, LAST_TX, NONE } @@ -20,12 +43,10 @@ enum class MeterFreshness { LIVE, LAST_TX, NONE } internal const val METER_MAX = 255 /** Fraction 0f..1f of a full-scale bar for a normalized 0..255 meter value. */ -internal fun meterBarFraction(normalized: Int): Float = - normalized.coerceIn(0, METER_MAX) / METER_MAX.toFloat() +internal fun meterBarFraction(normalized: Int): Float = normalized.coerceIn(0, METER_MAX) / METER_MAX.toFloat() /** ALC as a 0..100 percent for the readout. */ -internal fun alcPercent(normalized: Int): Int = - Math.round(meterBarFraction(normalized) * 100f) +internal fun alcPercent(normalized: Int): Int = Math.round(meterBarFraction(normalized) * 100f) /** * ALC zone. Below the target window the rig is under-driven (CAUTION — there is @@ -34,7 +55,11 @@ internal fun alcPercent(normalized: Int): Int = * means no drive at all (IDLE). The window matches the auto-volume controller's * target so the HUD and the protection logic agree on what "good" means. */ -internal fun alcZone(normalized: Int, targetLow: Int, targetHigh: Int): MeterZone { +internal fun alcZone( + normalized: Int, + targetLow: Int, + targetHigh: Int, +): MeterZone { if (normalized <= 0) return MeterZone.IDLE return when { normalized > targetHigh -> MeterZone.DANGER @@ -50,7 +75,10 @@ internal fun alcZone(normalized: Int, targetLow: Int, targetHigh: Int): MeterZon * (DANGER). A non-positive reading (≈1.0:1, a flat match) is IDLE. A zero/invalid * threshold can't define danger, so anything reads GOOD rather than false-alarm. */ -internal fun swrZone(normalized: Int, haltThreshold: Int): MeterZone { +internal fun swrZone( + normalized: Int, + haltThreshold: Int, +): MeterZone { if (normalized <= 0) return MeterZone.IDLE if (haltThreshold <= 0) return MeterZone.GOOD val frac = normalized.toFloat() / haltThreshold @@ -68,11 +96,15 @@ internal fun swrZone(normalized: Int, haltThreshold: Int): MeterZone { * "never reported" from a legitimate 0 reading needs [hasData] — a normalized 0 * is a valid value (ALC at no drive, SWR at 1.0:1), not "no data". */ -internal fun meterFreshness(isTransmitting: Boolean, hasData: Boolean): MeterFreshness = when { - isTransmitting && hasData -> MeterFreshness.LIVE - hasData -> MeterFreshness.LAST_TX - else -> MeterFreshness.NONE -} +internal fun meterFreshness( + isTransmitting: Boolean, + hasData: Boolean, +): MeterFreshness = + when { + isTransmitting && hasData -> MeterFreshness.LIVE + hasData -> MeterFreshness.LAST_TX + else -> MeterFreshness.NONE + } /** * Whether a top-edge drag should open the HUD: a downward drag ([totalDy] > 0 in @@ -80,5 +112,155 @@ internal fun meterFreshness(isTransmitting: Boolean, hasData: Boolean): MeterFre * out so the open gesture's commit rule is testable independent of pointer * plumbing. */ -internal fun shouldOpenFromEdgeDrag(totalDy: Float, thresholdPx: Float): Boolean = - totalDy >= thresholdPx +internal fun shouldOpenFromEdgeDrag( + totalDy: Float, + thresholdPx: Float, +): Boolean = totalDy >= thresholdPx + +// --------------------------------------------------------------------------- +// Per-source meter converters → MeterSample. Each takes a rig's native units and +// produces a common bar fraction + readout + zone, so the HUD renders uniformly +// across serial / Flex / Xiegu sources. All pure and unit-tested. +// --------------------------------------------------------------------------- + +/** Bar reaches full at a 3:1 reference; readout shows "∞" above ~9.5:1. */ +internal fun swrSampleFromRatio(ratio: Float): MeterSample { + val r = if (ratio < 1f) 1f else ratio + val frac = ((r - 1f) / (3f - 1f)).coerceIn(0f, 1f) + val zone = + when { + r < 1.5f -> MeterZone.GOOD + r < 3.0f -> MeterZone.CAUTION + else -> MeterZone.DANGER + } + val text = if (r >= 9.5f) "∞" else String.format(java.util.Locale.US, "%.1f:1", r) + return MeterSample(MeterType.SWR, frac, text, zone) +} + +/** + * Serial rigs report SWR as a normalized 0-255 value; convert to a ratio (the + * numeric inverse of MeterProtectionController.swrRatioToNormalized, matching its + * piecewise breakpoints) and reuse [swrSampleFromRatio]. + */ +internal fun swrRatioFromNormalized(normalized: Int): Float = + when { + normalized <= 0 -> 1.0f + normalized >= 255 -> 10.0f + normalized <= 60 -> 1.0f + normalized / 60f * 0.5f + normalized <= 120 -> 1.5f + (normalized - 60) / 60f * 1.5f + normalized <= 200 -> 3.0f + (normalized - 120) / 80f * 4.0f + else -> 7.0f + (normalized - 200) / 55f * 3.0f + } + +internal fun swrSampleFromNormalized(normalized: Int): MeterSample = swrSampleFromRatio(swrRatioFromNormalized(normalized)) + +/** Serial ALC: normalized 0-255 with the user's target window (low is under-driven). */ +internal fun alcSampleSerial( + normalized: Int, + targetLow: Int, + targetHigh: Int, +): MeterSample = + MeterSample( + MeterType.ALC, + meterBarFraction(normalized), + "${alcPercent(normalized)}%", + alcZone(normalized, targetLow, targetHigh), + ) + +/** + * Flex ALC is a dB reading (~ -150..+20); unlike the serial scale, LOW is good + * (no ALC compression) and climbing toward/past 0 dB means overdrive. The bar + * fills as drive rises so a pegging meter reads as a full red bar. + */ +internal fun alcSampleFlexDb(db: Float): MeterSample { + val frac = ((db + 150f) / 170f).coerceIn(0f, 1f) + val zone = + when { + db <= 0f -> MeterZone.GOOD + db <= 10f -> MeterZone.CAUTION + else -> MeterZone.DANGER + } + return MeterSample(MeterType.ALC, frac, "${Math.round(db)} dB", zone) +} + +/** Xiegu ALC arrives as 0..120 (higher = more drive). */ +internal fun alcSampleXiegu(alc: Float): MeterSample { + val v = alc.coerceIn(0f, 120f) + val frac = v / 120f + val zone = + when { + v >= 80f -> MeterZone.DANGER + v >= 40f -> MeterZone.CAUTION + else -> MeterZone.GOOD + } + return MeterSample(MeterType.ALC, frac, "${Math.round(v)}", zone) +} + +/** Output power in watts; informational (NEUTRAL), bar relative to [maxWatts]. */ +internal fun powerSample( + watts: Float, + maxWatts: Float = 100f, +): MeterSample { + val frac = if (maxWatts <= 0f) 0f else (watts / maxWatts).coerceIn(0f, 1f) + return MeterSample(MeterType.POWER, frac, "${Math.round(watts)} W", MeterZone.NEUTRAL) +} + +/** Receive signal strength in dBm; informational, bar spans roughly -120..-30 dBm. */ +internal fun sMeterSampleDbm(dbm: Float): MeterSample { + val frac = ((dbm + 120f) / 90f).coerceIn(0f, 1f) + return MeterSample(MeterType.SMETER, frac, "${Math.round(dbm)} dBm", MeterZone.NEUTRAL) +} + +/** Supply voltage; green in the normal 12V battery band, red at the extremes. */ +internal fun voltSample(volts: Float): MeterSample { + val frac = (volts / 16f).coerceIn(0f, 1f) + val zone = + when { + volts < 10f || volts > 15.5f -> MeterZone.DANGER + volts < 11.5f || volts > 14.8f -> MeterZone.CAUTION + else -> MeterZone.GOOD + } + return MeterSample(MeterType.VOLTAGE, frac, String.format(java.util.Locale.US, "%.1f V", volts), zone) +} + +/** PA temperature in °C; caution past 55°C, danger past 70°C. */ +internal fun tempSample(celsius: Float): MeterSample { + val frac = (celsius / 100f).coerceIn(0f, 1f) + val zone = + when { + celsius >= 70f -> MeterZone.DANGER + celsius >= 55f -> MeterZone.CAUTION + else -> MeterZone.GOOD + } + return MeterSample(MeterType.TEMP, frac, "${Math.round(celsius)}°C", zone) +} + +/** The set of meter types the user has enabled in settings (display order preserved). */ +internal fun enabledMeterTypes( + swr: Boolean, + alc: Boolean, + power: Boolean, + smeter: Boolean, + voltage: Boolean, + temp: Boolean, +): Set { + val set = linkedSetOf() + if (swr) set.add(MeterType.SWR) + if (alc) set.add(MeterType.ALC) + if (power) set.add(MeterType.POWER) + if (smeter) set.add(MeterType.SMETER) + if (voltage) set.add(MeterType.VOLTAGE) + if (temp) set.add(MeterType.TEMP) + return set +} + +/** + * The meters to actually render: those the rig reports ([available]) intersected + * with those the user enabled ([enabled]), in display (ordinal) order. This is + * the "adapt per rig" rule — an enabled meter the rig doesn't report is dropped + * rather than shown empty. + */ +internal fun visibleMeters( + available: List, + enabled: Set, +): List = available.filter { it.type in enabled }.sortedBy { it.type.ordinal } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt index e73929bf9..ac67f7bdf 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt @@ -42,26 +42,42 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.runtime.livedata.observeAsState import com.k1af.ft8af.GeneralVariables import com.k1af.ft8af.MainViewModel import com.k1af.ft8af.R -import com.k1af.ft8af.ft8transmit.MeterProtectionController import radio.ks3ckc.ft8af.theme.* /** Maps a [MeterZone] to its bar/readout color. */ -private fun zoneColor(zone: MeterZone): Color = when (zone) { - MeterZone.IDLE -> TextFaint - MeterZone.GOOD -> StatusConfirmed - MeterZone.CAUTION -> StatusWarn - MeterZone.DANGER -> StatusBad -} +private fun zoneColor(zone: MeterZone): Color = + when (zone) { + MeterZone.IDLE -> TextFaint + MeterZone.NEUTRAL -> Signal + MeterZone.GOOD -> StatusConfirmed + MeterZone.CAUTION -> StatusWarn + MeterZone.DANGER -> StatusBad + } + +/** The localized short label for each meter type. */ +@Composable +private fun meterTypeLabel(type: MeterType): String = + stringResource( + when (type) { + MeterType.SWR -> R.string.meters_swr + MeterType.ALC -> R.string.meters_alc + MeterType.POWER -> R.string.meters_power + MeterType.SMETER -> R.string.meters_smeter + MeterType.VOLTAGE -> R.string.meters_voltage + MeterType.TEMP -> R.string.meters_temp + }, + ) /** - * Pull-down meters HUD: ALC and SWR read back from the rig over CAT. These are - * the two meters every supported CAT rig reports, and only while keyed — so the - * readout is live during TX and labelled "last TX" otherwise. Opened by a - * top-edge swipe-down (see [TopEdgeMetersTrigger]) from anywhere in the app. + * Pull-down meters HUD. Shows the meters the connected rig reports back — + * universally ALC and SWR, plus power / S-meter / voltage / temperature on rigs + * that stream them (e.g. FlexRadio/Xiegu network). Which of the available meters + * appear is controlled per-meter in settings (SWR + ALC on by default). Meters + * are only measured while keyed, so the readout is live during TX and labelled + * "last TX" otherwise. Opened by a top-edge swipe-down ([TopEdgeMetersTrigger]). */ @Composable fun MetersSheet( @@ -70,19 +86,26 @@ fun MetersSheet( isTransmitting: Boolean, onDismiss: () -> Unit, ) { - val controller = mainViewModel.meterProtectionController - val alc by controller.lastAlc.observeAsState(0) - val swr by controller.lastSwr.observeAsState(0) - val hasData by controller.meterDataReceived.observeAsState(false) + val available = rememberRigMeterSamples(mainViewModel) + val enabled = + enabledMeterTypes( + swr = GeneralVariables.meterShowSwr, + alc = GeneralVariables.meterShowAlc, + power = GeneralVariables.meterShowPower, + smeter = GeneralVariables.meterShowSMeter, + voltage = GeneralVariables.meterShowVoltage, + temp = GeneralVariables.meterShowTemp, + ) + val visibleSamples = visibleMeters(available, enabled) + val freshness = meterFreshness(isTransmitting, hasData = available.isNotEmpty()) MetersTopSheet(visible = visible, onDismiss = onDismiss) { - val freshness = meterFreshness(isTransmitting, hasData) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 14.dp, bottom = 6.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 14.dp, bottom = 6.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -99,44 +122,45 @@ fun MetersSheet( Spacer(modifier = Modifier.height(16.dp)) - if (freshness == MeterFreshness.NONE) { - Text( - text = stringResource(R.string.meters_no_data), - color = TextMuted, - fontSize = 11.sp, - fontFamily = GeistMonoFamily, - ) - Spacer(modifier = Modifier.height(8.dp)) + when { + // The rig hasn't reported any meters (unsupported, or no TX yet). + available.isEmpty() -> HintText(stringResource(R.string.meters_no_data)) + // Rig reports meters but the user has hidden all the available ones. + visibleSamples.isEmpty() -> HintText(stringResource(R.string.meters_all_hidden)) + else -> + visibleSamples.forEachIndexed { index, sample -> + if (index > 0) Spacer(modifier = Modifier.height(14.dp)) + MeterRow( + label = meterTypeLabel(sample.type), + valueText = sample.text, + fraction = sample.fraction, + zone = sample.zone, + dim = freshness == MeterFreshness.NONE, + ) + } } - - MeterRow( - label = stringResource(R.string.meters_alc), - valueText = "${alcPercent(alc)}%", - fraction = meterBarFraction(alc), - zone = alcZone(alc, GeneralVariables.alcTargetLow, GeneralVariables.alcTargetHigh), - dim = freshness == MeterFreshness.NONE, - ) - - Spacer(modifier = Modifier.height(14.dp)) - - MeterRow( - label = stringResource(R.string.meters_swr), - valueText = MeterProtectionController.normalizedSwrToRatio(swr), - fraction = meterBarFraction(swr), - zone = swrZone(swr, GeneralVariables.swrHaltThreshold), - dim = freshness == MeterFreshness.NONE, - ) } } } +@Composable +private fun HintText(text: String) { + Text( + text = text, + color = TextMuted, + fontSize = 11.sp, + fontFamily = GeistMonoFamily, + ) +} + @Composable private fun FreshnessBadge(freshness: MeterFreshness) { - val (text, color) = when (freshness) { - MeterFreshness.LIVE -> stringResource(R.string.meters_live) to StatusConfirmed - MeterFreshness.LAST_TX -> stringResource(R.string.meters_last_tx) to TextMuted - MeterFreshness.NONE -> return - } + val (text, color) = + when (freshness) { + MeterFreshness.LIVE -> stringResource(R.string.meters_live) to StatusConfirmed + MeterFreshness.LAST_TX -> stringResource(R.string.meters_last_tx) to TextMuted + MeterFreshness.NONE -> return + } Text( text = text, color = color, @@ -144,10 +168,11 @@ private fun FreshnessBadge(freshness: MeterFreshness) { fontWeight = FontWeight.Bold, fontFamily = GeistMonoFamily, letterSpacing = 0.10.sp, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(color.copy(alpha = 0.12f)) - .padding(horizontal = 6.dp, vertical = 2.dp), + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color.copy(alpha = 0.12f)) + .padding(horizontal = 6.dp, vertical = 2.dp), ) } @@ -186,18 +211,20 @@ private fun MeterRow( Spacer(modifier = Modifier.height(6.dp)) // Bar track + fill Box( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(99.dp)) - .background(BgSurface3), - ) { - Box( - modifier = Modifier - .fillMaxWidth(fraction.coerceIn(0f, 1f)) + modifier = + Modifier + .fillMaxWidth() .height(8.dp) .clip(RoundedCornerShape(99.dp)) - .background(barColor), + .background(BgSurface3), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(fraction.coerceIn(0f, 1f)) + .height(8.dp) + .clip(RoundedCornerShape(99.dp)) + .background(barColor), ) } } @@ -238,13 +265,14 @@ private fun MetersTopSheet( AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { Box( - modifier = Modifier - .fillMaxSize() - .background(Color(0xB805080E)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { onDismiss() }, + modifier = + Modifier + .fillMaxSize() + .background(Color(0xB805080E)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, ) } @@ -259,50 +287,53 @@ private fun MetersTopSheet( contentAlignment = Alignment.TopCenter, ) { Column( - modifier = Modifier - .fillMaxWidth() - .offset { IntOffset(0, animatedOffset.toInt()) } - .onSizeChanged { sheetHeightPx = it.height.toFloat() } - .clip(RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp)) - .background(BgSurface2) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { /* consume click */ }, + modifier = + Modifier + .fillMaxWidth() + .offset { IntOffset(0, animatedOffset.toInt()) } + .onSizeChanged { sheetHeightPx = it.height.toFloat() } + .clip(RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp)) + .background(BgSurface2) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { /* consume click */ }, horizontalAlignment = Alignment.CenterHorizontally, ) { content() // Drag handle at the BOTTOM edge — a drag UP dismisses the sheet. Box( - modifier = Modifier - .padding(top = 4.dp, bottom = 10.dp) - .width(72.dp) - .height(20.dp) - .pointerInput(Unit) { - detectVerticalDragGestures( - onDragEnd = { - if (dragOffset < -dismissThresholdPx) { - onDismiss() - } else { - dragOffset = 0f - } - }, - onDragCancel = { dragOffset = 0f }, - onVerticalDrag = { _, dy -> - val maxUp = if (sheetHeightPx > 0f) sheetHeightPx else Float.MAX_VALUE - dragOffset = (dragOffset + dy).coerceIn(-maxUp, 0f) - }, - ) - }, + modifier = + Modifier + .padding(top = 4.dp, bottom = 10.dp) + .width(72.dp) + .height(20.dp) + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragEnd = { + if (dragOffset < -dismissThresholdPx) { + onDismiss() + } else { + dragOffset = 0f + } + }, + onDragCancel = { dragOffset = 0f }, + onVerticalDrag = { _, dy -> + val maxUp = if (sheetHeightPx > 0f) sheetHeightPx else Float.MAX_VALUE + dragOffset = (dragOffset + dy).coerceIn(-maxUp, 0f) + }, + ) + }, contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier - .width(36.dp) - .height(4.dp) - .clip(RoundedCornerShape(99.dp)) - .background(Color(0x6694A3B8)), + modifier = + Modifier + .width(36.dp) + .height(4.dp) + .clip(RoundedCornerShape(99.dp)) + .background(Color(0x6694A3B8)), ) } } @@ -327,19 +358,20 @@ fun TopEdgeMetersTrigger( val openThresholdPx = with(density) { 36.dp.toPx() } var totalDy by remember { mutableFloatStateOf(0f) } Box( - modifier = modifier - .fillMaxWidth() - .height(24.dp) - .pointerInput(Unit) { - detectVerticalDragGestures( - onDragStart = { totalDy = 0f }, - onDragEnd = { - if (shouldOpenFromEdgeDrag(totalDy, openThresholdPx)) onOpen() - totalDy = 0f - }, - onDragCancel = { totalDy = 0f }, - onVerticalDrag = { _, dy -> totalDy += dy }, - ) - }, + modifier = + modifier + .fillMaxWidth() + .height(24.dp) + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragStart = { totalDy = 0f }, + onDragEnd = { + if (shouldOpenFromEdgeDrag(totalDy, openThresholdPx)) onOpen() + totalDy = 0f + }, + onDragCancel = { totalDy = 0f }, + onVerticalDrag = { _, dy -> totalDy += dy }, + ) + }, ) } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSource.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSource.kt new file mode 100644 index 000000000..380e8aafd --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSource.kt @@ -0,0 +1,85 @@ +package radio.ks3ckc.ft8af.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.lifecycle.MutableLiveData +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.MainViewModel +import com.k1af.ft8af.connector.FlexConnector +import com.k1af.ft8af.connector.X6100Connector +import com.k1af.ft8af.flex.FlexMeterList +import com.k1af.ft8af.x6100.X6100Meters + +/** + * Resolves the meters the *connected rig* currently reports, as a list of + * [MeterSample] in display order. This is the "adapt per rig" data source: it + * observes whichever meter stream the active rig actually produces — + * + * - **FlexRadio (network)**: the UDP meter stream in [FlexConnector.mutableMeterList] + * (SWR ratio, ALC dB, power W, S-meter dBm, PA temp °C). + * - **Xiegu (network)**: [com.k1af.ft8af.x6100.X6100Radio.mutableMeters] + * (SWR, ALC, power, S-meter, voltage). + * - **Serial CAT rigs** (Yaesu/Kenwood/Icom/Elecraft/serial Xiegu): the + * normalized ALC/SWR fed to [com.k1af.ft8af.ft8transmit.MeterProtectionController]. + * + * The list is NOT filtered by the user's enabled-meters setting — that's the + * HUD's job via [visibleMeters]. Returning the rig's full available set lets the + * HUD tell "rig reports nothing" (empty) apart from "user hid everything". + */ +@Composable +fun rememberRigMeterSamples(mainViewModel: MainViewModel): List { + val isFlex by mainViewModel.mutableIsFlexRadio.observeAsState(false) + val isXiegu by mainViewModel.mutableIsXieguRadio.observeAsState(false) + return when { + isFlex -> flexMeterSamples(mainViewModel) + isXiegu -> xieguMeterSamples(mainViewModel) + else -> serialMeterSamples(mainViewModel) + } +} + +@Composable +private fun flexMeterSamples(mainViewModel: MainViewModel): List { + // observeAsState / remember are called unconditionally (a dummy LiveData backs + // the null-connector case) so composition order stays stable across recompositions. + val empty = remember { MutableLiveData() } + val connector = mainViewModel.baseRig?.connector as? FlexConnector + val meters by (connector?.mutableMeterList ?: empty).observeAsState() + val m = meters ?: return emptyList() + return listOf( + swrSampleFromRatio(m.swrVal), + alcSampleFlexDb(m.alcVal), + powerSample(m.pwrVal), + sMeterSampleDbm(m.sMeterVal), + tempSample(m.tempCVal), + ) +} + +@Composable +private fun xieguMeterSamples(mainViewModel: MainViewModel): List { + val empty = remember { MutableLiveData() } + val radio = (mainViewModel.baseRig?.connector as? X6100Connector)?.xieguRadio + val meters by (radio?.mutableMeters ?: empty).observeAsState() + val m = meters ?: return emptyList() + return listOf( + swrSampleFromRatio(m.swr), + alcSampleXiegu(m.alc), + powerSample(m.power), + sMeterSampleDbm(X6100Meters.getMeter_dBm(m.sMeter)), + voltSample(m.volt), + ) +} + +@Composable +private fun serialMeterSamples(mainViewModel: MainViewModel): List { + val controller = mainViewModel.meterProtectionController + val alc by controller.lastAlc.observeAsState(0) + val swr by controller.lastSwr.observeAsState(0) + val hasData by controller.meterDataReceived.observeAsState(false) + if (!hasData) return emptyList() + return listOf( + swrSampleFromNormalized(swr), + alcSampleSerial(alc, GeneralVariables.alcTargetLow, GeneralVariables.alcTargetHigh), + ) +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/MetersSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/MetersSettings.kt new file mode 100644 index 000000000..f79c77fd1 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/MetersSettings.kt @@ -0,0 +1,133 @@ +package radio.ks3ckc.ft8af.ui.settings + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.MainViewModel +import com.k1af.ft8af.R +import radio.ks3ckc.ft8af.theme.* +import radio.ks3ckc.ft8af.ui.components.GlassCard +import radio.ks3ckc.ft8af.ui.components.SettingsRow + +/** + * Meters HUD settings — toggles which meters the pull-down HUD shows. SWR and + * ALC are universal across CAT rigs and on by default; power / S-meter / voltage + * / temperature are only reported by some rigs (e.g. FlexRadio/Xiegu network) and + * are off by default. The HUD additionally hides any enabled meter the connected + * rig doesn't actually report ("adapt per rig"). + */ +@Composable +fun MetersSettings( + mainViewModel: MainViewModel, + onBack: () -> Unit, +) { + SettingsDetailScaffold( + title = stringResource(R.string.settings_cat_meters), + onBack = onBack, + ) { + SettingsSection(title = stringResource(R.string.settings_meters_universal_section)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + MeterToggleRow( + mainViewModel = mainViewModel, + label = stringResource(R.string.meters_swr), + description = stringResource(R.string.settings_meter_swr_desc), + configKey = "meterShowSwr", + initial = GeneralVariables.meterShowSwr, + apply = { GeneralVariables.meterShowSwr = it }, + ) + SectionDivider() + MeterToggleRow( + mainViewModel = mainViewModel, + label = stringResource(R.string.meters_alc), + description = stringResource(R.string.settings_meter_alc_desc), + configKey = "meterShowAlc", + initial = GeneralVariables.meterShowAlc, + apply = { GeneralVariables.meterShowAlc = it }, + ) + } + } + + SettingsSection(title = stringResource(R.string.settings_meters_rig_section)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + MeterToggleRow( + mainViewModel = mainViewModel, + label = stringResource(R.string.meters_power), + description = stringResource(R.string.settings_meter_power_desc), + configKey = "meterShowPower", + initial = GeneralVariables.meterShowPower, + apply = { GeneralVariables.meterShowPower = it }, + ) + SectionDivider() + MeterToggleRow( + mainViewModel = mainViewModel, + label = stringResource(R.string.meters_smeter), + description = stringResource(R.string.settings_meter_smeter_desc), + configKey = "meterShowSMeter", + initial = GeneralVariables.meterShowSMeter, + apply = { GeneralVariables.meterShowSMeter = it }, + ) + SectionDivider() + MeterToggleRow( + mainViewModel = mainViewModel, + label = stringResource(R.string.meters_voltage), + description = stringResource(R.string.settings_meter_voltage_desc), + configKey = "meterShowVoltage", + initial = GeneralVariables.meterShowVoltage, + apply = { GeneralVariables.meterShowVoltage = it }, + ) + SectionDivider() + MeterToggleRow( + mainViewModel = mainViewModel, + label = stringResource(R.string.meters_temp), + description = stringResource(R.string.settings_meter_temp_desc), + configKey = "meterShowTemp", + initial = GeneralVariables.meterShowTemp, + apply = { GeneralVariables.meterShowTemp = it }, + ) + } + } + + Text( + text = stringResource(R.string.settings_meters_footnote), + color = TextMuted, + fontSize = 13.sp, + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp), + ) + } +} + +/** + * A single meter on/off row. Mirrors the change into [GeneralVariables] (via + * [apply], so the live HUD picks it up) and persists it under [configKey]. + */ +@Composable +private fun MeterToggleRow( + mainViewModel: MainViewModel, + label: String, + description: String, + configKey: String, + initial: Boolean, + apply: (Boolean) -> Unit, +) { + var enabled by remember { mutableStateOf(initial) } + SettingsRow( + label = label, + description = description, + toggle = enabled, + onToggleChange = { checked -> + enabled = checked + apply(checked) + mainViewModel.databaseOpr.writeConfig(configKey, if (checked) "1" else "0", null) + }, + ) +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt index aec1fbb58..14a9555f2 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt @@ -66,6 +66,7 @@ import radio.ks3ckc.ft8af.ui.components.TopBar private enum class SettingsCategory { RADIO_AUDIO, TRANSMISSION, + METERS, TIME_SYNC, DECODE_FILTERS, LOGGING, @@ -135,6 +136,8 @@ fun SettingsScreen( RadioAudioSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.TRANSMISSION -> TransmissionSettings(mainViewModel, onBack = { currentCategory = null }) + SettingsCategory.METERS -> + MetersSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.TIME_SYNC -> TimeSyncSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.DECODE_FILTERS -> @@ -301,6 +304,13 @@ private fun SettingsLanding( onClick = { onOpenCategory(SettingsCategory.TRANSMISSION) }, ) SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_cat_meters), + description = stringResource(R.string.settings_cat_meters_desc), + showChevron = true, + onClick = { onOpenCategory(SettingsCategory.METERS) }, + ) + SectionDivider() SettingsRow( label = stringResource(R.string.settings_cat_time_sync), description = stringResource(R.string.settings_cat_time_sync_desc), diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 84b12bd4a..b3e353d8e 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -134,13 +134,31 @@ DISMISS TX blocked — SWR lockout active. Dismiss the lockout banner first. - + METERS ALC SWR + PWR + S + VOLTS + TEMP LIVE LAST TX No meter data yet — read back from the rig during transmit. + All available meters are hidden. Enable them in Settings → Meters. + + + Meters + Choose which meters the pull-down HUD shows + Standard meters + Extended meters (rig dependent) + Antenna SWR during transmit + ALC / drive level during transmit + RF output power (rigs that report it) + Receive signal strength (rigs that report it) + Supply voltage (rigs that report it) + PA temperature (rigs that report it) + Only meters your connected rig actually reports are shown. SWR and ALC are available on most CAT rigs; the extended meters need a rig that streams them (e.g. FlexRadio or Xiegu over the network). Calling CQ diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt index fc1e73507..b91103639 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt @@ -10,7 +10,6 @@ import org.junit.Test * plain JUnit with no Robolectric runner. */ class MetersDisplayTest { - // ---- meterBarFraction ---- @Test @@ -142,4 +141,168 @@ class MetersDisplayTest { // An upward drag (negative dy) must never open. assertThat(shouldOpenFromEdgeDrag(totalDy = -50f, thresholdPx = 36f)).isFalse() } + + // ---- swrSampleFromRatio ---- + + @Test + fun swrSample_flatMatchIsGood() { + val s = swrSampleFromRatio(1.0f) + assertThat(s.type).isEqualTo(MeterType.SWR) + assertThat(s.zone).isEqualTo(MeterZone.GOOD) + assertThat(s.fraction).isEqualTo(0f) + assertThat(s.text).isEqualTo("1.0:1") + } + + @Test + fun swrSample_belowOneClampsToOne() { + // A nonsense sub-1.0 ratio is treated as 1.0, not a negative bar. + val s = swrSampleFromRatio(0.5f) + assertThat(s.fraction).isEqualTo(0f) + assertThat(s.text).isEqualTo("1.0:1") + } + + @Test + fun swrSample_zonesByRatio() { + assertThat(swrSampleFromRatio(1.4f).zone).isEqualTo(MeterZone.GOOD) + assertThat(swrSampleFromRatio(2.0f).zone).isEqualTo(MeterZone.CAUTION) + assertThat(swrSampleFromRatio(3.0f).zone).isEqualTo(MeterZone.DANGER) + } + + @Test + fun swrSample_barFullAtThreeToOne() { + assertThat(swrSampleFromRatio(3.0f).fraction).isEqualTo(1f) + assertThat(swrSampleFromRatio(2.0f).fraction).isWithin(0.001f).of(0.5f) + } + + @Test + fun swrSample_veryHighShowsInfinity() { + assertThat(swrSampleFromRatio(12f).text).isEqualTo("∞") + assertThat(swrSampleFromRatio(12f).fraction).isEqualTo(1f) + } + + // ---- swrRatioFromNormalized (numeric inverse of swrRatioToNormalized) ---- + + @Test + fun swrRatioFromNormalized_matchesBreakpoints() { + // Same breakpoints as MeterProtectionController.normalizedSwrToRatio. + assertThat(swrRatioFromNormalized(0)).isEqualTo(1.0f) + assertThat(swrRatioFromNormalized(60)).isWithin(0.001f).of(1.5f) + assertThat(swrRatioFromNormalized(120)).isWithin(0.001f).of(3.0f) + assertThat(swrRatioFromNormalized(200)).isWithin(0.001f).of(7.0f) + } + + @Test + fun swrSampleFromNormalized_threadsThroughRatio() { + // normalized 120 ≈ 3.0:1 → DANGER, full bar. + val s = swrSampleFromNormalized(120) + assertThat(s.zone).isEqualTo(MeterZone.DANGER) + assertThat(s.text).isEqualTo("3.0:1") + } + + // ---- alc samples ---- + + @Test + fun alcSampleSerial_usesTargetWindow() { + val s = alcSampleSerial(90, targetLow = 60, targetHigh = 120) + assertThat(s.type).isEqualTo(MeterType.ALC) + assertThat(s.zone).isEqualTo(MeterZone.GOOD) + assertThat(s.text).isEqualTo("35%") // 90/255*100 = 35.3 -> 35 + } + + @Test + fun alcSampleFlexDb_lowIsGoodHighIsDanger() { + // Flex ALC: low dB = no compression = good; climbing past 0 = overdrive. + assertThat(alcSampleFlexDb(-120f).zone).isEqualTo(MeterZone.GOOD) + assertThat(alcSampleFlexDb(5f).zone).isEqualTo(MeterZone.CAUTION) + assertThat(alcSampleFlexDb(15f).zone).isEqualTo(MeterZone.DANGER) + // -150..20 dB maps onto 0..1; -150 floors the bar. + assertThat(alcSampleFlexDb(-150f).fraction).isEqualTo(0f) + assertThat(alcSampleFlexDb(20f).fraction).isEqualTo(1f) + assertThat(alcSampleFlexDb(-30f).text).isEqualTo("-30 dB") + } + + @Test + fun alcSampleXiegu_scales0to120() { + assertThat(alcSampleXiegu(0f).zone).isEqualTo(MeterZone.GOOD) + assertThat(alcSampleXiegu(60f).zone).isEqualTo(MeterZone.CAUTION) + assertThat(alcSampleXiegu(100f).zone).isEqualTo(MeterZone.DANGER) + assertThat(alcSampleXiegu(60f).fraction).isWithin(0.001f).of(0.5f) + } + + // ---- informational meters ---- + + @Test + fun powerSample_isNeutralAndRelativeToMax() { + val s = powerSample(25f, maxWatts = 100f) + assertThat(s.type).isEqualTo(MeterType.POWER) + assertThat(s.zone).isEqualTo(MeterZone.NEUTRAL) + assertThat(s.fraction).isWithin(0.001f).of(0.25f) + assertThat(s.text).isEqualTo("25 W") + } + + @Test + fun powerSample_guardsZeroMax() { + assertThat(powerSample(25f, maxWatts = 0f).fraction).isEqualTo(0f) + } + + @Test + fun sMeterSample_isNeutral() { + val s = sMeterSampleDbm(-75f) + assertThat(s.zone).isEqualTo(MeterZone.NEUTRAL) + assertThat(s.text).isEqualTo("-75 dBm") + assertThat(s.fraction).isWithin(0.001f).of((-75f + 120f) / 90f) + } + + @Test + fun voltSample_greenInBatteryBandRedAtExtremes() { + assertThat(voltSample(13.8f).zone).isEqualTo(MeterZone.GOOD) + assertThat(voltSample(11.0f).zone).isEqualTo(MeterZone.CAUTION) + assertThat(voltSample(9.0f).zone).isEqualTo(MeterZone.DANGER) + assertThat(voltSample(13.8f).text).isEqualTo("13.8 V") + } + + @Test + fun tempSample_zonesByCelsius() { + assertThat(tempSample(40f).zone).isEqualTo(MeterZone.GOOD) + assertThat(tempSample(60f).zone).isEqualTo(MeterZone.CAUTION) + assertThat(tempSample(75f).zone).isEqualTo(MeterZone.DANGER) + assertThat(tempSample(50f).text).isEqualTo("50°C") + } + + // ---- enabled / visible ---- + + @Test + fun enabledMeterTypes_defaultsAndOrder() { + val set = + enabledMeterTypes( + swr = true, + alc = true, + power = false, + smeter = false, + voltage = false, + temp = false, + ) + assertThat(set).containsExactly(MeterType.SWR, MeterType.ALC).inOrder() + } + + @Test + fun visibleMeters_intersectsAvailableWithEnabledInOrder() { + // Rig reports SWR, ALC, POWER (in a scrambled order); user enabled SWR+POWER. + val available = + listOf( + powerSample(10f), + swrSampleFromRatio(1.2f), + alcSampleFlexDb(-100f), + ) + val enabled = setOf(MeterType.SWR, MeterType.POWER) + val visible = visibleMeters(available, enabled) + // ALC dropped (not enabled); result in display (ordinal) order: SWR then POWER. + assertThat(visible.map { it.type }) + .containsExactly(MeterType.SWR, MeterType.POWER).inOrder() + } + + @Test + fun visibleMeters_emptyWhenRigReportsNothing() { + assertThat(visibleMeters(emptyList(), setOf(MeterType.SWR, MeterType.ALC))).isEmpty() + } } From a67d2c22d8dbaae571e7faed161d1c5933a7a02c Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 13:41:58 -0500 Subject: [PATCH 3/8] Meters HUD: in-app TX power control + PWR meter on by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Network rigs set TX power via flexMaxRfPower (default 10 W), pushed to the rig at connect — but the only screen that ever showed or changed it (FlexRadioInfoFragment's seekbar) is part of the legacy Java UI the Compose app no longer reaches, so there was no way to see or adjust it. Add an adjustable TX-power row at the top of the meters HUD, shown when the connected rig supports it (FlexRadio; Xiegu uses a different scale and is left for later). The label tracks the slider live; the value is pushed to the radio (FlexConnector.setMaxRfPower → RFPOWER) and persisted to config on release, not on every drag tick, to avoid spamming the network. Default the PWR (output watts) meter on, so set-power and live output read together on the rigs that report power; serial rigs don't report it, so "adapt per rig" still hides it there. The 0..100 W clamp is a pure, unit-tested helper. Co-Authored-By: Claude Opus 4.8 --- .../java/com/k1af/ft8af/GeneralVariables.java | 5 +- .../ft8af/ui/components/MetersDisplay.kt | 6 +++ .../ks3ckc/ft8af/ui/components/MetersSheet.kt | 53 +++++++++++++++++++ .../ft8af/ui/components/MetersSource.kt | 27 ++++++++++ .../src/main/res/values/strings_compose.xml | 1 + .../ft8af/ui/components/MetersDisplayTest.kt | 12 +++++ 6 files changed, 103 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 12787a6b6..5127d2482 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -373,7 +373,10 @@ public static Context getMainContext() { // rigs, e.g. FlexRadio/Xiegu network, report them) default off. public static boolean meterShowSwr = true; public static boolean meterShowAlc = true; - public static boolean meterShowPower = false; + // Power defaults on: only the network rigs (Flex/Xiegu) report output watts, + // and on those it's the natural companion to the in-HUD TX-power control. + // Serial rigs don't report it, so "adapt per rig" hides it there anyway. + public static boolean meterShowPower = true; public static boolean meterShowSMeter = false; public static boolean meterShowVoltage = false; public static boolean meterShowTemp = false; diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt index 0be2af3ba..98ed0f9f8 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt @@ -264,3 +264,9 @@ internal fun visibleMeters( available: List, enabled: Set, ): List = available.filter { it.type in enabled }.sortedBy { it.type.ordinal } + +/** Max settable TX power; the HUD slider uses a 0..[MAX_TX_POWER_WATTS] W range. */ +const val MAX_TX_POWER_WATTS: Int = 100 + +/** Clamp a requested TX power to the valid 0..[MAX_TX_POWER_WATTS] W range. */ +internal fun clampTxPowerWatts(watts: Int): Int = watts.coerceIn(0, MAX_TX_POWER_WATTS) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt index ac67f7bdf..10457b119 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -120,6 +121,15 @@ fun MetersSheet( FreshnessBadge(freshness) } + // TX power control — only on rigs whose power is settable from the app + // (FlexRadio). Shown above the meters so set-power and the live PWR + // meter read together. Independent of meter availability: you set + // power before keying up. + if (rememberRigSupportsTxPower(mainViewModel)) { + Spacer(modifier = Modifier.height(16.dp)) + TxPowerControl(mainViewModel) + } + Spacer(modifier = Modifier.height(16.dp)) when { @@ -153,6 +163,49 @@ private fun HintText(text: String) { ) } +/** + * Adjustable TX-power row (network rigs). The label tracks the slider live; the + * new value is pushed to the rig and persisted on release ([setRigTxPowerWatts]), + * not on every drag tick, to avoid spamming RFPOWER over the network. + */ +@Composable +private fun TxPowerControl(mainViewModel: MainViewModel) { + var watts by remember { mutableIntStateOf(currentTxPowerWatts()) } + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.meters_tx_power), + color = TextMuted, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.08.sp, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "$watts W", + color = Accent, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + IntSlider( + value = watts, + onValueChange = { watts = it }, + onValueChangeFinished = { setRigTxPowerWatts(mainViewModel, watts) }, + valueRange = 0f..MAX_TX_POWER_WATTS.toFloat(), + thumbColor = Accent, + activeTrackColor = Accent, + modifier = Modifier.fillMaxWidth(), + ) + } +} + @Composable private fun FreshnessBadge(freshness: MeterFreshness) { val (text, color) = diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSource.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSource.kt index 380e8aafd..8cf981328 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSource.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSource.kt @@ -83,3 +83,30 @@ private fun serialMeterSamples(mainViewModel: MainViewModel): List alcSampleSerial(alc, GeneralVariables.alcTargetLow, GeneralVariables.alcTargetHigh), ) } + +/** + * Whether the connected rig supports setting TX power from the app. Currently + * FlexRadio (network), whose [FlexConnector.setMaxRfPower] maps cleanly to 0-100 W. + * Xiegu uses a different scale (setMaxTXPower / commandSetTxPower) and is left out + * for now — see [setRigTxPowerWatts]. + */ +@Composable +fun rememberRigSupportsTxPower(mainViewModel: MainViewModel): Boolean { + val isFlex by mainViewModel.mutableIsFlexRadio.observeAsState(false) + return isFlex && mainViewModel.baseRig?.connector is FlexConnector +} + +/** The currently-set TX power in watts (persisted as flexMaxRfPower, default 10 W). */ +fun currentTxPowerWatts(): Int = GeneralVariables.flexMaxRfPower + +/** + * Apply a new TX power to the rig and persist it. [FlexConnector.setMaxRfPower] + * pushes RFPOWER to the radio and updates GeneralVariables.flexMaxRfPower; we also + * write it to config so it survives a restart (the old FlexRadioInfoFragment did + * this on its seekbar, but that screen is unreachable from the Compose UI). + */ +fun setRigTxPowerWatts(mainViewModel: MainViewModel, watts: Int) { + val w = clampTxPowerWatts(watts) + (mainViewModel.baseRig?.connector as? FlexConnector)?.setMaxRfPower(w) + mainViewModel.databaseOpr.writeConfig("flexMaxRfPower", w.toString(), null) +} diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index b3e353d8e..ef7f809e5 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -142,6 +142,7 @@ S VOLTS TEMP + TX POWER LIVE LAST TX No meter data yet — read back from the rig during transmit. diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt index b91103639..74da46837 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt @@ -305,4 +305,16 @@ class MetersDisplayTest { fun visibleMeters_emptyWhenRigReportsNothing() { assertThat(visibleMeters(emptyList(), setOf(MeterType.SWR, MeterType.ALC))).isEmpty() } + + // ---- clampTxPowerWatts ---- + + @Test + fun txPower_clampsToValidRange() { + assertThat(clampTxPowerWatts(25)).isEqualTo(25) + assertThat(clampTxPowerWatts(0)).isEqualTo(0) + assertThat(clampTxPowerWatts(MAX_TX_POWER_WATTS)).isEqualTo(MAX_TX_POWER_WATTS) + // Out of range is pulled back into [0, MAX]. + assertThat(clampTxPowerWatts(-5)).isEqualTo(0) + assertThat(clampTxPowerWatts(250)).isEqualTo(MAX_TX_POWER_WATTS) + } } From 2479f7ea7c563930edb19edb7fd7931308ea2cb5 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 13:58:35 -0500 Subject: [PATCH 4/8] Flex network: auto-discover and connect, no IP typing required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting Network + FlexRadio dropped the user on a manual "IP Address" field. Discovery code existed (FlexRadioFactory listens for the Flex UDP broadcast on 4992) and the picker even rendered found radios — but the list was always empty, so typing the IP was the only thing that worked. Root cause: Android's Wi-Fi chip filters out incoming broadcast/multicast packets unless an app holds a WifiManager.MulticastLock, and we never took one (nor held the CHANGE_WIFI_MULTICAST_STATE permission needed for it). So the discovery socket received nothing. - Add CHANGE_WIFI_MULTICAST_STATE and acquire a MulticastLock while the Flex picker is open (released on dismiss, so no battery cost otherwise). - Lead the picker with discovery: a "Searching…" state, the found radios as the primary list, and manual IP entry collapsed behind a link as a fallback (different subnet / VPN / broadcast blocked). - Auto-connect when discovery finds exactly one radio and the user hasn't started typing — connect to your Flex with zero input. The decision (shouldAutoConnectFlex) is a pure, unit-tested one-shot. Co-Authored-By: Claude Opus 4.8 --- ft8af/app/src/main/AndroidManifest.xml | 4 + .../ft8af/ui/settings/ConnectionDialogs.kt | 158 ++++++++++++------ .../ft8af/ui/settings/FlexDiscoveryLogic.kt | 17 ++ .../src/main/res/values/strings_compose.xml | 4 + .../ui/settings/FlexDiscoveryLogicTest.kt | 49 ++++++ 5 files changed, 183 insertions(+), 49 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt diff --git a/ft8af/app/src/main/AndroidManifest.xml b/ft8af/app/src/main/AndroidManifest.xml index 9a6ff9e1d..872a3e4c1 100644 --- a/ft8af/app/src/main/AndroidManifest.xml +++ b/ft8af/app/src/main/AndroidManifest.xml @@ -9,6 +9,10 @@ + + diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt index 9244175eb..f7c7d6f30 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -29,6 +30,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -37,6 +39,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +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 @@ -249,12 +252,31 @@ fun FlexRadioPickerDialog( onDismiss: () -> Unit, ) { var ipText by remember { mutableStateOf("") } + var showManualEntry by remember { mutableStateOf(false) } + var autoTriggered by remember { mutableStateOf(false) } val discoveredRadios = remember { mutableStateListOf() } + val context = LocalContext.current - // Subscribe to FlexRadioFactory discovery events + fun connect(radio: FlexRadio) { + mainViewModel.connectFlexRadioRig(GeneralVariables.getMainContext(), radio) + onDismiss() + } + + // Start discovery while the dialog is open. Acquiring a MulticastLock is the + // load-bearing part: the FlexRadioFactory UDP listener is already running, but + // Android's Wi-Fi chip drops incoming broadcast/multicast packets (which is what + // Flex discovery is) unless an app holds this lock — without it the discovered + // list stays empty and the user is forced to type an IP. Lock is held only while + // the picker is open, so it doesn't drain battery the rest of the time. DisposableEffect(Unit) { + val wifi = context.applicationContext + .getSystemService(android.content.Context.WIFI_SERVICE) as android.net.wifi.WifiManager + val multicastLock = wifi.createMulticastLock("ft8af-flex-discovery").apply { + setReferenceCounted(true) + runCatching { acquire() } + } + val factory = FlexRadioFactory.getInstance() - // Seed with any already-discovered radios discoveredRadios.clear() discoveredRadios.addAll(factory.flexRadios) @@ -269,7 +291,26 @@ fun FlexRadioPickerDialog( } } factory.setOnFlexRadioEvents(listener) - onDispose { factory.setOnFlexRadioEvents(null) } + onDispose { + factory.setOnFlexRadioEvents(null) + if (multicastLock.isHeld) runCatching { multicastLock.release() } + } + } + + // Auto-connect the moment a single radio is found and the user hasn't started + // typing — the "just connect to my Flex" path. One-shot via autoTriggered. + LaunchedEffect(discoveredRadios.size, ipText) { + if (shouldAutoConnectFlex(discoveredRadios.size, ipText.isNotBlank(), autoTriggered)) { + autoTriggered = true + val radio = discoveredRadios.first() + ToastMessage.show( + String.format( + GeneralVariables.getStringFromResource(R.string.select_flex_device), + radio.model, + ), + ) + connect(radio) + } } Dialog(onDismissRequest = onDismiss) { @@ -290,50 +331,25 @@ fun FlexRadioPickerDialog( Spacer(modifier = Modifier.height(16.dp)) - // Manual IP entry - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - value = ipText, - onValueChange = { ipText = it }, - label = { Text("IP Address") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - colors = DialogFieldColors(), - modifier = Modifier.weight(1f), - ) - DialogAccentButton( - label = stringResource(R.string.icom_login_button), - enabled = ipText.isNotBlank(), - modifier = Modifier.width(80.dp), + if (discoveredRadios.isEmpty()) { + // Searching state — discovery is active; no manual typing required. + Row( + modifier = Modifier.padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - ToastMessage.show( - String.format( - GeneralVariables.getStringFromResource(R.string.connect_flex_ip), - ipText, - ), + CircularProgressIndicator( + color = Accent, + strokeWidth = 2.dp, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(R.string.flex_searching), + color = TextMuted, + fontSize = 14.sp, ) - val flexRadio = FlexRadio() - flexRadio.ip = ipText.trim() - flexRadio.model = "FlexRadio" - mainViewModel.connectFlexRadioRig(GeneralVariables.getMainContext(), flexRadio) - onDismiss() } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Discovered radios - if (discoveredRadios.isNotEmpty()) { - HorizontalDivider(color = Border, modifier = Modifier.padding(horizontal = 24.dp)) - - Spacer(modifier = Modifier.height(8.dp)) - + } else { LazyColumn( modifier = Modifier .fillMaxWidth() @@ -350,11 +366,7 @@ fun FlexRadioPickerDialog( radio.model, ), ) - mainViewModel.connectFlexRadioRig( - GeneralVariables.getMainContext(), - radio, - ) - onDismiss() + connect(radio) } .padding(horizontal = 24.dp, vertical = 10.dp), ) { @@ -374,6 +386,54 @@ fun FlexRadioPickerDialog( } } + Spacer(modifier = Modifier.height(12.dp)) + + // Manual IP entry — a fallback for when discovery can't reach the rig + // (different subnet, VPN, broadcast blocked). Collapsed by default so + // discovery is the obvious path. + if (showManualEntry) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = ipText, + onValueChange = { ipText = it }, + label = { Text("IP Address") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + colors = DialogFieldColors(), + modifier = Modifier.weight(1f), + ) + DialogAccentButton( + label = stringResource(R.string.icom_login_button), + enabled = ipText.isNotBlank(), + modifier = Modifier.width(80.dp), + ) { + ToastMessage.show( + String.format( + GeneralVariables.getStringFromResource(R.string.connect_flex_ip), + ipText, + ), + ) + val flexRadio = FlexRadio() + flexRadio.ip = ipText.trim() + flexRadio.model = "FlexRadio" + connect(flexRadio) + } + } + } else { + TextButton( + onClick = { showManualEntry = true }, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text(stringResource(R.string.flex_enter_ip_manually), color = TextMuted) + } + } + Spacer(modifier = Modifier.height(8.dp)) Row( diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt new file mode 100644 index 000000000..18e1ad81f --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt @@ -0,0 +1,17 @@ +package radio.ks3ckc.ft8af.ui.settings + +/** + * Pure decision for whether the Flex picker should auto-connect without the user + * doing anything. Extracted so the rule is unit-tested independently of the + * dialog/discovery plumbing. + * + * We auto-connect only when discovery has found exactly one radio (the common + * single-rig case — connect with zero typing), the user hasn't started entering + * an IP manually (don't hijack a deliberate manual entry), and we haven't already + * fired (one-shot, so it can't loop or fight a second discovery event). + */ +internal fun shouldAutoConnectFlex( + discoveredCount: Int, + userTypedIp: Boolean, + alreadyTriggered: Boolean, +): Boolean = discoveredCount == 1 && !userTypedIp && !alreadyTriggered diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 23145e7bd..45ff9501c 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -12,6 +12,10 @@ --> + + Searching for FlexRadio on your network… + Enter IP manually + Cancel Save diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt new file mode 100644 index 000000000..cfd9f0b1d --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt @@ -0,0 +1,49 @@ +package radio.ks3ckc.ft8af.ui.settings + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [shouldAutoConnectFlex] — the rule that decides whether the Flex + * picker connects with no user action. Pure logic, no Android/Compose types. + */ +class FlexDiscoveryLogicTest { + + @Test + fun autoConnects_whenExactlyOneFound_noTyping_notYetTriggered() { + assertThat( + shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = false, alreadyTriggered = false), + ).isTrue() + } + + @Test + fun doesNotAutoConnect_whenNothingFound() { + assertThat( + shouldAutoConnectFlex(discoveredCount = 0, userTypedIp = false, alreadyTriggered = false), + ).isFalse() + } + + @Test + fun doesNotAutoConnect_whenMultipleFound() { + // Ambiguous which one the user wants — leave it to a tap. + assertThat( + shouldAutoConnectFlex(discoveredCount = 3, userTypedIp = false, alreadyTriggered = false), + ).isFalse() + } + + @Test + fun doesNotAutoConnect_whenUserIsTypingAnIp() { + // A deliberate manual entry must not be hijacked. + assertThat( + shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = true, alreadyTriggered = false), + ).isFalse() + } + + @Test + fun doesNotAutoConnect_onceAlreadyTriggered() { + // One-shot: a later discovery event can't re-fire the auto-connect. + assertThat( + shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = false, alreadyTriggered = true), + ).isFalse() + } +} From 46c45263b2bc3f15dca9601e5b9f5648c0b86577 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 14:29:51 -0500 Subject: [PATCH 5/8] Flex discovery: stop auto-connect closing the dialog / breaking live session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report: the picker "closes the instant you open it" and the rig stops connecting. Cause: FlexRadioFactory is a singleton, so once a radio is cached, reopening the picker seeds the list with it and the auto-connect LaunchedEffect fired immediately — dismissing the dialog and, worse, reconnecting an already-connected rig, which tears down the working session. Gate auto-connect so it only fires on a genuinely fresh discovery: - startedEmpty: the picker must have opened with no cached radios, so we never auto-connect to a stale singleton entry. - rigAlreadyConnected: never reconnect over a live session. (plus the existing single-radio / not-typing / one-shot guards.) Also add debug.log diagnostics (multicastLock.held, seeded count, each radio added, and connect path) so we can confirm whether discovery actually receives the UDP broadcasts on the user's network. Co-Authored-By: Claude Opus 4.8 --- .../ft8af/ui/settings/ConnectionDialogs.kt | 35 ++++++++++--- .../ft8af/ui/settings/FlexDiscoveryLogic.kt | 23 ++++++-- .../ui/settings/FlexDiscoveryLogicTest.kt | 52 +++++++++++++------ 3 files changed, 81 insertions(+), 29 deletions(-) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt index f7c7d6f30..f0486bd23 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt @@ -256,8 +256,12 @@ fun FlexRadioPickerDialog( var autoTriggered by remember { mutableStateOf(false) } val discoveredRadios = remember { mutableStateListOf() } val context = LocalContext.current + // True only if the picker opened with NO radios cached in the factory singleton. + // Gates auto-connect to a freshly discovered radio, never a stale cached one. + val startedEmpty = remember { FlexRadioFactory.getInstance().flexRadios.isEmpty() } - fun connect(radio: FlexRadio) { + fun connect(radio: FlexRadio, how: String) { + GeneralVariables.fileLog("FlexDiscovery: connect ($how) model=${radio.model} ip=${radio.ip}") mainViewModel.connectFlexRadioRig(GeneralVariables.getMainContext(), radio) onDismiss() } @@ -279,9 +283,17 @@ fun FlexRadioPickerDialog( val factory = FlexRadioFactory.getInstance() discoveredRadios.clear() discoveredRadios.addAll(factory.flexRadios) + GeneralVariables.fileLog( + "FlexDiscovery: picker open, multicastLock.held=${multicastLock.isHeld}, " + + "seeded=${factory.flexRadios.size}, startedEmpty=$startedEmpty", + ) val listener = object : FlexRadioFactory.OnFlexRadioEvents { override fun OnFlexRadioAdded(flexRadio: FlexRadio) { + GeneralVariables.fileLog( + "FlexDiscovery: radio added model=${flexRadio.model} ip=${flexRadio.ip} " + + "(total=${factory.flexRadios.size})", + ) discoveredRadios.clear() discoveredRadios.addAll(factory.flexRadios) } @@ -297,10 +309,19 @@ fun FlexRadioPickerDialog( } } - // Auto-connect the moment a single radio is found and the user hasn't started - // typing — the "just connect to my Flex" path. One-shot via autoTriggered. + // Auto-connect the moment a single radio is freshly discovered and the user + // hasn't started typing — the "just connect to my Flex" path. Heavily gated + // (see shouldAutoConnectFlex) so it never fires on a stale cached radio or + // while a rig is already connected. One-shot via autoTriggered. LaunchedEffect(discoveredRadios.size, ipText) { - if (shouldAutoConnectFlex(discoveredRadios.size, ipText.isNotBlank(), autoTriggered)) { + if (shouldAutoConnectFlex( + discoveredCount = discoveredRadios.size, + userTypedIp = ipText.isNotBlank(), + alreadyTriggered = autoTriggered, + startedEmpty = startedEmpty, + rigAlreadyConnected = mainViewModel.isRigConnected(), + ) + ) { autoTriggered = true val radio = discoveredRadios.first() ToastMessage.show( @@ -309,7 +330,7 @@ fun FlexRadioPickerDialog( radio.model, ), ) - connect(radio) + connect(radio, "auto") } } @@ -366,7 +387,7 @@ fun FlexRadioPickerDialog( radio.model, ), ) - connect(radio) + connect(radio, "tap") } .padding(horizontal = 24.dp, vertical = 10.dp), ) { @@ -422,7 +443,7 @@ fun FlexRadioPickerDialog( val flexRadio = FlexRadio() flexRadio.ip = ipText.trim() flexRadio.model = "FlexRadio" - connect(flexRadio) + connect(flexRadio, "manual") } } } else { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt index 18e1ad81f..68a3a9310 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt @@ -5,13 +5,26 @@ package radio.ks3ckc.ft8af.ui.settings * doing anything. Extracted so the rule is unit-tested independently of the * dialog/discovery plumbing. * - * We auto-connect only when discovery has found exactly one radio (the common - * single-rig case — connect with zero typing), the user hasn't started entering - * an IP manually (don't hijack a deliberate manual entry), and we haven't already - * fired (one-shot, so it can't loop or fight a second discovery event). + * Auto-connect only when ALL hold: + * - discovery has found exactly one radio (the common single-rig case), + * - the user hasn't started entering an IP manually (don't hijack manual entry), + * - we haven't already fired (one-shot, can't loop or fight a later event), + * - the picker opened with an EMPTY list ([startedEmpty]) — so we only auto-connect + * to a freshly discovered radio, never to a stale entry cached in the + * FlexRadioFactory singleton from earlier this session (that bug made the dialog + * appear to "close the instant you open it"), + * - no rig is already connected ([rigAlreadyConnected]) — reconnecting a live + * session would tear it down and can leave it broken. */ internal fun shouldAutoConnectFlex( discoveredCount: Int, userTypedIp: Boolean, alreadyTriggered: Boolean, -): Boolean = discoveredCount == 1 && !userTypedIp && !alreadyTriggered + startedEmpty: Boolean, + rigAlreadyConnected: Boolean, +): Boolean = + discoveredCount == 1 && + !userTypedIp && + !alreadyTriggered && + startedEmpty && + !rigAlreadyConnected diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt index cfd9f0b1d..1f8652c6b 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt @@ -9,41 +9,59 @@ import org.junit.Test */ class FlexDiscoveryLogicTest { + /** All five preconditions met → auto-connect. */ + private fun autoConnect( + discoveredCount: Int = 1, + userTypedIp: Boolean = false, + alreadyTriggered: Boolean = false, + startedEmpty: Boolean = true, + rigAlreadyConnected: Boolean = false, + ) = shouldAutoConnectFlex( + discoveredCount = discoveredCount, + userTypedIp = userTypedIp, + alreadyTriggered = alreadyTriggered, + startedEmpty = startedEmpty, + rigAlreadyConnected = rigAlreadyConnected, + ) + @Test - fun autoConnects_whenExactlyOneFound_noTyping_notYetTriggered() { - assertThat( - shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = false, alreadyTriggered = false), - ).isTrue() + fun autoConnects_freshSingleDiscovery_noTyping_notConnected() { + assertThat(autoConnect()).isTrue() } @Test fun doesNotAutoConnect_whenNothingFound() { - assertThat( - shouldAutoConnectFlex(discoveredCount = 0, userTypedIp = false, alreadyTriggered = false), - ).isFalse() + assertThat(autoConnect(discoveredCount = 0)).isFalse() } @Test fun doesNotAutoConnect_whenMultipleFound() { // Ambiguous which one the user wants — leave it to a tap. - assertThat( - shouldAutoConnectFlex(discoveredCount = 3, userTypedIp = false, alreadyTriggered = false), - ).isFalse() + assertThat(autoConnect(discoveredCount = 3)).isFalse() } @Test fun doesNotAutoConnect_whenUserIsTypingAnIp() { - // A deliberate manual entry must not be hijacked. - assertThat( - shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = true, alreadyTriggered = false), - ).isFalse() + assertThat(autoConnect(userTypedIp = true)).isFalse() } @Test fun doesNotAutoConnect_onceAlreadyTriggered() { // One-shot: a later discovery event can't re-fire the auto-connect. - assertThat( - shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = false, alreadyTriggered = true), - ).isFalse() + assertThat(autoConnect(alreadyTriggered = true)).isFalse() + } + + @Test + fun doesNotAutoConnect_whenPickerOpenedWithCachedRadio() { + // The bug that made the dialog "close the instant you open it": a radio + // cached in the FlexRadioFactory singleton seeds the list, so the picker + // did NOT start empty — must not auto-connect to it. + assertThat(autoConnect(startedEmpty = false)).isFalse() + } + + @Test + fun doesNotAutoConnect_whenRigAlreadyConnected() { + // Reconnecting a live session would tear it down. + assertThat(autoConnect(rigAlreadyConnected = true)).isFalse() } } From c9cc3b463769374517286a9ab672b7900e7958df Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 14:44:58 -0500 Subject: [PATCH 6/8] CAT chip: one-tap connect to saved Flex (and remember its address) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "If my Flex settings are set, I should just click the CAT button near CQ and have it connect." Two gaps stopped that: the Flex address was never persisted, and reconnectRig() no-ops when no connector exists yet (a cold start) — so the CAT chip did nothing until you'd already connected once via the picker. - Persist the Flex address (flexLastIp) whenever we connect — discovered, tapped, or typed — and load it at startup. - Route the CAT chip tap (catTapAction): reconnect an existing connector if one's live; otherwise, on a cold start with a saved network Flex, connect straight to the remembered address; with nothing saved, point the user to setup. Pure routing (catTapAction) is unit-tested. Co-Authored-By: Claude Opus 4.8 --- .../java/com/k1af/ft8af/GeneralVariables.java | 3 ++ .../java/com/k1af/ft8af/MainViewModel.java | 6 ++++ .../com/k1af/ft8af/database/DatabaseOpr.java | 3 ++ .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 28 +++++++++++++++++- .../ft8af/ui/components/CatStatusChip.kt | 23 +++++++++++++++ .../src/main/res/values/strings_compose.xml | 1 + .../ui/components/CatStatusChipLogicTest.kt | 29 +++++++++++++++++++ 7 files changed, 92 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 401b53892..1dac86082 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -93,6 +93,9 @@ public static boolean isConfiguredUsbAudioOutput(int vid, int pid) { public static int flexMaxRfPower = 10;//Flex radio max transmit power public static int flexMaxTunePower = 10;//Flex radio max tune power + // Last FlexRadio address connected to (discovered or typed). Persisted so the + // CAT chip can reconnect on a cold start without re-opening the picker. + public static String flexLastIp = ""; // Hidden debug mode (unlocked by tapping the version 7 times in About). // When true, Settings exposes the Debug screen for log viewing/sharing. diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index 3bda46748..8870ed638 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1283,6 +1283,12 @@ public void connectFlexRadioRig(Context context, FlexRadio flexRadio) { } } GeneralVariables.controlMode = ControlMode.CAT;//network control mode + // Remember the address so the CAT chip can reconnect on a cold start + // without re-opening the picker. + if (flexRadio != null && flexRadio.getIp() != null && !flexRadio.getIp().isEmpty()) { + GeneralVariables.flexLastIp = flexRadio.getIp(); + databaseOpr.writeConfig("flexLastIp", flexRadio.getIp(), null); + } FlexConnector flexConnector = new FlexConnector(context, flexRadio, GeneralVariables.controlMode); flexConnector.setOnWaveDataReceived(new FlexConnector.OnWaveDataReceived() { @Override diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 07d846c4b..2d2517074 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2565,6 +2565,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("flexMaxTunePower")) {//Flex max tune power GeneralVariables.flexMaxTunePower = result.equals("") ? 10 : Integer.parseInt(result); } + if (name.equalsIgnoreCase("flexLastIp")) {//Last Flex address, for CAT-chip reconnect + GeneralVariables.flexLastIp = result == null ? "" : result; + } if (name.equalsIgnoreCase("saveSWL")) {//Save decoded messages GeneralVariables.saveSWLMessage = result.equals("1"); } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 083802af2..98daf6237 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -38,10 +38,14 @@ import com.k1af.ft8af.ModeProfile import com.k1af.ft8af.R import com.k1af.ft8af.database.OperationBand import com.k1af.ft8af.ft8transmit.FT8TransmitSignal +import com.k1af.ft8af.flex.FlexRadio import com.k1af.ft8af.rigs.CatConnectionState +import com.k1af.ft8af.rigs.InstructionSet import com.k1af.ft8af.rigs.BaseRigOperation import radio.ks3ckc.ft8af.theme.BgApp import radio.ks3ckc.ft8af.ui.components.ActiveQsoPanel +import radio.ks3ckc.ft8af.ui.components.CatTapAction +import radio.ks3ckc.ft8af.ui.components.catTapAction import radio.ks3ckc.ft8af.ui.components.shouldShowCatChip import radio.ks3ckc.ft8af.ui.components.CqOptionsSheet import radio.ks3ckc.ft8af.ui.components.canEnableFieldDay @@ -488,7 +492,29 @@ fun FT8AFApp(mainViewModel: MainViewModel) { ).show() } }, - onReconnectCat = { mainViewModel.reconnectRig() }, + onReconnectCat = { + when ( + catTapAction( + connectorExists = mainViewModel.baseRig?.connector != null, + isFlexNetwork = GeneralVariables.instructionSet == InstructionSet.FLEX_NETWORK, + savedFlexIp = GeneralVariables.flexLastIp, + ) + ) { + CatTapAction.RECONNECT_EXISTING -> mainViewModel.reconnectRig() + CatTapAction.CONNECT_SAVED_FLEX -> { + val flexRadio = FlexRadio() + flexRadio.ip = GeneralVariables.flexLastIp + flexRadio.model = "FlexRadio" + mainViewModel.connectFlexRadioRig(GeneralVariables.getMainContext(), flexRadio) + } + CatTapAction.NEEDS_SETUP -> + Toast.makeText( + context, + context.getString(R.string.cat_needs_setup), + Toast.LENGTH_SHORT, + ).show() + } + }, onOpenFrequencyPicker = { showFrequencyPicker = true }, onToggleExpand = { qsoPanelExpanded = !qsoPanelExpanded }, ) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt index 27fa2fd0e..a03aaef7e 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt @@ -79,6 +79,29 @@ internal fun catChipVisuals(state: CatConnectionState): CatChipVisuals = when (s internal fun shouldShowCatChip(controlMode: Int, state: CatConnectionState): Boolean = controlMode != ControlMode.VOX || state != CatConnectionState.DISCONNECTED +/** What tapping the CAT chip should do, given the current connection state. */ +enum class CatTapAction { RECONNECT_EXISTING, CONNECT_SAVED_FLEX, NEEDS_SETUP } + +/** + * Decide what a tap on the CAT chip does. If a connector already exists, just + * reconnect it (the existing behavior — handy for Bluetooth/Flex retries). + * Otherwise, on a cold start, if the saved rig is a network Flex with a + * remembered address, connect straight to it — "my settings are set, just + * connect" — instead of doing nothing (the old reconnectRig() no-ops with no + * connector). With nothing saved to go on, the user needs to set the rig up. + * + * Pure so the routing is unit-tested without the rig/connect plumbing. + */ +internal fun catTapAction( + connectorExists: Boolean, + isFlexNetwork: Boolean, + savedFlexIp: String, +): CatTapAction = when { + connectorExists -> CatTapAction.RECONNECT_EXISTING + isFlexNetwork && savedFlexIp.isNotBlank() -> CatTapAction.CONNECT_SAVED_FLEX + else -> CatTapAction.NEEDS_SETUP +} + /** * A small CAT (rig control) connection indicator for the TX strip. Tappable to * re-trigger the connection — Bluetooth often only connects on the second try, diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 45ff9501c..25d87a765 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -15,6 +15,7 @@ Searching for FlexRadio on your network… Enter IP manually + Set up your radio in Settings → Radio & Audio first. Cancel diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt index e53fc66b6..6549ec2f6 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt @@ -87,4 +87,33 @@ class CatStatusChipLogicTest { assertThat(CatConnectionState.afterDisconnect(CatConnectionState.DISCONNECTED)) .isEqualTo(CatConnectionState.DISCONNECTED) } + + // ---- catTapAction (what tapping the CAT chip does) ---- + + @Test + fun `cat tap reconnects an existing connector`() { + // A live connector exists (any rig) — just re-poke it. Flex IP irrelevant. + assertThat(catTapAction(connectorExists = true, isFlexNetwork = true, savedFlexIp = "192.168.1.9")) + .isEqualTo(CatTapAction.RECONNECT_EXISTING) + assertThat(catTapAction(connectorExists = true, isFlexNetwork = false, savedFlexIp = "")) + .isEqualTo(CatTapAction.RECONNECT_EXISTING) + } + + @Test + fun `cat tap cold-connects to a saved Flex when no connector yet`() { + // The whole point: settings saved (Flex + remembered IP), no live + // connector → connect straight to it instead of no-opping. + assertThat(catTapAction(connectorExists = false, isFlexNetwork = true, savedFlexIp = "192.168.1.9")) + .isEqualTo(CatTapAction.CONNECT_SAVED_FLEX) + } + + @Test + fun `cat tap needs setup when nothing to go on`() { + // Flex selected but no remembered address. + assertThat(catTapAction(connectorExists = false, isFlexNetwork = true, savedFlexIp = "")) + .isEqualTo(CatTapAction.NEEDS_SETUP) + // Not a Flex-network rig and nothing connected (e.g. cold USB before scan). + assertThat(catTapAction(connectorExists = false, isFlexNetwork = false, savedFlexIp = "")) + .isEqualTo(CatTapAction.NEEDS_SETUP) + } } From 89f64a626f71b198446d8f7b78b6584568937bea Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 14:54:48 -0500 Subject: [PATCH 7/8] Flex: show discovered radio in picker, auto-connect it, toast on connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field test on a real FLEX-6400 exposed two bugs behind "it never found the radio / didn't connect until I hit CAT": 1. Discovery worked (MulticastLock fine — debug.log showed the radio found in 0.4s) but the picker kept spinning. FlexRadioFactory fires OnFlexRadioAdded BEFORE appending to its list, so the listener re-read an empty list and dropped the radio (logged "total=0"). It also meant the discoveredRadios count stayed 0, so auto-connect never fired. Now we include the radio handed to the callback (mergeDiscovered, deduped by address) — so it shows and auto-connect can fire. 2. The Flex connect never reported success: FlexConnector.onConnectSuccess didn't notify the rig state, so onConnected() (which sets CONNECTED + toasts) never ran — the CAT chip stayed idle and there was no confirmation. Added an OnConnectionResult callback; connectFlexRadioRig now sets CONNECTING up front and CONNECTED + a "connected" toast on success (ERROR on failure). mergeDiscovered is pure + unit-tested. Co-Authored-By: Claude Opus 4.8 --- .../java/com/k1af/ft8af/MainViewModel.java | 18 ++++++++++++++++++ .../k1af/ft8af/connector/FlexConnector.java | 14 +++++++++++++- .../ft8af/ui/settings/ConnectionDialogs.kt | 7 ++++++- .../ft8af/ui/settings/FlexDiscoveryLogic.kt | 11 +++++++++++ .../ui/settings/FlexDiscoveryLogicTest.kt | 19 +++++++++++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index 8870ed638..6c56565db 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1289,6 +1289,10 @@ public void connectFlexRadioRig(Context context, FlexRadio flexRadio) { GeneralVariables.flexLastIp = flexRadio.getIp(); databaseOpr.writeConfig("flexLastIp", flexRadio.getIp(), null); } + // Reflect the in-progress attempt on the CAT chip immediately; the Flex + // connect is async (TCP), so without this the chip looks idle until/unless + // the link comes up. + setCatConnectionState(CatConnectionState.CONNECTING); FlexConnector flexConnector = new FlexConnector(context, flexRadio, GeneralVariables.controlMode); flexConnector.setOnWaveDataReceived(new FlexConnector.OnWaveDataReceived() { @Override @@ -1296,6 +1300,20 @@ public void OnDataReceived(int bufferLen, float[] buffer) { hamRecorder.doOnWaveDataReceived(bufferLen, buffer); } }); + // Surface success/failure: the Flex path doesn't fire the BaseRig + // onConnected() callback, so without this the CAT chip never turns + // connected and there's no "connected" confirmation toast. + flexConnector.setOnConnectionResult(new FlexConnector.OnConnectionResult() { + @Override + public void onConnected() { + setCatConnectionState(CatConnectionState.CONNECTED); + ToastMessage.show(getStringFromResource(R.string.connected_rig)); + } + @Override + public void onFailed() { + setCatConnectionState(CatConnectionState.ERROR); + } + }); flexConnector.connect(); connectRig(); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java index 78ba8031a..5a00389a2 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java @@ -32,6 +32,12 @@ public class FlexConnector extends BaseRigConnector { public interface OnWaveDataReceived{ void OnDataReceived(int bufferLen,float[] buffer); } + /** Fired when the Flex link is up (TCP connected + GUI client created) or fails, + * so MainViewModel can surface the CAT connection state + a success/failure toast. */ + public interface OnConnectionResult{ + void onConnected(); + void onFailed(); + } public int maxRfPower; public int maxTunePower; @@ -40,6 +46,11 @@ public interface OnWaveDataReceived{ private FlexRadio flexRadio; private OnWaveDataReceived onWaveDataReceived; + private OnConnectionResult onConnectionResult; + + public void setOnConnectionResult(OnConnectionResult listener){ + this.onConnectionResult = listener; + } public FlexConnector(Context context, FlexRadio flexRadio, int controlMode) { @@ -209,13 +220,14 @@ public void onConnectSuccess(RadioTcpClient tcpClient) { //flexRadio.sendCommand("c1|client gui\n"); //playData(); - + if (onConnectionResult != null) onConnectionResult.onConnected(); } @Override public void onConnectFail(RadioTcpClient tcpClient) { ToastMessage.show(String.format(GeneralVariables.getStringFromResource (R.string.flex_connect_failed),flexRadio.getModel())); + if (onConnectionResult != null) onConnectionResult.onFailed(); } }); diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt index f0486bd23..c14145d20 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt @@ -294,8 +294,13 @@ fun FlexRadioPickerDialog( "FlexDiscovery: radio added model=${flexRadio.model} ip=${flexRadio.ip} " + "(total=${factory.flexRadios.size})", ) + // The factory fires this callback BEFORE appending the radio to + // flexRadios, so re-reading that list here would miss the new one + // (the bug where the picker kept spinning despite a found radio). + // Include the radio handed to us; dedupe by address. + val merged = mergeDiscovered(factory.flexRadios.toList(), flexRadio) { it.ip ?: "" } discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) + discoveredRadios.addAll(merged) } override fun OnFlexRadioInvalid(flexRadio: FlexRadio) { discoveredRadios.clear() diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt index 68a3a9310..aaded12dd 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt @@ -16,6 +16,17 @@ package radio.ks3ckc.ft8af.ui.settings * - no rig is already connected ([rigAlreadyConnected]) — reconnecting a live * session would tear it down and can leave it broken. */ +/** + * The radios to show after a discovery event: the factory's current list plus the + * just-added one, deduped by a stable key (its address). The FlexRadioFactory + * fires its "added" callback BEFORE appending the radio to its list, so simply + * re-reading that list drops the new radio — the bug where the picker kept + * "Searching…" even though a radio had been found. Including [added] explicitly + * fixes it; dedup guards against a later event that does include it. + */ +internal fun mergeDiscovered(existing: List, added: T, key: (T) -> String): List = + (existing + added).distinctBy(key) + internal fun shouldAutoConnectFlex( discoveredCount: Int, userTypedIp: Boolean, diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt index 1f8652c6b..e00260b3d 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt @@ -64,4 +64,23 @@ class FlexDiscoveryLogicTest { // Reconnecting a live session would tear it down. assertThat(autoConnect(rigAlreadyConnected = true)).isFalse() } + + // ---- mergeDiscovered ---- + + @Test + fun mergeDiscovered_includesTheNewRadio_evenWhenFactoryListLagsBehind() { + // The factory fires the callback before appending, so existing is empty + // when the very first radio arrives — it must still appear. + assertThat(mergeDiscovered(emptyList(), "192.168.1.9") { it }) + .containsExactly("192.168.1.9") + } + + @Test + fun mergeDiscovered_dedupesByKey() { + // A later event that already includes the radio must not duplicate it. + assertThat(mergeDiscovered(listOf("192.168.1.9"), "192.168.1.9") { it }) + .containsExactly("192.168.1.9") + assertThat(mergeDiscovered(listOf("192.168.1.9"), "10.0.0.5") { it }) + .containsExactly("192.168.1.9", "10.0.0.5").inOrder() + } } From 3e99265d4da0ed8c6e463e0f9e39b3f58343c5cc Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 15:07:33 -0500 Subject: [PATCH 8/8] Meters HUD: replace invisible top-edge swipe with a visible pull-tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open gesture was an invisible 24dp strip at the very top — undiscoverable, and it fought Android's notification-shade gesture, which owns the screen's top edge, so the swipe often just opened the system shade ("can't get it to slide down"). Replace it with a small visible "METERS ▾" tab centered at the top, below the status bar (statusBarsPadding, so the system doesn't steal the gesture). It opens the HUD on a tap or a short downward drag — whichever the user reaches for. Verified on device: tab is visible and opens the sheet. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 8 +-- .../ks3ckc/ft8af/ui/components/MetersSheet.kt | 56 +++++++++++++++---- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 274a71de3..85ab56e4b 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -50,7 +50,7 @@ import radio.ks3ckc.ft8af.ui.components.FT8AFTab import radio.ks3ckc.ft8af.ui.components.FrequencyPickerSheet import radio.ks3ckc.ft8af.ui.components.HoundSetupSheet import radio.ks3ckc.ft8af.ui.components.MetersSheet -import radio.ks3ckc.ft8af.ui.components.TopEdgeMetersTrigger +import radio.ks3ckc.ft8af.ui.components.MetersHandle import radio.ks3ckc.ft8af.ui.components.formatMhz import radio.ks3ckc.ft8af.ui.components.QsoCelebration import radio.ks3ckc.ft8af.ui.components.SlotTimerBar @@ -646,9 +646,9 @@ fun FT8AFApp(mainViewModel: MainViewModel) { }, ) - // Top-edge swipe-down opens the meters HUD from anywhere. Disabled while - // the HUD is already open so its own drag-to-dismiss isn't fought. - TopEdgeMetersTrigger( + // Visible pull-tab at the top center opens the meters HUD from anywhere + // (tap or short drag down). Hidden while the HUD is open. + MetersHandle( enabled = !showMeters, onOpen = { showMeters = true }, modifier = Modifier.align(Alignment.TopCenter), diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt index 10457b119..8b08c33a5 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt @@ -12,9 +12,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -395,26 +397,28 @@ private fun MetersTopSheet( } /** - * Invisible top-edge strip that opens the meters HUD on a downward swipe. Sized - * thin so it only claims the very top edge (like the Android status-bar pull), - * leaving the rest of the screen's gestures untouched. Tracks cumulative drag - * and commits via [shouldOpenFromEdgeDrag]. + * A small visible pull-tab centered at the very top of the app that opens the + * meters HUD. Replaces the old invisible top-edge swipe, which (a) gave no hint + * it existed and (b) fought Android's notification-shade gesture, which owns the + * screen's top edge. This tab sits just below the status bar (statusBarsPadding) + * so the system doesn't steal the gesture, and opens on either a tap or a short + * downward drag — whichever the user reaches for. */ @Composable -fun TopEdgeMetersTrigger( +fun MetersHandle( enabled: Boolean, onOpen: () -> Unit, modifier: Modifier = Modifier, ) { if (!enabled) return val density = LocalDensity.current - val openThresholdPx = with(density) { 36.dp.toPx() } + val openThresholdPx = with(density) { 24.dp.toPx() } var totalDy by remember { mutableFloatStateOf(0f) } Box( modifier = modifier - .fillMaxWidth() - .height(24.dp) + .statusBarsPadding() + // Generous touch target around the small visible tab. .pointerInput(Unit) { detectVerticalDragGestures( onDragStart = { totalDy = 0f }, @@ -425,6 +429,38 @@ fun TopEdgeMetersTrigger( onDragCancel = { totalDy = 0f }, onVerticalDrag = { _, dy -> totalDy += dy }, ) - }, - ) + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onOpen() } + .padding(horizontal = 28.dp) + .padding(top = 2.dp, bottom = 8.dp), + contentAlignment = Alignment.TopCenter, + ) { + // The visible tab: a rounded-bottom chip with a grabber + "METERS ▾". + Row( + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .background(AccentSoft) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + text = stringResource(R.string.meters_title), + color = Accent, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.12.sp, + ) + Text( + text = "▾", + color = Accent, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + ) + } + } }