mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +02:00
* 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:
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user