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