diff --git a/ft8af/app/src/main/AndroidManifest.xml b/ft8af/app/src/main/AndroidManifest.xml
index 9a6ff9e1d..872a3e4c1 100644
--- a/ft8af/app/src/main/AndroidManifest.xml
+++ b/ft8af/app/src/main/AndroidManifest.xml
@@ -9,6 +9,10 @@
+
+
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ExecutorUtils.java b/ft8af/app/src/main/java/com/k1af/ft8af/ExecutorUtils.java
new file mode 100644
index 000000000..bf07f1c5d
--- /dev/null
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/ExecutorUtils.java
@@ -0,0 +1,45 @@
+package com.k1af.ft8af;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.RejectedExecutionException;
+
+/**
+ * Helpers for submitting work to executors that may be torn down concurrently.
+ *
+ *
The decode pipeline runs on its own thread and calls back into
+ * {@code MainViewModel} after each pass to kick off background work (QTH/grid
+ * lookups, network TX audio). Those pools are shut down in
+ * {@code MainViewModel.onCleared()} when the ViewModel is destroyed (app close,
+ * connection reset/reconnect). A decode result that lands in the same instant
+ * the pool is shut down would otherwise reach {@link ExecutorService#execute}
+ * on a terminated pool and throw {@link RejectedExecutionException} on the
+ * decode thread, crashing the app. See the {@code afterDecode} race fixed
+ * alongside this helper.
+ */
+public final class ExecutorUtils {
+
+ private ExecutorUtils() {
+ }
+
+ /**
+ * Submit {@code task} to {@code pool}, tolerating a pool that is shutting
+ * down. The {@link ExecutorService#isShutdown()} check avoids the common
+ * case; the catch covers the narrow race where the pool is shut down
+ * between the check and {@link ExecutorService#execute}.
+ *
+ * @return {@code true} if the task was accepted, {@code false} if it was
+ * dropped because the pool (or task) was unavailable.
+ */
+ public static boolean safeExecute(ExecutorService pool, Runnable task) {
+ if (pool == null || task == null || pool.isShutdown()) {
+ return false;
+ }
+ try {
+ pool.execute(task);
+ return true;
+ } catch (RejectedExecutionException e) {
+ // Pool was shut down concurrently (ViewModel teardown). Drop the task.
+ return false;
+ }
+ }
+}
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java
index 401b53892..643438c51 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 3bda46748..a44b74e43 100644
--- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java
@@ -591,7 +591,10 @@ public void afterDecode(long utc, float time_sec, int sequential
getQTHRunnable.messages = messages;
- getQTHThreadPool.execute(getQTHRunnable);//query location via thread pool
+ //query location via thread pool; guarded so a decode landing during
+ //ViewModel teardown (pool shut down in onCleared()) drops the lookup
+ //instead of crashing with RejectedExecutionException.
+ ExecutorUtils.safeExecute(getQTHThreadPool, getQTHRunnable);
//this variable also notifies message list changes
mutable_Decoded_Counter.postValue(
@@ -685,8 +688,8 @@ public void onTransmitByWifi(Ft8Message msg) {
if (baseRig.isConnected()) {
sendWaveDataRunnable.baseRig = baseRig;
sendWaveDataRunnable.message = msg;
- //send network data packets via thread pool
- sendWaveDataThreadPool.execute(sendWaveDataRunnable);
+ //send network data packets via thread pool (guarded against teardown race)
+ ExecutorUtils.safeExecute(sendWaveDataThreadPool, sendWaveDataRunnable);
}
}
}
@@ -714,7 +717,7 @@ public void onTransmitOverCAT(Ft8Message msg) {//send audio message via CAT
}
sendWaveDataRunnable.baseRig = baseRig;
sendWaveDataRunnable.message = msg;
- sendWaveDataThreadPool.execute(sendWaveDataRunnable);
+ ExecutorUtils.safeExecute(sendWaveDataThreadPool, sendWaveDataRunnable);
}
}, new OnTransmitSuccess() {//when QSO is successful
@@ -1274,7 +1277,32 @@ public void run() {
* @param context context
* @param flexRadio FlexRadio object
*/
+ // IP of a Flex connect currently in flight (set when a connect starts, cleared
+ // when it succeeds or fails), so two near-simultaneous connect requests to the
+ // same radio don't both run the connect sequence. See the guard below.
+ private volatile String flexConnectInProgressIp = null;
+
public void connectFlexRadioRig(Context context, FlexRadio flexRadio) {
+ final String requestedIp = (flexRadio != null) ? flexRadio.getIp() : null;
+ // Guard against a DUPLICATE connect to the same Flex. Two UI paths can each
+ // fire a connect — the network picker's auto-connect and the CAT chip / a
+ // manual tap — and a second connect re-runs `client disconnect` + `slice
+ // create`, which spawns a SECOND slice on the radio (duplicate LEVEL/ALC
+ // meters) and tears down the GUI client whose meter subscription is live, so
+ // meters stream briefly then die. If we're already connected to this IP, or a
+ // connect to it is already in flight, make this call a no-op.
+ if (requestedIp != null && !requestedIp.isEmpty()) {
+ if (requestedIp.equals(flexConnectInProgressIp)) {
+ return;
+ }
+ if (baseRig != null && baseRig.getConnector() instanceof FlexConnector
+ && baseRig.getConnector().isConnected()
+ && requestedIp.equals(((FlexConnector) baseRig.getConnector()).getFlexIp())) {
+ setCatConnectionState(CatConnectionState.CONNECTED);
+ return;
+ }
+ }
+ flexConnectInProgressIp = requestedIp;
if (GeneralVariables.connectMode == ConnectMode.NETWORK) {
if (baseRig != null) {
if (baseRig.getConnector() != null) {
@@ -1283,6 +1311,26 @@ public void connectFlexRadioRig(Context context, FlexRadio flexRadio) {
}
}
GeneralVariables.controlMode = ControlMode.CAT;//network control mode
+ // A FlexRadio always uses network (DAX) audio — force NETWORK connect mode
+ // so TX and RX audio route over the network. Otherwise a stale connectMode
+ // (e.g. left as USB_CABLE from a previously-attached USB sound card) sends
+ // TX audio to that USB output instead of the Flex (radio transmits 0 W) and
+ // pulls RX from the phone mic instead of DAX. Also clear any stale USB-audio
+ // output override for the same reason.
+ GeneralVariables.connectMode = ConnectMode.NETWORK;
+ databaseOpr.writeConfig("connectMode", String.valueOf(ConnectMode.NETWORK), null);
+ GeneralVariables.usbAudioOutputVendorId = 0;
+ GeneralVariables.usbAudioOutputProductId = 0;
+ // Remember the address so the CAT chip can reconnect on a cold start
+ // without re-opening the picker.
+ if (flexRadio != null && flexRadio.getIp() != null && !flexRadio.getIp().isEmpty()) {
+ 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 +1338,22 @@ 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() {
+ flexConnectInProgressIp = null;
+ setCatConnectionState(CatConnectionState.CONNECTED);
+ ToastMessage.show(getStringFromResource(R.string.connected_rig));
+ }
+ @Override
+ public void onFailed() {
+ flexConnectInProgressIp = null;
+ setCatConnectionState(CatConnectionState.ERROR);
+ }
+ });
flexConnector.connect();
connectRig();
@@ -1527,6 +1591,15 @@ public void reconnectRig() {
if (baseRig == null || baseRig.getConnector() == null) {
return;
}
+ // Don't tear down a live or in-progress connection. Repeated CAT-chip taps would
+ // otherwise call connect() again on the Flex, opening a parallel TCP session whose
+ // "client gui" mints a new handle and orphans the slice/streams of the prior
+ // session — leaving the radio "connected" but owning nothing. Only reconnect when
+ // genuinely disconnected.
+ if (catConnectionState == CatConnectionState.CONNECTING
+ || catConnectionState == CatConnectionState.CONNECTED) {
+ return;
+ }
setCatConnectionState(CatConnectionState.CONNECTING);
baseRig.getConnector().connect();
}
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java
index 78ba8031a..53381ee2c 100644
--- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java
@@ -32,14 +32,32 @@ 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;
+ // True once at least one meter value packet has arrived, so the HUD can tell
+ // "no meter stream yet" apart from genuine zero readings.
+ public volatile boolean meterDataReceived = false;
+ // True once the slice + DAX streams have been created for this logical connection.
+ // Guards the one-time setup so a TCP reconnect (which re-fires onConnectSuccess)
+ // doesn't spawn duplicate slices/streams. Reset in disconnect().
+ private volatile boolean slicesConfigured = false;
private static final String TAG = "FlexConnector";
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) {
@@ -89,7 +107,9 @@ public void onReceiveMeter(VITA vita) {
meterList.setMeters(vita.payload,flexMeterInfos);
mutableMeterList.postValue(meterList);
-
+ if (!meterDataReceived) {
+ meterDataReceived = true;
+ }
}
@Override
@@ -122,6 +142,19 @@ public void onResponse(FlexRadio.FlexResponse response) {
if (response.flexCommand==FlexCommand.STREAM_CREATE_DAX_TX){
flexRadio.streamTxId=response.daxTxStreamId;
}
+ if (response.flexCommand==FlexCommand.SLICE_CREATE_FREQ){
+ // The radio assigns the new slice's index in this response; configure
+ // THAT slice (not a hardcoded 0) for FT8 and claim it as the transmit
+ // slice. Doing it here — once the index is known — is what makes the
+ // keyed slice the same slice we feed DAX audio to (real forward power),
+ // and makes us own a specific slice instead of stomping slice 0.
+ int slice = response.createdSliceIndex >= 0 ? response.createdSliceIndex : 0;
+ flexRadio.setActiveSliceIndex(slice);
+ flexRadio.commandSliceSetMode(slice, FlexRadio.FlexMode.DIGU);
+ flexRadio.commandSetFilter(slice, 0, 3000);
+ flexRadio.commandSetDaxAudio(1, slice, true);//bind DAX ch1 audio to our slice
+ flexRadio.commandSliceSetActiveTx(slice);//make our slice the transmit slice
+ }
// if (response.flexCommand== FlexCommand.METER_LIST){
// Log.e(TAG, "onResponse: ."+response.rawData.replace("#","\n") );
@@ -148,44 +181,25 @@ public void onConnectSuccess(RadioTcpClient tcpClient) {
ToastMessage.show(String.format(GeneralVariables.getStringFromResource(R.string.init_flex_operation)
,flexRadio.getModel()));
- flexRadio.commandClientDisconnect();//Disconnect all previous connections
- flexRadio.commandClientGui();//Create GUI
-
- flexRadio.commandSubDaxAll();//Register all DAX streams
-
-
-
- flexRadio.commandClientSetEnforceNetWorkGui();//Configure network MTU settings
-
- //flexRadio.commandSliceList();//List slices
- flexRadio.commandSliceCreate();//Create slice
-
-
-
-
-
- //todo To prevent stream ports from not being released, change the port?
- //FlexRadio.streamPort++;
-
- flexRadio.commandUdpPort();//Set UDP port
-
-
- flexRadio.commandStreamCreateDaxRx(1);//Create stream data to DAX channel 1
- flexRadio.commandStreamCreateDaxTx(1);//Create stream data to DAX channel 1
-
- flexRadio.commandSetDaxAudio(1, 0, true);//Enable DAX
-
- //TODO Should we set this??? dax tx T or dax tx 1
- flexRadio.commandSliceTune(0,String.format("%.3f",GeneralVariables.band/1000000f));
- flexRadio.commandSliceSetMode(0, FlexRadio.FlexMode.DIGU);//Set operating mode
- flexRadio.commandSetFilter(0, 0, 3000);//Set filter to 3000 Hz
-
-
- flexRadio.commandMeterList();//List the meters
- //flexRadio.commandSubMeterAll();//Subscription command moved to the response handling section
-
- setMaxRfPower(maxRfPower);//set transmit power
- setMaxTunePower(maxTunePower);//Set tune power
+ // (full setup moved into the guarded block below — runs once per connection)
+
+ if (!slicesConfigured) {
+ slicesConfigured = true;
+ flexRadio.commandClientDisconnect();//Disconnect all previous connections
+ flexRadio.commandClientGui();//Create GUI
+ flexRadio.commandSubDaxAll();//Register all DAX streams
+ flexRadio.commandClientSetEnforceNetWorkGui();//Configure network MTU settings
+ flexRadio.commandUdpPort();//Set UDP port
+ flexRadio.commandStreamCreateDaxRx(1);//Create stream data to DAX channel 1
+ flexRadio.commandStreamCreateDaxTx(1);//Create stream data to DAX channel 1
+ flexRadio.commandSliceCreateAtFreq(
+ String.format("%.6f", GeneralVariables.band / 1000000f));//Create our slice, tuned
+ flexRadio.commandMeterList();//List the meters (subscription happens in the response)
+ setMaxRfPower(maxRfPower);//set transmit power
+ setMaxTunePower(maxTunePower);//Set tune power
+ flexRadio.commandDaxTx(true);//enable the DAX-TX subsystem (fully open the audio path)
+ flexRadio.commandSetTransmitDax(true);//USE the DAX audio stream as the TX source
+ }
//flexRadio.commandSubMeterById(5);//List a specific meter
@@ -209,13 +223,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();
}
});
@@ -246,6 +261,17 @@ public void subAllMeters(){
}
}
+ /**
+ * Force a meter (re)subscription regardless of cached meter-info state. The
+ * connect-time subscription is gated on a METER_LIST response arriving; this
+ * unconditional path is what the meters HUD calls when it opens, so meters
+ * stream even if that response was missed. Safe to call repeatedly.
+ */
+ public void requestMeterStream(){
+ flexRadio.commandMeterList();
+ flexRadio.commandSubMeterAll();
+ }
+
@Override
public void sendData(byte[] data) {
flexRadio.sendData(data);
@@ -271,6 +297,10 @@ public void sendWaveData(float[] data) {
@Override
public void connect() {
+ // Re-arm the one-time setup for this fresh connection attempt. onConnectSuccess
+ // then runs the full client/gui + slice/stream setup exactly once; if it fires
+ // again for the same attempt it is a no-op (no re-gui, no orphaned slice).
+ slicesConfigured = false;
super.connect();
flexRadio.openAudio();
flexRadio.connect();
@@ -280,11 +310,21 @@ public void connect() {
@Override
public void disconnect() {
super.disconnect();
+ // Re-arm the one-time slice setup so the next connection creates a fresh slice
+ // and re-discovers its index, rather than reusing stale ownership state.
+ slicesConfigured = false;
+ flexRadio.clearSliceState();
flexRadio.closeAudio();
flexRadio.closeStreamPort();
flexRadio.disConnect();
}
+ /** The slice index this connector owns and transmits on (see FlexRadio.activeSliceIndex).
+ * Used by FlexNetworkRig so band/frequency changes retune OUR slice, not a hardcoded 0. */
+ public int getSliceIndex() {
+ return flexRadio.getActiveSliceIndex();
+ }
+
public OnWaveDataReceived getOnWaveDataReceived() {
return onWaveDataReceived;
}
@@ -326,4 +366,10 @@ public static float[] getMonoFloatFromBytes(byte[] bytes) {
public boolean isConnected() {
return flexRadio.isConnect();
}
+
+ /** IP of the Flex this connector is bound to, so a duplicate connect to the
+ * same radio can be detected and skipped (see MainViewModel.connectFlexRadioRig). */
+ public String getFlexIp() {
+ return flexRadio != null ? flexRadio.getIp() : null;
+ }
}
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java
index 07d846c4b..8de1e6769 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/flex/FlexMeterInfos.java b/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexMeterInfos.java
index 04426a7be..5e0081aa7 100644
--- a/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexMeterInfos.java
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexMeterInfos.java
@@ -56,19 +56,6 @@ public synchronized void setMeterInfos(String content) {
}
if (val[0].toLowerCase().contains(".nam")) {
meterInfo.nam = val[1];
- //For fast MeterList lookup
- if (val[1].toUpperCase().contains("LEVEL")) {
- sMeterId = index;
- } else if (val[1].toUpperCase().contains("PATEMP")) {
- tempCId = index;
- } else if (val[1].toUpperCase().contains("SWR")) {
- swrId = index;
- } else if (val[1].toUpperCase().contains("FWDPWR")) {
- pwrId = index;
- } else if (val[1].toUpperCase().contains("ALC")) {
- alcId = index;
- }
-
}
if (val[0].toLowerCase().contains(".low")) {
try { meterInfo.low = Float.parseFloat(val[1]); }
@@ -105,6 +92,7 @@ public synchronized void setMeterInfos(String content) {
}
}
}
+ resolveMeterIds();
// sMeterId=getMeterId("LEVEL");
// tempCId=getMeterId("PATEMP");
// swrId=getMeterId("SWR");
@@ -112,6 +100,42 @@ public synchronized void setMeterInfos(String content) {
// alcId=getMeterId("ALC");
}
+ /**
+ * Re-resolve the five fast-lookup meter ids from the full accumulated meter
+ * table, deterministically. A Flex reports DUPLICATE meters with the same
+ * name — a second receive slice has its own LEVEL meter, and a second
+ * transmit block (num=9) has its own ALC — so the original "remember the
+ * id of whatever name I last saw" logic latched ALC onto the inactive id33
+ * and the S-meter onto the unused slice-1 id27 (both idle at -150), which is
+ * why those two meters read dead while temp/SWR/PWR (unique names) were fine.
+ *
+ * Fix: scan every known meter in ascending id order and keep the LOWEST id
+ * whose name matches exactly. The lowest id is the original receive slice /
+ * the active transmit block — the meter that actually moves. Rescanning the
+ * whole table on every call makes this idempotent: re-requesting the meter
+ * list (e.g. each time the HUD opens) can no longer flip a good id to a
+ * duplicate, even when this FlexMeterInfos instance is reused across reconnects.
+ */
+ private synchronized void resolveMeterIds() {
+ sMeterId = -1;
+ tempCId = -1;
+ swrId = -1;
+ pwrId = -1;
+ alcId = -1;
+ java.util.List keys = new java.util.ArrayList<>(keySet());
+ java.util.Collections.sort(keys);
+ for (int key : keys) {
+ FlexMeterInfo info = get(key);
+ if (info == null || info.nam == null) continue;
+ String name = info.nam.toUpperCase();
+ if (sMeterId == -1 && name.equals("LEVEL")) sMeterId = key;
+ else if (tempCId == -1 && name.equals("PATEMP")) tempCId = key;
+ else if (swrId == -1 && name.equals("SWR")) swrId = key;
+ else if (pwrId == -1 && name.equals("FWDPWR")) pwrId = key;
+ else if (alcId == -1 && name.equals("ALC")) alcId = key;
+ }
+ }
+
/**
* Looks up Meter ID; returns -1 if not found
*
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java b/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java
index 4f719a25f..6d4016f83 100644
--- a/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java
@@ -59,6 +59,10 @@ public static int getStreamPort() {//Get UDP port for streaming, auto-increment
private String callsign;//=FlexRADIO
private String ip = "";//=192.168.3.86
private int port = 4992;//=4992//TCP port for controlling the radio
+ // The radio's fixed VITA-49 UDP data port (TCP command port 4992 minus 1). Inbound
+ // DAX-TX audio must go here — the radio streams RX *from* an ephemeral port (~4993)
+ // but listens for our TX audio on this fixed port.
+ public static final int FLEX_VITA_DATA_PORT = 4991;
private String status;//=Available
private String inUse_ip;//=192.168.3.5
private String inUse_host;//=DESKTOP-RR564NK.local
@@ -100,6 +104,15 @@ public static int getStreamPort() {//Get UDP port for streaming, auto-increment
private long panadapterStreamId = 0;
private final HashSet streamIdSet = new HashSet<>();
+ // Index of the slice this app owns and transmits on. The radio assigns it when we
+ // create our slice (see createdSliceIndex); 0 is only a fallback. EVERY slice op
+ // (tune, mode, filter, DAX bind, TX claim) must target THIS index — hardcoding 0
+ // meant configuring/keying a slice we don't own when the radio already had a slice,
+ // so the keyed slice had no DAX modulation (0 W TX).
+ private int activeSliceIndex = 0;
+ // Slice index parsed from the most recent "slice create" response; -1 until known.
+ public int createdSliceIndex = -1;
+
//************************Event handler interfaces*******************************
private OnReceiveDataListener onReceiveDataListener;//Current data receive event
private OnTcpConnectStatus onTcpConnectStatus;//TCP connection status change event
@@ -385,7 +398,6 @@ public void OnReceiveData(DatagramSocket socket, DatagramPacket packet, byte[] d
//Log.e(TAG, String.format("OnReceiveData: stream id:0x%x,class id:0x%x",vita.streamId,vita.classId) );
switch (vita.classId) {
case VITA.FLEX_DAX_AUDIO_CLASS_ID://Audio data
- //Log.e(TAG, String.format("FLEX_DAX_AUDIO_CLASS_ID stream id:0x%x",vita.streamId ));
doReceiveAudio(vita.payload);
break;
case VITA.FLEX_DAX_IQ_CLASS_ID://IQ data
@@ -465,7 +477,7 @@ public void sendWaveData(float[] data) {
//streamTxId=0x084000001;
// class id=0x00 00 1c 2d 53 4c 01 23????
//One packet every 5ms? Stereo, 256 floats total
- Log.e(TAG, String.format("sendWaveData: streamid:0x%x,ip:%s,port:%d",streamTxId,ip, port) );
+ Log.d(TAG, String.format("sendWaveData: txStreamId:0x%x,ip:%s,streamPort:%d",daxTxAudioStreamId,ip, flexStreamPort) );
new Thread(new Runnable() {
@Override
public void run() {
@@ -494,13 +506,26 @@ public void run() {
//Log.e(TAG, String.format("run: daxTxAudioStreamId:0x%X",daxTxAudioStreamId) );
//daxTxAudioStreamId,0x534c0123,
vita.streamId=daxTxAudioStreamId;
- vita.classId = 0x534c0123;
- vita.classId64 = 0x00001c2d534c0123L;
+ // DAX TX audio must carry the DAX-audio class id (0x534C03E3,
+ // FlexRadio OUI 0x001C2D) — the SAME class the radio uses for the
+ // DAX audio stream we already decode on RX (VITA.FLEX_DAX_AUDIO_CLASS_ID).
+ // The old value 0x534C0123 was not the DAX-audio class, so the radio
+ // keyed but discarded our audio (ALC stuck at the ~-70 dBFS keyed-noise
+ // floor, 0 W forward) — which is why DAX TX worked in other apps but
+ // never this one. Direction (tx vs rx) is set by the stream id, not the class.
+ vita.classId = VITA.FLEX_DAX_AUDIO_CLASS_ID;
+ vita.classId64 = 0x00001c2dL << 32 | (VITA.FLEX_DAX_AUDIO_CLASS_ID & 0xffffffffL);
byte[] send = vita.audioFloatDataToVita(packetCount, voice);
packetCount++;
try {
- //Log.e(TAG, String.format("run: send ip:%s, port:%d",ip,4993) );
- streamClient.sendData(send, ip, 4993);
+ // Send TX audio to the radio's VITA-49 data port (4991), NOT the
+ // source port the radio streams RX *from* (which we observed as 4993
+ // and which the old code hardcoded). The radio listens for inbound
+ // DAX-TX audio on its fixed VITA port (TCP command port 4992 minus 1),
+ // so audio sent to 4993 was never received — the radio keyed but had
+ // nothing to modulate (0 W). This is the likely reason DAX TX worked
+ // in other apps but never this one.
+ streamClient.sendData(send, ip, FLEX_VITA_DATA_PORT);
} catch (UnknownHostException e) {
throw new RuntimeException(e);
}
@@ -665,6 +690,10 @@ private void doReceiveLineEvent(String line) {
this.daxTxAudioStreamId = response.daxTxStreamId;
Log.d(TAG, String.format("doReceiveLineEvent: txStreamID:0x%x", daxTxAudioStreamId));
}
+ if (response.createdSliceIndex >= 0) {
+ this.createdSliceIndex = response.createdSliceIndex;
+ Log.d(TAG, "doReceiveLineEvent: createdSliceIndex=" + createdSliceIndex);
+ }
break;
}
@@ -787,6 +816,35 @@ public synchronized void commandSliceCreate() {
sendCommand(FlexCommand.SLICE_CREATE_FREQ, "slice create");
}
+ @SuppressLint("DefaultLocale")
+ public synchronized void commandSliceCreateAtFreq(String freq) {
+ // Create a new slice already tuned to freq. The response (R..|0|)
+ // carries the radio-assigned slice index, captured into createdSliceIndex.
+ sendCommand(FlexCommand.SLICE_CREATE_FREQ, String.format("slice create freq=%s", freq));
+ }
+
+ @SuppressLint("DefaultLocale")
+ public synchronized void commandSliceSetActiveTx(int sliceOder) {
+ // Make this slice the transmit slice so PTT keys the slice we feed DAX audio to.
+ // Without this the radio kept whatever slice already had tx=1 (e.g. a SmartSDR
+ // USB slice) as the transmit slice, so keying produced no DAX-modulated RF.
+ sendCommand(FlexCommand.SLICE_SET_TX_ANT, String.format("slice s %d tx=1", sliceOder));
+ }
+
+ public int getActiveSliceIndex() {
+ return activeSliceIndex;
+ }
+
+ public void setActiveSliceIndex(int index) {
+ this.activeSliceIndex = index;
+ }
+
+ /** Reset slice ownership state so a fresh connection re-discovers/creates its slice. */
+ public void clearSliceState() {
+ activeSliceIndex = 0;
+ createdSliceIndex = -1;
+ }
+
@SuppressLint("DefaultLocale")
public synchronized void commandSliceTune(int sliceOder, String freq) {
sendCommand(FlexCommand.SLICE_TUNE, String.format("slice t %d %s", sliceOder, freq));
@@ -951,6 +1009,24 @@ public synchronized void commandSetTunePower(int power) {
sendCommand(FlexCommand.AUT_TUNE_MAX_POWER, String.format("transmit set tunepower=%d", power));
}
+ @SuppressLint("DefaultLocale")
+ public synchronized void commandDaxTx(boolean on) {
+ // Enable the DAX-TX subsystem itself (the "dax tx" command, distinct from
+ // "dax audio set ... tx=1" which only routes a channel, and from "transmit set
+ // dax=1" which selects DAX as the transmit source). Without this the DAX TX audio
+ // path is not fully opened, so the audio reaches the modulator heavily attenuated.
+ sendCommand(FlexCommand.DAX_AUDIO, String.format("dax tx %d", on ? 1 : 0));
+ }
+
+ @SuppressLint("DefaultLocale")
+ public synchronized void commandSetTransmitDax(boolean on) {
+ // Tell the radio to USE the DAX audio stream as its transmit source. Creating the
+ // dax_tx stream and "dax audio set slice= tx=1" only route/enable the stream;
+ // WITHOUT "transmit set dax=1" the radio keys but ignores the DAX audio (ALC stays at
+ // the noise floor, 0 W). This is the toggle other DAX apps set and this one never did.
+ sendCommand(FlexCommand.TRANSMIT_POWER, String.format("transmit set dax=%d", on ? 1 : 0));
+ }
+
public synchronized void commandPTTOnOff(boolean on) {
if (on) {
sendCommand(FlexCommand.PTT_ON, "xmit 1");
@@ -1049,6 +1125,7 @@ public static class FlexResponse {
public long daxStreamId = 0;
public int daxTxStreamId = 0;
public long panadapterStreamId = 0;
+ public int createdSliceIndex = -1;//slice index from a "slice create" response; -1 if none
public FlexCommand flexCommand = FlexCommand.UNKNOW;
public long resultValue = 0;
@@ -1091,6 +1168,9 @@ public FlexResponse(String line) {
case STREAM_CREATE_DAX_TX:
this.daxTxStreamId = getStreamId(line);
break;
+ case SLICE_CREATE_FREQ:
+ this.createdSliceIndex = getSliceIndex(line);
+ break;
}
resultValue = Integer.parseInt(content, 16);//Get the command's return value
@@ -1157,7 +1237,13 @@ private int getStreamId(String line) {
if (lines.length > 2) {
if (lines[1].equals("0")) {
try {
- return Integer.parseInt(lines[2], 16);//stream id, hexadecimal
+ // Parse as long then narrow to int: a DAX *TX* stream id is
+ // 0x84000000+, which overflows Integer.parseInt (it rejects
+ // values past 0x7FFFFFFF) and threw — leaving the TX stream id
+ // 0, so transmit audio went to stream 0x0 and the radio keyed
+ // with no modulation (0 W). The (int) cast keeps the correct
+ // 32 bits. RX/panadapter ids (0x04.., 0x40..) were unaffected.
+ return (int) Long.parseLong(lines[2], 16);//stream id, hexadecimal
} catch (NumberFormatException e) {
e.printStackTrace();
Log.e(TAG, "getDaxStreamId exception: " + e.getMessage());
@@ -1167,6 +1253,24 @@ private int getStreamId(String line) {
return 0;
}
+ /**
+ * Parse the slice index from a "slice create" response: {@code R|0|}
+ * where {@code } is the radio-assigned slice number (decimal). Returns -1
+ * when the response is an error or unparseable. Static + package-visible so the
+ * parse can be unit-tested without a radio.
+ */
+ static int getSliceIndex(String line) {
+ String[] parts = line.split("\\|");
+ if (parts.length > 2 && parts[1].equals("0")) {
+ try {
+ return Integer.parseInt(parts[2].trim());
+ } catch (NumberFormatException e) {
+ // not a numeric slice index — fall through to -1
+ }
+ }
+ return -1;
+ }
+
/**
* Split the message into header and content, assigning them to head and content respectively
*
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/flex/VITA.java b/ft8af/app/src/main/java/com/k1af/ft8af/flex/VITA.java
index 7560ffd1b..30708115d 100644
--- a/ft8af/app/src/main/java/com/k1af/ft8af/flex/VITA.java
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/flex/VITA.java
@@ -234,10 +234,21 @@ public byte[] audioFloatDataToVita(int count, float[] data) {
packetType = VitaPacketType.EXT_DATA_WITH_STREAM;
classIdPresent = true;
trailerPresent = false;//No trailer
- tsi = VitaTSI.TSI_OTHER;//
+ // Timestamp-integer type MUST be TSI_UTC (1) to match the DAX-audio packets the
+ // radio itself sends/accepts. We were sending TSI_OTHER (3); the radio rejected
+ // those packets, so it keyed but had no modulation (0 W). Confirmed by logging the
+ // working RX format (tsi=1) vs our TX format (tsi=3) — the only differing field.
+ tsi = VitaTSI.TSI_UTC;
tsf = VitaTSF.TSF_SAMPLE_COUNT;
this.packetCount = count;
- this.packetSize = (data.length / 2) + 7;
+ // VITA packet_size is the TOTAL number of 32-bit words in the packet:
+ // 7 header words + one word per float sample (each float = 4 bytes = 1 word).
+ // The old "(data.length / 2) + 7" under-counted by half, so the declared size
+ // (135 words) didn't match the real datagram (263 words); a strict VITA
+ // receiver (SmartSDR) drops a packet whose declared size disagrees with its
+ // length, which left the keyed radio with no modulation (ALC at the noise
+ // floor, 0 W forward).
+ this.packetSize = data.length + 7;
//----HEADER--No.1 word------
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java
index 85dffc182..3d7274e0a 100644
--- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java
@@ -44,6 +44,12 @@ public class MeterProtectionController {
public final MutableLiveData swrLockout = new MutableLiveData<>(false);
public final MutableLiveData lastAlc = new MutableLiveData<>(0);
public final MutableLiveData lastSwr = new MutableLiveData<>(0);
+ // True once the rig has reported at least one valid meter reading. Lets the
+ // meters HUD tell "no data yet / unsupported rig" apart from a legitimate 0
+ // reading (lastAlc/lastSwr start at 0, and 0 is a real value — ALC at no
+ // drive, SWR at 1.0:1). Never reset: once a rig has reported, the last values
+ // remain meaningful as the "last TX" readout.
+ public final MutableLiveData meterDataReceived = new MutableLiveData<>(false);
// Holds the SWR ratio string (e.g. "3.2:1") that triggered lockout, for the banner.
public final MutableLiveData lockoutSwrRatio = new MutableLiveData<>("");
@@ -72,6 +78,10 @@ public void onMeterUpdate(int normalizedAlc, int normalizedSwr) {
// Publish for optional UI display
if (normalizedAlc >= 0) lastAlc.postValue(normalizedAlc);
if (normalizedSwr >= 0) lastSwr.postValue(normalizedSwr);
+ if ((normalizedAlc >= 0 || normalizedSwr >= 0)
+ && !Boolean.TRUE.equals(meterDataReceived.getValue())) {
+ meterDataReceived.postValue(true);
+ }
// Diagnostics: when SWR protection is on, record each SWR reading we actually
// receive so the debug.log shows whether meter data even reaches here during TX, the
diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/FlexNetworkRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/FlexNetworkRig.java
index 2a6c77995..6c6cb4436 100644
--- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/FlexNetworkRig.java
+++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/FlexNetworkRig.java
@@ -57,7 +57,7 @@ public boolean isConnected() {
@Override
public void setUsbModeToRig() {
if (getConnector() != null) {
- commandSliceSetMode(0, FlexRadio.FlexMode.DIGU);//set operating mode
+ commandSliceSetMode(sliceIndex(), FlexRadio.FlexMode.DIGU);//set operating mode on OUR slice
}
}
@@ -65,10 +65,19 @@ public void setUsbModeToRig() {
@Override
public void setFreqToRig() {
if (getConnector() != null) {
- commandSliceTune(0, String.format("%.3f", getFreq() / 1000000f));
+ commandSliceTune(sliceIndex(), String.format("%.6f", getFreq() / 1000000f));//retune OUR slice
}
}
+ /** The slice index the app owns, so band/frequency changes act on our slice rather
+ * than a hardcoded 0 (which could be a slice we don't own — see FlexConnector). */
+ private int sliceIndex() {
+ if (getConnector() instanceof FlexConnector) {
+ return ((FlexConnector) getConnector()).getSliceIndex();
+ }
+ return 0;
+ }
+
@Override
public void onReceiveData(byte[] data) {
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt
index 083802af2..afb82dedb 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 27fa2fd0e..a03aaef7e 100644
--- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt
@@ -79,6 +79,29 @@ internal fun catChipVisuals(state: CatConnectionState): CatChipVisuals = when (s
internal fun shouldShowCatChip(controlMode: Int, state: CatConnectionState): Boolean =
controlMode != ControlMode.VOX || state != CatConnectionState.DISCONNECTED
+/** What tapping the CAT chip should do, given the current connection state. */
+enum class CatTapAction { RECONNECT_EXISTING, CONNECT_SAVED_FLEX, NEEDS_SETUP }
+
+/**
+ * Decide what a tap on the CAT chip does. If a connector already exists, just
+ * reconnect it (the existing behavior — handy for Bluetooth/Flex retries).
+ * Otherwise, on a cold start, if the saved rig is a network Flex with a
+ * remembered address, connect straight to it — "my settings are set, just
+ * connect" — instead of doing nothing (the old reconnectRig() no-ops with no
+ * connector). With nothing saved to go on, the user needs to set the rig up.
+ *
+ * Pure so the routing is unit-tested without the rig/connect plumbing.
+ */
+internal fun catTapAction(
+ connectorExists: Boolean,
+ isFlexNetwork: Boolean,
+ savedFlexIp: String,
+): CatTapAction = when {
+ connectorExists -> CatTapAction.RECONNECT_EXISTING
+ isFlexNetwork && savedFlexIp.isNotBlank() -> CatTapAction.CONNECT_SAVED_FLEX
+ else -> CatTapAction.NEEDS_SETUP
+}
+
/**
* A small CAT (rig control) connection indicator for the TX strip. Tappable to
* re-trigger the connection — Bluetooth often only connects on the second try,
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplay.kt
new file mode 100644
index 000000000..98ed0f9f8
--- /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 000000000..e62005edf
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSheet.kt
@@ -0,0 +1,480 @@
+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.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import kotlinx.coroutines.delay
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+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,
+) {
+ // Poll a few times a second while the sheet is open so live rig meters
+ // refresh. Reading [frame] here (the render scope) — and passing it down — is
+ // what makes the bars actually update; polling inside the sampler would only
+ // recompose the sampler, not these bars. Idle (no recomposition) when hidden.
+ val frame by produceState(0L, visible) {
+ if (visible) {
+ while (true) {
+ delay(250)
+ value++
+ }
+ }
+ }
+ val available = rememberRigMeterSamples(mainViewModel, frame, active = visible)
+ 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 000000000..e44e07bff
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/MetersSource.kt
@@ -0,0 +1,116 @@
+package radio.ks3ckc.ft8af.ui.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import 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.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, frame: Long, active: Boolean): List {
+ val isFlex by mainViewModel.mutableIsFlexRadio.observeAsState(false)
+ val isXiegu by mainViewModel.mutableIsXieguRadio.observeAsState(false)
+ val flexConnector = mainViewModel.baseRig?.connector as? FlexConnector
+
+ // (Re)subscribe to the Flex meter stream each time the HUD opens ([active]).
+ // The connect-time subscription is gated on a METER_LIST response and can be
+ // missed (or race the connection coming up); re-requesting on every open makes
+ // meters reliably start, rather than firing only once at connect.
+ LaunchedEffect(flexConnector, isFlex, active) {
+ if (isFlex && active) flexConnector?.requestMeterStream()
+ }
+
+ // [frame] ticks a few times a second (driven by the HUD) so this re-runs and
+ // re-reads the rigs' live, in-place-mutated meter values. The readers below are
+ // plain (non-@Composable) so their results actually propagate to the caller —
+ // a poll tick read *inside* a child @Composable only recomposes that child, so
+ // the new values never reached the rendered bars (the "meters don't move" bug).
+ @Suppress("UNUSED_EXPRESSION") frame
+ return when {
+ isFlex -> flexSamples(flexConnector)
+ isXiegu -> xieguSamples(mainViewModel.baseRig?.connector as? X6100Connector)
+ else -> serialSamples(mainViewModel)
+ }
+}
+
+/** Live read of the Flex meter values (the UDP thread mutates [FlexConnector.meterList] in place). */
+private fun flexSamples(connector: FlexConnector?): List {
+ if (connector == null || !connector.meterDataReceived) return emptyList()
+ val m = connector.meterList
+ return listOf(
+ swrSampleFromRatio(m.swrVal),
+ alcSampleFlexDb(m.alcVal),
+ powerSample(m.pwrVal),
+ sMeterSampleDbm(m.sMeterVal),
+ tempSample(m.tempCVal),
+ )
+}
+
+private fun xieguSamples(connector: X6100Connector?): List {
+ val m = connector?.xieguRadio?.mutableMeters?.value ?: return emptyList()
+ return listOf(
+ swrSampleFromRatio(m.swr),
+ alcSampleXiegu(m.alc),
+ powerSample(m.power),
+ sMeterSampleDbm(X6100Meters.getMeter_dBm(m.sMeter)),
+ voltSample(m.volt),
+ )
+}
+
+private fun serialSamples(mainViewModel: MainViewModel): List {
+ val controller = mainViewModel.meterProtectionController
+ if (controller.meterDataReceived.value != true) return emptyList()
+ val alc = controller.lastAlc.value ?: 0
+ val swr = controller.lastSwr.value ?: 0
+ return listOf(
+ swrSampleFromNormalized(swr),
+ alcSampleSerial(alc, GeneralVariables.alcTargetLow, GeneralVariables.alcTargetHigh),
+ )
+}
+
+/**
+ * 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 9244175eb..2b6e38d7f 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
@@ -58,6 +61,7 @@ import com.k1af.ft8af.rigs.InstructionSet
import com.k1af.ft8af.ui.ToastMessage
import com.k1af.ft8af.x6100.X6100Radio
import com.k1af.ft8af.x6100.XieguRadioFactory
+import kotlinx.coroutines.delay
import radio.ks3ckc.ft8af.theme.*
// ─────────────────────────────────────────────────────────────────────
@@ -249,27 +253,84 @@ 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
+ // Hold a MulticastLock while the picker is open. This is the load-bearing
+ // part of discovery: 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. Held only
+ // while the picker is open, so it doesn't drain battery the rest of the time.
DisposableEffect(Unit) {
- val factory = FlexRadioFactory.getInstance()
- // Seed with any already-discovered radios
- discoveredRadios.clear()
- discoveredRadios.addAll(factory.flexRadios)
+ 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() }
+ }
+ GeneralVariables.fileLog(
+ "FlexDiscovery: picker open, multicastLock.held=${multicastLock.isHeld}, " +
+ "seeded=${FlexRadioFactory.getInstance().flexRadios.size}, startedEmpty=$startedEmpty",
+ )
+ onDispose {
+ if (multicastLock.isHeld) runCatching { multicastLock.release() }
+ }
+ }
- val listener = object : FlexRadioFactory.OnFlexRadioEvents {
- override fun OnFlexRadioAdded(flexRadio: FlexRadio) {
- discoveredRadios.clear()
- discoveredRadios.addAll(factory.flexRadios)
- }
- override fun OnFlexRadioInvalid(flexRadio: FlexRadio) {
+ // Poll discovery while the dialog is open so radios found *after* opening
+ // (discovery typically takes a few seconds) appear live — no need to close
+ // and reopen the picker. Polling also sidesteps the factory's add callback,
+ // which fires before the radio is appended to its list. Rebuild only when the
+ // discovered set actually changes (FlexPickerLogic) to avoid resetting scroll.
+ LaunchedEffect(Unit) {
+ val factory = FlexRadioFactory.getInstance()
+ while (true) {
+ val latest = factory.flexRadios.toList()
+ val displayedKeys = discoveredRadios.map { FlexPickerLogic.identityKey(it.serial, it.ip) }
+ val latestKeys = latest.map { FlexPickerLogic.identityKey(it.serial, it.ip) }
+ if (FlexPickerLogic.listChanged(displayedKeys, latestKeys)) {
discoveredRadios.clear()
- discoveredRadios.addAll(factory.flexRadios)
+ discoveredRadios.addAll(latest)
}
+ delay(1000)
+ }
+ }
+
+ // Auto-connect the moment a single radio is 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")
}
- factory.setOnFlexRadioEvents(listener)
- onDispose { factory.setOnFlexRadioEvents(null) }
}
Dialog(onDismissRequest = onDismiss) {
@@ -290,50 +351,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 +386,7 @@ fun FlexRadioPickerDialog(
radio.model,
),
)
- mainViewModel.connectFlexRadioRig(
- GeneralVariables.getMainContext(),
- radio,
- )
- onDismiss()
+ connect(radio, "tap")
}
.padding(horizontal = 24.dp, vertical = 10.dp),
) {
@@ -374,6 +406,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 000000000..68a3a9310
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt
@@ -0,0 +1,30 @@
+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.
+ */
+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/FlexPickerLogic.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt
new file mode 100644
index 000000000..407fabbf0
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt
@@ -0,0 +1,33 @@
+package radio.ks3ckc.ft8af.ui.settings
+
+/**
+ * Pure helpers for the FlexRadio picker's live-discovery list.
+ *
+ * The picker polls [com.k1af.ft8af.flex.FlexRadioFactory] once a second while
+ * open. These functions keep that polling cheap and stable: derive a stable
+ * identity per radio, and only rebuild the displayed list when the discovered
+ * set actually changed (so the list doesn't recompose / reset its scroll every
+ * tick). Kept free of Android/Compose types so it's unit-testable.
+ */
+internal object FlexPickerLogic {
+
+ /**
+ * Stable identity for a discovered radio. Prefers the serial number; falls
+ * back to the IP when the serial is missing/blank. Trimmed so incidental
+ * whitespace doesn't read as a change.
+ */
+ fun identityKey(serial: String?, ip: String?): String {
+ val s = serial?.trim().orEmpty()
+ if (s.isNotEmpty()) return s
+ return ip?.trim().orEmpty()
+ }
+
+ /**
+ * Whether the latest discovery snapshot differs from what's displayed,
+ * compared by identity key in order. When false, the caller skips the
+ * (otherwise per-second) list rebuild.
+ */
+ fun listChanged(displayedKeys: List, latestKeys: List): Boolean {
+ return displayedKeys != latestKeys
+ }
+}
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/MetersSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/MetersSettings.kt
new file mode 100644
index 000000000..f79c77fd1
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/MetersSettings.kt
@@ -0,0 +1,133 @@
+package radio.ks3ckc.ft8af.ui.settings
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.k1af.ft8af.GeneralVariables
+import com.k1af.ft8af.MainViewModel
+import com.k1af.ft8af.R
+import radio.ks3ckc.ft8af.theme.*
+import radio.ks3ckc.ft8af.ui.components.GlassCard
+import radio.ks3ckc.ft8af.ui.components.SettingsRow
+
+/**
+ * Meters HUD settings — toggles which meters the pull-down HUD shows. SWR and
+ * ALC are universal across CAT rigs and on by default; power / S-meter / voltage
+ * / temperature are only reported by some rigs (e.g. FlexRadio/Xiegu network) and
+ * are off by default. The HUD additionally hides any enabled meter the connected
+ * rig doesn't actually report ("adapt per rig").
+ */
+@Composable
+fun MetersSettings(
+ mainViewModel: MainViewModel,
+ onBack: () -> Unit,
+) {
+ SettingsDetailScaffold(
+ title = stringResource(R.string.settings_cat_meters),
+ onBack = onBack,
+ ) {
+ SettingsSection(title = stringResource(R.string.settings_meters_universal_section)) {
+ GlassCard(modifier = Modifier.fillMaxWidth()) {
+ MeterToggleRow(
+ mainViewModel = mainViewModel,
+ label = stringResource(R.string.meters_swr),
+ description = stringResource(R.string.settings_meter_swr_desc),
+ configKey = "meterShowSwr",
+ initial = GeneralVariables.meterShowSwr,
+ apply = { GeneralVariables.meterShowSwr = it },
+ )
+ SectionDivider()
+ MeterToggleRow(
+ mainViewModel = mainViewModel,
+ label = stringResource(R.string.meters_alc),
+ description = stringResource(R.string.settings_meter_alc_desc),
+ configKey = "meterShowAlc",
+ initial = GeneralVariables.meterShowAlc,
+ apply = { GeneralVariables.meterShowAlc = it },
+ )
+ }
+ }
+
+ SettingsSection(title = stringResource(R.string.settings_meters_rig_section)) {
+ GlassCard(modifier = Modifier.fillMaxWidth()) {
+ MeterToggleRow(
+ mainViewModel = mainViewModel,
+ label = stringResource(R.string.meters_power),
+ description = stringResource(R.string.settings_meter_power_desc),
+ configKey = "meterShowPower",
+ initial = GeneralVariables.meterShowPower,
+ apply = { GeneralVariables.meterShowPower = it },
+ )
+ SectionDivider()
+ MeterToggleRow(
+ mainViewModel = mainViewModel,
+ label = stringResource(R.string.meters_smeter),
+ description = stringResource(R.string.settings_meter_smeter_desc),
+ configKey = "meterShowSMeter",
+ initial = GeneralVariables.meterShowSMeter,
+ apply = { GeneralVariables.meterShowSMeter = it },
+ )
+ SectionDivider()
+ MeterToggleRow(
+ mainViewModel = mainViewModel,
+ label = stringResource(R.string.meters_voltage),
+ description = stringResource(R.string.settings_meter_voltage_desc),
+ configKey = "meterShowVoltage",
+ initial = GeneralVariables.meterShowVoltage,
+ apply = { GeneralVariables.meterShowVoltage = it },
+ )
+ SectionDivider()
+ MeterToggleRow(
+ mainViewModel = mainViewModel,
+ label = stringResource(R.string.meters_temp),
+ description = stringResource(R.string.settings_meter_temp_desc),
+ configKey = "meterShowTemp",
+ initial = GeneralVariables.meterShowTemp,
+ apply = { GeneralVariables.meterShowTemp = it },
+ )
+ }
+ }
+
+ Text(
+ text = stringResource(R.string.settings_meters_footnote),
+ color = TextMuted,
+ fontSize = 13.sp,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp),
+ )
+ }
+}
+
+/**
+ * A single meter on/off row. Mirrors the change into [GeneralVariables] (via
+ * [apply], so the live HUD picks it up) and persists it under [configKey].
+ */
+@Composable
+private fun MeterToggleRow(
+ mainViewModel: MainViewModel,
+ label: String,
+ description: String,
+ configKey: String,
+ initial: Boolean,
+ apply: (Boolean) -> Unit,
+) {
+ var enabled by remember { mutableStateOf(initial) }
+ SettingsRow(
+ label = label,
+ description = description,
+ toggle = enabled,
+ onToggleChange = { checked ->
+ enabled = checked
+ apply(checked)
+ mainViewModel.databaseOpr.writeConfig(configKey, if (checked) "1" else "0", null)
+ },
+ )
+}
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt
index aec1fbb58..14a9555f2 100644
--- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt
@@ -66,6 +66,7 @@ import radio.ks3ckc.ft8af.ui.components.TopBar
private enum class SettingsCategory {
RADIO_AUDIO,
TRANSMISSION,
+ METERS,
TIME_SYNC,
DECODE_FILTERS,
LOGGING,
@@ -135,6 +136,8 @@ fun SettingsScreen(
RadioAudioSettings(mainViewModel, onBack = { currentCategory = null })
SettingsCategory.TRANSMISSION ->
TransmissionSettings(mainViewModel, onBack = { currentCategory = null })
+ SettingsCategory.METERS ->
+ MetersSettings(mainViewModel, onBack = { currentCategory = null })
SettingsCategory.TIME_SYNC ->
TimeSyncSettings(mainViewModel, onBack = { currentCategory = null })
SettingsCategory.DECODE_FILTERS ->
@@ -301,6 +304,13 @@ private fun SettingsLanding(
onClick = { onOpenCategory(SettingsCategory.TRANSMISSION) },
)
SectionDivider()
+ SettingsRow(
+ label = stringResource(R.string.settings_cat_meters),
+ description = stringResource(R.string.settings_cat_meters_desc),
+ showChevron = true,
+ onClick = { onOpenCategory(SettingsCategory.METERS) },
+ )
+ SectionDivider()
SettingsRow(
label = stringResource(R.string.settings_cat_time_sync),
description = stringResource(R.string.settings_cat_time_sync_desc),
diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml
index 23145e7bd..4deada732 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/java/com/k1af/ft8af/ExecutorUtilsTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ExecutorUtilsTest.java
new file mode 100644
index 000000000..396da0a6c
--- /dev/null
+++ b/ft8af/app/src/test/java/com/k1af/ft8af/ExecutorUtilsTest.java
@@ -0,0 +1,61 @@
+package com.k1af.ft8af;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Unit tests for {@link ExecutorUtils#safeExecute(ExecutorService, Runnable)}.
+ *
+ * Locks the invariant behind the {@code afterDecode} crash fix: submitting a
+ * decode-followup task to a pool that has been shut down (ViewModel teardown)
+ * must drop the task quietly instead of throwing
+ * {@link java.util.concurrent.RejectedExecutionException}.
+ */
+public class ExecutorUtilsTest {
+
+ @Test
+ public void activePool_runsTaskAndReturnsTrue() throws InterruptedException {
+ ExecutorService pool = Executors.newSingleThreadExecutor();
+ try {
+ CountDownLatch ran = new CountDownLatch(1);
+ boolean accepted = ExecutorUtils.safeExecute(pool, ran::countDown);
+ assertThat(accepted).isTrue();
+ assertThat(ran.await(2, TimeUnit.SECONDS)).isTrue();
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Test
+ public void shutdownPool_dropsTaskAndReturnsFalse() {
+ ExecutorService pool = Executors.newSingleThreadExecutor();
+ pool.shutdown();
+ // The pre-fix code path: execute() on a terminated pool threw and crashed
+ // the decode thread. safeExecute must swallow it and report the drop.
+ boolean accepted = ExecutorUtils.safeExecute(pool, () -> {
+ throw new AssertionError("task must not run on a shut-down pool");
+ });
+ assertThat(accepted).isFalse();
+ }
+
+ @Test
+ public void nullPool_returnsFalse() {
+ assertThat(ExecutorUtils.safeExecute(null, () -> { })).isFalse();
+ }
+
+ @Test
+ public void nullTask_returnsFalse() {
+ ExecutorService pool = Executors.newSingleThreadExecutor();
+ try {
+ assertThat(ExecutorUtils.safeExecute(pool, null)).isFalse();
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+}
diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexResponseSliceTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexResponseSliceTest.java
new file mode 100644
index 000000000..a089a135d
--- /dev/null
+++ b/ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexResponseSliceTest.java
@@ -0,0 +1,45 @@
+package com.k1af.ft8af.flex;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+/**
+ * Pure-logic coverage for parsing the slice index out of a Flex "slice create"
+ * response. The app must own the slice the radio actually assigns (not a hardcoded
+ * 0); this is the parse that captures that index. See
+ * {@link com.k1af.ft8af.flex.FlexRadio.FlexResponse#getSliceIndex(String)} and its
+ * use in FlexConnector.onResponse(SLICE_CREATE_FREQ).
+ */
+public class FlexResponseSliceTest {
+
+ @Test
+ public void parsesIndexFromSuccessfulCreate() {
+ // R|0| — result 0 = success, third field is the slice number.
+ assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0|0")).isEqualTo(0);
+ assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0|1")).isEqualTo(1);
+ assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0|2")).isEqualTo(2);
+ }
+
+ @Test
+ public void toleratesWhitespaceAroundIndex() {
+ assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0| 1 ")).isEqualTo(1);
+ }
+
+ @Test
+ public void returnsMinusOneOnErrorResult() {
+ // Non-zero result code = the create failed; there is no valid index.
+ assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|50000005|0")).isEqualTo(-1);
+ }
+
+ @Test
+ public void returnsMinusOneOnNonNumericIndex() {
+ assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0|abc")).isEqualTo(-1);
+ }
+
+ @Test
+ public void returnsMinusOneWhenIndexMissing() {
+ assertThat(FlexRadio.FlexResponse.getSliceIndex("R30030|0")).isEqualTo(-1);
+ assertThat(FlexRadio.FlexResponse.getSliceIndex("")).isEqualTo(-1);
+ }
+}
diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt
index e53fc66b6..6549ec2f6 100644
--- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt
+++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt
@@ -87,4 +87,33 @@ class CatStatusChipLogicTest {
assertThat(CatConnectionState.afterDisconnect(CatConnectionState.DISCONNECTED))
.isEqualTo(CatConnectionState.DISCONNECTED)
}
+
+ // ---- catTapAction (what tapping the CAT chip does) ----
+
+ @Test
+ fun `cat tap reconnects an existing connector`() {
+ // A live connector exists (any rig) — just re-poke it. Flex IP irrelevant.
+ assertThat(catTapAction(connectorExists = true, isFlexNetwork = true, savedFlexIp = "192.168.1.9"))
+ .isEqualTo(CatTapAction.RECONNECT_EXISTING)
+ assertThat(catTapAction(connectorExists = true, isFlexNetwork = false, savedFlexIp = ""))
+ .isEqualTo(CatTapAction.RECONNECT_EXISTING)
+ }
+
+ @Test
+ fun `cat tap cold-connects to a saved Flex when no connector yet`() {
+ // The whole point: settings saved (Flex + remembered IP), no live
+ // connector → connect straight to it instead of no-opping.
+ assertThat(catTapAction(connectorExists = false, isFlexNetwork = true, savedFlexIp = "192.168.1.9"))
+ .isEqualTo(CatTapAction.CONNECT_SAVED_FLEX)
+ }
+
+ @Test
+ fun `cat tap needs setup when nothing to go on`() {
+ // Flex selected but no remembered address.
+ assertThat(catTapAction(connectorExists = false, isFlexNetwork = true, savedFlexIp = ""))
+ .isEqualTo(CatTapAction.NEEDS_SETUP)
+ // Not a Flex-network rig and nothing connected (e.g. cold USB before scan).
+ assertThat(catTapAction(connectorExists = false, isFlexNetwork = false, savedFlexIp = ""))
+ .isEqualTo(CatTapAction.NEEDS_SETUP)
+ }
}
diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/MetersDisplayTest.kt
new file mode 100644
index 000000000..74da46837
--- /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 000000000..1f8652c6b
--- /dev/null
+++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt
@@ -0,0 +1,67 @@
+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()
+ }
+}
diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt
new file mode 100644
index 000000000..20f7a2f19
--- /dev/null
+++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt
@@ -0,0 +1,52 @@
+package radio.ks3ckc.ft8af.ui.settings
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+/**
+ * Unit tests for [FlexPickerLogic], the live-discovery list helpers behind the
+ * FlexRadio picker polling fix.
+ */
+class FlexPickerLogicTest {
+
+ @Test
+ fun identityKey_prefersSerial() {
+ assertThat(FlexPickerLogic.identityKey("0123-4567-89", "192.168.86.159"))
+ .isEqualTo("0123-4567-89")
+ }
+
+ @Test
+ fun identityKey_fallsBackToIpWhenSerialBlank() {
+ assertThat(FlexPickerLogic.identityKey(" ", "192.168.86.159"))
+ .isEqualTo("192.168.86.159")
+ assertThat(FlexPickerLogic.identityKey(null, "192.168.86.159"))
+ .isEqualTo("192.168.86.159")
+ }
+
+ @Test
+ fun identityKey_trimsWhitespace() {
+ assertThat(FlexPickerLogic.identityKey(" FLEX-1 ", null)).isEqualTo("FLEX-1")
+ }
+
+ @Test
+ fun identityKey_emptyWhenNothingKnown() {
+ assertThat(FlexPickerLogic.identityKey(null, null)).isEmpty()
+ }
+
+ @Test
+ fun listChanged_falseWhenIdentical() {
+ // The common per-tick case: nothing new discovered → no rebuild.
+ assertThat(FlexPickerLogic.listChanged(listOf("a", "b"), listOf("a", "b"))).isFalse()
+ }
+
+ @Test
+ fun listChanged_trueWhenRadioAppears() {
+ // A radio surfaces after the dialog is already open — must update live.
+ assertThat(FlexPickerLogic.listChanged(emptyList(), listOf("a"))).isTrue()
+ }
+
+ @Test
+ fun listChanged_trueWhenRadioDropsOff() {
+ assertThat(FlexPickerLogic.listChanged(listOf("a", "b"), listOf("a"))).isTrue()
+ }
+}