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