manual: updated pstrotator settings descriptin. Source: implemented plausibility checks for pstrotator port range and changed ui blocking problem on the spid workaround

This commit is contained in:
Marc Froehlich
2026-08-16 03:05:46 +02:00
parent 422e6cf4b7
commit 8fa5360752
12 changed files with 845 additions and 97 deletions
@@ -37,6 +37,7 @@ import java.nio.charset.StandardCharsets;
import kst4contest.logic.FrequencyTextParser;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.concurrent.ScheduledFuture;
@@ -69,8 +70,30 @@ 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 PstRotatorClient rotatorClient;
private Consumer<Double> viewRotorCallback;
private volatile PstRotatorClient rotatorClient;
private Consumer<Double> viewRotorCallback;
/*
* The rotator retry must never block the JavaFX Application Thread.
* A daemon scheduler performs the delayed SPID compatibility check.
*/
private final ScheduledExecutorService rotatorCommandScheduler =
Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(
runnable,
"PSTRotator-Command-Retry"
);
thread.setDaemon(true);
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 Kst4ContestApplication view; //effectively final, for recoupling of the controller to the view
@@ -303,41 +326,142 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
rotatorClient.start();
}
/**
* sets rotator to "AZ DEGREE" by button click <br/><br/>
* <b>Note that there is a workaround for spid rotators: <br/>
* The AZ will be set, after 'time' secs it will be controlled if the rotator started, If not, the rotator will<br/>
* be homed to 0 deg for very shord period, then the AZ value will be set again.
* </b>
* @param azimuth
*/
public void rotateTo(double azimuth) {
/**
* 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>
*
* @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;
}
double beforeRotateAzWas = chatPreferences.getActualQTF().getValue();
PstRotatorClient activeClient = rotatorClient;
if (activeClient == null) {
LOGGER.log(
Level.WARNING,
"Cannot rotate antenna to {0} degrees: "
+ "PSTRotator integration is not active.",
azimuth
);
return;
}
if (rotatorClient != null) {
rotatorClient.setTrackingMode(false);
System.out.println("Chatcontroller, Info: turning ant to " + azimuth + " by user request");
rotatorClient.setAzimuth(azimuth);
double targetAzimuth = normalizeAzimuth(azimuth);
double positionBeforeCommand = lastReportedRotatorAzimuth;
Object lockDelay = new Object();
synchronized (lockDelay) {
try{
activeClient.setTrackingMode(false);
TimeUnit.SECONDS.sleep(2);; //wait 2s, then check if rotator does anything due SPID
// sometimes does simply not accept a rotating value for first try!
} catch (InterruptedException e) {
LOGGER.log(
Level.INFO,
"Sending PSTRotator azimuth requested by the operator: {0}",
targetAzimuth
);
}
}
activeClient.setAzimuth(targetAzimuth);
if (chatPreferences.getActualQTF().getValue() == beforeRotateAzWas) {
rotatorClient.setAzimuth(0); //do some reset
rotatorClient.setAzimuth(azimuth); //then rotate
}
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) < 0.5) {
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;
}
/**
* Called when an external logger reports that a QSO was logged.
@@ -441,16 +565,42 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
public void stopRotator() {
if (rotatorClient != null) {
rotatorClient.stop();
}
}
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();
}
}
@Override
public void onAzimuthUpdate(double azimuth) {
// We are in the rotor client thread. JavaFX properties must be updated on the FX thread.
Runnable fxUpdate = () -> chatPreferences.getActualQTF().setValue(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().setValue(normalizedAzimuth);
if (Platform.isFxApplicationThread()) {
fxUpdate.run();
@@ -1167,16 +1167,22 @@ public class MessageBusManagementThread extends Thread {
// }
// }
// ==== Unified auto-answer (generic + QRG) with ping-pong guard and per-remote cooldown ====
// ==== Unified auto-answer (generic + QRG) with ping-pong guard
// and per-remote cooldown ====
final String incomingText = newMessageArrived.getMessageText();
final String incomingLower = (incomingText == null) ? "" : incomingText.toLowerCase(Locale.ROOT);
final String incomingLower =
(incomingText == null)
? ""
: incomingText.toLowerCase(Locale.ROOT);
// Never answer another automatically generated message.
// Never answer another automatically generated message.
if (!isAutoMessage(newMessageArrived)) {
boolean qrgRequested = false;
if (this.client.getChatPreferences().isMessageHandling_autoAnswerToQRGRequestEnabled()) {
if (this.client.getChatPreferences()
.isMessageHandling_autoAnswerToQRGRequestEnabled()) {
for (String lookForQRGString : qrgQuestionTexts) {
if (incomingLower.contains(lookForQRGString)) {
qrgRequested = true;
@@ -1185,36 +1191,47 @@ public class MessageBusManagementThread extends Thread {
}
}
boolean genericEnabled = this.client.getChatPreferences().isMsgHandling_autoAnswerEnabled();
boolean genericEnabled =
this.client.getChatPreferences()
.isMsgHandling_autoAnswerEnabled();
// A QRG reply takes precedence over the generic reply.
String payload = null;
String automaticAnswerText = buildAutoAnswerMessageText(
newMessageArrived,
qrgRequested,
genericEnabled
);
if (qrgRequested) {
payload = "QRG is: " + getAutoAnswerQrgForCategory(newMessageArrived.getChatCategory());
} else if (genericEnabled) {
payload = this.client.getChatPreferences().getMessageHandling_autoAnswerTextMainCat();
}
// Apply the cooldown only when this client is about to send a reply.
if (payload != null && isAutoAnswerAllowedNow(newMessageArrived)) {
/*
* Invalid or incomplete replies are rejected before the cooldown
* is checked or updated. A missing QRG must therefore not suppress
* a later valid reply.
*/
if (automaticAnswerText != null
&& isAutoAnswerAllowedNow(newMessageArrived)) {
ChatMessage automaticAnswer = new ChatMessage();
ChatMember itsMe = new ChatMember();
itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign());
itsMe.setCallSign(
this.client.getChatPreferences()
.getStn_loginCallSign()
);
automaticAnswer.setSender(itsMe);
automaticAnswer.setReceiver(newMessageArrived.getSender());
automaticAnswer.setChatCategory(newMessageArrived.getChatCategory());
// The fixed prefix prevents automatic clients from answering each other.
automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign()
+ " " + AUTOANSWER_PREFIX + " " + payload);
automaticAnswer.setReceiver(
newMessageArrived.getSender()
);
automaticAnswer.setChatCategory(
newMessageArrived.getChatCategory()
);
automaticAnswer.setMessageText(automaticAnswerText);
this.client.getMessageTXBus().add(automaticAnswer);
// Record only locally generated replies, not the later server echo.
/*
* Record the cooldown only after a complete and locally
* validated reply has been placed in the transmit queue.
*/
markLocalAutoAnswerSent(newMessageArrived);
}
}
@@ -1906,6 +1923,101 @@ public class MessageBusManagementThread extends Thread {
}
/**
* Builds and validates one automatic private reply.
*
* <p>A QRG request is answered only when the QRG belonging to the
* incoming chat category is available. The generic answer is used
* only for other private messages and only when it contains actual
* text.</p>
*
* <p>The complete message is validated before it enters the transmit
* queue. Invalid configuration values must neither produce an empty
* automatic reply nor start the cooldown.</p>
*
* @param incoming incoming private message
* @param qrgRequested whether the message contains a recognised QRG request
* @param genericEnabled whether the general automatic reply is enabled
* @return validated message text or {@code null} when no reply may be sent
*/
private String buildAutoAnswerMessageText(
ChatMessage incoming,
boolean qrgRequested,
boolean genericEnabled
) {
if (incoming == null
|| incoming.getSender() == null
|| incoming.getSender().getCallSign() == null
|| incoming.getSender().getCallSign().isBlank()) {
System.err.println(
"KST4Contest auto-answer skipped: "
+ "incoming message has no valid sender callsign."
);
return null;
}
String payload;
if (qrgRequested) {
String qrg = getAutoAnswerQrgForCategory(
incoming.getChatCategory()
);
if (qrg == null || qrg.isBlank()) {
System.err.println(
"KST4Contest QRG auto-answer skipped for "
+ incoming.getSender().getCallSign()
+ ": no QRG is available for chat category "
+ autoAnswerCooldownKey(incoming)
+ "."
);
return null;
}
payload = "QRG is: " + qrg.trim();
} else if (genericEnabled) {
payload = this.client.getChatPreferences()
.getMessageHandling_autoAnswerTextMainCat();
if (payload == null || payload.isBlank()) {
System.err.println(
"KST4Contest generic auto-answer skipped for "
+ incoming.getSender().getCallSign()
+ ": the configured answer text is empty."
);
return null;
}
payload = payload.trim();
} else {
return null;
}
String messageText =
"/CQ "
+ incoming.getSender().getCallSign().trim()
+ " "
+ AUTOANSWER_PREFIX
+ " "
+ payload;
try {
return On4KstProtocol.messageText(messageText);
} catch (IllegalArgumentException invalidMessage) {
System.err.println(
"KST4Contest auto-answer skipped for "
+ incoming.getSender().getCallSign()
+ ": "
+ invalidMessage.getMessage()
);
return null;
}
}
/**
* Returns whether a message carries the fixed marker used for automatic replies.
*/
@@ -820,7 +820,11 @@ public class ChatPreferences {
}
public void setStn_pstRotatorPort(int stn_pstRotatorPort) {
if (stn_pstRotatorPort < 1 || stn_pstRotatorPort > 65535) {
/*
* PSTRotator sends position reports to the configured UDP port + 1.
* Port 65535 would therefore require the invalid local port 65536.
*/
if (stn_pstRotatorPort < 1 || stn_pstRotatorPort > 65534) {
this.stn_pstRotatorPort = 12000;
} else {
this.stn_pstRotatorPort = stn_pstRotatorPort;
@@ -9402,7 +9402,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
txtFld_station_pstRotatorPort.getText().trim()
);
if (configuredPort < 1 || configuredPort > 65535) {
if (configuredPort < 1 || configuredPort > 65534) {
throw new NumberFormatException();
}
@@ -9412,7 +9412,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} catch (NumberFormatException exception) {
showUserInputErrorWindow(
"\"" + txtFld_station_pstRotatorPort.getText()
+ "\" is not a valid UDP port. Enter a value between 1 and 65535."
+ "\" is not a valid UDP port. Enter a value between 1 and 65534. PSTRotator reports its position on the following UDP port."
);
}
@@ -9523,7 +9523,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} catch (NumberFormatException exception) {
showUserInputErrorWindow(
"\"" + stn_txtServerPort.getText()
+ "\" is not a valid TCP port. Enter a value between 1 and 65535."
+ "\" is not a valid TCP port. Enter a value between 1 and 65534. PSTRotator reports its position on the following UDP port."
);
stn_txtServerPort.setText(
@@ -10249,7 +10249,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
showUserInputErrorWindow(
"\"" + txtFld_asUDPPortInt.getText()
+ "\" is not a valid UDP port. "
+ "Enter a value between 1 and 65535."
+ "Enter a value between 1 and 65534. PSTRotator reports its position on the following UDP port."
);
txtFld_asUDPPortInt.setText(