From 8dd01035a31c2133503374b152f6281c3970f0d4 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 11:14:40 -0500 Subject: [PATCH 01/16] Fix crash: guard executor submits against teardown race afterDecode() runs on the FT8 decode thread and submits a QTH-lookup task to getQTHThreadPool after every decode pass. That pool (and sendWaveDataThreadPool) is shut down in MainViewModel.onCleared() when the ViewModel is destroyed on app close or connection reset/reconnect. A decode result landing in the same instant the pool is shut down reaches ExecutorService.execute() on a terminated pool and throws RejectedExecutionException on the decode thread, crashing the app (observed on a FlexRadio network reconnect). Add ExecutorUtils.safeExecute(), which skips submission when the pool is shut down and swallows the residual RejectedExecutionException race, dropping the follow-up task instead of crashing. Route all three submit sites (QTH lookup + two network/CAT TX-audio sends) through it. Covered by ExecutorUtilsTest. Co-Authored-By: Claude Opus 4.8 --- .../java/com/k1af/ft8af/ExecutorUtils.java | 45 ++++++++++++++ .../java/com/k1af/ft8af/MainViewModel.java | 11 ++-- .../com/k1af/ft8af/ExecutorUtilsTest.java | 61 +++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/ExecutorUtils.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ExecutorUtilsTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ExecutorUtils.java b/ft8af/app/src/main/java/com/k1af/ft8af/ExecutorUtils.java new file mode 100644 index 000000000..bf07f1c5d --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ExecutorUtils.java @@ -0,0 +1,45 @@ +package com.k1af.ft8af; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; + +/** + * Helpers for submitting work to executors that may be torn down concurrently. + * + *

The decode pipeline runs on its own thread and calls back into + * {@code MainViewModel} after each pass to kick off background work (QTH/grid + * lookups, network TX audio). Those pools are shut down in + * {@code MainViewModel.onCleared()} when the ViewModel is destroyed (app close, + * connection reset/reconnect). A decode result that lands in the same instant + * the pool is shut down would otherwise reach {@link ExecutorService#execute} + * on a terminated pool and throw {@link RejectedExecutionException} on the + * decode thread, crashing the app. See the {@code afterDecode} race fixed + * alongside this helper. + */ +public final class ExecutorUtils { + + private ExecutorUtils() { + } + + /** + * Submit {@code task} to {@code pool}, tolerating a pool that is shutting + * down. The {@link ExecutorService#isShutdown()} check avoids the common + * case; the catch covers the narrow race where the pool is shut down + * between the check and {@link ExecutorService#execute}. + * + * @return {@code true} if the task was accepted, {@code false} if it was + * dropped because the pool (or task) was unavailable. + */ + public static boolean safeExecute(ExecutorService pool, Runnable task) { + if (pool == null || task == null || pool.isShutdown()) { + return false; + } + try { + pool.execute(task); + return true; + } catch (RejectedExecutionException e) { + // Pool was shut down concurrently (ViewModel teardown). Drop the task. + return false; + } + } +} 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..f7ec10f42 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -591,7 +591,10 @@ public void afterDecode(long utc, float time_sec, int sequential getQTHRunnable.messages = messages; - getQTHThreadPool.execute(getQTHRunnable);//query location via thread pool + //query location via thread pool; guarded so a decode landing during + //ViewModel teardown (pool shut down in onCleared()) drops the lookup + //instead of crashing with RejectedExecutionException. + ExecutorUtils.safeExecute(getQTHThreadPool, getQTHRunnable); //this variable also notifies message list changes mutable_Decoded_Counter.postValue( @@ -685,8 +688,8 @@ public void onTransmitByWifi(Ft8Message msg) { if (baseRig.isConnected()) { sendWaveDataRunnable.baseRig = baseRig; sendWaveDataRunnable.message = msg; - //send network data packets via thread pool - sendWaveDataThreadPool.execute(sendWaveDataRunnable); + //send network data packets via thread pool (guarded against teardown race) + ExecutorUtils.safeExecute(sendWaveDataThreadPool, sendWaveDataRunnable); } } } @@ -714,7 +717,7 @@ public void onTransmitOverCAT(Ft8Message msg) {//send audio message via CAT } sendWaveDataRunnable.baseRig = baseRig; sendWaveDataRunnable.message = msg; - sendWaveDataThreadPool.execute(sendWaveDataRunnable); + ExecutorUtils.safeExecute(sendWaveDataThreadPool, sendWaveDataRunnable); } }, new OnTransmitSuccess() {//when QSO is successful diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ExecutorUtilsTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ExecutorUtilsTest.java new file mode 100644 index 000000000..396da0a6c --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ExecutorUtilsTest.java @@ -0,0 +1,61 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * Unit tests for {@link ExecutorUtils#safeExecute(ExecutorService, Runnable)}. + * + *

Locks the invariant behind the {@code afterDecode} crash fix: submitting a + * decode-followup task to a pool that has been shut down (ViewModel teardown) + * must drop the task quietly instead of throwing + * {@link java.util.concurrent.RejectedExecutionException}. + */ +public class ExecutorUtilsTest { + + @Test + public void activePool_runsTaskAndReturnsTrue() throws InterruptedException { + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + CountDownLatch ran = new CountDownLatch(1); + boolean accepted = ExecutorUtils.safeExecute(pool, ran::countDown); + assertThat(accepted).isTrue(); + assertThat(ran.await(2, TimeUnit.SECONDS)).isTrue(); + } finally { + pool.shutdownNow(); + } + } + + @Test + public void shutdownPool_dropsTaskAndReturnsFalse() { + ExecutorService pool = Executors.newSingleThreadExecutor(); + pool.shutdown(); + // The pre-fix code path: execute() on a terminated pool threw and crashed + // the decode thread. safeExecute must swallow it and report the drop. + boolean accepted = ExecutorUtils.safeExecute(pool, () -> { + throw new AssertionError("task must not run on a shut-down pool"); + }); + assertThat(accepted).isFalse(); + } + + @Test + public void nullPool_returnsFalse() { + assertThat(ExecutorUtils.safeExecute(null, () -> { })).isFalse(); + } + + @Test + public void nullTask_returnsFalse() { + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + assertThat(ExecutorUtils.safeExecute(pool, null)).isFalse(); + } finally { + pool.shutdownNow(); + } + } +} From 4ac757578d295071782aedd6856d12f0d64be9df Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 12:08:33 -0500 Subject: [PATCH 02/16] Flex picker: refresh discovered radios live while dialog is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FlexRadio network picker only refreshed its discovered-radio list via a one-shot FlexRadioFactory event listener that fired on the first add. Discovery typically takes a few seconds, so a radio that surfaced shortly after the dialog opened never appeared — the user had to close and reselect Network to get a fresh seed. Replace the listener with a 1s poll of FlexRadioFactory.flexRadios while the dialog is open, rebuilding the displayed list only when the discovered set actually changes (FlexPickerLogic.identityKey/listChanged) so the list doesn't recompose / reset scroll every tick. Covered by FlexPickerLogicTest. Co-Authored-By: Claude Opus 4.8 --- .../ft8af/ui/settings/ConnectionDialogs.kt | 32 ++++++------ .../ft8af/ui/settings/FlexPickerLogic.kt | 33 ++++++++++++ .../ft8af/ui/settings/FlexPickerLogicTest.kt | 52 +++++++++++++++++++ 3 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt 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..0e40fed63 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 @@ -29,6 +29,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 @@ -58,6 +59,7 @@ import com.k1af.ft8af.rigs.InstructionSet import com.k1af.ft8af.ui.ToastMessage import com.k1af.ft8af.x6100.X6100Radio import com.k1af.ft8af.x6100.XieguRadioFactory +import kotlinx.coroutines.delay import radio.ks3ckc.ft8af.theme.* // ───────────────────────────────────────────────────────────────────── @@ -251,25 +253,25 @@ fun FlexRadioPickerDialog( var ipText by remember { mutableStateOf("") } val discoveredRadios = remember { mutableStateListOf() } - // Subscribe to FlexRadioFactory discovery events - DisposableEffect(Unit) { + // Poll discovery while the dialog is open so radios found *after* opening + // (discovery typically takes a few seconds) appear live — no need to close + // and reopen the picker. The previous one-shot event listener only fired on + // the first add, so a radio that surfaced moments later went unseen until + // the dialog was reopened. We rebuild the list only when the discovered set + // actually changes (see FlexPickerLogic) to avoid recomposing / resetting + // the scroll position every tick. + LaunchedEffect(Unit) { val factory = FlexRadioFactory.getInstance() - // Seed with any already-discovered radios - discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) - - val listener = object : FlexRadioFactory.OnFlexRadioEvents { - override fun OnFlexRadioAdded(flexRadio: FlexRadio) { - discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) - } - override fun OnFlexRadioInvalid(flexRadio: FlexRadio) { + while (true) { + val latest = factory.flexRadios.toList() + val displayedKeys = discoveredRadios.map { FlexPickerLogic.identityKey(it.serial, it.ip) } + val latestKeys = latest.map { FlexPickerLogic.identityKey(it.serial, it.ip) } + if (FlexPickerLogic.listChanged(displayedKeys, latestKeys)) { discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) + discoveredRadios.addAll(latest) } + delay(1000) } - factory.setOnFlexRadioEvents(listener) - onDispose { factory.setOnFlexRadioEvents(null) } } Dialog(onDismissRequest = onDismiss) { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt new file mode 100644 index 000000000..407fabbf0 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt @@ -0,0 +1,33 @@ +package radio.ks3ckc.ft8af.ui.settings + +/** + * Pure helpers for the FlexRadio picker's live-discovery list. + * + * The picker polls [com.k1af.ft8af.flex.FlexRadioFactory] once a second while + * open. These functions keep that polling cheap and stable: derive a stable + * identity per radio, and only rebuild the displayed list when the discovered + * set actually changed (so the list doesn't recompose / reset its scroll every + * tick). Kept free of Android/Compose types so it's unit-testable. + */ +internal object FlexPickerLogic { + + /** + * Stable identity for a discovered radio. Prefers the serial number; falls + * back to the IP when the serial is missing/blank. Trimmed so incidental + * whitespace doesn't read as a change. + */ + fun identityKey(serial: String?, ip: String?): String { + val s = serial?.trim().orEmpty() + if (s.isNotEmpty()) return s + return ip?.trim().orEmpty() + } + + /** + * Whether the latest discovery snapshot differs from what's displayed, + * compared by identity key in order. When false, the caller skips the + * (otherwise per-second) list rebuild. + */ + fun listChanged(displayedKeys: List, latestKeys: List): Boolean { + return displayedKeys != latestKeys + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt new file mode 100644 index 000000000..20f7a2f19 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt @@ -0,0 +1,52 @@ +package radio.ks3ckc.ft8af.ui.settings + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [FlexPickerLogic], the live-discovery list helpers behind the + * FlexRadio picker polling fix. + */ +class FlexPickerLogicTest { + + @Test + fun identityKey_prefersSerial() { + assertThat(FlexPickerLogic.identityKey("0123-4567-89", "192.168.86.159")) + .isEqualTo("0123-4567-89") + } + + @Test + fun identityKey_fallsBackToIpWhenSerialBlank() { + assertThat(FlexPickerLogic.identityKey(" ", "192.168.86.159")) + .isEqualTo("192.168.86.159") + assertThat(FlexPickerLogic.identityKey(null, "192.168.86.159")) + .isEqualTo("192.168.86.159") + } + + @Test + fun identityKey_trimsWhitespace() { + assertThat(FlexPickerLogic.identityKey(" FLEX-1 ", null)).isEqualTo("FLEX-1") + } + + @Test + fun identityKey_emptyWhenNothingKnown() { + assertThat(FlexPickerLogic.identityKey(null, null)).isEmpty() + } + + @Test + fun listChanged_falseWhenIdentical() { + // The common per-tick case: nothing new discovered → no rebuild. + assertThat(FlexPickerLogic.listChanged(listOf("a", "b"), listOf("a", "b"))).isFalse() + } + + @Test + fun listChanged_trueWhenRadioAppears() { + // A radio surfaces after the dialog is already open — must update live. + assertThat(FlexPickerLogic.listChanged(emptyList(), listOf("a"))).isTrue() + } + + @Test + fun listChanged_trueWhenRadioDropsOff() { + assertThat(FlexPickerLogic.listChanged(listOf("a", "b"), listOf("a"))).isTrue() + } +} From c706c4c56aa01e724d597c31db3c2babe66bf50f Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 12:59:46 -0500 Subject: [PATCH 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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 09/16] 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 10/16] 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, + ) + } + } } From a0675d90aebdad1b56d4688af385d34edf93bcc0 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 15:42:34 -0500 Subject: [PATCH 11/16] Flex meters: force meter subscription + poll live values so the HUD populates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: the meters HUD shows the TX-power slider (so the Flex is connected) but "No meter data yet" — the meter rows never populate. Two causes: 1. The meter subscription is only sent when the METER_LIST *response* arrives at connect (FlexConnector's response handler) — an easily-missed path. The old legacy meter screen used to force it directly. Add FlexConnector.requestMeterStream() (unconditional meter list + sub) and call it from the HUD when it opens. 2. The Flex posts the SAME FlexMeterList instance on every update, which observeAsState dedupes via structural equality — so even once data flowed the bars wouldn't update. Poll the live (UDP-thread-mutated) instance ~4x/sec instead, and gate the empty state on a new FlexConnector.meterDataReceived flag (set on the first packet, also logged for diagnosis). Co-Authored-By: Claude Opus 4.8 --- .../k1af/ft8af/connector/FlexConnector.java | 19 +++++++++++- .../ft8af/ui/components/MetersSource.kt | 30 +++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) 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 5a00389a2..d1dbca715 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 @@ -40,6 +40,9 @@ public interface OnConnectionResult{ } public int maxRfPower; public int maxTunePower; + // True once at least one meter value packet has arrived, so the HUD can tell + // "no meter stream yet" apart from genuine zero readings. + public volatile boolean meterDataReceived = false; private static final String TAG = "FlexConnector"; @@ -100,7 +103,10 @@ public void onReceiveMeter(VITA vita) { meterList.setMeters(vita.payload,flexMeterInfos); mutableMeterList.postValue(meterList); - + if (!meterDataReceived) { + meterDataReceived = true; + GeneralVariables.fileLog("FlexDiscovery: first meter packet received"); + } } @Override @@ -258,6 +264,17 @@ public void subAllMeters(){ } } + /** + * Force a meter (re)subscription regardless of cached meter-info state. The + * connect-time subscription is gated on a METER_LIST response arriving; this + * unconditional path is what the meters HUD calls when it opens, so meters + * stream even if that response was missed. Safe to call repeatedly. + */ + public void requestMeterStream(){ + flexRadio.commandMeterList(); + flexRadio.commandSubMeterAll(); + } + @Override public void sendData(byte[] data) { flexRadio.sendData(data); 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 8cf981328..2524a74cb 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 @@ -1,15 +1,17 @@ package radio.ks3ckc.ft8af.ui.components import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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 /** @@ -41,12 +43,28 @@ fun rememberRigMeterSamples(mainViewModel: MainViewModel): List { @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() + + // Force a meter (re)subscription when the HUD opens. The connect-time + // subscription is gated on a METER_LIST response arriving, which is easily + // missed; this guarantees the stream is running while the HUD is shown. + LaunchedEffect(connector) { connector?.requestMeterStream() } + + // The Flex posts the SAME FlexMeterList instance on every update, which + // observeAsState dedupes (so bars would freeze after the first packet). Poll + // the live instance a few times a second instead — the UDP thread keeps + // mutating it in place — and use a tick to drive recomposition. + var tick by remember { mutableIntStateOf(0) } + LaunchedEffect(connector) { + while (true) { + kotlinx.coroutines.delay(250) + tick++ + } + } + tick // read so recomposition tracks the poll + + val m = connector?.meterList + if (m == null || !connector.meterDataReceived) return emptyList() return listOf( swrSampleFromRatio(m.swrVal), alcSampleFlexDb(m.alcVal), From 527d5c09c9ca0efdcc2184035b0ed8fe8d037d91 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 15:49:06 -0500 Subject: [PATCH 12/16] Flex meters: drive the poll from the HUD so bars animate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meters appeared but didn't move. The 250ms poll tick was read *inside* the per-rig sampler @Composable, so only that child recomposed — its fresh values never propagated to the bars rendered in MetersSheet's scope (a returned List doesn't update the caller unless the caller recomposes). Move the poll to MetersSheet: a produceState frame (250ms, only while the sheet is visible) is read in the render scope and passed into rememberRigMeterSamples, forcing it to re-run and re-read each rig's live, in-place-mutated meter values. The per-rig readers are now plain functions so their results propagate normally. Co-Authored-By: Claude Opus 4.8 --- .../ks3ckc/ft8af/ui/components/MetersSheet.kt | 16 ++++- .../ft8af/ui/components/MetersSource.kt | 69 +++++++------------ 2 files changed, 41 insertions(+), 44 deletions(-) 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 8b08c33a5..c339b9ca6 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 @@ -31,9 +31,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import kotlinx.coroutines.delay import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -89,7 +91,19 @@ fun MetersSheet( isTransmitting: Boolean, onDismiss: () -> Unit, ) { - val available = rememberRigMeterSamples(mainViewModel) + // Poll a few times a second while the sheet is open so live rig meters + // refresh. Reading [frame] here (the render scope) — and passing it down — is + // what makes the bars actually update; polling inside the sampler would only + // recompose the sampler, not these bars. Idle (no recomposition) when hidden. + val frame by produceState(0L, visible) { + if (visible) { + while (true) { + delay(250) + value++ + } + } + } + val available = rememberRigMeterSamples(mainViewModel, frame) val enabled = enabledMeterTypes( swr = GeneralVariables.meterShowSwr, 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 2524a74cb..d2244419d 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 @@ -4,10 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.lifecycle.MutableLiveData import com.k1af.ft8af.GeneralVariables import com.k1af.ft8af.MainViewModel import com.k1af.ft8af.connector.FlexConnector @@ -31,40 +27,33 @@ import com.k1af.ft8af.x6100.X6100Meters * HUD tell "rig reports nothing" (empty) apart from "user hid everything". */ @Composable -fun rememberRigMeterSamples(mainViewModel: MainViewModel): List { +fun rememberRigMeterSamples(mainViewModel: MainViewModel, frame: Long): 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 { - val connector = mainViewModel.baseRig?.connector as? FlexConnector + val flexConnector = mainViewModel.baseRig?.connector as? FlexConnector - // Force a meter (re)subscription when the HUD opens. The connect-time + // Force a meter (re)subscription while the HUD is up. The connect-time // subscription is gated on a METER_LIST response arriving, which is easily - // missed; this guarantees the stream is running while the HUD is shown. - LaunchedEffect(connector) { connector?.requestMeterStream() } + // missed; this guarantees the stream runs whenever meters are on screen. + LaunchedEffect(flexConnector, isFlex) { if (isFlex) flexConnector?.requestMeterStream() } - // The Flex posts the SAME FlexMeterList instance on every update, which - // observeAsState dedupes (so bars would freeze after the first packet). Poll - // the live instance a few times a second instead — the UDP thread keeps - // mutating it in place — and use a tick to drive recomposition. - var tick by remember { mutableIntStateOf(0) } - LaunchedEffect(connector) { - while (true) { - kotlinx.coroutines.delay(250) - tick++ - } + // [frame] ticks a few times a second (driven by the HUD) so this re-runs and + // re-reads the rigs' live, in-place-mutated meter values. The readers below are + // plain (non-@Composable) so their results actually propagate to the caller — + // a poll tick read *inside* a child @Composable only recomposes that child, so + // the new values never reached the rendered bars (the "meters don't move" bug). + @Suppress("UNUSED_EXPRESSION") frame + return when { + isFlex -> flexSamples(flexConnector) + isXiegu -> xieguSamples(mainViewModel.baseRig?.connector as? X6100Connector) + else -> serialSamples(mainViewModel) } - tick // read so recomposition tracks the poll +} - val m = connector?.meterList - if (m == null || !connector.meterDataReceived) return emptyList() +/** Live read of the Flex meter values (the UDP thread mutates [FlexConnector.meterList] in place). */ +private fun flexSamples(connector: FlexConnector?): List { + if (connector == null || !connector.meterDataReceived) return emptyList() + val m = connector.meterList return listOf( swrSampleFromRatio(m.swrVal), alcSampleFlexDb(m.alcVal), @@ -74,12 +63,8 @@ private fun flexMeterSamples(mainViewModel: MainViewModel): List { ) } -@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() +private fun xieguSamples(connector: X6100Connector?): List { + val m = connector?.xieguRadio?.mutableMeters?.value ?: return emptyList() return listOf( swrSampleFromRatio(m.swr), alcSampleXiegu(m.alc), @@ -89,13 +74,11 @@ private fun xieguMeterSamples(mainViewModel: MainViewModel): List { ) } -@Composable -private fun serialMeterSamples(mainViewModel: MainViewModel): List { +private fun serialSamples(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() + if (controller.meterDataReceived.value != true) return emptyList() + val alc = controller.lastAlc.value ?: 0 + val swr = controller.lastSwr.value ?: 0 return listOf( swrSampleFromNormalized(swr), alcSampleSerial(alc, GeneralVariables.alcTargetLow, GeneralVariables.alcTargetHigh), From a249a424c2ee1b38aafa84b823064fda54fd03b4 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 16:06:45 -0500 Subject: [PATCH 13/16] Flex meters: drop diagnostic value logging (kept the meterDataReceived gate) Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/com/k1af/ft8af/connector/FlexConnector.java | 1 - 1 file changed, 1 deletion(-) 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 d1dbca715..cd596793e 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 @@ -105,7 +105,6 @@ public void onReceiveMeter(VITA vita) { mutableMeterList.postValue(meterList); if (!meterDataReceived) { meterDataReceived = true; - GeneralVariables.fileLog("FlexDiscovery: first meter packet received"); } } From 33a50c29ea3a27417cacfa36b9f098f23202cf77 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 16:11:04 -0500 Subject: [PATCH 14/16] Flex: force NETWORK connect mode so TX/RX audio routes over DAX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A FlexRadio (network) was transmitting 0 W and the operator had to listen to RX audio acoustically. Root cause: GeneralVariables.connectMode was left as USB_CABLE (from a previously-attached USB sound card, even after unplugging), and both the transmit path and onTransmitByWifi (the DAX sender) — plus the RX source selection — are gated on connectMode == NETWORK. So TX audio went to the stale USB output (radio transmitted silence) and RX fell back to the phone mic. Connecting to a Flex always means network/DAX audio, so connectFlexRadioRig now forces connectMode = NETWORK (persisted) and clears any stale USB-audio output override. TX then streams to the Flex over DAX (real power, meters move) and RX comes from DAX instead of the mic. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/com/k1af/ft8af/MainViewModel.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 47d2b86ae..0a828bf14 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1286,6 +1286,16 @@ public void connectFlexRadioRig(Context context, FlexRadio flexRadio) { } } GeneralVariables.controlMode = ControlMode.CAT;//network control mode + // A FlexRadio always uses network (DAX) audio — force NETWORK connect mode + // so TX and RX audio route over the network. Otherwise a stale connectMode + // (e.g. left as USB_CABLE from a previously-attached USB sound card) sends + // TX audio to that USB output instead of the Flex (radio transmits 0 W) and + // pulls RX from the phone mic instead of DAX. Also clear any stale USB-audio + // output override for the same reason. + GeneralVariables.connectMode = ConnectMode.NETWORK; + databaseOpr.writeConfig("connectMode", String.valueOf(ConnectMode.NETWORK), null); + GeneralVariables.usbAudioOutputVendorId = 0; + GeneralVariables.usbAudioOutputProductId = 0; // 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()) { From 539881ba377595a465ae21fca1535ac5cc925958 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 16:20:45 -0500 Subject: [PATCH 15/16] Flex meters: re-subscribe to the meter stream each time the HUD opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meters intermittently showed "No meter data yet" on a connected Flex. The subscription (requestMeterStream) only fired once, when the rig connected — and if that raced the link coming up, it never retried, so meters never started. Key the subscription on the HUD's visibility (active) as well as the connector, so opening the meters HUD always (re)requests the stream. Restores a light debug.log diagnostic (requestMeterStream call + first packet) to confirm. Co-Authored-By: Claude Opus 4.8 --- .../com/k1af/ft8af/connector/FlexConnector.java | 2 ++ .../radio/ks3ckc/ft8af/ui/components/MetersSheet.kt | 2 +- .../ks3ckc/ft8af/ui/components/MetersSource.kt | 13 ++++++++----- 3 files changed, 11 insertions(+), 6 deletions(-) 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 cd596793e..c29d06732 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 @@ -105,6 +105,7 @@ public void onReceiveMeter(VITA vita) { mutableMeterList.postValue(meterList); if (!meterDataReceived) { meterDataReceived = true; + GeneralVariables.fileLog("FlexMeter: first meter packet received"); } } @@ -270,6 +271,7 @@ public void subAllMeters(){ * stream even if that response was missed. Safe to call repeatedly. */ public void requestMeterStream(){ + GeneralVariables.fileLog("FlexMeter: requestMeterStream (subscribing)"); flexRadio.commandMeterList(); flexRadio.commandSubMeterAll(); } 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 c339b9ca6..e62005edf 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 @@ -103,7 +103,7 @@ fun MetersSheet( } } } - val available = rememberRigMeterSamples(mainViewModel, frame) + val available = rememberRigMeterSamples(mainViewModel, frame, active = visible) val enabled = enabledMeterTypes( swr = GeneralVariables.meterShowSwr, 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 d2244419d..e44e07bff 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 @@ -27,15 +27,18 @@ import com.k1af.ft8af.x6100.X6100Meters * HUD tell "rig reports nothing" (empty) apart from "user hid everything". */ @Composable -fun rememberRigMeterSamples(mainViewModel: MainViewModel, frame: Long): List { +fun rememberRigMeterSamples(mainViewModel: MainViewModel, frame: Long, active: Boolean): List { val isFlex by mainViewModel.mutableIsFlexRadio.observeAsState(false) val isXiegu by mainViewModel.mutableIsXieguRadio.observeAsState(false) val flexConnector = mainViewModel.baseRig?.connector as? FlexConnector - // Force a meter (re)subscription while the HUD is up. The connect-time - // subscription is gated on a METER_LIST response arriving, which is easily - // missed; this guarantees the stream runs whenever meters are on screen. - LaunchedEffect(flexConnector, isFlex) { if (isFlex) flexConnector?.requestMeterStream() } + // (Re)subscribe to the Flex meter stream each time the HUD opens ([active]). + // The connect-time subscription is gated on a METER_LIST response and can be + // missed (or race the connection coming up); re-requesting on every open makes + // meters reliably start, rather than firing only once at connect. + LaunchedEffect(flexConnector, isFlex, active) { + if (isFlex && active) flexConnector?.requestMeterStream() + } // [frame] ticks a few times a second (driven by the HUD) so this re-runs and // re-reads the rigs' live, in-place-mutated meter values. The readers below are From c11161e98e421bb2729543045decac0613461dcf Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 24 Jun 2026 14:31:59 -0500 Subject: [PATCH 16/16] Flex: meter mapping, connection reliability, and DAX transmit groundwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the Flex meter mapping and connection handling, and lays the groundwork for DAX transmit. Meters (ALC/temp/S-meter), connection reliability, and slice ownership are working and verified on-device; DAX transmit now reaches the modulator but does not yet make full power (paused — see below). Meters / mapping (FlexMeterInfos, FlexConnector): - Resolve the SWR/ALC/PWR/S-meter/temp meter ids deterministically from the full meter table (lowest id, exact-name match), so duplicate per-slice/per-TX-block meters can't latch ALC/S-meter onto an inactive duplicate. Idempotent across meter-list re-requests. Connection reliability (FlexConnector, MainViewModel): - Run the client/gui + slice/stream setup exactly once per logical connection (slicesConfigured guard, re-armed in connect()); a re-fired onConnectSuccess no longer re-mints the client and orphans the slice. - reconnectRig() no-ops while CONNECTING/CONNECTED so repeated CAT-chip taps can't spawn parallel sessions. Guard connectFlexRadioRig against a duplicate connect to the same IP. Slice ownership (FlexRadio, FlexConnector, FlexNetworkRig): - Capture the radio-assigned slice index from the "slice create" response instead of hardcoding 0; configure THAT slice (DIGU/filter/DAX bind) and claim it as the transmit slice (slice s tx=1). Band/frequency changes retune the owned slice. DAX transmit audio (FlexRadio, VITA): - Parse DAX TX stream id via long (was overflowing Integer.parseInt -> 0). - Tag TX audio with the DAX-audio class id (0x534C03E3), TSI_UTC, and the correct VITA packet_size, matching the radio's own RX audio format. - Send TX audio to the radio's VITA data port (4991). - Enable DAX transmit (dax tx 1 + transmit set dax=1). This gets audio to the modulator (ALC moves off the floor), but output power is still low — likely the SmartSDR DAX-TX gain (a GUI/profile setting with no API command). Transmit is paused here pending a packet capture vs a working DAX app. Test: FlexResponseSliceTest covers the slice-index parse. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 36 ++++++ .../k1af/ft8af/connector/FlexConnector.java | 96 ++++++++------ .../com/k1af/ft8af/flex/FlexMeterInfos.java | 50 ++++++-- .../java/com/k1af/ft8af/flex/FlexRadio.java | 118 ++++++++++++++++-- .../main/java/com/k1af/ft8af/flex/VITA.java | 15 ++- .../com/k1af/ft8af/rigs/FlexNetworkRig.java | 13 +- .../ft8af/flex/FlexResponseSliceTest.java | 45 +++++++ 7 files changed, 309 insertions(+), 64 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexResponseSliceTest.java 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 0a828bf14..a44b74e43 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1277,7 +1277,32 @@ public void run() { * @param context context * @param flexRadio FlexRadio object */ + // IP of a Flex connect currently in flight (set when a connect starts, cleared + // when it succeeds or fails), so two near-simultaneous connect requests to the + // same radio don't both run the connect sequence. See the guard below. + private volatile String flexConnectInProgressIp = null; + public void connectFlexRadioRig(Context context, FlexRadio flexRadio) { + final String requestedIp = (flexRadio != null) ? flexRadio.getIp() : null; + // Guard against a DUPLICATE connect to the same Flex. Two UI paths can each + // fire a connect — the network picker's auto-connect and the CAT chip / a + // manual tap — and a second connect re-runs `client disconnect` + `slice + // create`, which spawns a SECOND slice on the radio (duplicate LEVEL/ALC + // meters) and tears down the GUI client whose meter subscription is live, so + // meters stream briefly then die. If we're already connected to this IP, or a + // connect to it is already in flight, make this call a no-op. + if (requestedIp != null && !requestedIp.isEmpty()) { + if (requestedIp.equals(flexConnectInProgressIp)) { + return; + } + if (baseRig != null && baseRig.getConnector() instanceof FlexConnector + && baseRig.getConnector().isConnected() + && requestedIp.equals(((FlexConnector) baseRig.getConnector()).getFlexIp())) { + setCatConnectionState(CatConnectionState.CONNECTED); + return; + } + } + flexConnectInProgressIp = requestedIp; if (GeneralVariables.connectMode == ConnectMode.NETWORK) { if (baseRig != null) { if (baseRig.getConnector() != null) { @@ -1319,11 +1344,13 @@ public void OnDataReceived(int bufferLen, float[] buffer) { flexConnector.setOnConnectionResult(new FlexConnector.OnConnectionResult() { @Override public void onConnected() { + flexConnectInProgressIp = null; setCatConnectionState(CatConnectionState.CONNECTED); ToastMessage.show(getStringFromResource(R.string.connected_rig)); } @Override public void onFailed() { + flexConnectInProgressIp = null; setCatConnectionState(CatConnectionState.ERROR); } }); @@ -1564,6 +1591,15 @@ public void reconnectRig() { if (baseRig == null || baseRig.getConnector() == null) { return; } + // Don't tear down a live or in-progress connection. Repeated CAT-chip taps would + // otherwise call connect() again on the Flex, opening a parallel TCP session whose + // "client gui" mints a new handle and orphans the slice/streams of the prior + // session — leaving the radio "connected" but owning nothing. Only reconnect when + // genuinely disconnected. + if (catConnectionState == CatConnectionState.CONNECTING + || catConnectionState == CatConnectionState.CONNECTED) { + return; + } setCatConnectionState(CatConnectionState.CONNECTING); baseRig.getConnector().connect(); } 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 c29d06732..53381ee2c 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 @@ -43,6 +43,10 @@ public interface OnConnectionResult{ // True once at least one meter value packet has arrived, so the HUD can tell // "no meter stream yet" apart from genuine zero readings. public volatile boolean meterDataReceived = false; + // True once the slice + DAX streams have been created for this logical connection. + // Guards the one-time setup so a TCP reconnect (which re-fires onConnectSuccess) + // doesn't spawn duplicate slices/streams. Reset in disconnect(). + private volatile boolean slicesConfigured = false; private static final String TAG = "FlexConnector"; @@ -105,7 +109,6 @@ public void onReceiveMeter(VITA vita) { mutableMeterList.postValue(meterList); if (!meterDataReceived) { meterDataReceived = true; - GeneralVariables.fileLog("FlexMeter: first meter packet received"); } } @@ -139,6 +142,19 @@ public void onResponse(FlexRadio.FlexResponse response) { if (response.flexCommand==FlexCommand.STREAM_CREATE_DAX_TX){ flexRadio.streamTxId=response.daxTxStreamId; } + if (response.flexCommand==FlexCommand.SLICE_CREATE_FREQ){ + // The radio assigns the new slice's index in this response; configure + // THAT slice (not a hardcoded 0) for FT8 and claim it as the transmit + // slice. Doing it here — once the index is known — is what makes the + // keyed slice the same slice we feed DAX audio to (real forward power), + // and makes us own a specific slice instead of stomping slice 0. + int slice = response.createdSliceIndex >= 0 ? response.createdSliceIndex : 0; + flexRadio.setActiveSliceIndex(slice); + flexRadio.commandSliceSetMode(slice, FlexRadio.FlexMode.DIGU); + flexRadio.commandSetFilter(slice, 0, 3000); + flexRadio.commandSetDaxAudio(1, slice, true);//bind DAX ch1 audio to our slice + flexRadio.commandSliceSetActiveTx(slice);//make our slice the transmit slice + } // if (response.flexCommand== FlexCommand.METER_LIST){ // Log.e(TAG, "onResponse: ."+response.rawData.replace("#","\n") ); @@ -165,44 +181,25 @@ public void onConnectSuccess(RadioTcpClient tcpClient) { ToastMessage.show(String.format(GeneralVariables.getStringFromResource(R.string.init_flex_operation) ,flexRadio.getModel())); - flexRadio.commandClientDisconnect();//Disconnect all previous connections - flexRadio.commandClientGui();//Create GUI - - flexRadio.commandSubDaxAll();//Register all DAX streams - - - - flexRadio.commandClientSetEnforceNetWorkGui();//Configure network MTU settings - - //flexRadio.commandSliceList();//List slices - flexRadio.commandSliceCreate();//Create slice - - - - - - //todo To prevent stream ports from not being released, change the port? - //FlexRadio.streamPort++; - - flexRadio.commandUdpPort();//Set UDP port - - - flexRadio.commandStreamCreateDaxRx(1);//Create stream data to DAX channel 1 - flexRadio.commandStreamCreateDaxTx(1);//Create stream data to DAX channel 1 - - flexRadio.commandSetDaxAudio(1, 0, true);//Enable DAX - - //TODO Should we set this??? dax tx T or dax tx 1 - flexRadio.commandSliceTune(0,String.format("%.3f",GeneralVariables.band/1000000f)); - flexRadio.commandSliceSetMode(0, FlexRadio.FlexMode.DIGU);//Set operating mode - flexRadio.commandSetFilter(0, 0, 3000);//Set filter to 3000 Hz - - - flexRadio.commandMeterList();//List the meters - //flexRadio.commandSubMeterAll();//Subscription command moved to the response handling section - - setMaxRfPower(maxRfPower);//set transmit power - setMaxTunePower(maxTunePower);//Set tune power + // (full setup moved into the guarded block below — runs once per connection) + + if (!slicesConfigured) { + slicesConfigured = true; + flexRadio.commandClientDisconnect();//Disconnect all previous connections + flexRadio.commandClientGui();//Create GUI + flexRadio.commandSubDaxAll();//Register all DAX streams + flexRadio.commandClientSetEnforceNetWorkGui();//Configure network MTU settings + flexRadio.commandUdpPort();//Set UDP port + flexRadio.commandStreamCreateDaxRx(1);//Create stream data to DAX channel 1 + flexRadio.commandStreamCreateDaxTx(1);//Create stream data to DAX channel 1 + flexRadio.commandSliceCreateAtFreq( + String.format("%.6f", GeneralVariables.band / 1000000f));//Create our slice, tuned + flexRadio.commandMeterList();//List the meters (subscription happens in the response) + setMaxRfPower(maxRfPower);//set transmit power + setMaxTunePower(maxTunePower);//Set tune power + flexRadio.commandDaxTx(true);//enable the DAX-TX subsystem (fully open the audio path) + flexRadio.commandSetTransmitDax(true);//USE the DAX audio stream as the TX source + } //flexRadio.commandSubMeterById(5);//List a specific meter @@ -271,7 +268,6 @@ public void subAllMeters(){ * stream even if that response was missed. Safe to call repeatedly. */ public void requestMeterStream(){ - GeneralVariables.fileLog("FlexMeter: requestMeterStream (subscribing)"); flexRadio.commandMeterList(); flexRadio.commandSubMeterAll(); } @@ -301,6 +297,10 @@ public void sendWaveData(float[] data) { @Override public void connect() { + // Re-arm the one-time setup for this fresh connection attempt. onConnectSuccess + // then runs the full client/gui + slice/stream setup exactly once; if it fires + // again for the same attempt it is a no-op (no re-gui, no orphaned slice). + slicesConfigured = false; super.connect(); flexRadio.openAudio(); flexRadio.connect(); @@ -310,11 +310,21 @@ public void connect() { @Override public void disconnect() { super.disconnect(); + // Re-arm the one-time slice setup so the next connection creates a fresh slice + // and re-discovers its index, rather than reusing stale ownership state. + slicesConfigured = false; + flexRadio.clearSliceState(); flexRadio.closeAudio(); flexRadio.closeStreamPort(); flexRadio.disConnect(); } + /** The slice index this connector owns and transmits on (see FlexRadio.activeSliceIndex). + * Used by FlexNetworkRig so band/frequency changes retune OUR slice, not a hardcoded 0. */ + public int getSliceIndex() { + return flexRadio.getActiveSliceIndex(); + } + public OnWaveDataReceived getOnWaveDataReceived() { return onWaveDataReceived; } @@ -356,4 +366,10 @@ public static float[] getMonoFloatFromBytes(byte[] bytes) { public boolean isConnected() { return flexRadio.isConnect(); } + + /** IP of the Flex this connector is bound to, so a duplicate connect to the + * same radio can be detected and skipped (see MainViewModel.connectFlexRadioRig). */ + public String getFlexIp() { + return flexRadio != null ? flexRadio.getIp() : null; + } } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexMeterInfos.java b/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexMeterInfos.java index 04426a7be..5e0081aa7 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexMeterInfos.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexMeterInfos.java @@ -56,19 +56,6 @@ public synchronized void setMeterInfos(String content) { } if (val[0].toLowerCase().contains(".nam")) { meterInfo.nam = val[1]; - //For fast MeterList lookup - if (val[1].toUpperCase().contains("LEVEL")) { - sMeterId = index; - } else if (val[1].toUpperCase().contains("PATEMP")) { - tempCId = index; - } else if (val[1].toUpperCase().contains("SWR")) { - swrId = index; - } else if (val[1].toUpperCase().contains("FWDPWR")) { - pwrId = index; - } else if (val[1].toUpperCase().contains("ALC")) { - alcId = index; - } - } if (val[0].toLowerCase().contains(".low")) { try { meterInfo.low = Float.parseFloat(val[1]); } @@ -105,6 +92,7 @@ public synchronized void setMeterInfos(String content) { } } } + resolveMeterIds(); // sMeterId=getMeterId("LEVEL"); // tempCId=getMeterId("PATEMP"); // swrId=getMeterId("SWR"); @@ -112,6 +100,42 @@ public synchronized void setMeterInfos(String content) { // alcId=getMeterId("ALC"); } + /** + * Re-resolve the five fast-lookup meter ids from the full accumulated meter + * table, deterministically. A Flex reports DUPLICATE meters with the same + * name — a second receive slice has its own LEVEL meter, and a second + * transmit block (num=9) has its own ALC — so the original "remember the + * id of whatever name I last saw" logic latched ALC onto the inactive id33 + * and the S-meter onto the unused slice-1 id27 (both idle at -150), which is + * why those two meters read dead while temp/SWR/PWR (unique names) were fine. + * + * Fix: scan every known meter in ascending id order and keep the LOWEST id + * whose name matches exactly. The lowest id is the original receive slice / + * the active transmit block — the meter that actually moves. Rescanning the + * whole table on every call makes this idempotent: re-requesting the meter + * list (e.g. each time the HUD opens) can no longer flip a good id to a + * duplicate, even when this FlexMeterInfos instance is reused across reconnects. + */ + private synchronized void resolveMeterIds() { + sMeterId = -1; + tempCId = -1; + swrId = -1; + pwrId = -1; + alcId = -1; + java.util.List keys = new java.util.ArrayList<>(keySet()); + java.util.Collections.sort(keys); + for (int key : keys) { + FlexMeterInfo info = get(key); + if (info == null || info.nam == null) continue; + String name = info.nam.toUpperCase(); + if (sMeterId == -1 && name.equals("LEVEL")) sMeterId = key; + else if (tempCId == -1 && name.equals("PATEMP")) tempCId = key; + else if (swrId == -1 && name.equals("SWR")) swrId = key; + else if (pwrId == -1 && name.equals("FWDPWR")) pwrId = key; + else if (alcId == -1 && name.equals("ALC")) alcId = key; + } + } + /** * Looks up Meter ID; returns -1 if not found * diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java b/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java index 4f719a25f..6d4016f83 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java @@ -59,6 +59,10 @@ public static int getStreamPort() {//Get UDP port for streaming, auto-increment private String callsign;//=FlexRADIO private String ip = "";//=192.168.3.86 private int port = 4992;//=4992//TCP port for controlling the radio + // The radio's fixed VITA-49 UDP data port (TCP command port 4992 minus 1). Inbound + // DAX-TX audio must go here — the radio streams RX *from* an ephemeral port (~4993) + // but listens for our TX audio on this fixed port. + public static final int FLEX_VITA_DATA_PORT = 4991; private String status;//=Available private String inUse_ip;//=192.168.3.5 private String inUse_host;//=DESKTOP-RR564NK.local @@ -100,6 +104,15 @@ public static int getStreamPort() {//Get UDP port for streaming, auto-increment private long panadapterStreamId = 0; private final HashSet streamIdSet = new HashSet<>(); + // Index of the slice this app owns and transmits on. The radio assigns it when we + // create our slice (see createdSliceIndex); 0 is only a fallback. EVERY slice op + // (tune, mode, filter, DAX bind, TX claim) must target THIS index — hardcoding 0 + // meant configuring/keying a slice we don't own when the radio already had a slice, + // so the keyed slice had no DAX modulation (0 W TX). + private int activeSliceIndex = 0; + // Slice index parsed from the most recent "slice create" response; -1 until known. + public int createdSliceIndex = -1; + //************************Event handler interfaces******************************* private OnReceiveDataListener onReceiveDataListener;//Current data receive event private OnTcpConnectStatus onTcpConnectStatus;//TCP connection status change event @@ -385,7 +398,6 @@ public void OnReceiveData(DatagramSocket socket, DatagramPacket packet, byte[] d //Log.e(TAG, String.format("OnReceiveData: stream id:0x%x,class id:0x%x",vita.streamId,vita.classId) ); switch (vita.classId) { case VITA.FLEX_DAX_AUDIO_CLASS_ID://Audio data - //Log.e(TAG, String.format("FLEX_DAX_AUDIO_CLASS_ID stream id:0x%x",vita.streamId )); doReceiveAudio(vita.payload); break; case VITA.FLEX_DAX_IQ_CLASS_ID://IQ data @@ -465,7 +477,7 @@ public void sendWaveData(float[] data) { //streamTxId=0x084000001; // class id=0x00 00 1c 2d 53 4c 01 23???? //One packet every 5ms? Stereo, 256 floats total - Log.e(TAG, String.format("sendWaveData: streamid:0x%x,ip:%s,port:%d",streamTxId,ip, port) ); + Log.d(TAG, String.format("sendWaveData: txStreamId:0x%x,ip:%s,streamPort:%d",daxTxAudioStreamId,ip, flexStreamPort) ); new Thread(new Runnable() { @Override public void run() { @@ -494,13 +506,26 @@ public void run() { //Log.e(TAG, String.format("run: daxTxAudioStreamId:0x%X",daxTxAudioStreamId) ); //daxTxAudioStreamId,0x534c0123, vita.streamId=daxTxAudioStreamId; - vita.classId = 0x534c0123; - vita.classId64 = 0x00001c2d534c0123L; + // DAX TX audio must carry the DAX-audio class id (0x534C03E3, + // FlexRadio OUI 0x001C2D) — the SAME class the radio uses for the + // DAX audio stream we already decode on RX (VITA.FLEX_DAX_AUDIO_CLASS_ID). + // The old value 0x534C0123 was not the DAX-audio class, so the radio + // keyed but discarded our audio (ALC stuck at the ~-70 dBFS keyed-noise + // floor, 0 W forward) — which is why DAX TX worked in other apps but + // never this one. Direction (tx vs rx) is set by the stream id, not the class. + vita.classId = VITA.FLEX_DAX_AUDIO_CLASS_ID; + vita.classId64 = 0x00001c2dL << 32 | (VITA.FLEX_DAX_AUDIO_CLASS_ID & 0xffffffffL); byte[] send = vita.audioFloatDataToVita(packetCount, voice); packetCount++; try { - //Log.e(TAG, String.format("run: send ip:%s, port:%d",ip,4993) ); - streamClient.sendData(send, ip, 4993); + // Send TX audio to the radio's VITA-49 data port (4991), NOT the + // source port the radio streams RX *from* (which we observed as 4993 + // and which the old code hardcoded). The radio listens for inbound + // DAX-TX audio on its fixed VITA port (TCP command port 4992 minus 1), + // so audio sent to 4993 was never received — the radio keyed but had + // nothing to modulate (0 W). This is the likely reason DAX TX worked + // in other apps but never this one. + streamClient.sendData(send, ip, FLEX_VITA_DATA_PORT); } catch (UnknownHostException e) { throw new RuntimeException(e); } @@ -665,6 +690,10 @@ private void doReceiveLineEvent(String line) { this.daxTxAudioStreamId = response.daxTxStreamId; Log.d(TAG, String.format("doReceiveLineEvent: txStreamID:0x%x", daxTxAudioStreamId)); } + if (response.createdSliceIndex >= 0) { + this.createdSliceIndex = response.createdSliceIndex; + Log.d(TAG, "doReceiveLineEvent: createdSliceIndex=" + createdSliceIndex); + } break; } @@ -787,6 +816,35 @@ public synchronized void commandSliceCreate() { sendCommand(FlexCommand.SLICE_CREATE_FREQ, "slice create"); } + @SuppressLint("DefaultLocale") + public synchronized void commandSliceCreateAtFreq(String freq) { + // Create a new slice already tuned to freq. The response (R..|0|) + // carries the radio-assigned slice index, captured into createdSliceIndex. + sendCommand(FlexCommand.SLICE_CREATE_FREQ, String.format("slice create freq=%s", freq)); + } + + @SuppressLint("DefaultLocale") + public synchronized void commandSliceSetActiveTx(int sliceOder) { + // Make this slice the transmit slice so PTT keys the slice we feed DAX audio to. + // Without this the radio kept whatever slice already had tx=1 (e.g. a SmartSDR + // USB slice) as the transmit slice, so keying produced no DAX-modulated RF. + sendCommand(FlexCommand.SLICE_SET_TX_ANT, String.format("slice s %d tx=1", sliceOder)); + } + + public int getActiveSliceIndex() { + return activeSliceIndex; + } + + public void setActiveSliceIndex(int index) { + this.activeSliceIndex = index; + } + + /** Reset slice ownership state so a fresh connection re-discovers/creates its slice. */ + public void clearSliceState() { + activeSliceIndex = 0; + createdSliceIndex = -1; + } + @SuppressLint("DefaultLocale") public synchronized void commandSliceTune(int sliceOder, String freq) { sendCommand(FlexCommand.SLICE_TUNE, String.format("slice t %d %s", sliceOder, freq)); @@ -951,6 +1009,24 @@ public synchronized void commandSetTunePower(int power) { sendCommand(FlexCommand.AUT_TUNE_MAX_POWER, String.format("transmit set tunepower=%d", power)); } + @SuppressLint("DefaultLocale") + public synchronized void commandDaxTx(boolean on) { + // Enable the DAX-TX subsystem itself (the "dax tx" command, distinct from + // "dax audio set ... tx=1" which only routes a channel, and from "transmit set + // dax=1" which selects DAX as the transmit source). Without this the DAX TX audio + // path is not fully opened, so the audio reaches the modulator heavily attenuated. + sendCommand(FlexCommand.DAX_AUDIO, String.format("dax tx %d", on ? 1 : 0)); + } + + @SuppressLint("DefaultLocale") + public synchronized void commandSetTransmitDax(boolean on) { + // Tell the radio to USE the DAX audio stream as its transmit source. Creating the + // dax_tx stream and "dax audio set slice= tx=1" only route/enable the stream; + // WITHOUT "transmit set dax=1" the radio keys but ignores the DAX audio (ALC stays at + // the noise floor, 0 W). This is the toggle other DAX apps set and this one never did. + sendCommand(FlexCommand.TRANSMIT_POWER, String.format("transmit set dax=%d", on ? 1 : 0)); + } + public synchronized void commandPTTOnOff(boolean on) { if (on) { sendCommand(FlexCommand.PTT_ON, "xmit 1"); @@ -1049,6 +1125,7 @@ public static class FlexResponse { public long daxStreamId = 0; public int daxTxStreamId = 0; public long panadapterStreamId = 0; + public int createdSliceIndex = -1;//slice index from a "slice create" response; -1 if none public FlexCommand flexCommand = FlexCommand.UNKNOW; public long resultValue = 0; @@ -1091,6 +1168,9 @@ public FlexResponse(String line) { case STREAM_CREATE_DAX_TX: this.daxTxStreamId = getStreamId(line); break; + case SLICE_CREATE_FREQ: + this.createdSliceIndex = getSliceIndex(line); + break; } resultValue = Integer.parseInt(content, 16);//Get the command's return value @@ -1157,7 +1237,13 @@ private int getStreamId(String line) { if (lines.length > 2) { if (lines[1].equals("0")) { try { - return Integer.parseInt(lines[2], 16);//stream id, hexadecimal + // Parse as long then narrow to int: a DAX *TX* stream id is + // 0x84000000+, which overflows Integer.parseInt (it rejects + // values past 0x7FFFFFFF) and threw — leaving the TX stream id + // 0, so transmit audio went to stream 0x0 and the radio keyed + // with no modulation (0 W). The (int) cast keeps the correct + // 32 bits. RX/panadapter ids (0x04.., 0x40..) were unaffected. + return (int) Long.parseLong(lines[2], 16);//stream id, hexadecimal } catch (NumberFormatException e) { e.printStackTrace(); Log.e(TAG, "getDaxStreamId exception: " + e.getMessage()); @@ -1167,6 +1253,24 @@ private int getStreamId(String line) { return 0; } + /** + * Parse the slice index from a "slice create" response: {@code R|0|} + * where {@code } is the radio-assigned slice number (decimal). Returns -1 + * when the response is an error or unparseable. Static + package-visible so the + * parse can be unit-tested without a radio. + */ + static int getSliceIndex(String line) { + String[] parts = line.split("\\|"); + if (parts.length > 2 && parts[1].equals("0")) { + try { + return Integer.parseInt(parts[2].trim()); + } catch (NumberFormatException e) { + // not a numeric slice index — fall through to -1 + } + } + return -1; + } + /** * Split the message into header and content, assigning them to head and content respectively * diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/flex/VITA.java b/ft8af/app/src/main/java/com/k1af/ft8af/flex/VITA.java index 7560ffd1b..30708115d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/flex/VITA.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/flex/VITA.java @@ -234,10 +234,21 @@ public byte[] audioFloatDataToVita(int count, float[] data) { packetType = VitaPacketType.EXT_DATA_WITH_STREAM; classIdPresent = true; trailerPresent = false;//No trailer - tsi = VitaTSI.TSI_OTHER;// + // Timestamp-integer type MUST be TSI_UTC (1) to match the DAX-audio packets the + // radio itself sends/accepts. We were sending TSI_OTHER (3); the radio rejected + // those packets, so it keyed but had no modulation (0 W). Confirmed by logging the + // working RX format (tsi=1) vs our TX format (tsi=3) — the only differing field. + tsi = VitaTSI.TSI_UTC; tsf = VitaTSF.TSF_SAMPLE_COUNT; this.packetCount = count; - this.packetSize = (data.length / 2) + 7; + // VITA packet_size is the TOTAL number of 32-bit words in the packet: + // 7 header words + one word per float sample (each float = 4 bytes = 1 word). + // The old "(data.length / 2) + 7" under-counted by half, so the declared size + // (135 words) didn't match the real datagram (263 words); a strict VITA + // receiver (SmartSDR) drops a packet whose declared size disagrees with its + // length, which left the keyed radio with no modulation (ALC at the noise + // floor, 0 W forward). + this.packetSize = data.length + 7; //----HEADER--No.1 word------ diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/FlexNetworkRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/FlexNetworkRig.java index 2a6c77995..6c6cb4436 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/FlexNetworkRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/FlexNetworkRig.java @@ -57,7 +57,7 @@ public boolean isConnected() { @Override public void setUsbModeToRig() { if (getConnector() != null) { - commandSliceSetMode(0, FlexRadio.FlexMode.DIGU);//set operating mode + commandSliceSetMode(sliceIndex(), FlexRadio.FlexMode.DIGU);//set operating mode on OUR slice } } @@ -65,10 +65,19 @@ public void setUsbModeToRig() { @Override public void setFreqToRig() { if (getConnector() != null) { - commandSliceTune(0, String.format("%.3f", getFreq() / 1000000f)); + commandSliceTune(sliceIndex(), String.format("%.6f", getFreq() / 1000000f));//retune OUR slice } } + /** The slice index the app owns, so band/frequency changes act on our slice rather + * than a hardcoded 0 (which could be a slice we don't own — see FlexConnector). */ + private int sliceIndex() { + if (getConnector() instanceof FlexConnector) { + return ((FlexConnector) getConnector()).getSliceIndex(); + } + return 0; + } + @Override public void onReceiveData(byte[] data) { diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexResponseSliceTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexResponseSliceTest.java new file mode 100644 index 000000000..a089a135d --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexResponseSliceTest.java @@ -0,0 +1,45 @@ +package com.k1af.ft8af.flex; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-logic coverage for parsing the slice index out of a Flex "slice create" + * response. The app must own the slice the radio actually assigns (not a hardcoded + * 0); this is the parse that captures that index. See + * {@link com.k1af.ft8af.flex.FlexRadio.FlexResponse#getSliceIndex(String)} and its + * use in FlexConnector.onResponse(SLICE_CREATE_FREQ). + */ +public class FlexResponseSliceTest { + + @Test + public void parsesIndexFromSuccessfulCreate() { + // R|0| — result 0 = success, third field is the slice number. + assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0|0")).isEqualTo(0); + assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0|1")).isEqualTo(1); + assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0|2")).isEqualTo(2); + } + + @Test + public void toleratesWhitespaceAroundIndex() { + assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0| 1 ")).isEqualTo(1); + } + + @Test + public void returnsMinusOneOnErrorResult() { + // Non-zero result code = the create failed; there is no valid index. + assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|50000005|0")).isEqualTo(-1); + } + + @Test + public void returnsMinusOneOnNonNumericIndex() { + assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0|abc")).isEqualTo(-1); + } + + @Test + public void returnsMinusOneWhenIndexMissing() { + assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0")).isEqualTo(-1); + assertThat(FlexRadio.FlexResponse.getSliceIndex("")).isEqualTo(-1); + } +}