diff --git a/ft8af/app/src/main/AndroidManifest.xml b/ft8af/app/src/main/AndroidManifest.xml
index 9a6ff9e1..872a3e4c 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/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java
index 401b5389..643438c5 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.
@@ -366,6 +369,21 @@ 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;
+ // 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;
+
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/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java
index 3bda4674..6c56565d 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,16 @@ 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);
+ }
+ // 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
@@ -1290,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 78ba8031..5a00389a 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/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java
index 07d846c4..8de1e676 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");
}
@@ -2667,6 +2670,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/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java
index 85dffc18..3d7274e0 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 083802af..afb82ded 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
@@ -49,6 +53,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.MetersHandle
import radio.ks3ckc.ft8af.ui.components.formatMhz
import radio.ks3ckc.ft8af.ui.components.QsoCelebration
import radio.ks3ckc.ft8af.ui.components.SlotTimerBar
@@ -159,6 +165,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()
@@ -488,7 +497,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 },
)
@@ -640,5 +671,22 @@ fun FT8AFApp(mainViewModel: MainViewModel) {
}
},
)
+
+ // 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),
+ )
+
+ // 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/CatStatusChip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt
index 27fa2fd0..a03aaef7 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/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 00000000..98ed0f9f
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt
@@ -0,0 +1,272 @@
+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.
+ * 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 }
+
+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
+
+// ---------------------------------------------------------------------------
+// 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 }
+
+/** 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
new file mode 100644
index 00000000..8b08c33a
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt
@@ -0,0 +1,466 @@
+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.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
+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.mutableIntStateOf
+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 com.k1af.ft8af.GeneralVariables
+import com.k1af.ft8af.MainViewModel
+import com.k1af.ft8af.R
+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.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. 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(
+ visible: Boolean,
+ mainViewModel: MainViewModel,
+ isTransmitting: Boolean,
+ onDismiss: () -> Unit,
+) {
+ 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) {
+ 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)
+ }
+
+ // 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 {
+ // 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,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun HintText(text: String) {
+ Text(
+ text = text,
+ color = TextMuted,
+ fontSize = 11.sp,
+ fontFamily = GeistMonoFamily,
+ )
+}
+
+/**
+ * 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) =
+ 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)),
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * 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 MetersHandle(
+ enabled: Boolean,
+ onOpen: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (!enabled) return
+ val density = LocalDensity.current
+ val openThresholdPx = with(density) { 24.dp.toPx() }
+ var totalDy by remember { mutableFloatStateOf(0f) }
+ Box(
+ modifier =
+ modifier
+ .statusBarsPadding()
+ // Generous touch target around the small visible tab.
+ .pointerInput(Unit) {
+ detectVerticalDragGestures(
+ onDragStart = { totalDy = 0f },
+ onDragEnd = {
+ if (shouldOpenFromEdgeDrag(totalDy, openThresholdPx)) onOpen()
+ totalDy = 0f
+ },
+ 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,
+ )
+ }
+ }
+}
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 00000000..8cf98132
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSource.kt
@@ -0,0 +1,112 @@
+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),
+ )
+}
+
+/**
+ * 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/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt
index 9244175e..c14145d2 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,19 +252,55 @@ 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
+ // 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, how: String) {
+ GeneralVariables.fileLog("FlexDiscovery: connect ($how) model=${radio.model} ip=${radio.ip}")
+ mainViewModel.connectFlexRadioRig(GeneralVariables.getMainContext(), radio)
+ onDismiss()
+ }
- // Subscribe to FlexRadioFactory discovery events
+ // 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)
+ 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})",
+ )
+ // 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()
@@ -269,7 +308,35 @@ 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 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(
+ discoveredCount = discoveredRadios.size,
+ userTypedIp = ipText.isNotBlank(),
+ alreadyTriggered = autoTriggered,
+ startedEmpty = startedEmpty,
+ rigAlreadyConnected = mainViewModel.isRigConnected(),
+ )
+ ) {
+ autoTriggered = true
+ val radio = discoveredRadios.first()
+ ToastMessage.show(
+ String.format(
+ GeneralVariables.getStringFromResource(R.string.select_flex_device),
+ radio.model,
+ ),
+ )
+ connect(radio, "auto")
+ }
}
Dialog(onDismissRequest = onDismiss) {
@@ -290,50 +357,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 +392,7 @@ fun FlexRadioPickerDialog(
radio.model,
),
)
- mainViewModel.connectFlexRadioRig(
- GeneralVariables.getMainContext(),
- radio,
- )
- onDismiss()
+ connect(radio, "tap")
}
.padding(horizontal = 24.dp, vertical = 10.dp),
) {
@@ -374,6 +412,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, "manual")
+ }
+ }
+ } 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 00000000..aaded12d
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt
@@ -0,0 +1,41 @@
+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.
+ *
+ * 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.
+ */
+/**
+ * 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,
+ alreadyTriggered: Boolean,
+ startedEmpty: Boolean,
+ rigAlreadyConnected: Boolean,
+): Boolean =
+ discoveredCount == 1 &&
+ !userTypedIp &&
+ !alreadyTriggered &&
+ startedEmpty &&
+ !rigAlreadyConnected
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 00000000..f79c77fd
--- /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 aec1fbb5..14a9555f 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 23145e7b..4deada73 100644
--- a/ft8af/app/src/main/res/values/strings_compose.xml
+++ b/ft8af/app/src/main/res/values/strings_compose.xml
@@ -12,6 +12,11 @@
-->
+
+ Searching for FlexRadio on your network…
+ Enter IP manually
+ Set up your radio in Settings → Radio & Audio first.
+
Cancel
Save
@@ -134,6 +139,33 @@
DISMISS
TX blocked — SWR lockout active. Dismiss the lockout banner first.
+
+ METERS
+ ALC
+ SWR
+ PWR
+ S
+ VOLTS
+ TEMP
+ TX POWER
+ 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
Searching...
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 e53fc66b..6549ec2f 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)
+ }
}
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 00000000..74da4683
--- /dev/null
+++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt
@@ -0,0 +1,320 @@
+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()
+ }
+
+ // ---- 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()
+ }
+
+ // ---- 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)
+ }
+}
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 00000000..e00260b3
--- /dev/null
+++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt
@@ -0,0 +1,86 @@
+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 {
+
+ /** 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_freshSingleDiscovery_noTyping_notConnected() {
+ assertThat(autoConnect()).isTrue()
+ }
+
+ @Test
+ fun doesNotAutoConnect_whenNothingFound() {
+ assertThat(autoConnect(discoveredCount = 0)).isFalse()
+ }
+
+ @Test
+ fun doesNotAutoConnect_whenMultipleFound() {
+ // Ambiguous which one the user wants — leave it to a tap.
+ assertThat(autoConnect(discoveredCount = 3)).isFalse()
+ }
+
+ @Test
+ fun doesNotAutoConnect_whenUserIsTypingAnIp() {
+ assertThat(autoConnect(userTypedIp = true)).isFalse()
+ }
+
+ @Test
+ fun doesNotAutoConnect_onceAlreadyTriggered() {
+ // One-shot: a later discovery event can't re-fire the auto-connect.
+ 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()
+ }
+
+ // ---- 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()
+ }
+}