WT-Logsync when KST4C is not actively receiving WT Packets,

so that we can later mark QSOs as marked, even though we never received
the packet.
This commit is contained in:
2026-09-04 13:42:50 +02:00
parent 830e4020a2
commit 634b88238d
17 changed files with 2224 additions and 30 deletions
@@ -1204,10 +1204,11 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return;
}
InetAddress broadcastAddress = InetAddress.getByName(
chatPreferences
.getLogsynch_wintestNetworkBroadcastAddress()
);
InetAddress broadcastAddress =
winTestAddressResolver.resolveBroadcastAddress(
chatPreferences
.getLogsynch_wintestNetworkBroadcastAddress()
);
int port =
chatPreferences.getLogsynch_wintestNetworkPort();
@@ -1647,6 +1648,21 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
private int port = 23001; // kst4contest.test 4 23001 //TODO: auslagern in Chatprefs
private ReadUDPbyUCXMessageThread readUDPbyUCXThread;
private ReadUDPByWintestThread readUDPByWintestThread;
/**
* Shared resolver for the Win-Test broadcast address. Win-Test only reacts
* to broadcasts, so both the log synchronization and the SKED handover have
* to reach the network the station was actually heard on.
*/
private final WinTestNetworkAddressResolver winTestAddressResolver =
new WinTestNetworkAddressResolver();
/**
* @return resolver for outgoing Win-Test packets
*/
public WinTestNetworkAddressResolver getWinTestAddressResolver() {
return winTestAddressResolver;
}
private WriteThread writeThread;
private ReadThread readThread;
private InputReaderThread consoleReader;
@@ -13,8 +13,9 @@ import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -31,18 +32,40 @@ public class ReadUDPByWintestThread extends Thread {
private static final int BUFFER_SIZE = 4096;
private final Map<Integer, String> receivedQsos = new ConcurrentHashMap<>();
private long lastPacketTime = 0;
private String myStation = "DO5AMF";
private String targetStation = "";
private String stationID = "";
private int lastKnownQso = 0;
private ThreadStatusCallback callBackToController;
private String ThreadNickName = "Wintest-msg";
/**
* Number of fields of a complete ADDQSO packet, including message type,
* source and destination.
*/
private static final int ADDQSO_FIELD_COUNT = 24;
/** Field position of the Win-Test QSO number inside an ADDQSO packet. */
private static final int ADDQSO_QSO_NUMBER_INDEX = 11;
/** Field position of the logging station name inside an ADDQSO packet. */
private static final int ADDQSO_STATION_NAME_INDEX = 3;
private final WinTestLogSyncService logSyncService;
private WinTestLogSyncService.SyncState lastReportedSyncState;
/**
* Last IHAVE payload seen per station. Win-Test repeats the inventory
* periodically, so tracing only the changes keeps the output readable.
*/
private final Map<String, String> lastTracedIhaveByStation = new HashMap<>();
private final WinTestNetworkAddressResolver addressResolver;
public ReadUDPByWintestThread(ChatController client, ThreadStatusCallback callback) {
@@ -51,6 +74,21 @@ public class ReadUDPByWintestThread extends Thread {
this.myStation = client.getChatPreferences().getStn_loginCallSignRaw(); //callsign of the logging stn
this.PORT = client.getChatPreferences().getLogsynch_wintestNetworkPort();
WinTestNetworkAddressResolver sharedAddressResolver =
client.getWinTestAddressResolver();
this.addressResolver = sharedAddressResolver != null
? sharedAddressResolver
: new WinTestNetworkAddressResolver();
/*
* Preferences are read late on purpose: station name, port and broadcast
* address can be changed while the listener is running.
*/
this.logSyncService = new WinTestLogSyncService(
this::sendNeedQso,
this::resolveOwnWinTestStationName
);
}
@Override
@@ -85,35 +123,113 @@ public class ReadUDPByWintestThread extends Thread {
while (running) {
try {
/*
* DatagramPacket keeps the length of the previous datagram, so
* without resetting it a long packet would be truncated after a
* short one. A truncated packet loses its trailing fields and
* its checksum.
*/
packet.setLength(buffer.length);
socket.receive(packet);
String msg = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.US_ASCII).trim();
processWinTestMessage(msg);
processWinTestDatagram(
packet.getData(), packet.getLength(), packet.getAddress());
} catch (SocketTimeoutException e) {
// checkForMissingQsos();
logSyncService.tick();
reportSyncStateIfChanged();
} catch (IOException e) {
//TODO: here is something to catch
}
}
}
/**
* Resolves the Win-Test framing of a received datagram and processes it.
*
* <p>The checksum byte and the NUL terminator are removed on the raw bytes
* before any text parsing, so the trailing fields of the packet stay
* readable. Afterwards the log synchronization gets its chance to request
* QSOs that were logged before this listener was started.</p>
*
* @param datagram raw datagram buffer
* @param length number of valid bytes in the buffer
*/
void processWinTestDatagram(byte[] datagram, int length, InetAddress source) {
if (datagram == null || length <= 0) {
return;
}
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, length);
if (packet != null && isWinTestStationMessage(packet.getMessageType())) {
/*
* Win-Test only answers broadcasts. Remembering where its packets
* come from keeps outgoing requests on the network the station
* actually lives in, even when the configured broadcast address
* belongs to a different or no longer existing network.
*/
addressResolver.rememberStationAddress(source);
}
if (packet == null) {
/*
* The datagram does not follow the Win-Test framing. It still
* reaches the established text handling, which also recognizes the
* poison pill that stops this listener.
*/
processWinTestPacket(
null,
new String(datagram, 0, length, StandardCharsets.US_ASCII).trim()
);
return;
}
processWinTestPacket(packet, packet.getMessageText());
logSyncService.tick();
reportSyncStateIfChanged();
}
void processWinTestMessage(String msg) {
processWinTestPacket(WinTestPacket.fromMessageText(msg), msg);
}
/**
* Processes one Win-Test message.
*
* @param packet parsed packet, or {@code null} when the message does not
* follow the Win-Test framing
* @param msg complete message text
*/
private void processWinTestPacket(WinTestPacket packet, String msg) {
// System.out.println("Wintest-Message received: " + msg);
if (msg == null) {
return;
}
lastPacketTime = System.currentTimeMillis();
if (msg.startsWith("HELLO:")) { //Client Signon of wintest
parseHello(msg);
try {
// send_needqso();
}catch (Exception e) {
System.out.println("Error: ");
e.printStackTrace();
}
if (packet != null) {
System.out.println("[WinTest RX] HELLO from " + packet.getSource());
logSyncService.onStationSeen(packet.getSource());
}
} else if (msg.startsWith("ADDQSO:")) { //adding qso to wintest log
try {
if (packet != null && !packet.getDestination().isEmpty()) {
/*
* A directed ADDQSO is the answer to one of our NEEDQSO
* requests. Tracing it separates a missing answer from a
* failing evaluation of the answer.
*/
System.out.println("[WinTest RX] ADDQSO answer from "
+ packet.getSource() + " to " + packet.getDestination());
}
parseAddQso(msg);
} catch (Exception e) {
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "Parsing ERROR: " + Arrays.toString(e.getStackTrace()), true);
@@ -123,8 +239,19 @@ public class ReadUDPByWintestThread extends Thread {
} else if (msg.startsWith("STATUS")) {
parseStatus(msg);
/*
* HELLO is only sent when a log is opened, so a listener that was
* started later learns about a station from its periodic STATUS.
* The configured station-name filter stays a QRG-sync setting: in a
* multi-station setup every band station keeps its own log, and all
* of them contribute Worked state.
*/
if (packet != null) {
logSyncService.onStationSeen(packet.getSource());
}
} else if (msg.startsWith("IHAVE:")) { //periodical message of wintest, which qsos are in the log
// parseIHave(msg); //TODO
parseIHave(packet);
}
else if (msg.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
@@ -138,6 +265,130 @@ public class ReadUDPByWintestThread extends Thread {
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
}
/**
* Hands the periodic Win-Test log inventory to the log synchronization.
*
* <p>A packet with a broken checksum is discarded here. The run-length
* inventory is the last field of an IHAVE packet, so a corrupted packet
* would announce QSO ranges that do not exist. The established handling of
* the other message types is deliberately left unchanged, because it never
* verified the checksum.</p>
*
* @param packet received IHAVE packet
*/
private void parseIHave(WinTestPacket packet) {
if (packet == null) {
return;
}
if (packet.isChecksumPresent() && !packet.isChecksumValid()) {
System.out.println("[WinTest] IHAVE with invalid checksum ignored");
return;
}
String tracedPayload = String.join(" ", packet.getDataTokens());
if (!tracedPayload.equals(lastTracedIhaveByStation.put(packet.getSource(), tracedPayload))) {
System.out.println("[WinTest RX] IHAVE from " + packet.getSource()
+ " to '" + packet.getDestination() + "': " + tracedPayload
+ (WinTestIhaveInventory.fromPacket(packet).isEmpty()
? " <-- not usable as inventory" : ""));
}
logSyncService.onIhaveReceived(packet);
}
/**
* Reports a change of the log-synchronization progress to the controller.
*/
private void reportSyncStateIfChanged() {
WinTestLogSyncService.SyncState currentSyncState = logSyncService.getState();
if (currentSyncState == lastReportedSyncState) {
return;
}
lastReportedSyncState = currentSyncState;
ThreadStateMessage threadStateMessage = new ThreadStateMessage(
this.ThreadNickName, true, "log sync: " + currentSyncState, false);
callBackToController.onThreadStatus(ThreadNickName, threadStateMessage);
}
/**
* Sends a NEEDQSO request as a UDP broadcast.
*
* <p>The framing follows the wtKST implementation exactly, including the
* leading blank of the data part:</p>
*
* <pre>
* NEEDQSO: "KST4Contest" "STN1" "STN1@44510" 1 50{checksum}\0
* </pre>
*
* @param targetStation Win-Test station the request is addressed to
* @param logId log identity in the form {@code StationName@LogUniqueID}
* @param countFrom first requested QSO number
* @param countTo last requested QSO number
*/
private void sendNeedQso(String targetStation, String logId, long countFrom, long countTo) {
String data = " \"" + logId + "\" " + countFrom + " " + countTo;
WinTestMessage needQsoMessage = new WinTestMessage(
WinTestMessage.MessageType.NEEDQSO,
resolveOwnWinTestStationName(),
targetStation,
data
);
try (DatagramSocket sendSocket = new DatagramSocket()) {
sendSocket.setBroadcast(true);
sendSocket.setReuseAddress(true);
byte[] messageBytes = needQsoMessage.toBytes();
InetAddress broadcastAddress = addressResolver.resolveBroadcastAddress(
client.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress());
int targetPort = client.getChatPreferences().getLogsynch_wintestNetworkPort();
sendSocket.send(new DatagramPacket(
messageBytes, messageBytes.length, broadcastAddress, targetPort));
System.out.println("[WinTest LogSync] NEEDQSO to " + targetStation
+ " for " + logId + " " + countFrom + "-" + countTo);
} catch (IOException | RuntimeException exception) {
System.out.println("[WinTest LogSync] NEEDQSO could not be sent: "
+ exception.getMessage());
}
}
/**
* @return own station name in the Win-Test network, never blank
*/
private String resolveOwnWinTestStationName() {
String configuredStationName =
client.getChatPreferences().getLogsynch_wintestNetworkStationNameOfKST();
if (configuredStationName == null || configuredStationName.isBlank()) {
return "KST4Contest";
}
return configuredStationName.trim();
}
/**
* Checks whether a message type identifies a genuine Win-Test station.
*
* <p>Internal control messages such as the poison pill must not influence
* the address of outgoing Win-Test packets.</p>
*
* @param messageType message type of a received packet
* @return {@code true} for a Win-Test station message
*/
private static boolean isWinTestStationMessage(String messageType) {
return "HELLO".equals(messageType)
|| "STATUS".equals(messageType)
|| "IHAVE".equals(messageType)
|| "ADDQSO".equals(messageType);
}
/**
* parsing of the hello message of wintest:
* "HELLO: "STN1" "" 6667 130 "SLAVE" 1 0 1762201985"
@@ -346,15 +597,6 @@ public class ReadUDPByWintestThread extends Thread {
}
}
// private void send_needqso() throws IOException {
// String payload = String.format("NEEDQSO:\"%s\" \"%s\" \"%s\" %d %d?\0",
// "DO5AMF", "STN1", stationID, 1, 9999);
// InetAddress broadcast = InetAddress.getByName("255.255.255.255");
// byte[] bytes = payload.getBytes(StandardCharsets.US_ASCII);
// bytes[bytes.length - 2] = util_calculateChecksum((bytes));
// socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871));
// }
// private void send_hello() throws IOException {
// String payload = String.format("HELLO:\"%s\" \"%s\" \"%s\" %d %d?\0",
// "DO5AMF", "", stationID, "SLAVE", 1, 14);
@@ -390,6 +632,61 @@ public class ReadUDPByWintestThread extends Thread {
return packetFields.length > 3 ? packetFields[3] : "";
}
/**
* Builds the log identity of an ADDQSO packet.
*
* <p>Win-Test numbers the QSOs of every log continuously, so a QSO is only
* identified by the combination of the logging station, the unique log ID
* and the QSO number. The log ID is the last field of the packet.</p>
*
* @param packetFields fields of the ADDQSO packet
* @return identity in the form {@code StationName@LogUniqueID}, or
* {@code null} when the packet does not carry both values
*/
static String extractLogIdFromWinTestAddQso(List<String> packetFields) {
if (packetFields == null || packetFields.size() < ADDQSO_FIELD_COUNT) {
return null;
}
String stationName = packetFields.get(ADDQSO_STATION_NAME_INDEX);
String logUniqueId = packetFields.get(packetFields.size() - 1);
if (stationName == null || stationName.isBlank()
|| logUniqueId == null || logUniqueId.isBlank()) {
return null;
}
return stationName.trim() + "@" + logUniqueId.trim();
}
/**
* Extracts the Win-Test QSO number of an ADDQSO packet.
*
* <p>Win-Test sends {@code 0} instead of {@code 1} for the first QSO of a
* log in some situations. wtKST corrects that the same way.</p>
*
* @param packetFields fields of the ADDQSO packet
* @return QSO number, or {@code 0} when the packet carries no usable value
*/
static long extractQsoNumberFromWinTestAddQso(List<String> packetFields) {
if (packetFields == null || packetFields.size() < ADDQSO_FIELD_COUNT) {
return 0L;
}
String rawQsoNumber = packetFields.get(ADDQSO_QSO_NUMBER_INDEX);
if (rawQsoNumber == null) {
return 0L;
}
try {
long qsoNumber = Long.parseLong(rawQsoNumber.trim());
return qsoNumber <= 0L ? 1L : qsoNumber;
} catch (NumberFormatException exception) {
return 0L;
}
}
/**
* Extracts the locator from a Win-Test ADDQSO packet.
*
@@ -443,6 +740,17 @@ public class ReadUDPByWintestThread extends Thread {
*/
private void parseAddQso(String msg) {
try {
List<String> packetFields = WinTestPacket.tokenize(msg);
String logId = extractLogIdFromWinTestAddQso(packetFields);
long qsoNumber = extractQsoNumberFromWinTestAddQso(packetFields);
/*
* The QSO number is registered before any validation. Otherwise the
* log synchronization would request a QSO with unusable content
* over and over again.
*/
boolean isUnknownQso = logSyncService.registerReceivedQso(logId, qsoNumber);
String[] quotedParts = msg == null ? new String[0] : msg.split("\"");
String callSign = quotedParts.length > 7 ? quotedParts[7] : "";
String rawBandId = extractBandIdFromWinTestAddQso(msg);
@@ -455,6 +763,16 @@ public class ReadUDPByWintestThread extends Thread {
return;
}
if (!isUnknownQso) {
/*
* Win-Test resends known QSOs when a NEEDQSO request overlaps
* with QSOs that already arrived as a broadcast. Worked state
* and database entry exist in that case, so repeating the write
* would only cost time during the initial log recovery.
*/
return;
}
if (loggedBand == null && !rawBandId.isEmpty()) {
System.out.println("[WinTestUDPRcvr: warning] Unknown band ID: " + rawBandId);
}
@@ -0,0 +1,217 @@
package kst4contest.controller;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
/**
* Inventory of one Win-Test log, transported in an {@code IHAVE} packet.
*
* <p>Win-Test announces which QSO numbers of a log a station currently holds.
* To keep the packet short the inventory is run-length encoded:</p>
*
* <pre>
* IHAVE: "Shack" "" "Shack@9" E 1 1 911-1-117
* ^logId ^ ^ ^ ^run lengths
* origin | initial state
* first row
* </pre>
*
* <p>The run lengths alternate between present and missing QSOs, starting with
* the state given by {@code InitialState} at QSO number {@code FirstRow}. The
* example above therefore means: QSOs 1 to 911 are present, QSO 912 is missing
* and QSOs 913 to 1029 are present again.</p>
*
* <p>Unlike the wtKST implementation this parser honours {@code FirstRow}
* instead of assuming that every inventory starts at QSO number one. Win-Test
* splits long inventories into several packets, and a split inventory starts at
* a higher first row.</p>
*/
public final class WinTestIhaveInventory {
/** Where the sending station got the log from. */
public enum Origin {
/** The station owns the log or the operator is logged on there. */
OWNER,
/** The station only mirrors a log owned by somebody else. */
LOGGED_ELSE
}
/** Protects against endless loops caused by a corrupted run-length chain. */
private static final int MAX_SEGMENTS = 512;
private static final int EXPECTED_FIELD_COUNT = 5;
private final String logId;
private final Origin origin;
private final List<WinTestLogSegment> segments;
private WinTestIhaveInventory(String logId, Origin origin, List<WinTestLogSegment> segments) {
this.logId = logId;
this.origin = origin;
this.segments = segments;
}
/**
* Parses an {@code IHAVE} packet.
*
* @param packet received packet
* @return inventory, or an empty value when the packet is not a usable
* {@code IHAVE} announcement
*/
public static Optional<WinTestIhaveInventory> fromPacket(WinTestPacket packet) {
if (packet == null || !"IHAVE".equals(packet.getMessageType())) {
return Optional.empty();
}
List<String> fields = packet.getDataTokens();
if (fields.size() != EXPECTED_FIELD_COUNT) {
/*
* Win-Test versions before 1.29 use a shorter IHAVE format without
* run-length encoding. It carries no usable range information, so
* the blind fallback of the sync service has to take over.
*/
return Optional.empty();
}
String parsedLogId = fields.get(0) == null ? "" : fields.get(0).trim();
if (parsedLogId.isEmpty()) {
return Optional.empty();
}
Origin parsedOrigin = parseOrigin(fields.get(1));
long firstRow = parseUnsignedValue(fields.get(2));
long initialState = parseUnsignedValue(fields.get(3));
if (firstRow < 1L || initialState < 0L || initialState > 1L) {
return Optional.empty();
}
List<WinTestLogSegment> parsedSegments =
parseRunLengths(fields.get(4), firstRow, initialState == 1L);
if (parsedSegments == null) {
return Optional.empty();
}
return Optional.of(new WinTestIhaveInventory(parsedLogId, parsedOrigin, parsedSegments));
}
private static Origin parseOrigin(String rawOrigin) {
if (rawOrigin == null) {
return Origin.OWNER;
}
String normalizedOrigin = rawOrigin.trim().toUpperCase(java.util.Locale.ROOT);
if ("E".equals(normalizedOrigin) || "LOGGEDELSE".equals(normalizedOrigin)) {
return Origin.LOGGED_ELSE;
}
return Origin.OWNER;
}
/**
* Expands the hyphen-separated run lengths into ranges.
*
* @param rawRunLengths run-length chain such as {@code 911-1-117}
* @param firstRow QSO number the first run starts at
* @param startsPresent {@code true} when the first run describes present QSOs
* @return ranges of present QSOs, or {@code null} for an unusable chain
*/
private static List<WinTestLogSegment> parseRunLengths(
String rawRunLengths,
long firstRow,
boolean startsPresent
) {
if (rawRunLengths == null || rawRunLengths.isBlank()) {
return null;
}
String[] runLengths = rawRunLengths.trim().split("-");
/*
* A chain that starts with present QSOs has to end with a present run,
* so its length is odd. A chain that starts with missing QSOs needs an
* even length for the same reason.
*/
if (startsPresent) {
if (runLengths.length % 2 == 0) {
return null;
}
} else if (runLengths.length % 2 == 1 || runLengths.length < 2) {
return null;
}
List<WinTestLogSegment> parsedSegments = new ArrayList<>();
long cursor = firstRow;
boolean present = startsPresent;
for (String runLength : runLengths) {
long count = parseUnsignedValue(runLength);
if (count < 0L) {
return null;
}
if (present && count > 0L) {
if (parsedSegments.size() >= MAX_SEGMENTS) {
return null;
}
parsedSegments.add(new WinTestLogSegment(cursor, cursor + count - 1L));
}
cursor += count;
present = !present;
}
return parsedSegments;
}
private static long parseUnsignedValue(String rawValue) {
if (rawValue == null) {
return -1L;
}
try {
return Long.parseLong(rawValue.trim());
} catch (NumberFormatException exception) {
return -1L;
}
}
/**
* @return log identity in the form {@code StationName@LogUniqueID}
*/
public String getLogId() {
return logId;
}
public Origin getOrigin() {
return origin;
}
/**
* @return ranges of QSO numbers the announcing station holds
*/
public List<WinTestLogSegment> getSegments() {
return Collections.unmodifiableList(segments);
}
/**
* @return highest announced QSO number, or {@code 0} for an empty inventory
*/
public long getHighestQsoNumber() {
long highestQsoNumber = 0L;
for (WinTestLogSegment segment : segments) {
if (segment.getCountTo() > highestQsoNumber) {
highestQsoNumber = segment.getCountTo();
}
}
return highestQsoNumber;
}
@Override
public String toString() {
return logId + " " + origin + " " + segments;
}
}
@@ -0,0 +1,60 @@
package kst4contest.controller;
/**
* Consecutive range of Win-Test QSO numbers inside one log.
*
* <p>Win-Test numbers the QSOs of every log continuously. The {@code IHAVE}
* inventory of a log is therefore expressed as a list of ranges that are
* present in that log. A range is inclusive on both ends.</p>
*/
public final class WinTestLogSegment {
private final long countFrom;
private final long countTo;
/**
* @param countFrom first QSO number of the range
* @param countTo last QSO number of the range
*/
public WinTestLogSegment(long countFrom, long countTo) {
this.countFrom = countFrom;
this.countTo = countTo;
}
public long getCountFrom() {
return countFrom;
}
public long getCountTo() {
return countTo;
}
/**
* @return number of QSOs covered by this range, never negative
*/
public long getCount() {
return countTo < countFrom ? 0L : countTo - countFrom + 1L;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof WinTestLogSegment)) {
return false;
}
WinTestLogSegment otherSegment = (WinTestLogSegment) other;
return countFrom == otherSegment.countFrom && countTo == otherSegment.countTo;
}
@Override
public int hashCode() {
return Long.hashCode(countFrom) * 31 + Long.hashCode(countTo);
}
@Override
public String toString() {
return countFrom + "-" + countTo;
}
}
@@ -0,0 +1,611 @@
package kst4contest.controller;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableSet;
import java.util.Optional;
import java.util.TreeSet;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/**
* Recovers the part of a Win-Test log that was written before KST4Contest was
* started.
*
* <p>Win-Test broadcasts every new QSO as an {@code ADDQSO} packet. A client
* that joins the network later never sees the QSOs logged before it started, so
* stations already worked would still be shown as not worked. Win-Test also
* offers a pull mechanism for exactly this situation, and this service is the
* port of the wtKST {@code WtLogSync} implementation of it:</p>
*
* <ol>
* <li>a Win-Test station announces itself with {@code HELLO} or, if its log
* was opened before we started listening, with its periodic
* {@code STATUS};</li>
* <li>its periodic {@code IHAVE} packets announce which QSO numbers of
* which log it holds;</li>
* <li>missing ranges are requested with {@code NEEDQSO}, at most
* {@value #MAX_QSOS_PER_REQUEST} QSOs per request;</li>
* <li>Win-Test answers with ordinary {@code ADDQSO} packets that are
* addressed to us instead of being broadcast.</li>
* </ol>
*
* <p>Because the answers are ordinary {@code ADDQSO} packets, the recovered
* QSOs run through the same Worked handling as live QSOs. This service only
* decides what still has to be requested; it neither touches the database nor
* the user interface.</p>
*
* <p>If a station is known but no usable {@code IHAVE} inventory arrives within
* {@value #INVENTORY_GRACE_PERIOD_MS} ms, a blind fallback requests fixed
* blocks starting at QSO number one until a block stays unanswered. That covers
* Win-Test versions whose {@code IHAVE} format carries no run lengths.</p>
*
* <p>Deviation from wtKST: wtKST discards its whole QSO table whenever a
* {@code HELLO} arrives, because it displays that table. KST4Contest only
* accumulates Worked state, where a stale entry is harmless while a discarded
* one would cause the complete log to be requested and written again. The log
* identity {@code StationName@LogUniqueID} already changes when Win-Test opens
* a different log, so nothing is cleared here.</p>
*/
public class WinTestLogSyncService {
/** Win-Test answers at most this many QSOs for one NEEDQSO request. */
static final int MAX_QSOS_PER_REQUEST = 50;
/** Time after which an unanswered request is retried elsewhere. */
static final long REQUEST_TIMEOUT_MS = 5000L;
/** Shortest distance between two evaluations without a pending trigger. */
static final long TICK_INTERVAL_MS = 2000L;
/** Waiting time for a usable IHAVE before the blind fallback starts. */
static final long INVENTORY_GRACE_PERIOD_MS = 15000L;
/** Upper bound for the blind fallback, equals 10000 QSOs. */
static final int MAX_BLIND_BLOCKS = 200;
/** Guards the gap search against a corrupted inventory. */
private static final long MAX_SCANNED_QSO_NUMBERS = 200000L;
/**
* Sends a NEEDQSO request to a Win-Test station.
*/
@FunctionalInterface
public interface NeedQsoSender {
/**
* @param targetStation Win-Test station name the request is sent to
* @param logId log identity in the form {@code StationName@LogUniqueID}
* @param countFrom first requested QSO number
* @param countTo last requested QSO number
*/
void sendNeedQso(String targetStation, String logId, long countFrom, long countTo);
}
/** Progress of the log recovery, used for status reporting. */
public enum SyncState {
/** No Win-Test station seen yet. */
IDLE,
/** A station is known, but nothing has been requested yet. */
STATION_DETECTED,
/** QSOs are being requested. */
SYNCING,
/** Everything announced by the known stations has been received. */
IN_SYNC
}
private final NeedQsoSender needQsoSender;
private final Supplier<String> ownStationNameSupplier;
private final LongSupplier clock;
/** Inventories per Win-Test station, keyed by log identity. */
private final Map<String, Map<String, WinTestIhaveInventory>> inventoriesByStation =
new LinkedHashMap<>();
/** QSO numbers already received, keyed by log identity. */
private final Map<String, NavigableSet<Long>> receivedQsoNumbersByLogId = new HashMap<>();
/** State of the blind fallback, keyed by log identity. */
private final Map<String, BlindScan> blindScansByLogId = new LinkedHashMap<>();
private PendingRequest pendingRequest;
private long firstStationSeenAtMs;
private boolean usableInventorySeen;
private long lastTickMs;
private boolean tickDueImmediately;
private SyncState state = SyncState.IDLE;
/**
* @param needQsoSender transport used for NEEDQSO requests
* @param ownStationNameSupplier own Win-Test station name, read late because
* it can be changed in the settings at runtime
*/
public WinTestLogSyncService(
NeedQsoSender needQsoSender,
Supplier<String> ownStationNameSupplier
) {
this(needQsoSender, ownStationNameSupplier, System::currentTimeMillis);
}
/**
* @param needQsoSender transport used for NEEDQSO requests
* @param ownStationNameSupplier own Win-Test station name
* @param clock time source in milliseconds
*/
WinTestLogSyncService(
NeedQsoSender needQsoSender,
Supplier<String> ownStationNameSupplier,
LongSupplier clock
) {
this.needQsoSender = needQsoSender;
this.ownStationNameSupplier = ownStationNameSupplier;
this.clock = clock;
}
/**
* Registers a Win-Test station seen in a HELLO or STATUS packet.
*
* @param stationName Win-Test station name
*/
public synchronized void onStationSeen(String stationName) {
if (stationName == null || stationName.isBlank()) {
return;
}
String normalizedStationName = stationName.trim();
if (normalizedStationName.equalsIgnoreCase(resolveOwnStationName())) {
// our own packets, nothing to synchronize from
return;
}
if (inventoriesByStation.putIfAbsent(normalizedStationName, new LinkedHashMap<>()) == null) {
tickDueImmediately = true;
}
if (firstStationSeenAtMs == 0L) {
firstStationSeenAtMs = clock.getAsLong();
}
if (state == SyncState.IDLE) {
state = SyncState.STATION_DETECTED;
}
}
/**
* Takes over the inventory of an IHAVE packet.
*
* @param packet received IHAVE packet
*/
public synchronized void onIhaveReceived(WinTestPacket packet) {
if (packet == null || !packet.isAddressedTo(resolveOwnStationName())) {
return;
}
Optional<WinTestIhaveInventory> parsedInventory = WinTestIhaveInventory.fromPacket(packet);
if (parsedInventory.isEmpty()) {
return;
}
WinTestIhaveInventory inventory = parsedInventory.get();
onStationSeen(packet.getSource());
Map<String, WinTestIhaveInventory> stationInventories =
inventoriesByStation.get(packet.getSource() == null ? "" : packet.getSource().trim());
if (stationInventories == null) {
return;
}
WinTestIhaveInventory previousInventory =
stationInventories.put(inventory.getLogId(), inventory);
if (previousInventory == null
|| !previousInventory.getSegments().equals(inventory.getSegments())) {
System.out.println("[WinTest LogSync] inventory of " + inventory.getLogId()
+ " from " + packet.getSource()
+ " (" + inventory.getOrigin() + "): " + inventory.getSegments());
}
usableInventorySeen = true;
blindScansByLogId.remove(inventory.getLogId());
tickDueImmediately = true;
}
/**
* Registers a QSO received in an ADDQSO packet.
*
* @param logId log identity in the form {@code StationName@LogUniqueID}
* @param qsoNumber Win-Test QSO number inside that log
* @return {@code true} when this QSO was not known before, and therefore
* still has to be applied to Worked state and database
*/
public synchronized boolean registerReceivedQso(String logId, long qsoNumber) {
if (logId == null || logId.isBlank() || qsoNumber <= 0L) {
// without a usable identity the QSO cannot be deduplicated
return true;
}
NavigableSet<Long> receivedQsoNumbers =
receivedQsoNumbersByLogId.computeIfAbsent(logId.trim(), key -> new TreeSet<>());
boolean isNewQso = receivedQsoNumbers.add(qsoNumber);
if (pendingRequest != null
&& pendingRequest.logId.equals(logId.trim())
&& qsoNumber >= pendingRequest.countFrom
&& qsoNumber <= pendingRequest.countTo) {
pendingRequest.answeredQsoCount++;
if (qsoNumber == pendingRequest.countTo
|| pendingRequest.answeredQsoCount >= pendingRequest.getRequestedQsoCount()) {
PendingRequest completedRequest = pendingRequest;
pendingRequest = null;
if (completedRequest.blind) {
finishBlindBlock(completedRequest);
}
tickDueImmediately = true;
}
}
return isNewQso;
}
/**
* Advances the recovery. Called after every received packet and on every
* receive timeout of the listener; an internal interval keeps the actual
* work rare while a satisfied request triggers the next one immediately.
*/
public synchronized void tick() {
long now = clock.getAsLong();
if (!tickDueImmediately && now - lastTickMs < TICK_INTERVAL_MS) {
return;
}
lastTickMs = now;
tickDueImmediately = false;
if (pendingRequest != null) {
if (now - pendingRequest.sentAtMs < REQUEST_TIMEOUT_MS) {
return;
}
handlePendingTimeout();
return;
}
if (requestNextMissingRange(now)) {
return;
}
if (requestNextBlindBlock(now)) {
return;
}
if (state == SyncState.SYNCING) {
state = SyncState.IN_SYNC;
}
}
/**
* @return current progress of the recovery
*/
public synchronized SyncState getState() {
return state;
}
/**
* @return number of QSO numbers known for the given log
*/
synchronized int getKnownQsoCount(String logId) {
NavigableSet<Long> receivedQsoNumbers = receivedQsoNumbersByLogId.get(logId);
return receivedQsoNumbers == null ? 0 : receivedQsoNumbers.size();
}
private void handlePendingTimeout() {
PendingRequest timedOutRequest = pendingRequest;
pendingRequest = null;
if (timedOutRequest.blind) {
finishBlindBlock(timedOutRequest);
return;
}
String alternativeStation =
findAlternativeStation(timedOutRequest.logId, timedOutRequest.targetStation);
System.out.println("[WinTest LogSync] no answer from " + timedOutRequest.targetStation
+ " for " + timedOutRequest.logId + " "
+ timedOutRequest.countFrom + "-" + timedOutRequest.countTo);
if (alternativeStation != null) {
sendRequest(
alternativeStation,
timedOutRequest.logId,
timedOutRequest.countFrom,
timedOutRequest.countTo,
false,
clock.getAsLong()
);
return;
}
/*
* Nobody else holds this log. The silent station is dropped and returns
* with its next STATUS or IHAVE packet.
*/
System.out.println("[WinTest LogSync] dropping silent station "
+ timedOutRequest.targetStation + ", waiting for its next STATUS or IHAVE");
inventoriesByStation.remove(timedOutRequest.targetStation);
tickDueImmediately = true;
}
private String findAlternativeStation(String logId, String excludedStation) {
for (Map.Entry<String, Map<String, WinTestIhaveInventory>> station
: inventoriesByStation.entrySet()) {
if (station.getKey().equals(excludedStation)) {
continue;
}
if (station.getValue().containsKey(logId)) {
return station.getKey();
}
}
return null;
}
/**
* Looks for the first announced QSO range that is still missing and
* requests it. Stations that own a log are preferred over stations that
* only mirror it.
*
* @param now current time in milliseconds
* @return {@code true} when a request was sent
*/
private boolean requestNextMissingRange(long now) {
for (int pass = 0; pass < 2; pass++) {
boolean preferOwner = pass == 0;
for (Map.Entry<String, Map<String, WinTestIhaveInventory>> station
: new ArrayList<>(inventoriesByStation.entrySet())) {
for (WinTestIhaveInventory inventory : new ArrayList<>(station.getValue().values())) {
boolean isOwner = inventory.getOrigin() == WinTestIhaveInventory.Origin.OWNER;
if (preferOwner != isOwner) {
continue;
}
long[] missingRange = findMissingRange(
inventory.getSegments(),
receivedQsoNumbersByLogId.get(inventory.getLogId())
);
if (missingRange == null) {
continue;
}
sendRequest(
station.getKey(),
inventory.getLogId(),
missingRange[0],
missingRange[1],
false,
now
);
return true;
}
}
}
return false;
}
/**
* Determines the next missing QSO range of one log.
*
* <p>wtKST compares its own segment list against the announced one and
* derives the request bounds from the segment indices. Searching the gap
* directly produces the same ranges for the ordinary cases, cannot run past
* the end of either list, and never asks for QSO numbers that are already
* known.</p>
*
* @param segments ranges announced by the station
* @param receivedQsoNumbers QSO numbers already received for this log
* @return first missing range as {@code {countFrom, countTo}}, or
* {@code null} when nothing is missing
*/
static long[] findMissingRange(
List<WinTestLogSegment> segments,
NavigableSet<Long> receivedQsoNumbers
) {
if (segments == null) {
return null;
}
long remainingScanBudget = MAX_SCANNED_QSO_NUMBERS;
for (WinTestLogSegment segment : segments) {
for (long qsoNumber = segment.getCountFrom();
qsoNumber <= segment.getCountTo();
qsoNumber++) {
remainingScanBudget--;
if (remainingScanBudget < 0L) {
return null;
}
if (receivedQsoNumbers != null && receivedQsoNumbers.contains(qsoNumber)) {
continue;
}
long countFrom = qsoNumber;
long countTo = countFrom;
while (countTo < segment.getCountTo()
&& countTo - countFrom + 1L < MAX_QSOS_PER_REQUEST
&& (receivedQsoNumbers == null || !receivedQsoNumbers.contains(countTo + 1L))) {
countTo++;
}
return new long[] { countFrom, countTo };
}
}
return null;
}
/**
* Requests the next fixed block of a log whose station never sent a usable
* inventory.
*
* @param now current time in milliseconds
* @return {@code true} when a request was sent
*/
private boolean requestNextBlindBlock(long now) {
if (usableInventorySeen || firstStationSeenAtMs == 0L) {
return false;
}
if (now - firstStationSeenAtMs < INVENTORY_GRACE_PERIOD_MS) {
return false;
}
for (String logId : new ArrayList<>(receivedQsoNumbersByLogId.keySet())) {
BlindScan blindScan = blindScansByLogId.computeIfAbsent(logId, key -> new BlindScan());
if (blindScan.completed || blindScan.requestedBlockCount >= MAX_BLIND_BLOCKS) {
continue;
}
String targetStation = resolveStationForLogId(logId);
if (targetStation == null) {
continue;
}
blindScan.requestedBlockCount++;
sendRequest(
targetStation,
logId,
blindScan.nextCountFrom,
blindScan.nextCountFrom + MAX_QSOS_PER_REQUEST - 1L,
true,
now
);
return true;
}
return false;
}
private void finishBlindBlock(PendingRequest finishedRequest) {
BlindScan blindScan = blindScansByLogId.get(finishedRequest.logId);
if (blindScan == null) {
return;
}
System.out.println("[WinTest LogSync] blind block " + finishedRequest.countFrom
+ "-" + finishedRequest.countTo + " of " + finishedRequest.logId
+ " answered with " + finishedRequest.answeredQsoCount + " QSOs");
if (finishedRequest.answeredQsoCount == 0) {
// the log ends before this block, nothing left to fetch
blindScan.completed = true;
} else {
blindScan.nextCountFrom = finishedRequest.countTo + 1L;
}
tickDueImmediately = true;
}
/**
* Resolves the Win-Test station a log belongs to. The log identity carries
* the owning station name in front of the {@code @} separator.
*
* @param logId log identity
* @return station name to ask, or {@code null} when none is known
*/
private String resolveStationForLogId(String logId) {
int separatorIndex = logId.indexOf('@');
String ownerStationName = separatorIndex > 0 ? logId.substring(0, separatorIndex) : logId;
for (String stationName : inventoriesByStation.keySet()) {
if (stationName.equalsIgnoreCase(ownerStationName)) {
return stationName;
}
}
return ownerStationName.isBlank() ? null : ownerStationName;
}
private void sendRequest(
String targetStation,
String logId,
long countFrom,
long countTo,
boolean blind,
long now
) {
pendingRequest = new PendingRequest(targetStation, logId, countFrom, countTo, blind, now);
state = SyncState.SYNCING;
try {
needQsoSender.sendNeedQso(targetStation, logId, countFrom, countTo);
} catch (RuntimeException exception) {
/*
* A failed transmission must not stop the receive loop. The pending
* request runs into its timeout and is retried from there.
*/
System.out.println(
"[WinTest LogSync] NEEDQSO could not be sent: " + exception.getMessage()
);
}
}
private String resolveOwnStationName() {
if (ownStationNameSupplier == null) {
return "";
}
String ownStationName = ownStationNameSupplier.get();
return ownStationName == null ? "" : ownStationName.trim();
}
/** Request that is waiting for its answer. */
private static final class PendingRequest {
private final String targetStation;
private final String logId;
private final long countFrom;
private final long countTo;
private final boolean blind;
private final long sentAtMs;
private int answeredQsoCount;
private PendingRequest(
String targetStation,
String logId,
long countFrom,
long countTo,
boolean blind,
long sentAtMs
) {
this.targetStation = targetStation;
this.logId = logId;
this.countFrom = countFrom;
this.countTo = countTo;
this.blind = blind;
this.sentAtMs = sentAtMs;
}
private long getRequestedQsoCount() {
return countTo - countFrom + 1L;
}
}
/** Progress of the blind fallback for one log. */
private static final class BlindScan {
private long nextCountFrom = 1L;
private int requestedBlockCount;
private boolean completed;
}
}
@@ -17,8 +17,10 @@ import java.nio.charset.StandardCharsets;
*/
public class WinTestMessage {
/** Win-Test message types relevant for SKED management. */
/** Win-Test message types sent by KST4Contest. */
public enum MessageType {
/** Requests a range of QSOs of one Win-Test log for log synchronization. */
NEEDQSO,
LOCKSKED,
UNLOCKSKED,
ADDSKED,
@@ -0,0 +1,227 @@
package kst4contest.controller;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.Enumeration;
/**
* Determines the broadcast address used to talk to the Win-Test network.
*
* <p>Win-Test only reacts to broadcast packets; a unicast request to the same
* station stays unanswered. The configured broadcast address is therefore the
* one setting that silently disables every outgoing Win-Test feature when it is
* wrong: sending to an address outside the local networks succeeds without an
* error and the packet is routed away.</p>
*
* <p>Incoming Win-Test packets carry the information that is actually needed.
* The source address of a received packet identifies the network the station
* lives in, so the broadcast address of the matching local interface reaches it
* reliably. The configured address remains the fallback and keeps working for a
* station behind a router, where no local interface matches.</p>
*/
public class WinTestNetworkAddressResolver {
/** Last resort when neither a station nor a usable setting is available. */
private static final String LIMITED_BROADCAST_ADDRESS = "255.255.255.255";
/**
* Resolves the local broadcast address for a remote address.
*/
@FunctionalInterface
public interface LocalBroadcastLookup {
/**
* @param remoteAddress address a Win-Test packet was received from
* @return broadcast address of the matching local interface, or
* {@code null} when no local interface serves that network
*/
InetAddress findBroadcastFor(InetAddress remoteAddress);
}
private final LocalBroadcastLookup localBroadcastLookup;
private volatile InetAddress lastStationAddress;
private volatile String lastReportedBroadcastAddress;
public WinTestNetworkAddressResolver() {
this(WinTestNetworkAddressResolver::findLocalBroadcastAddress);
}
/**
* @param localBroadcastLookup interface lookup, replaceable for tests
*/
WinTestNetworkAddressResolver(LocalBroadcastLookup localBroadcastLookup) {
this.localBroadcastLookup = localBroadcastLookup;
}
/**
* Remembers where Win-Test packets come from.
*
* <p>Only addresses of real Win-Test stations may be passed in. Loopback and
* wildcard addresses are ignored, so an internal control packet cannot
* redirect outgoing Win-Test traffic.</p>
*
* @param stationAddress source address of a received Win-Test packet
*/
public void rememberStationAddress(InetAddress stationAddress) {
if (stationAddress == null
|| stationAddress.isLoopbackAddress()
|| stationAddress.isAnyLocalAddress()
|| !(stationAddress instanceof Inet4Address)) {
return;
}
this.lastStationAddress = stationAddress;
}
/**
* Determines the broadcast address for outgoing Win-Test packets.
*
* <p>Order of preference: the broadcast address of the local interface that
* serves the last seen Win-Test station, then the configured address, then
* the limited broadcast address.</p>
*
* @param configuredBroadcastAddress address from the settings, may be blank
* @return address to send Win-Test packets to
* @throws UnknownHostException if the configured address cannot be resolved
* and the limited broadcast address fails too
*/
public InetAddress resolveBroadcastAddress(String configuredBroadcastAddress)
throws UnknownHostException {
InetAddress stationAddress = this.lastStationAddress;
if (stationAddress != null) {
InetAddress derivedBroadcastAddress =
localBroadcastLookup.findBroadcastFor(stationAddress);
if (derivedBroadcastAddress != null) {
reportDerivedAddress(derivedBroadcastAddress, configuredBroadcastAddress);
return derivedBroadcastAddress;
}
}
if (configuredBroadcastAddress != null && !configuredBroadcastAddress.isBlank()) {
return InetAddress.getByName(configuredBroadcastAddress.trim());
}
return InetAddress.getByName(LIMITED_BROADCAST_ADDRESS);
}
/**
* Logs a derived address once as long as it stays the same, and points out
* a configured address that does not match the Win-Test network.
*/
private void reportDerivedAddress(
InetAddress derivedBroadcastAddress,
String configuredBroadcastAddress
) {
String derivedHostAddress = derivedBroadcastAddress.getHostAddress();
if (derivedHostAddress.equals(lastReportedBroadcastAddress)) {
return;
}
lastReportedBroadcastAddress = derivedHostAddress;
String configuredHostAddress = configuredBroadcastAddress == null
? "" : configuredBroadcastAddress.trim();
if (derivedHostAddress.equals(configuredHostAddress)) {
return;
}
System.out.println("[WinTest] using broadcast address " + derivedHostAddress
+ " of the network Win-Test was heard on, configured is '"
+ configuredHostAddress + "'");
}
/**
* Searches the local interfaces for the network a remote address belongs to.
*
* @param remoteAddress address of a Win-Test station
* @return broadcast address of the matching interface, or {@code null}
*/
static InetAddress findLocalBroadcastAddress(InetAddress remoteAddress) {
if (!(remoteAddress instanceof Inet4Address)) {
return null;
}
try {
Enumeration<NetworkInterface> networkInterfaces =
NetworkInterface.getNetworkInterfaces();
while (networkInterfaces != null && networkInterfaces.hasMoreElements()) {
NetworkInterface networkInterface = networkInterfaces.nextElement();
for (InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) {
InetAddress broadcastAddress = interfaceAddress.getBroadcast();
if (broadcastAddress == null
|| !(interfaceAddress.getAddress() instanceof Inet4Address)) {
continue;
}
if (isInSameSubnet(
interfaceAddress.getAddress(),
remoteAddress,
interfaceAddress.getNetworkPrefixLength())) {
return broadcastAddress;
}
}
}
} catch (SocketException exception) {
System.out.println("[WinTest] could not inspect local interfaces: "
+ exception.getMessage());
}
return null;
}
/**
* Compares two IPv4 addresses up to the given network prefix length.
*
* @param localAddress address of a local interface
* @param remoteAddress address of the Win-Test station
* @param networkPrefixLength prefix length of the local interface
* @return {@code true} when both addresses share the same network
*/
static boolean isInSameSubnet(
InetAddress localAddress,
InetAddress remoteAddress,
int networkPrefixLength
) {
if (localAddress == null || remoteAddress == null) {
return false;
}
byte[] localBytes = localAddress.getAddress();
byte[] remoteBytes = remoteAddress.getAddress();
if (localBytes.length != remoteBytes.length
|| networkPrefixLength < 0
|| networkPrefixLength > localBytes.length * 8) {
return false;
}
int remainingPrefixBits = networkPrefixLength;
for (int byteIndex = 0; byteIndex < localBytes.length && remainingPrefixBits > 0; byteIndex++) {
int comparedBits = Math.min(8, remainingPrefixBits);
int mask = (0xFF << (8 - comparedBits)) & 0xFF;
if ((localBytes[byteIndex] & mask) != (remoteBytes[byteIndex] & mask)) {
return false;
}
remainingPrefixBits -= comparedBits;
}
return true;
}
}
@@ -0,0 +1,238 @@
package kst4contest.controller;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Win-Test network packet received over UDP.
*
* <p>This is the receiving counterpart of {@link WinTestMessage} and follows the
* same framing:</p>
*
* <pre>
* MESSAGETYPE: "src" "dst" data{checksum}\0
* </pre>
*
* <p>The checksum byte always has bit 7 set and is therefore not valid ASCII.
* Decoding the datagram as text before removing it turns the byte into a
* replacement character that sticks to the last data field. That is harmless
* for fields KST4Contest never reads, but the log synchronization needs exactly
* those trailing fields: the log ID of an {@code ADDQSO} packet and the
* run-length inventory of an {@code IHAVE} packet. The framing is therefore
* resolved on the raw bytes here, once, before any text parsing.</p>
*/
public final class WinTestPacket {
/** Quoted values stay one token, unquoted values are split at whitespace. */
private static final Pattern TOKEN_PATTERN = Pattern.compile("\"([^\"]*)\"|(\\S+)");
private final String messageType;
private final String source;
private final String destination;
private final String messageText;
private final List<String> dataTokens;
private final boolean checksumPresent;
private final boolean checksumValid;
private WinTestPacket(
String messageType,
String source,
String destination,
String messageText,
List<String> dataTokens,
boolean checksumPresent,
boolean checksumValid
) {
this.messageType = messageType;
this.source = source;
this.destination = destination;
this.messageText = messageText;
this.dataTokens = dataTokens;
this.checksumPresent = checksumPresent;
this.checksumValid = checksumValid;
}
/**
* Builds a packet from a received datagram.
*
* <p>Trailing NUL bytes are removed first. If the resulting last byte has
* bit 7 set it is the Win-Test checksum: it is verified against the sum of
* all preceding bytes and removed before the message text is decoded.</p>
*
* @param datagram raw datagram buffer
* @param length number of valid bytes in the buffer
* @return parsed packet, or {@code null} when the datagram carries no message
*/
public static WinTestPacket fromDatagram(byte[] datagram, int length) {
if (datagram == null || length <= 0 || length > datagram.length) {
return null;
}
int endIndex = length;
while (endIndex > 0 && datagram[endIndex - 1] == 0) {
endIndex--;
}
if (endIndex == 0) {
return null;
}
boolean hasChecksum = (datagram[endIndex - 1] & 0x80) != 0;
boolean isChecksumValid = false;
int textEndIndex = endIndex;
if (hasChecksum) {
int sum = 0;
for (int index = 0; index < endIndex - 1; index++) {
sum += datagram[index] & 0xFF;
}
byte expectedChecksum = (byte) ((sum | 0x80) & 0xFF);
isChecksumValid = expectedChecksum == datagram[endIndex - 1];
textEndIndex = endIndex - 1;
}
String text = new String(datagram, 0, textEndIndex, StandardCharsets.US_ASCII);
return fromMessageText(text, hasChecksum, isChecksumValid);
}
/**
* Builds a packet from an already decoded message text without checksum
* information. Used for messages that reach the listener as text.
*
* @param messageText complete message text
* @return parsed packet, or {@code null} for an unusable message
*/
public static WinTestPacket fromMessageText(String messageText) {
return fromMessageText(messageText, false, false);
}
private static WinTestPacket fromMessageText(
String rawMessageText,
boolean checksumPresent,
boolean checksumValid
) {
if (rawMessageText == null) {
return null;
}
String text = rawMessageText.trim();
int typeEndIndex = text.indexOf(": ");
if (typeEndIndex <= 0) {
return null;
}
String type = text.substring(0, typeEndIndex);
List<String> tokens = tokenize(text.substring(typeEndIndex + 2));
String packetSource = tokens.isEmpty() ? "" : tokens.get(0);
String packetDestination = tokens.size() > 1 ? tokens.get(1) : "";
List<String> data = tokens.size() > 2
? new ArrayList<>(tokens.subList(2, tokens.size()))
: new ArrayList<>();
return new WinTestPacket(
type,
packetSource,
packetDestination,
text,
data,
checksumPresent,
checksumValid
);
}
/**
* Splits Win-Test payload text into fields. Quoted values are kept together
* and empty quoted values are preserved, so field positions stay stable.
*
* @param text payload text
* @return field values without their surrounding quotes
*/
static List<String> tokenize(String text) {
List<String> tokens = new ArrayList<>();
if (text == null) {
return tokens;
}
Matcher matcher = TOKEN_PATTERN.matcher(text);
while (matcher.find()) {
tokens.add(matcher.group(1) != null ? matcher.group(1) : matcher.group(2));
}
return tokens;
}
/**
* @return message type such as {@code ADDQSO}, never {@code null}
*/
public String getMessageType() {
return messageType;
}
/**
* @return Win-Test station that sent the packet
*/
public String getSource() {
return source;
}
/**
* @return addressed Win-Test station, empty for a broadcast
*/
public String getDestination() {
return destination;
}
/**
* @return complete message text without checksum byte and NUL terminator
*/
public String getMessageText() {
return messageText;
}
/**
* @return payload fields following source and destination
*/
public List<String> getDataTokens() {
return Collections.unmodifiableList(dataTokens);
}
/**
* @param index payload field position
* @return field value, or {@code null} when the field is missing
*/
public String getDataToken(int index) {
return index >= 0 && index < dataTokens.size() ? dataTokens.get(index) : null;
}
public boolean isChecksumPresent() {
return checksumPresent;
}
public boolean isChecksumValid() {
return checksumValid;
}
/**
* Checks whether this packet is meant for us.
*
* @param ownStationName own Win-Test station name
* @return {@code true} for a broadcast or for a packet addressed to us
*/
public boolean isAddressedTo(String ownStationName) {
if (destination == null || destination.isEmpty()) {
return true;
}
return ownStationName != null && destination.equalsIgnoreCase(ownStationName.trim());
}
@Override
public String toString() {
return messageType + ": src=" + source + " dst=" + destination
+ " fields=" + dataTokens.size()
+ (checksumPresent ? (checksumValid ? " checksum=ok" : " checksum=bad") : " checksum=none");
}
}
@@ -0,0 +1,71 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class WinTestIhaveInventoryTest {
private static Optional<WinTestIhaveInventory> parse(String messageText) {
return WinTestIhaveInventory.fromPacket(WinTestPacket.fromMessageText(messageText));
}
@Test
void inventoryStartingWithPresentQsosIsExpanded() {
Optional<WinTestIhaveInventory> inventory =
parse("IHAVE: \"STN1\" \"\" \"STN1@9\" E 1 1 911-1-117");
assertTrue(inventory.isPresent());
assertEquals("STN1@9", inventory.get().getLogId());
assertEquals(WinTestIhaveInventory.Origin.LOGGED_ELSE, inventory.get().getOrigin());
assertEquals(
List.of(new WinTestLogSegment(1L, 911L), new WinTestLogSegment(913L, 1029L)),
inventory.get().getSegments());
assertEquals(1029L, inventory.get().getHighestQsoNumber());
}
@Test
void inventoryStartingWithMissingQsosIsExpanded() {
Optional<WinTestIhaveInventory> inventory =
parse("IHAVE: \"STN1\" \"\" \"STN1@9\" O 1 0 30-5");
assertTrue(inventory.isPresent());
assertEquals(WinTestIhaveInventory.Origin.OWNER, inventory.get().getOrigin());
assertEquals(List.of(new WinTestLogSegment(31L, 35L)), inventory.get().getSegments());
}
@Test
void splitInventoryStartsAtItsFirstRow() {
/*
* Win-Test splits long inventories. The documented example "100 1 10-5-5"
* means: ten QSOs from 100, five missing, five present again.
*/
Optional<WinTestIhaveInventory> inventory =
parse("IHAVE: \"STN1\" \"\" \"STN1@9\" O 100 1 10-5-5");
assertTrue(inventory.isPresent());
assertEquals(
List.of(new WinTestLogSegment(100L, 109L), new WinTestLogSegment(115L, 119L)),
inventory.get().getSegments());
}
@Test
void runLengthChainWithWrongParityIsRejected() {
assertTrue(parse("IHAVE: \"STN1\" \"\" \"STN1@9\" O 1 1 10-5").isEmpty());
assertTrue(parse("IHAVE: \"STN1\" \"\" \"STN1@9\" O 1 0 10-5-5").isEmpty());
}
@Test
void legacyInventoryWithoutRunLengthsIsRejected() {
assertTrue(parse("IHAVE: \"STN1\" \"\" \"STN1@169\" \"OWNER\" 2").isEmpty());
}
@Test
void otherMessageTypesAreRejected() {
assertTrue(parse("STATUS: \"STN1\" \"\" 0 12 0 0 0 1443210 0").isEmpty());
}
}
@@ -0,0 +1,171 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class WinTestLogSyncServiceTest {
private static final String LOG_ID = "STN1@44510";
private final List<String> sentRequests = new ArrayList<>();
private long currentTimeMs = 1_000_000L;
private WinTestLogSyncService service;
@BeforeEach
void createService() {
sentRequests.clear();
service = new WinTestLogSyncService(
(targetStation, logId, countFrom, countTo) ->
sentRequests.add(targetStation + " " + logId + " " + countFrom + "-" + countTo),
() -> "KST4Contest",
() -> currentTimeMs
);
}
private void receiveIhave(String messageText) {
service.onIhaveReceived(WinTestPacket.fromMessageText(messageText));
}
private void receiveQsos(long countFrom, long countTo) {
for (long qsoNumber = countFrom; qsoNumber <= countTo; qsoNumber++) {
service.registerReceivedQso(LOG_ID, qsoNumber);
}
}
@Test
void firstBlockOfAnAnnouncedLogIsRequested() {
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 120");
service.tick();
assertEquals(List.of("STN1 " + LOG_ID + " 1-50"), sentRequests);
assertEquals(WinTestLogSyncService.SyncState.SYNCING, service.getState());
}
@Test
void answeredBlockTriggersTheNextBlockUntilTheLogIsComplete() {
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 120");
service.tick();
receiveQsos(1L, 50L);
service.tick();
receiveQsos(51L, 100L);
service.tick();
receiveQsos(101L, 120L);
service.tick();
assertEquals(
List.of(
"STN1 " + LOG_ID + " 1-50",
"STN1 " + LOG_ID + " 51-100",
"STN1 " + LOG_ID + " 101-120"
),
sentRequests);
assertEquals(WinTestLogSyncService.SyncState.IN_SYNC, service.getState());
}
@Test
void onlyMissingQsoNumbersAreRequested() {
receiveQsos(1L, 10L);
receiveQsos(21L, 30L);
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 30");
service.tick();
assertEquals(List.of("STN1 " + LOG_ID + " 11-20"), sentRequests);
}
@Test
void alreadyKnownQsoIsReportedAsKnown() {
assertTrue(service.registerReceivedQso(LOG_ID, 5L));
assertFalse(service.registerReceivedQso(LOG_ID, 5L));
}
@Test
void qsoWithoutUsableIdentityIsAlwaysTreatedAsNew() {
assertTrue(service.registerReceivedQso(null, 5L));
assertTrue(service.registerReceivedQso("", 5L));
assertTrue(service.registerReceivedQso(LOG_ID, 0L));
}
@Test
void unansweredRequestIsRepeatedAtAnotherStationHoldingTheSameLog() {
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 120");
receiveIhave("IHAVE: \"STN2\" \"\" \"" + LOG_ID + "\" E 1 1 120");
service.tick();
currentTimeMs += WinTestLogSyncService.REQUEST_TIMEOUT_MS;
service.tick();
assertEquals(
List.of("STN1 " + LOG_ID + " 1-50", "STN2 " + LOG_ID + " 1-50"),
sentRequests);
}
@Test
void silentStationIsDroppedWhenNobodyElseHoldsTheLog() {
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 120");
service.tick();
currentTimeMs += WinTestLogSyncService.REQUEST_TIMEOUT_MS;
service.tick();
currentTimeMs += WinTestLogSyncService.REQUEST_TIMEOUT_MS;
service.tick();
assertEquals(List.of("STN1 " + LOG_ID + " 1-50"), sentRequests);
}
@Test
void blindFallbackRequestsFixedBlocksWithoutInventory() {
service.onStationSeen("STN1");
service.registerReceivedQso(LOG_ID, 7L);
service.tick();
assertEquals(List.of(), sentRequests);
currentTimeMs += WinTestLogSyncService.INVENTORY_GRACE_PERIOD_MS;
service.tick();
receiveQsos(1L, 50L);
service.tick();
currentTimeMs += WinTestLogSyncService.REQUEST_TIMEOUT_MS;
service.tick();
currentTimeMs += WinTestLogSyncService.REQUEST_TIMEOUT_MS;
service.tick();
assertEquals(
List.of("STN1 " + LOG_ID + " 1-50", "STN1 " + LOG_ID + " 51-100"),
sentRequests);
}
@Test
void inventoryStopsTheBlindFallback() {
service.onStationSeen("STN1");
service.registerReceivedQso(LOG_ID, 7L);
currentTimeMs += WinTestLogSyncService.INVENTORY_GRACE_PERIOD_MS;
receiveIhave("IHAVE: \"STN1\" \"\" \"" + LOG_ID + "\" O 1 1 10");
service.tick();
assertEquals(List.of("STN1 " + LOG_ID + " 1-6"), sentRequests);
}
@Test
void ownPacketsDoNotStartASynchronization() {
service.onStationSeen("KST4Contest");
service.tick();
assertEquals(List.of(), sentRequests);
assertEquals(WinTestLogSyncService.SyncState.IDLE, service.getState());
}
}
@@ -0,0 +1,85 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.InetAddress;
import java.net.UnknownHostException;
import org.junit.jupiter.api.Test;
class WinTestNetworkAddressResolverTest {
private static InetAddress address(String hostAddress) throws UnknownHostException {
return InetAddress.getByName(hostAddress);
}
@Test
void stationNetworkWinsOverConfiguredAddress() throws UnknownHostException {
WinTestNetworkAddressResolver resolver = new WinTestNetworkAddressResolver(
remoteAddress -> {
try {
return address("192.168.122.255");
} catch (UnknownHostException exception) {
return null;
}
});
resolver.rememberStationAddress(address("192.168.122.1"));
assertEquals(address("192.168.122.255"),
resolver.resolveBroadcastAddress("192.168.101.255"));
}
@Test
void configuredAddressIsUsedWhenNoLocalInterfaceServesTheStation()
throws UnknownHostException {
WinTestNetworkAddressResolver resolver =
new WinTestNetworkAddressResolver(remoteAddress -> null);
resolver.rememberStationAddress(address("10.9.8.7"));
assertEquals(address("192.168.101.255"),
resolver.resolveBroadcastAddress("192.168.101.255"));
}
@Test
void limitedBroadcastIsUsedWithoutStationAndWithoutSetting()
throws UnknownHostException {
WinTestNetworkAddressResolver resolver =
new WinTestNetworkAddressResolver(remoteAddress -> null);
assertEquals(address("255.255.255.255"), resolver.resolveBroadcastAddress(" "));
assertEquals(address("255.255.255.255"), resolver.resolveBroadcastAddress(null));
}
@Test
void loopbackAndWildcardSourcesAreIgnored() throws UnknownHostException {
WinTestNetworkAddressResolver resolver = new WinTestNetworkAddressResolver(
remoteAddress -> {
throw new IllegalStateException("must not be asked for " + remoteAddress);
});
resolver.rememberStationAddress(address("127.0.0.1"));
resolver.rememberStationAddress(address("0.0.0.0"));
resolver.rememberStationAddress(null);
assertEquals(address("192.168.101.255"),
resolver.resolveBroadcastAddress("192.168.101.255"));
}
@Test
void subnetComparisonHonoursThePrefixLength() throws UnknownHostException {
assertTrue(WinTestNetworkAddressResolver.isInSameSubnet(
address("192.168.122.1"), address("192.168.122.203"), 24));
assertFalse(WinTestNetworkAddressResolver.isInSameSubnet(
address("192.168.101.5"), address("192.168.122.1"), 24));
assertTrue(WinTestNetworkAddressResolver.isInSameSubnet(
address("172.19.0.1"), address("172.19.240.9"), 16));
assertFalse(WinTestNetworkAddressResolver.isInSameSubnet(
address("10.244.22.73"), address("10.244.23.1"), 24));
assertTrue(WinTestNetworkAddressResolver.isInSameSubnet(
address("10.244.22.73"), address("10.244.23.1"), 16));
}
}
@@ -0,0 +1,111 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.junit.jupiter.api.Test;
class WinTestPacketTest {
private static final String ADDQSO_MESSAGE =
"ADDQSO: \"STN1\" \"\" \"STN1\" 1762202297 1440000 0 12 0 0 0 2 2 "
+ "\"DM2RN\" \"599\" \"599001\" \"JO51UM\" \"\" \"\" 0 \"\" \"\" \"\" 44510";
/**
* Builds a datagram exactly like Win-Test does: message text, checksum byte
* replacing the placeholder, NUL terminator.
*/
private static byte[] toDatagram(String messageText) {
byte[] datagram = (messageText + "?\0").getBytes(StandardCharsets.US_ASCII);
int sum = 0;
for (int index = 0; index < datagram.length - 2; index++) {
sum += datagram[index] & 0xFF;
}
datagram[datagram.length - 2] = (byte) ((sum | 0x80) & 0xFF);
return datagram;
}
@Test
void checksumByteAndTerminatorAreRemovedFromMessageText() {
byte[] datagram = toDatagram(ADDQSO_MESSAGE);
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, datagram.length);
assertEquals(ADDQSO_MESSAGE, packet.getMessageText());
assertTrue(packet.isChecksumPresent());
assertTrue(packet.isChecksumValid());
}
@Test
void trailingLogIdStaysReadableAfterFramingIsResolved() {
byte[] datagram = toDatagram(ADDQSO_MESSAGE);
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, datagram.length);
List<String> packetFields = WinTestPacket.tokenize(packet.getMessageText());
assertEquals("STN1@44510",
ReadUDPByWintestThread.extractLogIdFromWinTestAddQso(packetFields));
assertEquals(2L,
ReadUDPByWintestThread.extractQsoNumberFromWinTestAddQso(packetFields));
}
@Test
void manipulatedChecksumIsDetected() {
byte[] datagram = toDatagram(ADDQSO_MESSAGE);
datagram[datagram.length - 2] = (byte) 0xFF;
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, datagram.length);
assertTrue(packet.isChecksumPresent());
assertFalse(packet.isChecksumValid());
}
@Test
void sourceAndDestinationAreSeparatedFromPayload() {
byte[] datagram = toDatagram(
"IHAVE: \"STN1\" \"KST4Contest\" \"STN1@44510\" O 1 1 120");
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, datagram.length);
assertEquals("IHAVE", packet.getMessageType());
assertEquals("STN1", packet.getSource());
assertEquals("KST4Contest", packet.getDestination());
assertEquals(List.of("STN1@44510", "O", "1", "1", "120"), packet.getDataTokens());
assertTrue(packet.isAddressedTo("KST4Contest"));
assertFalse(packet.isAddressedTo("STN2"));
}
@Test
void emptyQuotedFieldsKeepFieldPositions() {
List<String> packetFields = WinTestPacket.tokenize(ADDQSO_MESSAGE);
assertEquals("ADDQSO:", packetFields.get(0));
assertEquals("STN1", packetFields.get(1));
assertEquals("", packetFields.get(2));
assertEquals("12", packetFields.get(7));
assertEquals("DM2RN", packetFields.get(13));
assertEquals(24, packetFields.size());
}
@Test
void messageWithoutWinTestFramingIsRejected() {
assertNull(WinTestPacket.fromMessageText("no win-test message"));
assertNull(WinTestPacket.fromDatagram(new byte[] { 0 }, 1));
assertNull(WinTestPacket.fromDatagram(null, 0));
}
@Test
void broadcastPacketIsAcceptedForEveryStationName() {
WinTestPacket packet = WinTestPacket.fromMessageText(ADDQSO_MESSAGE);
assertTrue(packet.isAddressedTo("KST4Contest"));
assertFalse(packet.isChecksumPresent());
}
}