mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-11 11:45:27 +02:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user