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() + } +}