Fix DX Cluster spot formatting - emit fixed 75-character DXSpider-compatible lines - align callsign, comment, and UTC fields - support frequencies up to 24 GHz - reject overlong callsigns instead of truncating them - add protocol tests and update documentation Fixes #86

This commit is contained in:
Marc Froehlich
2026-09-02 20:03:55 +02:00
parent 6b0d699a98
commit 51aa04bfb5
15 changed files with 422 additions and 77 deletions
@@ -0,0 +1,165 @@
package kst4contest.controller;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
/**
* Formats local DX Cluster spots using the fixed-column layout emitted by
* DXSpider and accepted by common logging programs.
*/
final class DXClusterSpotFormatter {
/** Length of the DX Cluster line before BEL and CRLF framing. */
/* package */
static final int LINE_LENGTH = 75;
/** One-based column in which the spotted callsign starts. */
/* package */
static final int DX_CALL_COLUMN = 27;
/** Width of the fixed comment field. */
/* package */
static final int COMMENT_LENGTH = 30;
/** One-based column in which the UTC time starts. */
/* package */
static final int TIME_COLUMN = 71;
/** Zero-based exclusive end position of the frequency field. */
private static final int FREQUENCY_END = 24;
/** Maximum width of the spotted callsign field. */
private static final int DX_CALL_LENGTH = 12;
/** Required width of the HHMMZ time field. */
private static final int TIME_LENGTH = 5;
/** Minimum separator width between spotter and frequency. */
private static final int MIN_FREQUENCY_GAP = 1;
/** Wire framing appended to every formatted line. */
private static final String PAYLOAD_SUFFIX = "\u0007\u0007\r\n";
private DXClusterSpotFormatter() {
}
/** Builds the fixed 75-character payload line without wire framing. */
/* package */
static String formatLine(
final String spotterCallSign,
final String frequency,
final String dxCallSign,
final String comment,
final String time
) {
final String spotter = requireValue(
spotterCallSign,
"spotter callsign"
)
.toUpperCase(Locale.ROOT);
final String frequencyValue = requireValue(frequency, "frequency");
final String dxCall = requireValue(dxCallSign, "DX callsign")
.toUpperCase(Locale.ROOT);
final String timeValue = requireValue(time, "time");
validateDxCall(dxCall);
validateTime(timeValue);
final String prefix = "DX de " + spotter + ":";
final int frequencyPadding = calculateFrequencyPadding(
prefix,
frequencyValue
);
final String normalizedComment = normalizeComment(comment);
final String line = prefix
+ " ".repeat(frequencyPadding)
+ frequencyValue
+ " "
+ padRight(dxCall, DX_CALL_LENGTH)
+ " "
+ padRight(normalizedComment, COMMENT_LENGTH)
+ " "
+ timeValue;
if (line.length() != LINE_LENGTH) {
throw new IllegalStateException(
"DX Cluster formatter produced "
+ line.length()
+ " characters instead of "
+ LINE_LENGTH
);
}
return line;
}
/** Builds one complete ASCII spot payload including BEL and CRLF framing. */
/* package */
static byte[] formatPayload(
final String spotterCallSign,
final String frequency,
final String dxCallSign,
final String comment,
final String time
) {
return (formatLine(
spotterCallSign,
frequency,
dxCallSign,
comment,
time
) + PAYLOAD_SUFFIX).getBytes(StandardCharsets.US_ASCII);
}
private static void validateDxCall(final String dxCall) {
if (dxCall.length() > DX_CALL_LENGTH) {
throw new IllegalArgumentException(
"DX callsign exceeds 12 characters: " + dxCall
);
}
}
private static void validateTime(final String time) {
if (time.length() != TIME_LENGTH) {
throw new IllegalArgumentException(
"DX Cluster time must contain exactly five characters"
);
}
}
private static int calculateFrequencyPadding(
final String prefix,
final String frequency
) {
final int padding = FREQUENCY_END
- prefix.length()
- frequency.length();
if (padding < MIN_FREQUENCY_GAP) {
throw new IllegalArgumentException(
"Spotter callsign and frequency do not fit the DX Cluster prefix"
);
}
return padding;
}
private static String normalizeComment(final String comment) {
final String normalized = comment == null ? "" : comment.trim();
return normalized.length() > COMMENT_LENGTH
? normalized.substring(0, COMMENT_LENGTH)
: normalized;
}
private static String requireValue(
final String value,
final String fieldName
) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(
"DX Cluster " + fieldName + " is missing"
);
}
return value.trim();
}
private static String padRight(final String value, final int length) {
return value + " ".repeat(length - value.length());
}
}
@@ -152,6 +152,7 @@ public class DXClusterThreadPooledServer implements Runnable {
public boolean broadcastSingleDXClusterEntryToLoggers(
ChatMember chatMember
) {
final byte[] clusterPayload;
final String clusterMessage;
try {
@@ -162,24 +163,27 @@ public class DXClusterThreadPooledServer implements Runnable {
.getNotify_optionalFrequencyPrefix()
);
clusterMessage =
"DX de "
+ chatController
clusterPayload = DXClusterSpotFormatter.formatPayload(
chatController
.getChatPreferences()
.getNotify_DXCSrv_SpottersCallSign()
.getValue()
+ ": "
+ frequency
+ " "
+ chatMember.getCallSign().toUpperCase()
+ " "
+ chatMember.getQra().toUpperCase()
+ " "
+ new Utils4KST()
.getValue(),
frequency,
chatMember.getCallSign(),
chatMember.getQra(),
new Utils4KST()
.time_generateCurrenthhmmZTimeStringForClusterMessage()
+ ((char) 7)
+ ((char) 7)
+ "\r\n";
);
clusterMessage = new String(
clusterPayload,
StandardCharsets.US_ASCII
);
} catch (IllegalArgumentException exception) {
LOGGER.log(
Level.WARNING,
"DX Cluster spot rejected: " + exception.getMessage()
);
return false;
} catch (Exception exception) {
LOGGER.log(
Level.SEVERE,
@@ -204,11 +208,7 @@ public class DXClusterThreadPooledServer implements Runnable {
try {
OutputStream output = socket.getOutputStream();
output.write(
clusterMessage.getBytes(
StandardCharsets.US_ASCII
)
);
output.write(clusterPayload);
output.flush();
deliveredClients++;
} catch (IOException exception) {
@@ -360,4 +360,4 @@ class DXClusterServerWorkerRunnable implements Runnable {
}
}
}
}
}
@@ -1,44 +0,0 @@
package kst4contest.controller;
import javafx.beans.property.SimpleStringProperty;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
public class DXClusterThreadPooledServerTest {
public static void main(String[] args) {
ChatController client = new ChatController();
ChatPreferences testPreferences = new ChatPreferences();
testPreferences.setStn_loginCallSign("DM5M");
client.setChatPreferences(testPreferences);
DXClusterThreadPooledServer dxClusterServer = new DXClusterThreadPooledServer(8000, client, client);
new Thread(dxClusterServer).start();
try {
Thread.sleep(10 * 1000);
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>ready.....go!");
} catch (InterruptedException e) {
e.printStackTrace();
}
ChatMember test = new ChatMember();
test.setCallSign("DL5ASG");
test.setQra("JO51HK");
test.setFrequency(new SimpleStringProperty("144776.0"));
dxClusterServer.broadcastSingleDXClusterEntryToLoggers(test);
// try {
// Thread.sleep(20 * 3333);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
// System.out.println("Stopping Server");
// server.stop();
}
}
@@ -1847,14 +1847,14 @@ public class MessageBusManagementThread extends Thread {
* @param sender station for which the DX Cluster spot is generated
* @return locator with up to two optional AP entries
*/
private String buildDxClusterSpotComment(ChatMember sender) {
static String buildDxClusterSpotComment(ChatMember sender) {
if (sender == null) {
return "";
}
String locator = sender.getQra() == null
? ""
: sender.getQra().trim();
: sender.getQra().trim().toUpperCase(Locale.ROOT);
AirPlaneReflectionInfo reflectionInfo =
sender.getAirPlaneReflectInfo();
@@ -1882,7 +1882,7 @@ public class MessageBusManagementThread extends Thread {
aircraftComments.add(
aircraft.getArrivingDurationMinutes()
+ "min, "
+ "m/"
+ aircraft.getPotential()
+ "%"
);
@@ -1893,11 +1893,11 @@ public class MessageBusManagementThread extends Thread {
}
String apComment =
"AP: " + String.join("; ", aircraftComments);
"AP " + String.join(";", aircraftComments);
return locator.isEmpty()
? apComment
: locator + " , " + apComment;
: locator + " " + apComment;
}
/**
@@ -10736,7 +10736,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
testSpot.setFrequency(
new SimpleStringProperty("300")
);
testSpot.setQra("Testing DXC-Spot: Congrats, you donated $100!");
testSpot.setQra("DXC test: You donated $100!");
testSpot.setCallSign("DO5AMF");
if (!dxClusterServer
@@ -0,0 +1,138 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.charset.StandardCharsets;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
class DXClusterSpotFormatterTest {
@ParameterizedTest
@MethodSource("supportedFrequencies")
void keepsFixedColumnsAcrossSupportedFrequencies(
String spotter,
String frequency
) {
String line = DXClusterSpotFormatter.formatLine(
spotter,
frequency,
"DL5ASG",
"JO51HK",
"1234Z"
);
assertEquals(DXClusterSpotFormatter.LINE_LENGTH, line.length());
assertEquals(
"DL5ASG",
line.substring(
DXClusterSpotFormatter.DX_CALL_COLUMN - 1,
DXClusterSpotFormatter.DX_CALL_COLUMN - 1 + 6
)
);
assertEquals(
"JO51HK",
line.substring(39, 45)
);
assertEquals(
"1234Z",
line.substring(DXClusterSpotFormatter.TIME_COLUMN - 1)
);
assertEquals(
frequency,
line.substring(0, 24).trim().replaceFirst("^DX de .+?:\\s*", "")
);
}
@Test
void padsShortCommentsAndTruncatesLongCommentsToThirtyCharacters() {
String shortLine = DXClusterSpotFormatter.formatLine(
"DM5M",
"144205.0",
"DL5ASG",
"JO51HK",
"1234Z"
);
String longLine = DXClusterSpotFormatter.formatLine(
"DM5M",
"144205.0",
"DL5ASG",
"123456789012345678901234567890EXTRA",
"1234Z"
);
assertEquals(
"JO51HK" + " ".repeat(24),
shortLine.substring(39, 69)
);
assertEquals(
"123456789012345678901234567890",
longLine.substring(39, 69)
);
}
@Test
void keepsVariableDxCallsignsWithoutMovingTheComment() {
String twelveCharacterLine = DXClusterSpotFormatter.formatLine(
"DO5AMF",
"24048100.0",
"ABCDEFGHIJKL",
"JO51HK AP 1m/100%;4m/75%",
"2359Z"
);
assertEquals("ABCDEFGHIJKL", twelveCharacterLine.substring(26, 38));
assertEquals(
"JO51HK AP 1m/100%;4m/75%" + " ".repeat(6),
twelveCharacterLine.substring(39, 69)
);
assertThrows(
IllegalArgumentException.class,
() -> DXClusterSpotFormatter.formatLine(
"DO5AMF",
"144205.0",
"ABCDEFGHIJKLM",
"JO51HK",
"2359Z"
)
);
}
@Test
void appendsExactlyTwoBellCharactersAndCrlf() {
byte[] payload = DXClusterSpotFormatter.formatPayload(
"DM5M",
"50200.0",
"DL5ASG",
"JO51HK",
"0000Z"
);
assertEquals(DXClusterSpotFormatter.LINE_LENGTH + 4, payload.length);
assertEquals(7, payload[75]);
assertEquals(7, payload[76]);
assertEquals('\r', payload[77]);
assertEquals('\n', payload[78]);
assertEquals(
75,
new String(payload, 0, 75, StandardCharsets.US_ASCII).length()
);
}
private static Stream<Arguments> supportedFrequencies() {
return Stream.of(
Arguments.of("DM5M", "50200.0"),
Arguments.of("DO5AMF", "70250.0"),
Arguments.of("DM5M", "144205.0"),
Arguments.of("DO5AMF", "432088.0"),
Arguments.of("DM5M", "1296338.0"),
Arguments.of("DO5AMF", "10368100.0"),
Arguments.of("DO5AMF", "24048100.0")
);
}
}
@@ -0,0 +1,51 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import javafx.collections.FXCollections;
import kst4contest.model.AirPlane;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
class MessageBusManagementThreadDxClusterCommentTest {
@Test
void keepsLocatorAndAddsCompactAirScoutInformation() {
ChatMember sender = new ChatMember();
sender.setQra("jo51hk");
AirPlane firstAircraft = new AirPlane();
firstAircraft.setArrivingDurationMinutes(1);
firstAircraft.setPotential(100);
AirPlane secondAircraft = new AirPlane();
secondAircraft.setArrivingDurationMinutes(4);
secondAircraft.setPotential(75);
AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo();
reflectionInfo.setRisingAirplanes(
FXCollections.observableArrayList(
firstAircraft,
secondAircraft
)
);
sender.setAirPlaneReflectInfo(reflectionInfo);
assertEquals(
"JO51HK AP 1m/100%;4m/75%",
MessageBusManagementThread.buildDxClusterSpotComment(sender)
);
}
@Test
void returnsLocatorWhenAirScoutInformationIsMissing() {
ChatMember sender = new ChatMember();
sender.setQra("JO51HK");
assertEquals(
"JO51HK",
MessageBusManagementThread.buildDxClusterSpotComment(sender)
);
}
}