mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-12 12:15:33 +02:00
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:
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
+51
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user