manual: updated beacon settings descriptin. Source: implemented plausibility checks for beacon texts

This commit is contained in:
Marc Froehlich
2026-08-16 02:34:40 +02:00
parent f77e0bd8f9
commit 422e6cf4b7
10 changed files with 481 additions and 101 deletions
@@ -2,6 +2,7 @@ package kst4contest.controller;
import java.util.TimerTask;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ThreadStateMessage;
@@ -12,6 +13,10 @@ import kst4contest.model.ThreadStateMessage;
* interval. Their enable flags and message templates remain independent. Every
* run reads the current preferences, resolves global message variables and
* sends only the categories which are currently enabled.</p>
*
* <p>Beacon messages use the regular outbound chat-message pipeline. They are
* not assembled as raw ON4KST frames, because that would bypass the common
* category, delimiter and message-text validation.</p>
*/
public class BeaconTask extends TimerTask {
@@ -39,35 +44,40 @@ public class BeaconTask extends TimerTask {
Thread.currentThread().setName("BeaconTask");
reportStatus(THREAD_NICKNAME, true, "initialized", false);
MessageVariableResolver variableResolver =
new MessageVariableResolver(chatController.getChatPreferences());
sendMainCategoryBeacon(variableResolver);
sendSecondCategoryBeacon(variableResolver);
sendMainCategoryBeacon();
sendSecondCategoryBeacon();
}
/**
* Sends the main-category beacon if it is currently enabled.
*/
private void sendMainCategoryBeacon(MessageVariableResolver variableResolver) {
if (!chatController.getChatPreferences().isBcn_beaconsEnabledMainCat()) {
reportStatus(THREAD_NICKNAME + " 1", false, "off", false);
private void sendMainCategoryBeacon() {
if (!chatController.getChatPreferences()
.isBcn_beaconsEnabledMainCat()) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"off",
false
);
return;
}
String resolvedText = variableResolver.resolveGlobalVariables(
chatController.getChatPreferences().getBcn_beaconTextMainCat()
);
ChatMessage beaconMessage = buildBeaconMessage(
chatController.getChatPreferences()
.getLoginChatCategoryMain()
.getCategoryNumber(),
resolvedText,
.getLoginChatCategoryMain(),
chatController.getChatPreferences()
.getBcn_beaconTextMainCat(),
"main category"
);
if (beaconMessage == null) {
reportStatus(THREAD_NICKNAME + " 1", false, "invalid text", true);
reportStatus(
THREAD_NICKNAME + " 1",
false,
"invalid text",
true
);
return;
}
@@ -76,37 +86,50 @@ public class BeaconTask extends TimerTask {
+ " [BeaconTask, Info]: Sending main-category CQ: "
+ beaconMessage.getMessageText()
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(THREAD_NICKNAME + " 1", true, "on", false);
reportStatus(
THREAD_NICKNAME + " 1",
true,
"on",
false
);
}
/**
* Sends the second-category beacon if the second login and its beacon are
* currently enabled.
*/
private void sendSecondCategoryBeacon(
MessageVariableResolver variableResolver
) {
if (!chatController.getChatPreferences().isLoginToSecondChatEnabled()
private void sendSecondCategoryBeacon() {
if (!chatController.getChatPreferences()
.isLoginToSecondChatEnabled()
|| !chatController.getChatPreferences()
.isBcn_beaconsEnabledSecondCat()) {
reportStatus(THREAD_NICKNAME + " 2", false, "off", false);
reportStatus(
THREAD_NICKNAME + " 2",
false,
"off",
false
);
return;
}
String resolvedText = variableResolver.resolveGlobalVariables(
chatController.getChatPreferences().getBcn_beaconTextSecondCat()
);
ChatMessage beaconMessage = buildBeaconMessage(
chatController.getChatPreferences()
.getLoginChatCategorySecond()
.getCategoryNumber(),
resolvedText,
.getLoginChatCategorySecond(),
chatController.getChatPreferences()
.getBcn_beaconTextSecondCat(),
"second category"
);
if (beaconMessage == null) {
reportStatus(THREAD_NICKNAME + " 2", false, "invalid text", true);
reportStatus(
THREAD_NICKNAME + " 2",
false,
"invalid text",
true
);
return;
}
@@ -115,47 +138,65 @@ public class BeaconTask extends TimerTask {
+ " [BeaconTask, Info]: Sending second-category CQ: "
+ beaconMessage.getMessageText()
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(THREAD_NICKNAME + " 2", true, "on", false);
reportStatus(
THREAD_NICKNAME + " 2",
true,
"on",
false
);
}
/**
* Builds the server-directed message after validating the resolved payload.
* Resolves and validates one beacon before placing it in the regular outbound
* message queue.
*
* <p>The resolved text is checked rather than only the configured template
* because inserted values can increase the final message length.</p>
* <p>The returned message contains only the public-chat payload and its chat
* category. {@link WriteThread} creates the final ON4KST frame through
* {@link On4KstProtocol#chatMessage(int, String)}. This prevents a configurable
* beacon text from bypassing the common protocol validation.</p>
*
* @param categoryNumber ON4KST category number
* @param resolvedText fully resolved beacon payload
* @param category target ON4KST chat category
* @param configuredText configured beacon template
* @param categoryDescription text used in diagnostic output
* @return prepared message, or {@code null} if the payload is invalid
* @return prepared message, or {@code null} if the category or text is invalid
*/
private ChatMessage buildBeaconMessage(
int categoryNumber,
String resolvedText,
ChatCategory category,
String configuredText,
String categoryDescription
) {
if (resolvedText == null
|| resolvedText.length() > ChatController.MAX_BEACON_TEXT_LENGTH) {
int actualLength = resolvedText == null ? 0 : resolvedText.length();
try {
if (category == null) {
throw new IllegalArgumentException(
"No chat category is configured."
);
}
On4KstProtocol.category(category.getCategoryNumber());
String resolvedText =
chatController.resolveAndValidateBeaconText(
configuredText
);
ChatMessage beaconMessage = new ChatMessage();
beaconMessage.setMessageText(resolvedText);
beaconMessage.setChatCategory(category);
beaconMessage.setMessageDirectedToServer(false);
return beaconMessage;
} catch (IllegalArgumentException exception) {
System.out.println(
"[BeaconTask, Warning]: Beacon for "
+ categoryDescription
+ " was not sent because the resolved text contains "
+ actualLength
+ " characters; maximum is "
+ ChatController.MAX_BEACON_TEXT_LENGTH
+ "."
+ " was not queued: "
+ exception.getMessage()
);
return null;
}
ChatMessage beaconMessage = new ChatMessage();
beaconMessage.setMessageText(
"MSG|" + categoryNumber + "|0|" + resolvedText + "|0|"
);
beaconMessage.setMessageDirectedToServer(true);
return beaconMessage;
}
/**
@@ -173,6 +214,9 @@ public class BeaconTask extends TimerTask {
information,
criticalState
);
callbackToController.onThreadStatus(THREAD_NICKNAME, stateMessage);
callbackToController.onThreadStatus(
THREAD_NICKNAME,
stateMessage
);
}
}
@@ -2827,6 +2827,82 @@ private ObservableList<String>
this.dbHandler = dbHandler;
}
/**
* Validates a configured beacon template before it is stored.
*
* <p>The template itself must be a non-empty, protocol-safe message. Global
* variables are resolved as far as their values are currently available.
* A template which temporarily resolves to an empty string, for example
* {@code MYQRG} before a frequency is known, remains valid. The timer performs
* the stricter final validation immediately before transmission.</p>
*
* @param configuredText configured beacon template
* @throws IllegalArgumentException if the template contains invalid protocol
* content or currently resolves to more than
* {@value MAX_BEACON_TEXT_LENGTH} characters
*/
public void validateBeaconTemplate(String configuredText) {
String normalizedTemplate =
On4KstProtocol.messageText(configuredText);
String resolvedText =
new MessageVariableResolver(chatPreferences)
.resolveGlobalVariables(normalizedTemplate);
/*
* A template consisting only of a variable such as MYQRG may temporarily
* resolve to an empty value. It can still be stored because the value may
* become available through TRX synchronisation before the timer runs.
*/
if (resolvedText == null || resolvedText.isBlank()) {
return;
}
validateResolvedBeaconText(resolvedText);
}
/**
* Resolves and validates the final beacon text immediately before transmission.
*
* <p>This is deliberately stricter than {@link #validateBeaconTemplate(String)}.
* A timer run must never queue an empty message merely because a dynamic value
* is not available at that moment.</p>
*
* @param configuredText configured beacon template
* @return normalized text which may safely pass through the regular ON4KST
* message pipeline
* @throws IllegalArgumentException if the resolved message is empty, too long
* or contains an ON4KST delimiter
*/
public String resolveAndValidateBeaconText(String configuredText) {
String resolvedText =
new MessageVariableResolver(chatPreferences)
.resolveGlobalVariables(configuredText);
return validateResolvedBeaconText(resolvedText);
}
/**
* Applies the message-text and length rules to a fully resolved beacon.
*/
private String validateResolvedBeaconText(String resolvedText) {
String normalizedText =
On4KstProtocol.messageText(resolvedText);
if (normalizedText.length() > MAX_BEACON_TEXT_LENGTH) {
throw new IllegalArgumentException(
"The resolved beacon message contains "
+ normalizedText.length()
+ " characters; maximum is "
+ MAX_BEACON_TEXT_LENGTH
+ "."
);
}
return normalizedText;
}
/**
* Starts the shared beacon timer with the interval currently stored in the
* preferences.
@@ -1,5 +1,8 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -7,6 +10,8 @@ import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import kst4contest.model.ChatPreferences;
class On4KstProtocolTest {
@Test
@@ -66,4 +71,72 @@ class On4KstProtocolTest {
On4KstConnectionManager.parseMessageTimestamp(
"CH|2|20260813123456|DL1ABC|Op|0|msg|0|"));
}
@Test
void resolvesBeaconVariablesBeforeApplyingProtocolValidation() {
ChatPreferences preferences = new ChatPreferences();
preferences.setMYQRGFirstCat("144.300");
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
controller.validateBeaconTemplate(
"calling cq at MYQRG"
);
assertEquals(
"calling cq at 144.300",
controller.resolveAndValidateBeaconText(
"calling cq at MYQRG"
)
);
}
@Test
void acceptsTemporarilyUnresolvedVariableOnlyBeaconTemplate() {
ChatPreferences preferences = new ChatPreferences();
preferences.setMYQRGFirstCat("");
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
assertDoesNotThrow(
() -> controller.validateBeaconTemplate("MYQRG")
);
assertThrows(
IllegalArgumentException.class,
() -> controller.resolveAndValidateBeaconText("MYQRG")
);
}
@Test
void rejectsEmptyOverlongAndProtocolBreakingBeaconText() {
ChatPreferences preferences = new ChatPreferences();
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate(" ")
);
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate(
"cq at 144.300|0|QUIT"
)
);
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate(
"x".repeat(
ChatController.MAX_BEACON_TEXT_LENGTH
+ 1
)
)
);
}
}
@@ -4933,40 +4933,51 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
/**
* Validates and stores one beacon text.
* Validates and stores one beacon template.
*
* <p>The final text is checked after global variables have been resolved.
* Otherwise a template with at most 120 characters could still exceed the
* server limit after values such as MYQRG or MYLOCATOR were inserted.</p>
* <p>The template is checked against the same protocol and length rules which
* are applied by the timer before transmission. A variable-only template may
* temporarily resolve to an empty value and can still be stored; the timer will
* not send it until a usable value is available.</p>
*
* @param textField field containing the configured beacon template
* @param mainCategory {@code true} for the main category, {@code false} for
* the optional second category
*/
private void applyBeaconTextSetting(TextField textField, boolean mainCategory) {
String configuredText = textField.getText() == null ? "" : textField.getText();
String resolvedText = messageVariableResolver.resolveGlobalVariables(configuredText);
private void applyBeaconTextSetting(
TextField textField,
boolean mainCategory
) {
String configuredText =
textField.getText() == null
? ""
: textField.getText();
try {
chatcontroller.validateBeaconTemplate(configuredText);
if (resolvedText != null
&& resolvedText.length() <= ChatController.MAX_BEACON_TEXT_LENGTH) {
if (mainCategory) {
chatcontroller.getChatPreferences().setBcn_beaconTextMainCat(configuredText);
chatcontroller.getChatPreferences()
.setBcn_beaconTextMainCat(configuredText);
} else {
chatcontroller.getChatPreferences().setBcn_beaconTextSecondCat(configuredText);
chatcontroller.getChatPreferences()
.setBcn_beaconTextSecondCat(configuredText);
}
return;
} catch (IllegalArgumentException exception) {
String previousText =
mainCategory
? chatcontroller.getChatPreferences()
.getBcn_beaconTextMainCat()
: chatcontroller.getChatPreferences()
.getBcn_beaconTextSecondCat();
textField.setText(previousText);
alertWindowEvent(
"The beacon message is invalid: "
+ exception.getMessage()
);
}
String previousText = mainCategory
? chatcontroller.getChatPreferences().getBcn_beaconTextMainCat()
: chatcontroller.getChatPreferences().getBcn_beaconTextSecondCat();
textField.setText(previousText);
alertWindowEvent(
"The resolved beacon message must not exceed "
+ ChatController.MAX_BEACON_TEXT_LENGTH
+ " characters."
);
}
/**