SO2R: two radios in one instance (#46) (#137)

* Spec: SO2R with two radios in one instance (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Plan: SO2R implementation (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move the rotator command rules into RotatorControl (#46)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Store the SO2R switch, the second Win-Test station and rotator 2 (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Route frequency reports to radio 1 or 2 (#46)

Add the RadioSlot enum (FIRST, SECOND) with its QRG and QTF targets, and
the pure RadioSlotRouter that decides per RadioInfo packet and per
Win-Test station which radio a report belongs to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Send RadioInfo of radio 2 to SECONDQRG when SO2R is on (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Send Win-Test STATUS of the second station to SECONDQRG when SO2R is on (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run a second PSTRotator for radio 2 with SO2R (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start rotator 2 only while an ON4KST session runs (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Settings for SO2R, the second Win-Test station and rotator 2 (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Turn antenna 2 and show QTF2 with SO2R (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make the SO2R switches observable so Apply shows them (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document SO2R (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse clashing PSTRotator ports in both directions (#46)

Writing either port now checks same-or-adjacent against the drafted value of
the other; rotor 1 is only checked while SO2R and rotor 2 are on. Also fixes
a misplaced Javadoc, stale WinTest parseStatus Javadoc, docs wording and
removes an unused import and dead comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove the executed SO2R plan (#46)

The plan's content lives on in the commits, the spec and PROJECT_CONTEXT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-16 19:14:27 +02:00
1 parent feb4b4e2c9
commit 5b7c774f5f
37 files changed
+1587 -266

No files matched your search

@@ -35,7 +35,6 @@ import java.nio.charset.StandardCharsets;
import kst4contest.logic.FrequencyTextParser;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.concurrent.ScheduledFuture;
import kst4contest.observe.SimpleValue;
@@ -48,7 +47,7 @@ import kst4contest.observe.SimpleValue;
* <b>SINGLETON</b>
*
*/
public class ChatController implements ThreadStatusCallback, PstRotatorEventListener {
public class ChatController implements ThreadStatusCallback {
/**
* Chat selection ? 50/70 MHz..............1 144/432 MHz............2
@@ -69,7 +68,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
public static final int MAX_BEACON_TEXT_LENGTH = 120;
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
private volatile PstRotatorClient rotatorClient;
private Consumer<Double> viewRotorCallback;
/*
@@ -86,14 +84,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return thread;
});
private ScheduledFuture<?> pendingRotatorRetry;
/*
* Updated directly by the PSTRotator receiver thread. This avoids reading
* a JavaFX property from the command scheduler.
*/
private volatile double lastReportedRotatorAzimuth = Double.NaN;
private StatusUpdateListener statusListener; //update info interface for the threads
@@ -311,158 +301,108 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
public void initRotor() {
rotatorClient = new PstRotatorClient(
chatPreferences.getStn_pstRotatorHost(),
chatPreferences.getStn_pstRotatorPort(),
this,
this
);
rotator1.start();
}
rotatorClient.start();
/** Kept for existing references; the value lives in RotatorControl. */
static final double ROTATOR_REACHED_TOLERANCE_DEGREES = RotatorControl.REACHED_TOLERANCE_DEGREES;
/** Rotator of radio 1; also the only one without SO2R. */
private final RotatorControl rotator1 = new RotatorControl(
"PSTRotator",
control -> startPstRotatorClient(control, chatPreferences.getStn_pstRotatorHost(),
chatPreferences.getStn_pstRotatorPort(), "PSTRotator"),
rotatorCommandScheduler,
azimuth -> runOnUi(() -> chatPreferences.getActualQTF().set(azimuth)));
/** Rotator of radio 2; only with SO2R and its own switch. */
private final RotatorControl rotator2 = new RotatorControl(
"PSTRotator2",
control -> startPstRotatorClient(control, chatPreferences.getStn_pstRotator2Host(),
chatPreferences.getStn_pstRotator2Port(), "PSTRotator2"),
rotatorCommandScheduler,
azimuth -> runOnUi(() -> chatPreferences.getActualQTF2().set(azimuth)));
/** Test seam: the rotator of radio 2. */
RotatorControl secondRotator() {
return rotator2;
}
static boolean secondRotatorWanted(ChatPreferences prefs) {
return prefs.isTrxSynch_so2rEnabled() && prefs.isStn_pstRotator2Enabled();
}
/** Turns the antenna of [slot]; radio 2 only does something while its rotator runs. */
public void rotateTo(RadioSlot slot, double azimuth) {
(slot == RadioSlot.SECOND ? rotator2 : rotator1).rotateTo(azimuth);
}
/*
* Not isDisconnected(): that flag defaults to false before the first connect,
* so it would claim a session where none exists.
*/
private boolean isSessionRunning() {
return on4KstConnectionManager != null && sessionRunning(on4KstConnectionManager.getState());
}
static boolean sessionRunning(On4KstConnectionState state) {
return state != null
&& state != On4KstConnectionState.DISCONNECTED
&& state != On4KstConnectionState.STOPPING;
}
/**
* How close the reported position must be to count as "reached". The target carries a
* decimal, while many rotor controllers report whole degrees: with 0.5 a target of 145.7
* and a report of 145 would count as missed and could trigger the retry.
* Follows the settings for rotator 2 after Apply: always stopped first, so a changed
* host or port is bound anew, and started again only while a session runs. Without it,
* QTF2 shows nothing rather than a stale heading.
*/
static final double ROTATOR_REACHED_TOLERANCE_DEGREES = 1.0;
public void reconfigureSecondRotator() {
rotator2.stop();
if (secondRotatorWanted(chatPreferences)) {
if (isSessionRunning()) {
rotator2.start();
}
} else {
runOnUi(() -> chatPreferences.getActualQTF2().set(Double.NaN));
}
}
/**
* Sends a new azimuth to PSTRotator without blocking the JavaFX thread.
*
* <p>Some SPID configurations occasionally ignore the first azimuth
* command. KST4Contest therefore checks the latest reported position after
* two seconds. If no movement was reported and the target has not already
* been reached, the original compatibility sequence is sent again.</p>
* Opens one PSTRotator connection whose position reports go to [control]. The
* returned link is what RotatorControl drives; stopping it also stops the polling.
*/
private RotatorControl.Link startPstRotatorClient(RotatorControl control, String host, int port, String threadName) {
PstRotatorClient client = new PstRotatorClient(host, port, threadName, new PstRotatorEventListener() {
@Override public void onAzimuthUpdate(double azimuth) { control.onAzimuthUpdate(azimuth); }
@Override public void onElevationUpdate(double elevation) { /* not used */ }
@Override public void onModeUpdate(boolean isTracking) { /* not used */ }
@Override public void onMessageReceived(String raw) { /* not used */ }
}, this);
client.start();
return new RotatorControl.Link() {
@Override public void setTrackingMode(boolean on) { client.setTrackingMode(on); }
@Override public void setAzimuth(double azimuth) { client.setAzimuth(azimuth); }
@Override public void stopRotation() { client.stopRotor(); }
@Override public void stop() { client.stop(); }
};
}
/* Runs on the UI thread, directly when already there. */
private void runOnUi(Runnable update) {
if (uiDispatcher.isUiThread()) {
update.run();
} else {
uiDispatcher.runOnUi(update);
}
}
/**
* Sends a new azimuth to PSTRotator without blocking the UI thread; see RotatorControl.
*
* @param azimuth required antenna azimuth in degrees
*/
public void rotateTo(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(
Level.WARNING,
"Ignoring invalid PSTRotator azimuth: {0}",
azimuth
);
return;
}
PstRotatorClient activeClient = rotatorClient;
if (activeClient == null) {
LOGGER.log(
Level.WARNING,
"Cannot rotate antenna to {0} degrees: "
+ "PSTRotator integration is not active.",
azimuth
);
return;
}
double targetAzimuth = normalizeAzimuth(azimuth);
double positionBeforeCommand = lastReportedRotatorAzimuth;
activeClient.setTrackingMode(false);
LOGGER.log(
Level.INFO,
"Sending PSTRotator azimuth requested by the operator: {0}",
targetAzimuth
);
activeClient.setAzimuth(targetAzimuth);
ScheduledFuture<?> previousRetry = pendingRotatorRetry;
if (previousRetry != null) {
previousRetry.cancel(false);
}
pendingRotatorRetry = rotatorCommandScheduler.schedule(
() -> retryRotatorCommandIfRequired(
positionBeforeCommand,
targetAzimuth
),
2,
TimeUnit.SECONDS
);
}
/**
* Performs the delayed SPID compatibility check.
*
* <p>No retry is required when the requested position has already been
* reached or when PSTRotator reported movement after the original command.
* Missing feedback is treated like an unchanged position.</p>
*/
private void retryRotatorCommandIfRequired(
double positionBeforeCommand,
double targetAzimuth
) {
PstRotatorClient activeClient = rotatorClient;
if (activeClient == null) {
return;
}
double currentAzimuth = lastReportedRotatorAzimuth;
if (Double.isFinite(currentAzimuth)
&& angularDistance(currentAzimuth, targetAzimuth) < ROTATOR_REACHED_TOLERANCE_DEGREES) {
LOGGER.log(
Level.FINE,
"PSTRotator reached the requested azimuth without retry: {0}",
targetAzimuth
);
return;
}
boolean noPositionFeedback =
!Double.isFinite(currentAzimuth);
boolean positionUnchanged =
Double.isFinite(positionBeforeCommand)
&& Double.isFinite(currentAzimuth)
&& angularDistance(
positionBeforeCommand,
currentAzimuth
) < 0.5;
if (!noPositionFeedback && !positionUnchanged) {
LOGGER.log(
Level.FINE,
"PSTRotator reported movement towards {0}; no retry required.",
targetAzimuth
);
return;
}
LOGGER.log(
Level.WARNING,
"PSTRotator reported no movement after the command for {0} degrees; "
+ "sending the SPID compatibility retry.",
targetAzimuth
);
activeClient.setAzimuth(0.0);
activeClient.setAzimuth(targetAzimuth);
}
/**
* Returns the smallest angular distance between two azimuth values.
*/
private static double angularDistance(double first, double second) {
double difference = Math.abs(
normalizeAzimuth(first) - normalizeAzimuth(second)
);
return Math.min(difference, 360.0 - difference);
}
/**
* Normalises an azimuth to the range from 0 inclusive to 360 exclusive.
*/
private static double normalizeAzimuth(double azimuth) {
double normalized = azimuth % 360.0;
return normalized < 0.0 ? normalized + 360.0 : normalized;
rotator1.rotateTo(azimuth);
}
/**
@@ -568,69 +508,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
public void stopRotator() {
ScheduledFuture<?> pendingRetry = pendingRotatorRetry;
if (pendingRetry != null) {
pendingRetry.cancel(false);
pendingRotatorRetry = null;
}
PstRotatorClient activeClient = rotatorClient;
rotatorClient = null;
lastReportedRotatorAzimuth = Double.NaN;
if (activeClient != null) {
activeClient.stop();
}
rotator1.stop();
}
@Override
public void onAzimuthUpdate(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(
Level.WARNING,
"Ignoring invalid azimuth reported by PSTRotator: {0}",
azimuth
);
return;
}
double normalizedAzimuth = normalizeAzimuth(azimuth);
lastReportedRotatorAzimuth = normalizedAzimuth;
/*
* The callback runs in the PSTRotator receiver thread. JavaFX properties
* must be updated on the JavaFX Application Thread.
*/
Runnable fxUpdate = () ->
chatPreferences.getActualQTF().set(normalizedAzimuth);
if (uiDispatcher.isUiThread()) {
fxUpdate.run();
} else {
uiDispatcher.runOnUi(fxUpdate);
}
}
@Override
public void onElevationUpdate(double elevation) {
// System.out.println("Neue Elevation: " + elevation);
//not used in first version
}
@Override
public void onModeUpdate(boolean isTracking) {
// System.out.println("Modus: " + (isTracking ? "Tracking" : "Manuell"));
//not used in first version
}
@Override
public void onMessageReceived(String raw) {
// Logging
}
/**
* Helping
* @param targetCallSignRaw
@@ -921,8 +801,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE)) {
// rotatorClient.
// this.lst_chatMemberList.clear();;
this.clearActiveChatMembers();
this.lst_clusterMemberList.clear();
@@ -971,8 +849,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
dbHandler.closeDBConnection();
rotatorClient.stopRotor();
rotatorClient.stop();
rotator1.stopRotation();
rotator1.stop();
rotator2.stopRotation();
rotator2.stop();
try {
@@ -1079,11 +959,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
if (dbHandler != null) {
dbHandler.closeDBConnection();
}
if (rotatorClient != null) {
rotatorClient.stopRotor();
rotatorClient.stop();
rotatorClient = null;
}
rotator1.stopRotation();
rotator1.stop();
rotator2.stopRotation();
rotator2.stop();
releaseBackgroundExecutors();
}
@@ -1105,11 +984,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
reachabilityService.shutdown();
}
if (pendingRotatorRetry != null) {
pendingRotatorRetry.cancel(false);
pendingRotatorRetry = null;
}
rotatorCommandScheduler.shutdownNow();
}
@@ -3612,6 +3486,9 @@ private SimpleRoster<String>
} else {
System.out.println("[ChatController, info]: PSTRotator disabled by user preference -> not starting rotator client.");
}
if (secondRotatorWanted(chatPreferences) && !rotator2.isRunning()) {
rotator2.start();
}
/**
* DX cluster service running config
@@ -3791,9 +3668,12 @@ private SimpleRoster<String>
new AirScoutPeriodicalAPReflectionInquirerTask(this),
10_000L, 60_000L);
if (chatPreferences.isStn_pstRotatorEnabled() && rotatorClient == null) {
if (chatPreferences.isStn_pstRotatorEnabled() && !rotator1.isRunning()) {
initRotor();
}
if (secondRotatorWanted(chatPreferences) && !rotator2.isRunning()) {
rotator2.start();
}
startDxClusterServerIfEnabled();
on4KstConnectionManager.start();
}
@@ -14,7 +14,7 @@ import java.util.logging.Logger;
public class PstRotatorClient implements Runnable {
private ThreadStatusCallback callBackToController;
private String ThreadNickName = "PSTRotator";
private final String ThreadNickName;
private static final Logger LOGGER = Logger.getLogger(PstRotatorClient.class.getName());
private static final int BUFFER_SIZE = 1024;
@@ -38,20 +38,34 @@ public class PstRotatorClient implements Runnable {
* @param listener Callback für den Chatcontroller
*/
public PstRotatorClient(String host, int remotePort, PstRotatorEventListener listener, ThreadStatusCallback callBack) {
// Laut Manual antwortet PSTRotator oft auf Port+1.
// Wir binden uns also standardmäßig auf remotePort + 1.
this(host, remotePort, remotePort + 1, listener, callBack);
this(host, remotePort, "PSTRotator", listener, callBack);
}
/**
* @param threadNickName name of the status button and the listener thread, so two
* rotators can be told apart in the status bar
*/
public PstRotatorClient(String host, int remotePort, String threadNickName,
PstRotatorEventListener listener, ThreadStatusCallback callBack) {
// PSTRotator answers on the configured port + 1.
this(host, remotePort, remotePort + 1, threadNickName, listener, callBack);
}
/**
* alternative constructor for seting the remote port explicitely
*/
public PstRotatorClient(String host, int remotePort, int localPort, PstRotatorEventListener listener, ThreadStatusCallback callBack) {
this(host, remotePort, localPort, "PSTRotator", listener, callBack);
}
private PstRotatorClient(String host, int remotePort, int localPort, String threadNickName,
PstRotatorEventListener listener, ThreadStatusCallback callBack) {
this.callBackToController = callBack;
this.host = host;
this.remotePort = remotePort;
this.localPort = localPort;
this.listener = listener;
this.ThreadNickName = threadNickName;
}
/**
@@ -0,0 +1,23 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.observe.MutableValue;
/**
* One of the two radios of an SO2R station: the place where its frequency and its
* antenna heading are shown. Without SO2R only {@link #FIRST} is used.
*/
public enum RadioSlot {
FIRST,
SECOND;
/** The frequency field of this radio: MYQRG for radio 1, SECONDQRG for radio 2. */
public MutableValue<String> qrgIn(ChatPreferences prefs) {
return this == FIRST ? prefs.getMYQRGFirstCat() : prefs.getMYQRGSecondCat();
}
/** The heading field of this radio: QTF for radio 1, QTF2 for radio 2. */
public MutableValue<Double> qtfIn(ChatPreferences prefs) {
return this == FIRST ? prefs.getActualQTF() : prefs.getActualQTF2();
}
}
@@ -0,0 +1,47 @@
package kst4contest.controller;
import java.util.Optional;
/**
* Decides which radio a received report belongs to. Pure, so the rule is tested without
* a network connection; the receivers ask it and write to the returned slot.
*
* <p>Without SO2R every report goes to radio 1, as before SO2R existed. An empty result
* means the report is dropped.</p>
*/
public final class RadioSlotRouter {
private RadioSlotRouter() {
}
/**
* @param radioNr the RadioInfo {@code RadioNr} text, null when the packet has none
*/
public static Optional<RadioSlot> forRadioInfo(boolean so2rEnabled, String radioNr) {
if (!so2rEnabled || radioNr == null || radioNr.isBlank()) {
return Optional.of(RadioSlot.FIRST);
}
return switch (radioNr.trim()) {
case "1" -> Optional.of(RadioSlot.FIRST);
case "2" -> Optional.of(RadioSlot.SECOND);
default -> Optional.empty();
};
}
/**
* @param nameRadio1 the existing station filter; empty accepts any station for radio 1
* @param nameRadio2 the station of radio 2; empty means radio 2 receives nothing
*/
public static Optional<RadioSlot> forWinTestStation(
boolean so2rEnabled, String stationName, String nameRadio1, String nameRadio2) {
String station = stationName == null ? "" : stationName;
boolean radio1Accepts = nameRadio1 == null || nameRadio1.isBlank()
|| station.equalsIgnoreCase(nameRadio1);
if (so2rEnabled && nameRadio2 != null && !nameRadio2.isBlank()
&& station.equalsIgnoreCase(nameRadio2)) {
return Optional.of(RadioSlot.SECOND);
}
return radio1Accepts ? Optional.of(RadioSlot.FIRST) : Optional.empty();
}
}
@@ -2,6 +2,7 @@ package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
import java.io.BufferedWriter;
@@ -466,12 +467,13 @@ public class ReadUDPByWintestThread extends Thread {
}
/**
* Parses a Win-Test STATUS packet and optionally updates MYQRG.
* Parses a Win-Test STATUS packet and routes it to a radio.
*
* <p>The packet is tokenised while preserving quoted station names.
* The configured station-name filter is applied before any frequency is
* processed. An empty filter accepts STATUS packets from every Win-Test
* station.</p>
* {@code RadioSlotRouter} decides before any frequency is processed: radio 1
* updates MYQRG, radio 2 (only with SO2R) updates SECONDQRG, and a packet
* from any other station is ignored. An empty radio 1 station name accepts
* STATUS packets from every Win-Test station.</p>
*
* <p>The main frequency is read from token 7. If pass-frequency use is
* enabled and token 11 contains a valid frequency, the pass frequency is
@@ -480,7 +482,8 @@ public class ReadUDPByWintestThread extends Thread {
*
* @param msg complete Win-Test STATUS packet
*/
private void parseStatus(String msg) {
/* package-private for tests */
void parseStatus(String msg) {
try {
ArrayList<String> parts = new ArrayList<>();
Matcher matcher = STATUS_TOKEN_PATTERN.matcher(msg);
@@ -501,13 +504,13 @@ public class ReadUDPByWintestThread extends Thread {
}
String stationName = parts.get(1);
String stationFilter = client
.getChatPreferences()
.getLogsynch_wintestNetworkStationNameOfWintestClient1();
if (stationFilter != null
&& !stationFilter.isBlank()
&& !stationName.equalsIgnoreCase(stationFilter)) {
ChatPreferences prefs = client.getChatPreferences();
java.util.Optional<RadioSlot> slot = RadioSlotRouter.forWinTestStation(
prefs.isTrxSynch_so2rEnabled(),
stationName,
prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1(),
prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2());
if (slot.isEmpty()) {
return;
}
@@ -564,15 +567,9 @@ public class ReadUDPByWintestThread extends Thread {
? formattedPassQrg
: formattedMainQrg;
if (client
.getChatPreferences()
.isLogsynch_wintestQrgSyncEnabled()) {
client.uiDispatcher().runOnUi(
() -> client
.getChatPreferences()
.getMYQRGFirstCat()
.set(qrgToSet)
);
if (prefs.isLogsynch_wintestQrgSyncEnabled()) {
RadioSlot target = slot.get();
client.uiDispatcher().runOnUi(() -> target.qrgIn(prefs).set(qrgToSet));
}
System.out.println(
@@ -515,6 +515,15 @@ public class ReadUDPbyUCXMessageThread extends Thread {
continue;
}
Node radioNrNode = element.getElementsByTagName("RadioNr").item(0);
String radioNr = radioNrNode == null ? null : radioNrNode.getTextContent();
java.util.Optional<RadioSlot> slot = RadioSlotRouter.forRadioInfo(
client.getChatPreferences().isTrxSynch_so2rEnabled(), radioNr);
if (slot.isEmpty()) {
System.out.println("[ReadUDPFromUCX, warning]: RadioInfo for unknown RadioNr \"" + radioNr + "\" ignored");
continue;
}
String qrg = freqNode.getTextContent();
String mode = modeNode.getTextContent();
@@ -561,8 +570,9 @@ public class ReadUDPbyUCXMessageThread extends Thread {
// this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
final String finalFormattedQRG = formattedQRG;
final RadioSlot target = slot.get();
helper_runOnFxThread(() ->
this.client.getChatPreferences().getMYQRGFirstCat().set(finalFormattedQRG)
target.qrgIn(this.client.getChatPreferences()).set(finalFormattedQRG)
);
// System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG);
@@ -0,0 +1,186 @@
package kst4contest.controller;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* One PSTRotator as the operator drives it: start and stop, a heading command with its
* delayed compatibility check, and the last reported position.
*
* <p>Moved out of ChatController unchanged so SO2R can run one per radio. The command
* scheduler is shared and passed in; nothing here blocks the UI thread.</p>
*/
public final class RotatorControl {
/** What the control needs from a rotator connection; PstRotatorClient in production. */
public interface Link {
void setTrackingMode(boolean on);
void setAzimuth(double azimuth);
/** Tells the rotor to stop turning. */
void stopRotation();
/** Closes the connection and its polling. */
void stop();
}
private static final Logger LOGGER = Logger.getLogger(RotatorControl.class.getName());
/**
* How close the reported position must be to count as "reached". The target carries a
* decimal, while many rotor controllers report whole degrees: with 0.5 a target of 145.7
* and a report of 145 would count as missed and could trigger the retry.
*/
public static final double REACHED_TOLERANCE_DEGREES = 1.0;
private final String threadName;
private final Function<RotatorControl, Link> linkFactory;
private final ScheduledExecutorService retryScheduler;
private final Consumer<Double> reportedAzimuthSink;
private volatile Link link;
private ScheduledFuture<?> pendingRetry;
/* Updated by the receiver thread; read by the retry check, never from UI state. */
private volatile double lastReportedAzimuth = Double.NaN;
/**
* @param threadName names the rotator in logs and in the status bar
* @param linkFactory opens the connection; receives this control as the event target
* @param retryScheduler runs the delayed compatibility check
* @param reportedAzimuthSink receives every valid, normalised reported azimuth on the
* receiver thread; the caller moves it to the UI thread
*/
public RotatorControl(String threadName, Function<RotatorControl, Link> linkFactory,
ScheduledExecutorService retryScheduler, Consumer<Double> reportedAzimuthSink) {
this.threadName = threadName;
this.linkFactory = linkFactory;
this.retryScheduler = retryScheduler;
this.reportedAzimuthSink = reportedAzimuthSink;
}
/** Opens the connection unless one is open. */
public synchronized void start() {
if (link == null) {
link = linkFactory.apply(this);
}
}
public boolean isRunning() {
return link != null;
}
/** Cancels a pending check, closes the connection and forgets the position. */
public synchronized void stop() {
ScheduledFuture<?> retry = pendingRetry;
if (retry != null) {
retry.cancel(false);
pendingRetry = null;
}
Link active = link;
link = null;
lastReportedAzimuth = Double.NaN;
if (active != null) {
active.stop();
}
}
public double lastReportedAzimuth() {
return lastReportedAzimuth;
}
/** Tells the rotor to stop turning; used when the session ends. */
public void stopRotation() {
Link active = link;
if (active != null) {
active.stopRotation();
}
}
/**
* Sends a new azimuth without blocking the caller.
*
* <p>Some SPID configurations occasionally ignore the first azimuth command. The latest
* reported position is therefore checked after two seconds. If no movement was reported
* and the target has not already been reached, the original compatibility sequence is
* sent again.</p>
*/
public synchronized void rotateTo(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(Level.WARNING, "Ignoring invalid {0} azimuth: {1}", new Object[] { threadName, azimuth });
return;
}
Link active = link;
if (active == null) {
LOGGER.log(Level.WARNING, "Cannot rotate antenna to {0} degrees: {1} integration is not active.",
new Object[] { azimuth, threadName });
return;
}
double target = normalizeAzimuth(azimuth);
double positionBeforeCommand = lastReportedAzimuth;
active.setTrackingMode(false);
LOGGER.log(Level.INFO, "Sending {0} azimuth requested by the operator: {1}", new Object[] { threadName, target });
active.setAzimuth(target);
ScheduledFuture<?> previous = pendingRetry;
if (previous != null) {
previous.cancel(false);
}
pendingRetry = retryScheduler.schedule(
() -> retryIfRequired(positionBeforeCommand, target), 2, TimeUnit.SECONDS);
}
/** Called by the receiver thread for every reported position. */
public void onAzimuthUpdate(double azimuth) {
if (!Double.isFinite(azimuth)) {
LOGGER.log(Level.WARNING, "Ignoring invalid azimuth reported by {0}: {1}", new Object[] { threadName, azimuth });
return;
}
double normalized = normalizeAzimuth(azimuth);
lastReportedAzimuth = normalized;
reportedAzimuthSink.accept(normalized);
}
private void retryIfRequired(double positionBeforeCommand, double target) {
Link active = link;
if (active == null) {
return;
}
double current = lastReportedAzimuth;
if (Double.isFinite(current) && angularDistance(current, target) < REACHED_TOLERANCE_DEGREES) {
LOGGER.log(Level.FINE, "{0} reached the requested azimuth without retry: {1}", new Object[] { threadName, target });
return;
}
boolean noPositionFeedback = !Double.isFinite(current);
boolean positionUnchanged = Double.isFinite(positionBeforeCommand)
&& Double.isFinite(current)
&& angularDistance(positionBeforeCommand, current) < 0.5;
if (!noPositionFeedback && !positionUnchanged) {
LOGGER.log(Level.FINE, "{0} reported movement towards {1}; no retry required.", new Object[] { threadName, target });
return;
}
LOGGER.log(Level.WARNING, "{0} reported no movement after the command for {1} degrees; "
+ "sending the SPID compatibility retry.", new Object[] { threadName, target });
active.setAzimuth(0.0);
active.setAzimuth(target);
}
static double angularDistance(double first, double second) {
double difference = Math.abs(normalizeAzimuth(first) - normalizeAzimuth(second));
return Math.min(difference, 360.0 - difference);
}
static double normalizeAzimuth(double azimuth) {
double normalized = azimuth % 360.0;
return normalized < 0.0 ? normalized + 360.0 : normalized;
}
}
@@ -204,6 +204,9 @@ public class ChatPreferences {
boolean stn_pstRotatorEnabled = false;
String stn_pstRotatorHost = "127.0.0.1";
int stn_pstRotatorPort = 12000;
boolean stn_pstRotator2Enabled = false;
String stn_pstRotator2Host = "127.0.0.1";
int stn_pstRotator2Port = 12010;
boolean stn_loginAFKState = false; //always start as here
/*
@@ -255,6 +258,7 @@ public class ChatPreferences {
ChatCategory loginChatCategorySecond = new ChatCategory(3);
boolean loginToSecondChatEnabled;
MutableValue<Double> actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime!
MutableValue<Double> actualQTF2 = new SimpleValue<>(Double.NaN); // heading of rotator 2 (SO2R), NaN when unknown
boolean stn_bandActive50;
boolean stn_bandActive70;
@@ -277,6 +281,7 @@ public class ChatPreferences {
String logsynch_wintestNetworkStationNameOfKST = "KST4Contest";
String logsynch_wintestNetworkStationNameOfWintestClient1 = "STN1";
String logsynch_wintestNetworkStationNameOfWintestClient2 = "";
boolean logsynch_wintestNetworkSimulationEnabled = false;
int logsynch_wintestNetworkStationIDOfKST = 55555;
int logsynch_wintestNetworkPort = 9871;
@@ -297,6 +302,7 @@ public class ChatPreferences {
MutableValue<String> MYQRGSecondCat = new SimpleValue<>(); // own qrg will be set by user entry or ucxlog if trx Synch is activated
boolean trxSynch_ucxLogUDPListenerEnabled = false;
boolean trxSynch_so2rEnabled = false;
/**
* AirScout prefs
@@ -645,6 +651,14 @@ public class ChatPreferences {
this.logsynch_wintestNetworkStationNameOfWintestClient1 = logsynch_wintestNetworkStationNameOfWintestClient1;
}
public String getLogsynch_wintestNetworkStationNameOfWintestClient2() {
return logsynch_wintestNetworkStationNameOfWintestClient2;
}
public void setLogsynch_wintestNetworkStationNameOfWintestClient2(String name) {
this.logsynch_wintestNetworkStationNameOfWintestClient2 = name == null ? "" : name.trim();
}
public boolean isLogsynch_wintestNetworkSimulationEnabled() {
return logsynch_wintestNetworkSimulationEnabled;
}
@@ -1057,6 +1071,31 @@ public class ChatPreferences {
}
}
public boolean isStn_pstRotator2Enabled() {
return stn_pstRotator2Enabled;
}
public void setStn_pstRotator2Enabled(boolean enabled) {
this.stn_pstRotator2Enabled = enabled;
}
public String getStn_pstRotator2Host() {
return stn_pstRotator2Host;
}
public void setStn_pstRotator2Host(String host) {
this.stn_pstRotator2Host = host == null || host.isBlank() ? "127.0.0.1" : host.trim();
}
public int getStn_pstRotator2Port() {
return stn_pstRotator2Port;
}
/** PSTRotator answers on port + 1, so 65535 is not usable. */
public void setStn_pstRotator2Port(int port) {
this.stn_pstRotator2Port = port < 1 || port > 65534 ? 12010 : port;
}
public MutableValue<String> getNotify_optionalFrequencyPrefix() {
return notify_optionalFrequencyPrefix;
}
@@ -1375,6 +1414,11 @@ public class ChatPreferences {
return actualQTF;
}
/** Heading reported by rotator 2; NaN while there is none. Display only. */
public MutableValue<Double> getActualQTF2() {
return actualQTF2;
}
public void setActualQTF(MutableValue<Double> actualQTF) {
this.actualQTF = actualQTF;
}
@@ -1473,6 +1517,14 @@ public class ChatPreferences {
this.trxSynch_ucxLogUDPListenerEnabled = trxSynch_ucxLogUDPListenerEnabled;
}
public boolean isTrxSynch_so2rEnabled() {
return trxSynch_so2rEnabled;
}
public void setTrxSynch_so2rEnabled(boolean enabled) {
this.trxSynch_so2rEnabled = enabled;
}
public void setNotify_optionalFrequencyPrefix(String notify_optionalFrequencyPrefix) {
this.notify_optionalFrequencyPrefix.set(notify_optionalFrequencyPrefix);
}
@@ -1809,6 +1861,18 @@ public class ChatPreferences {
stn_pstRotatorPort.setTextContent(Integer.toString(this.stn_pstRotatorPort));
station.appendChild(stn_pstRotatorPort);
Element stn_pstRotator2Enabled = doc.createElement("stn_pstRotator2Enabled");
stn_pstRotator2Enabled.setTextContent(this.stn_pstRotator2Enabled + "");
station.appendChild(stn_pstRotator2Enabled);
Element stn_pstRotator2Host = doc.createElement("stn_pstRotator2Host");
stn_pstRotator2Host.setTextContent(this.stn_pstRotator2Host);
station.appendChild(stn_pstRotator2Host);
Element stn_pstRotator2Port = doc.createElement("stn_pstRotator2Port");
stn_pstRotator2Port.setTextContent(Integer.toString(this.stn_pstRotator2Port));
station.appendChild(stn_pstRotator2Port);
/**
* LOGSYNCH
@@ -1847,6 +1911,10 @@ public class ChatPreferences {
logsynch_wintestNetworkStationNameOfWintestClient1.setTextContent(this.logsynch_wintestNetworkStationNameOfWintestClient1);
logsynch.appendChild(logsynch_wintestNetworkStationNameOfWintestClient1);
Element logsynch_wintestNetworkStationNameOfWintestClient2 = doc.createElement("logsynch_wintestNetworkStationNameOfWintestClient2");
logsynch_wintestNetworkStationNameOfWintestClient2.setTextContent(this.logsynch_wintestNetworkStationNameOfWintestClient2);
logsynch.appendChild(logsynch_wintestNetworkStationNameOfWintestClient2);
Element logsynch_wintestNetworkSimulationEnabled = doc.createElement("logsynch_wintestNetworkSimulationEnabled");
logsynch_wintestNetworkSimulationEnabled.setTextContent(this.logsynch_wintestNetworkSimulationEnabled + "");
logsynch.appendChild(logsynch_wintestNetworkSimulationEnabled);
@@ -1895,6 +1963,10 @@ public class ChatPreferences {
trxSynch_ucxLogUDPListenerEnabled.setTextContent(this.isTrxSynch_ucxLogUDPListenerEnabled()+"");
trxSynchUCX.appendChild(trxSynch_ucxLogUDPListenerEnabled);
Element trxSynch_so2rEnabled = doc.createElement("trxSynch_so2rEnabled");
trxSynch_so2rEnabled.setTextContent(this.trxSynch_so2rEnabled + "");
trxSynchUCX.appendChild(trxSynch_so2rEnabled);
Element trxSynch_defaultMYQRGValue = doc.createElement("trxSynch_defaultMYQRGValue");
trxSynch_defaultMYQRGValue.setTextContent(this.getMYQRGFirstCat().get());
trxSynchUCX.appendChild(trxSynch_defaultMYQRGValue);
@@ -2666,6 +2738,11 @@ public class ChatPreferences {
)
);
// Added in release 1.50 for SO2R; absent in older files.
stn_pstRotator2Enabled = getBoolean(stationEl, stn_pstRotator2Enabled, "stn_pstRotator2Enabled");
setStn_pstRotator2Host(getText(stationEl, stn_pstRotator2Host, "stn_pstRotator2Host"));
setStn_pstRotator2Port(getInt(stationEl, stn_pstRotator2Port, "stn_pstRotator2Port"));
}
/**
@@ -2704,6 +2781,10 @@ public class ChatPreferences {
logsynchEl,
logsynch_wintestNetworkStationNameOfWintestClient1,
"logsynch_wintestNetworkStationNameOfWintestClient1");
setLogsynch_wintestNetworkStationNameOfWintestClient2(getText(
logsynchEl,
logsynch_wintestNetworkStationNameOfWintestClient2,
"logsynch_wintestNetworkStationNameOfWintestClient2"));
logsynch_wintestNetworkSimulationEnabled = getBoolean(
logsynchEl,
logsynch_wintestNetworkSimulationEnabled,
@@ -2763,6 +2844,7 @@ public class ChatPreferences {
trxSynchEl,
trxSynch_ucxLogUDPListenerEnabled,
"trxSynch_ucxLogUDPListenerEnabled");
trxSynch_so2rEnabled = getBoolean(trxSynchEl, trxSynch_so2rEnabled, "trxSynch_so2rEnabled");
String qrg1 = getText(trxSynchEl, null, "trxSynch_defaultMYQRGValue");
if (qrg1 != null) {
@@ -0,0 +1,61 @@
package kst4contest.controller;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static kst4contest.controller.RadioSlot.FIRST;
import static kst4contest.controller.RadioSlot.SECOND;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** Every row of the spec's two routing tables. */
class RadioSlotRouterTest {
@Test
void withoutSo2rEveryRadioInfoGoesToRadioOne() {
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(false, "2"));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(false, null));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(false, "x"));
}
@Test
void withSo2rTheRadioNumberDecides() {
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(true, "1"));
assertEquals(Optional.of(SECOND), RadioSlotRouter.forRadioInfo(true, "2"));
assertEquals(Optional.of(SECOND), RadioSlotRouter.forRadioInfo(true, " 2 "));
}
@Test
void withSo2rAMissingRadioNumberIsRadioOne() {
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(true, null));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(true, " "));
}
@Test
void withSo2rOtherRadioNumbersAreDropped() {
assertEquals(Optional.empty(), RadioSlotRouter.forRadioInfo(true, "3"));
assertEquals(Optional.empty(), RadioSlotRouter.forRadioInfo(true, "0"));
assertEquals(Optional.empty(), RadioSlotRouter.forRadioInfo(true, "x"));
}
@Test
void withoutSo2rWinTestKeepsTheOldFilter() {
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(false, "STN1", "stn1", "STN2"));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(false, "ANY", "", "STN2"));
assertEquals(Optional.empty(), RadioSlotRouter.forWinTestStation(false, "STN2", "STN1", "STN2"));
}
@Test
void withSo2rTheSecondNameWinsThenTheFirst() {
assertEquals(Optional.of(SECOND), RadioSlotRouter.forWinTestStation(true, "stn2", "STN1", "STN2"));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(true, "STN1", "STN1", "STN2"));
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(true, "ANY", "", "STN2"));
assertEquals(Optional.empty(), RadioSlotRouter.forWinTestStation(true, "STN3", "STN1", "STN2"));
}
@Test
void withSo2rAnEmptySecondNameSendsNothingToRadioTwo() {
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(true, "STN2", "", ""));
assertEquals(Optional.empty(), RadioSlotRouter.forWinTestStation(true, "STN2", "STN1", ""));
}
}
@@ -18,7 +18,9 @@ import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
@@ -304,6 +306,65 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
.anyMatch(message -> message.getRunningInformation().startsWith("packet dropped after error")));
}
/** Records what radio 2 receives. */
private final List<String> secondQrgWrites = new CopyOnWriteArrayList<>();
private final SimpleValue<String> secondQrg = new SimpleValue<>("");
@Test
void withSo2rRadioTwoGoesToTheSecondQrgAndLeavesMyQrgAlone(@TempDir Path tempDir) throws Exception {
givenRadioInfoMarkerEnabled();
givenSo2r(true);
List<String> firstWrites = new CopyOnWriteArrayList<>();
markerQrg.addListener(firstWrites::add);
sendThenAwaitMarker("<RadioInfo><RadioNr>2</RadioNr><Freq>43220000</Freq><Mode>SSB</Mode></RadioInfo>");
assertEquals(List.of("432.200.00"), secondQrgWrites);
assertEquals(List.of("1296.200.00"), firstWrites, "only the marker may write MYQRG");
}
@Test
void withSo2rARadioInfoWithoutRadioNumberIsRadioOne(@TempDir Path tempDir) throws Exception {
givenRadioInfoMarkerEnabled();
givenSo2r(true);
List<String> firstWrites = new CopyOnWriteArrayList<>();
markerQrg.addListener(firstWrites::add);
sendThenAwaitMarker("<RadioInfo><Freq>14420000</Freq><Mode>CW</Mode></RadioInfo>");
assertEquals(List.of("144.200.00", "1296.200.00"), firstWrites);
assertTrue(secondQrgWrites.isEmpty());
}
@Test
void withSo2rAnUnknownRadioNumberIsDroppedAndTheListenerKeepsRunning(@TempDir Path tempDir) throws Exception {
givenRadioInfoMarkerEnabled();
givenSo2r(true);
sendThenAwaitMarker("<RadioInfo><RadioNr>x</RadioNr><Freq>43220000</Freq><Mode>SSB</Mode></RadioInfo>");
assertTrue(secondQrgWrites.isEmpty());
}
@Test
void withoutSo2rRadioTwoStillWritesMyQrg(@TempDir Path tempDir) throws Exception {
givenRadioInfoMarkerEnabled();
givenSo2r(false);
List<String> firstWrites = new CopyOnWriteArrayList<>();
markerQrg.addListener(firstWrites::add);
sendThenAwaitMarker("<RadioInfo><RadioNr>2</RadioNr><Freq>43220000</Freq><Mode>SSB</Mode></RadioInfo>");
assertEquals(List.of("432.200.00", "1296.200.00"), firstWrites);
assertTrue(secondQrgWrites.isEmpty());
}
private void givenSo2r(boolean enabled) {
when(preferences.isTrxSynch_so2rEnabled()).thenReturn(enabled);
secondQrg.addListener(secondQrgWrites::add);
when(preferences.getMYQRGSecondCat()).thenReturn(secondQrg);
}
private CountDownLatch markerLatch() {
CountDownLatch marker = new CountDownLatch(1);
markerQrg.addListener(value -> {
@@ -0,0 +1,120 @@
package kst4contest.controller;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Delayed;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* The rotator command rules moved out of ChatController unchanged: the 1 degree
* "reached" tolerance, no retry after reported movement, and the compatibility retry
* when nothing moved. The scheduler is captured, so the delayed check runs on demand.
*/
class RotatorControlTest {
private final List<String> commands = new ArrayList<>();
private final List<Double> reported = new ArrayList<>();
private final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
private Runnable pendingRetry;
private RotatorControl control() {
when(scheduler.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))).thenAnswer(call -> {
pendingRetry = call.getArgument(0);
return mock(ScheduledFuture.class);
});
RotatorControl control = new RotatorControl(
"PSTRotator",
ignored -> new RotatorControl.Link() {
@Override public void setTrackingMode(boolean on) { commands.add("tracking " + on); }
@Override public void setAzimuth(double azimuth) { commands.add("az " + azimuth); }
@Override public void stopRotation() { commands.add("halt"); }
@Override public void stop() { commands.add("stop"); }
},
scheduler,
reported::add);
control.start();
return control;
}
@Test
void rotateSendsTheNormalisedTargetAndSchedulesTheCheckAfterTwoSeconds() {
RotatorControl control = control();
control.rotateTo(-10.0);
assertEquals(List.of("tracking false", "az 350.0"), commands);
verify(scheduler).schedule(any(Runnable.class), eq(2L), eq(TimeUnit.SECONDS));
}
@Test
void aReportWithinOneDegreeCountsAsReachedAndSendsNoRetry() {
RotatorControl control = control();
control.onAzimuthUpdate(100.0);
control.rotateTo(145.7);
control.onAzimuthUpdate(145.0);
commands.clear();
pendingRetry.run();
assertTrue(commands.isEmpty());
}
@Test
void reportedMovementSendsNoRetry() {
RotatorControl control = control();
control.onAzimuthUpdate(100.0);
control.rotateTo(200.0);
control.onAzimuthUpdate(120.0);
commands.clear();
pendingRetry.run();
assertTrue(commands.isEmpty());
}
@Test
void noMovementSendsTheCompatibilityRetry() {
RotatorControl control = control();
control.onAzimuthUpdate(100.0);
control.rotateTo(200.0);
commands.clear();
pendingRetry.run();
assertEquals(List.of("az 0.0", "az 200.0"), commands);
}
@Test
void reportsAreNormalisedAndPassedOnAndInvalidOnesIgnored() {
RotatorControl control = control();
control.onAzimuthUpdate(370.0);
control.onAzimuthUpdate(Double.NaN);
assertEquals(List.of(10.0), reported);
assertEquals(10.0, control.lastReportedAzimuth());
}
@Test
void stopClosesTheLinkForgetsThePositionAndIgnoresLaterCommands() {
RotatorControl control = control();
control.onAzimuthUpdate(100.0);
control.stop();
control.rotateTo(50.0);
assertEquals(List.of("stop"), commands);
assertFalse(control.isRunning());
assertTrue(Double.isNaN(control.lastReportedAzimuth()));
}
}
@@ -0,0 +1,40 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class SecondRotatorLifecycleTest {
@TempDir
Path directory;
@Test
void rotatorTwoIsWantedOnlyWithSo2rAndItsOwnSwitch() {
ChatPreferences prefs = new ChatPreferences(directory.resolve("p.xml"));
assertFalse(ChatController.secondRotatorWanted(prefs));
prefs.setStn_pstRotator2Enabled(true);
assertFalse(ChatController.secondRotatorWanted(prefs), "rotator 2 without SO2R");
prefs.setTrxSynch_so2rEnabled(true);
assertTrue(ChatController.secondRotatorWanted(prefs));
prefs.setStn_pstRotator2Enabled(false);
assertFalse(ChatController.secondRotatorWanted(prefs));
}
@Test
void sessionRunsOnlyInStatesBetweenStartAndStop() {
assertFalse(ChatController.sessionRunning(null));
assertFalse(ChatController.sessionRunning(On4KstConnectionState.DISCONNECTED));
assertFalse(ChatController.sessionRunning(On4KstConnectionState.STOPPING));
assertTrue(ChatController.sessionRunning(On4KstConnectionState.CONNECTING));
assertTrue(ChatController.sessionRunning(On4KstConnectionState.ONLINE));
assertTrue(ChatController.sessionRunning(On4KstConnectionState.RECONNECT_WAIT));
}
}
@@ -0,0 +1,60 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.observe.DirectUiDispatcher;
import kst4contest.observe.SimpleValue;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** A Win-Test STATUS of the second station writes SECONDQRG when SO2R is on. */
class WinTestStatusSlotTest {
/* STATUS layout as parseStatus reads it: token 1 station, 5 mode, 7 frequency in 100 Hz. */
private static final String STATUS_STN2 = "STATUS: \"STN2\" \"\" 0 12 0 0 4322000 0";
private final ChatController controller = mock(ChatController.class);
private final ChatPreferences prefs = mock(ChatPreferences.class);
private final List<String> first = new ArrayList<>();
private final List<String> second = new ArrayList<>();
private static SimpleValue<String> recording(List<String> writes) {
SimpleValue<String> value = new SimpleValue<>("");
value.addListener(writes::add);
return value;
}
private ReadUDPByWintestThread thread(boolean so2r) {
when(controller.getChatPreferences()).thenReturn(prefs);
when(controller.uiDispatcher()).thenReturn(new DirectUiDispatcher());
when(prefs.isLogsynch_wintestQrgSyncEnabled()).thenReturn(true);
when(prefs.isTrxSynch_so2rEnabled()).thenReturn(so2r);
when(prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()).thenReturn("STN1");
when(prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2()).thenReturn("STN2");
when(prefs.getMYQRGFirstCat()).thenReturn(recording(first));
when(prefs.getMYQRGSecondCat()).thenReturn(recording(second));
return new ReadUDPByWintestThread(controller, mock(ThreadStatusCallback.class));
}
@Test
void withSo2rTheSecondStationWritesTheSecondQrg() {
thread(true).parseStatus(STATUS_STN2);
assertEquals(1, second.size());
assertTrue(first.isEmpty());
}
@Test
void withoutSo2rTheSecondStationIsFilteredOutAsBefore() {
thread(false).parseStatus(STATUS_STN2);
assertTrue(first.isEmpty());
assertTrue(second.isEmpty());
}
}
@@ -0,0 +1,72 @@
package kst4contest.test;
import kst4contest.model.ChatPreferences;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class ChatPreferencesSo2rPersistenceTest {
@TempDir
Path directory;
@Test
void so2rSettingsSurviveAFullRoundTrip() {
Path file = directory.resolve("preferences.xml");
ChatPreferences written = new ChatPreferences(file);
written.setTrxSynch_so2rEnabled(true);
written.setLogsynch_wintestNetworkStationNameOfWintestClient2("STN2");
written.setStn_pstRotator2Enabled(true);
written.setStn_pstRotator2Host("10.0.0.7");
written.setStn_pstRotator2Port(12020);
assertTrue(written.writePreferencesToXmlFile());
ChatPreferences restored = new ChatPreferences(file);
assertTrue(restored.readPreferencesFromXmlFile());
assertTrue(restored.isTrxSynch_so2rEnabled());
assertEquals("STN2", restored.getLogsynch_wintestNetworkStationNameOfWintestClient2());
assertTrue(restored.isStn_pstRotator2Enabled());
assertEquals("10.0.0.7", restored.getStn_pstRotator2Host());
assertEquals(12020, restored.getStn_pstRotator2Port());
}
@Test
void anOlderFileWithoutThemYieldsTheDefaults() throws IOException {
Path file = directory.resolve("old.xml");
Files.writeString(file, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>8</configVersion>
<station><stn_pstRotatorPort>12000</stn_pstRotatorPort></station>
<trxSynchUCX><trxSynch_ucxLogUDPListenerEnabled>true</trxSynch_ucxLogUDPListenerEnabled></trxSynchUCX>
</praktiKST>
""");
ChatPreferences restored = new ChatPreferences(file);
assertTrue(restored.readPreferencesFromXmlFile());
assertFalse(restored.isTrxSynch_so2rEnabled());
assertEquals("", restored.getLogsynch_wintestNetworkStationNameOfWintestClient2());
assertFalse(restored.isStn_pstRotator2Enabled());
assertEquals("127.0.0.1", restored.getStn_pstRotator2Host());
assertEquals(12010, restored.getStn_pstRotator2Port());
assertTrue(Double.isNaN(restored.getActualQTF2().get()));
}
@Test
void invalidRotator2ValuesFallBackToTheDefaults() {
ChatPreferences prefs = new ChatPreferences(directory.resolve("p.xml"));
prefs.setStn_pstRotator2Host(" ");
prefs.setStn_pstRotator2Port(70000);
assertEquals("127.0.0.1", prefs.getStn_pstRotator2Host());
assertEquals(12010, prefs.getStn_pstRotator2Port());
}
}