Build with Gradle in two modules, and free core of JavaFX

Etappen 1 and 2 of the Compose migration (43 commits).

The Maven build, the single source tree and the JPMS module-info went together.
core now holds the domain and app-desktop the user interface, both built by
Gradle, packaged through jpackage from Gradle, with SpotBugs and PMD wired in.
JavaFX moves to the classpath, which is why the launcher is Main and not the
Application subclass. The PR check, the nightly artifacts, the release and the
AUR packages all build from Gradle; the macOS DMG points at its output.
Verified on Linux, macOS and Windows.

core then loses JavaFX entirely. It observes through kst4contest.observe --
ObservableValue, ObservableRoster, and UiDispatcher as the one boundary a
worker thread crosses to touch the interface. The controller collections become
rosters and the visible station list a pure derivation rather than a
FilteredList/SortedList chain, which is also what fixed two focus defects only
a load test had exposed: the keyboard focus stays where the operator put it,
and the send field keeps it after a row click.

The rule that outlives this commit: ObservableRoster is the store and the list
a view is bound to is a mirror of it, never the other way round.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:31:56 +02:00
1 parent bcbeadae95
commit a6fba12f01
360 files changed
+6304 -2422

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
plugins {
java
}
// No user-interface technology here: the domain layer observes through
// kst4contest.observe and hands UI work over via UiDispatcher.
dependencies {
// Test classes live under src/main/java in this project, so JUnit and Mockito
// are needed on the main compile classpath. This mirrored pom.xml before Gradle.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
implementation(libs.jetbrains.annotations)
implementation(libs.sqlite.jdbc)
implementation(libs.jlayer)
testImplementation(libs.junit.jupiter)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.junit.jupiter)
// Gradle 9 no longer adds the launcher implicitly.
testRuntimeOnly(libs.junit.platform.launcher)
}
@@ -0,0 +1,78 @@
package kst4contest;
import java.util.Random;
public class ApplicationConstants {
/**
* default constructor generates runtime id
*/
ApplicationConstants() {
sessionRuntimeUniqueId = generateRuntimeId();
};
public static int sessionRuntimeUniqueId = generateRuntimeId();
/**
* Name of the Application.
*/
public static final String APPLICATION_NAME = "praktiKST";
/**
* Version shown to the user and used for semantic version comparison.
*/
public static final String APPLICATION_CURRENT_VERSION = "1.50.0";
/**
* Legacy numeric representation used only while older update feeds and
* application versions still exist.
*/
@Deprecated
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.50;
public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://kst4contest.hamradioonline.de/kst4ContestVersionInfo.xml";
public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml";
public static final String STYLECSSFILE_DEFAULT_DAYLIGHT = "KST4ContestDefaultDay.css";
public static final String STYLECSSFILE_DEFAULT_EVENING = "KST4ContestDefaultEvening.css";
public static final String DISCSTRING_DISCONNECT_AND_CLOSE = "CLOSEALL";
public static final String DISCSTRING_DISCONNECT_DUE_PAWWORDERROR = "JUSTDSICCAUSEPWWRONG";
public static final String DISCSTRING_DISCONNECTONLY = "ONLYDISCONNECT";
// public static final String DISCONNECT_RDR_POISONPILL = "POISONPILL_KILLTHREAD: " + sessionRuntimeUniqueId; //whereever a (blocking) udp or tcp reader in an infinite loop gets this message, it will break this loop
public static final String DISCONNECT_RDR_POISONPILL = "UNKNOWN: KST4C KILL POISONPILL_KILLTHREAD=: " + sessionRuntimeUniqueId; //whereever a (blocking) udp or tcp reader in an infinite loop gets this message, it will break this loop
public static final String AUTOANSWER_PREFIX = "[KST4C Automsg]"; // hard-coded marker (user cannot remove it)
/**
* UI message retention limits.
*
* The global chat message list is the backing list for several FilteredLists
* and TableViews. It must not grow without limit during long contest runs.
*
* The list is kept in newest-first order:
* index 0 = newest message
* last index = oldest message
*/
public static final int CHAT_MESSAGE_STORE_MAX_SIZE = 30000;
public static final int CHAT_MESSAGE_STORE_TRIM_TO_SIZE = 25000;
/**
* DXCluster table retention limits.
*/
public static final int CLUSTER_MESSAGE_STORE_MAX_SIZE = 10000;
public static final int CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE = 8000;
/**
* generates a unique runtime id per session. Its used to feed the poison pill in order to kill only this one and
* only instance if the program and not multiple instances
* @return
*/
public static int generateRuntimeId() {
Random ran = new Random();
return ran.nextInt(6) + 100;
}
}
@@ -0,0 +1,48 @@
package kst4contest.controller;
import kst4contest.model.OperatorProfileSelection;
/**
* Holds the operator profile the current runtime works with.
*
* <p>The state is deliberately static, which is safe here for reasons that did not apply
* to the former static database connection: the value is immutable, it holds no live
* resource, and it is set on the JavaFX Application Thread before anything reads it -
* during startup, and again during a profile switch after the previous runtime has been
* shut down completely.</p>
*/
public final class ActiveOperatorProfile {
private static volatile OperatorProfileSelection currentSelection;
private ActiveOperatorProfile() {
// Utility class.
}
/**
* Returns the active profile selection.
*
* @return the active selection, or null when startup has not resolved one yet
*/
public static OperatorProfileSelection get() {
return currentSelection;
}
/**
* Sets the active profile selection.
*
* @param selection selection to activate
*/
public static void set(final OperatorProfileSelection selection) {
currentSelection = selection;
}
/**
* Returns whether a profile has already been resolved for this runtime.
*
* @return true if a selection is present
*/
public static boolean isInitialized() {
return currentSelection != null;
}
}
@@ -0,0 +1,315 @@
package kst4contest.controller;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TimerTask;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
/**
* Sends periodical path requests and an AirScout watchlist for the currently
* active ON4KST stations.
*/
public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
private static final Logger LOGGER = Logger.getLogger(
AirScoutPeriodicalAPReflectionInquirerTask.class.getName()
);
private static final String BROADCAST_ADDRESS = "255.255.255.255";
private final ChatController client;
/*
* ASWATCHLIST is sent as one common list. Remember one syntactically valid
* AirScout band value so an empty list can still be sent on a later cycle
* to remove stations which are no longer active.
*/
private String lastWatchListBandValue;
public AirScoutPeriodicalAPReflectionInquirerTask(
ChatController client
) {
this.client = client;
}
@Override
public void run() {
Thread.currentThread().setName(
"AirscoutPeriodicalReflectionInquirerTask"
);
/*
* Keep the scheduled task installed so that AirScout can be enabled at
* runtime, but do not send anything while the integration is disabled.
*/
if (!client.getChatPreferences().isAirScout_asUDPListenerEnabled()) {
return;
}
String clientIdentifier =
client.getChatPreferences().getAirScout_asClientNameString();
String serverIdentifier =
client.getChatPreferences().getAirScout_asServerNameString();
String ownCallSign = normalizeOwnCallSign(
client.getChatPreferences().getStn_loginCallSign()
);
String ownLocator =
client.getChatPreferences().getStn_loginLocatorMainCat();
if (ownCallSign == null
|| ownCallSign.isBlank()
|| ownLocator == null
|| ownLocator.isBlank()) {
LOGGER.warning(
"AirScout queries were skipped because the own callsign "
+ "or locator is missing."
);
return;
}
String setPathPrefix =
"ASSETPATH: \"" + clientIdentifier
+ "\" \"" + serverIdentifier + "\" ";
String watchListPrefix =
"ASWATCHLIST: \"" + clientIdentifier
+ "\" \"" + serverIdentifier + "\" ";
String ownStation = ownCallSign + "," + ownLocator;
List<ChatMember> activeMembers = client.snapshotChatMembers();
List<String> watchListTargets = new ArrayList<>();
Set<String> processedCallsigns = new LinkedHashSet<>();
String watchListBandValue = null;
int port = client.getChatPreferences()
.getAirScout_asCommunicationPort();
try (
DatagramSocket socket = new DatagramSocket()
) {
socket.setBroadcast(true);
InetAddress broadcastAddress =
InetAddress.getByName(BROADCAST_ADDRESS);
for (ChatMember member : activeMembers) {
if (!isUsableAirScoutTarget(member)) {
continue;
}
if (member.getQrb() == null
|| member.getQrb()
>= client.getChatPreferences().getStn_maxQRBDefault()) {
continue;
}
String callsignKey = member.getCallSignRaw();
if (callsignKey == null || callsignKey.isBlank()) {
callsignKey = member.getCallSign();
}
if (callsignKey == null
|| !processedCallsigns.add(
callsignKey.trim().toUpperCase(Locale.ROOT)
)) {
continue;
}
/*
* The resolver may deliberately return an exact QRG. AirScout must
* only see a canonical protocol band value such as 4320000.
*/
String bandValue = canonicalizeAirScoutBandValue(
client.resolveAirScoutBandValue(member)
);
if (bandValue == null) {
continue;
}
if (watchListBandValue == null) {
watchListBandValue = bandValue;
}
String targetStation =
member.getCallSign() + "," + member.getQra();
String pathQuery =
setPathPrefix
+ bandValue
+ ","
+ ownStation
+ ","
+ targetStation
+ " ";
sendPacket(
socket,
broadcastAddress,
port,
pathQuery
);
watchListTargets.add(targetStation);
}
/*
* AirScout keeps one watchlist per client/server pair. Do not send
* separate lists for the individual station bands because a later
* list would replace stations from an earlier one.
*
* If there are no targets in this cycle, reuse the last valid band
* token and send an empty list so AirScout can clear stale entries.
*/
if (watchListBandValue == null) {
watchListBandValue = lastWatchListBandValue;
}
if (watchListBandValue != null) {
StringBuilder watchListMessage = new StringBuilder(
watchListPrefix
+ watchListBandValue
+ ","
+ ownStation
);
for (String targetStation : watchListTargets) {
watchListMessage
.append(",")
.append(targetStation);
}
watchListMessage.append(" ");
sendPacket(
socket,
broadcastAddress,
port,
watchListMessage.toString()
);
lastWatchListBandValue = watchListBandValue;
}
} catch (IOException exception) {
LOGGER.log(
Level.WARNING,
"Could not send the periodical AirScout queries.",
exception
);
}
}
/**
* Converts a frequency-like value returned by the station resolver into the
* canonical band token expected by the AirScout UDP protocol.
*
* <p>The internal resolver may keep an exact working frequency for path
* analysis. This method removes that precision only at the AirScout protocol
* boundary. For example, {@code 4321740} is sent to AirScout as
* {@code 4320000}.</p>
*
* @param resolvedValue frequency-like AirScout value produced by the resolver
* @return canonical AirScout band value, or {@code null} if unsupported
*/
private String canonicalizeAirScoutBandValue(String resolvedValue) {
if (resolvedValue == null || resolvedValue.isBlank()) {
return null;
}
String normalizedValue = resolvedValue.trim();
if ("off".equalsIgnoreCase(normalizedValue)
|| "auto".equalsIgnoreCase(normalizedValue)) {
return null;
}
final long numericValue;
try {
numericValue = Long.parseLong(normalizedValue);
} catch (NumberFormatException exception) {
LOGGER.log(
Level.WARNING,
"Unsupported AirScout band value: " + resolvedValue,
exception
);
return null;
}
double frequencyMHz = numericValue / 10_000.0;
Band band = Band.fromFrequency(frequencyMHz);
if (band == null) {
LOGGER.warning(
"AirScout query skipped because frequency "
+ frequencyMHz
+ " MHz does not belong to a supported band."
);
return null;
}
return band.getPrefix() + "0000";
}
/**
* Removes the ON4KST login suffix because AirScout expects the actual
* station callsign, for example 9A1W instead of 9A1W-2.
*
* @param callSign configured ON4KST login callsign
* @return callsign without an ON4KST login suffix
*/
private String normalizeOwnCallSign(String callSign) {
if (callSign == null) {
return null;
}
String normalizedCallSign = callSign.trim();
int suffixSeparator = normalizedCallSign.indexOf("-");
if (suffixSeparator > 0) {
return normalizedCallSign.substring(0, suffixSeparator);
}
return normalizedCallSign;
}
private boolean isUsableAirScoutTarget(ChatMember member) {
return member != null
&& member.getCallSign() != null
&& !member.getCallSign().isBlank()
&& member.getQra() != null
&& !member.getQra().isBlank();
}
private void sendPacket(
DatagramSocket socket,
InetAddress address,
int port,
String message
) throws IOException {
byte[] payload = message.getBytes(StandardCharsets.UTF_8);
DatagramPacket packet = new DatagramPacket(
payload,
payload.length,
address,
port
);
socket.send(packet);
}
}
@@ -0,0 +1,222 @@
package kst4contest.controller;
import java.util.TimerTask;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ThreadStateMessage;
/**
* Sends the configured public-chat beacons for both active chat categories.
*
* <p>Both categories deliberately share one timer and therefore the same
* interval. Their enable flags and message templates remain independent. Every
* run reads the current preferences, resolves global message variables and
* sends only the categories which are currently enabled.</p>
*
* <p>Beacon messages use the regular outbound chat-message pipeline. They are
* not assembled as raw ON4KST frames, because that would bypass the common
* category, delimiter and message-text validation.</p>
*/
public class BeaconTask extends TimerTask {
private static final String THREAD_NICKNAME = "MyBeacon";
private final ChatController chatController;
private final ThreadStatusCallback callbackToController;
/**
* Creates one execution of the shared beacon timer.
*
* @param chatController controller providing preferences and the TX queue
* @param callbackToController callback used by the thread-status display
*/
public BeaconTask(
ChatController chatController,
ThreadStatusCallback callbackToController
) {
this.chatController = chatController;
this.callbackToController = callbackToController;
}
@Override
public void run() {
Thread.currentThread().setName("BeaconTask");
reportStatus(THREAD_NICKNAME, true, "initialized", false);
sendMainCategoryBeacon();
sendSecondCategoryBeacon();
}
/**
* Sends the main-category beacon if it is currently enabled.
*/
private void sendMainCategoryBeacon() {
if (!chatController.getChatPreferences()
.isBcn_beaconsEnabledMainCat()) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"off",
false
);
return;
}
ChatMessage beaconMessage = buildBeaconMessage(
chatController.getChatPreferences()
.getLoginChatCategoryMain(),
chatController.getChatPreferences()
.getBcn_beaconTextMainCat(),
"main category"
);
if (beaconMessage == null) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"invalid text",
true
);
return;
}
System.out.println(
new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
+ " [BeaconTask, Info]: Sending main-category CQ: "
+ beaconMessage.getMessageText()
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(
THREAD_NICKNAME + " 1",
true,
"on",
false
);
}
/**
* Sends the second-category beacon if the second login and its beacon are
* currently enabled.
*/
private void sendSecondCategoryBeacon() {
if (!chatController.getChatPreferences()
.isLoginToSecondChatEnabled()
|| !chatController.getChatPreferences()
.isBcn_beaconsEnabledSecondCat()) {
reportStatus(
THREAD_NICKNAME + " 2",
false,
"off",
false
);
return;
}
ChatMessage beaconMessage = buildBeaconMessage(
chatController.getChatPreferences()
.getLoginChatCategorySecond(),
chatController.getChatPreferences()
.getBcn_beaconTextSecondCat(),
"second category"
);
if (beaconMessage == null) {
reportStatus(
THREAD_NICKNAME + " 2",
false,
"invalid text",
true
);
return;
}
System.out.println(
new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
+ " [BeaconTask, Info]: Sending second-category CQ: "
+ beaconMessage.getMessageText()
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(
THREAD_NICKNAME + " 2",
true,
"on",
false
);
}
/**
* Resolves and validates one beacon before placing it in the regular outbound
* message queue.
*
* <p>The returned message contains only the public-chat payload and its chat
* category. {@link WriteThread} creates the final ON4KST frame through
* {@link On4KstProtocol#chatMessage(int, String)}. This prevents a configurable
* beacon text from bypassing the common protocol validation.</p>
*
* @param category target ON4KST chat category
* @param configuredText configured beacon template
* @param categoryDescription text used in diagnostic output
* @return prepared message, or {@code null} if the category or text is invalid
*/
private ChatMessage buildBeaconMessage(
ChatCategory category,
String configuredText,
String categoryDescription
) {
try {
if (category == null) {
throw new IllegalArgumentException(
"No chat category is configured."
);
}
On4KstProtocol.category(category.getCategoryNumber());
String resolvedText =
chatController.resolveAndValidateBeaconText(
configuredText
);
ChatMessage beaconMessage = new ChatMessage();
beaconMessage.setMessageText(resolvedText);
beaconMessage.setChatCategory(category);
beaconMessage.setMessageDirectedToServer(false);
return beaconMessage;
} catch (IllegalArgumentException exception) {
System.out.println(
"[BeaconTask, Warning]: Beacon for "
+ categoryDescription
+ " was not queued: "
+ exception.getMessage()
);
return null;
}
}
/**
* Forwards one state update to the existing thread-status display.
*/
private void reportStatus(
String threadName,
boolean running,
String information,
boolean criticalState
) {
ThreadStateMessage stateMessage = new ThreadStateMessage(
threadName,
running,
information,
criticalState
);
callbackToController.onThreadStatus(
THREAD_NICKNAME,
stateMessage
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,77 @@
package kst4contest.controller;
import kst4contest.model.ChatMember;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.function.Predicate;
/**
* Derives the visible station list from the canonical one.
*
* <p>This replaces the JavaFX {@code FilteredList}/{@code SortedList} pair that
* used to sit in {@link ChatController}. Being a pure function, the filtering
* and ordering rules are testable without a running toolkit for the first time.
*/
public final class ChatMemberListView {
private ChatMemberListView() {
}
/**
* @param members the canonical list; never modified
* @param filters combined with AND; an empty list lets everything through
* @param order {@code null} keeps the incoming order; sorting is stable
* @return an immutable result list
*/
public static List<ChatMember> compute(List<ChatMember> members,
List<Predicate<ChatMember>> filters,
Comparator<ChatMember> order) {
List<ChatMember> result = new ArrayList<>(members.size());
for (ChatMember member : members) {
if (matches(member, filters)) {
result.add(member);
}
}
if (order != null) {
// List.sort is stable: within a tie the arrival order is kept.
result.sort(order);
}
/*
* Collections.unmodifiableList and not List.copyOf: the JavaFX list this
* replaces carried null entries, and List.copyOf rejects them. A
* NullPointerException raised here would be swallowed by the listener guard
* and the station list would stop updating without a word.
*/
return java.util.Collections.unmodifiableList(result);
}
/**
* How many members pass the filters. Same rules as {@link #compute}, but it
* neither sorts nor builds a list: the status line only wants the number.
*/
public static int countMatching(List<ChatMember> members, List<Predicate<ChatMember>> filters) {
int count = 0;
for (ChatMember member : members) {
if (matches(member, filters)) {
count++;
}
}
return count;
}
private static boolean matches(ChatMember member, List<Predicate<ChatMember>> filters) {
for (Predicate<ChatMember> filter : filters) {
try {
if (!filter.test(member)) {
return false;
}
} catch (RuntimeException e) {
// A broken filter must not empty the station list during a contest.
System.err.println("[station list] filter failed, keeping the member: " + e);
}
}
return true;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,196 @@
//package kst4contest.controller;
//
//import kst4contest.model.ChatMember;
//import kst4contest.model.ChatMessage;
//
//import java.io.*;
//import java.net.ServerSocket;
//import java.net.Socket;
//import java.nio.channels.ServerSocketChannel;
//import java.nio.channels.SocketChannel;
//import java.time.Instant;
//
///**
// * This thread is responsible for providing DXCluster messages for a connected log program.
// *
// *
// */
////public class DXClusterController extends Thread {
// PrintWriter outTelnet;
// BufferedReader inTelnet;
// private Socket socket;
// private ChatController client;
//// private OutputStream output;
//// private InputStream input;
//
// private ChatMessage messageTextRaw;
//
// private static final int PORT = 23;
// private static final String USERNAME = "user";
// private static final String PASSWORD = "pass";
// private Socket clientSocket;
//
//
//
//
//// public DXClusterController(Socket clientSocket, ChatController client) throws InterruptedException {
////
//// this.client = client;
////
//// try {
//// outTelnet = new PrintWriter(clientSocket.getOutputStream(), true);
//// inTelnet = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
//// } catch (IOException e) {
//// throw new RuntimeException(e);
//// }
//// System.out.println("defcons");
//// this.clientSocket = clientSocket;
////
//// }
//
// public DXClusterController(Socket clientSocket, ChatController chatController) {
//
// try {
// socket = clientSocket;
// } catch (Exception e) {
// throw new RuntimeException(e);
// }
// this.client = chatController;
//
// try {
// outTelnet = new PrintWriter(socket.getOutputStream(), true);
// inTelnet = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// } catch (IOException e) {
// throw new RuntimeException(e);
// }
// System.out.println("[DXCCtrl, info:] DXCluster Controller created!");
// this.clientSocket = socket;
// }
//
// public DXClusterController(Socket clientSocket, ObjectOutputStream objectout, ChatController chatController) {
//
// try {
// socket = clientSocket;
// } catch (Exception e) {
// throw new RuntimeException(e);
// }
// this.client = chatController;
//
// try {
// outTelnet = new PrintWriter(socket.getOutputStream(), true);
// inTelnet = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// } catch (IOException e) {
// throw new RuntimeException(e);
// }
// System.out.println("[DXCCtrl, info:] DXCluster Controller created!");
// this.clientSocket = socket;
// }
//
//// public DXClusterController(ServerSocket clientSocket, ChatController client) throws InterruptedException {
//// //TODO: GOT FROM https://stackoverflow.com/questions/15541804/creating-the-serversocket-in-a-separate-thread
//// try {
//// socket = clientSocket.accept(2);
//// } catch (IOException e) {
//// throw new RuntimeException(e);
//// }
//// this.client = client;
////
//// try {
//// outTelnet = new PrintWriter(socket.getOutputStream(), true);
//// inTelnet = new BufferedReader(new InputStreamReader(socket.getInputStream()));
//// } catch (IOException e) {
//// throw new RuntimeException(e);
//// }
//// System.out.println("defcons");
//// this.clientSocket = socket;
////
//// }
//
//// public DXClusterController(ServerSocketChannel serverSocketChannel, ChatController client) throws InterruptedException {
////
//// this.client = client;
////
////// clientSocketChannel.ac
////
//// try {
////
//// serverSocketChannel.accept();
//// serverSocketChannel.rea
////
//// outTelnet = new PrintWriter(clientSocket.getOutputStream(), true);
//// inTelnet = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
//// } catch (IOException e) {
//// throw new RuntimeException(e);
//// }
//// System.out.println("defcons");
//// this.clientSocket = clientSocket;
////
//// }
//
// public boolean terminateConnection() throws IOException {
//
//// this.output.close();
// this.socket.close();
//
// return true;
// }
//
// public void sendLocalClusterMessage() {
//
// }
//
// public void run() {
//// try (
//// PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
//// BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())))
//// {
//
//// out.println("Welcome to the Telnet Server");
// outTelnet.print("login: ");
// outTelnet.flush();
// try {
// String user = inTelnet.readLine();
// } catch (IOException ex) {
// throw new RuntimeException(ex);
// }
//// finally {
//// try {
//// clientSocket.close();
//// } catch (Exception e) {
//// System.out.println("Error closing client socket: " + e.getMessage());
//// }
//// }
//
//
//// for (int i = 0; i < 10; i++) {
////
//// outTelnet.println("DX de DM5M: 144222.0 DO5AMF JN49FL 2250Z\n");
//// }
//
//
//
// }
//
// /**
// * Sends a DX cluster message to the connected log programs via telnet, returns true if sent
// *
// * @param aChatMember
// * @return
// */
// public boolean propagateSingleDXClusterEntry(ChatMember aChatMember) {
//
// String singleDXClusterMessage = "DX de ";
//
// singleDXClusterMessage += client.getChatPreferences().getLoginCallSign() + " ";
// singleDXClusterMessage += aChatMember.getFrequency().get() + " ";
// singleDXClusterMessage += aChatMember.getCallSign().toUpperCase() + " ";
// singleDXClusterMessage += aChatMember.getQra().toUpperCase() + " ";
// singleDXClusterMessage += new Utils4KST().time_generateCurrenthhmmZTimeStringForClusterMessage() + "\n";
//
// outTelnet.println(singleDXClusterMessage);
// outTelnet.flush();
// return true;
// }
//}
@@ -0,0 +1,165 @@
package kst4contest.controller;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
/**
* Formats local DX Cluster spots using the fixed-column layout emitted by
* DXSpider and accepted by common logging programs.
*/
final class DXClusterSpotFormatter {
/** Length of the DX Cluster line before BEL and CRLF framing. */
/* package */
static final int LINE_LENGTH = 75;
/** One-based column in which the spotted callsign starts. */
/* package */
static final int DX_CALL_COLUMN = 27;
/** Width of the fixed comment field. */
/* package */
static final int COMMENT_LENGTH = 30;
/** One-based column in which the UTC time starts. */
/* package */
static final int TIME_COLUMN = 71;
/** Zero-based exclusive end position of the frequency field. */
private static final int FREQUENCY_END = 24;
/** Maximum width of the spotted callsign field. */
private static final int DX_CALL_LENGTH = 12;
/** Required width of the HHMMZ time field. */
private static final int TIME_LENGTH = 5;
/** Minimum separator width between spotter and frequency. */
private static final int MIN_FREQUENCY_GAP = 1;
/** Wire framing appended to every formatted line. */
private static final String PAYLOAD_SUFFIX = "\u0007\u0007\r\n";
private DXClusterSpotFormatter() {
}
/** Builds the fixed 75-character payload line without wire framing. */
/* package */
static String formatLine(
final String spotterCallSign,
final String frequency,
final String dxCallSign,
final String comment,
final String time
) {
final String spotter = requireValue(
spotterCallSign,
"spotter callsign"
)
.toUpperCase(Locale.ROOT);
final String frequencyValue = requireValue(frequency, "frequency");
final String dxCall = requireValue(dxCallSign, "DX callsign")
.toUpperCase(Locale.ROOT);
final String timeValue = requireValue(time, "time");
validateDxCall(dxCall);
validateTime(timeValue);
final String prefix = "DX de " + spotter + ":";
final int frequencyPadding = calculateFrequencyPadding(
prefix,
frequencyValue
);
final String normalizedComment = normalizeComment(comment);
final String line = prefix
+ " ".repeat(frequencyPadding)
+ frequencyValue
+ " "
+ padRight(dxCall, DX_CALL_LENGTH)
+ " "
+ padRight(normalizedComment, COMMENT_LENGTH)
+ " "
+ timeValue;
if (line.length() != LINE_LENGTH) {
throw new IllegalStateException(
"DX Cluster formatter produced "
+ line.length()
+ " characters instead of "
+ LINE_LENGTH
);
}
return line;
}
/** Builds one complete ASCII spot payload including BEL and CRLF framing. */
/* package */
static byte[] formatPayload(
final String spotterCallSign,
final String frequency,
final String dxCallSign,
final String comment,
final String time
) {
return (formatLine(
spotterCallSign,
frequency,
dxCallSign,
comment,
time
) + PAYLOAD_SUFFIX).getBytes(StandardCharsets.US_ASCII);
}
private static void validateDxCall(final String dxCall) {
if (dxCall.length() > DX_CALL_LENGTH) {
throw new IllegalArgumentException(
"DX callsign exceeds 12 characters: " + dxCall
);
}
}
private static void validateTime(final String time) {
if (time.length() != TIME_LENGTH) {
throw new IllegalArgumentException(
"DX Cluster time must contain exactly five characters"
);
}
}
private static int calculateFrequencyPadding(
final String prefix,
final String frequency
) {
final int padding = FREQUENCY_END
- prefix.length()
- frequency.length();
if (padding < MIN_FREQUENCY_GAP) {
throw new IllegalArgumentException(
"Spotter callsign and frequency do not fit the DX Cluster prefix"
);
}
return padding;
}
private static String normalizeComment(final String comment) {
final String normalized = comment == null ? "" : comment.trim();
return normalized.length() > COMMENT_LENGTH
? normalized.substring(0, COMMENT_LENGTH)
: normalized;
}
private static String requireValue(
final String value,
final String fieldName
) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(
"DX Cluster " + fieldName + " is missing"
);
}
return value.trim();
}
private static String padRight(final String value, final int length) {
return value + " ".repeat(length - value.length());
}
}
@@ -0,0 +1,367 @@
package kst4contest.controller;
import kst4contest.model.ChatMember;
import kst4contest.model.ThreadStateMessage;
import java.io.IOException;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.observe.SimpleValue;
public class DXClusterThreadPooledServer implements Runnable {
private static final Logger LOGGER =
Logger.getLogger(DXClusterThreadPooledServer.class.getName());
private static final String THREAD_NICKNAME = "DXCluster-Server";
private final List<Socket> clientSockets =
Collections.synchronizedList(new ArrayList<>());
private final ChatController chatController;
private final ThreadStatusCallback callBackToController;
private final int serverPort;
private final ExecutorService threadPool =
Executors.newFixedThreadPool(10);
private final ScheduledExecutorService keepAliveExecutor =
Executors.newSingleThreadScheduledExecutor();
private volatile boolean stopped;
private ServerSocket serverSocket;
public DXClusterThreadPooledServer(
int port,
ChatController chatController,
ThreadStatusCallback callback
) {
this.serverPort = port;
this.chatController = chatController;
this.callBackToController = callback;
}
@Override
public void run() {
Thread.currentThread().setName("DXCluster-thread-pooled-server");
try {
serverSocket = new ServerSocket(serverPort);
if (stopped) {
return;
}
callBackToController.onThreadStatus(
THREAD_NICKNAME,
new ThreadStateMessage(
THREAD_NICKNAME,
true,
"Listening on TCP port " + serverPort,
false
)
);
keepAliveExecutor.scheduleAtFixedRate(
this::sendKeepAlive,
30,
30,
TimeUnit.SECONDS
);
while (!stopped) {
try {
Socket clientSocket = serverSocket.accept();
clientSockets.add(clientSocket);
threadPool.execute(
new DXClusterServerWorkerRunnable(
clientSocket,
clientSockets
)
);
} catch (IOException exception) {
if (!stopped) {
LOGGER.log(
Level.SEVERE,
"Error accepting DX Cluster client connection",
exception
);
}
}
}
} catch (IOException exception) {
if (!stopped) {
LOGGER.log(
Level.SEVERE,
"Cannot open DX Cluster TCP port " + serverPort,
exception
);
callBackToController.onThreadStatus(
THREAD_NICKNAME,
new ThreadStateMessage(
THREAD_NICKNAME,
false,
"Cannot open TCP port "
+ serverPort
+ ": "
+ exception.getMessage(),
true
)
);
}
} finally {
closeServerSocket();
closeClientSockets();
keepAliveExecutor.shutdownNow();
threadPool.shutdownNow();
}
}
public synchronized void stop() {
stopped = true;
closeServerSocket();
closeClientSockets();
keepAliveExecutor.shutdownNow();
threadPool.shutdownNow();
}
public boolean hasConnectedClients() {
synchronized (clientSockets) {
removeClosedClients();
return !clientSockets.isEmpty();
}
}
/**
* Sends one DX Cluster spot to all currently connected clients.
*
* @return true if the spot was delivered to at least one client
*/
public boolean broadcastSingleDXClusterEntryToLoggers(
ChatMember chatMember
) {
final byte[] clusterPayload;
final String clusterMessage;
try {
String frequency = Utils4KST.normalizeFrequencyString(
chatMember.getFrequency().get(),
// Bridge until ChatPreferences moves to the observe types:
// Utils4KST only reads the prefix, so a snapshot is enough.
new SimpleValue<>(chatController
.getChatPreferences()
.getNotify_optionalFrequencyPrefix()
.get())
);
clusterPayload = DXClusterSpotFormatter.formatPayload(
chatController
.getChatPreferences()
.getNotify_DXCSrv_SpottersCallSign()
.get(),
frequency,
chatMember.getCallSign(),
chatMember.getQra(),
new Utils4KST()
.time_generateCurrenthhmmZTimeStringForClusterMessage()
);
clusterMessage = new String(
clusterPayload,
StandardCharsets.US_ASCII
);
} catch (IllegalArgumentException exception) {
LOGGER.log(
Level.WARNING,
"DX Cluster spot rejected: " + exception.getMessage()
);
return false;
} catch (Exception exception) {
LOGGER.log(
Level.SEVERE,
"Cannot build DX Cluster message",
exception
);
return false;
}
int deliveredClients = 0;
synchronized (clientSockets) {
Iterator<Socket> iterator = clientSockets.iterator();
while (iterator.hasNext()) {
Socket socket = iterator.next();
if (socket == null || socket.isClosed()) {
iterator.remove();
continue;
}
try {
OutputStream output = socket.getOutputStream();
output.write(clusterPayload);
output.flush();
deliveredClients++;
} catch (IOException exception) {
LOGGER.log(
Level.WARNING,
"DX Cluster client disconnected while sending a spot",
exception
);
closeSocket(socket);
iterator.remove();
}
}
}
if (deliveredClients > 0) {
callBackToController.onThreadStatus(
THREAD_NICKNAME,
new ThreadStateMessage(
THREAD_NICKNAME,
true,
"Last spot sent to "
+ deliveredClients
+ " DX Cluster client(s):\n"
+ clusterMessage,
false
)
);
}
return deliveredClients > 0;
}
private void sendKeepAlive() {
synchronized (clientSockets) {
Iterator<Socket> iterator = clientSockets.iterator();
while (iterator.hasNext()) {
Socket socket = iterator.next();
if (socket == null || socket.isClosed()) {
iterator.remove();
continue;
}
try {
OutputStream output = socket.getOutputStream();
output.write(
"\r\n".getBytes(StandardCharsets.US_ASCII)
);
output.flush();
} catch (IOException exception) {
closeSocket(socket);
iterator.remove();
}
}
}
}
private void removeClosedClients() {
clientSockets.removeIf(
socket -> socket == null || socket.isClosed()
);
}
private synchronized void closeServerSocket() {
if (serverSocket == null || serverSocket.isClosed()) {
return;
}
try {
serverSocket.close();
} catch (IOException exception) {
LOGGER.log(
Level.WARNING,
"Error closing DX Cluster server socket",
exception
);
}
}
private void closeClientSockets() {
synchronized (clientSockets) {
for (Socket socket : clientSockets) {
closeSocket(socket);
}
clientSockets.clear();
}
}
private static void closeSocket(Socket socket) {
if (socket == null || socket.isClosed()) {
return;
}
try {
socket.close();
} catch (IOException ignored) {
// The connection is already unusable.
}
}
}
class DXClusterServerWorkerRunnable implements Runnable {
private static final Logger LOGGER =
Logger.getLogger(DXClusterServerWorkerRunnable.class.getName());
private final Socket clientSocket;
private final List<Socket> clientSockets;
DXClusterServerWorkerRunnable(
Socket clientSocket,
List<Socket> clientSockets
) {
this.clientSocket = clientSocket;
this.clientSockets = clientSockets;
}
@Override
public void run() {
try {
OutputStream output = clientSocket.getOutputStream();
output.write(
"login: ".getBytes(StandardCharsets.US_ASCII)
);
output.flush();
System.out.println(
"[DXClusterServer] New client connected: "
+ clientSocket.getInetAddress()
);
} catch (IOException exception) {
LOGGER.log(
Level.WARNING,
"Cannot initialise DX Cluster client connection",
exception
);
synchronized (clientSockets) {
clientSockets.remove(clientSocket);
}
try {
clientSocket.close();
} catch (IOException ignored) {
// The connection is already unusable.
}
}
}
}
@@ -0,0 +1,116 @@
package kst4contest.controller;
import java.util.Optional;
import kst4contest.model.ChatMember;
/**
* Validated QSO state shared by external logger listeners and the controller.
*/
public final class ExternalLoggedQso {
private final String callSign;
private final LoggedQsoBand band;
private final String locator;
private final String source;
private ExternalLoggedQso(
String callSign,
LoggedQsoBand band,
String locator,
String source
) {
this.callSign = callSign;
this.band = band;
this.locator = locator;
this.source = source;
}
/**
* Creates a QSO only when the logger supplied a usable callsign. Missing band
* and locator values remain unavailable and never receive guessed defaults.
*
* @param rawCallSign callsign received from the logger
* @param band normalized band or {@code null}
* @param rawLocator locator received from the logger or {@code null}
* @param source logger name used for persistence diagnostics
* @return validated QSO, or an empty value for a missing callsign
*/
public static Optional<ExternalLoggedQso> create(
String rawCallSign,
LoggedQsoBand band,
String rawLocator,
String source
) {
if (rawCallSign == null) {
return Optional.empty();
}
String normalizedCallSign = rawCallSign.trim();
if (normalizedCallSign.isEmpty()) {
return Optional.empty();
}
String normalizedLocator = WorkedGrossFieldCache.extractLocator6(rawLocator);
return Optional.of(new ExternalLoggedQso(
normalizedCallSign,
band,
normalizedLocator,
source
));
}
public String getCallSign() {
return callSign;
}
public LoggedQsoBand getBand() {
return band;
}
public String getLocator() {
return locator;
}
public String getSource() {
return source;
}
/**
* Builds the database representation of this QSO.
*
* @return chat-member state with global and optional band Worked flags
*/
public ChatMember toWorkedChatMember() {
ChatMember workedCall = new ChatMember();
workedCall.setCallSign(callSign);
workedCall.setWorked(true);
if (band != null) {
band.applyWorkedFlag(workedCall);
}
if (locator != null) {
workedCall.setQra(locator);
}
return workedCall;
}
/**
* Applies this QSO to one active callsign variant.
*
* @param member active variant with the same base callsign
*/
public void applyToActiveMember(ChatMember member) {
if (member == null) {
return;
}
member.setWorked(true);
if (band != null) {
band.applyWorkedFlag(member);
}
if (locator != null
&& (member.getQra() == null
|| member.getQra().isBlank()
|| "unknown".equalsIgnoreCase(member.getQra()))) {
member.setQra(locator);
}
}
}
@@ -0,0 +1,65 @@
package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.ChatMessage;
/**
* Consolereader, reads a full line and adds it to the tx-queue after formatting it like: <br> <br>
*
* MSG|2|0|/cq DO5AMF kst4contest.test|0| <br/><br/>
*
* No need for it as it´s not longer a console application
*/
public class InputReaderThread extends Thread {
private static final Logger LOGGER = Logger.getLogger(InputReaderThread.class.getName());
private PrintWriter writer;
private Socket socket;
private ChatController client;
public InputReaderThread(ChatController client) throws InterruptedException {
this.client = client;
}
public void run() {
while (true) {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
ChatMessage ownMSG = new ChatMessage();
// ownMSG.setDirectedToServer(true);
String sendThisMessage23001 = "";
try {
sendThisMessage23001 = reader.readLine();
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Error reading from stdin", e);
}
ownMSG.setMessageText("MSG|" + this.client.getChatCategoryMain().getCategoryNumber() + "|0|" + sendThisMessage23001 + "|0|");
// System.out.println("inreader " + ownMSG.getMessage() + client.getMessageTXBus().size());
// client.getMessageTXBus().add(ownMSG);
client.getMessageTXBus().add(ownMSG);
try {
this.sleep(500);
} catch (InterruptedException e) {
LOGGER.log(Level.SEVERE, "InputReaderThread interrupted", e);
Thread.currentThread().interrupt();
}
}
}
}
@@ -0,0 +1,129 @@
package kst4contest.controller;
import java.util.Locale;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
/**
* Normalized band of a QSO received from an external logger.
*
* <p>Logger protocols use different representations for the same amateur-radio
* band. UCXLog-compatible XML may contain a frequency-like number or a metre /
* centimetre label, while Win-Test uses its own numeric band IDs. Resolving the
* raw value once prevents the Worked flag and gross-field update from interpreting
* the same packet differently.</p>
*/
public enum LoggedQsoBand {
BAND_50(Band.B_50),
BAND_70(Band.B_70),
BAND_144(Band.B_144),
BAND_432(Band.B_432),
BAND_1296(Band.B_1296),
BAND_2320(Band.B_2320),
BAND_3400(Band.B_3400),
BAND_5760(Band.B_5760),
BAND_10G(Band.B_10G),
BAND_24G(Band.B_24G),
BAND_47G(null),
BAND_76G(null);
private final Band projectBand;
LoggedQsoBand(Band projectBand) {
this.projectBand = projectBand;
}
/**
* Resolves aliases used by UCXLog, N1MM+, DXLog.net, QARTest and compatible
* loggers. The input is trimmed and case-normalized exactly once here.
*
* @param rawBand band value received in the XML packet
* @return normalized band, or {@code null} for a missing or unknown value
*/
public static LoggedQsoBand fromLoggerValue(String rawBand) {
if (rawBand == null) {
return null;
}
String normalizedBand = rawBand.trim().toLowerCase(Locale.ROOT);
return switch (normalizedBand) {
case "50", "6m" -> BAND_50;
case "70", "4m" -> BAND_70;
case "144", "2m" -> BAND_144;
case "432", "70cm" -> BAND_432;
case "1240", "1296", "23cm" -> BAND_1296;
case "2300", "2320", "13cm" -> BAND_2320;
case "3400", "9cm" -> BAND_3400;
case "5600", "5760", "6cm" -> BAND_5760;
case "10g", "10368", "3cm" -> BAND_10G;
default -> null;
};
}
/**
* Resolves the established Win-Test ADDQSO band IDs. IDs 22 and 23 retain
* their existing Worked flags even though the project Band enum has no 47 GHz
* or 76 GHz value for gross-field storage.
*
* @param rawBandId Win-Test band ID
* @return normalized band, or {@code null} for a missing or unknown ID
*/
public static LoggedQsoBand fromWinTestBandId(String rawBandId) {
if (rawBandId == null) {
return null;
}
String normalizedBandId = rawBandId.trim();
return switch (normalizedBandId) {
case "10" -> BAND_50;
case "11" -> BAND_70;
case "12" -> BAND_144;
case "14" -> BAND_432;
case "16" -> BAND_1296;
case "17" -> BAND_2320;
case "18" -> BAND_3400;
case "19" -> BAND_5760;
case "20" -> BAND_10G;
case "21" -> BAND_24G;
case "22" -> BAND_47G;
case "23" -> BAND_76G;
default -> null;
};
}
/**
* Returns the common Band value used by worked-grid persistence.
*
* @return project band, or {@code null} where no matching Band value exists
*/
public Band getProjectBand() {
return projectBand;
}
/**
* Applies the established per-band Worked flag to a chat member.
*
* @param member worked station or active callsign variant
*/
public void applyWorkedFlag(ChatMember member) {
if (member == null) {
return;
}
switch (this) {
case BAND_50 -> member.setWorked50(true);
case BAND_70 -> member.setWorked70(true);
case BAND_144 -> member.setWorked144(true);
case BAND_432 -> member.setWorked432(true);
case BAND_1296 -> member.setWorked1240(true);
case BAND_2320 -> member.setWorked2300(true);
case BAND_3400 -> member.setWorked3400(true);
case BAND_5760 -> member.setWorked5600(true);
case BAND_10G -> member.setWorked10G(true);
case BAND_24G -> member.setWorked24G(true);
case BAND_47G -> member.setWorked47G(true);
case BAND_76G -> member.setWorked76G(true);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,81 @@
//package kst4contest.controller;
//
//import kst4contest.model.Band;
//import org.junit.jupiter.api.Test;
//import org.junit.jupiter.params.ParameterizedTest;
//import org.junit.jupiter.params.provider.ValueSource;
//
//import java.util.regex.Matcher;
//import java.util.regex.Pattern;
//
//import static org.junit.jupiter.api.Assertions.assertEquals;
//import static org.junit.jupiter.api.Assertions.assertFalse;
//import static org.junit.jupiter.api.Assertions.assertNotNull;
//import static org.junit.jupiter.api.Assertions.assertNull;
//import static org.junit.jupiter.api.Assertions.assertTrue;
//
//class MessageBusManagementThreadFrequencyContextTest {
//
// private static final Pattern THREE_DIGIT_VALUE =
// Pattern.compile("\\b\\d{3}\\b");
//
// @ParameterizedTest
// @ValueSource(strings = {
// "qrg 210",
// "QRG: 210",
// "freq is 210",
// "frequency = 210",
// "on 210",
// "210 MHz",
// "210 qrg"
// })
// void acceptsBareThreeDigitValueWithFrequencyContext(String messageText) {
// Matcher matcher = findThreeDigitValue(messageText);
//
// assertTrue(
// MessageBusManagementThread
// .hasExplicitBareFrequencyContext(
// messageText,
// matcher.start(),
// matcher.end()
// )
// );
// }
//
// @ParameterizedTest
// @ValueSource(strings = {
// "599",
// "144",
// "serial 210",
// "score 210",
// "worked 210 stations"
// })
// void rejectsBareThreeDigitValueWithoutFrequencyContext(String messageText) {
// Matcher matcher = findThreeDigitValue(messageText);
//
// assertFalse(
// MessageBusManagementThread
// .hasExplicitBareFrequencyContext(
// messageText,
// matcher.start(),
// matcher.end()
// )
// );
// }
//
// @Test
// void resolvesOnlySupportedFallbackPrefixes() {
// assertEquals(Band.B_144, Band.fromPrefix("144"));
// assertEquals(Band.B_432, Band.fromPrefix(" 432 "));
// assertEquals(Band.B_10G, Band.fromPrefix("10368"));
// assertNull(Band.fromPrefix("999"));
// assertNull(Band.fromPrefix(null));
// }
//
// private Matcher findThreeDigitValue(String messageText) {
// Matcher matcher = THREE_DIGIT_VALUE.matcher(messageText);
// assertTrue(matcher.find(), "Test message must contain a three-digit value");
// assertNotNull(matcher.group());
// return matcher;
// }
//}
@@ -0,0 +1,238 @@
package kst4contest.controller;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Records the raw ON4KST transport frames of one application session.
*
* <p>The recorder is shared by the reader and writer threads. Every event is
* written under one lock so concurrent RX and TX traffic cannot interleave.
* File-system failures are contained here and never propagated to connection
* threads.</p>
*/
public final class MessageHistoryRecorder implements AutoCloseable {
/** File name of the active program-session history. */
/* package */ static final String CURRENT_FILE_NAME = "Messagehistory.raw";
/** Component logger. */
private static final Logger LOGGER =
Logger.getLogger(MessageHistoryRecorder.class.getName());
/** Timestamp format for individual history events. */
private static final DateTimeFormatter EVENT_TIMESTAMP =
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'")
.withZone(ZoneOffset.UTC);
/** Timestamp format used in archived history file names. */
private static final DateTimeFormatter ARCHIVE_TIMESTAMP =
DateTimeFormatter.ofPattern("uuuuMMdd-HHmmss'Z'")
.withZone(ZoneOffset.UTC);
/** Shared recorder instance that deliberately discards all frames. */
private static final MessageHistoryRecorder NO_OP_RECORDER =
new MessageHistoryRecorder();
/** Serializes state transitions and complete line writes. */
private final ReentrantLock lock = new ReentrantLock();
/** Directory containing the active and archived history files. */
private final Path appDirectory;
/** Path of the active program-session history. */
private final Path currentFile;
/** Time source for event and archive timestamps. */
private final Clock clock;
/** Whether this instance is the shared no-op recorder. */
private final boolean noOp;
/** Whether new frames should currently be recorded. */
private boolean enabled;
/** Whether this program session has completed its one-time rotation. */
private boolean sessionReady;
/** Whether opening the active file has already been attempted. */
private boolean openAttempted;
/** Writer for the active history file, or {@code null} while closed. */
private BufferedWriter writer;
private MessageHistoryRecorder() {
appDirectory = null;
currentFile = null;
clock = Clock.systemUTC();
noOp = true;
}
/**
* Creates a recorder for one application program session.
*
* @param appDirectory directory containing the current and archived history
* files
* @param enabled whether recording starts immediately
* @param clock time source used for event and archive timestamps
*/
public MessageHistoryRecorder(
final Path appDirectory,
final boolean enabled,
final Clock clock
) {
this.appDirectory = appDirectory;
this.currentFile = appDirectory.resolve(CURRENT_FILE_NAME);
this.clock = clock;
this.noOp = false;
setEnabled(enabled);
}
/** Returns a recorder that deliberately discards every frame. */
/* package */ static MessageHistoryRecorder disabled() {
return NO_OP_RECORDER;
}
/** Enables or disables recording immediately for the current program session. */
public void setEnabled(final boolean requestedEnabled) {
lock.lock();
try {
if (!noOp && enabled != requestedEnabled) {
enabled = requestedEnabled;
if (requestedEnabled) {
if (!sessionReady) {
openAttempted = false;
}
ensureWriter();
} else {
closeWriter();
openAttempted = false;
}
}
} finally {
lock.unlock();
}
}
/** Records one frame received from ON4KST without its transport CR/LF. */
public void recordRx(final String frame) {
record("RX", frame);
}
/** Records one frame successfully sent to ON4KST without its transport CR/LF. */
public void recordTx(final String frame) {
record("TX", frame);
}
private void record(final String direction, final String frame) {
lock.lock();
try {
if (enabled && !noOp) {
ensureWriter();
if (writer != null) {
final String safeFrame =
On4KstProtocol.redactSecretsForRecording(frame);
try {
writer.write(EVENT_TIMESTAMP.format(Instant.now(clock)));
writer.write('\t');
writer.write(direction);
writer.write('\t');
writer.write(safeFrame == null ? "" : safeFrame);
writer.newLine();
writer.flush();
} catch (IOException exception) {
if (LOGGER.isLoggable(Level.WARNING)) {
LOGGER.log(Level.WARNING,
"Could not write ON4KST message history; "
+ "recording is disabled until it "
+ "is enabled again",
exception);
}
closeWriter();
openAttempted = false;
enabled = false;
}
}
}
} finally {
lock.unlock();
}
}
private void ensureWriter() {
if (!enabled || writer != null || openAttempted) {
return;
}
openAttempted = true;
try {
Files.createDirectories(appDirectory);
if (!sessionReady) {
rotatePreviousSession();
sessionReady = true;
}
writer = Files.newBufferedWriter(
currentFile,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
} catch (IOException | RuntimeException exception) {
if (LOGGER.isLoggable(Level.WARNING)) {
LOGGER.log(Level.WARNING,
"Could not initialize ON4KST message history at "
+ currentFile,
exception);
}
closeWriter();
}
}
private void rotatePreviousSession() throws IOException {
if (!Files.isRegularFile(currentFile) || Files.size(currentFile) == 0L) {
return;
}
final String baseName = "Messagehistory-"
+ ARCHIVE_TIMESTAMP.format(Instant.now(clock));
Path archive = appDirectory.resolve(baseName + ".raw");
int suffix = 1;
while (Files.exists(archive)) {
archive = appDirectory.resolve(
baseName + "-" + suffix + ".raw");
suffix++;
}
try {
Files.move(currentFile, archive, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException unsupported) {
Files.move(currentFile, archive);
}
}
private void closeWriter() {
if (writer == null) {
return;
}
try {
writer.close();
} catch (IOException exception) {
LOGGER.log(Level.WARNING,
"Could not close ON4KST message history", exception);
} finally {
writer = null;
}
}
/** Stops recording and closes the current history file. */
@Override
public void close() {
lock.lock();
try {
enabled = false;
closeWriter();
} finally {
lock.unlock();
}
}
}
@@ -0,0 +1,154 @@
package kst4contest.controller;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
import kst4contest.model.AirPlane;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
/**
* Resolves variables used in operator messages, shortcuts, snippets and
* beacons.
*
* <p>Global variables only depend on the local station configuration and can
* therefore be used in every message context. Station variables additionally
* require a selected remote station. Keeping both groups in one resolver
* prevents the send field and the beacon tasks from implementing different
* replacement rules.</p>
*/
public final class MessageVariableResolver {
private static final int SHORT_VALUE_LENGTH = 7;
private final ChatPreferences chatPreferences;
/**
* Creates a resolver backed by the live application preferences.
*
* @param chatPreferences preferences that provide current station values
*/
public MessageVariableResolver(ChatPreferences chatPreferences) {
this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences");
}
/**
* Resolves variables which do not require a selected remote station.
*
* <p>The replacement is literal rather than regular-expression based.
* Callsigns, locators and frequencies are data, not regular expressions.</p>
*
* @param template text that may contain variables
* @return text with all available global variables resolved, or {@code null}
* when the supplied template is {@code null}
*/
public String resolveGlobalVariables(String template) {
if (template == null) {
return null;
}
String primaryFrequency = valueOrEmpty(chatPreferences.getMYQRGFirstCat().get());
String secondaryFrequency = valueOrEmpty(chatPreferences.getMYQRGSecondCat().get());
String ownLocator = valueOrEmpty(chatPreferences.getStn_loginLocatorMainCat());
String resolvedText = template;
resolvedText = resolvedText.replace("MYQRGSHORT", abbreviate(primaryFrequency));
resolvedText = resolvedText.replace("MYQRG", primaryFrequency);
resolvedText = resolvedText.replace("SECONDQRG", secondaryFrequency);
resolvedText = resolvedText.replace("MYLOCATORSHORT", abbreviateLocator(ownLocator));
resolvedText = resolvedText.replace("MYLOCATOR", ownLocator);
resolvedText = resolvedText.replace("MYCALL", valueOrEmpty(chatPreferences.getStn_loginCallSign()));
resolvedText = resolvedText.replace("MYQTF", formatHeading(chatPreferences.getActualQTF().get().doubleValue()));
return resolvedText;
}
/**
* Resolves global variables and variables derived from a selected station.
*
* <p>If no station is selected, station-specific placeholders remain visible.
* This is intentional: silently removing {@code QRZNAME}, {@code FIRSTAP} or
* {@code SECONDAP} could create a plausible-looking but incomplete message.</p>
*
* @param template text that may contain variables
* @param selectedStation currently selected remote station, may be {@code null}
* @return resolved message text
*/
public String resolveForSelectedStation(String template, ChatMember selectedStation) {
String resolvedText = resolveGlobalVariables(template);
if (resolvedText == null || selectedStation == null) {
return resolvedText;
}
resolvedText = resolvedText.replace("QRZNAME", resolveStationName(selectedStation));
resolvedText = resolvedText.replace("FIRSTAP", resolveFirstAirPlane(selectedStation));
resolvedText = resolvedText.replace("SECONDAP", resolveSecondAirPlane(selectedStation));
return resolvedText;
}
private String resolveStationName(ChatMember selectedStation) {
String stationName = valueOrEmpty(selectedStation.getName()).trim();
if (!stationName.isEmpty()) {
return stationName;
}
return valueOrEmpty(selectedStation.getCallSign());
}
private String resolveFirstAirPlane(ChatMember selectedStation) {
List<AirPlane> risingAirPlanes = getRisingAirPlanes(selectedStation);
if (risingAirPlanes.isEmpty()) {
return "no ap available";
}
AirPlane firstAirPlane = risingAirPlanes.get(0);
return "a " + firstAirPlane.getPotencialDescriptionAsWord()
+ " in " + firstAirPlane.getArrivingDurationMinutes() + " min";
}
private String resolveSecondAirPlane(ChatMember selectedStation) {
List<AirPlane> risingAirPlanes = getRisingAirPlanes(selectedStation);
if (risingAirPlanes.size() < 2) {
return "";
}
AirPlane secondAirPlane = risingAirPlanes.get(1);
return "Next " + secondAirPlane.getPotencialDescriptionAsWord()
+ " in " + secondAirPlane.getArrivingDurationMinutes() + " min";
}
private List<AirPlane> getRisingAirPlanes(ChatMember selectedStation) {
AirPlaneReflectionInfo reflectionInfo = selectedStation.getAirPlaneReflectInfo();
if (reflectionInfo == null || reflectionInfo.getRisingAirplanes() == null) {
return List.of();
}
return reflectionInfo.getRisingAirplanes();
}
private String abbreviate(String value) {
return value.substring(0, Math.min(value.length(), SHORT_VALUE_LENGTH));
}
private String abbreviateLocator(String locator) {
return locator.substring(0, Math.min(locator.length(), 4));
}
private String formatHeading(double headingDegrees) {
if (!Double.isFinite(headingDegrees)) {
return "";
}
return BigDecimal.valueOf(headingDegrees).stripTrailingZeros().toPlainString();
}
private String valueOrEmpty(String value) {
return value == null ? "" : value;
}
}
@@ -0,0 +1,965 @@
package kst4contest.controller;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Level;
import java.util.logging.Logger;
import jdk.net.ExtendedSocketOptions;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
/**
* Owns the complete lifecycle of the single ON4KST TCP session.
*
* <p>Every reader, writer, queue and parser belongs to an immutable session id.
* A delayed failure from an old socket can therefore never close or consume data
* from its replacement.</p>
*/
final class On4KstConnectionManager {
private static final Logger LOGGER =
Logger.getLogger(On4KstConnectionManager.class.getName());
private static final DateTimeFormatter LIVE_MESSAGE_TIMESTAMP =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
static final int CONNECT_TIMEOUT_MILLIS = 10_000; //TCP-Connect-Timeout
static final long LOGIN_FALLBACK_MILLIS = 2_000L; //Login-Fallback
static final long HANDSHAKE_TIMEOUT_MILLIS = 45_000L; //Handshake-Timeout
static final long CLIENT_LIVENESS_PROBE_AFTER_MILLIS = 90_000L;
static final long INBOUND_STALE_AFTER_MILLIS = 210_000L; //Stale-Timeout - time without rxed data
static final List<Long> RECONNECT_DELAYS_MILLIS =
List.of(2_000L, 5_000L, 10_000L, 20_000L, 30_000L); //Reconnect-Backoff if no connection possible
private final ChatController controller;
private final ScheduledExecutorService scheduler;
private final AtomicLong generation = new AtomicLong();
private final AtomicLong lastReceivedMessageTimestamp = new AtomicLong();
private volatile Session activeSession;
private volatile On4KstConnectionState state =
On4KstConnectionState.DISCONNECTED;
private volatile boolean stopRequested = true;
private int reconnectAttempt;
On4KstConnectionManager(ChatController controller) {
this.controller = controller;
this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "On4KstConnectionSupervisor");
thread.setDaemon(true);
return thread;
});
this.scheduler.scheduleAtFixedRate(
this::monitorActiveSession, 5L, 5L, TimeUnit.SECONDS);
LOGGER.fine("ON4KST connection supervisor initialized");
}
/**
* Returns the last lifecycle state published by the connection supervisor.
*
* @return current immutable connection-state value
*/
On4KstConnectionState getState() {
return state;
}
/**
* Verifies that a callback still belongs to the currently installed session.
*
* <p>Every reconnect receives a new id. Late EOF, write or parser callbacks from
* an obsolete socket therefore become harmless instead of closing the replacement
* connection.</p>
*
* @param sessionId id captured by the calling worker
* @return {@code true} only for the current, open and non-stopped session
*/
boolean isActiveSession(long sessionId) {
Session session = activeSession;
return session != null
&& session.id == sessionId
&& !session.closed
&& !stopRequested;
}
/**
* Starts a non-blocking connection attempt.
*
* <p>Configuration is validated before a socket is opened. A duplicate Connect
* action is ignored while another attempt or session is active. Connection work
* runs on the supervisor executor, so an unreachable server cannot block the
* JavaFX application thread.</p>
*/
void start() {
long token;
synchronized (this) {
if (!stopRequested && state.isConnectionAttemptActive()) {
return;
}
try {
validateConfiguration();
} catch (IllegalArgumentException invalidConfiguration) {
stopRequested = true;
transition(On4KstConnectionState.DISCONNECTED,
"Invalid ON4KST configuration: "
+ invalidConfiguration.getMessage(), true);
return;
}
stopRequested = false;
reconnectAttempt = 0;
token = generation.incrementAndGet();
transition(On4KstConnectionState.CONNECTING,
"Opening ON4KST connection", false);
}
scheduler.execute(() -> openConnection(token));
}
/**
* Stops the supervisor thread of this manager for good.
*
* <p>{@link #stopByUser()} only ends the current ON4KST session; the periodic
* session monitor keeps running. That is correct while the application lives, but a
* manager belonging to a discarded runtime must release its thread, otherwise every
* operator profile switch would leave another supervisor behind holding the dead
* controller.</p>
*/
void shutdown() {
scheduler.shutdownNow();
LOGGER.fine("ON4KST connection supervisor shut down");
}
/**
* Stops the current session and invalidates every scheduled callback or reconnect
* belonging to it.
*/
void stopByUser() {
Session oldSession;
synchronized (this) {
stopRequested = true;
generation.incrementAndGet();
transition(On4KstConnectionState.STOPPING,
"Disconnecting from ON4KST", false);
oldSession = activeSession;
activeSession = null;
}
closeSession(oldSession);
controller.onOn4KstConnectionLost();
transition(On4KstConnectionState.DISCONNECTED,
"Disconnected by user", false);
}
/**
* Records one received protocol line as proof of application-level liveness.
*
* <p>TCP's {@code isConnected()} only states that a connection once succeeded.
* It does not prove that the peer is still reachable. Updating the inbound
* timestamp here gives the monitor a meaningful end-to-end signal.</p>
*
* @param sessionId immutable source-session id
* @param line complete protocol line received from ON4KST
*/
void onInboundActivity(long sessionId, String line) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
long now = System.currentTimeMillis();
session.lastInboundMillis.set(now);
session.lastProgressMillis.set(now);
String opcode = On4KstProtocol.opcode(line);
long probeResponseMillis = session.clientLivenessProbe.acknowledge(now);
if (probeResponseMillis >= 0L) {
LOGGER.log(Level.INFO,
"ON4KST client liveness probe confirmed: session {0}, "
+ "received opcode {1}, response time {2} ms",
new Object[] {
sessionId,
opcode,
probeResponseMillis
});
}
if (On4KstProtocol.isServerLivenessProbe(line)) {
sendServerLivenessProbeResponse(session);
}
if (!session.loginSent
&& line != null
&& line.toLowerCase(Locale.ROOT).contains("login")) {
scheduler.execute(() -> sendLogin(sessionId));
}
if ("CH".equals(opcode) || "CR".equals(opcode)) {
recordHistoryTimestamp(line);
}
}
void onLogstat(long sessionId, String[] fields) {
String[] copy = fields == null ? new String[0] : fields.clone();
scheduler.execute(() -> handleLogstat(sessionId, copy));
}
void stageInitialChatMember(long sessionId, ChatMember member) {
Session session = activeSession;
if (session == null || session.id != sessionId || member == null
|| member.getChatCategory() == null || member.getCallSign() == null) {
return;
}
int category = member.getChatCategory().getCategoryNumber();
session.initialMembers
.computeIfAbsent(category, ignored -> new ConcurrentHashMap<>())
.put(member.getCallSign().trim().toUpperCase(Locale.ROOT), member);
session.lastProgressMillis.set(System.currentTimeMillis());
}
void onInitialUserListCompleted(long sessionId, ChatCategory category) {
if (category == null) {
return;
}
scheduler.execute(() -> completeInitialUserList(
sessionId, category.getCategoryNumber()));
}
private void openConnection(long token) {
if (!mayOpen(token)) {
return;
}
LOGGER.log(Level.INFO,
"Opening ON4KST TCP session {0}", token);
Socket socket = new Socket();
try {
ChatPreferences preferences = controller.getChatPreferences();
socket.connect(new InetSocketAddress(
preferences.getStn_on4kstServersDns(),
preferences.getStn_on4kstServersPort()),
CONNECT_TIMEOUT_MILLIS);
configureSocket(socket);
LOGGER.log(Level.INFO,
"ON4KST TCP session {0} connected to {1}",
new Object[] {token, socket.getRemoteSocketAddress()});
LinkedBlockingQueue<ChatMessage> receiveQueue =
new LinkedBlockingQueue<>();
LinkedBlockingQueue<ChatMessage> transmitQueue =
new LinkedBlockingQueue<>();
int mainCategory = controller.getChatPreferences()
.getLoginChatCategoryMain()
.getCategoryNumber();
Session session = new Session(
token,
socket,
receiveQueue,
transmitQueue);
MessageHistoryRecorder historyRecorder =
controller.getMessageHistoryRecorder();
ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession,
line -> onInboundActivity(token, line),
failure -> onConnectionFailure(token, failure),
historyRecorder);
WriteThread writeThread = new WriteThread(
token, socket, transmitQueue,
mainCategory,
this::isActiveSession,
failure -> onConnectionFailure(token, failure),
controller::onOn4KstOutboundFrameRejected,
historyRecorder);
MessageBusManagementThread messageProcessor =
new MessageBusManagementThread(
controller, controller, token, receiveQueue,
this::isActiveSession);
session.readThread = readThread;
session.writeThread = writeThread;
session.messageProcessor = messageProcessor;
synchronized (this) {
if (!mayOpen(token)) {
closeSession(session);
return;
}
activeSession = session;
controller.installOn4KstSession(
token, socket, receiveQueue, transmitQueue,
readThread, writeThread, messageProcessor);
transition(On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
"TCP connected; waiting for ON4KST login prompt", false);
}
messageProcessor.start();
writeThread.start();
readThread.start();
scheduler.schedule(
() -> sendLogin(token), LOGIN_FALLBACK_MILLIS,
TimeUnit.MILLISECONDS);
} catch (Throwable exception) {
// Errors must be caught as well: an Error escaping here would be
// swallowed by the scheduler and leave the state machine stuck in
// CONNECTING without any reconnect attempt or user visible failure.
try {
socket.close();
} catch (IOException ignored) {
// The original connection exception is more useful.
}
scheduler.execute(() -> handleOpenFailure(token, exception));
}
}
private boolean mayOpen(long token) {
return !stopRequested && generation.get() == token;
}
private void sendLogin(long sessionId) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.loginSent
|| session.closed || stopRequested) {
return;
}
try {
ChatPreferences preferences = controller.getChatPreferences();
int mainCategory = preferences.getLoginChatCategoryMain()
.getCategoryNumber();
long historyFrom = Math.max(
0L, lastReceivedMessageTimestamp.get() - 1L);
String login = On4KstProtocol.login(
preferences.getStn_loginCallSign(),
preferences.getStn_loginPassword(),
mainCategory,
"KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION,
historyFrom);
session.loginSent = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"Sending ON4KST login for session {0}, main category {1}",
new Object[] {sessionId, mainCategory});
transition(On4KstConnectionState.AUTHENTICATING,
"ON4KST login sent", false);
sendControl(session, login);
} catch (IllegalArgumentException invalidConfiguration) {
failPermanently(session,
"Invalid ON4KST login configuration: "
+ invalidConfiguration.getMessage());
}
}
private void handleLogstat(long sessionId, String[] fields) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
String code = fields.length > 1 ? fields[1] : "";
if (!"100".equals(code)) {
String serverText = fields.length > 2 ? fields[2] : "Login rejected";
failPermanently(session,
"ON4KST login rejected (" + code + "): " + serverText);
return;
}
if (session.authenticated) {
return;
}
session.authenticated = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"ON4KST login accepted for session {0}", sessionId);
int mainCategory = controller.getChatPreferences()
.getLoginChatCategoryMain().getCategoryNumber();
transition(On4KstConnectionState.SYNCING_MAIN_CHAT,
"Login accepted; loading main chat", false);
sendControl(session, On4KstProtocol.settingsDone(mainCategory));
}
/**
* Publishes the initial user snapshot for one chat category exactly once.
*
* <p>ON4KST can send further {@code UE} frames after live user updates or
* after commands such as {@code SETNAME} and {@code BACK}. Those frames do
* not announce a new, empty snapshot. Treating them as another initial-list
* completion would remove the already published members because the staging
* map was consumed by the first {@code UE} frame.</p>
*
* <p>The completed-category set is updated before the staging map is removed.
* This makes the operation idempotent even if completion callbacks should
* later be invoked from more than one thread. A genuinely empty initial list
* remains valid: the first {@code UE} for a category is always processed,
* even when no preceding valid {@code UA0} frame was staged.</p>
*
* @param sessionId immutable id of the socket session that received the frame
* @param categoryNumber numeric ON4KST category terminated by {@code UE}
*/
private void completeInitialUserList(long sessionId, int categoryNumber) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
if (!session.completedInitialUserLists.add(categoryNumber)) {
LOGGER.log(Level.FINE,
"ON4KST session {0}: ignoring duplicate user-list end "
+ "marker for category {1}; the initial snapshot "
+ "has already been published",
new Object[] {sessionId, categoryNumber});
return;
}
Map<String, ChatMember> staged =
session.initialMembers.remove(categoryNumber);
Collection<ChatMember> completeMembers = staged == null
? List.of()
: new ArrayList<>(staged.values());
LOGGER.log(Level.INFO,
"ON4KST session {0}: complete user list for category {1} "
+ "contains {2} valid users",
new Object[] {
sessionId,
categoryNumber,
completeMembers.size()
});
controller.replaceActiveChatMembersForCategory(
sessionId,
new ChatCategory(categoryNumber),
completeMembers);
ChatPreferences preferences = controller.getChatPreferences();
int mainCategory =
preferences.getLoginChatCategoryMain().getCategoryNumber();
if (categoryNumber == mainCategory && !session.mainListComplete) {
session.mainListComplete = true;
configureMainChat(session);
if (hasDistinctSecondChat(preferences)) {
int secondCategory =
preferences.getLoginChatCategorySecond()
.getCategoryNumber();
transition(
On4KstConnectionState.SYNCING_SECOND_CHAT,
"Main chat ready; loading second chat",
false);
sendControl(
session,
On4KstProtocol.addChat(
secondCategory,
Math.max(
0L,
lastReceivedMessageTimestamp.get() - 1L)));
} else {
markOnline(session);
}
return;
}
if (hasDistinctSecondChat(preferences)
&& categoryNumber
== preferences.getLoginChatCategorySecond()
.getCategoryNumber()
&& !session.secondListComplete) {
session.secondListComplete = true;
configureSecondChat(session);
markOnline(session);
}
}
private void configureMainChat(Session session) {
ChatPreferences preferences = controller.getChatPreferences();
int category = preferences.getLoginChatCategoryMain().getCategoryNumber();
sendControl(session, On4KstProtocol.setLocator(
category, preferences.getStn_loginLocatorMainCat()));
if (preferences.getStn_loginNameMainCat() != null
&& !preferences.getStn_loginNameMainCat().isBlank()) {
sendControl(session, On4KstProtocol.setName(
category, preferences.getStn_loginNameMainCat()));
}
sendControl(session, On4KstProtocol.back(category));
String secondLocator = preferences.getStn_loginLocatorSecondCat();
String mainLocator = preferences.getStn_loginLocatorMainCat();
if (preferences.isLoginToSecondChatEnabled()
&& secondLocator != null && !secondLocator.isBlank()
&& !secondLocator.equalsIgnoreCase(mainLocator)) {
controller.onOn4KstConnectionWarning(
"ON4KST uses one locator per TCP session. The second-chat locator '"
+ secondLocator + "' is ignored; using '" + mainLocator + "'.");
}
}
private void configureSecondChat(Session session) {
ChatPreferences preferences = controller.getChatPreferences();
int category = preferences.getLoginChatCategorySecond().getCategoryNumber();
if (preferences.getStn_loginNameSecondCat() != null
&& !preferences.getStn_loginNameSecondCat().isBlank()) {
sendControl(session, On4KstProtocol.setName(
category, preferences.getStn_loginNameSecondCat()));
}
sendControl(session, On4KstProtocol.back(category));
}
private void markOnline(Session session) {
if (!isActiveSession(session.id)) {
return;
}
reconnectAttempt = 0;
session.online = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"ON4KST session {0} is authenticated and synchronized",
session.id);
transition(On4KstConnectionState.ONLINE,
"ON4KST session is authenticated and synchronized", false);
controller.onOn4KstConnectionOnline();
}
private void sendControl(Session session, String frame) {
if (session == null || !isActiveSession(session.id)) {
return;
}
ChatMessage message = new ChatMessage();
message.setMessageDirectedToServer(true);
message.setMessageText(frame);
session.transmitQueue.offer(message);
}
private void sendServerLivenessProbeResponse(Session session) {
if (session == null || !isActiveSession(session.id)) {
return;
}
LOGGER.log(Level.FINE,
"Responding to ON4KST server liveness probe: session {0}, "
+ "opcode CK",
session.id);
sendControl(session, On4KstProtocol.serverLivenessProbeResponse());
}
private void sendClientLivenessProbe(
Session session,
long now,
long inboundIdle
) {
if (session == null || !isActiveSession(session.id)
|| !session.clientLivenessProbe.tryStart(now)) {
return;
}
LOGGER.log(Level.INFO,
"Sending ON4KST client liveness probe: session {0}, "
+ "opcode CK, inbound idle {1} seconds",
new Object[] {
session.id,
inboundIdle / 1_000L
});
sendControl(session, On4KstProtocol.clientLivenessProbe());
}
private void onConnectionFailure(long sessionId, Throwable failure) {
scheduler.execute(() -> failSession(sessionId, failure));
}
private void failSession(long sessionId, Throwable failure) {
Session failedSession;
synchronized (this) {
failedSession = activeSession;
if (failedSession == null || failedSession.id != sessionId
|| failedSession.closed) {
return;
}
activeSession = null;
failedSession.closed = true;
}
closeSession(failedSession);
controller.onOn4KstConnectionLost();
if (stopRequested) {
transition(On4KstConnectionState.DISCONNECTED,
"ON4KST connection stopped", false);
return;
}
scheduleReconnect(failure);
}
private void handleOpenFailure(long token, Throwable failure) {
if (!mayOpen(token)) {
return;
}
controller.onOn4KstConnectionLost();
scheduleReconnect(failure);
}
private void scheduleReconnect(Throwable failure) {
if (stopRequested) {
transition(On4KstConnectionState.DISCONNECTED,
"ON4KST connection stopped", false);
return;
}
String reason = describeFailure(failure);
LOGGER.log(Level.WARNING,
"ON4KST connection lost; automatic reconnect scheduled", failure);
long delay = RECONNECT_DELAYS_MILLIS.get(Math.min(
reconnectAttempt, RECONNECT_DELAYS_MILLIS.size() - 1));
reconnectAttempt++;
transition(On4KstConnectionState.RECONNECT_WAIT,
"Connection lost (" + reason + "); reconnecting in "
+ Duration.ofMillis(delay).toSeconds() + " s", true);
long nextToken = generation.incrementAndGet();
scheduler.schedule(() -> {
if (!mayOpen(nextToken)) {
return;
}
transition(On4KstConnectionState.CONNECTING,
"Reconnecting to ON4KST", false);
openConnection(nextToken);
}, delay, TimeUnit.MILLISECONDS);
}
private void failPermanently(Session session, String reason) {
if (session == null || !isActiveSession(session.id)) {
return;
}
stopRequested = true;
generation.incrementAndGet();
activeSession = null;
session.closed = true;
closeSession(session);
controller.onOn4KstConnectionLost();
LOGGER.log(Level.WARNING, reason);
transition(On4KstConnectionState.DISCONNECTED, reason, true);
}
private void monitorActiveSession() {
try {
Session session = activeSession;
if (session == null || session.closed || stopRequested) {
return;
}
if (session.socket.isClosed()) {
failSession(session.id,
new SocketException("Socket is closed"));
return;
}
long now = System.currentTimeMillis();
if (!session.online
&& now - session.lastProgressMillis.get()
> HANDSHAKE_TIMEOUT_MILLIS) {
failSession(session.id,
new SocketException("ON4KST handshake timed out"));
return;
}
long lastInboundMillis = session.lastInboundMillis.get();
long inboundIdle = now - lastInboundMillis;
IdleAction idleAction = determineIdleAction(
inboundIdle,
session.online,
session.clientLivenessProbe.isOutstanding());
if (session.lastInboundMillis.get() != lastInboundMillis) {
return;
}
switch (idleAction) {
case TIMEOUT -> {
if (session.lastInboundMillis.get() != lastInboundMillis) {
return;
}
long probeWaitMillis =
session.clientLivenessProbe.responseWaitMillis(now);
if (probeWaitMillis >= 0L) {
LOGGER.log(Level.WARNING,
"ON4KST client liveness probe timed out: "
+ "session {0}, opcode CK, no response "
+ "for {1} ms, inbound idle {2} seconds; "
+ "reconnecting",
new Object[] {
session.id,
probeWaitMillis,
inboundIdle / 1_000L
});
}
failSession(session.id,
new SocketException("No ON4KST data received for "
+ inboundIdle / 1_000L + " seconds"));
}
case CLIENT_LIVENESS_PROBE ->
sendClientLivenessProbe(session, now, inboundIdle);
case NONE -> {
// The session is active or already has the required idle action.
}
}
} catch (RuntimeException exception) {
LOGGER.log(Level.WARNING,
"ON4KST connection monitor failed", exception);
}
}
/**
* Selects at most one maintenance action for the current inbound idle phase.
*/
static IdleAction determineIdleAction(
long inboundIdleMillis,
boolean online,
boolean probeOutstanding
) {
if (!online) {
return IdleAction.NONE;
}
if (inboundIdleMillis > INBOUND_STALE_AFTER_MILLIS) {
return IdleAction.TIMEOUT;
}
if (inboundIdleMillis > CLIENT_LIVENESS_PROBE_AFTER_MILLIS
&& !probeOutstanding) {
return IdleAction.CLIENT_LIVENESS_PROBE;
}
return IdleAction.NONE;
}
private void validateConfiguration() {
ChatPreferences preferences = controller.getChatPreferences();
On4KstProtocol.login(
preferences.getStn_loginCallSign(),
preferences.getStn_loginPassword(),
preferences.getLoginChatCategoryMain().getCategoryNumber(),
"KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION,
0L);
On4KstProtocol.locator(preferences.getStn_loginLocatorMainCat());
if (preferences.getStn_loginNameMainCat() != null
&& !preferences.getStn_loginNameMainCat().isBlank()) {
On4KstProtocol.field(
preferences.getStn_loginNameMainCat(), "main chat name");
}
if (preferences.isLoginToSecondChatEnabled()) {
if (preferences.getLoginChatCategorySecond() == null) {
throw new IllegalArgumentException("Second chat has no category");
}
On4KstProtocol.category(
preferences.getLoginChatCategorySecond().getCategoryNumber());
if (preferences.getLoginChatCategorySecond().getCategoryNumber()
== preferences.getLoginChatCategoryMain().getCategoryNumber()) {
controller.onOn4KstConnectionWarning(
"Second ON4KST chat equals the main chat and will not be added twice.");
}
if (preferences.getStn_loginNameSecondCat() != null
&& !preferences.getStn_loginNameSecondCat().isBlank()) {
On4KstProtocol.field(
preferences.getStn_loginNameSecondCat(), "second chat name");
}
}
}
private boolean hasDistinctSecondChat(ChatPreferences preferences) {
return preferences.isLoginToSecondChatEnabled()
&& preferences.getLoginChatCategorySecond() != null
&& preferences.getLoginChatCategorySecond().getCategoryNumber()
!= preferences.getLoginChatCategoryMain().getCategoryNumber();
}
private void configureSocket(Socket socket) throws IOException {
socket.setTcpNoDelay(true);
socket.setKeepAlive(true);
try {
socket.setOption(ExtendedSocketOptions.TCP_KEEPIDLE, 45);
socket.setOption(ExtendedSocketOptions.TCP_KEEPINTERVAL, 15);
socket.setOption(ExtendedSocketOptions.TCP_KEEPCOUNT, 3);
} catch (UnsupportedOperationException | IOException | LinkageError exception) {
// LinkageError covers runtime images built without the jdk.net module;
// the connection stays usable, only kernel side keepalive is missing.
LOGGER.log(Level.INFO,
"Platform does not support configurable TCP keepalive; "
+ "application heartbeat remains active", exception);
}
}
private void closeSession(Session session) {
if (session == null) {
return;
}
LOGGER.log(Level.FINE,
"Closing ON4KST session {0}", session.id);
session.closed = true;
if (session.readThread != null) {
session.readThread.interrupt();
}
if (session.writeThread != null) {
session.writeThread.interrupt();
}
if (session.messageProcessor != null) {
session.messageProcessor.interrupt();
}
try {
session.socket.close();
} catch (IOException exception) {
LOGGER.log(Level.FINE, "Error closing obsolete ON4KST socket", exception);
}
}
private void transition(
On4KstConnectionState newState,
String detail,
boolean critical
) {
On4KstConnectionState previousState = state;
state = newState;
Level level = critical
|| newState == On4KstConnectionState.DISCONNECTED
|| newState == On4KstConnectionState.RECONNECT_WAIT
? Level.WARNING : Level.INFO;
LOGGER.log(level,
"ON4KST state {0} -> {1}; detail: {2}",
new Object[] {previousState, newState, detail});
controller.updateOn4KstConnectionState(newState, detail, critical);
}
private void recordHistoryTimestamp(String line) {
long timestamp = parseMessageTimestamp(line);
if (timestamp > 0L) {
lastReceivedMessageTimestamp.accumulateAndGet(timestamp, Math::max);
}
}
static long parseMessageTimestamp(String line) {
String[] fields = line == null ? new String[0] : line.split("\\|", -1);
if (fields.length < 3) {
return 0L;
}
try {
long numeric = Long.parseLong(fields[2]);
long now = System.currentTimeMillis() / 1_000L;
if (numeric > 0L && numeric <= now + 86_400L) {
return numeric;
}
} catch (NumberFormatException ignored) {
return 0L;
}
try {
return LocalDateTime.parse(fields[2], LIVE_MESSAGE_TIMESTAMP)
.toEpochSecond(ZoneOffset.UTC);
} catch (DateTimeParseException ignored) {
return 0L;
}
}
private String describeFailure(Throwable failure) {
if (failure == null) {
return "unknown error";
}
String message = failure.getMessage();
return message == null || message.isBlank()
? failure.getClass().getSimpleName() : message;
}
private static final class Session {
private final Set<Integer> completedInitialUserLists =
ConcurrentHashMap.newKeySet();
private final long id;
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
private final long connectedMillis = System.currentTimeMillis();
private final AtomicLong lastInboundMillis =
new AtomicLong(connectedMillis);
private final AtomicLong lastProgressMillis =
new AtomicLong(connectedMillis);
private final ClientLivenessProbeState clientLivenessProbe =
new ClientLivenessProbeState();
private final Map<Integer, Map<String, ChatMember>> initialMembers =
new ConcurrentHashMap<>();
private volatile ReadThread readThread;
private volatile WriteThread writeThread;
private volatile MessageBusManagementThread messageProcessor;
private volatile boolean loginSent;
private volatile boolean authenticated;
private volatile boolean mainListComplete;
private volatile boolean secondListComplete;
private volatile boolean online;
private volatile boolean closed;
private Session(
long id,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LinkedBlockingQueue<ChatMessage> transmitQueue
) {
this.id = id;
this.socket = socket;
this.receiveQueue = receiveQueue;
this.transmitQueue = transmitQueue;
}
}
/** Maintenance action selected by the session monitor. */
enum IdleAction {
NONE,
CLIENT_LIVENESS_PROBE,
TIMEOUT
}
/** Tracks one client-initiated liveness probe for the complete TCP session. */
static final class ClientLivenessProbeState {
private final AtomicLong sentMillis = new AtomicLong();
boolean tryStart(long now) {
return now > 0L && sentMillis.compareAndSet(0L, now);
}
long acknowledge(long now) {
long sent = sentMillis.getAndSet(0L);
return sent == 0L ? -1L : Math.max(0L, now - sent);
}
boolean isOutstanding() {
return sentMillis.get() > 0L;
}
long responseWaitMillis(long now) {
long sent = sentMillis.get();
return sent == 0L ? -1L : Math.max(0L, now - sent);
}
}
}
@@ -0,0 +1,50 @@
package kst4contest.controller;
/**
* Observable lifecycle of the ON4KST TCP session.
*
* <p>A connected TCP socket is deliberately not synonymous with an authenticated
* chat session. The intermediate states make that distinction visible to the UI
* and prevent application messages from being sent in the wrong protocol context.</p>
*/
public enum On4KstConnectionState {
DISCONNECTED,
CONNECTING,
WAITING_FOR_LOGIN_PROMPT,
AUTHENTICATING,
SYNCING_MAIN_CHAT,
SYNCING_SECOND_CHAT,
ONLINE,
RECONNECT_WAIT,
STOPPING;
/**
* Returns whether the complete application-level ON4KST handshake has finished.
*
* @return {@code true} only after authentication and all requested user lists
* have been synchronized
*/
public boolean isOnline() {
return this == ONLINE;
}
/**
* Returns whether a connection attempt or usable session is currently owned by
* the connection manager.
*
* <p>This is intentionally broader than {@link #isOnline()}. The UI uses it to
* prevent a second Connect action while authentication, synchronization or a
* scheduled reconnect is already in progress.</p>
*
* @return {@code true} while connecting, synchronizing, online or waiting for
* an automatic reconnect
*/
public boolean isConnectionAttemptActive() {
return switch (this) {
case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING,
SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT, ONLINE,
RECONNECT_WAIT -> true;
default -> false;
};
}
}
@@ -0,0 +1,259 @@
package kst4contest.controller;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Builds ON4KST port-23001 frames and rejects values that could break framing or
* put the server into an invalid chat context.
*
* <p>All outbound protocol construction is concentrated here. User-controlled
* values may therefore never introduce a field separator or a second line, and
* category and locator validation happens before the frame reaches the socket.</p>
*/
final class On4KstProtocol {
private static final Pattern LOCATOR_6 =
Pattern.compile("^[A-Ra-r]{2}[0-9]{2}[A-Xa-x]{2}$");
private On4KstProtocol() {
}
/**
* Builds the initial authenticated login frame.
*
* @param callsign login callsign
* @param password ON4KST password; never logged by this class
* @param category primary chat category
* @param clientName client identification sent to the server
* @param lastMessageTimestamp earliest history timestamp to request
* @return validated frame without CR/LF terminator
*/
static String login(
String callsign,
String password,
int category,
String clientName,
long lastMessageTimestamp
) {
return "LOGINC|" + field(callsign, "callsign")
+ "|" + password(password)
+ "|" + category(category)
+ "|" + field(clientName, "client name")
+ "|25|0|1|" + Math.max(0L, lastMessageTimestamp) + "|0|";
}
/**
* Redacts credentials from a raw login frame while preserving its structure.
* All non-login frames are returned byte-for-character unchanged.
*/
static String redactSecretsForRecording(String frame) {
if (frame == null || !("LOGINC".equals(opcode(frame))
|| "LOGIN".equals(opcode(frame)))) {
return frame;
}
int opcodeSeparator = frame.indexOf('|');
if (opcodeSeparator < 0) {
return frame;
}
int callsignSeparator = frame.indexOf('|', opcodeSeparator + 1);
if (callsignSeparator < 0) {
return frame.substring(0, opcodeSeparator + 1) + "<REDACTED>";
}
int passwordSeparator = frame.indexOf('|', callsignSeparator + 1);
if (passwordSeparator < 0) {
return frame.substring(0, callsignSeparator + 1) + "<REDACTED>";
}
return frame.substring(0, callsignSeparator + 1)
+ "<REDACTED>"
+ frame.substring(passwordSeparator);
}
/** Builds the settings-complete frame for the supplied chat category. */
static String settingsDone(int category) {
return "SDONE|" + category(category) + "|";
}
/** Builds the frame used to add a distinct second chat to the same session. */
static String addChat(int category, long lastMessageTimestamp) {
return "ACHAT|" + category(category)
+ "|25|10|2|" + Math.max(0L, lastMessageTimestamp)
+ "|0|";
}
/** Builds the session-wide liveness probe initiated by this client. */
static String clientLivenessProbe() {
return "CK|";
}
/** Returns whether a server frame acknowledges a client-initiated probe. */
static boolean isClientLivenessProbeResponse(String frame) {
if (frame == null) {
return false;
}
String normalized = frame.trim().toUpperCase(Locale.ROOT);
return "OK".equals(normalized) || "OK|".equals(normalized);
}
/** Returns whether ON4KST initiated its own liveness check. */
static boolean isServerLivenessProbe(String frame) {
return "CK".equals(opcode(frame));
}
/** Builds the established empty response to a server-initiated {@code CK}. */
static String serverLivenessProbeResponse() {
return "";
}
/** Preserves the existing internal handling of DXQ server data. */
static boolean isInternalDxqResponse(String frame) {
return "DXQ".equals(opcode(frame));
}
/** Extracts and normalizes the opcode without exposing the remaining frame. */
static String opcode(String frame) {
if (frame == null) {
return "";
}
int separator = frame.indexOf('|');
return (separator < 0 ? frame : frame.substring(0, separator))
.trim()
.toUpperCase(Locale.ROOT);
}
/** Builds a category-qualified locator command after validating Maidenhead syntax. */
static String setLocator(int category, String locator) {
return command(category, "/SETLOC " + locator(locator));
}
/** Builds a category-qualified chat-name command. */
static String setName(int category, String name) {
return command(category, "/SETNAME " + field(name, "chat name"));
}
/** Builds the command that changes the operator state back to available. */
static String back(int category) {
return command(category, "/BACK");
}
/**
* Wraps one validated slash command in an ON4KST message frame.
*
* @return frame without CR/LF terminator
*/
static String command(int category, String command) {
return "MSG|" + category(category) + "|0|"
+ messageText(command) + "|0|";
}
/**
* Wraps one operator chat message in a category-qualified ON4KST frame.
*
* @return frame without CR/LF terminator
*/
static String chatMessage(int category, String text) {
return "MSG|" + category(category) + "|0|"
+ messageText(text) + "|0|";
}
/**
* Removes trailing line terminators from a legacy raw frame while rejecting an
* embedded line break that could inject a second server command.
*
* @param frame legacy raw frame, possibly with trailing CR/LF
* @return exactly one normalized protocol line
* @throws IllegalArgumentException if the value is {@code null} or contains an
* embedded line break
*/
static String normalizeRawFrame(String frame) {
if (frame == null) {
throw new IllegalArgumentException("ON4KST frame must not be null");
}
int end = frame.length();
while (end > 0) {
char last = frame.charAt(end - 1);
if (last != '\r' && last != '\n') {
break;
}
end--;
}
String normalized = frame.substring(0, end);
if (normalized.indexOf('\r') >= 0 || normalized.indexOf('\n') >= 0) {
throw new IllegalArgumentException(
"ON4KST frame contains an embedded line break");
}
return normalized;
}
/**
* Validates and normalizes a six-character Maidenhead locator.
*
* @return upper-case locator
*/
static String locator(String locator) {
String normalized = field(locator, "locator").toUpperCase(Locale.ROOT);
if (!LOCATOR_6.matcher(normalized).matches()) {
throw new IllegalArgumentException(
"Locator must be a six-character Maidenhead locator: " + normalized);
}
return normalized;
}
/** Rejects message text containing an ON4KST field or line delimiter. */
static String messageText(String text) {
String value = field(text, "message text");
if (value.indexOf('|') >= 0) {
throw new IllegalArgumentException(
"Message text contains the ON4KST field separator '|'");
}
return value;
}
/**
* Validates one required, non-password protocol field.
*
* @param value field value
* @param label diagnostic label used in validation errors
* @return trimmed value
*/
static String field(String value, String label) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(label + " must not be empty");
}
if (value.indexOf('|') >= 0
|| value.indexOf('\r') >= 0
|| value.indexOf('\n') >= 0) {
throw new IllegalArgumentException(
label + " contains an ON4KST frame delimiter");
}
return value.trim();
}
private static String password(String value) {
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException("password must not be empty");
}
if (value.indexOf('|') >= 0
|| value.indexOf('\r') >= 0
|| value.indexOf('\n') >= 0) {
throw new IllegalArgumentException(
"password contains an ON4KST frame delimiter");
}
return value;
}
/**
* Validates the category range supported by ON4KST.
*
* @return the unchanged category for convenient inline use
*/
static int category(int category) {
if (category < 1 || category > 12) {
throw new IllegalArgumentException(
"Unsupported ON4KST chat category: " + category);
}
return category;
}
}
@@ -0,0 +1,142 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import kst4contest.model.ChatPreferences;
class On4KstProtocolTest {
@Test
void buildsLoginWithReplayOverlap() {
assertEquals(
"LOGINC|DL1ABC|secret|2|KST4Contest v1.2.3|25|0|1|12344|0|",
On4KstProtocol.login(
"DL1ABC", "secret", 2,
"KST4Contest v1.2.3", 12_344L));
}
@Test
void buildsContextSafeSecondChatFrames() {
assertEquals("SDONE|2|", On4KstProtocol.settingsDone(2));
assertEquals("ACHAT|3|25|10|2|100|0|",
On4KstProtocol.addChat(3, 100L));
assertEquals("MSG|2|0|/SETLOC JO31AA|0|",
On4KstProtocol.setLocator(2, "jo31aa"));
assertEquals("MSG|3|0|/SETNAME 10G 10368.200|0|",
On4KstProtocol.setName(3, "10G 10368.200"));
}
@Test
void stripsOnlyTrailingLineEndings() {
assertEquals("CK|", On4KstProtocol.normalizeRawFrame("CK|\r\n"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.normalizeRawFrame("CK|\rBROKEN"));
}
@Test
void rejectsValuesThatCouldCreateASecondProtocolFrame() {
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.chatMessage(2, "hello|0|"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.chatMessage(2, "hello\r\nQUIT|"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.login(
"DL1ABC", "bad|password", 2, "client", 0L));
}
@Test
void rejectsInvalidLocatorAndCategoryBeforeTheyReachTheServer() {
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.setLocator(2, "JO31"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.settingsDone(99));
}
@Test
void convertsBothHistoryAndLiveMessageTimestampsForReconnect() {
assertEquals(1_186_819_108L,
On4KstConnectionManager.parseMessageTimestamp(
"CR|2|1186819108|EA6VQ|Gabriel|0|msg|0|"));
assertEquals(
LocalDateTime.of(2026, 8, 13, 12, 34, 56)
.toEpochSecond(ZoneOffset.UTC),
On4KstConnectionManager.parseMessageTimestamp(
"CH|2|20260813123456|DL1ABC|Op|0|msg|0|"));
}
@Test
void resolvesBeaconVariablesBeforeApplyingProtocolValidation() {
ChatPreferences preferences = new ChatPreferences();
preferences.setMYQRGFirstCat("144.300");
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
controller.validateBeaconTemplate(
"calling cq at MYQRG"
);
assertEquals(
"calling cq at 144.300",
controller.resolveAndValidateBeaconText(
"calling cq at MYQRG"
)
);
}
@Test
void acceptsTemporarilyUnresolvedVariableOnlyBeaconTemplate() {
ChatPreferences preferences = new ChatPreferences();
preferences.setMYQRGFirstCat("");
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
assertDoesNotThrow(
() -> controller.validateBeaconTemplate("MYQRG")
);
assertThrows(
IllegalArgumentException.class,
() -> controller.resolveAndValidateBeaconText("MYQRG")
);
}
@Test
void rejectsEmptyOverlongAndProtocolBreakingBeaconText() {
ChatPreferences preferences = new ChatPreferences();
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate(" ")
);
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate(
"cq at 144.300|0|QUIT"
)
);
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate(
"x".repeat(
ChatController.MAX_BEACON_TEXT_LENGTH
+ 1
)
)
);
}
}
@@ -0,0 +1,100 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.BufferedReader;
import java.io.OutputStreamWriter;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import kst4contest.model.ChatMessage;
class On4KstSocketThreadTest {
@Test
@Timeout(5)
void eofIsReportedImmediately() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<Void> serverDone = CompletableFuture.runAsync(() -> {
try (Socket accepted = server.accept();
OutputStreamWriter out = new OutputStreamWriter(
accepted.getOutputStream(), StandardCharsets.UTF_8)) {
out.write("CK|\r\n");
out.flush();
} catch (Exception exception) {
throw new RuntimeException(exception);
}
});
try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
LinkedBlockingQueue<ChatMessage> queue = new LinkedBlockingQueue<>();
AtomicBoolean active = new AtomicBoolean(true);
CompletableFuture<Throwable> failure = new CompletableFuture<>();
ReadThread reader = new ReadThread(
7L, client, queue, ignored -> active.get(), ignored -> { },
failure::complete);
reader.start();
assertEquals("CK|", queue.poll(2, TimeUnit.SECONDS).getMessageText());
failure.get(2, TimeUnit.SECONDS);
active.set(false);
reader.join(Duration.ofSeconds(2).toMillis());
}
serverDone.get(2, TimeUnit.SECONDS);
}
}
@Test
@Timeout(5)
void writerUsesOneExactCrLfPerFrameIncludingHeartbeat() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<String> firstLine = new CompletableFuture<>();
CompletableFuture<String> secondLine = new CompletableFuture<>();
CompletableFuture<Void> serverDone = CompletableFuture.runAsync(() -> {
try (Socket accepted = server.accept();
BufferedReader in = new BufferedReader(new InputStreamReader(
accepted.getInputStream(), StandardCharsets.UTF_8))) {
firstLine.complete(in.readLine());
secondLine.complete(in.readLine());
} catch (Exception exception) {
throw new RuntimeException(exception);
}
});
try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
LinkedBlockingQueue<ChatMessage> queue = new LinkedBlockingQueue<>();
AtomicBoolean active = new AtomicBoolean(true);
WriteThread writer = new WriteThread(
11L, client, queue, 2, ignored -> active.get(),
ignored -> { }, ignored -> { });
writer.start();
queue.add(serverFrame(""));
queue.add(serverFrame("SDONE|2|\r"));
assertEquals("", firstLine.get(2, TimeUnit.SECONDS));
assertEquals("SDONE|2|", secondLine.get(2, TimeUnit.SECONDS));
active.set(false);
writer.interrupt();
writer.join(Duration.ofSeconds(2).toMillis());
}
serverDone.get(2, TimeUnit.SECONDS);
}
}
private ChatMessage serverFrame(String text) {
ChatMessage message = new ChatMessage();
message.setMessageDirectedToServer(true);
message.setMessageText(text);
return message;
}
}
@@ -0,0 +1,312 @@
package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatPreferences;
import kst4contest.model.OperatorProfile;
import kst4contest.utils.ApplicationFileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;
/**
* Creates, renames, duplicates and removes operator profiles.
*
* <p>Kept free of user interface code so the behaviour can be tested without a JavaFX
* runtime. All methods work on the registry and on the files below the application
* directory.</p>
*/
public class OperatorProfileManagementService {
private static final Logger LOGGER =
Logger.getLogger(OperatorProfileManagementService.class.getName());
private final OperatorProfileStore profileStore;
public OperatorProfileManagementService() {
this(new OperatorProfileStore());
}
public OperatorProfileManagementService(final OperatorProfileStore profileStore) {
this.profileStore = profileStore;
}
/**
* Lists all profiles, including the implicit root profile of a plain installation.
*
* @return the known profiles, never empty
*/
public List<OperatorProfile> listProfiles() {
List<OperatorProfile> knownProfiles = profileStore.loadProfiles();
if (knownProfiles.isEmpty()) {
knownProfiles = new ArrayList<>();
knownProfiles.add(profileStore.buildImplicitRootProfile());
}
return knownProfiles;
}
/**
* Creates a new operator profile with its own preferences file.
*
* <p>Creating the first additional profile is also the moment the registry appears:
* the root profile is written alongside, so both are selectable afterwards.</p>
*
* @param displayName name entered by the operator
* @param sharedWorkedDatabase true to use the common station worked database
* @return the created profile, or null when it could not be stored
*/
public OperatorProfile createProfile(final String displayName, final boolean sharedWorkedDatabase) {
List<OperatorProfile> knownProfiles = listProfiles();
Set<String> takenProfileIds = new LinkedHashSet<>();
for (OperatorProfile existingProfile : knownProfiles) {
takenProfileIds.add(existingProfile.getProfileId());
}
OperatorProfile createdProfile = new OperatorProfile(
OperatorProfilePaths.toProfileId(displayName, takenProfileIds),
displayName == null || displayName.isBlank() ? "New profile" : displayName.trim(),
false,
sharedWorkedDatabase);
knownProfiles.add(createdProfile);
if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) {
return null;
}
createPreferencesFile(createdProfile, null);
return createdProfile;
}
/**
* Creates a copy of an existing profile.
*
* <p>The preferences are taken over completely except for the login credentials:
* callsign and password are cleared on purpose, because a duplicate is meant for
* another operator. Antenna, locator, layout and integration settings are exactly
* what the operator does not want to enter twice.</p>
*
* <p>The worked-station database is never copied.</p>
*
* @param sourceProfile profile to copy
* @param displayName name of the new profile
* @return the created profile, or null when it could not be stored
*/
public OperatorProfile duplicateProfile(final OperatorProfile sourceProfile, final String displayName) {
if (sourceProfile == null) {
return null;
}
List<OperatorProfile> knownProfiles = listProfiles();
Set<String> takenProfileIds = new LinkedHashSet<>();
for (OperatorProfile existingProfile : knownProfiles) {
takenProfileIds.add(existingProfile.getProfileId());
}
OperatorProfile createdProfile = new OperatorProfile(
OperatorProfilePaths.toProfileId(displayName, takenProfileIds),
displayName == null || displayName.isBlank() ? "Copy" : displayName.trim(),
false,
sourceProfile.isSharedWorkedDatabase());
knownProfiles.add(createdProfile);
if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) {
return null;
}
createPreferencesFile(createdProfile, sourceProfile);
return createdProfile;
}
/**
* Changes the visible name of a profile. The identifier and all paths stay as they are.
*
* @param profile profile to rename
* @param newDisplayName new name
* @return true if the registry was updated
*/
public boolean renameProfile(final OperatorProfile profile, final String newDisplayName) {
if (profile == null || newDisplayName == null || newDisplayName.isBlank()) {
return false;
}
List<OperatorProfile> knownProfiles = listProfiles();
for (OperatorProfile currentProfile : knownProfiles) {
if (currentProfile.getProfileId().equals(profile.getProfileId())) {
currentProfile.setDisplayName(newDisplayName.trim());
}
}
return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null));
}
/**
* Switches a profile between the common station database and its own one.
*
* @param profile profile to change
* @param sharedWorkedDatabase true to use the common station worked database
* @return true if the registry was updated
*/
public boolean setSharedWorkedDatabase(final OperatorProfile profile, final boolean sharedWorkedDatabase) {
if (profile == null || profile.isRootProfile()) {
return false;
}
List<OperatorProfile> knownProfiles = listProfiles();
for (OperatorProfile currentProfile : knownProfiles) {
if (currentProfile.getProfileId().equals(profile.getProfileId())) {
currentProfile.setSharedWorkedDatabase(sharedWorkedDatabase);
}
}
return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null));
}
/**
* Removes a profile and its directory.
*
* <p>The root profile can never be removed, because its files are the installation
* itself. A profile using the common station database keeps that database untouched;
* only its own directory is deleted.</p>
*
* @param profile profile to remove
* @return true if the profile was removed
*/
public boolean deleteProfile(final OperatorProfile profile) {
if (profile == null || profile.isRootProfile()) {
return false;
}
List<OperatorProfile> remainingProfiles = new ArrayList<>();
for (OperatorProfile currentProfile : listProfiles()) {
if (!currentProfile.getProfileId().equals(profile.getProfileId())) {
remainingProfiles.add(currentProfile);
}
}
if (!profileStore.saveProfiles(remainingProfiles,
profileStore.loadLastUsedProfileId().orElse(null))) {
return false;
}
deleteProfileDirectory(profile);
return true;
}
/**
* Returns the absolute directory of a profile.
*
* @param profile profile to resolve
* @return absolute profile directory
*/
public String getProfileDirectory(final OperatorProfile profile) {
return ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME,
OperatorProfilePaths.profileRelativeDirectory(profile));
}
/**
* Creates the preferences file of a new profile.
*
* <p>The file is either seeded from the bundled template or copied from the source
* profile. In both cases the login credentials are cleared, so a new profile never
* carries another operator's callsign or password.</p>
*
* @param createdProfile profile that needs a preferences file
* @param sourceProfile profile to copy the preferences from, or null for the template
*/
private void createPreferencesFile(final OperatorProfile createdProfile,
final OperatorProfile sourceProfile) {
String createdRelativeFileName = OperatorProfilePaths.preferencesRelativeFileName(createdProfile);
if (sourceProfile != null) {
copyPreferencesFile(
OperatorProfilePaths.preferencesRelativeFileName(sourceProfile),
createdRelativeFileName);
}
// Seeds from the bundled template when nothing was copied, and always resolves
// the preferences of the new profile.
ChatPreferences createdPreferences = new ChatPreferences(createdRelativeFileName);
createdPreferences.readPreferencesFromXmlFile();
createdPreferences.setStn_loginCallSign("");
createdPreferences.setStn_loginPassword("");
createdPreferences.writePreferencesToXmlFile();
}
private void copyPreferencesFile(final String sourceRelativeFileName,
final String targetRelativeFileName) {
Path sourcePath = Path.of(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, sourceRelativeFileName));
Path targetPath = Path.of(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, targetRelativeFileName));
if (!Files.isRegularFile(sourcePath)) {
return;
}
try {
Path targetDirectory = targetPath.getParent();
if (targetDirectory != null) {
Files.createDirectories(targetDirectory);
}
Files.copy(sourcePath, targetPath);
} catch (IOException e) {
LOGGER.log(Level.WARNING,
"Could not copy the preferences of the source profile, using the defaults instead", e);
}
}
private void deleteProfileDirectory(final OperatorProfile profile) {
Path profileDirectory = Path.of(getProfileDirectory(profile));
if (!Files.isDirectory(profileDirectory)) {
return;
}
try (Stream<Path> containedPaths = Files.walk(profileDirectory)) {
List<Path> deepestFirst = containedPaths
.sorted(Comparator.reverseOrder())
.toList();
for (Path currentPath : deepestFirst) {
Files.deleteIfExists(currentPath);
}
} catch (IOException e) {
LOGGER.log(Level.WARNING,
"Could not remove the directory of the deleted operator profile", e);
}
}
}
@@ -0,0 +1,199 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import java.util.Collection;
import java.util.Locale;
/**
* Derives the file names of an operator profile.
*
* <p>This is the single place that knows how a profile maps onto files. The registry
* stores only the shared/own flag, never a path, so the two can never drift apart.</p>
*/
public final class OperatorProfilePaths {
/**
* Directory below the application directory that holds the additional profiles.
*/
public static final String PROFILES_DIRECTORY = "profiles";
/**
* Identifier of the profile that uses the historic flat installation layout.
*/
public static final String ROOT_PROFILE_ID = "default";
/**
* Maximum length of a generated profile identifier.
*/
private static final int MAX_PROFILE_ID_LENGTH = 32;
private OperatorProfilePaths() {
// Utility class.
}
/**
* Builds the profile descriptor of the historic flat installation.
*
* @param displayName name to show for the root profile
* @return the root profile descriptor
*/
public static OperatorProfile buildRootProfile(final String displayName) {
return new OperatorProfile(ROOT_PROFILE_ID, displayName, true, true);
}
/**
* Returns the profile directory relative to the application directory.
*
* @param profile profile to resolve
* @return relative directory name
*/
public static String profileRelativeDirectory(final OperatorProfile profile) {
return PROFILES_DIRECTORY + "/" + profile.getProfileId();
}
/**
* Returns the preferences file name relative to the application directory.
*
* @param profile profile to resolve
* @return relative preferences file name
*/
public static String preferencesRelativeFileName(final OperatorProfile profile) {
if (profile.isRootProfile()) {
return ChatPreferences.PREFERENCES_FILE;
}
return profileRelativeDirectory(profile) + "/" + ChatPreferences.PREFERENCES_FILE;
}
/**
* Returns the worked-station database file name relative to the application directory.
*
* <p>A profile using the common station database always resolves to the historic flat
* file, which is what a multi operator station wants: the existing contest state stays
* the shared one.</p>
*
* @param profile profile to resolve
* @return relative database file name
*/
public static String workedDatabaseRelativeFileName(final OperatorProfile profile) {
if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
return DBController.DATABASE_FILE;
}
return profileRelativeDirectory(profile) + "/" + DBController.DATABASE_FILE;
}
/**
* Resolves a profile descriptor into the runtime selection used during startup.
*
* @param profile profile to resolve
* @return resolved selection
*/
public static OperatorProfileSelection resolve(final OperatorProfile profile) {
boolean usesSharedStationDatabase = profile.isRootProfile() || profile.isSharedWorkedDatabase();
return new OperatorProfileSelection(
profile,
preferencesRelativeFileName(profile),
workedDatabaseRelativeFileName(profile),
usesSharedStationDatabase
);
}
/**
* Derives a stable, file system safe identifier from a display name.
*
* <p>The identifier becomes a directory name and is never changed afterwards, so a
* later rename of the profile does not move any file.</p>
*
* @param displayName name entered by the operator
* @param takenProfileIds identifiers that are already in use
* @return an identifier that is not yet taken
*/
public static String toProfileId(final String displayName, final Collection<String> takenProfileIds) {
StringBuilder sanitized = new StringBuilder();
if (displayName != null) {
String foldedDisplayName = foldGermanUmlauts(displayName.toUpperCase(Locale.ROOT));
for (char currentCharacter : foldedDisplayName.toCharArray()) {
boolean isAcceptable = (currentCharacter >= 'A' && currentCharacter <= 'Z')
|| (currentCharacter >= '0' && currentCharacter <= '9')
|| currentCharacter == '-';
if (isAcceptable) {
sanitized.append(currentCharacter);
} else if (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) != '_') {
sanitized.append('_');
}
}
}
while (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) == '_') {
sanitized.setLength(sanitized.length() - 1);
}
if (sanitized.length() > MAX_PROFILE_ID_LENGTH) {
sanitized.setLength(MAX_PROFILE_ID_LENGTH);
}
String candidate = sanitized.toString();
if (candidate.isEmpty() || ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) {
candidate = "OP";
}
if (!isProfileIdTaken(candidate, takenProfileIds)) {
return candidate;
}
int suffix = 2;
while (isProfileIdTaken(candidate + "_" + suffix, takenProfileIds)) {
suffix++;
}
return candidate + "_" + suffix;
}
/**
* Folds German umlauts so a name like "Muller" written with an umlaut still produces a
* readable identifier instead of a placeholder character.
*
* @param upperCaseText already upper-cased text
* @return text with umlauts replaced by their base letters
*/
private static String foldGermanUmlauts(final String upperCaseText) {
return upperCaseText
.replace("\u00C4", "A")
.replace("\u00D6", "O")
.replace("\u00DC", "U")
.replace("\u00DF", "SS");
}
private static boolean isProfileIdTaken(final String candidate, final Collection<String> takenProfileIds) {
if (ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) {
return true;
}
if (takenProfileIds == null) {
return false;
}
for (String takenProfileId : takenProfileIds) {
if (candidate.equalsIgnoreCase(takenProfileId)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,386 @@
package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.model.OperatorProfile;
import kst4contest.utils.ApplicationFileUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.File;
import java.io.OutputStream;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Reads and writes the operator profile registry.
*
* <p>The registry file is created lazily. As long as an installation has only the
* historic flat layout, no registry exists and none is written, so a single operator
* installation behaves exactly as before. The file appears when the second profile is
* created; at that moment the root profile is materialised as well.</p>
*
* <p>A missing, unreadable or malformed registry is never fatal. It is logged and
* treated like an installation without additional profiles.</p>
*/
public class OperatorProfileStore {
private static final Logger LOGGER = Logger.getLogger(OperatorProfileStore.class.getName());
/**
* Name of the registry file inside the application directory.
*/
public static final String PROFILES_REGISTRY_FILE = "profiles.xml";
private static final String TAG_ROOT = "praktiKSTProfiles";
private static final String TAG_REGISTRY_VERSION = "registryVersion";
private static final String TAG_LAST_USED_PROFILE_ID = "lastUsedProfileId";
private static final String TAG_PROFILE = "profile";
private static final String TAG_PROFILE_ID = "profileId";
private static final String TAG_DISPLAY_NAME = "displayName";
private static final String TAG_ROOT_PROFILE = "rootProfile";
private static final String TAG_SHARED_WORKED_DATABASE = "sharedWorkedDatabase";
private static final String TAG_LAST_USED_EPOCH_MS = "lastUsedEpochMs";
private static final int REGISTRY_VERSION = 1;
private final String registryFilePath;
/**
* Creates a store working on the registry of the current installation.
*/
public OperatorProfileStore() {
this(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, PROFILES_REGISTRY_FILE));
}
/**
* Creates a store working on an explicit registry file.
*
* @param registryFilePath absolute path of the registry file
*/
public OperatorProfileStore(final String registryFilePath) {
this.registryFilePath = registryFilePath;
}
/**
* Returns whether a registry file exists at all.
*
* @return true if the installation already has more than the historic flat layout
*/
public boolean isRegistryPresent() {
return new File(registryFilePath).isFile();
}
/**
* Builds the in-memory descriptor of the historic flat installation.
*
* <p>Nothing is written. This keeps a single operator installation untouched.</p>
*
* @return the implicit root profile
*/
public OperatorProfile buildImplicitRootProfile() {
return OperatorProfilePaths.buildRootProfile("Default");
}
/**
* Reads all stored profiles.
*
* @return the stored profiles, or an empty list when no usable registry exists
*/
public List<OperatorProfile> loadProfiles() {
List<OperatorProfile> loadedProfiles = new ArrayList<>();
Document document = readRegistryDocument();
if (document == null) {
return loadedProfiles;
}
NodeList profileNodes = document.getElementsByTagName(TAG_PROFILE);
for (int profileIndex = 0; profileIndex < profileNodes.getLength(); profileIndex++) {
Node currentNode = profileNodes.item(profileIndex);
if (currentNode.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
Element profileElement = (Element) currentNode;
String profileId = readText(profileElement, TAG_PROFILE_ID);
if (profileId == null || profileId.isBlank()) {
LOGGER.log(Level.WARNING, "Skipping operator profile entry without an identifier");
continue;
}
OperatorProfile loadedProfile = new OperatorProfile();
loadedProfile.setProfileId(profileId.trim());
loadedProfile.setDisplayName(readText(profileElement, TAG_DISPLAY_NAME));
loadedProfile.setRootProfile(readBoolean(profileElement, TAG_ROOT_PROFILE, false));
loadedProfile.setSharedWorkedDatabase(
readBoolean(profileElement, TAG_SHARED_WORKED_DATABASE, true));
loadedProfile.setLastUsedEpochMs(readLong(profileElement, TAG_LAST_USED_EPOCH_MS));
if (loadedProfile.getDisplayName() == null || loadedProfile.getDisplayName().isBlank()) {
loadedProfile.setDisplayName(loadedProfile.getProfileId());
}
// The root profile always uses the common station database, because its
// database is the historic flat file itself.
if (loadedProfile.isRootProfile()) {
loadedProfile.setSharedWorkedDatabase(true);
}
loadedProfiles.add(loadedProfile);
}
return loadedProfiles;
}
/**
* Reads the identifier of the profile that was activated last.
*
* @return the identifier, or empty when unknown
*/
public Optional<String> loadLastUsedProfileId() {
Document document = readRegistryDocument();
if (document == null) {
return Optional.empty();
}
Element rootElement = document.getDocumentElement();
if (rootElement == null) {
return Optional.empty();
}
String lastUsedProfileId = readText(rootElement, TAG_LAST_USED_PROFILE_ID);
if (lastUsedProfileId == null || lastUsedProfileId.isBlank()) {
return Optional.empty();
}
return Optional.of(lastUsedProfileId.trim());
}
/**
* Writes the complete registry.
*
* @param profiles profiles to store
* @param lastUsedProfileId identifier of the profile that was activated last, may be null
* @return true if the registry was written
*/
public boolean saveProfiles(final List<OperatorProfile> profiles, final String lastUsedProfileId) {
try {
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
Document document = documentBuilder.newDocument();
Element rootElement = document.createElement(TAG_ROOT);
document.appendChild(rootElement);
appendTextElement(document, rootElement, TAG_REGISTRY_VERSION, String.valueOf(REGISTRY_VERSION));
if (lastUsedProfileId != null && !lastUsedProfileId.isBlank()) {
appendTextElement(document, rootElement, TAG_LAST_USED_PROFILE_ID, lastUsedProfileId);
}
for (OperatorProfile currentProfile : profiles) {
Element profileElement = document.createElement(TAG_PROFILE);
rootElement.appendChild(profileElement);
appendTextElement(document, profileElement, TAG_PROFILE_ID, currentProfile.getProfileId());
appendTextElement(document, profileElement, TAG_DISPLAY_NAME, currentProfile.getDisplayName());
appendTextElement(document, profileElement, TAG_ROOT_PROFILE,
String.valueOf(currentProfile.isRootProfile()));
appendTextElement(document, profileElement, TAG_SHARED_WORKED_DATABASE,
String.valueOf(currentProfile.isRootProfile() || currentProfile.isSharedWorkedDatabase()));
appendTextElement(document, profileElement, TAG_LAST_USED_EPOCH_MS,
String.valueOf(currentProfile.getLastUsedEpochMs()));
}
return writeDocumentAtomically(document);
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Could not write the operator profile registry", e);
return false;
}
}
/**
* Records that a profile has been activated.
*
* <p>Does nothing when no registry exists, so a single operator installation is not
* turned into a multi profile installation by merely starting the application.</p>
*
* @param profileId identifier of the activated profile
* @return true if the registry was updated
*/
public boolean recordLastUsed(final String profileId) {
if (!isRegistryPresent()) {
return false;
}
List<OperatorProfile> storedProfiles = loadProfiles();
if (storedProfiles.isEmpty()) {
return false;
}
for (OperatorProfile currentProfile : storedProfiles) {
if (currentProfile.getProfileId().equalsIgnoreCase(profileId)) {
currentProfile.setLastUsedEpochMs(System.currentTimeMillis());
}
}
return saveProfiles(storedProfiles, profileId);
}
/**
* Returns the absolute path of the registry file.
*
* @return absolute registry path
*/
public String getRegistryFilePath() {
return registryFilePath;
}
private Document readRegistryDocument() {
File registryFile = new File(registryFilePath);
if (!registryFile.isFile()) {
return null;
}
try {
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
return documentBuilderFactory.newDocumentBuilder().parse(registryFile);
} catch (Exception e) {
LOGGER.log(Level.WARNING,
"Could not read the operator profile registry, continuing without additional profiles", e);
return null;
}
}
private static String readText(final Element parentElement, final String tagName) {
NodeList matchingNodes = parentElement.getElementsByTagName(tagName);
if (matchingNodes.getLength() == 0) {
return null;
}
return matchingNodes.item(0).getTextContent();
}
private static boolean readBoolean(final Element parentElement,
final String tagName,
final boolean defaultValue) {
String rawValue = readText(parentElement, tagName);
if (rawValue == null || rawValue.isBlank()) {
return defaultValue;
}
return Boolean.parseBoolean(rawValue.trim());
}
private static long readLong(final Element parentElement, final String tagName) {
String rawValue = readText(parentElement, tagName);
if (rawValue == null || rawValue.isBlank()) {
return 0L;
}
try {
return Long.parseLong(rawValue.trim());
} catch (NumberFormatException e) {
return 0L;
}
}
private static void appendTextElement(final Document document,
final Element parentElement,
final String tagName,
final String textContent) {
Element createdElement = document.createElement(tagName);
createdElement.setTextContent(textContent == null ? "" : textContent);
parentElement.appendChild(createdElement);
}
/**
* Writes the registry through a temporary file so a crash can never leave a
* half-written registry behind. This mirrors the established preferences writer.
*
* @param document document to write
* @return true if the registry file was replaced
*/
private boolean writeDocumentAtomically(final Document document) {
Path targetPath = Path.of(registryFilePath).toAbsolutePath();
Path parentDirectory = targetPath.getParent();
if (parentDirectory == null) {
LOGGER.log(Level.SEVERE,
"The operator profile registry path has no directory: {0}", registryFilePath);
return false;
}
try {
Files.createDirectories(parentDirectory);
// The temporary file has to live next to the target so the final move can be
// atomic; both must be on the same file system.
Path temporaryPath = Files.createTempFile(
parentDirectory, PROFILES_REGISTRY_FILE, ".tmp");
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
try (OutputStream outputStream = Files.newOutputStream(temporaryPath)) {
transformer.transform(new DOMSource(document), new StreamResult(outputStream));
}
try {
Files.move(temporaryPath, targetPath,
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException atomicMoveUnsupported) {
Files.move(temporaryPath, targetPath, StandardCopyOption.REPLACE_EXISTING);
}
return true;
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Could not store the operator profile registry", e);
return false;
}
}
}
@@ -0,0 +1,237 @@
package kst4contest.controller;
import kst4contest.controller.interfaces.PstRotatorEventListener;
import kst4contest.model.ThreadStateMessage;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
import java.util.logging.Level;
import java.util.logging.Logger;
public class PstRotatorClient implements Runnable {
private ThreadStatusCallback callBackToController;
private String ThreadNickName = "PSTRotator";
private static final Logger LOGGER = Logger.getLogger(PstRotatorClient.class.getName());
private static final int BUFFER_SIZE = 1024;
// Konfiguration
private final String host;
private final int remotePort; // Port, auf dem PSTRotator hört (z.B. 12060)
private final int localPort; // Port, auf dem wir hören (z.B. 12061)
private DatagramSocket socket;
private volatile boolean running = false;
private PstRotatorEventListener listener;
// Executor für Polling (Status-Abfrage)
private ScheduledExecutorService poller;
/**
* Konstruktor
* @param host IP Adresse von PSTRotator (meist "127.0.0.1")
* @param remotePort Der Port, der in PSTRotator eingestellt ist (User-Wunsch: 12060)
* @param listener Callback für den Chatcontroller
*/
public PstRotatorClient(String host, int remotePort, PstRotatorEventListener listener, ThreadStatusCallback callBack) {
this.callBackToController = callBack;
this.host = host;
this.remotePort = remotePort;
// Laut Manual antwortet PSTRotator oft auf Port+1.
// Wir binden uns also standardmäßig auf remotePort + 1.
this.localPort = remotePort + 1;
this.listener = listener;
}
/**
* alternative constructor for seting the remote port explicitely
*/
public PstRotatorClient(String host, int remotePort, int localPort, PstRotatorEventListener listener) {
this.host = host;
this.remotePort = remotePort;
this.localPort = localPort;
this.listener = listener;
}
/**
* Startet den Empfangs-Thread und das Polling
*/
public void start() {
try {
// Socket binden
// socket = new DatagramSocket(null);
// socket.setReuseAddress(true);
// socket = new DatagramSocket(localPort);
//
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(localPort)); //bind to port
running = true;
// 1. Empfangs-Thread starten (dieses Runnable)
Thread thread = new Thread(this, "PSTRotator-Listener-" + remotePort);
thread.start();
// 2. Polling starten (z.B. alle 2 Sekunden Status abfragen)
poller = Executors.newSingleThreadScheduledExecutor();
poller.scheduleAtFixedRate(this::pollStatus, 1, 2, TimeUnit.SECONDS);
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, running, "initialized", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
LOGGER.info("PstRotatorClient started. Remote: " + remotePort + ", Local: " + localPort);
} catch (SocketException e) {
LOGGER.log(Level.SEVERE, "Fehler beim Öffnen des UDP Sockets", e);
}
}
/**
* Stopping threads and closing sockets of pstRotator communicator
*/
public void stop() {
running = false;
if (poller != null && !poller.isShutdown()) {
poller.shutdownNow();
}
if (socket != null && !socket.isClosed()) {
socket.close();
}
}
/**
* Main loop in thread which listens fpr PSTrotator packets
*/
@Override
public void run() {
byte[] buffer = new byte[BUFFER_SIZE];
while (running && !socket.isClosed()) {
try {
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
socket.receive(packet); // Blockiert bis Daten kommen
String received = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.US_ASCII).trim();
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received line\n" + received, false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
parseResponse(received);
} catch (IOException e) {
if (running) {
LOGGER.log(Level.WARNING, "Fehler beim Empfangen des Pakets", e);
}
}
}
}
/**
* parses a pst rotatpr message to fit the PST listener interface
* @param msg
*/
private void parseResponse(String msg) {
// Debug
if (listener != null) listener.onMessageReceived(msg);
// Example answer: "AZ:145.0<CR>", "EL:010.0<CR>", "MODE:1<CR>"
msg = msg.replace("<CR>", "").trim();
try {
if (msg.startsWith("AZ:")) {
String val = msg.substring(3);
if (listener != null) listener.onAzimuthUpdate(Double.parseDouble(val));
}
else if (msg.startsWith("EL:")) {
String val = msg.substring(3);
if (listener != null) listener.onElevationUpdate(Double.parseDouble(val));
}
else if (msg.startsWith("MODE:")) {
// MODE:1 = Tracking, MODE:0 = Manual
String val = msg.substring(5);
boolean tracking = "1".equals(val);
if (listener != null) listener.onModeUpdate(tracking);
}
else if (msg.startsWith("OK:")) {
// Bestätigung von Befehlen, z.B. OK:STOP:1
LOGGER.fine("Befehl bestätigt: " + msg);
}
} catch (NumberFormatException e) {
LOGGER.warning("Konnte Wert nicht parsen: " + msg);
}
}
// --- Sende Methoden (API für den Chatcontroller) ---
private void sendUdp(String message) {
if (socket == null || socket.isClosed()) return;
try {
byte[] data = message.getBytes(StandardCharsets.US_ASCII);
InetAddress address = InetAddress.getByName(host);
DatagramPacket packet = new DatagramPacket(data, data.length, address, remotePort);
socket.send(packet);
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Fehler beim Senden an PstRotator", e);
}
}
/**
* Sendet den generischen XML Befehl.
* Bsp: <PST><AZIMUTH>85</AZIMUTH></PST>
*/
private void sendCommand(String tag, String value) {
String xml = String.format("<PST><%s>%s</%s></PST>", tag, value, tag);
System.out.println("PSTRotatorClient: sent: " + xml);
sendUdp(xml);
}
// Öffentliche Steuermethoden
public void setAzimuth(double degrees) {
// Formatierung ohne unnötige Nachkommastellen, falls nötig
sendCommand("AZIMUTH", String.valueOf((int) degrees));
}
public void setElevation(double degrees) {
sendCommand("ELEVATION", String.valueOf(degrees));
}
public void stopRotor() {
sendCommand("STOP", "1");
}
public void park() {
sendCommand("PARK", "1");
}
public void setTrackingMode(boolean enable) {
sendCommand("TRACK", enable ? "1" : "0");
}
/**
* Method for polling rotators status via PSTRotator software. Asks only for AZ value!<br/>
* Scheduled in a fixed time by executor
*/
public void pollStatus() {
// PSTRotator Dokumentation:
// <PST>AZ?</PST>
// <PST>EL?</PST>
// <PST>MODE?</PST>
// Man kann mehrere Befehle in einem Paket senden
String query = "<PST><AZ?></AZ?><EL?></EL?><MODE?></MODE?></PST>";
// HINWEIS: Laut Doku ist die Syntax für Abfragen etwas anders: <PST>AZ?</PST>
// Daher bauen wir den String manuell, da sendCommand Tags schließt.
sendUdp("<PST>AZ?</PST>");
// sendUdp("<PST>EL?</PST>");
sendUdp("<PST>MODE?</PST>");
}
}
@@ -0,0 +1,670 @@
package kst4contest.controller;
import kst4contest.logic.BandOpportunityResolver;
import kst4contest.view.map.MapCallsignRawSnapshot;
import kst4contest.logic.PropagationFrequencyResolver;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
import kst4contest.locatorUtils.Location;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.view.map.GeometryOnlyPathAnalysisService;
import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
import kst4contest.view.map.PathAnalysisRequest;
import kst4contest.view.map.PathAnalysisResult;
import kst4contest.view.map.PathAnalysisService;
import kst4contest.view.map.PathGeometryUtils;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.function.Consumer;
/**
* Central service for tropo/path reachability calculations.
*
* <p>This service is now the single calculation path for:
* <ul>
* <li>the station table Tropo column</li>
* <li>the Tropo filter/sorter</li>
* <li>the station map path-analysis detail panel</li>
* </ul>
*
* <p>The service always calculates a full {@link PathAnalysisResult}. When the
* result contains a usable link budget, the bidirectional SSB margin is copied
* into all matching {@link ChatMember} objects. The UI can then sort/filter by
* a simple number while the map still receives the full path result.</p>
*/
public final class ReachabilityService {
private static final long FAILED_ANALYSIS_RETRY_DELAY_MS = 5L * 60L * 1000L;
private final ChatController chatController;
private final PathAnalysisService pathAnalysisService;
private final ExecutorService executor;
/**
* Prevents duplicate jobs for the same path while a calculation is already
* queued or running.
*/
private final Set<String> queuedCalculationKeys = ConcurrentHashMap.newKeySet();
/**
* Stores completed usable path-analysis results. Failed/no-budget results are
* not permanently cached so transient terrain/network problems can be retried.
*/
private final Map<String, PathAnalysisResult> pathAnalysisResultCache = new ConcurrentHashMap<>();
/**
* Temporary callback list for map requests waiting for an already running
* background calculation.
*/
private final Map<String, List<Consumer<PathAnalysisResult>>> pendingMapCallbacksByKey = new ConcurrentHashMap<>();
/**
* Prevents immediate retry loops for failed/no-budget analyses, especially
* when a TableView cell repeatedly asks for the same value.
*/
private final Map<String, Long> failedCalculationRetryAfterEpochMs = new ConcurrentHashMap<>();
public ReachabilityService(ChatController chatController) {
this.chatController = Objects.requireNonNull(chatController, "chatController");
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider());
this.executor = Executors.newSingleThreadExecutor(new ReachabilityThreadFactory());
}
/**
* Ensures that the automatically selected reachability band is calculated.
*
* @param member chatmember to evaluate
*/
public void ensureAutoTropoMarginCalculated(ChatMember member) {
ensureTropoMarginCalculated(member, resolveAutoBand(member));
}
/**
* Ensures that a station/band SSB-margin value exists if it is already cached.
*
* <p>This method deliberately does not start online terrain analysis anymore.
* It is safe to call from table cells and filters because it will not consume
* API quota. Full calculations are now started only by explicit map/manual
* requests.</p>
*
* @param member chatmember to evaluate
* @param band band to evaluate
*/
public void ensureTropoMarginCalculated(ChatMember member, Band band) {
if (member == null || band == null) {
return;
}
if (member.hasFiniteTropoSsbMarginDb(band)) {
return;
}
PathAnalysisRequest request = buildRequestForChatMember(member, band);
if (request == null) {
return;
}
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request));
if (cachedResult != null) {
storePathAnalysisResultInChatMembers(member, band, cachedResult);
}
}
/**
* Requests a full path analysis for the station map.
*
* <p>This is the only normal automatic entry point for online/full terrain
* analysis. The map needs the full PathAnalysisResult, and the station table
* needs the SSB margin. Both are produced here through the same calculation.</p>
*
* @param member best matching ChatMember, may be null when only a map snapshot exists
* @param selectedSnapshot selected map snapshot
* @param fxCallback callback executed on the JavaFX thread
* @param requestedBandOverride operator-selected band, or null for automatic resolution
*/
public void requestPathAnalysisForMap(ChatMember member,
MapCallsignRawSnapshot selectedSnapshot,
Band requestedBandOverride,
Consumer<PathAnalysisResult> fxCallback) {
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
if (selectedSnapshot == null) {
dispatchFxCallback(fxCallback, PathAnalysisResult.waitingForSelection(ownLocator6));
return;
}
String targetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
if (ownLocator6.length() != 6) {
dispatchFxCallback(fxCallback,
PathAnalysisResult.waitingForValidHomeLocator(ownLocator6, targetLocator6));
return;
}
if (!selectedSnapshot.hasUsablePosition()) {
dispatchFxCallback(fxCallback,
PathAnalysisResult.waitingForValidTarget(ownLocator6, targetLocator6));
return;
}
Band analysisBand;
double analysisFrequencyMHz;
if (requestedBandOverride != null) {
/*
* An explicit operator selection has priority over automatic propagation
* resolution. Exact recent QRG information on that band is still used
* when available; otherwise the band's default analysis frequency is used.
*/
analysisBand = requestedBandOverride;
analysisFrequencyMHz =
resolveAnalysisFrequencyForBand(member, analysisBand);
} else {
PropagationFrequencyResolver.Resolution frequencyResolution =
resolveAutomaticPropagationFrequency(member);
if (frequencyResolution == null) {
dispatchFxCallback(
fxCallback,
PathAnalysisResult.waitingForUsableBand(
ownLocator6,
targetLocator6,
selectedSnapshot.callSignRaw()
)
);
return;
}
analysisBand = frequencyResolution.getBand();
analysisFrequencyMHz =
frequencyResolution.getAnalysisFrequencyMHz();
}
PathAnalysisRequest request = buildRequest(
ownLocator6,
selectedSnapshot.callSignRaw(),
targetLocator6,
selectedSnapshot.latitudeDeg(),
selectedSnapshot.longitudeDeg(),
analysisFrequencyMHz
);
requestPathAnalysisAndStore(member, analysisBand, request, fxCallback);
}
/**
* Starts a full on-demand reachability calculation for one station row.
*
* <p>Use this for explicit operator actions only. It may call the online terrain
* API and therefore must not be used from TableView cell factories or automatic
* chat-join processing.</p>
*
* @param member station to calculate
* @param band selected reachability band
*/
public void calculateSelectedStationOnDemand(ChatMember member, Band band) {
if (member == null || band == null) {
return;
}
PathAnalysisRequest request = buildRequestForChatMember(member, band);
if (request == null) {
member.setTropoSsbMarginDb(band, Double.NaN);
chatController.fireUserListUpdate("Reachability unavailable");
return;
}
requestPathAnalysisAndStore(member, band, request, null);
}
/**
* Resolves the auto reachability band through the shared propagation
* frequency selection used by AirScout and path analysis.
*
* @param member member to inspect
* @return resolved band
*/
public Band resolveAutoBand(ChatMember member) {
PropagationFrequencyResolver.Resolution resolution =
resolveAutomaticPropagationFrequency(member);
return resolution == null ? null : resolution.getBand();
}
/**
* Resolves one automatic band and exact analysis frequency for a station.
*
* @param member any active category variant of the target station
* @return shared propagation resolution, or {@code null} for unsupported data
*/
public PropagationFrequencyResolver.Resolution resolveAutomaticPropagationFrequency(
ChatMember member
) {
return PropagationFrequencyResolver.resolve(
resolveCallsignVariants(member),
getEnabledStationBands(),
System.currentTimeMillis()
);
}
/**
* Returns the active own bands configured in the station preferences. High bands
* above 10 GHz are intentionally ignored for the first version.
*
* @return set of enabled bands
*/
public EnumSet<Band> getEnabledStationBands() {
return BandOpportunityResolver.getEnabledStationBands(
chatController.getChatPreferences()
);
}
private List<ChatMember> resolveCallsignVariants(ChatMember member) {
if (member == null) {
return List.of();
}
String rawCall = member.getCallSignRaw() != null
? member.getCallSignRaw()
: member.getCallSign();
List<ChatMember> variants = chatController.findActiveChatMembersByRawCall(rawCall);
return variants.isEmpty() ? List.of(member) : variants;
}
/**
* Stops the background executor.
*/
public void shutdown() {
executor.shutdownNow();
}
/**
* Starts or reuses one shared path-analysis calculation.
*
* @param member matching ChatMember, may be null for map-only snapshots
* @param band band under which the value is stored in ChatMember
* @param request full path-analysis request
* @param fxCallback optional map callback
*/
private void requestPathAnalysisAndStore(ChatMember member,
Band band,
PathAnalysisRequest request,
Consumer<PathAnalysisResult> fxCallback) {
if (request == null || band == null) {
return;
}
String calculationKey = buildCalculationKey(request);
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(calculationKey);
if (cachedResult != null) {
storePathAnalysisResultInChatMembers(member, band, cachedResult);
dispatchFxCallback(fxCallback, cachedResult);
return;
}
Long retryAfterEpochMs = failedCalculationRetryAfterEpochMs.get(calculationKey);
if (retryAfterEpochMs != null && System.currentTimeMillis() < retryAfterEpochMs) {
dispatchFxCallback(fxCallback, createNoProfileResult(
request,
"Previous path analysis failed or the terrain API limit was reached. Retry is delayed briefly."
));
return;
}
addPendingCallback(calculationKey, fxCallback);
if (!queuedCalculationKeys.add(calculationKey)) {
return;
}
executor.submit(() -> {
PathAnalysisResult result;
try {
result = pathAnalysisService.analyze(request);
if (result == null) {
result = createNoProfileResult(request, "Path analysis returned no result.");
}
} catch (Exception exception) {
result = createNoProfileResult(
request,
"Path analysis failed: " + exception.getMessage()
);
}
boolean usableBudget = hasUsableLinkBudget(result);
if (usableBudget) {
pathAnalysisResultCache.put(calculationKey, result);
failedCalculationRetryAfterEpochMs.remove(calculationKey);
} else {
failedCalculationRetryAfterEpochMs.put(
calculationKey,
System.currentTimeMillis() + FAILED_ANALYSIS_RETRY_DELAY_MS
);
}
queuedCalculationKeys.remove(calculationKey);
PathAnalysisResult finalResult = result;
Runnable updateTask = () -> {
storePathAnalysisResultInChatMembers(member, band, finalResult);
dispatchAndClearPendingCallbacks(calculationKey, finalResult);
chatController.fireUserListUpdate("Reachability calculated");
};
if (chatController.uiDispatcher().isUiThread()) {
updateTask.run();
} else {
chatController.uiDispatcher().runOnUi(updateTask);
}
});
}
/**
* Builds a path-analysis request from a ChatMember row.
*
* @param member station row
* @param band selected reachability band
* @return request or null if locators are not usable
*/
private PathAnalysisRequest buildRequestForChatMember(ChatMember member, Band band) {
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
String targetLocator6 = normalizeLocator6(member.getQra());
if (ownLocator6.length() != 6 || targetLocator6.length() != 6) {
return null;
}
Location targetLocation = new Location(targetLocator6);
double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band);
return buildRequest(
ownLocator6,
member.getCallSignRaw(),
targetLocator6,
targetLocation.getLatitude().toDegrees(),
targetLocation.getLongitude().toDegrees(),
analysisFrequencyMHz
);
}
/**
* Builds the shared PathAnalysisRequest used by map and table/manual requests.
*/
private PathAnalysisRequest buildRequest(String ownLocator6,
String targetCallsignRaw,
String targetLocator6,
double targetLatitudeDeg,
double targetLongitudeDeg,
double analysisFrequencyMHz) {
Location homeLocation = new Location(ownLocator6);
return new PathAnalysisRequest(
ownLocator6,
homeLocation.getLatitude().toDegrees(),
homeLocation.getLongitude().toDegrees(),
targetCallsignRaw,
targetLocator6,
targetLatitudeDeg,
targetLongitudeDeg,
analysisFrequencyMHz,
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
chatController.getChatPreferences().buildPathLinkBudgetSettings()
);
}
/**
* Stores the SSB margin from a completed analysis in all matching ChatMember
* objects. Matching uses raw callsign and locator.
*/
private void storePathAnalysisResultInChatMembers(ChatMember primaryMember,
Band band,
PathAnalysisResult result) {
if (band == null || result == null) {
return;
}
double marginDb = hasUsableLinkBudget(result)
? result.linkBudgetSummary().bidirectionalSsbMarginDb()
: Double.NaN;
if (primaryMember != null) {
primaryMember.setTropoSsbMarginDb(band, marginDb);
}
String resultCallSignRaw = normalizeCallsignRaw(result.toCallsignRaw());
String resultLocator6 = normalizeLocator6(result.toLocator6());
for (ChatMember member : chatController.snapshotChatMembers()) {
if (member == null) {
continue;
}
String memberCallSignRaw = normalizeCallsignRaw(member.getCallSignRaw());
if (!resultCallSignRaw.isBlank() && !resultCallSignRaw.equals(memberCallSignRaw)) {
continue;
}
String memberLocator6 = normalizeLocator6(member.getQra());
if (!resultLocator6.isBlank() && !resultLocator6.equals(memberLocator6)) {
continue;
}
member.setTropoSsbMarginDb(band, marginDb);
}
}
/**
* Resolves the analysis frequency for one member/band pair.
*
* <p>Preference order:
* <ol>
* <li>knownActiveBands frequency for the band</li>
* <li>current displayed QRG if it belongs to the same band</li>
* <li>band default frequency</li>
* </ol>
*/
private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) {
if (band == null) {
return Double.NaN;
}
ChatMember.ActiveFrequencyInfo latestFrequencyInfo = null;
for (ChatMember variant : resolveCallsignVariants(member)) {
ChatMember.ActiveFrequencyInfo activeFrequencyInfo =
variant.getKnownActiveBands().get(band);
if (activeFrequencyInfo != null
&& Double.isFinite(activeFrequencyInfo.frequency)
&& activeFrequencyInfo.frequency > 0.0
&& (latestFrequencyInfo == null
|| activeFrequencyInfo.timestampEpoch > latestFrequencyInfo.timestampEpoch)) {
latestFrequencyInfo = activeFrequencyInfo;
}
}
if (latestFrequencyInfo != null) {
return latestFrequencyInfo.frequency;
}
if (member != null && member.getFrequency() != null && member.getFrequency().get() != null) {
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().get());
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
Band parsedBand = Band.fromFrequency(parsedFrequencyMHz);
if (parsedBand == null || parsedBand == band) {
return parsedFrequencyMHz;
}
}
}
return band.getDefaultAnalysisFrequencyMHz();
}
/**
* Adds a map callback to the pending callback list for one calculation key.
*
* @param calculationKey path key
* @param fxCallback callback to add
*/
private void addPendingCallback(String calculationKey, Consumer<PathAnalysisResult> fxCallback) {
if (fxCallback == null) {
return;
}
pendingMapCallbacksByKey
.computeIfAbsent(
calculationKey,
ignored -> Collections.synchronizedList(new ArrayList<>())
)
.add(fxCallback);
}
/**
* Dispatches and removes all callbacks waiting for one completed calculation.
*
* @param calculationKey path key
* @param result completed result
*/
private void dispatchAndClearPendingCallbacks(String calculationKey, PathAnalysisResult result) {
List<Consumer<PathAnalysisResult>> callbacks = pendingMapCallbacksByKey.remove(calculationKey);
if (callbacks == null || callbacks.isEmpty()) {
return;
}
List<Consumer<PathAnalysisResult>> callbackSnapshot;
synchronized (callbacks) {
callbackSnapshot = new ArrayList<>(callbacks);
}
for (Consumer<PathAnalysisResult> callback : callbackSnapshot) {
dispatchFxCallback(callback, result);
}
}
/**
* Executes a map callback on the JavaFX application thread.
*
* @param fxCallback callback to execute
* @param result result to pass
*/
private void dispatchFxCallback(Consumer<PathAnalysisResult> fxCallback, PathAnalysisResult result) {
if (fxCallback == null) {
return;
}
if (chatController.uiDispatcher().isUiThread()) {
fxCallback.accept(result);
} else {
chatController.uiDispatcher().runOnUi(() -> fxCallback.accept(result));
}
}
/**
* Checks whether the result contains a usable link-budget summary.
*
* @param result path-analysis result
* @return true if SSB margin can be read
*/
private boolean hasUsableLinkBudget(PathAnalysisResult result) {
return result != null
&& result.linkBudgetSummary() != null
&& result.linkBudgetSummary().hasUsableBudget();
}
/**
* Creates a no-profile/no-budget result for failed service calculations.
*
* @param request original request
* @param statusText status shown in the map detail panel
* @return placeholder path result
*/
private PathAnalysisResult createNoProfileResult(PathAnalysisRequest request, String statusText) {
return PathAnalysisResult.noProfile(
"Reachability",
request.fromLocator6(),
request.toLocator6(),
request.toCallsignRaw(),
Double.NaN,
Double.NaN,
request.homeAntennaHeightMeters(),
request.targetAntennaHeightMeters(),
request.frequencyMHz(),
statusText
);
}
/**
* Builds a stable calculation key.
*
* <p>The key includes station, locators, frequency, antenna heights and link
* budget settings. If any relevant input changes, a new calculation is allowed.</p>
*/
private String buildCalculationKey(PathAnalysisRequest request) {
return normalizeLocator6(request.fromLocator6())
+ "|"
+ normalizeCallsignRaw(request.toCallsignRaw())
+ "|"
+ normalizeLocator6(request.toLocator6())
+ "|"
+ String.format(Locale.US, "%.5f", request.toLatitudeDeg())
+ "|"
+ String.format(Locale.US, "%.5f", request.toLongitudeDeg())
+ "|"
+ String.format(Locale.US, "%.3f", request.frequencyMHz())
+ "|"
+ String.format(Locale.US, "%.1f", request.homeAntennaHeightMeters())
+ "|"
+ String.format(Locale.US, "%.1f", request.targetAntennaHeightMeters())
+ "|"
+ request.linkBudgetSettings();
}
private String normalizeCallsignRaw(String callSignRaw) {
return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT);
}
private String normalizeLocator6(String locator) {
return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT);
}
private static final class ReachabilityThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable, "reachability-service");
thread.setDaemon(true);
return thread;
}
}
}
@@ -0,0 +1,160 @@
package kst4contest.controller;
import java.io.BufferedReader;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.Consumer;
import java.util.function.LongPredicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.ChatMessage;
/**
* Reads exactly one immutable ON4KST connection session.
*
* <p>EOF is a connection-loss event, not an empty chat message. Every line is
* associated with the session id captured by this reader, so a delayed exception
* from an obsolete socket cannot affect a newer reconnect.</p>
*/
public class ReadThread extends Thread {
private static final Logger LOGGER = Logger.getLogger(ReadThread.class.getName());
private final long sessionId;
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LongPredicate sessionIsActive;
private final Consumer<String> inboundActivity;
private final Consumer<Throwable> connectionFailure;
private final MessageHistoryRecorder historyRecorder;
private final BufferedReader reader;
/**
* Compatibility constructor for the pre-session controller path.
*
* @deprecated new connections should be created by
* {@link On4KstConnectionManager}
*/
@Deprecated
public ReadThread(Socket socket, ChatController client) throws IOException {
this(0L, socket, client.getMessageRXBus(), ignored -> true,
ignored -> { }, ignored -> { },
client.getMessageHistoryRecorder());
}
/**
* Creates the reader for one connection generation.
*
* @param sessionId immutable id of the owning socket session
* @param socket connected ON4KST socket
* @param receiveQueue private receive queue belonging to this session
* @param sessionIsActive guard against callbacks from an obsolete session
* @param inboundActivity callback used for liveness and protocol progress
* @param connectionFailure callback for EOF, I/O and unexpected runtime errors
* @throws IOException if the socket input stream cannot be opened
*/
public ReadThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LongPredicate sessionIsActive,
Consumer<String> inboundActivity,
Consumer<Throwable> connectionFailure
) throws IOException {
this(sessionId, socket, receiveQueue, sessionIsActive,
inboundActivity, connectionFailure,
MessageHistoryRecorder.disabled());
}
/** Creates a reader that reports every received frame to the recorder. */
public ReadThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LongPredicate sessionIsActive,
Consumer<String> inboundActivity,
Consumer<Throwable> connectionFailure,
MessageHistoryRecorder historyRecorder
) throws IOException {
this.sessionId = sessionId;
this.socket = socket;
this.receiveQueue = receiveQueue;
this.sessionIsActive = sessionIsActive;
this.inboundActivity = inboundActivity;
this.connectionFailure = connectionFailure;
this.historyRecorder = historyRecorder == null
? MessageHistoryRecorder.disabled() : historyRecorder;
this.reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(), StandardCharsets.UTF_8));
}
@Override
public void run() {
Thread.currentThread().setName("ReadFromOn4Kst-" + sessionId);
LOGGER.log(Level.FINE,
"ON4KST reader started for session {0}", sessionId);
try {
while (!isInterrupted() && sessionIsActive.test(sessionId)) {
String response = reader.readLine();
if (response == null) {
throw new EOFException("ON4KST closed the TCP connection");
}
historyRecorder.recordRx(response);
if (!sessionIsActive.test(sessionId)) {
break;
}
inboundActivity.accept(response);
if (!sessionIsActive.test(sessionId)) {
break;
}
if (On4KstProtocol.isClientLivenessProbeResponse(response)) {
// OK and OK| acknowledge the client-side CK| probe.
continue;
}
ChatMessage message = new ChatMessage();
message.setMessageText(response);
receiveQueue.put(message);
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (IOException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.FINE,
"ON4KST read failed for session " + sessionId,
exception);
connectionFailure.accept(exception);
}
} catch (RuntimeException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.SEVERE, "Unexpected ON4KST reader failure", exception);
connectionFailure.accept(exception);
}
} finally {
LOGGER.log(Level.FINE,
"ON4KST reader stopped for session {0}", sessionId);
}
}
/**
* Interrupts the read loop and closes the session socket.
*
* @return always {@code true} after a successful close
* @throws IOException if closing the reader or socket fails
*/
public boolean terminateConnection() throws IOException {
interrupt();
reader.close();
socket.close();
return true;
}
}
@@ -0,0 +1,834 @@
package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember;
import kst4contest.model.ThreadStateMessage;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
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.regex.Matcher;
import java.util.regex.Pattern;
public class ReadUDPByWintestThread extends Thread {
private static final Pattern STATUS_TOKEN_PATTERN = Pattern.compile("\"([^\"]*)\"|(\\S+)");
private DatagramSocket socket;
private ChatController client;
private volatile boolean running = true;
private int PORT = 9871; //default
private static final int BUFFER_SIZE = 4096;
private long lastPacketTime = 0;
private String myStation = "DO5AMF";
private String targetStation = "";
private String stationID = "";
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) {
this.callBackToController = callback;
this.client = client;
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
public void interrupt() {
running = false;
if (socket != null && !socket.isClosed()) socket.close();
super.interrupt();
}
@Override
public void run() {
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
Thread.currentThread().setName("ReadUDPByWintestThread");
byte[] buffer = new byte[BUFFER_SIZE];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
try {
socket = new DatagramSocket(null); //first init with null, then make ready for reuse
socket.setReuseAddress(true);
// socket = new DatagramSocket(PORT);
int boundPort = client.getChatPreferences().getLogsynch_wintestNetworkPort();
socket.bind(new InetSocketAddress(boundPort));
socket.setSoTimeout(3000);
System.out.println("[WinTest UDP listener] started at port: " + boundPort);
} catch (SocketException e) {
e.printStackTrace();
return;
}
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);
processWinTestDatagram(
packet.getData(), packet.getLength(), packet.getAddress());
} catch (SocketTimeoutException e) {
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);
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);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
}
} 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(packet);
}
else if (msg.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
System.out.println("ReadUdpByWintest, Info: got poison, now dieing....");
socket.close();
running = false;
}
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "message received\n" + msg, false);
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"
* @param msg
*/
private void parseHello(String msg) {
try {
String[] tokens = msg.split("\"");
if (tokens.length >= 2) {
targetStation = tokens[1];
System.out.println("[WinTest rcv: found logger instance: " + targetStation);
}
} catch (Exception e) {
System.out.println("[WinTest] ERROR on HELLO-Parsing: " + e.getMessage());
}
}
private byte util_calculateChecksum(byte[] bytes) {
int sum = 0;
for (byte b : bytes) sum += b;
return (byte) ((sum | 0x80) & 0xFF);
}
/**
* Formats a frequency from a Win-Test STATUS packet for use as MYQRG.
*
* <p>Win-Test transmits the frequency in units of 0.1 kHz. KST4Contest
* displays frequencies as {@code MHz.kHz.10Hz}, for example
* {@code 144.300.00} or {@code 10368.100.00}. The MHz part may contain
* between one and five digits. Deriving its length from the complete value
* avoids separate and incomplete handling for individual bands.</p>
*
* @param frequencyIn100Hz frequency received from Win-Test in units of
* 0.1 kHz, equivalent to 100 Hz
* @return frequency formatted for MYQRG
* @throws IllegalArgumentException if the supplied frequency is not positive
* or too small to be formatted
* @throws ArithmeticException if the supplied value exceeds the supported
* numeric range
*/
private String helper_formatWinTestFrequency(long frequencyIn100Hz) {
if (frequencyIn100Hz <= 0) {
throw new IllegalArgumentException(
"Win-Test frequency must be greater than zero"
);
}
/*
* Multiplication by ten creates a digit sequence whose final five
* digits represent kHz and 10-Hz groups:
*
* 1443210 -> 14432100 -> 144.321.00
* 103681000 -> 1036810000 -> 10368.100.00
*/
long frequencyIn10Hz = Math.multiplyExact(
frequencyIn100Hz,
10L
);
String frequencyDigits = Long.toString(frequencyIn10Hz);
if (frequencyDigits.length() < 6) {
throw new IllegalArgumentException(
"Win-Test frequency is too small: " + frequencyIn100Hz
);
}
int mhzEndIndex = frequencyDigits.length() - 5;
int khzEndIndex = frequencyDigits.length() - 2;
return frequencyDigits.substring(0, mhzEndIndex)
+ "."
+ frequencyDigits.substring(mhzEndIndex, khzEndIndex)
+ "."
+ frequencyDigits.substring(khzEndIndex);
}
/**
* Parses a Win-Test STATUS packet and optionally updates MYQRG.
*
* <p>The packet is tokenised while preserving quoted station names.
* The configured station-name filter is applied before any frequency is
* processed. An empty filter accepts STATUS packets from every Win-Test
* station.</p>
*
* <p>The main frequency is read from token 7. If pass-frequency use is
* enabled and token 11 contains a valid frequency, the pass frequency is
* used instead. A missing or invalid pass frequency deliberately falls
* back to the main frequency.</p>
*
* @param msg complete Win-Test STATUS packet
*/
private void parseStatus(String msg) {
try {
ArrayList<String> parts = new ArrayList<>();
Matcher matcher = STATUS_TOKEN_PATTERN.matcher(msg);
while (matcher.find()) {
if (matcher.group(1) != null) {
parts.add(matcher.group(1));
} else {
parts.add(matcher.group(2));
}
}
if (parts.size() < 8) {
System.out.println(
"[WinTest] STATUS too short: " + msg
);
return;
}
String stationName = parts.get(1);
String stationFilter = client
.getChatPreferences()
.getLogsynch_wintestNetworkStationNameOfWintestClient1();
if (stationFilter != null
&& !stationFilter.isBlank()
&& !stationName.equalsIgnoreCase(stationFilter)) {
return;
}
String modeValue = parts.get(5);
long mainFrequencyRaw = Long.parseLong(parts.get(7));
double mainFrequencyKHz = mainFrequencyRaw / 10.0;
String mode;
if ("1".equals(modeValue)) {
mode = mainFrequencyKHz > 10000.0 ? "usb" : "lsb";
} else {
mode = "cw";
}
String formattedMainQrg =
helper_formatWinTestFrequency(mainFrequencyRaw);
/*
* Token 11 may contain the pass frequency, depending on the
* Win-Test STATUS packet. Small numeric flag values must not be
* interpreted as frequencies.
*/
String formattedPassQrg = null;
if (parts.size() > 11) {
try {
long passFrequencyRaw =
Long.parseLong(parts.get(11));
double passFrequencyKHz =
passFrequencyRaw / 10.0;
if (passFrequencyKHz > 100.0) {
formattedPassQrg =
helper_formatWinTestFrequency(
passFrequencyRaw
);
}
} catch (IllegalArgumentException
| ArithmeticException ignored) {
/*
* Token 11 does not contain a usable frequency.
* The main frequency remains the safe fallback.
*/
}
}
boolean usePassQrg = client
.getChatPreferences()
.isLogsynch_wintestUsePassQrg();
final String qrgToSet =
usePassQrg && formattedPassQrg != null
? formattedPassQrg
: formattedMainQrg;
if (client
.getChatPreferences()
.isLogsynch_wintestQrgSyncEnabled()) {
client.uiDispatcher().runOnUi(
() -> client
.getChatPreferences()
.getMYQRGFirstCat()
.set(qrgToSet)
);
}
System.out.println(
"[WinTest STATUS] stn=" + stationName
+ ", mode=" + mode
+ ", qrg=" + formattedMainQrg
+ (formattedPassQrg != null
? ", passQrg=" + formattedPassQrg
: "")
+ ", selectedQrg=" + qrgToSet
+ ", syncActive="
+ client
.getChatPreferences()
.isLogsynch_wintestQrgSyncEnabled()
);
} catch (Exception exception) {
System.out.println(
"[WinTest] STATUS parsing error: "
+ exception.getMessage()
);
}
}
// private void send_hello() throws IOException {
// String payload = String.format("HELLO:\"%s\" \"%s\" \"%s\" %d %d?\0",
// "DO5AMF", "", stationID, "SLAVE", 1, 14);
// 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));
// }
/**
* Extracts the unchanged Win-Test band ID from the unquoted ADDQSO fields.
*
* @param message complete ADDQSO packet
* @return raw band ID or an empty value when the field is missing
*/
static String extractBandIdFromWinTestAddQso(String message) {
if (message == null) {
return "";
}
String[] quotedParts = message.split("\"");
if (quotedParts.length <= 6) {
return "";
}
String unquotedFields = quotedParts[6].trim();
if (unquotedFields.isEmpty()) {
return "";
}
String[] packetFields = unquotedFields.split("\\s+");
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.
*
* <p>Current parser model based on the existing split-by-quotes code:
* <ul>
* <li>{@code split("\"")[7]} = callsign</li>
* <li>{@code split("\"")[11]} = received exchange, e.g. 599001</li>
* <li>{@code split("\"")[13]} = locator, e.g. JO51UM</li>
* </ul>
*
* <p>If the dedicated locator field is empty, the exchange is used as fallback.</p>
*
* @param msg raw ADDQSO message
* @return normalized six-character locator or null
*/
private String helper_resolveLocatorFromWinTestAddQso(String msg) {
if (msg == null) {
return null;
}
String[] quotedParts = msg.split("\"");
if (quotedParts.length > 13) {
String locator = WorkedGrossFieldCache.extractLocator6(quotedParts[13]);
if (locator != null) {
return locator;
}
}
if (quotedParts.length > 11) {
return WorkedGrossFieldCache.extractLocator6(quotedParts[11]);
}
return null;
}
/**
* Catches add-qso messages of wintest if a new qso gets into the log<br/>
*
* String is like this:<br/><br/>
*ADDQSO: "STN1" "" "STN1" 1762202297 1440000 0 12 0 0 0 2 2 "DM2RN" "599" "599001" "JO51UM" "" "" 0 "" "" "" 44510
*
* ^^^^sentby<br/>
* ^^^^^^^^^^time<br/>
* ^^^^^^qrg<br/>
* ^^band<br/>
* ^^^^^callsign logged<br/>
* stn-id ^^^^
* @param msg
*/
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);
String locatorFromLogger = helper_resolveLocatorFromWinTestAddQso(msg);
LoggedQsoBand loggedBand = LoggedQsoBand.fromWinTestBandId(rawBandId);
ExternalLoggedQso loggedQso = ExternalLoggedQso.create(
callSign, loggedBand, locatorFromLogger, "WINTEST").orElse(null);
if (loggedQso == null) {
System.out.println("[WinTestUDPRcvr: warning] ADDQSO without usable callsign ignored");
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);
}
ChatMember workedCall = loggedQso.toWorkedChatMember();
if (loggedBand != null
&& loggedBand.getProjectBand() != null
&& locatorFromLogger != null) {
this.client.registerWorkedGrossField(
loggedBand.getProjectBand(), locatorFromLogger, workedCall, loggedQso.getSource());
}
client.applyExternalLoggedQso(loggedQso);
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
File logUDPMessageToThisFile = new File(this.client.getChatPreferences()
.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup());
FileWriter fileWriterPersistUDPToFile = null;
BufferedWriter bufwrtrRawMSGOut;
try {
fileWriterPersistUDPToFile = new FileWriter(logUDPMessageToThisFile, true);
} catch (IOException e1) {
e1.printStackTrace();
}
bufwrtrRawMSGOut = new BufferedWriter(fileWriterPersistUDPToFile);
bufwrtrRawMSGOut.write("\n" + workedCall.toString());
bufwrtrRawMSGOut.flush();
bufwrtrRawMSGOut.close();
System.out.println("[WinTest, Info: Marking Chatmember as worked: " + workedCall.toString());
// markChatMemberAsWorked(call, band); //TODO
} catch (Exception e) {
System.out.println("[WinTest] Fehler beim ADDQSO-Parsing: " + e.getMessage());
}
}
}
@@ -0,0 +1,18 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
public class ReadUDPByWintestThreadTest {
public static void main(String[] args) {
ChatController ctrl1 = new ChatController();
ChatPreferences prefs = new ChatPreferences();
ctrl1.setChatPreferences(prefs);
// ReadUDPByWintestThread test = new ReadUDPByWintestThread(ctrl1);
// test.run();
}
}
@@ -0,0 +1,374 @@
package kst4contest.controller;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.util.Comparator;
import java.util.List;
import kst4contest.ApplicationConstants;
import java.util.ArrayList;
import kst4contest.model.AirPlane;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatMember;
import kst4contest.model.ThreadStateMessage;
/**
* This thread is responsible for reading server's input and printing it to the
* console. It runs in an infinite loop until the client disconnects from the
* server.
*
* @author www.codejava.net
*/
public class ReadUDPbyAirScoutMessageThread extends Thread {
private final ChatController client;
private final int localPort;
private final ThreadStatusCallback callBackToController;
private final String threadNickName = "AirScout msg";
private DatagramSocket socket;
public ReadUDPbyAirScoutMessageThread(
int localPort,
ChatController client,
ThreadStatusCallback callback
) {
this.localPort = localPort;
this.client = client;
this.callBackToController = callback;
}
@Override
public void interrupt() {
super.interrupt();
if (socket != null && !socket.isClosed()) {
socket.close();
}
}
/**
* Checks whether an AirScout response is addressed to the currently
* configured server and client identifiers.
*
* Outgoing message:
* ASSETPATH: "client" "server" ...
*
* Corresponding response:
* ASNEAREST: "server" "client" ...
*
* The comparison is deliberately case-sensitive. In a setup with several
* clients, KST-A and kst-a must not silently become the same destination.
*
* @param message received UDP message
* @return true if the response belongs to this KST4Contest instance
*/
private boolean isMessageForConfiguredClient(String message) {
if (message == null || !message.startsWith("ASNEAREST:")) {
return false;
}
String[] quotedParts = message.split("\"");
if (quotedParts.length < 4) {
return false;
}
String receivedServerIdentifier = quotedParts[1].trim();
String receivedClientIdentifier = quotedParts[3].trim();
String configuredServerIdentifier =
client.getChatPreferences()
.getAirScout_asServerNameString();
String configuredClientIdentifier =
client.getChatPreferences()
.getAirScout_asClientNameString();
if (configuredServerIdentifier == null
|| configuredClientIdentifier == null) {
return false;
}
return configuredServerIdentifier.equals(
receivedServerIdentifier
) && configuredClientIdentifier.equals(
receivedClientIdentifier
);
}
@Override
public void run() {
Thread.currentThread().setName(
"ReadUDPByAirScoutThread"
);
try {
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(localPort));
socket.setSoTimeout(3000);
while (!Thread.currentThread().isInterrupted()) {
if (client.isDisconnectionPerformedByUser()) {
break;
}
byte[] buffer = new byte[1777];
DatagramPacket packet = new DatagramPacket(
buffer,
buffer.length
);
try {
socket.receive(packet);
} catch (SocketTimeoutException timeoutException) {
/*
* AirScout may remain silent for some time. Continue with a
* new packet instead of processing the previous packet again.
*/
continue;
}
String received = new String(
packet.getData(),
packet.getOffset(),
packet.getLength()
).trim();
if (received.contains(
ApplicationConstants.DISCONNECT_RDR_POISONPILL
)) {
System.out.println(
"[AirScout UDP, info]: Received shutdown packet."
);
break;
}
/*
* The socket remains bound so that AirScout can be enabled
* without reconnecting. Disabled means that received data is
* discarded and no station state is changed.
*/
if (!client.getChatPreferences()
.isAirScout_asUDPListenerEnabled()) {
continue;
}
if (!isMessageForConfiguredClient(received)) {
continue;
}
processAirScoutResponse(received);
}
} catch (SocketException exception) {
if (!Thread.currentThread().isInterrupted()) {
System.out.println(
"[AirScout UDP, error]: Could not use UDP port "
+ localPort
+ ": "
+ exception.getMessage()
);
}
} catch (IOException exception) {
if (!Thread.currentThread().isInterrupted()) {
System.out.println(
"[AirScout UDP, error]: Communication failed: "
+ exception.getMessage()
);
}
} finally {
if (socket != null && !socket.isClosed()) {
socket.close();
}
socket = null;
}
}
/**
* Parses an AirScout response and applies it to every active category
* instance of the reported station.
*
* @param received received ASNEAREST message
*/
private void processAirScoutResponse(String received) {
try {
AirPlaneReflectionInfo reflectionInfo =
processASUDPMessage(received);
if (reflectionInfo == null
|| reflectionInfo.getReceiver() == null) {
return;
}
String receiverCallSign =
reflectionInfo.getReceiver().getCallSignRaw();
if (receiverCallSign == null
|| receiverCallSign.isBlank()) {
receiverCallSign =
reflectionInfo.getReceiver().getCallSign();
}
if (receiverCallSign == null
|| receiverCallSign.isBlank()) {
return;
}
List<ChatMember> matchingMembers =
client.findActiveChatMembersByRawCall(
receiverCallSign
);
for (ChatMember matchingMember : matchingMembers) {
matchingMember.setAirPlaneReflectInfo(
reflectionInfo
);
}
if (!matchingMembers.isEmpty()) {
client.getScoreService().requestRecompute(
"airscout-update"
);
}
if (callBackToController != null) {
ThreadStateMessage threadStateMessage =
new ThreadStateMessage(
threadNickName,
true,
"Received AirScout response\n"
+ reflectionInfo,
false
);
callBackToController.onThreadStatus(
threadNickName,
threadStateMessage
);
}
} catch (RuntimeException exception) {
System.out.println(
"[AirScout UDP, warning]: Could not process response: "
+ exception.getMessage()
);
}
}
public AirPlaneReflectionInfo processASUDPMessage(String udpStringToProcess) {
// System.out.println("RDUDPAS RECV: " + udpStringToProcess);
/*
* Example mesage: ASNEAREST: "AS" "KST"
* "2023-04-01 21:33:42Z,DO5AMF,JN49GL,G4CLA,IO92JL,9,VLG2PT,M,190,75,14,BAW809,M,250,50,18,BEL6CM,M,143,50,12,WZZ6719,M,148,50,11,KLM1678,M,313,75,22,TRA1B,M,271,75,20,SAS4728,M,125,75,9,RYR6TL,M,90,75,6,UAE10,S,96,50,6"
* Syntax: ASNEAREST: "AS" "KST" "2023-03-09
* 23:21:50Z,DO5AMF,JN49GL,DM5M,JO51IJ,3, SWT8TB,M,121,75,16, ^kleines Ding
* ^^^Distanz km ^^Potenzial 0-100% ^^Dauer bis ankunft minutes
*/
String[] fullStringSplitter;
String[] apStringSplitter;
AirPlaneReflectionInfo apInfo = new AirPlaneReflectionInfo();
List<AirPlane> airplaneList = new ArrayList<>();
if (udpStringToProcess.contains("ASNEAREST: ")) {
udpStringToProcess = udpStringToProcess.replace("ASNEAREST: ", "");
udpStringToProcess = udpStringToProcess.replace(" ", "");
fullStringSplitter = udpStringToProcess.split("\"");
// for (int i = 0; i < fullStringSplitter.length; i++) {
// System.out.println(i + " " + fullStringSplitter[i]);
// }
String APInfoString = fullStringSplitter[5];
apStringSplitter = APInfoString.split(",");
String[] allAPInfos = new String[apStringSplitter.length - 6]; // new String shold only provide aps, nothing
// other
for (int i = 0; i < apStringSplitter.length; i++) {
if (i >= 6) {
allAPInfos[i - 6] = apStringSplitter[i];
}
// System.out.println(i + ": " + apStringSplitter[i]);
// if (i>=6) {
// allAPInfos[i-6] = apStringSplitter[i];
// System.out.println(i-5 + " > " + apStringSplitter[i]);
//
//
//
// }
}
AirPlane airPlane = new AirPlane();
for (int i = 0; i < allAPInfos.length; i++) {
if (((i) % 5) == 0) {
airPlane = new AirPlane();
// airPlane = new AirPlane();
airPlane.setApCallSign(allAPInfos[i]);
// System.out.println(i + " AP: " + allAPInfos[i]);
} else if (((i) % 5) == 1) {
airPlane.setApSizeCategory(allAPInfos[i]);
// System.out.println(i + " cat: " + allAPInfos[i]);
} else if (((i) % 5) == 2) {
airPlane.setDistanceKm(Integer.parseInt(allAPInfos[i]));
// System.out.println(i + " dist: " + allAPInfos[i]);
} else if (((i) % 5) == 3) {
airPlane.setPotential(Integer.parseInt(allAPInfos[i]));
// System.out.println(i + " potential: " + allAPInfos[i]);
}
if (((i) % 5) == 4) {
// System.out.println(i + " duration: " + allAPInfos[i]);
airPlane.setArrivingDurationMinutes(Integer.parseInt(allAPInfos[i]));
airplaneList.add(airPlane);
}
}
apInfo.setDate(apStringSplitter[0]);
ChatMember apStartCallSign = new ChatMember();
apStartCallSign.setCallSign(apStringSplitter[1]);
apStartCallSign.setQra(apStringSplitter[2]);
apInfo.setSender(apStartCallSign); // usally its the callsign of own chatmember object, may check this
ChatMember apDestCallSign = new ChatMember();
apDestCallSign.setCallSign(apStringSplitter[3]);
apDestCallSign.setQra(apStringSplitter[4]);
apInfo.setReceiver(apDestCallSign);
apInfo.setAirPlanesReachableCntr(Integer.parseInt(apStringSplitter[5]));
apInfo.setRisingAirplanes(airplaneList);
// System.out.println("total airplanes for rx stn " + apInfo.getReceiver().getCallSign() + ": " + airplaneList.size() + " " + apInfo.toString());
airplaneList.sort(Comparator.comparing(AirPlane::getPotential).reversed()
.thenComparing(AirPlane::getArrivingDurationMinutes));
}
return apInfo;
}
public boolean terminateConnection() {
if (socket != null && !socket.isClosed()) {
socket.close();
}
return true;
}
}
@@ -0,0 +1,593 @@
package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.sql.SQLException;
import java.util.Arrays;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import kst4contest.ApplicationConstants;
import kst4contest.model.ThreadStateMessage;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import kst4contest.model.ChatMember;
import kst4contest.model.Band;
/**
* This thread is responsible for reading server's input and printing it to the
* console. It runs in an infinite loop until the client disconnects from the
* server.
*
* @author www.codejava.net
*/
public class ReadUDPbyUCXMessageThread extends Thread {
private BufferedReader reader;
private Socket socket;
private ChatController client;
private int udpPortNr = 12060;
private ThreadStatusCallback callBackToController;
private String ThreadNickName = "UDP-Log msg";
// public ReadUDPbyUCXMessageThread(int localPort , ThreadStatusCallback callback) {
//
//// this.callBackToController = callback;
// }
public ReadUDPbyUCXMessageThread(int localPort, ChatController client, ThreadStatusCallback callback) {
this.udpPortNr = localPort;
this.client = client;
this.callBackToController = callback;
}
@Override
public void interrupt() {
super.interrupt();
try {
if (this.socket != null) {
System.out.println(">>>>>>>>>>>>>>ReadUdpbyUCS: closing socket");
terminateConnection();
// callBackToController.onThreadStatus("UDPReceiver", new String[]);
}
} catch (Exception e) {
// TODO Auto-generated catch block
System.out.println("UCXUDPRDR: catched error " + e.getMessage());
}
}
/**
* Strips binary logger framing bytes before the XML payload. Some UCXLog packets
* contain transport bytes before the XML declaration.
*
* @param rawPacket raw UDP payload
* @return cleaned XML string or trimmed original text
*/
private String helper_extractXmlPayload(String rawPacket) {
if (rawPacket == null) {
return "";
}
int xmlStart = rawPacket.indexOf("<?xml");
if (xmlStart < 0) {
xmlStart = rawPacket.indexOf("<contactinfo");
}
if (xmlStart < 0) {
xmlStart = rawPacket.indexOf("<contactreplace");
}
if (xmlStart < 0) {
xmlStart = rawPacket.indexOf("<RadioInfo");
}
return xmlStart >= 0
? rawPacket.substring(xmlStart).trim()
: rawPacket.trim();
}
/**
* Reads an optional XML child node.
*
* @param element parent element
* @param tagName tag to read
* @return trimmed value or empty string
*/
private String helper_getOptionalElementText(Element element, String tagName) {
if (element == null || tagName == null) {
return "";
}
NodeList nodeList = element.getElementsByTagName(tagName);
if (nodeList == null || nodeList.getLength() == 0 || nodeList.item(0) == null) {
return "";
}
String textContent = nodeList.item(0).getTextContent();
return textContent == null ? "" : textContent.trim();
}
/**
* Resolves the QSO locator from UCXLog contactinfo. gridsquare is preferred,
* rcvnr is used as fallback for exchanges such as 001JO41HK.
*
* @param element contactinfo XML element
* @return normalized six-character locator or null
*/
private String helper_resolveLocatorFromContactInfo(Element element) {
String gridSquare = WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "gridsquare"));
if (gridSquare != null) {
return gridSquare;
}
return WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "rcvnr"));
}
public void run() {
System.out.println("ReadUDPByUCXLogThread: started Thread for UCXLog getUDP");
Thread.currentThread().setName("ReadUDPByUCXLogThread");
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
DatagramSocket socket = null;
boolean running;
byte[] buf = new byte[1777];
DatagramPacket packet = new DatagramPacket(buf, buf.length);
try {
// socket = new DatagramSocket(12060);
socket = new DatagramSocket(udpPortNr);
socket.setSoTimeout(2000); //TODO try for end properly
}
catch (SocketException e) {
//this will catch the repeating Sockettimeoutexception...nothing to do
// e.printStackTrace();
}
while (true) {
boolean timeOutIndicator = false;
// packet = new DatagramPacket(buf, buf.length); //TODO: Changed that due to memory leak, check if all works (seems like that)
// DatagramPacket packet = new DatagramPacket(SRPDefinitions.BYTE_BUFFER_MAX_LENGTH); //TODO: Changed that due to memory leak, check if all works (seems like that)
try {
socket.receive(packet);
} catch (SocketTimeoutException e2) {
timeOutIndicator = true;
// this will catch the repeating Sockettimeoutexception...nothing to do
// e2.printStackTrace();
}
catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (NullPointerException nE) {
// TODO Auto-generated catch block
nE.printStackTrace();
System.out.println("ReadUdpByUCXTH: Socket not ready");
try {
socket = new DatagramSocket(client.getChatPreferences().getLogsynch_ucxUDPWkdCallListenerPort());
socket.setSoTimeout(2000);
} catch (SocketException e) {
System.out.println("[ReadUDPByUCSMsgTH, Error]: socket in use or something:");
e.printStackTrace();
try {
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(client.getChatPreferences().getLogsynch_ucxUDPWkdCallListenerPort()));
socket.receive(packet);
socket.setSoTimeout(3000);
} catch (Exception ex) {
System.out.println("ReadUDPByUCXMsgTh: Could not solve that. Program Restart needed.");
throw new RuntimeException(ex);
}
}
}
InetAddress address = packet.getAddress();
int port = packet.getPort();
// packet = new DatagramPacket(buf, buf.length, address, port);
String received = new String(packet.getData(), packet.getOffset(), packet.getLength());
received = received.trim();
// System.out.println("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<recv " + received);
if (received.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
System.out.println("ReadUdpByUCX, Info: got poison, now dieing....");
socket.close();
timeOutIndicator = true;
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "stopped";
// threadStatusMessage[1] = "by poisonpill message (disconnect on purpose)";
threadStateMessage = new ThreadStateMessage(this.ThreadNickName, false, "stopped by Poisonpill", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
break;
}
if (this.client.isDisconnectionPerformedByUser()) {
break;//TODO: what if it´s not the finally closage but a band channel change?
}
if (!timeOutIndicator) {
processUCXUDPMessage(received);
} else {
//dont process the empty message
}
buf = new byte[1777]; // reset buffer for future smaller packets
}
}
public String processUCXUDPMessage(String udpPacketToProcess) {
File logUDPMessageToThisFile;
String udpMsg = helper_extractXmlPayload(udpPacketToProcess);
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received Message\n" + udpMsg, false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
try {
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
} catch (ParserConfigurationException e1) {
e1.printStackTrace();
}
try {
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new InputSource(new StringReader(udpMsg)));
/**
* case Log-QSO-Packet in ucxlog / DXLog and compatible
*
*/
NodeList list = doc.getElementsByTagName("contactinfo");
if (list.getLength() == 0) {
list = doc.getElementsByTagName("contactreplace");
//DXlog will send contactreplace instead of contactinfo on clicking "broadcast whole logbook"
}
if (list.getLength() != 0) {
/*
* QSO and TRX information share the same UDP receiver. The log-sync
* preference therefore controls processing, not ownership of the socket.
*/
if (!client.getChatPreferences().isLogsynch_ucxUDPWkdCallListenerEnabled()) {
return "";
}
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
String call = helper_getOptionalElementText(element, "call");
String rawBand = helper_getOptionalElementText(element, "band");
String gridSquare = helper_resolveLocatorFromContactInfo(element);
String points = helper_getOptionalElementText(element, "points");
LoggedQsoBand loggedBand = LoggedQsoBand.fromLoggerValue(rawBand);
ExternalLoggedQso loggedQso = ExternalLoggedQso.create(
call, loggedBand, gridSquare, "UCXLOG").orElse(null);
if (loggedQso == null) {
System.out.println("[ReadUDPFromUCX, warning]: QSO packet without usable callsign ignored");
continue;
}
System.out.println("[Readudp, info ]: received Current Element :" + node.getNodeName()
+ "call: " + call + " / " + rawBand + " ----> " + points + " POINTS");
// client.getChatPreferences().setBcn_contestScoreSum(Long.parseLong(points));
ChatMember workedCall = loggedQso.toWorkedChatMember();
Band workedBand = loggedBand == null ? null : loggedBand.getProjectBand();
if (loggedBand == null && !rawBand.isEmpty()) {
System.out.println("[ReadUDPFromUCX, warning]: unexpected band value: \"" + rawBand + "\"");
}
{
/**
* That means, the station is worked already but maybe at another band. So we
* have to get the worked ChatMember out of the list and to modify the worked
* options.
*/
// modifyThat = (ChatMember) client.getMap_ucxLogInfoWorkedCalls().get(call);
// asd //TODO: Check if callsign and callsignraw is similar, then mark first and further via new checklistforchatmembermultiplemethod with array of indize
client.applyExternalLoggedQso(loggedQso);
/**
* old mechanic to markup worked stations in the chatmember table
*/
// int indexOfChatMemberInTable = -1; //chatmember not in table
// indexOfChatMemberInTable = client.checkListForChatMemberIndexByCallSign(workedCall);
//
// if (indexOfChatMemberInTable == -1) {
// // do nothing
// } else {
// modifyThat = client.getLst_chatMemberList().get(indexOfChatMemberInTable);
//
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)).setWorked(true);
//
// if (workedCall.isWorked144()) {
// modifyThat.setWorked144(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked144(true);
//
// } else if (workedCall.isWorked432()) {
// modifyThat.setWorked432(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked432(true);
//
// } else if (workedCall.isWorked1240()) {
// modifyThat.setWorked1240(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked1240(true);
//
// } else if (workedCall.isWorked2300()) {
// modifyThat.setWorked2300(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked2300(true);
//
// } else if (workedCall.isWorked3400()) {
// modifyThat.setWorked3400(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked3400(true);
//
// } else if (workedCall.isWorked5600()) {
// modifyThat.setWorked5600(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked5600(true);
//
// } else if (workedCall.isWorked10G()) {
// modifyThat.setWorked10G(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked10G(true);
// }
/**
* //TODO: following line is a quick fix to making disappear worked chatmembers of the list
* Thats uncomfortable due to this also causes selection changes,
* Better way is to change all worked and qrv values to observables and then trigger the underlying
* list to fire an invalidationevent. Really Todo!
*/
// try{
//
// GuiUtils.triggerGUIFilteredChatMemberListChange(client); //not clean at all
// } catch (Exception IllegalStateException) {
// //do nothing, as it works...
// }
// }
/**
* end -> old mechanic to markup worked stations in the chatmember table
*/
}
if (workedBand != null && gridSquare != null) {
this.client.registerWorkedGrossField(
workedBand, gridSquare, workedCall, loggedQso.getSource());
}
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
logUDPMessageToThisFile = new File(this.client.getChatPreferences()
.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup());
FileWriter fileWriterPersistUDPToFile = null;
BufferedWriter bufwrtrRawMSGOut;
try {
fileWriterPersistUDPToFile = new FileWriter(logUDPMessageToThisFile, true);
} catch (IOException e1) {
e1.printStackTrace();
}
bufwrtrRawMSGOut = new BufferedWriter(fileWriterPersistUDPToFile);
bufwrtrRawMSGOut.write("\n" + workedCall.toString());
bufwrtrRawMSGOut.flush();
bufwrtrRawMSGOut.close();
}
}
} else {
list = doc.getElementsByTagName("RadioInfo");
/*
* RadioInfo packets may arrive on the shared UDP port even when automatic
* QRG synchronization is disabled. Ignore them unless TRX sync is enabled.
*/
if (list.getLength() != 0
&& !client.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled()) {
return "";
}
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
if (node.getNodeType() == Node.ELEMENT_NODE) {
String formattedQRG;
Element element = (Element) node;
String qrg = element.getElementsByTagName("Freq").item(0).getTextContent();
String mode = element.getElementsByTagName("Mode").item(0).getTextContent();
// System.out.println("QRG Length: " + qrg.length() + " // " + qrg);
/**
* The following if statement is only for formatting the frequency input for
* good readability to avoid values like 129601000 and set it to something
* readable like 1296.010.00
*
*/
if (qrg.length() == 6) {
// 701000 KHz
formattedQRG = qrg.format("%s.%s.%s", qrg.substring(0, 1), qrg.substring(2, 5),
qrg.substring(5, 6));
} else if (qrg.length() == 7) {
// 700000 KHz
formattedQRG = qrg.format("%s.%s.%s", qrg.substring(0, 2), qrg.substring(2, 5),
qrg.substring(5, 7));
} else if (qrg.length() == 8) {
// 144.123.22 KHz
formattedQRG = qrg.format("%s.%s.%s", qrg.substring(0, 3), qrg.substring(3, 6),
qrg.substring(6, 8));
} else if (qrg.length() == 9) {
// 1296.010.00
formattedQRG = qrg.format("%s.%s.%s", qrg.substring(0, 4), qrg.substring(4, 7),
qrg.substring(7, 9));
} else if (qrg.length() == 10) {
// 10000.010.00
formattedQRG = qrg.format("%s.%s.%s", qrg.substring(0, 5), qrg.substring(5, 8),
qrg.substring(8, 10));
}
else {
formattedQRG = qrg;
}
// System.out.println("Current Element :" + node.getNodeName());
// System.out.println("Radio QRG : " + qrg);
// System.out.println("Radio Mode: " + mode);
// System.out.println("[ReadUDPFromUCX, Info:] Setted QRG pref to: \"" + qrg + "\"" );
// this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
final String finalFormattedQRG = formattedQRG;
helper_runOnFxThread(() ->
this.client.getChatPreferences().getMYQRGFirstCat().set(finalFormattedQRG)
);
// System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG);
}
}
}
} catch (ParserConfigurationException | SAXException | IOException e) {
e.printStackTrace();
System.out.println(e.getCause());
System.out.println(e.getMessage());
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "STOPPED";
// threadStatusMessage[1] = Arrays.toString(e.getStackTrace());
threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true);
threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace()));
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
} catch (SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true);
threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace()));
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "STOPPED";
// threadStatusMessage[1] = Arrays.toString(e.getStackTrace());
// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage);
}
// System.out.println("[ReadUDPbyUCXTh: ] worked size = " + this.client.getMap_ucxLogInfoWorkedCalls().size());
// System.out.println("[ReadUDPbyUCXTh: ] worked size = removeThisActions" );
return "";
}
public boolean terminateConnection() throws IOException {
// String[] threadStatusMessage = new String[2];
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "STOPPED";
// threadStatusMessage[1] = "Connection terminated for purpose.";
// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage);
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, false, "terminated", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
this.socket.close();
return true;
}
/**
* Runs UI-bound changes on the JavaFX application thread.
*
* <p>UCXLog UDP packets are processed in a background thread. Some preference
* properties are bound to JavaFX controls, so setting them directly from this
* thread can crash JavaFX with "Not on FX application thread".</p>
*
* @param runnable UI-bound update
*/
private void helper_runOnFxThread(Runnable runnable) {
if (runnable == null) {
return;
}
if (client.uiDispatcher().isUiThread()) {
runnable.run();
} else {
client.uiDispatcher().runOnUi(runnable);
}
}
}
@@ -0,0 +1,86 @@
package kst4contest.controller;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import kst4contest.model.ChatMember;
public class RegexTester {
public static void main(String[] args) {
String temp;
temp = "DL1KDA JO30EP Alex\r\n"
+ "DL3LST JO61FI Ray(Rainer)\r\n"
+ "DL4ZAQ JN49MP Peter 2m&70cm&6m\r\n"
+ "DL9LBH JN59ID Hans 2/70/23\r\n"
+ "DO5AMF JN49FK Marc\r\n"
+ "F5ICN JN03BF Alex QRV 2/70/23\r\n"
+ "F6GRA JN04DB Carol 2m\r\n"
+ "F6HTJ JN12KQ Michel\r\n"
+ "G0BIX JO01GI Terry\r\n"
+ "G0HOF IO92UL Ken 11el + 400 W\r\n"
+ "G0LBK JO03BD David\r\n"
+ "G0MBL JO01QH Andrew 2m\r\n"
+ "G1SDX IO80FL Grant\r\n"
+ "G3MXH JO02LF Terry\r\n"
+ "G3OVH IO92JP Tony\r\n"
+ "G3VCA IO93MG bob\r\n"
+ "G4AEP IO91MB bill\r\n"
+ "G4DHF IO92UU David\r\n"
+ "G4FUF JO01GN Keith\r\n"
+ "G4TRA IO81WN Steve\r\n"
+ "G6HKS IO92OB Richard\r\n"
+ "G8SEI IO92FO Jeff 6/4/2/70\r\n"
+ "GM0EWX IO67UL Calum\r\n"
+ "GW8IZR IO73TI Paul\r\n"
+ "IV3GTH JN65RU Gigi\r\n"
+ "IZ2XZM JN45KH Nick\r\n"
+ "LY1BWB KO24PR VU Club 2/70\r\n"
+ "LY2EN KO24PQ QRT\r\n"
+ "OK1FPR JO80CE Milos\r\n"
+ "OV3T JO46CM Thomas\r\n"
+ "OY4TN IP62NB Trygvi 11EL/350W\r\n"
+ "OZ2M JO65FR Bo PNGonVHF\r\n"
+ "OZ3Z JO45UN Anders @432.228\r\n"
+ "OZ7UV JO65DH Svend\r\n"
+ "PA3DOL JO22MT Sjoerd\r\n"
+ "PA3PCV JO20VV Marcel 2m\r\n"
+ "PA9R JO22JK Rob\r\n"
+ "S52FO JN76EF Janez\r\n"
+ "S53RM JN76HD Sine\r\n"
+ "SM2CEW KP15CR Peter\r\n"
+ "SP6VGJ JO81HU Jacek";
Pattern pattern = Pattern.compile("([a-zA-Z0-9]{2}/{1})?([a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z]{0,3})(/p)? [a-zA-Z]{2}[0-9]{2}[a-zA-Z]{2} [ -~]{1,20}");
Matcher matcher = pattern.matcher(temp);
/**
* "([a-zA-Z0-9]{1,2}\/)?[a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z](\/(p|m))?( )[a-zA-Z]{2}[0-9]{2}[a-zA-Z]{2}[ -~]{0,30}"gm
* Thats a line of the show users list
*/
while (matcher.find()) {
// System.out.println("Chatmember detected: "+ matcher.group() + " " + matcher.start());
ChatMember member = new ChatMember();
String matchedString = matcher.group();
String[] splittedUserString;
splittedUserString = matchedString.split(" ");
member.setCallSign(splittedUserString[0]);
member.setQra(splittedUserString[1]);
String stringAggregation = "";
for (int i = 2; i < splittedUserString.length; i++) {
stringAggregation += splittedUserString[i] + " ";
}
member.setName(stringAggregation);
System.out.println("Call: " + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName());
}
}
}
@@ -0,0 +1,354 @@
package kst4contest.controller;
import kst4contest.logic.PriorityCalculator;
import kst4contest.observe.MutableValue;
import kst4contest.observe.ObservableRoster;
import kst4contest.observe.ObservableValue;
import kst4contest.observe.SimpleRoster;
import kst4contest.observe.SimpleValue;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ContestSked;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* Calculates priority scores off the JavaFX thread and publishes a small UI model.
*
* Design goals:
* - No per-member UI hand-off flooding.
* - Score is computed once per callsignRaw (e.g. "SM6VTZ"), even if it exists in multiple chat categories.
* - A routing hint (preferred ChatCategory) is kept using "last inbound category" if available.
*/
public final class ScoreService {
public static final int DEFAULT_TOP_N = 15; //how many top places we have?
/** Force a refresh at least every X ms (some scoring inputs are time dependent). */
private static final long MAX_SNAPSHOT_AGE_MS = 10_000L;
private final ChatController controller;
private final PriorityCalculator priorityCalculator;
private final AtomicBoolean recomputeRequested = new AtomicBoolean(true);
private final AtomicReference<ScoreSnapshot> latestSnapshot = new AtomicReference<>(ScoreSnapshot.empty());
// UI outputs
private final SimpleRoster<TopCandidate> topCandidates = new SimpleRoster<>();
private final MutableValue<Double> selectedCallPriorityScore = new SimpleValue<>(Double.NaN);
private final MutableValue<Long> uiPulse = new SimpleValue<>(0L);
private volatile String selectedCallSignRaw;
private volatile long lastComputedEpochMs = 0L;
private final int topN;
private final MutableValue<ChatMember> selectedChatMember = new SimpleValue<>(null);
public ScoreService(ChatController controller, PriorityCalculator priorityCalculator, int topN) {
this.controller = Objects.requireNonNull(controller, "controller");
this.priorityCalculator = Objects.requireNonNull(priorityCalculator, "priorityCalculator");
this.topN = topN > 0 ? topN : DEFAULT_TOP_N;
}
public ObservableRoster<TopCandidate> topCandidates() {
return topCandidates;
}
public ObservableValue<Double> selectedCallPriorityScore() {
return selectedCallPriorityScore;
}
/**
* A lightweight UI invalidation signal that increments after every published snapshot.
* Consumers can refresh small panels (timeline/toplist), but should avoid refreshing huge tables.
*/
public ObservableValue<Long> uiPulse() {
return uiPulse;
}
public ScoreSnapshot getLatestSnapshot() {
return latestSnapshot.get();
}
/** Coalesced recompute request (safe to call frequently from other threads). */
public void requestRecompute(String reason) {
recomputeRequested.set(true);
}
/** Called by UI when selection changes. */
public void setSelectedChatMember(ChatMember member) {
// keep a central selection for UI actions (FurtherInfo buttons, timeline clicks, etc.)
if (controller.uiDispatcher().isUiThread()) {
selectedChatMember.set(member);
} else {
controller.uiDispatcher().runOnUi(() -> selectedChatMember.set(member));
}
selectedCallSignRaw = member == null ? null : normalizeCallRaw(member.getCallSignRaw());
// Update score immediately from the latest snapshot
if (controller.uiDispatcher().isUiThread()) {
updateSelectedScoreFromSnapshot(latestSnapshot.get());
} else {
controller.uiDispatcher().runOnUi(() -> updateSelectedScoreFromSnapshot(latestSnapshot.get()));
}
}
/**
* Called periodically by the scheduler thread.
* Recomputes only if explicitly requested or if the snapshot is too old.
*/
public void tick() {
long now = System.currentTimeMillis();
boolean shouldRecompute = recomputeRequested.getAndSet(false) || (now - lastComputedEpochMs) > MAX_SNAPSHOT_AGE_MS;
if (!shouldRecompute) return;
try {
// Apply "no reply" strikes (operator pinged via /cq but no inbound line arrived)
controller.getStationMetricsService().evaluateNoReplyTimeouts(now, controller.getChatPreferences());
recompute(now);
} catch (Exception e) {
System.err.println("[ScoreService] CRITICAL error while recomputing scores");
e.printStackTrace();
}
}
private void recompute(long nowEpochMs) {
// Keep sked list clean (must happen on FX thread)
controller.requestRemoveExpiredSkeds(nowEpochMs);
final List<ChatMember> members = controller.snapshotChatMembers();
final List<ContestSked> activeSkeds = controller.snapshotActiveSkeds();
final ChatPreferences prefs = controller.getChatPreferences();
final Map<String, ChatCategory> lastInbound = controller.snapshotLastInboundCategoryMap();
StationMetricsService.Snapshot metricsSnapshot =
controller.getStationMetricsService().snapshot(nowEpochMs, prefs);
// 1) Choose one representative per callsignRaw
Map<String, List<ChatMember>> variantsByCallRaw = groupMembersByCallRaw(members);
Map<String, ChatMember> representativeByCallRaw =
chooseRepresentativeMembers(variantsByCallRaw, lastInbound);
// 2) Compute score once per callsignRaw
Map<String, Double> scoreByCallRaw = new HashMap<>(representativeByCallRaw.size());
Map<String, ChatCategory> preferredCategoryByCallRaw = new HashMap<>(representativeByCallRaw.size());
List<TopCandidate> topAll = new ArrayList<>(representativeByCallRaw.size());
for (Map.Entry<String, ChatMember> e : representativeByCallRaw.entrySet()) {
String callRaw = e.getKey();
ChatMember representative = e.getValue();
if (representative == null) continue;
double score = priorityCalculator.calculatePriority(
representative,
variantsByCallRaw.getOrDefault(callRaw, List.of(representative)),
prefs,
activeSkeds,
metricsSnapshot,
nowEpochMs
);
scoreByCallRaw.put(callRaw, score);
preferredCategoryByCallRaw.put(callRaw, representative.getChatCategory());
if (Double.isFinite(score) && score > 0.0) {
topAll.add(new TopCandidate(
callRaw,
representative.getCallSign(),
representative.getChatCategory(),
score
));
}
}
// 3) Build Top-N
topAll.sort(Comparator.comparingDouble(TopCandidate::getScore).reversed());
List<TopCandidate> topNList = topAll.size() <= topN ? topAll : new ArrayList<>(topAll.subList(0, topN));
ScoreSnapshot snap = new ScoreSnapshot(
nowEpochMs,
Collections.unmodifiableMap(scoreByCallRaw),
Collections.unmodifiableMap(preferredCategoryByCallRaw),
Collections.unmodifiableList(topNList)
);
latestSnapshot.set(snap);
lastComputedEpochMs = nowEpochMs;
// 4) Publish to UI in ONE batched hand-off
controller.uiDispatcher().runOnUi(() -> {
applyScoreSnapshotToChatMembers(snap);
topCandidates.setAll(snap.getTopCandidates());
updateSelectedScoreFromSnapshot(snap);
uiPulse.set(uiPulse.get() + 1L);
});
}
private Map<String, List<ChatMember>> groupMembersByCallRaw(List<ChatMember> members) {
Map<String, List<ChatMember>> byCallRaw = new HashMap<>();
for (ChatMember member : members) {
if (member == null) continue;
String callRaw = normalizeCallRaw(member.getCallSignRaw());
if (callRaw == null || callRaw.isEmpty()) continue;
byCallRaw.computeIfAbsent(callRaw, ignored -> new ArrayList<>()).add(member);
}
return byCallRaw;
}
/**
* Picks one ChatMember object per callsignRaw.
* Preference order:
* 1) Variant in last inbound chat category (stable reply routing)
* 2) Most recently active variant (fallback)
*/
private Map<String, ChatMember> chooseRepresentativeMembers(
Map<String, List<ChatMember>> byCallRaw,
Map<String, ChatCategory> lastInboundCategoryByCallRaw
) {
Map<String, ChatMember> representative = new HashMap<>(byCallRaw.size());
for (Map.Entry<String, List<ChatMember>> entry : byCallRaw.entrySet()) {
String callRaw = entry.getKey();
List<ChatMember> variants = entry.getValue();
ChatCategory preferredCat = lastInboundCategoryByCallRaw.get(callRaw);
ChatMember chosen = null;
if (preferredCat != null) {
chosen = variants.stream()
.filter(Objects::nonNull)
.filter(v -> isSameChatCategory(v.getChatCategory(), preferredCat))
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
.orElse(null);
}
if (chosen == null) {
chosen = variants.stream()
.filter(Objects::nonNull)
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
.orElse(null);
}
if (chosen != null) representative.put(callRaw, chosen);
}
return representative;
}
private static boolean isSameChatCategory(ChatCategory left, ChatCategory right) {
return left != null
&& right != null
&& left.getCategoryNumber() == right.getCategoryNumber();
}
/**
* Projects the immutable score snapshot back into ChatMember display fields so
* the normal station table can sort/filter by score without knowing the score
* calculation internals.
*
* @param snap latest score snapshot
*/
private void applyScoreSnapshotToChatMembers(ScoreSnapshot snap) {
if (snap == null) {
return;
}
Map<String, Double> scoreByCallSignRaw = snap.getScoreByCallSignRaw();
for (ChatMember member : controller.snapshotChatMembers()) {
if (member == null || member.getCallSignRaw() == null) {
continue;
}
Double score = scoreByCallSignRaw.get(normalizeCallRaw(member.getCallSignRaw()));
member.setCurrentPriorityScore(score == null ? 0.0 : score);
}
controller.fireUserListUpdate();
}
private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) {
if (snap == null || selectedCallSignRaw == null) {
selectedCallPriorityScore.set(Double.NaN);
return;
}
Double v = snap.getScoreByCallSignRaw().get(selectedCallSignRaw);
selectedCallPriorityScore.set(v == null ? Double.NaN : v);
}
private static String normalizeCallRaw(String callRaw) {
if (callRaw == null) return null;
return callRaw.trim().toUpperCase();
}
// ------------------------- DTOs -------------------------
public static final class TopCandidate {
private final String callSignRaw;
private final String displayCallSign;
private final ChatCategory preferredChatCategory;
private final double score;
public TopCandidate(String callSignRaw, String displayCallSign, ChatCategory preferredChatCategory, double score) {
this.callSignRaw = callSignRaw;
this.displayCallSign = displayCallSign;
this.preferredChatCategory = preferredChatCategory;
this.score = score;
}
public String getCallSignRaw() { return callSignRaw; }
public String getDisplayCallSign() { return displayCallSign; }
public ChatCategory getPreferredChatCategory() { return preferredChatCategory; }
public double getScore() { return score; }
}
public static final class ScoreSnapshot {
private final long computedAtEpochMs;
private final Map<String, Double> scoreByCallSignRaw;
private final Map<String, ChatCategory> preferredCategoryByCallSignRaw;
private final List<TopCandidate> topCandidates;
public ScoreSnapshot(long computedAtEpochMs,
Map<String, Double> scoreByCallSignRaw,
Map<String, ChatCategory> preferredCategoryByCallSignRaw,
List<TopCandidate> topCandidates) {
this.computedAtEpochMs = computedAtEpochMs;
this.scoreByCallSignRaw = scoreByCallSignRaw;
this.preferredCategoryByCallSignRaw = preferredCategoryByCallSignRaw;
this.topCandidates = topCandidates;
}
public static ScoreSnapshot empty() {
return new ScoreSnapshot(System.currentTimeMillis(), Collections.emptyMap(), Collections.emptyMap(), Collections.emptyList());
}
public long getComputedAtEpochMs() { return computedAtEpochMs; }
public Map<String, Double> getScoreByCallSignRaw() { return scoreByCallSignRaw; }
public Map<String, ChatCategory> getPreferredCategoryByCallSignRaw() { return preferredCategoryByCallSignRaw; }
public List<TopCandidate> getTopCandidates() { return topCandidates; }
}
public ObservableValue<ChatMember> selectedChatMember() {
return selectedChatMember;
}
public ChatMember getSelectedChatMember() {
return selectedChatMember.get();
}
}
@@ -0,0 +1,63 @@
package kst4contest.controller;
import kst4contest.model.ChatMessage;
import java.util.TimerTask;
/**
* This class is updateing the scoreboard at https://slovhf.net/claimed/. Gets scores of all bands out of the
* preferences which is updated via ReadUdpByUCXLog Thread.
*
* api description: https://slovhf.net/claimed-score-api/
*
* <br/><br/>
* The task will be runned out of the singleton ChatController instance in an
* intervall as specified by the Chatpreferences-instance (typically as
* configured in the xml file.
*
*
* @author prakt
*
*/
public class ScoreboardUpdateTask extends TimerTask {
private ChatController chatController;
public ScoreboardUpdateTask(ChatController client) {
this.chatController = client;
}
@Override
public void run() {
Thread.currentThread().setName("BeaconTask");
ChatMessage beaconMSG = new ChatMessage();
MessageVariableResolver variableResolver =
new MessageVariableResolver(this.chatController.getChatPreferences());
String replaceVariables = variableResolver.resolveGlobalVariables(
this.chatController.getChatPreferences().getBcn_beaconTextMainCat()
);
beaconMSG.setMessageText(
"MSG|" + this.chatController.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber() + "|0|" + replaceVariables + "|0|");
beaconMSG.setMessageDirectedToServer(true);
// System.out.println("########### " + replaceVariables);
if (this.chatController.getChatPreferences().isBcn_beaconsEnabledMainCat() ) {
System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
+ " [BeaconTask, Info]: Sending CQ: " + beaconMSG.getMessageText());
this.chatController.getMessageTXBus().add(beaconMSG);
} else {
//do nothing, CQ is disabled
}
}
}
@@ -0,0 +1,141 @@
package kst4contest.controller;
import kst4contest.model.ChatCategory;
import kst4contest.model.ThreadStateMessage;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
/**
* Schedules PM reminders for a specific sked time.
*
* Requirements:
* - Reminder goes out as PM to the station (via "/cq CALL ...").
* - Reminders are armed manually from FurtherInfo.
*/
public final class SkedReminderService {
private final ChatController controller;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r);
t.setDaemon(true);
t.setName("SkedReminderService");
return t;
});
private final ConcurrentHashMap<String, List<ScheduledFuture<?>>> scheduledByCallRaw = new ConcurrentHashMap<>();
public SkedReminderService(ChatController controller) {
this.controller = controller;
}
/**
* Cancels every armed reminder and stops the scheduler thread.
*
* <p>Called when the runtime that owns this service is torn down, so a discarded
* runtime does not keep a thread and pending reminders alive.</p>
*/
public void shutdown() {
for (List<ScheduledFuture<?>> remindersOfOneCall : scheduledByCallRaw.values()) {
for (ScheduledFuture<?> armedReminder : remindersOfOneCall) {
armedReminder.cancel(false);
}
}
scheduledByCallRaw.clear();
scheduler.shutdownNow();
}
/**
* Arms reminders for one sked. Existing reminders for this call are cancelled.
*
* @param callSignRaw target call
* @param preferredCategory where to send (if null, controller resolves via lastInbound category)
* @param skedTimeEpochMs sked time
* @param offsetsMinutes e.g. [5,2,1] => reminders 5,2,1 minutes before
*/
public void armReminders(String callSignRaw,
ChatCategory preferredCategory,
long skedTimeEpochMs,
List<Integer> offsetsMinutes) {
String callRaw = normalize(callSignRaw);
if (callRaw == null || callRaw.isBlank()) return;
cancelReminders(callRaw);
long now = System.currentTimeMillis();
List<Integer> offsets = (offsetsMinutes == null) ? List.of() : offsetsMinutes;
List<ScheduledFuture<?>> futures = new ArrayList<>();
for (Integer offMin : offsets) {
if (offMin == null) continue;
long fireAt = skedTimeEpochMs - (offMin * 60_000L);
long delayMs = fireAt - now;
if (delayMs <= 0) continue;
ScheduledFuture<?> f = scheduler.schedule(
() -> fireReminder(callRaw, preferredCategory, offMin),
delayMs,
TimeUnit.MILLISECONDS
);
futures.add(f);
}
scheduledByCallRaw.put(callRaw, futures);
controller.onThreadStatus("SkedReminderService",
new ThreadStateMessage("SkedReminder", true,
"Armed for " + callRaw + " (" + offsets + " min before)", false));
}
public void cancelReminders(String callSignRaw) {
String callRaw = normalize(callSignRaw);
if (callRaw == null || callRaw.isBlank()) return;
List<ScheduledFuture<?>> futures = scheduledByCallRaw.remove(callRaw);
if (futures != null) {
for (ScheduledFuture<?> f : futures) {
if (f != null) f.cancel(false);
}
}
}
private void fireReminder(String callRaw, ChatCategory preferredCategory, int minutesBefore) {
try {
controller.queuePrivateCqMessage(callRaw, preferredCategory, "[KST4C Autoreminder] sked in " + minutesBefore + " min");
controller.fireUiReminderEvent(callRaw, minutesBefore); //triggers some blingbling in the UI
///Local acoustic hint (reuse existing project audio utilities, no AWT, no extra JavaFX modules)
try {
if (controller.getChatPreferences().isNotify_playSimpleSounds()) {
controller.getPlayAudioUtils().playNoiseLauncher('!'); // choose a suitable char you already use
}
// Optional: voice/cw hint (short, not too intrusive)
// controller.getPlayAudioUtils().playCWLauncher(" SKED " + minutesBefore);
} catch (Exception ignore) {
// never block reminder sending because of audio issues
}
controller.onThreadStatus("SkedReminderService",
new ThreadStateMessage("SkedReminder", true,
"PM reminder sent to " + callRaw + " (" + minutesBefore + " min)", false));
} catch (Exception e) {
controller.onThreadStatus("SkedReminderService",
new ThreadStateMessage("SkedReminder", false,
"ERROR sending reminder to " + callRaw + ": " + e.getMessage(), true));
e.printStackTrace();
}
}
private static String normalize(String s) {
if (s == null) return null;
return s.trim().toUpperCase();
}
}
@@ -0,0 +1,31 @@
/**
*
*/
package kst4contest.controller;
import java.io.IOException;
/**
* @author mywire
*
*/
public class StartChat {
/**
* @param args
* @throws InterruptedException
* @throws IOException
*/
public static void main(String[] args) throws InterruptedException, IOException {
System.out.println("[Startchat:] Starting new Chat instance");
// ObservableStringValue messageBus = new SimpleStringProperty("");
ChatController client = new ChatController();
client.execute();
}
}
@@ -0,0 +1,269 @@
package kst4contest.controller;
import kst4contest.logic.SignalDetector;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ChatMember;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Thread-safe metrics store keyed by normalized callsignRaw (e.g. "SM6VTZ").
*
* Purpose:
* - Provide inputs for scoring (momentum, reply time, no-reply strikes, manual sked-fail, positive signals).
* - Decouple MessageBus / TX from ScoreService (only data flows, no UI calls here).
*/
public final class StationMetricsService {
/** /cq <CALL> ... */
private static final Pattern OUTBOUND_CQ_PATTERN = Pattern.compile("(?i)^\\s*/cq\\s+([A-Z0-9/-]+)\\b.*");
/** Rolling window timestamps for momentum scoring. */
private static final int MAX_STORED_INBOUND_TIMESTAMPS = 32;
private final ConcurrentHashMap<String, StationMetrics> byCallRaw = new ConcurrentHashMap<>();
/**
* Called when the operator sends a message.
* If it is a "/cq CALL ..." message, this arms a pending ping for response-time / no-reply tracking.
*/
public Optional<String> tryRecordOutboundCq(String messageText, long nowEpochMs) {
if (messageText == null) return Optional.empty();
Matcher m = OUTBOUND_CQ_PATTERN.matcher(messageText.trim());
if (!m.matches()) return Optional.empty();
String callRaw = normalizeCallRaw(m.group(1));
if (callRaw == null || callRaw.isBlank()) return Optional.empty();
StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics());
synchronized (metrics) {
metrics.pendingCqSentAtEpochMs = nowEpochMs;
metrics.lastOutboundCqEpochMs = nowEpochMs;
}
return Optional.of(callRaw);
}
/**
* Called for EVERY inbound line from a station (CH or PM).
* "Any line counts as activity"
*/
public void onInboundMessage(String senderCallSignRaw,
long nowEpochMs,
String messageText,
ChatPreferences prefs,
String ownCallSignRaw) {
String callRaw = normalizeCallRaw(senderCallSignRaw);
if (callRaw == null || callRaw.isBlank()) return;
// ignore own echoed messages
if (ownCallSignRaw != null && callRaw.equalsIgnoreCase(normalizeCallRaw(ownCallSignRaw))) return;
StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics());
synchronized (metrics) {
metrics.lastInboundEpochMs = nowEpochMs;
// rolling timestamps (momentum)
metrics.recentInboundEpochMs.addLast(nowEpochMs);
while (metrics.recentInboundEpochMs.size() > MAX_STORED_INBOUND_TIMESTAMPS) {
metrics.recentInboundEpochMs.removeFirst();
}
// positive signal detection (extendable by prefs)
if (messageText != null && prefs != null) {
if (SignalDetector.containsPositiveSignal(messageText, prefs.getNotify_positiveSignalsPatterns())) {
metrics.lastPositiveSignalEpochMs = nowEpochMs;
}
}
// response time measurement: any inbound line ends a pending ping
if (metrics.pendingCqSentAtEpochMs > 0) {
long rttMs = Math.max(0, nowEpochMs - metrics.pendingCqSentAtEpochMs);
metrics.pendingCqSentAtEpochMs = 0;
// EWMA for response time (stable, no spikes)
final double alpha = 0.25;
if (metrics.avgResponseTimeMs <= 0) {
metrics.avgResponseTimeMs = rttMs;
} else {
metrics.avgResponseTimeMs = alpha * rttMs + (1.0 - alpha) * metrics.avgResponseTimeMs;
}
}
}
}
/**
* Called periodically (e.g. from ScoreService.tick()).
* Applies a "no reply" strike if the pending ping is older than prefs timeout.
*/
public void evaluateNoReplyTimeouts(long nowEpochMs, ChatPreferences prefs) {
if (prefs == null) return;
long timeoutMs = Math.max(1, prefs.getNotify_noReplyPenaltyMinutes()) * 60_000L;
for (Map.Entry<String, StationMetrics> e : byCallRaw.entrySet()) {
StationMetrics metrics = e.getValue();
if (metrics == null) continue;
synchronized (metrics) {
if (metrics.pendingCqSentAtEpochMs <= 0) continue;
long age = nowEpochMs - metrics.pendingCqSentAtEpochMs;
if (age >= timeoutMs) {
metrics.pendingCqSentAtEpochMs = 0;
metrics.noReplyStrikes++;
metrics.lastNoReplyStrikeEpochMs = nowEpochMs;
}
}
}
}
/** Manual sked fail: permanent until reset. */
public void markManualSkedFail(String callSignRaw) {
String callRaw = normalizeCallRaw(callSignRaw);
if (callRaw == null || callRaw.isBlank()) return;
StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics());
synchronized (metrics) {
metrics.manualSkedFailed = true;
metrics.manualSkedFailCount++;
}
}
public void resetManualSkedFail(String callSignRaw) {
String callRaw = normalizeCallRaw(callSignRaw);
if (callRaw == null || callRaw.isBlank()) return;
StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics());
synchronized (metrics) {
metrics.manualSkedFailed = false;
metrics.manualSkedFailCount = 0;
}
}
public boolean isManualSkedFailed(String callSignRaw) {
String callRaw = normalizeCallRaw(callSignRaw);
if (callRaw == null || callRaw.isBlank()) return false;
StationMetrics metrics = byCallRaw.get(callRaw);
if (metrics == null) return false;
synchronized (metrics) {
return metrics.manualSkedFailed;
}
}
/** Immutable snapshot for scoring */
public Snapshot snapshot(long nowEpochMs, ChatPreferences prefs) {
long momentumWindowMs = (prefs != null ? prefs.getNotify_momentumWindowSeconds() : 180) * 1000L;
Snapshot snap = new Snapshot(nowEpochMs, momentumWindowMs);
for (Map.Entry<String, StationMetrics> e : byCallRaw.entrySet()) {
String callRaw = e.getKey();
StationMetrics m = e.getValue();
if (m == null) continue;
synchronized (m) {
snap.byCallRaw.put(callRaw, new Snapshot.Metrics(
m.lastInboundEpochMs,
countRecent(m.recentInboundEpochMs, nowEpochMs, momentumWindowMs),
m.avgResponseTimeMs,
m.noReplyStrikes,
m.manualSkedFailed,
m.manualSkedFailCount,
m.lastPositiveSignalEpochMs
));
}
}
return snap;
}
private static int countRecent(Deque<Long> timestamps, long nowEpochMs, long windowMs) {
if (timestamps == null || timestamps.isEmpty()) return 0;
int cnt = 0;
for (Long t : timestamps) {
if (t == null) continue;
if (nowEpochMs - t <= windowMs) cnt++;
}
return cnt;
}
private static String normalizeCallRaw(String s) {
if (s == null) return null;
return ChatMember.normalizeCallSignToBaseCallSign(s);
}
private static final class StationMetrics {
long lastInboundEpochMs;
long lastOutboundCqEpochMs;
long pendingCqSentAtEpochMs; // 0 = none
int noReplyStrikes;
long lastNoReplyStrikeEpochMs;
double avgResponseTimeMs; // EWMA
final Deque<Long> recentInboundEpochMs = new ArrayDeque<>();
long lastPositiveSignalEpochMs;
boolean manualSkedFailed;
int manualSkedFailCount;
}
public static final class Snapshot {
private final long snapshotEpochMs;
private final long momentumWindowMs;
private final ConcurrentHashMap<String, Metrics> byCallRaw = new ConcurrentHashMap<>();
private Snapshot(long snapshotEpochMs, long momentumWindowMs) {
this.snapshotEpochMs = snapshotEpochMs;
this.momentumWindowMs = momentumWindowMs;
}
public Metrics get(String callSignRaw) {
if (callSignRaw == null) return null;
return byCallRaw.get(normalizeCallRaw(callSignRaw));
}
public long getSnapshotEpochMs() {
return snapshotEpochMs;
}
public long getMomentumWindowMs() {
return momentumWindowMs;
}
public static final class Metrics {
public final long lastInboundEpochMs;
public final int inboundCountInWindow;
public final double avgResponseTimeMs;
public final int noReplyStrikes;
public final boolean manualSkedFailed;
public final int manualSkedFailCount;
public final long lastPositiveSignalEpochMs;
public Metrics(long lastInboundEpochMs,
int inboundCountInWindow,
double avgResponseTimeMs,
int noReplyStrikes,
boolean manualSkedFailed,
int manualSkedFailCount,
long lastPositiveSignalEpochMs) {
this.lastInboundEpochMs = lastInboundEpochMs;
this.inboundCountInWindow = inboundCountInWindow;
this.avgResponseTimeMs = avgResponseTimeMs;
this.noReplyStrikes = noReplyStrikes;
this.manualSkedFailed = manualSkedFailed;
this.manualSkedFailCount = manualSkedFailCount;
this.lastPositiveSignalEpochMs = lastPositiveSignalEpochMs;
}
}
}
}
@@ -0,0 +1,55 @@
package kst4contest.controller;
import java.nio.file.Path;
import kst4contest.model.ThreadStateMessage;
public interface StatusUpdateListener {
/**
* Thread (key) will send update status (value) to the view via this interface.
*
*/
void onThreadStatusChanged(String key, ThreadStateMessage threadStateMessage);
/**
* Called when the user list needs a UI refresh without a diagnostic reason.
*/
void onUserListUpdated();
/**
* Called on change if the userlist needs a UI update and the change has a
* useful diagnostic reason.
*
* @param reason short diagnostic reason for the refresh
*/
void onUserListUpdated(final String reason);
// new: userlist-update
/**
* Called whenever the authoritative ON4KST session changes lifecycle state.
*
* <p>The callback may originate from a background connection supervisor. A UI
* implementation must marshal control changes onto its application thread.</p>
*
* @param state new connection, authentication or synchronization state
* @param detail human-readable progress or failure reason
*/
default void onConnectionStateChanged(
On4KstConnectionState state,
String detail
) {
// Optional for non-UI listeners.
}
/**
* Called after KST4Contest successfully creates a missing Simplelogfile.
*
* @param filePath absolute path of the newly created file
*/
default void onSimpleLogFileCreated(Path filePath) {
// Optional for non-UI listeners.
}
}
@@ -0,0 +1,8 @@
package kst4contest.controller;
import kst4contest.model.ThreadStateMessage;
public interface ThreadStatusCallback {
void onThreadStatus(String threadName, ThreadStateMessage threadStateMessage);
}
@@ -0,0 +1,64 @@
package kst4contest.controller;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import kst4contest.model.ChatMember;
public class UCXLogFileToHashsetParser {
private static final Pattern CALL_SIGN_PATTERN = Pattern.compile(
"(([a-zA-Z]{1,2}[\\d]{1}?\\/)?(\\d{1}[a-zA-Z][\\d]{1}[a-zA-Z]{1,3})((\\/p)|(\\/\\d))?)"
+ "|(([a-zA-Z0-9]{1,2}[\\d]{1}?\\/)?(([a-zA-Z]{1,2}(\\d{1}[a-zA-Z]{1,4})))((\\/p)|(\\/\\d))?)"
+ "|([A-Z]\\d{2}[A-Z]{1,3})");
private final Path logFile;
public UCXLogFileToHashsetParser(String filePathAndName) {
this.logFile = Path.of(filePathAndName);
}
private String findLastCallSign(String line) {
Matcher matcher = CALL_SIGN_PATTERN.matcher(line);
String matchedCallSign = "";
while (matcher.find()) {
matchedCallSign = matcher.group();
}
return matchedCallSign.toUpperCase(Locale.ROOT);
}
/**
* Parses the selected log file and returns every detected station as a
* normalized base callsign. The reader is closed after each pass so the logging
* application can continue replacing or rotating the file.
*
* @return unique normalized base callsigns found in the file
* @throws IOException if the file cannot be read
*/
public Set<String> parse() throws IOException {
Set<String> workedBaseCalls = new HashSet<>();
try (BufferedReader fileReader = Files.newBufferedReader(logFile, Charset.defaultCharset())) {
String line;
while ((line = fileReader.readLine()) != null) {
String matchedCallSign = findLastCallSign(line);
String baseCallSign = ChatMember.normalizeCallSignToBaseCallSign(matchedCallSign);
if (baseCallSign != null && !baseCallSign.isBlank()) {
workedBaseCalls.add(baseCallSign.toUpperCase(Locale.ROOT));
}
}
}
return workedBaseCalls;
}
}
@@ -0,0 +1,247 @@
package kst4contest.controller;
import java.io.InputStream;
import kst4contest.ApplicationConstants;
import kst4contest.model.UpdateInformation;
import kst4contest.utils.ApplicationFileUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.File;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
public class UpdateChecker {
public static void main(String[] args) {
// new UpdateChecker(null).parseUpdateXMLFile();
if (new UpdateChecker(null).downloadLatestVersionInfoXML()) {
// ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME,ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE,ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE);
}
new UpdateChecker(null).parseUpdateXMLFile();
}
public UpdateChecker(ChatController chatController) {
System.out.println("[Updatechecker: checking for updates...]");
// double currentVersionNumber = ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER;
}
String versionInfoDownloadedFromServerFileName = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE);
String versionInfoXMLURLAtServer = ApplicationConstants.VERSIONINFOURLFORUPDATES_KST4CONTEST;
// double currentVersion = ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER;
//DOWNLOAD from URL, then parse, then do anything with it...
/**
* Downloads the versioninfo-xml-file from a webserver to local. Returns true if download was successful, else false
*
* @return true if successful
*/
public boolean downloadLatestVersionInfoXML() {
try {
InputStream in = new URL(versionInfoXMLURLAtServer).openStream();
Files.copy(in, Paths.get(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/"+ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE)), StandardCopyOption.REPLACE_EXISTING);
in.close();
// System.out.println(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/"+ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE));
// ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME,ApplicationFileUtils.get,ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE);
} catch (Exception e) {
System.out.println("ERROR DOWNLOADING!" + e);
return false;
}
return true;
}
public UpdateInformation parseUpdateXMLFile() {
UpdateInformation updateInfos = new UpdateInformation();
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME,"/"+ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE,ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE);
// System.out.println("[Updatecker, Info]: restoring prefs from file " + versionInfoDownloadedFromServerFileName);
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
try {
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
} catch (ParserConfigurationException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}
try {
File xmlConfigFile = new File(versionInfoDownloadedFromServerFileName);
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(xmlConfigFile);
/**
* latestVersion on server
*/
NodeList list = doc.getElementsByTagName("latestVersion");
if (list.getLength() != 0) {
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
updateInfos.setLatestVersionNumberOnServer(Double.parseDouble(element.getElementsByTagName("versionNumber").item(0).getTextContent()));
NodeList semanticVersionNodes =
element.getElementsByTagName("semanticVersion");
if (semanticVersionNodes.getLength() > 0) {
updateInfos.setLatestSemanticVersionOnServer(
semanticVersionNodes.item(0).getTextContent()
);
}
updateInfos.setAdminMessage(element.getElementsByTagName("adminMessage").item(0).getTextContent());
updateInfos.setMajorChanges(element.getElementsByTagName("majorChanges").item(0)
.getTextContent());
updateInfos.setLatestVersionPathOnWebserver(element.getElementsByTagName("latestVersionPathOnWebserver").item(0).getTextContent());
// System.out.println(updateInfos.toString());
}
}
}
/**
* Section changeLog
*/
list = doc.getElementsByTagName("changeLog");
ArrayList<String[]> changeLogArrayList = new ArrayList<String[]>();
if (list.getLength() != 0) {
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
Element element = (Element) node;
int childNodeCounter = 0; //need an extra counter due to childnodes are counted...no idea, how
String[] aChangeLogEntry = new String[7];
aChangeLogEntry[0] = "";
aChangeLogEntry[1] = "Date: ";
aChangeLogEntry[2] = "Desc: ";
aChangeLogEntry[3] = "Added: ";
aChangeLogEntry[4] = "Changed: ";
aChangeLogEntry[5] = "Fixed: ";
aChangeLogEntry[6] = "Removed: ";
for (int i = 0; i < element.getChildNodes().getLength(); i++) {
if (element.getChildNodes().item(i).getNodeType() == Node.ELEMENT_NODE) {
// System.out.println(element.getChildNodes().item(i).getTextContent() + " <<<<<<<<<<<<<<<<<< " + i + " / " + childNodeCounter);
// System.out.println(element.getChildNodes().item(i).getNodeName());
aChangeLogEntry[childNodeCounter] = aChangeLogEntry[childNodeCounter] + element.getChildNodes().item(i).getTextContent();
childNodeCounter++;
}
}
changeLogArrayList.add(aChangeLogEntry);
}
updateInfos.setChangeLog(changeLogArrayList);
}
/**
* Section Buglist
*/
list = doc.getElementsByTagName("bug");
ArrayList<String[]> bugFixArrayList = new ArrayList<String[]>();
if (list.getLength() != 0) {
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
Element element = (Element) node;
int childNodeCounter = 0; //need an extra counter due to childnodes are counted...no idea, how
String[] aChangeLogEntry = new String[3];
aChangeLogEntry[0] = "";
aChangeLogEntry[1] = "State: ";
for (int i = 0; i < element.getChildNodes().getLength(); i++) {
if (element.getChildNodes().item(i).getNodeType() == Node.ELEMENT_NODE) {
// System.out.println(element.getChildNodes().item(i).getTextContent() + " <<<<<<<<<<<<<<<<<< " + i + " / " + childNodeCounter);
// System.out.println(element.getChildNodes().item(i).getNodeName());
aChangeLogEntry[childNodeCounter] = aChangeLogEntry[childNodeCounter] + element.getChildNodes().item(i).getTextContent();
childNodeCounter++;
}
}
bugFixArrayList.add(aChangeLogEntry);
}
updateInfos.setBugList(bugFixArrayList);
}
} catch (Exception e) {
System.out.println(e.getMessage());
e.printStackTrace();
}
String[] testEntry = new String[7];
testEntry[0] = "0.99";
testEntry[1] = "2022-09";
testEntry[2] = "researched the Chatprotocol";
testEntry[3] = "addednothing";
testEntry[4] = "changedsome";
testEntry[5] = "fixedxed";
testEntry[6] = "removedYourMom";
String[] testEntry2 = new String[7];
testEntry2[0] = "0.29";
testEntry2[1] = "2033-09";
testEntry2[2] = "tested";
testEntry2[3] = "addednotashing";
testEntry2[4] = "changeasdsome";
testEntry2[5] = "fixedxeds";
testEntry2[6] = "removedYosssurMom";
// changeLogArrayList.add(testEntry);
// changeLogArrayList.add(testEntry2);
return updateInfos;
}
@Override
public String toString() {
String toString = "";
// toString += this.currentVersion;
return toString;
}
}
@@ -0,0 +1,100 @@
package kst4contest.controller;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Set;
import java.util.TimerTask;
import java.util.logging.Level;
import java.util.logging.Logger;
public class UserActualizationTask extends TimerTask {
private static final Logger LOGGER = Logger.getLogger(UserActualizationTask.class.getName());
private final ChatController client;
public UserActualizationTask(ChatController client) {
this.client = client;
}
@Override
public void run() {
Thread.currentThread().setName("UserActualizationTask");
try {
updateWorkedCallSignsFromFile();
} catch (RuntimeException exception) {
LOGGER.log(Level.WARNING,
"Unexpected failure while updating Worked callsigns from the Simplelogfile; "
+ "the periodic task will continue.",
exception);
}
}
private void updateWorkedCallSignsFromFile() {
if (!client.getChatPreferences().isLogsynch_fileBasedWkdCallInterpreterEnabled()) {
return;
}
String configuredFileName = client.getChatPreferences()
.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly();
if (configuredFileName == null || configuredFileName.isBlank()) {
LOGGER.warning("Cannot read the Simplelogfile because no file is selected.");
return;
}
final Path logFile;
try {
logFile = Path.of(configuredFileName).toAbsolutePath().normalize();
} catch (InvalidPathException exception) {
LOGGER.log(Level.WARNING,
"Cannot use the configured Simplelogfile path: " + configuredFileName,
exception);
return;
}
boolean created = createMissingLogFile(logFile);
if (!Files.isRegularFile(logFile)) {
return;
}
try {
Set<String> workedBaseCalls = new UCXLogFileToHashsetParser(logFile.toString()).parse();
client.applySimpleLogWorkedBaseCalls(workedBaseCalls);
LOGGER.log(Level.FINE,
"Read {0} unique base callsigns from Simplelogfile {1}.",
new Object[] { workedBaseCalls.size(), logFile });
} catch (IOException exception) {
LOGGER.log(Level.WARNING, "Cannot read Simplelogfile " + logFile, exception);
}
if (created) {
client.notifySimpleLogFileCreated(logFile);
}
}
private boolean createMissingLogFile(Path logFile) {
if (Files.exists(logFile)) {
if (!Files.isRegularFile(logFile)) {
LOGGER.log(Level.WARNING,
"The selected Simplelogfile path is not a regular file: {0}",
logFile);
}
return false;
}
try {
Files.createFile(logFile);
LOGGER.log(Level.INFO, "Created missing Simplelogfile {0}.", logFile);
return true;
} catch (FileAlreadyExistsException exception) {
return false;
} catch (IOException | SecurityException exception) {
LOGGER.log(Level.WARNING, "Cannot create Simplelogfile " + logFile, exception);
return false;
}
}
}
@@ -0,0 +1,242 @@
package kst4contest.controller;
import kst4contest.observe.ObservableValue;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Utils4KST {
public long time_generateCurrentEpochTime() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
// System.out.println(currentTimeInUtc.format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm X")));
long millisecondsSinceEpoch = currentTimeInUtc.toInstant().toEpochMilli() / 1000;
// System.out.println(millisecondsSinceEpoch);
return millisecondsSinceEpoch;
}
public String time_generateCurrenthhmmZTimeStringForClusterMessage() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
return currentTimeInUtc.format(DateTimeFormatter.ofPattern("HHmm"))+"Z";
}
public String time_generateCurrentMMDDhhmmTimeString() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
return currentTimeInUtc.format(DateTimeFormatter.ofPattern("MM-dd hh:mm"));
}
public String time_generateCurrentMMddString() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
return currentTimeInUtc.format(DateTimeFormatter.ofPattern("MM-dd"));
}
public String time_convertEpochToReadable(String epochFromServer) {
long epoch = Long.parseLong(epochFromServer);
// Instant instant = Instant.ofEpochSecond(epoch);
Date date = new Date(epoch * 1000L);
// DateFormat format = new SimpleDateFormat("dd.MM HH:mm:ss"); //old value which is too long
DateFormat format = new SimpleDateFormat("H:mm:ss");
format.setTimeZone(TimeZone.getTimeZone("Etc/UTC"));
String formatted = format.format(date);
// System.out.println("UTIL " + formatted);
return formatted;
}
public static long time_getSecondsBetweenEpochAndNow(String epoch1) {
long epoch1Long = Long.parseLong(epoch1);
long epoch2Long = new Utils4KST().time_generateCurrentEpochTime();
// Instant instant = Instant.ofEpochSecond(epoch);
Date date = new Date(epoch1Long * 1000L);
Date date2 = new Date(epoch2Long * 1000L);
long seconds = Math.abs(date.getTime()-date2.getTime())/1000;
return seconds;
}
public Date time_generateActualTimeInDateFormat() {
Date date = new Date(time_generateCurrentEpochTime() * 1000L);
return date;
}
/**
* This method tests a regexp-pattern against a given string
*
* @param testString: check if this string matches a given pattern
* @param regExPattern: pattern which should be checked
* @return true if match, else false
*/
private static boolean testPattern(String testString, String regExPattern) {
Pattern pattern = Pattern.compile(regExPattern);
Matcher matcher = pattern.matcher(testString);
return matcher.find();
}
/**
* Converts a frequency detected in a chat message into the kHz representation
* used by the DX Cluster protocol.
*
* <p>Complete frequencies with two to five MHz digits are supported, for
* example 50.200, 144.205, 1296.338, 10368.100 and 24048.100. An optional
* second fractional group is retained as sub-kHz precision, for example
* 144.205.2 becomes 144205.2 kHz.</p>
*
* <p>Relative values such as .205 or 205 use the configured fallback-band
* prefix. The method only performs this fallback conversion after the general
* message parser has already accepted the value as a frequency.</p>
*
* @param qrgString frequency as detected by the chat parser
* @param optionalPrefix fallback MHz prefix for relative frequencies
* @return frequency in kHz for a DX Cluster spot, or an empty string if the
* value cannot be converted safely
*/
public static String normalizeFrequencyString(
String qrgString,
ObservableValue<String> optionalPrefix
) {
if (qrgString == null || qrgString.isBlank()) {
return "";
}
/*
* A comma is accepted as a decimal separator. Spaces are removed so older
* stored formats such as "432 088" remain usable.
*/
String normalizedValue = qrgString
.trim()
.replace(" ", "")
.replace(',', '.');
/*
* Complete frequency with an explicit separator:
*
* 50.200
* 144.205
* 1296.338
* 10368.100
* 144.205.2
*/
Matcher completeFrequencyMatcher = Pattern.compile(
"^(\\d{2,5})\\.(\\d{1,3})(?:\\.(\\d{1,2}))?$"
).matcher(normalizedValue);
if (completeFrequencyMatcher.matches()) {
return formatDxClusterFrequency(
completeFrequencyMatcher.group(1),
completeFrequencyMatcher.group(2),
completeFrequencyMatcher.group(3)
);
}
/*
* Compact complete frequency retained for compatibility:
*
* 144205
* 1296338
* 10368100
* 432088.2
*/
Matcher compactFrequencyMatcher = Pattern.compile(
"^(\\d{2,5})(\\d{3})(?:\\.(\\d{1,2}))?$"
).matcher(normalizedValue);
if (compactFrequencyMatcher.matches()) {
return formatDxClusterFrequency(
compactFrequencyMatcher.group(1),
compactFrequencyMatcher.group(2),
compactFrequencyMatcher.group(3)
);
}
/*
* Relative frequency. Values above 499 are deliberately rejected, matching
* the previous behaviour and preventing a report such as 599 from becoming
* a plausible-looking cluster frequency.
*/
Matcher relativeFrequencyMatcher = Pattern.compile(
"^\\.?([0-4]\\d{2})(?:\\.(\\d{1,2}))?$"
).matcher(normalizedValue);
if (!relativeFrequencyMatcher.matches()) {
return "";
}
String fallbackPrefix = optionalPrefix == null
? null
: optionalPrefix.get();
if (fallbackPrefix == null) {
return "";
}
fallbackPrefix = fallbackPrefix.trim();
if (!fallbackPrefix.matches("\\d{2,5}")) {
return "";
}
return formatDxClusterFrequency(
fallbackPrefix,
relativeFrequencyMatcher.group(1),
relativeFrequencyMatcher.group(2)
);
}
/**
* Formats an MHz part, a fractional MHz part and optional sub-kHz digits as a
* DX Cluster frequency in kHz.
*
* <p>The fractional MHz part is padded on the right because 144.2 means
* 144.200 MHz, while 144.21 means 144.210 MHz.</p>
*
* @param mhzPart complete MHz part
* @param fractionalMhzPart one to three digits following the first separator
* @param subKhzPart optional digits following a second separator
* @return DX Cluster frequency in kHz
*/
private static String formatDxClusterFrequency(
String mhzPart,
String fractionalMhzPart,
String subKhzPart
) {
String paddedFraction =
(fractionalMhzPart + "000").substring(0, 3);
String subKhz = subKhzPart == null
? "0"
: subKhzPart;
return mhzPart
+ paddedFraction
+ "."
+ subKhz;
}
}
@@ -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;
}
}
@@ -0,0 +1,82 @@
package kst4contest.controller;
import java.nio.charset.StandardCharsets;
/**
* Represents a Win-Test network protocol message.
* <p>
* Ported from the C# wtMessage class in wtKST.
* <p>
* Win-Test uses a simple ASCII-based UDP protocol with a checksum byte.
* Message format (for sending):
* <pre>
* MESSAGETYPE: "src" "dst" data{checksum}\0
* </pre>
* The checksum is calculated over all bytes before the checksum position,
* then OR'd with 0x80.
*/
public class WinTestMessage {
/** 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,
DELETESKED,
UPDATESKED
}
private final MessageType type;
private final String src;
private final String dst;
private final String data;
public WinTestMessage(MessageType type, String src, String dst, String data) {
this.type = type;
this.src = src;
this.dst = dst;
this.data = data;
}
/**
* Serializes this message to bytes for UDP transmission.
* <p>
* Format: {@code MESSAGETYPE: "src" "dst" data{checksum}\0}
* <p>
* The '?' placeholder is replaced by the calculated checksum,
* followed by a NUL terminator.
* Degree signs (°) are escaped as \260 per Win-Test convention.
*/
public byte[] toBytes() {
String escapedData = data.replace("°", "\\260");
// Format: MESSAGETYPE: "src" "dst" data?\0
// The '?' is a placeholder for the checksum byte
String raw = type.name() + ": \"" + src + "\" \"" + dst + "\" " + escapedData + "?\0";
byte[] bytes = raw.getBytes(StandardCharsets.US_ASCII);
// Calculate checksum over everything before the checksum position (length - 2)
int sum = 0;
for (int i = 0; i < bytes.length - 2; i++) {
sum += (bytes[i] & 0xFF);
}
byte checksum = (byte) ((sum | 0x80) & 0xFF);
bytes[bytes.length - 2] = checksum;
return bytes;
}
// Getters for debugging/logging
public MessageType getType() { return type; }
public String getSrc() { return src; }
public String getDst() { return dst; }
public String getData() { return data; }
@Override
public String toString() {
return type.name() + ": src=" + src + " dst=" + dst + " data=" + data;
}
}
@@ -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,221 @@
package kst4contest.controller;
import kst4contest.model.Band;
import kst4contest.model.ContestSked;
import kst4contest.model.ThreadStateMessage;
import java.net.*;
import java.nio.charset.StandardCharsets;
/**
* Sends SKED entries to Win-Test via UDP broadcast.
* <p>
* Ported from the C# wtSked class in wtKST.
* <p>
* Win-Test expects a LOCKSKED / ADDSKED / UNLOCKSKED sequence
* to safely insert a new sked into its schedule window.
*/
public class WinTestSkedSender {
private final String stationName;
private final InetAddress broadcastAddress;
private final int port;
private final ThreadStatusCallback callback;
private static final String THREAD_NICKNAME = "WT-SkedSend";
/**
* @param stationName our station name in the Win-Test network (e.g. "KST4Contest")
* @param broadcastAddress UDP broadcast address (e.g. 255.255.255.255 or subnet broadcast)
* @param port Win-Test network port (default 9871)
* @param callback optional callback for status reporting (may be null)
*/
public WinTestSkedSender(String stationName, InetAddress broadcastAddress, int port,
ThreadStatusCallback callback) {
this.stationName = stationName;
this.broadcastAddress = broadcastAddress;
this.port = port;
this.callback = callback;
}
/**
* Pushes a ContestSked into Win-Test by sending the
* LOCKSKED / ADDSKED / UNLOCKSKED sequence.
*
* @param sked sked to push
* @param targetCallsign callsign prepared for Win-Test
* @param frequencyKHz operating frequency in kHz
* @param notes optional notes
* @param mode Win-Test mode ID: 0 for CW, 1 for SSB
*/
public void pushSkedToWinTest(ContestSked sked,
String targetCallsign,
double frequencyKHz,
String notes,
int mode) {
try {
sendLockSked();
sendAddSked(
sked,
targetCallsign,
frequencyKHz,
notes,
mode
);
sendUnlockSked();
reportStatus(
"Sked pushed to WT: " + targetCallsign,
false
);
System.out.println(
"[WinTestSkedSender] Sked pushed: "
+ targetCallsign
+ " at "
+ frequencyKHz
+ " kHz, band="
+ sked.getBand()
+ ", mode="
+ mode
);
} catch (Exception exception) {
reportStatus(
"ERROR pushing sked: "
+ exception.getMessage(),
true
);
System.out.println(
"[WinTestSkedSender] Error pushing sked: "
+ exception.getMessage()
);
exception.printStackTrace();
}
}
/**
* Sends a LOCKSKED message to lock the Win-Test sked window.
*/
private void sendLockSked() throws Exception {
WinTestMessage msg = new WinTestMessage(
WinTestMessage.MessageType.LOCKSKED,
stationName, "",
"\"" + stationName + "\"");
sendUdp(msg);
}
/**
* Sends an UNLOCKSKED message to unlock the Win-Test sked window.
*/
private void sendUnlockSked() throws Exception {
WinTestMessage msg = new WinTestMessage(
WinTestMessage.MessageType.UNLOCKSKED,
stationName, "",
"\"" + stationName + "\"");
sendUdp(msg);
}
/**
* Sends an ADDSKED message with the sked details.
*
* <p>The wtKST implementation subtracts a reference time of
* 1970-01-01 00:01:00 UTC and subsequently adds 60 seconds. Both
* operations cancel each other out. The transmitted value is therefore
* an ordinary Unix timestamp and must not receive another offset here.</p>
*/
private void sendAddSked(ContestSked sked,
String targetCallsign,
double frequencyKHz,
String notes,
int mode) throws Exception {
long wtTimestamp =
sked.getSkedTimeEpoch() / 1000L;
// Frequency in 0.1 kHz units.
long frequencyTenthKHz =
Math.round(frequencyKHz * 10.0);
int bandId =
toWinTestBandId(sked.getBand());
/*
* Accept only the mode IDs supported by this UI.
* Any unexpected value falls back to SSB.
*/
int winTestMode =
mode == 0
? 0
: 1;
String data =
wtTimestamp
+ " " + frequencyTenthKHz
+ " " + bandId
+ " " + winTestMode
+ " \"" + targetCallsign + "\""
+ " \"" + (notes != null ? notes : "") + "\"";
WinTestMessage message = new WinTestMessage(
WinTestMessage.MessageType.ADDSKED,
stationName,
"",
data
);
sendUdp(message);
}
/**
* Sends a WinTestMessage via UDP broadcast.
*/
private void sendUdp(WinTestMessage msg) throws Exception {
try (DatagramSocket socket = new DatagramSocket()) {
socket.setBroadcast(true);
socket.setReuseAddress(true);
byte[] bytes = msg.toBytes();
DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcastAddress, port);
socket.send(packet);
System.out.println("[WinTestSkedSender] sent: " + msg);
}
}
/**
* Maps the kst4contest Band enum to Win-Test band IDs.
* <p>
* Win-Test band IDs (reverse-engineered from wtKST):
* 10=50MHz, 11=70MHz, 12=144MHz, 14=432MHz, 16=1.2GHz,
* 17=2.3GHz, 18=3.4GHz, 19=5.7GHz, 20=10GHz, 21=24GHz,
* 22=47GHz, 23=76GHz
*/
public static int toWinTestBandId(Band band) {
if (band == null) return 12; // default to 144 MHz
return switch (band) {
case B_50 -> 10;
case B_70 -> 11;
case B_144 -> 12;
case B_432 -> 14;
case B_1296 -> 16;
case B_2320 -> 17;
case B_3400 -> 18;
case B_5760 -> 19;
case B_10G -> 20;
case B_24G -> 21;
};
}
private void reportStatus(String text, boolean isError) {
if (callback != null) {
callback.onThreadStatus(THREAD_NICKNAME,
new ThreadStateMessage(THREAD_NICKNAME, !isError, text, isError));
}
}
}
@@ -0,0 +1,202 @@
package kst4contest.controller;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import java.util.Collection;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Runtime cache for worked Maidenhead gross fields per band.
*
* <p>The cache is used by the "new locator" station filter. It is kept in memory
* for fast UI predicates and rebuilt from SQLite on startup/refresh. New QSO log
* packets update both SQLite and this cache immediately.</p>
*/
public final class WorkedGrossFieldCache {
private static final Pattern MAIDENHEAD_6_PATTERN =
Pattern.compile("(?i)([A-R]{2}[0-9]{2}[A-X]{2})");
private final Map<Band, Set<String>> workedGrossFieldsByBand = new EnumMap<>(Band.class);
/**
* Replaces the full cache content with data read from the database.
*
* @param databaseSnapshot map of band to worked gross fields
*/
public synchronized void rebuildFromDatabaseSnapshot(Map<Band, Set<String>> databaseSnapshot) {
workedGrossFieldsByBand.clear();
if (databaseSnapshot == null) {
return;
}
for (Map.Entry<Band, Set<String>> entry : databaseSnapshot.entrySet()) {
Band band = entry.getKey();
Set<String> grossFields = entry.getValue();
if (band == null || grossFields == null) {
continue;
}
Set<String> normalizedGrossFields = workedGrossFieldsByBand.computeIfAbsent(band, ignored -> new HashSet<>());
for (String grossField : grossFields) {
String normalizedGrossField = normalizeGrossField(grossField);
if (normalizedGrossField != null) {
normalizedGrossFields.add(normalizedGrossField);
}
}
}
}
/**
* Adds one worked locator to the cache.
*
* @param band worked band
* @param locatorOrGrossField six-character locator or four-character gross field
*/
public synchronized void addWorked(Band band, String locatorOrGrossField) {
String grossField = extractGrossField(locatorOrGrossField);
if (band == null || grossField == null) {
return;
}
workedGrossFieldsByBand
.computeIfAbsent(band, ignored -> new HashSet<>())
.add(grossField);
}
/**
* Adds all worked-band flags from stored ChatMember rows. This is only a fallback
* for legacy data before WorkedGrossField existed or when logger packets did not
* provide a locator.
*
* @param storedMembers stored ChatMember rows
*/
public synchronized void addWorkedBandsFromStoredChatMembers(Collection<ChatMember> storedMembers) {
if (storedMembers == null) {
return;
}
for (ChatMember member : storedMembers) {
if (member == null) {
continue;
}
String locator = member.getQra();
if (member.isWorked144()) addWorked(Band.B_144, locator);
if (member.isWorked432()) addWorked(Band.B_432, locator);
if (member.isWorked1240()) addWorked(Band.B_1296, locator);
if (member.isWorked2300()) addWorked(Band.B_2320, locator);
if (member.isWorked3400()) addWorked(Band.B_3400, locator);
if (member.isWorked5600()) addWorked(Band.B_5760, locator);
if (member.isWorked10G()) addWorked(Band.B_10G, locator);
if (member.isWorked50()) addWorked(Band.B_50, locator);
if (member.isWorked70()) addWorked(Band.B_70, locator);
}
}
/**
* Checks whether a locator gross field is already worked on a band.
*
* @param band band to check
* @param locatorOrGrossField six-character locator or four-character gross field
* @return true if the gross field is already worked on that band
*/
public synchronized boolean isGrossFieldWorked(Band band, String locatorOrGrossField) {
String grossField = extractGrossField(locatorOrGrossField);
if (band == null || grossField == null) {
return false;
}
return workedGrossFieldsByBand
.getOrDefault(band, Set.of())
.contains(grossField);
}
/**
* Checks whether a locator gross field has already been worked on any band.
*
* <p>The UI grid status and the "Only new grids" filter use this any-band logic.
* This avoids user errors caused by wrong active-band settings and fits single
* band contests such as NAC better.</p>
*
* @param locatorOrGrossField six-character locator or four-character gross field
* @return true if the gross field exists in the cache on any band
*/
public synchronized boolean isGrossFieldWorkedAny(String locatorOrGrossField) {
String grossField = extractGrossField(locatorOrGrossField);
if (grossField == null) {
return false;
}
for (Set<String> workedGrossFields : workedGrossFieldsByBand.values()) {
if (workedGrossFields != null && workedGrossFields.contains(grossField)) {
return true;
}
}
return false;
}
/**
* Tries to normalize a locator. Accepts a plain six-character locator or extracts
* one from exchange strings such as "001JO41HK".
*
* @param rawLocatorOrExchange raw locator/exchange text
* @return normalized six-character locator, or null if none was found
*/
public static String extractLocator6(String rawLocatorOrExchange) {
if (rawLocatorOrExchange == null || rawLocatorOrExchange.isBlank()) {
return null;
}
Matcher matcher = MAIDENHEAD_6_PATTERN.matcher(rawLocatorOrExchange.trim());
if (!matcher.find()) {
return null;
}
return matcher.group(1).toUpperCase(Locale.ROOT);
}
/**
* Extracts and normalizes the four-character gross field.
*
* @param rawLocatorOrGrossField six-character locator, four-character gross field or exchange text
* @return normalized gross field, or null if no valid value is available
*/
public static String extractGrossField(String rawLocatorOrGrossField) {
if (rawLocatorOrGrossField == null || rawLocatorOrGrossField.isBlank()) {
return null;
}
String trimmed = rawLocatorOrGrossField.trim().toUpperCase(Locale.ROOT);
if (trimmed.matches("[A-R]{2}[0-9]{2}")) {
return trimmed;
}
String locator6 = extractLocator6(trimmed);
if (locator6 == null || locator6.length() < 4) {
return null;
}
return locator6.substring(0, 4);
}
private static String normalizeGrossField(String grossField) {
if (grossField == null) {
return null;
}
String normalized = grossField.trim().toUpperCase(Locale.ROOT);
return normalized.matches("[A-R]{2}[0-9]{2}") ? normalized : null;
}
}
@@ -0,0 +1,195 @@
package kst4contest.controller;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.Consumer;
import java.util.function.LongPredicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
/**
* Serializes and writes exactly one immutable ON4KST connection session.
*
* <p>The writer owns one private queue and appends exactly one CR/LF terminator
* per frame. All category selection and delimiter validation happens before bytes
* reach the socket.</p>
*/
public class WriteThread extends Thread {
private static final Logger LOGGER =
Logger.getLogger(WriteThread.class.getName());
private final long sessionId;
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
private final LongPredicate sessionIsActive;
private final Consumer<Throwable> connectionFailure;
private final Consumer<String> rejectedFrame;
private final MessageHistoryRecorder historyRecorder;
private final BufferedWriter writer;
private final int defaultCategory;
/**
* Compatibility constructor for the pre-session controller path.
*
* @deprecated new connections should be created by
* {@link On4KstConnectionManager}
*/
@Deprecated
public WriteThread(Socket socket, ChatController client) throws IOException {
this(0L, socket, client.getMessageTXBus(),
client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber(),
ignored -> true,
ignored -> { },
message -> LOGGER.log(Level.WARNING, message),
client.getMessageHistoryRecorder());
}
/**
* Creates the writer for one connection generation.
*
* @param sessionId immutable id of the owning socket session
* @param socket connected ON4KST socket
* @param transmitQueue private transmit queue belonging to this session
* @param defaultCategory fallback category for unqualified chat messages
* @param sessionIsActive guard against writes from an obsolete session
* @param connectionFailure callback for socket and unexpected runtime errors
* @param rejectedFrame callback for locally rejected protocol content
* @throws IOException if the socket output stream cannot be opened
*/
public WriteThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> transmitQueue,
int defaultCategory,
LongPredicate sessionIsActive,
Consumer<Throwable> connectionFailure,
Consumer<String> rejectedFrame
) throws IOException {
this(sessionId, socket, transmitQueue, defaultCategory,
sessionIsActive, connectionFailure, rejectedFrame,
MessageHistoryRecorder.disabled());
}
/** Creates a writer that records each frame after a successful flush. */
public WriteThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> transmitQueue,
int defaultCategory,
LongPredicate sessionIsActive,
Consumer<Throwable> connectionFailure,
Consumer<String> rejectedFrame,
MessageHistoryRecorder historyRecorder
) throws IOException {
this.sessionId = sessionId;
this.socket = socket;
this.transmitQueue = transmitQueue;
this.defaultCategory = defaultCategory;
this.sessionIsActive = sessionIsActive;
this.connectionFailure = connectionFailure;
this.rejectedFrame = rejectedFrame;
this.historyRecorder = historyRecorder == null
? MessageHistoryRecorder.disabled() : historyRecorder;
this.writer = new BufferedWriter(new OutputStreamWriter(
socket.getOutputStream(), StandardCharsets.UTF_8));
}
@Override
public void run() {
Thread.currentThread().setName("WriteToOn4Kst-" + sessionId);
LOGGER.log(Level.FINE,
"ON4KST writer started for session {0}", sessionId);
try {
while (!isInterrupted() && sessionIsActive.test(sessionId)) {
ChatMessage message = transmitQueue.take();
if (isPoisonPill(message)) {
break;
}
if (!sessionIsActive.test(sessionId)) {
break;
}
try {
writeFrame(formatFrame(message));
} catch (IllegalArgumentException invalidFrame) {
LOGGER.log(Level.FINE,
"Rejected outbound ON4KST frame in session {0}: {1}",
new Object[] {sessionId, invalidFrame.getMessage()});
rejectedFrame.accept(invalidFrame.getMessage());
}
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (IOException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.FINE,
"ON4KST write failed for session " + sessionId,
exception);
connectionFailure.accept(exception);
}
} catch (RuntimeException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.SEVERE,
"Unexpected ON4KST writer failure for session "
+ sessionId,
exception);
connectionFailure.accept(exception);
}
} finally {
LOGGER.log(Level.FINE,
"ON4KST writer stopped for session {0}", sessionId);
}
}
private String formatFrame(ChatMessage message) {
if (message == null) {
throw new IllegalArgumentException("Cannot send an empty ON4KST message");
}
if (message.isMessageDirectedToServer()) {
return On4KstProtocol.normalizeRawFrame(message.getMessageText());
}
ChatCategory category = message.getChatCategory();
return On4KstProtocol.chatMessage(
category == null ? defaultCategory : category.getCategoryNumber(),
message.getMessageText());
}
private void writeFrame(String frame) throws IOException {
writer.write(frame);
writer.write("\r\n");
writer.flush();
historyRecorder.recordTx(frame);
}
private boolean isPoisonPill(ChatMessage message) {
return message != null
&& ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(
message.getMessageText())
&& ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(
message.getMessageSenderName());
}
/**
* Interrupts the write loop and closes the session socket.
*
* @return always {@code true} after a successful close
* @throws IOException if closing the writer or socket fails
*/
public boolean terminateConnection() throws IOException {
interrupt();
writer.close();
socket.close();
return true;
}
}
@@ -0,0 +1,13 @@
package kst4contest.controller.interfaces;
public interface PstRotatorEventListener {
void onAzimuthUpdate(double azimuth);
void onElevationUpdate(double elevation);
void onModeUpdate(boolean isTracking); // true = Tracking, false = Manual
void onMessageReceived(String rawMessage); // Debugging usage
// void setRotorPosition(double azimuth);
}
@@ -0,0 +1,44 @@
package kst4contest.controller;
import java.util.TimerTask;
import kst4contest.model.ChatMessage;
/**
* Enqueues the empty application-level reply expected by ON4KST keepalive
* handling.
*
* <p>The writer owns line termination and appends exactly one CR/LF sequence.
* Supplying an empty payload here avoids the historic double-terminator frame.</p>
*/
public class keepAliveMessageSenderTask extends TimerTask {
private ChatController client;
public keepAliveMessageSenderTask(ChatController client) {
this.client = client;
}
@Override
public void run() {
Thread.currentThread().setName("KeepAliveMessageSenderTask");
// System.out.println("[keepalive: ] Thread runned now");
ChatMessage keepAliveMSG = new ChatMessage();
// WriteThread appends exactly one CRLF. An empty frame is the protocol reply.
keepAliveMSG.setMessageText("");
keepAliveMSG.setMessageDirectedToServer(true);
// System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + " [keepaliveTask]: Sending keepalive: "
// + keepAliveMSG.getMessageText());
/**
* Sending keepalive
*/
this.client.getMessageTXBus().add(keepAliveMSG);
}
}
@@ -0,0 +1,140 @@
package kst4contest.locatorUtils;
import java.math.BigDecimal;
import java.math.MathContext;
/**
* Allow for easy use of angles without messy conversions to and from degrees
* and radians.
*
* @author Paul Picazo <ppicazo@gmail.com>
*
*/
public class Angle {
/**
* pi.
*/
private static final BigDecimal BIG_PI = new BigDecimal(Math.PI);
/**
* representation of the angle in radians.
*/
protected BigDecimal radians;
/**
* no argument constructor.
*
*/
public Angle() {
}
/**
* @param radiansIn
* angle in radians
*/
public Angle(final double radiansIn) {
this.radians = new BigDecimal(radiansIn);
}
@Override
public int hashCode() {
final int prime = 7;
if (radians == null) {
return prime;
}
return prime + radians.hashCode();
}
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (!(obj instanceof Angle)) {
return false;
}
return ((Angle) obj).hashCode() == this.hashCode();
}
/**
* @param degrees
* angle in degrees
* @return angle
*/
public static Angle fromDegrees(final double degrees) {
Angle angle = new Angle();
angle.radians = degreesToRadians(new BigDecimal(degrees),
MathContext.DECIMAL128);
return angle;
}
/**
* @param radiansIn
* the radians to set
*/
public void setRadians(final double radiansIn) {
this.radians = new BigDecimal(radiansIn);
}
/**
* @return the radians
*/
public double getRadians() {
return radians.doubleValue();
}
/**
* @return degrees
*/
public double toDegrees() {
return radiansToDegrees(this.radians, MathContext.DECIMAL128).doubleValue();
}
/**
* @param radians
* input
* @return degrees
*/
public static double radiansToDegrees(final double radians) {
return radiansToDegrees(new BigDecimal(radians), MathContext.DECIMAL128)
.doubleValue();
}
/**
* @param degrees
* input
* @return radians
*/
public static double degreesToRadians(final double degrees) {
return degreesToRadians(new BigDecimal(degrees), MathContext.DECIMAL128)
.doubleValue();
}
/**
* @param radians
* input
* @param mc
* MathContext for division
* @return degrees
*/
protected static BigDecimal radiansToDegrees(final BigDecimal radians,
final MathContext mc) {
return radians.multiply(new BigDecimal(180)).divide(BIG_PI, mc);
}
/**
* @param degrees
* input
* @param mc
* MathContext for division
* @return radians
*/
protected static BigDecimal degreesToRadians(final BigDecimal degrees,
final MathContext mc) {
return degrees.divide(new BigDecimal(180), mc).multiply(BIG_PI);
}
}
@@ -0,0 +1,109 @@
package kst4contest.locatorUtils;
public class DirectionUtils {
/**
* Checks wheter a sked-sender writes to a sked-receiver and is in my direction due he beams to this receiver
*
* @param myLocator
* @param locatorOfSkedSender
* @param locatorOfSekdReceiver
* @param maxRangeKm
* @param hisAntennaBeamWidth
* @return
*/
public static boolean isInAngleAndRange(String myLocator, String locatorOfSkedSender, String locatorOfSekdReceiver, double maxRangeKm, double hisAntennaBeamWidth) {
Location myLocation = new Location(myLocator);
Location skedSenderLocation = new Location(locatorOfSkedSender);
Location skedReceiverLocation = new Location(locatorOfSekdReceiver);
double distanceFromMeToLocSender = new Location(myLocator).getDistanceKm(new Location(locatorOfSkedSender));
// Check if distance exceeds my setted maximum range
if (distanceFromMeToLocSender > maxRangeKm) {
return false;
}
//check bearing of sender to receiver
double bearingOfSekdSenderToSkedReceiver = skedSenderLocation.getBearing(skedReceiverLocation);
// System.out.println("skedTX -> skedTX deg: " + bearingOfSekdSenderToSkedReceiver);
double bearingOfSekdSenderToMe = skedSenderLocation.getBearing(myLocation);
// System.out.println("skedTX -> me deg: " + bearingOfSekdSenderToMe);
/**
* simple mech works
*/
// if (bearingOfSekdSenderToMe >= bearingOfSekdSenderToSkedReceiver) {
// if (bearingOfSekdSenderToMe-bearingOfSekdSenderToSkedReceiver <= hisAntennaBeamWidth/2){
// System.out.println(bearingOfSekdSenderToMe-bearingOfSekdSenderToSkedReceiver + " <= " + hisAntennaBeamWidth);
// return true;
// }
// } else if ((bearingOfSekdSenderToMe <= bearingOfSekdSenderToSkedReceiver)) {
// if (bearingOfSekdSenderToSkedReceiver-bearingOfSekdSenderToMe <= hisAntennaBeamWidth/2){
// return true;
// }
// } else return false;
/**
* simple mech end
*/
if (DirectionUtils.isAngleInRange(bearingOfSekdSenderToSkedReceiver, bearingOfSekdSenderToMe, hisAntennaBeamWidth)) {
//I may should get "/2" because of 50% of the 3dB opening angle if txer is directed to sender exactly
// System.out.println("------------> isinangleandrange!");
return true;
} else {
// System.out.println("not in angle and reach");
return false;
}
}
/**
* Tests, if the angle (from me to) other station is in the range of the
* angle (qtf) in degrees where my antenna points to.
*
* @param toForeignAngle [degrees]
* @param mySelectedQTFAngle [degrees]
* @param antennaBeamwidth [degrees]
* @return
*/
public static boolean isAngleInRange(double toForeignAngle,
double mySelectedQTFAngle, double antennaBeamwidth) {
double beamwidth = antennaBeamwidth / 2; // half left, half right
double startAngle = mySelectedQTFAngle - beamwidth;
double endAngle = mySelectedQTFAngle + beamwidth;
// Normalize angles to be between 0 and 360 degrees
toForeignAngle = normalizeAngle(toForeignAngle);
startAngle = normalizeAngle(startAngle);
endAngle = normalizeAngle(endAngle);
// Check if the range wraps around 360 degrees
if (startAngle <= endAngle) {
return toForeignAngle >= startAngle && toForeignAngle <= endAngle;
} else {
// Range wraps around 360 degrees, so check if angle is within the
// range or outside the range
return toForeignAngle >= startAngle || toForeignAngle <= endAngle;
}
}
private static double normalizeAngle(double angle) {
if (angle < 0) {
angle += 360;
}
if (angle >= 360) {
angle -= 360;
}
return angle;
}
}
@@ -0,0 +1,48 @@
package kst4contest.locatorUtils;
import java.math.BigDecimal;
import java.math.MathContext;
/**
* Latitude.
*
* @author Paul Picazo <ppicazo@gmail.com>
*
*/
public class Latitude extends Angle {
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (!(obj.getClass() == this.getClass())) {
return false;
}
return ((Latitude) obj).hashCode() == this.hashCode();
}
@Override
public int hashCode() {
final int prime = 37;
if (radians == null) {
return prime;
}
return prime + radians.hashCode();
}
/**
* @param degrees input
* @return latitude object
*/
public static Latitude fromDegrees(final double degrees) {
Latitude latitude = new Latitude();
latitude.radians = Angle.degreesToRadians(new BigDecimal(degrees),
MathContext.DECIMAL128);
return latitude;
}
}
@@ -0,0 +1,359 @@
package kst4contest.locatorUtils;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Locale;
/**
* Location class with methods allowing conversion to and from Maidenhead
* locator (grid squares) based off of
* "Conversion Between Geodetic and Grid Locator Systems" by Edmund T. Tyson,
* N5JTY in QST January 1989, pp. 29-30, 43
*
* @author Paul Picazo <ppicazo@gmail.com>
*
*/
public class Location {
/**
* Average earth radius in kilometers, IUGG definition.
*/
private static final double AVG_EARTH_RADIUS_KM = 6371.009;
/**
* Average earth radius in statute miles, IUGG definition.
*/
private static final double AVG_EARTH_RADIUS_SM = 3958.761;
/**
* Average earth radius in nautical miles, IUGG definition.
*/
private static final double AVG_EARTH_RADIUS_NM = 3440.069;
/**
* latitude of location.
*/
private Latitude latitude;
/**
* longitude of location.
*/
private Longitude longitude;
/**
* No argument constructor.
*
*/
public Location() {
this.latitude = new Latitude();
this.longitude = new Longitude();
}
/**
* @param latitudeIn
* initial latitude
* @param longitudeIn
* initial longitude
*/
public Location(final Latitude latitudeIn, final Longitude longitudeIn) {
this.latitude = latitudeIn;
this.longitude = longitudeIn;
}
/**
* @param latitudeIn
* initial latitude
* @param longitudeIn
* initial longitude
*/
public Location(final double latitudeIn, final double longitudeIn) {
this.latitude = Latitude.fromDegrees(latitudeIn);
this.longitude = Longitude.fromDegrees(longitudeIn);
}
/**
* @param maidenhead
* used construct location from maidenhead locator string
*/
public Location(final String maidenhead) {
this.latitude = extractLat(maidenhead);
this.longitude = extractLon(maidenhead);
}
@Override
public boolean equals(final Object obj) {
if (obj instanceof Location) {
return ((Location) obj).hashCode() == this.hashCode();
}
return false;
}
@Override
public int hashCode() {
int hash = 1;
hash = hash * 17 + this.latitude.hashCode();
hash = hash * 31 + this.longitude.hashCode();
return hash;
}
/**
* @return maidenhead locator string
*/
public String toMaidenhead() {
return toMaidenhead(this.latitude.toDegrees(),
this.longitude.toDegrees());
}
/**
* @param latitudeIn
* latitude component of locator string
* @param longitudeIn
* longitude component of locator string
* @return maidenhead locator string
*/
public static String toMaidenhead(final double latitudeIn,
final double longitudeIn) {
double longitude = longitudeIn + 180;
longitude /= 2;
char lonFirst = (char) ('A' + (longitude / 10));
char lonSecond = (char) ('0' + longitude % 10);
char lonThird = (char) ('A' + (longitude % 1) * 24);
double latitude = latitudeIn + 90;
char latFirst = (char) ('A' + (latitude / 10));
char latSecond = (char) ('0' + latitude % 10);
char latThird = (char) ('A' + (latitude % 1) * 24);
StringBuilder sb = new StringBuilder();
sb.append(lonFirst);
sb.append(latFirst);
sb.append(lonSecond);
sb.append(latSecond);
sb.append(("" + lonThird).toLowerCase());
sb.append(("" + latThird).toLowerCase());
return sb.toString();
}
/**
* @param maidenheadIn
* locator string to be converted
* @return latitude
*/
public static Latitude extractLat(final String maidenheadIn) {
String maidenhead = maidenheadIn.toUpperCase();
double latitude = -90 + 10 * (maidenhead.charAt(1) - 'A')
+ (maidenhead.charAt(3) - '0') + 2.5 / 60
* (maidenhead.charAt(5) - 'A') + 2.5 / 60 / 2;
return Latitude.fromDegrees(latitude);
}
/**
* @param maidenheadIn
* locator string to be converted
* @return longitude
*/
public static Longitude extractLon(final String maidenheadIn) {
String maidenhead = maidenheadIn.toUpperCase();
double longitude = -180 + 20 * (maidenhead.charAt(0) - 'A') + 2
* (maidenhead.charAt(2) - '0') + 5.0 / 60
* (maidenhead.charAt(4) - 'A') + 5.0 / 60 / 2;
return Longitude.fromDegrees(longitude);
}
/**
* @return latitude
*/
public Latitude getLatitude() {
return latitude;
}
/**
* @return longitude
*/
public Longitude getLongitude() {
return longitude;
}
/**
* @param latitudeIn
* north/south component of location
*/
public void setLatitude(final Latitude latitudeIn) {
this.latitude = latitudeIn;
}
/**
* @param longitudeIn
* east/west component of location
*/
public void setLongitude(final Longitude longitudeIn) {
this.longitude = longitudeIn;
}
/**
* @param loc2
* second location
* @return great circle distance in miles
*/
public double getDistanceMi(final Location loc2) {
return getDistanceMi(this, loc2);
}
/**
* @param loc2
* second location
* @return great circle distance in kilometers
*/
public double getDistanceKm(final Location loc2) {
return getDistanceKm(this, loc2);
}
/**
* @param locator1 6 letter location string
* @param locator2 6 letter location string
* @return great circle distance in kilometers
*/
public double getDistanceKmByTwoLocatorStrings(String locator1,String locator2 ) {
Location loc1 = new Location(locator1);
Location loc2 = new Location(locator2);
Locale locale = new Locale("en", "UK");
String pattern = "###.##";
DecimalFormat decimalFormat = (DecimalFormat)
NumberFormat.getNumberInstance(locale);
decimalFormat.applyPattern(pattern);
String format = decimalFormat.format(loc1.getDistanceKm(loc2));
return Double.parseDouble(format);
}
/**
* @param loc2
* second location
* @return great circle distance in nautical miles
*/
public double getDistanceNm(final Location loc2) {
return getDistanceNm(this, loc2);
}
/**
* @param loc1
* first location
* @param loc2
* second location
* @return great circle distance in miles
*/
public static double getDistanceMi(final Location loc1, final Location loc2) {
return getDistance(loc1, loc2, AVG_EARTH_RADIUS_SM);
}
/**
* @param loc1
* first location
* @param loc2
* second location
* @return great circle distance in kilometers
*/
private static double getDistanceKm(final Location loc1, final Location loc2) {
return getDistance(loc1, loc2, AVG_EARTH_RADIUS_KM);
}
/**
* @param loc1
* first location
* @param loc2
* second location
* @return great circle distance in nautical miles
*/
private static double getDistanceNm(final Location loc1, final Location loc2) {
return getDistance(loc1, loc2, AVG_EARTH_RADIUS_NM);
}
/**
* @param loc1
* first location
* @param loc2
* second location
* @param radius
* radius of the earth in the units desired for result
* @return great circle distance between the two locations, result units same
* of the radius units
*/
private static double getDistance(final Location loc1, final Location loc2,
final double radius) {
if (loc1.equals(loc2)) {
return 0;
}
return Math.acos(Math.sin(loc1.getLatitude().getRadians())
* Math.sin(loc2.getLatitude().getRadians())
+ Math.cos(loc1.getLatitude().getRadians())
* Math.cos(loc2.getLatitude().getRadians())
* Math.cos(loc2.getLongitude().getRadians()
- loc1.getLongitude().getRadians()))
* radius;
}
/**
* @param loc2
* destination location
* @return bearing in degrees
*/
public double getBearing(final Location loc2) {
return getBearing(this, loc2);
}
/**
*
* @return bearing in degrees
*/
public double getBearingOfTwoLocatorStrings(String locator1, String locator2) {
Location loc1 = new Location(locator1);
Location loc2 = new Location(locator2);
return getBearing(loc1, loc2);
}
/**
* @param loc1
* source location
* @param loc2
* destination location
* @return bearing in degrees
*/
public static double getBearing(final Location loc1, final Location loc2) {
if (loc1.equals(loc2)) {
return Double.NaN;
}
double dLon = loc2.getLongitude().getRadians()
- loc1.getLongitude().getRadians();
double y = Math.sin(dLon)
* Math.cos(loc2.getLatitude().getRadians());
double x = Math.cos(loc1.getLatitude().getRadians())
* Math.sin(loc2.getLatitude().getRadians())
- Math.sin(loc1.getLatitude().getRadians())
* Math.cos(loc2.getLatitude().getRadians())
* Math.cos(dLon);
double bearing = (Angle.radiansToDegrees(Math.atan2(y, x)) + 360) % 360;
// return bearing;
Locale locale = new Locale("en", "UK");
String pattern = "###.##";
DecimalFormat decimalFormat = (DecimalFormat)
NumberFormat.getNumberInstance(locale);
decimalFormat.applyPattern(pattern);
String format = decimalFormat.format(bearing);
return Double.parseDouble(format);
}
}
@@ -0,0 +1,49 @@
package kst4contest.locatorUtils;
import java.math.BigDecimal;
import java.math.MathContext;
/**
* Longitude.
*
* @author Paul Picazo <ppicazo@gmail.com>
*
*/
public class Longitude extends Angle {
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (!(obj.getClass() == this.getClass())) {
return false;
}
return ((Longitude) obj).hashCode() == this.hashCode();
}
@Override
public int hashCode() {
final int prime = 29;
if (radians == null) {
return prime;
}
return prime + radians.hashCode();
}
/**
* @param degrees input
* @return longitude object
*/
public static Longitude fromDegrees(final double degrees) {
Longitude longitude = new Longitude();
longitude.radians = Angle.degreesToRadians(new BigDecimal(degrees),
MathContext.DECIMAL128);
return longitude;
}
}
@@ -0,0 +1,43 @@
package kst4contest.locatorUtils;
public class TestLocatorUtils {
public static void main(String[] args) {
// isInAngle(myLocation, location1, location2);
System.out.println(isInAngleAndRange("JN49FL", "jo43xm", "jo30sa", 900, 50));
System.out.println(isInAngleAndRange("JN49FL", "jo51ij", "jn39oc", 900, 50));
System.out.println(isInAngleAndRange("JN49FL", "jn39oc", "jo51ij", 1100, 50));
}
public static boolean isInAngleAndRange(String myLocator, String locatorOfSkedSender, String locatorOfSekdReceiver, double maxRangeKm, double hisAntennaBeamWidth) {
Location myLocation = new Location(myLocator);
Location skedSenderLocation = new Location(locatorOfSkedSender);
Location skedReceiverLocation = new Location(locatorOfSekdReceiver);
double distanceFromMeToLocSender = new Location(myLocator).getDistanceKm(new Location(locatorOfSkedSender));
// Check if distance exceeds my setted maximum range
if (distanceFromMeToLocSender > maxRangeKm) {
System.out.println("too far, " + distanceFromMeToLocSender + " km");
return false;
}
//check bearing of sender to receiver
double bearingOfSekdSenderToSkedReceiver = skedSenderLocation.getBearing(skedReceiverLocation);
System.out.println("skedTX -> skedRX deg: " + bearingOfSekdSenderToSkedReceiver);
double bearingOfSekdSenderToMe = skedSenderLocation.getBearing(myLocation);
System.out.println("skedTX -> me deg: " + bearingOfSekdSenderToMe);
if (DirectionUtils.isAngleInRange(bearingOfSekdSenderToSkedReceiver, bearingOfSekdSenderToMe, hisAntennaBeamWidth)) {
//may I should get "/2" because of 50% of the 3dB opening angle if txer is directed to sender exactly
return true;
} else return false;
}
}
@@ -0,0 +1,284 @@
package kst4contest.logic;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Resolves band availability and band-upgrade opportunities from one or more
* active {@link ChatMember} variants of the same base callsign.
*
* <p>The resolver deliberately separates a band hint from an exact frequency:
* {@code knownActiveBands} remains the source for detected QRGs with timestamps,
* while the station name may add a band without inventing a frequency.</p>
*
* <p>A manual NOT-QRV flag always overrides automatic evidence. Worked flags are
* evaluated separately because an offered band may still be useful for display,
* even when it is no longer a band-upgrade opportunity.</p>
*/
public final class BandOpportunityResolver {
public static final long RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS = 30L * 60L * 1000L;
private static final Map<Band, Pattern> STATION_NAME_BAND_PATTERNS = createStationNameBandPatterns();
private BandOpportunityResolver() {
}
/**
* Resolves the common band state using the application-wide 30-minute window
* for frequency evidence. Name-derived band hints remain valid while the
* ChatMember is present in the active chat model.
*/
public static Resolution resolve(Collection<ChatMember> variants, long nowEpochMs) {
return resolve(variants, nowEpochMs, RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS);
}
/**
* Resolves offered, worked and manually excluded bands across all supplied
* category/callsign variants.
*/
public static Resolution resolve(Collection<ChatMember> variants,
long nowEpochMs,
long dynamicEvidenceMaxAgeMs) {
EnumSet<Band> offeredBands = EnumSet.noneOf(Band.class);
EnumSet<Band> workedBands = EnumSet.noneOf(Band.class);
EnumSet<Band> notQrvBands = EnumSet.noneOf(Band.class);
if (variants == null) {
return new Resolution(offeredBands, workedBands, notQrvBands);
}
for (ChatMember member : variants) {
if (member == null) {
continue;
}
collectRecentFrequencyBands(
member,
offeredBands,
nowEpochMs,
dynamicEvidenceMaxAgeMs
);
offeredBands.addAll(detectBandsFromStationName(member.getName()));
collectWorkedBands(member, workedBands);
collectNotQrvBands(member, notQrvBands);
}
return new Resolution(offeredBands, workedBands, notQrvBands);
}
/**
* Returns the bands enabled in the local station setup. Bands above 10 GHz
* remain excluded because the current preferences do not provide active-band
* flags for them.
*/
public static EnumSet<Band> getEnabledStationBands(ChatPreferences preferences) {
EnumSet<Band> enabledBands = EnumSet.noneOf(Band.class);
if (preferences == null) {
return enabledBands;
}
if (preferences.isStn_bandActive50()) enabledBands.add(Band.B_50);
if (preferences.isStn_bandActive70()) enabledBands.add(Band.B_70);
if (preferences.isStn_bandActive144()) enabledBands.add(Band.B_144);
if (preferences.isStn_bandActive432()) enabledBands.add(Band.B_432);
if (preferences.isStn_bandActive1240()) enabledBands.add(Band.B_1296);
if (preferences.isStn_bandActive2300()) enabledBands.add(Band.B_2320);
if (preferences.isStn_bandActive3400()) enabledBands.add(Band.B_3400);
if (preferences.isStn_bandActive5600()) enabledBands.add(Band.B_5760);
if (preferences.isStn_bandActive10G()) enabledBands.add(Band.B_10G);
return enabledBands;
}
/**
* Detects explicit amateur-band designators in a station name.
*
* <p>The boundary rules are intentionally stricter than simple token splitting.
* In particular, the trailing {@code 2} in {@code 1.2 cm} must not be mistaken
* for the 2 m band.</p>
*/
public static EnumSet<Band> detectBandsFromStationName(String stationName) {
EnumSet<Band> detectedBands = EnumSet.noneOf(Band.class);
if (stationName == null || stationName.isBlank()) {
return detectedBands;
}
/*
* Explicit band descriptions:
* 2m, 70cm, 23cm, 432, 1296, ...
*/
for (Map.Entry<Band, Pattern> entry : STATION_NAME_BAND_PATTERNS.entrySet()) {
if (entry.getValue().matcher(stationName).find()) {
detectedBands.add(entry.getKey());
}
}
/*
* Explicit full QRGs in name field:
* 432.357 -> 432 MHz
* 1296.210 -> 1296 MHz
* etc.
*/
for (FrequencyTextParser.DetectedFrequency detectedFrequency
: FrequencyTextParser.findExplicitFrequencies(
stationName
)) {
detectedBands.add(
detectedFrequency.getBand()
);
}
return detectedBands;
}
private static void collectRecentFrequencyBands(ChatMember member,
EnumSet<Band> target,
long nowEpochMs,
long dynamicEvidenceMaxAgeMs) {
if (member.getKnownActiveBands() == null || member.getKnownActiveBands().isEmpty()) {
return;
}
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> entry
: member.getKnownActiveBands().entrySet()) {
Band band = entry.getKey();
ChatMember.ActiveFrequencyInfo info = entry.getValue();
if (band == null || info == null) {
continue;
}
long ageMs = nowEpochMs - info.timestampEpoch;
boolean ageAccepted = dynamicEvidenceMaxAgeMs <= 0L
? ageMs >= 0L
: ageMs >= 0L && ageMs <= dynamicEvidenceMaxAgeMs;
if (ageAccepted) {
target.add(band);
}
}
}
private static void collectWorkedBands(ChatMember member, EnumSet<Band> target) {
if (member.isWorked50()) target.add(Band.B_50);
if (member.isWorked70()) target.add(Band.B_70);
if (member.isWorked144()) target.add(Band.B_144);
if (member.isWorked432()) target.add(Band.B_432);
if (member.isWorked1240()) target.add(Band.B_1296);
if (member.isWorked2300()) target.add(Band.B_2320);
if (member.isWorked3400()) target.add(Band.B_3400);
if (member.isWorked5600()) target.add(Band.B_5760);
if (member.isWorked10G()) target.add(Band.B_10G);
if (member.isWorked24G()) target.add(Band.B_24G);
}
private static void collectNotQrvBands(ChatMember member, EnumSet<Band> target) {
if (!member.isQrv50()) target.add(Band.B_50);
if (!member.isQrv70()) target.add(Band.B_70);
if (!member.isQrv144()) target.add(Band.B_144);
if (!member.isQrv432()) target.add(Band.B_432);
if (!member.isQrv1240()) target.add(Band.B_1296);
if (!member.isQrv2300()) target.add(Band.B_2320);
if (!member.isQrv3400()) target.add(Band.B_3400);
if (!member.isQrv5600()) target.add(Band.B_5760);
if (!member.isQrv10G()) target.add(Band.B_10G);
// There is currently no persisted NOT-QRV flag for 24 GHz.
}
private static Map<Band, Pattern> createStationNameBandPatterns() {
Map<Band, Pattern> patterns = new EnumMap<>(Band.class);
// Bare "70" and bare "6" are already claimed by the 70cm/6cm shorthand below
// (their "CM" suffix is optional), so 4m/6m must require an explicit MHz/"M"
// suffix here to avoid misreading a cm-band shorthand as 70/50 MHz.
patterns.put(Band.B_50, bandPattern("50(?:\\s*MHZ)?|6\\s*M"));
patterns.put(Band.B_70, bandPattern("70\\s*MHZ|4\\s*M"));
patterns.put(Band.B_144, bandPattern("144(?:\\s*MHZ)?|2(?:\\s*M)?"));
patterns.put(Band.B_432, bandPattern("432(?:\\s*MHZ)?|70(?:\\s*CM)?"));
patterns.put(Band.B_1296, bandPattern("1296(?:\\s*MHZ)?|23(?:\\s*CM)?"));
patterns.put(Band.B_2320, bandPattern("(?:2300|2320)(?:\\s*MHZ)?|13(?:\\s*CM)?"));
patterns.put(Band.B_3400, bandPattern("3400(?:\\s*MHZ)?|9(?:\\s*CM)?"));
patterns.put(Band.B_5760, bandPattern("(?:5600|5760)(?:\\s*MHZ)?|6(?:\\s*CM)?"));
patterns.put(Band.B_10G, bandPattern("10368(?:\\s*MHZ)?|10\\s*G(?:HZ)?|3(?:\\s*CM)?"));
patterns.put(Band.B_24G, bandPattern("24048(?:\\s*MHZ)?|24\\s*G(?:HZ)?|1[.,]2(?:\\s*CM)?"));
return Collections.unmodifiableMap(patterns);
}
private static Pattern bandPattern(String alternatives) {
return Pattern.compile(
"(?<![A-Z0-9.,])(?:" + alternatives + ")(?![A-Z0-9.,])",
Pattern.CASE_INSENSITIVE
);
}
/** Immutable result of one callsign-wide band resolution. */
public static final class Resolution {
private final EnumSet<Band> offeredBands;
private final EnumSet<Band> workedBands;
private final EnumSet<Band> notQrvBands;
private Resolution(EnumSet<Band> offeredBands,
EnumSet<Band> workedBands,
EnumSet<Band> notQrvBands) {
this.offeredBands = copyOf(offeredBands);
this.workedBands = copyOf(workedBands);
this.notQrvBands = copyOf(notQrvBands);
}
/** Returns all recent/name-derived bands before NOT-QRV is applied. */
public EnumSet<Band> getOfferedBands() {
return copyOf(offeredBands);
}
public EnumSet<Band> getWorkedBands() {
return copyOf(workedBands);
}
public EnumSet<Band> getNotQrvBands() {
return copyOf(notQrvBands);
}
/** Returns offered bands after manual NOT-QRV exclusions. */
public EnumSet<Band> getAvailableBands() {
EnumSet<Band> availableBands = copyOf(offeredBands);
availableBands.removeAll(notQrvBands);
return availableBands;
}
/** Returns offered, QRV, enabled and not-yet-worked bands. */
public EnumSet<Band> getUnworkedEnabledBands(EnumSet<Band> enabledBands) {
EnumSet<Band> opportunities = getAvailableBands();
if (enabledBands == null || enabledBands.isEmpty()) {
opportunities.clear();
return opportunities;
}
opportunities.retainAll(enabledBands);
opportunities.removeAll(workedBands);
return opportunities;
}
public boolean hasBandEvidence() {
return !offeredBands.isEmpty();
}
private static EnumSet<Band> copyOf(EnumSet<Band> source) {
return source == null || source.isEmpty()
? EnumSet.noneOf(Band.class)
: EnumSet.copyOf(source);
}
}
}
@@ -0,0 +1,196 @@
package kst4contest.logic;
import kst4contest.model.Band;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Common parser for explicit amateur-radio frequencies embedded in text.
*
* <p>This parser deliberately handles only complete frequencies such as
* 144.300, 432.357, 10368.100 or their compact digit-only forms. Relative
* forms such as ".210" or ambiguous bare values such as "210" require
* additional message context and remain the responsibility of the
* chat-message parser.</p>
*/
public final class FrequencyTextParser {
/*
* Examples:
* 50.150
* 144.300
* 432,357
* 10368.100
* 144.300.03
* 144300
* 10368100
*
* At least two digits are required before a decimal separator. Compact
* values need at least five digits because their final three digits form
* the kHz part. This intentionally prevents "1.2" and bare values such as
* "210" from being interpreted as complete frequencies.
*/
private static final Pattern EXPLICIT_FREQUENCY_PATTERN = Pattern.compile(
"(?<![A-Z0-9])"
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?|\\d{5,8})"
+ "(?![A-Z0-9])",
Pattern.CASE_INSENSITIVE
);
private FrequencyTextParser() {
}
/**
* Finds all distinct explicit frequencies that fall into one of the bands
* supported by {@link Band}.
*/
public static List<DetectedFrequency> findExplicitFrequencies(String text) {
if (text == null || text.isBlank()) {
return List.of();
}
Matcher matcher = EXPLICIT_FREQUENCY_PATTERN.matcher(text);
Map<String, DetectedFrequency> uniqueFrequencies =
new LinkedHashMap<>();
while (matcher.find()) {
DetectedFrequency detected =
parseExplicitFrequency(matcher.group(1));
if (detected == null) {
continue;
}
String uniqueKey =
detected.getBand().name()
+ "|"
+ Double.toString(
detected.getFrequencyMHz()
);
uniqueFrequencies.putIfAbsent(
uniqueKey,
detected
);
}
return List.copyOf(
new ArrayList<>(uniqueFrequencies.values())
);
}
/**
* Parses one complete frequency.
*
* @return detected band/frequency or {@code null} when the value is invalid
* or outside the supported amateur bands
*/
public static DetectedFrequency parseExplicitFrequency(
String rawFrequency
) {
if (rawFrequency == null || rawFrequency.isBlank()) {
return null;
}
final String trimmedFrequency = rawFrequency.trim();
final String normalized = trimmedFrequency.matches("\\d{5,8}")
? trimmedFrequency.substring(0, trimmedFrequency.length() - 3)
+ "."
+ trimmedFrequency.substring(trimmedFrequency.length() - 3)
: normalizeFrequencyString(trimmedFrequency.replace(',', '.'));
try {
double frequencyMHz =
Double.parseDouble(normalized);
if (!Double.isFinite(frequencyMHz)) {
return null;
}
Band band =
Band.fromFrequency(frequencyMHz);
if (band == null) {
return null;
}
return new DetectedFrequency(
band,
frequencyMHz,
rawFrequency.trim()
);
} catch (NumberFormatException ignored) {
return null;
}
}
/**
* Normalizes KST-style frequency strings with an optional second decimal
* separator.
*
* Examples:
* 144.300.03 -> 144.30003
* 144.300 -> 144.300
*/
private static String normalizeFrequencyString(
String rawInput
) {
int firstDotIndex = rawInput.indexOf('.');
if (firstDotIndex < 0) {
return rawInput;
}
String decimalPart =
rawInput.substring(firstDotIndex + 1);
if (!decimalPart.contains(".")) {
return rawInput;
}
decimalPart =
decimalPart.replace(".", "");
return rawInput.substring(0, firstDotIndex + 1)
+ decimalPart;
}
/**
* Immutable result of one explicit-frequency detection.
*/
public static final class DetectedFrequency {
private final Band band;
private final double frequencyMHz;
private final String sourceText;
private DetectedFrequency(
Band band,
double frequencyMHz,
String sourceText
) {
this.band = band;
this.frequencyMHz = frequencyMHz;
this.sourceText = sourceText;
}
public Band getBand() {
return band;
}
public double getFrequencyMHz() {
return frequencyMHz;
}
public String getSourceText() {
return sourceText;
}
}
}
@@ -0,0 +1,278 @@
package kst4contest.logic;
import kst4contest.controller.StationMetricsService;
import kst4contest.model.*;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
/**
* Priority score calculation (off FX-thread).
*
* Notes:
* - Score is computed once per callsignRaw by ScoreService.
* - This calculator MUST be pure (no UI calls) and fast.
*/
public class PriorityCalculator {
public double calculatePriority(ChatMember member,
Collection<ChatMember> callsignVariants,
ChatPreferences prefs,
List<ContestSked> activeSkeds,
StationMetricsService.Snapshot metricsSnapshot,
long nowEpochMs) {
if (member == null || prefs == null) return 0.0;
final String callRaw = normalize(member.getCallSignRaw());
if (callRaw == null || callRaw.isBlank()) return 0.0;
// --------------------------------------------------------------------
// 1) HARD FILTER: reachable hardware + "already worked on all possible bands"
// --------------------------------------------------------------------
Collection<ChatMember> variants = callsignVariants == null || callsignVariants.isEmpty()
? List.of(member)
: callsignVariants;
BandOpportunityResolver.Resolution bandResolution =
BandOpportunityResolver.resolve(variants, nowEpochMs);
EnumSet<Band> myEnabledBands =
BandOpportunityResolver.getEnabledStationBands(prefs);
// ChatMember.worked remains UI-only. Scoring uses only per-band worked flags.
EnumSet<Band> workedBandsForScoring = bandResolution.getWorkedBands();
EnumSet<Band> unworkedPossible = EnumSet.noneOf(Band.class);
EnumSet<Band> stationOfferedBands = bandResolution.getOfferedBands();
EnumSet<Band> stationAvailableBands = bandResolution.getAvailableBands();
EnumSet<Band> possibleBands = stationOfferedBands.isEmpty()
? EnumSet.noneOf(Band.class)
: EnumSet.copyOf(stationAvailableBands);
if (!stationOfferedBands.isEmpty()) {
possibleBands.retainAll(myEnabledBands);
if (possibleBands.isEmpty()) {
// Known bands are disabled locally or manually marked NOT QRV.
return 0.0;
}
unworkedPossible = EnumSet.copyOf(possibleBands);
unworkedPossible.removeAll(workedBandsForScoring);
if (unworkedPossible.isEmpty()) {
return 0.0;
}
} else {
/*
* Missing band evidence is not automatically negative. A complete manual
* NOT-QRV exclusion is different: if every enabled own band is excluded,
* this station cannot be a current contest candidate.
*/
EnumSet<Band> notExplicitlyExcluded = EnumSet.copyOf(myEnabledBands);
notExplicitlyExcluded.removeAll(bandResolution.getNotQrvBands());
if (!myEnabledBands.isEmpty() && notExplicitlyExcluded.isEmpty()) {
return 0.0;
}
}
// --------------------------------------------------------------------
// 2) BASE SCORE
// --------------------------------------------------------------------
double score = 100.0;
/*
* A manual Sked-fail mark represents an explicit operator decision. The
* penalty is therefore applied after all positive contributions, including
* an imminent sked. Applying it earlier would allow the later sked boost to
* almost completely cancel the failure mark.
*/
boolean manualSkedFailed = false;
if (workedBandsForScoring.isEmpty()) {
score += 200.0; // never worked on any supported band -> higher priority
} else {
score -= 150.0; // already worked on at least one band -> lower base priority
}
// Multi-band bonus: if they offer >1 possible band and we worked at least one, prefer them
if (!possibleBands.isEmpty()) {
int bandCount = possibleBands.size();
score += (bandCount - 1) * 80.0;
}
// Optional: band-upgrade visibility boost
// If the station is already worked on at least one band, but is still QRV on other unworked enabled band(s),
// we can optionally add a boost so it remains visible in the list.
if (prefs.isNotify_bandUpgradePriorityBoostEnabled()
&& !workedBandsForScoring.isEmpty()
&& !unworkedPossible.isEmpty()) {
score += 180.0; // tuned visibility boost
}
// --------------------------------------------------------------------
// 3) DISTANCE ("Goldilocks Zone")
// --------------------------------------------------------------------
double distKm = member.getQrb() == null ? 0.0 : member.getQrb();
if (distKm > 0) {
if (distKm < 200) {
score *= 0.7;
} else if (distKm > prefs.getStn_maxQRBDefault()) {
score *= 0.3;
} else {
score *= 1.15;
}
}
// --------------------------------------------------------------------
// 4) AIRSCOUT BOOST
// --------------------------------------------------------------------
AirPlaneReflectionInfo apInfo = member.getAirPlaneReflectInfo();
if (apInfo != null && apInfo.getAirPlanesReachableCntr() > 0) {
score += 200;
int nextMinutes = findNextAirplaneArrivingMinutes(apInfo);
if (nextMinutes == 0) score += 120;
else if (nextMinutes == 1) score += 60;
else if (nextMinutes == 2) score += 30;
}
// --------------------------------------------------------------------
// 5) BOOST IDEA #1: Beam direction match (within beamwidth)
// --------------------------------------------------------------------
if (member.getQTFdirection() != null) {
double myAz = prefs.getActualQTF().get();
double targetAz = member.getQTFdirection();
double diff = minimalAngleDiffDeg(myAz, targetAz);
double halfBeam = Math.max(1.0, prefs.getStn_antennaBeamWidthDeg()) / 2.0;
if (diff <= halfBeam) {
double centerFactor = 1.0 - (diff / halfBeam); // 1.0 center -> 0.0 edge
score += 80.0 + (120.0 * centerFactor);
}
}
// --------------------------------------------------------------------
// 6) BOOST IDEA #3: Conversation momentum (recent inbound burst)
// --------------------------------------------------------------------
if (metricsSnapshot != null) {
StationMetricsService.Snapshot.Metrics mx = metricsSnapshot.get(callRaw);
if (mx != null) {
long ageMs = mx.lastInboundEpochMs > 0 ? (nowEpochMs - mx.lastInboundEpochMs) : Long.MAX_VALUE;
// "Active now" bonus
if (ageMs < 60_000) score += 120;
else if (ageMs < 3 * 60_000) score += 60;
// Momentum bonus: multiple lines in the configured window
int cnt = mx.inboundCountInWindow;
if (cnt >= 6) score += 160;
else if (cnt >= 4) score += 110;
else if (cnt >= 2) score += 60;
// Positive signal (configurable)
if (mx.lastPositiveSignalEpochMs > 0 && (nowEpochMs - mx.lastPositiveSignalEpochMs) < 5 * 60_000) {
score += 120;
}
// Reply time: prefer fast responders
if (mx.avgResponseTimeMs > 0) {
if (mx.avgResponseTimeMs < 60_000) score += 80;
else if (mx.avgResponseTimeMs < 3 * 60_000) score += 40;
}
// No-reply penalty (automatic failed attempt)
if (mx.noReplyStrikes > 0) {
score /= (1.0 + (mx.noReplyStrikes * 0.6));
}
// Manual sked fail (path likely bad) => strong, permanent penalty until reset
/*
* Remember the explicit failure state here. The actual penalty is applied
* after the sked contribution has been calculated.
*/
manualSkedFailed = mx.manualSkedFailed;
}
}
// --------------------------------------------------------------------
// 7) BOOST IDEA #4: Sked commitment ramp-up
// --------------------------------------------------------------------
if (activeSkeds != null && !activeSkeds.isEmpty()) {
for (ContestSked sked : activeSkeds) {
if (sked == null) continue;
if (!callRaw.equals(normalize(sked.getTargetCallsign()))) continue;
long seconds = sked.getTimeUntilSkedSeconds();
// Imminent sked: absolute priority (T-3min..T+1min)
if (seconds < 180 && seconds > -60) {
score += 5000;
continue;
}
// Ramp: 0..15 minutes before => up to +1200
if (seconds >= 0 && seconds <= 15 * 60) {
double t = (15 * 60 - seconds) / (15.0 * 60.0); // 0.0..1.0
score += 300 + (900 * t);
} else if (seconds > 15 * 60) {
score += 40;
}
}
}
// --------------------------------------------------------------------
// 8) Legacy penalty: failed attempts in ChatMember
// --------------------------------------------------------------------
// --------------------------------------------------------------------
// 8) Legacy penalty: failed attempts in ChatMember
// --------------------------------------------------------------------
if (member.getFailedQSOAttempts() > 0) {
score = score / (member.getFailedQSOAttempts() + 1);
}
// --------------------------------------------------------------------
// 9) Explicit operator override: manual Sked fail
// --------------------------------------------------------------------
if (manualSkedFailed) {
/*
* Apply the penalty to the complete result. This includes an imminent
* sked and prevents its strong time-dependent boost from cancelling the
* operator's explicit failure assessment.
*/
score *= 0.15;
}
return Math.max(0.0, score);
}
private static int findNextAirplaneArrivingMinutes(AirPlaneReflectionInfo apInfo) {
try {
if (apInfo.getRisingAirplanes() == null || apInfo.getRisingAirplanes().isEmpty()) return -1;
int min = Integer.MAX_VALUE;
for (AirPlane ap : apInfo.getRisingAirplanes()) {
if (ap == null) continue;
min = Math.min(min, ap.getArrivingDurationMinutes());
}
return min == Integer.MAX_VALUE ? -1 : min;
} catch (Exception ignore) {
return -1;
}
}
private static double minimalAngleDiffDeg(double a, double b) {
double diff = Math.abs((a - b) % 360.0);
return diff > 180.0 ? 360.0 - diff : diff;
}
private static String normalize(String s) {
if (s == null) return null;
return s.trim().toUpperCase();
}
}
@@ -0,0 +1,381 @@
package kst4contest.logic;
import kst4contest.model.Band;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
/**
* Selects one realistic propagation frequency for a station.
*
* <p>The same resolution is used by AirScout and by the internal path analysis.
* Only the chat categories supported by these features participate. This keeps
* unrelated KST categories from silently falling back to 144 MHz.</p>
*/
public final class PropagationFrequencyResolver {
// private static final double DUAL_VUHF_MICROWAVE_FALLBACK_MHZ = 430.0;
private PropagationFrequencyResolver() {
}
/** Explains why a frequency was selected. */
public enum Source {
CURRENT_QRG,
STATION_NAME_QRG,
STATION_NAME,
DUAL_CATEGORY_FALLBACK,
CHAT_CATEGORY
}
/**
* Resolves the frequency from all active category variants of one base
* callsign.
*
* <ol>
* <li>Most recently detected QRG</li>
* <li>Lowest band explicitly named by the station</li>
* <li>432 MHz if the station is present in VUHF and Microwave</li>
* <li>Lowest usable fallback band of the supported chat category</li>
* </ol>
*
* <p>Locally disabled and manually excluded bands are never selected.</p>
*
* @param variants active category variants of one callsign
* @param enabledBands bands enabled for the local station
* @param nowEpochMs current time used for the QRG age check
* @return one resolution, or {@code null} if no safe choice exists
*/
public static Resolution resolve(Collection<ChatMember> variants,
EnumSet<Band> enabledBands,
long nowEpochMs) {
if (variants == null || variants.isEmpty()
|| enabledBands == null || enabledBands.isEmpty()) {
return null;
}
List<ChatMember> supportedVariants = variants.stream()
.filter(PropagationFrequencyResolver::isSupportedVariant)
.toList();
if (supportedVariants.isEmpty()) {
return null;
}
BandOpportunityResolver.Resolution opportunityResolution =
BandOpportunityResolver.resolve(supportedVariants, nowEpochMs);
EnumSet<Band> usableBands = EnumSet.copyOf(enabledBands);
usableBands.removeAll(opportunityResolution.getNotQrvBands());
if (usableBands.isEmpty()) {
return null;
}
FrequencyCandidate latestQrg = findLatestQrg(
supportedVariants,
usableBands,
nowEpochMs
);
if (latestQrg != null) {
return new Resolution(
latestQrg.band,
latestQrg.frequencyMHz,
Source.CURRENT_QRG
);
}
FrequencyCandidate stationNameQrg =
findUniqueStationNameQrg(
supportedVariants,
usableBands
);
if (stationNameQrg != null) {
return new Resolution(
stationNameQrg.band,
stationNameQrg.frequencyMHz,
Source.STATION_NAME_QRG
);
}
EnumSet<Band> nameBands = EnumSet.noneOf(Band.class);
for (ChatMember variant : supportedVariants) {
nameBands.addAll(
BandOpportunityResolver.detectBandsFromStationName(variant.getName())
);
}
nameBands.retainAll(usableBands);
Band nameBand = lowestBand(nameBands);
if (nameBand != null) {
return new Resolution(
nameBand,
nameBand.getDefaultAnalysisFrequencyMHz(),
Source.STATION_NAME
);
}
EnumSet<SupportedCategory> categories = collectSupportedCategories(supportedVariants);
if (categories.contains(SupportedCategory.VUHF)
&& categories.contains(SupportedCategory.MICROWAVE)
&& usableBands.contains(Band.B_432)) {
return new Resolution(
Band.B_432,
Band.B_432.getDefaultAnalysisFrequencyMHz(),
Source.DUAL_CATEGORY_FALLBACK
);
}
EnumSet<Band> categoryBands = EnumSet.noneOf(Band.class);
for (SupportedCategory category : categories) {
categoryBands.addAll(category.fallbackBands);
}
categoryBands.retainAll(usableBands);
Band categoryBand = lowestBand(categoryBands);
if (categoryBand == null) {
return null;
}
return new Resolution(
categoryBand,
categoryBand.getDefaultAnalysisFrequencyMHz(),
Source.CHAT_CATEGORY
);
}
private static FrequencyCandidate findLatestQrg(List<ChatMember> variants,
EnumSet<Band> usableBands,
long nowEpochMs) {
FrequencyCandidate latest = null;
for (ChatMember variant : variants) {
for (var entry : variant.getKnownActiveBands().entrySet()) {
Band band = entry.getKey();
ChatMember.ActiveFrequencyInfo info = entry.getValue();
if (band == null || info == null || !usableBands.contains(band)) {
continue;
}
long ageMs = nowEpochMs - info.timestampEpoch;
if (ageMs < 0L
|| ageMs > BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS
|| !Double.isFinite(info.frequency)
|| !band.isPlausible(info.frequency)) {
continue;
}
if (latest == null || info.timestampEpoch > latest.timestampEpochMs) {
latest = new FrequencyCandidate(
band,
info.frequency,
info.timestampEpoch
);
}
}
}
return latest;
}
/**
* Resolves one exact station-name QRG only when the evidence is unambiguous.
*
* <p>The same QRG repeated in several category variants counts only once.
* If different explicit QRGs are advertised, no run frequency is guessed and
* the caller continues with normal band-name/category resolution.</p>
*/
private static FrequencyCandidate findUniqueStationNameQrg(
List<ChatMember> variants,
EnumSet<Band> usableBands
) {
java.util.LinkedHashMap<String, FrequencyCandidate> uniqueCandidates =
new java.util.LinkedHashMap<>();
for (ChatMember variant : variants) {
if (variant == null) {
continue;
}
for (FrequencyTextParser.DetectedFrequency detectedFrequency
: FrequencyTextParser.findExplicitFrequencies(
variant.getName()
)) {
Band band =
detectedFrequency.getBand();
if (!usableBands.contains(band)) {
continue;
}
double frequencyMHz =
detectedFrequency.getFrequencyMHz();
String key =
band.name()
+ "|"
+ Double.toString(frequencyMHz);
uniqueCandidates.putIfAbsent(
key,
new FrequencyCandidate(
band,
frequencyMHz,
Long.MIN_VALUE
)
);
/*
* More than one different exact QRG means that we cannot safely
* identify one run frequency.
*/
if (uniqueCandidates.size() > 1) {
return null;
}
}
}
return uniqueCandidates.size() == 1
? uniqueCandidates.values()
.iterator()
.next()
: null;
}
private static boolean isSupportedVariant(ChatMember member) {
if (member == null || member.getChatCategory() == null) {
return false;
}
int categoryNumber = member.getChatCategory().getCategoryNumber();
return categoryNumber == ChatCategory.FIFTYSEVENTYMHz
|| categoryNumber == ChatCategory.VUHF
|| categoryNumber == ChatCategory.MICROWAVE
|| categoryNumber == ChatCategory.EMEJT65;
}
private static EnumSet<SupportedCategory> collectSupportedCategories(
List<ChatMember> variants
) {
EnumSet<SupportedCategory> categories = EnumSet.noneOf(SupportedCategory.class);
for (ChatMember variant : variants) {
int categoryNumber = variant.getChatCategory().getCategoryNumber();
if (categoryNumber == ChatCategory.FIFTYSEVENTYMHz) {
categories.add(SupportedCategory.FIFTY_SEVENTY);
} else if (categoryNumber == ChatCategory.VUHF) {
categories.add(SupportedCategory.VUHF);
} else if (categoryNumber == ChatCategory.MICROWAVE) {
categories.add(SupportedCategory.MICROWAVE);
} else if (categoryNumber == ChatCategory.EMEJT65) {
categories.add(SupportedCategory.EME);
}
}
return categories;
}
private static Band lowestBand(Collection<Band> bands) {
if (bands == null || bands.isEmpty()) {
return null;
}
return bands.stream()
.min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz))
.orElse(null);
}
private enum SupportedCategory {
FIFTY_SEVENTY(EnumSet.of(Band.B_50, Band.B_70)),
VUHF(EnumSet.of(Band.B_144, Band.B_432)),
MICROWAVE(EnumSet.of(
Band.B_1296,
Band.B_2320,
Band.B_3400,
Band.B_5760,
Band.B_10G,
Band.B_24G
)),
EME(EnumSet.of(
Band.B_144,
Band.B_432,
Band.B_1296,
Band.B_2320,
Band.B_3400,
Band.B_5760,
Band.B_10G,
Band.B_24G
));
private final EnumSet<Band> fallbackBands;
SupportedCategory(EnumSet<Band> fallbackBands) {
this.fallbackBands = fallbackBands;
}
}
private static final class FrequencyCandidate {
private final Band band;
private final double frequencyMHz;
private final long timestampEpochMs;
private FrequencyCandidate(Band band,
double frequencyMHz,
long timestampEpochMs) {
this.band = band;
this.frequencyMHz = frequencyMHz;
this.timestampEpochMs = timestampEpochMs;
}
}
/** Immutable selected band/frequency pair. */
public static final class Resolution {
private final Band band;
private final double analysisFrequencyMHz;
private final Source source;
private Resolution(Band band,
double analysisFrequencyMHz,
Source source) {
this.band = band;
this.analysisFrequencyMHz = analysisFrequencyMHz;
this.source = source;
}
public Band getBand() {
return band;
}
public double getAnalysisFrequencyMHz() {
return analysisFrequencyMHz;
}
public Source getSource() {
return source;
}
/**
* Converts MHz to AirScout's 100-Hz protocol unit.
*
* @return integer protocol value, for example 1442100 for 144.210 MHz
*/
public String getAirScoutBandValue() {
return Long.toString(Math.round(analysisFrequencyMHz * 10_000.0));
}
}
}
@@ -0,0 +1,51 @@
package kst4contest.logic;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
/**
* Lightweight positive-signal detector.
*
* Patterns are configured via a single preference string, delimited by ';' or newlines.
* Examples: "QRV;READY;RX OK;RGR;TNX;TU;HRD"
*/
public final class SignalDetector {
private static final AtomicReference<String> lastPatterns = new AtomicReference<>("");
private static final AtomicReference<List<Pattern>> cached = new AtomicReference<>(List.of());
private SignalDetector() {}
public static boolean containsPositiveSignal(String messageText, String patternsDelimited) {
if (messageText == null || messageText.isBlank()) return false;
List<Pattern> patterns = compileIfChanged(patternsDelimited);
String txt = messageText.toUpperCase();
for (Pattern p : patterns) {
if (p.matcher(txt).find()) return true;
}
return false;
}
private static List<Pattern> compileIfChanged(String patternsDelimited) {
String p = patternsDelimited == null ? "" : patternsDelimited.trim();
String prev = lastPatterns.get();
if (p.equals(prev)) return cached.get();
List<Pattern> out = new ArrayList<>();
for (String token : p.split("[;\\n\\r]+")) {
String t = token.trim();
if (t.isEmpty()) continue;
// plain substring match, but regex-safe
String regex = Pattern.quote(t.toUpperCase());
out.add(Pattern.compile(regex));
}
lastPatterns.set(p);
cached.set(List.copyOf(out));
return out;
}
}
@@ -0,0 +1,67 @@
package kst4contest.model;
public class AirPlane {
String apCallSign, apSizeCategory;
String potencialDescriptionAsWord;
public String getPotencialDescriptionAsWord() {
if (this.getPotential() <=50) {
return "small AP";
} else if (this.getPotential() <=75 && this.getPotential() > 50) {
return "big AP";
} else if (this.getPotential() > 75) {
return "very big AP";
}
return potencialDescriptionAsWord;
}
public void setPotencialDescriptionAsWord(String potencialDescriptionAsWord) {
this.potencialDescriptionAsWord = potencialDescriptionAsWord;
}
int distanceKm, potential, arrivingDurationMinutes;
public String getApCallSign() {
return apCallSign;
}
public void setApCallSign(String apCallSign) {
this.apCallSign = apCallSign;
}
public String getApSizeCategory() {
return apSizeCategory;
}
public void setApSizeCategory(String apSizeCategory) {
this.apSizeCategory = apSizeCategory;
}
public int getDistanceKm() {
return distanceKm;
}
public void setDistanceKm(int distanceKm) {
this.distanceKm = distanceKm;
}
public int getPotential() {
return potential;
}
public void setPotential(int potential) {
this.potential = potential;
}
public int getArrivingDurationMinutes() {
return arrivingDurationMinutes;
}
public void setArrivingDurationMinutes(int arrivingDurationMinutes) {
this.arrivingDurationMinutes = arrivingDurationMinutes;
}
@Override
public String toString() {
String toStringString = "\n";
toStringString += this.apCallSign + ", category: " + this.apSizeCategory + ", distance: " + this.getDistanceKm() + ", potential: " + this.potential + ", distance: " + this.getDistanceKm() + ", duration " + this.arrivingDurationMinutes + "" ;
return toStringString;
}
}
@@ -0,0 +1,62 @@
package kst4contest.model;
import java.util.List;
public class AirPlaneReflectionInfo {
String date;
ChatMember sender, receiver;
int airPlanesReachableCntr;
List<AirPlane> risingAirplanes;
public String getDate() {
return date;
}
public void setDate(String date) {
this.date = date;
}
public ChatMember getSender() {
return sender;
}
public void setSender(ChatMember sender) {
this.sender = sender;
}
public ChatMember getReceiver() {
return receiver;
}
public void setReceiver(ChatMember receiver) {
this.receiver = receiver;
}
public int getAirPlanesReachableCntr() {
return airPlanesReachableCntr;
}
public void setAirPlanesReachableCntr(int airPlanesReachableCntr) {
this.airPlanesReachableCntr = airPlanesReachableCntr;
}
public List<AirPlane> getRisingAirplanes() {
return risingAirplanes;
}
public void setRisingAirplanes(List<AirPlane> risingAirplanes) {
this.risingAirplanes = risingAirplanes;
}
@Override
public String toString() {
String toStringString = "";
toStringString += this.sender.getCallSign() + " > " + this.getReceiver().getCallSign() + " at " + this.getDate() + " " + this.airPlanesReachableCntr + " planes: " + this.getRisingAirplanes().toString();
return toStringString;
}
}
@@ -0,0 +1,111 @@
package kst4contest.model;
/**
* Represents Amateur Radio Bands and their physical limits.
* Used for plausibility checks in the Smart Parser.
*/
public enum Band {
B_50(50.000, 54.000, "50"),
B_70(70.000, 70.500, "70"),
B_144(144.000, 146.000, "144"),
B_432(432.000, 434.000, "432"),
B_1296(1296.000, 1298.000, "1296"),
B_2320(2320.000, 2322.000, "2320"),
B_3400(3400.000, 3410.000, "3400"),
B_5760(5760.000, 5762.000, "5760"),
B_10G(10368.000, 10370.000, "10368"),
B_24G(24048.000, 24050.000, "24048");
// more space for future usage
private final double minFreq;
private final double maxFreq;
private final String prefix; // Default prefix for "short value" parsing (e.g., .210)
Band(double min, double max, String prefix) {
this.minFreq = min;
this.maxFreq = max;
this.prefix = prefix;
}
public String getPrefix() {
return prefix;
}
/**
* Resolves a configured MHz prefix to one of the bands supported by the
* frequency parser.
*
* <p>The former free-text preference accepted arbitrary numeric values even
* though only prefixes represented by this enum can be used for a plausible
* frequency. Keeping the lookup here gives the UI and the parser one common
* definition of a valid fallback band.</p>
*
* @param prefix configured MHz prefix, for example {@code 144} or {@code 10368}
* @return matching band, or {@code null} if the prefix is not supported
*/
public static Band fromPrefix(String prefix) {
if (prefix == null) {
return null;
}
String normalizedPrefix = prefix.trim();
for (Band band : values()) {
if (band.prefix.equals(normalizedPrefix)) {
return band;
}
}
return null;
}
/**
* Returns the lower edge used as practical analysis frequency when only the band
* is known. This keeps the batch reachability calculation deterministic.
*
* @return frequency in MHz
*/
public double getDefaultAnalysisFrequencyMHz() {
return minFreq;
}
/**
* Returns a compact label for table display and filter controls.
*
* @return human readable band label
*/
public String getDisplayLabel() {
switch (this) {
case B_50: return "50";
case B_70: return "70";
case B_144: return "144";
case B_432: return "432";
case B_1296: return "1296";
case B_2320: return "2320";
case B_3400: return "3400";
case B_5760: return "5760";
case B_10G: return "10G";
case B_24G: return "24G";
default: return prefix;
}
}
/**
* Checks if a specific frequency falls within this band's limits.
*/
public boolean isPlausible(double freq) {
return freq >= minFreq && freq <= maxFreq;
}
/**
* Helper to find the matching Band enum for a given frequency.
* Returns null if no band matches.
*/
public static Band fromFrequency(double freq) {
for (Band b : values()) {
if (b.isPlausible(freq)) return b;
}
return null;
}
}
@@ -0,0 +1,279 @@
package kst4contest.model;
/* 50/70 MHz..............1 <br/> 144/432 MHz............2<br/> Microwave..............3<br/>
* EME/JT65...............4 <br/> Low Band...............5 <br/> 50 MHz IARU Region 3...6 <br/> 50
* MHz IARU Region 2...7 <br/> 144/432 MHz IARU R 2...8 <br/> 144/432 MHz IARU R 3...9 <br/> kHz
* (2000-630m).......10 <br/> Warc (30,17,12m)......11 <br/> 28 MHz................12
* <br/>
* TODO: LowBand and khz need to be continued later...
*/
public class ChatCategory {
@Override
public String toString() {
String toStringString ="";
toStringString = this.getCategoryNumber()+": " + this.getChatCategoryName(categoryNumber);
return toStringString;
}
public static final int FIFTYSEVENTYMHz = 1;
public static final int VUHF = 2;
public static final int MICROWAVE = 3;
public static final int EMEJT65 = 4;
public static final int LOWBAND = 5;
public static final int FIFTYR3 = 6;
public static final int FIFTYR2 = 7;
public static final int VUHFR2 = 8;
public static final int VUHFR3 = 9;
public static final int KHZ2KM630M = 10;
public static final int WARC301712 = 11;
public static final int TENMeter = 12;
private int categoryNumber;
public int[] getPossibleCategoryNumbers() {
int[] possibleVals = new int[12];
possibleVals[0] = 1;
possibleVals[1] = 2;
possibleVals[2] = 3;
possibleVals[3] = 4;
possibleVals[4] = 5;
possibleVals[5] = 6;
possibleVals[6] = 7;
possibleVals[7] = 8;
possibleVals[8] = 9;
possibleVals[9] = 10;
possibleVals[10] = 11;
possibleVals[11] = 12;
return possibleVals;
}
public ChatCategory(int setThiscategoryNumber) {
this.categoryNumber = setThiscategoryNumber;
setCategoryNumber(setThiscategoryNumber);
}
public int getCategoryNumber() {
return categoryNumber;
}
public void setCategoryNumber(int categoryNumber) {
this.categoryNumber = categoryNumber;
}
/**
* Returns an Array of int with possible frequency prefixes, due to in the chat
* normally the following format is used (not ever): <br/>
* "pse listen at .325"<br/>
* <br/>
*
* 50/70 MHz..............1 <br/>
* 144/432 MHz............2 <br/>
* Microwave..............3 <br/>
* EME/JT65...............4 <br/>
* Low Band...............5 <br/>
* 50 MHz IARU Region 3...6 <br/>
* 50 MHz IARU Region 2...7 <br/>
* 144/432 MHz IARU R 2...8 <br/>
* 144/432 MHz IARU R 3...9 <br/>
* kHz (2000-630m).......10 <br/>
* Warc (30,17,12m)......11 <br/>
* 28 MHz................12 <br/>
* TODO: LowBand and khz need to be continued later...
*
* @param int chatCategory (KST ChatCategory)
* @return Array of int with frequency prefixes, beginning with the lowest band
* (e.G. int[2] = [144, 430] for VUHF)
*/
public int[] getChatCategoryFrequencyPrefix(int chatCategory) {
int[] answer = new int[1];
answer[0] = 0;
switch (chatCategory) {
case ChatCategory.FIFTYSEVENTYMHz: {
answer = new int[2];
answer[0] = 50;
answer[1] = 70;
return answer;
}
case ChatCategory.VUHF: {
answer = new int[2];
answer[0] = 144;
answer[1] = 432;
return answer;
}
case ChatCategory.VUHFR2: {
answer = new int[2];
answer[0] = 144;
answer[1] = 432;
return answer;
}
case ChatCategory.VUHFR3: {
answer = new int[2];
answer[0] = 144;
answer[1] = 432;
return answer;
}
case ChatCategory.EMEJT65: { // 1296.2
answer = new int[13];
answer[0] = 144;
answer[1] = 432;
answer[2] = 1296;
answer[3] = 3400;
answer[4] = 5760;
answer[5] = 10368;
answer[6] = 24048;
answer[7] = 47000;
answer[8] = 77500;
answer[9] = 122250;
answer[10] = 134928;
answer[11] = 241920;
answer[12] = 453000;
return answer;
}
case ChatCategory.MICROWAVE: { // 1296.2
answer = new int[13];
answer[0] = 144;
answer[1] = 432;
answer[2] = 1296;
answer[3] = 3400;
answer[4] = 5760;
answer[5] = 10368;
answer[6] = 24048;
answer[7] = 47000;
answer[8] = 77500;
answer[9] = 122250;
answer[10] = 134928;
answer[11] = 241920;
answer[12] = 453000;
return answer;
}
case ChatCategory.TENMeter: {
answer = new int[1];
answer[0] = 28;
return answer;
}
case ChatCategory.WARC301712: {
answer = new int[3];
answer[0] = 10;
answer[1] = 18;
answer[2] = 24;
return answer;
}
default:
return answer;
}
};
/**
* Returns an Array of int with possible frequency prefixes, due to in the chat
* normally the following format is used (not ever): <br/>
* "pse listen at .325"<br/>
* <br/>
*
* 50/70 MHz..............1 <br/>
* 144/432 MHz............2 <br/>
* Microwave..............3 <br/>
* EME/JT65...............4 <br/>
* Low Band...............5 <br/>
* 50 MHz IARU Region 3...6 <br/>
* 50 MHz IARU Region 2...7 <br/>
* 144/432 MHz IARU R 2...8 <br/>
* 144/432 MHz IARU R 3...9 <br/>
* kHz (2000-630m).......10 <br/>
* Warc (30,17,12m)......11 <br/>
* 28 MHz................12 <br/>
* TODO: LowBand and khz need to be continued later...
*
* @param int chatCategory (KST ChatCategory)
* @return Array of int with frequency prefixes, beginning with the lowest band
* (e.G. int[2] = [144, 430] for VUHF)
*/
public String getChatCategoryName(int chatCategory) {
switch (chatCategory) {
case ChatCategory.FIFTYSEVENTYMHz: {
return "50/70 MHz";
}
case ChatCategory.FIFTYR2: {
return "50 MHz IARU Region 2";
}
case ChatCategory.FIFTYR3: {
return "50 MHz IARU Region 3";
}
case ChatCategory.LOWBAND: {
return "Low Band";
}
case ChatCategory.KHZ2KM630M: {
return "kHz (2000-630m)";
}
case ChatCategory.VUHF: {
return "144/432 MHz";
}
case ChatCategory.VUHFR2: {
return "144/432 MHz IARU R 2";
}
case ChatCategory.VUHFR3: {
return "144/432 MHz IARU R 3";
}
case ChatCategory.EMEJT65: { // 1296.2
return "EME/JT65";
}
case ChatCategory.MICROWAVE: { // 1296.2
return "Microwave";
}
case ChatCategory.TENMeter: {
return "28 MHz";
}
case ChatCategory.WARC301712: {
return "Warc (30,17,12m)";
}
default:
return "ERRROR: unknown";
}
};
}
@@ -0,0 +1,858 @@
package kst4contest.model;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.OptionalDouble;
import kst4contest.observe.MutableValue;
import kst4contest.observe.SimpleValue;
public class ChatMember {
long lastFlagsChangeEpochMs; // timestamp of the last worked/not-QRV flag change in the internal DB
// private final BooleanProperty workedInfoChangeFireListEventTrigger = new SimpleBooleanProperty();
AirPlaneReflectionInfo airPlaneReflectInfo;
String callSign;
String qra;
String name;
String callSignRaw; //without -2 or -70 etc.
/**
* A directional opportunity inferred from a directed chat message remains
* relevant for five minutes. The timestamp is used instead of a permanent
* boolean so an old antenna-direction assumption cannot remain visible
* indefinitely.
*/
static final long DIRECTION_OPPORTUNITY_VALIDITY_MILLIS = 5L * 60L * 1000L;
private volatile long directionOpportunityValidUntilEpochMs;
// String frequency; // last known qrg of the station
MutableValue<String> frequency = new SimpleValue<>();
String password; // only used by own instance of the chatmember instance to login to the chat
ChatCategory chatCategory; //Source category
// ChatCategory chatCategory;//only used by own instance of the chatmember instance to login to the chat
long activityTimeLastInEpoch; // time of last activity in epochtimesec
Date lastActivity; // time of last activity in epochtimesec
Date lastActualizationTimeOfThisMember; // time of last state change if that member
Double qrb;
int state;
Double QTFdirection; // antenna direction in deg
int[] workedCategories; // Chatcategory where the station is in the log, see kst4contest.model.ChatCategory
boolean worked; // true if the callsign is logged already - for temporary worked processing
boolean worked144;
boolean worked432;
boolean worked1240;
boolean worked2300;
boolean worked3400;
boolean worked5600;
boolean worked10G;
boolean Worked50;
boolean Worked70;
boolean Worked24G;
boolean Worked47G;
boolean Worked76G;
/**
* Chatmember is qrv at all band except we initialize anything other, depending to user entry
*/
boolean qrv50 = true;
boolean qrv70 = true;
boolean qrv144 = true;
boolean qrv432 = true;
boolean qrv1240 = true;
boolean qrv2300 = true;
boolean qrv3400 = true;
boolean qrv5600 = true;
boolean qrv10G = true;
boolean qrvAny = true;
// Stores the last known frequency per band (Context History)
private final Map<Band, ActiveFrequencyInfo> knownActiveBands = new ConcurrentHashMap<>();
// Stores the calculated bidirectional SSB tropo margin per band.
// Values are calculated by the reachability backend and used only for UI sorting/filtering.
private final Map<Band, Double> tropoSsbMarginDbByBand = new ConcurrentHashMap<>();
// --- INNER CLASS FOR QRG HISTORY ---
public class ActiveFrequencyInfo {
public double frequency;
public long timestampEpoch;
public ActiveFrequencyInfo(double freq) {
this.frequency = freq;
this.timestampEpoch = System.currentTimeMillis();
}
}
// Counter for failed calls (Penalty Logic)
private int failedQSOAttempts = 0;
// Calculated Score for sorting the user list
private double currentPriorityScore = 0.0;
public long getLastFlagsChangeEpochMs() {
return lastFlagsChangeEpochMs;
}
public void setLastFlagsChangeEpochMs(long lastFlagsChangeEpochMs) {
this.lastFlagsChangeEpochMs = lastFlagsChangeEpochMs;
}
/**
* Returns whether the most recently inferred directional opportunity is
* still valid.
*
* @return {@code true} until the five-minute validity period has expired
*/
public boolean isInAngleAndRange() {
return isInAngleAndRangeAt(System.currentTimeMillis());
}
/**
* Time-aware variant used by the public getter and by unit tests.
*
* @param nowEpochMs time against which the validity is checked
* @return {@code true} while the stored validity timestamp is still in the future
*/
boolean isInAngleAndRangeAt(long nowEpochMs) {
return directionOpportunityValidUntilEpochMs > nowEpochMs;
}
/**
* Starts a new five-minute validity period or removes the current
* directional opportunity immediately.
*
* @param inAngleAndRange {@code true} for a newly detected opportunity;
* {@code false} to clear it
*/
public void setInAngleAndRange(boolean inAngleAndRange) {
directionOpportunityValidUntilEpochMs = inAngleAndRange
? System.currentTimeMillis() + DIRECTION_OPPORTUNITY_VALIDITY_MILLIS
: 0L;
}
public AirPlaneReflectionInfo getAirPlaneReflectInfo() {
return airPlaneReflectInfo;
}
public void setAirPlaneReflectInfo(AirPlaneReflectionInfo airPlaneReflectInfo) {
this.airPlaneReflectInfo = airPlaneReflectInfo;
}
public ChatCategory getChatCategory() {
return chatCategory;
}
public void setChatCategory(ChatCategory chatCategory) {
this.chatCategory = chatCategory;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public boolean isWorked144() {
return worked144;
}
public void setWorked144(boolean worked144) {
this.worked144 = worked144;
}
public boolean isWorked432() {
return worked432;
}
public void setWorked432(boolean worked432) {
this.worked432 = worked432;
}
public boolean isWorked1240() {
return worked1240;
}
public void setWorked1240(boolean worked1240) {
this.worked1240 = worked1240;
}
public boolean isWorked2300() {
return worked2300;
}
public void setWorked2300(boolean worked2300) {
this.worked2300 = worked2300;
}
public boolean isWorked3400() {
return worked3400;
}
public void setWorked3400(boolean worked3400) {
this.worked3400 = worked3400;
}
public boolean isWorked5600() {
return worked5600;
}
public void setWorked5600(boolean worked5600) {
this.worked5600 = worked5600;
}
public boolean isWorked10G() {
return worked10G;
}
public void setWorked10G(boolean worked10g) {
worked10G = worked10g;
}
public boolean isQrv50() {
return qrv50;
}
public void setQrv50(boolean qrv50) {
this.qrv50 = qrv50;
}
public boolean isQrv70() {
return qrv70;
}
public void setQrv70(boolean qrv70) {
this.qrv70 = qrv70;
}
public boolean isQrv144() {
return qrv144;
}
public void setQrv144(boolean qrv144) {
this.qrv144 = qrv144;
}
public boolean isQrv432() {
return qrv432;
}
public void setQrv432(boolean qrv432) {
this.qrv432 = qrv432;
}
public boolean isQrv1240() {
return qrv1240;
}
public void setQrv1240(boolean qrv1240) {
this.qrv1240 = qrv1240;
}
public boolean isQrv2300() {
return qrv2300;
}
public void setQrv2300(boolean qrv2300) {
this.qrv2300 = qrv2300;
}
public boolean isQrv3400() {
return qrv3400;
}
public void setQrv3400(boolean qrv3400) {
this.qrv3400 = qrv3400;
}
public boolean isQrv5600() {
return qrv5600;
}
public void setQrv5600(boolean qrv5600) {
this.qrv5600 = qrv5600;
}
public boolean isQrv10G() {
return qrv10G;
}
public void setQrv10G(boolean qrv10G) {
this.qrv10G = qrv10G;
}
public boolean isQrvAny() {
return qrvAny;
}
public void setQrvAny(boolean qrvAny) {
this.qrvAny = qrvAny;
}
public int[] getWorkedCategories() {
return workedCategories;
}
public void setWorkedCategories(int[] workedCategories) {
this.workedCategories = workedCategories;
}
public void setActivityTimeLastInEpoch(long activityTimeLastInEpoch) {
this.activityTimeLastInEpoch = activityTimeLastInEpoch;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
public Date getLastActivity() {
return lastActivity;
}
public void setLastActivity(Date lastActivity) {
this.lastActivity = lastActivity;
}
public Date getLastActualizationTimeOfThisMember() {
return lastActualizationTimeOfThisMember;
}
public void setLastActualizationTimeOfThisMember(Date lastActualizationTimeOfThisMember) {
this.lastActualizationTimeOfThisMember = lastActualizationTimeOfThisMember;
}
public Double getQrb() {
return qrb;
}
public void setQrb(Double qrb) {
this.qrb = qrb;
}
public Double getQTFdirection() {
return QTFdirection;
}
public void setQTFdirection(Double qTFdirection) {
QTFdirection = qTFdirection;
}
public String getCallSign() {
return callSign;
}
/**
* Sets the original callsign and derives the normalized base callsign which is
* used as the database key. Prefixes like EA5/ and suffixes like /P or -70 are
* ignored for the raw-key handling.
*
* @param callSign callsign as received from chat or database
*/
public void setCallSign(String callSign) {
if (callSign == null) {
this.callSign = null;
this.callSignRaw = null;
return;
}
this.callSign = callSign.trim().toUpperCase(Locale.ROOT);
this.callSignRaw = normalizeCallSignToBaseCallSign(this.callSign);
}
/**
* Normalizes a callsign to the base callsign which is used as the unique key in
* the internal database. The method removes KST suffixes like "-2", portable
* suffixes like "/P" and prefix additions like "EA5/".
*
* @param callSign callsign to normalize
* @return normalized base callsign in upper case
*/
public static String normalizeCallSignToBaseCallSign(String callSign) {
if (callSign == null) {
return null;
}
String normalizedCallSign = callSign.trim().toUpperCase(Locale.ROOT);
if (normalizedCallSign.isBlank()) {
return normalizedCallSign;
}
String callSignWithoutDashSuffix = normalizedCallSign.split("-", 2)[0].trim();
if (!callSignWithoutDashSuffix.contains("/")) {
return callSignWithoutDashSuffix;
}
String[] callSignParts = callSignWithoutDashSuffix.split("/");
String bestMatchingCallsignPart = helper_selectBestCallsignPart(callSignParts);
if (bestMatchingCallsignPart == null || bestMatchingCallsignPart.isBlank()) {
return callSignWithoutDashSuffix;
}
return bestMatchingCallsignPart;
}
/**
* Selects the most plausible base callsign segment from a slash-separated
* callsign. In strings like "EA5/G8MBI/P" the segment "G8MBI" is preferred over
* prefix or portable markers.
*
* @param callSignParts slash-separated callsign parts
* @return best matching base callsign segment
*/
private static String helper_selectBestCallsignPart(String[] callSignParts) {
String bestLikelyBaseCallsignPart = null;
int bestLikelyBaseCallsignLength = -1;
String bestFallbackCallsignPart = null;
int bestFallbackCallsignLength = -1;
for (String rawCallsignPart : callSignParts) {
String currentCallsignPart = rawCallsignPart == null ? "" : rawCallsignPart.trim().toUpperCase(Locale.ROOT);
if (currentCallsignPart.isBlank()) {
continue;
}
if (currentCallsignPart.length() > bestFallbackCallsignLength) {
bestFallbackCallsignPart = currentCallsignPart;
bestFallbackCallsignLength = currentCallsignPart.length();
}
if (helper_isLikelyBaseCallsignSegment(currentCallsignPart)
&& currentCallsignPart.length() > bestLikelyBaseCallsignLength) {
bestLikelyBaseCallsignPart = currentCallsignPart;
bestLikelyBaseCallsignLength = currentCallsignPart.length();
}
}
if (bestLikelyBaseCallsignPart != null) {
return bestLikelyBaseCallsignPart;
}
return bestFallbackCallsignPart;
}
/**
* Checks whether a slash-separated segment looks like a real base callsign. A
* normal amateur-radio callsign typically contains letters and digits and is
* longer than one-character postfix markers.
*
* @param callsignSegment segment to inspect
* @return true if the segment looks like a base callsign
*/
private static boolean helper_isLikelyBaseCallsignSegment(String callsignSegment) {
boolean containsLetter = false;
boolean containsDigit = false;
for (int currentIndex = 0; currentIndex < callsignSegment.length(); currentIndex++) {
char currentCharacter = callsignSegment.charAt(currentIndex);
if (Character.isLetter(currentCharacter)) {
containsLetter = true;
}
if (Character.isDigit(currentCharacter)) {
containsDigit = true;
}
}
return containsLetter && containsDigit && callsignSegment.length() >= 3;
}
public String getQra() {
return qra;
}
public void setQra(String qra) {
this.qra = qra;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public MutableValue<String> getFrequency() {
return frequency;
// return frequency;
}
public void setFrequency(MutableValue<String> frequency) {
this.frequency = frequency;
}
public long getActivityTimeLastInEpoch() {
return activityTimeLastInEpoch;
}
public void setActivityCounter(int activityCounter) {
this.activityTimeLastInEpoch = activityCounter;
}
public boolean isWorked() {
return worked;
}
public boolean isWorked50() {
return Worked50;
}
public void setWorked50(boolean worked50) {
Worked50 = worked50;
}
public boolean isWorked70() {
return Worked70;
}
public void setWorked70(boolean worked70) {
Worked70 = worked70;
}
public boolean isWorked24G() {
return Worked24G;
}
public void setWorked24G(boolean worked24G) {
Worked24G = worked24G;
}
public boolean isWorked47G() {
return Worked47G;
}
public void setWorked47G(boolean worked47G) {
Worked47G = worked47G;
}
public boolean isWorked76G() {
return Worked76G;
}
public void setWorked76G(boolean worked76G) {
Worked76G = worked76G;
}
public void setWorked(boolean worked) {
this.worked = worked;
}
/**
*
* @return String (callsign) without -2 or -70 etc.
*/
public String getCallSignRaw() {
return callSignRaw;
// String raw = "";
//
// try {
// return this.getCallSign().split("-")[0]; //e.g. OK2M-70, returns only ok2m
// } catch (Exception e) {
// return getCallSign();
// }
}
/**
* Initializes the compatibility frequency only when no frequency is known yet.
*
* <p>This is used for an explicit QRG contained in the ON4KST station name.
* A subsequently detected chat QRG may overwrite this initial value, but a
* station-name QRG never replaces an already known frequency.</p>
*
* @param frequencyMhz explicit station-name frequency
* @return true when the frequency was initialized
*/
public boolean initializeFrequencyIfEmpty(double frequencyMhz) {
if (!Double.isFinite(frequencyMhz)
|| frequencyMhz <= 0.0) {
return false;
}
if (frequency == null) {
frequency = new SimpleValue<>();
}
String currentFrequency = frequency.get();
if (currentFrequency != null
&& !currentFrequency.isBlank()) {
return false;
}
frequency.set(
Double.toString(frequencyMhz)
);
return true;
}
/**
* Sets all worked information of this object to false. Scope: GUI, Reset Button
* for worked info, called by appcontroller
*/
public void resetWorkedInformationAtAllBands() {
this.setWorked(false);
this.setWorked144(false);
this.setWorked50(false);
this.setWorked70(false);
this.setWorked432(false);
this.setWorked1240(false);
this.setWorked2300(false);
this.setWorked3400(false);
this.setWorked5600(false);
this.setWorked10G(false);
this.setWorked24G(false);
this.setWorked47G(false);
this.setWorked76G(false);
}
/**
* Sets all worked information of this object to false. Scope: GUI, Reset Button
* for worked info, called by appcontroller
*/
public void resetQRVInformationAtAllBands() {
this.setQrvAny(true);
this.setQrv50(true);
this.setQrv70(true);
this.setQrv144(true);
this.setQrv432(true);
this.setQrv1240(true);
this.setQrv2300(true);
this.setQrv3400(true);
this.setQrv5600(true);
this.setQrv10G(true);
}
@Override
public String toString() {
String chatMemberSerialization = "";
chatMemberSerialization += callSign + ";" + name + ";" + qra + ";" + frequency + "; wkd " + worked + "; wkd144 " + worked144
+ "; wkd432" + worked432 + "; wkd1240" + worked1240 + "; wkd2300" + worked2300 + "; wkd3400" + worked3400 + "; wkd5600" + worked5600 + "; wkd10G"
+ worked10G + " ; " + chatCategory;
return chatMemberSerialization;
}
/**
* Finds out if a given Chatmember-instance has the same callsign as this
* instance. Callsign is used as a key.
*
* @param anotherChatMember
* @return
*/
/*
* Deliberately an overload of equals, not an override of equals(Object).
* FxRosterBinding compares a mirror against a freshly derived list and relies
* on List.equals falling back to reference comparison; with a real override two
* different rows sharing a callsign would compare equal and a genuine change
* could be dropped. Do not "fix" the signature without looking there first.
*/
public boolean equals(ChatMember anotherChatMember) {
if (this.getCallSign().toUpperCase().equals(anotherChatMember.getCallSign().toUpperCase())) {
return true;
} else
return false;
}
/**
* Adds a new recognized frequency by band to the internal band/qrg map
* @param band
* @param freq
*/
public void addKnownFrequency(Band band, double freq) {
this.knownActiveBands.put(band, new ActiveFrequencyInfo(freq));
}
/**
* represents a map of bands which are known of this chatmember
*
* @return Band
*/
public Map<Band, ActiveFrequencyInfo> getKnownActiveBands() {
return knownActiveBands;
}
/**
* Stores the calculated bidirectional SSB tropo margin for one band.
*
* <p>The value is deliberately stored in ChatMember because the station table
* can then sort and filter directly without triggering a new RF calculation.
* The actual calculation stays outside ChatMember.</p>
*
* @param band band for which the margin was calculated
* @param marginDb bidirectional SSB margin in dB; NaN marks an attempted but failed analysis
*/
public void setTropoSsbMarginDb(Band band, double marginDb) {
if (band == null) {
return;
}
this.tropoSsbMarginDbByBand.put(band, marginDb);
}
/**
* Returns true if a tropo analysis result already exists for the given band.
*
* <p>A stored NaN also counts as an existing result because it means the analysis
* was attempted and failed. This prevents endless retry loops when a terrain
* provider is temporarily unavailable.</p>
*
* @param band band to check
* @return true if a value or NaN marker is stored
*/
public boolean hasTropoSsbMarginDb(Band band) {
return band != null && this.tropoSsbMarginDbByBand.containsKey(band);
}
/**
* Returns true only when a finite, usable tropo SSB margin is stored for the band.
*
* <p>This is intentionally different from {@link #hasTropoSsbMarginDb(Band)}:
* a stored NaN means that an analysis was attempted but did not produce a usable
* link budget. Such failed values should not permanently block later retries.</p>
*
* @param band band to check
* @return true if a finite SSB margin exists
*/
public boolean hasFiniteTropoSsbMarginDb(Band band) {
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
return false;
}
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
return storedValue != null && Double.isFinite(storedValue);
}
/**
* Reads the calculated tropo SSB margin for one band.
*
* @param band band to read
* @return OptionalDouble with the stored value, or empty if no calculation exists yet
*/
public OptionalDouble getTropoSsbMarginDb(Band band) {
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
return OptionalDouble.empty();
}
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
if (storedValue == null) {
return OptionalDouble.empty();
}
return OptionalDouble.of(storedValue);
}
/**
* Formats the calculated tropo margin for direct table display.
*
* <p>The method distinguishes three states:
* <ul>
* <li>{@code ... @144}: analysis has not run yet</li>
* <li>{@code ? @144}: analysis ran, but no usable link budget was produced</li>
* <li>{@code +8.4 dB @144}: finite SSB margin exists</li>
* </ul>
*
* @param band selected/auto-resolved reachability band
* @return display text such as "+8.4 dB @144", "? @144" or "... @144"
*/
public String formatTropoSsbMarginForBand(Band band) {
String bandLabel = band == null ? "?" : band.getDisplayLabel();
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
return "... @" + bandLabel;
}
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
if (storedValue == null || !Double.isFinite(storedValue)) {
return "? @" + bandLabel;
}
return String.format(Locale.US, "%+.1f dB @%s", storedValue, bandLabel);
}
/**
* If a sked fails and the user tells this to the client, this counter will be increased to give the station a
* lower score
*/
public void incrementFailedAttempts() {
this.failedQSOAttempts++;
}
public void resetFailedAttempts() {
this.failedQSOAttempts = 0;
}
public int getFailedQSOAttempts() {
return failedQSOAttempts;
}
/**
* Sets the working-priority score of a chatmember for the "Todo-List"
* @param score
*/
public void setCurrentPriorityScore(double score) {
this.currentPriorityScore = score;
}
/**
* Gets the working-priority score of a chatmember for the "Todo-List"
*
*/
public double getCurrentPriorityScore() {
return currentPriorityScore;
}
}
@@ -0,0 +1,34 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatMemberDirectionOpportunityTest {
@Test
void directionalOpportunityExpiresAfterConfiguredValidityPeriod() {
ChatMember member = new ChatMember();
member.setInAngleAndRange(true);
long afterActivationEpochMs = System.currentTimeMillis();
assertTrue(member.isInAngleAndRangeAt(afterActivationEpochMs));
assertFalse(member.isInAngleAndRangeAt(
afterActivationEpochMs
+ ChatMember.DIRECTION_OPPORTUNITY_VALIDITY_MILLIS
+ 1L));
}
@Test
void directionalOpportunityCanBeClearedImmediately() {
ChatMember member = new ChatMember();
member.setInAngleAndRange(true);
assertTrue(member.isInAngleAndRange());
member.setInAngleAndRange(false);
assertFalse(member.isInAngleAndRange());
}
}
@@ -0,0 +1,90 @@
package kst4contest.model;
//import java.time.OffsetDateTime;
/**
* boolean directedToServer is important
* @author mywire
*
*/
public class ChatMessage {
ChatMember sender;
String messageText;
ChatMember receiver; //the callsign to whom the message is directed
String messageGeneratedTime; //generated time in epochtimemillis/1000, as used by the chatserver
ChatCategory chatCategory;
String messageSenderName;
boolean messageByServer; //whether the chat message is directed do the server
boolean messageDirectedToCommunity; //wheter the message is directed to anyone or to all users
boolean messageDirectedToMe; //wheter message is directed to the praktiKST User
boolean messageDirectedToServer;
public boolean isMessageDirectedToServer() {
return messageDirectedToServer;
}
public void setMessageDirectedToServer(boolean messageDirectedToServer) {
this.messageDirectedToServer = messageDirectedToServer;
}
public boolean isMessageDirectedToCommunity() {
return messageDirectedToCommunity;
}
public void setMessageDirectedToCommunity(boolean messageDirectedToCommunity) {
this.messageDirectedToCommunity = messageDirectedToCommunity;
}
public boolean isMessageDirectedToMe() {
return messageDirectedToMe;
}
public void setMessageDirectedToMe(boolean messageDirectedToMe) {
this.messageDirectedToMe = messageDirectedToMe;
}
public String getMessageSenderName() {
return messageSenderName;
}
public void setMessageSenderName(String messageSenderName) {
this.messageSenderName = messageSenderName;
}
public ChatCategory getChatCategory() {
return chatCategory;
}
public void setChatCategory(ChatCategory chatCategory) {
this.chatCategory = chatCategory;
}
public boolean isMessageByServer() {
return messageByServer;
}
public void setMessageByServer(boolean isMessageByServer) {
this.messageByServer = isMessageByServer;
}
public void setMessageText(String messageText) {
this.messageText = messageText;
}
public String getMessageGeneratedTime() {
return messageGeneratedTime;
}
public void setMessageGeneratedTime(String messageGeneratedTime) {
this.messageGeneratedTime = messageGeneratedTime;
}
public ChatMember getSender() {
return sender;
}
public void setSender(ChatMember sender) {
this.sender = sender;
}
public String getMessageText() {
return messageText;
}
public ChatMember getReceiver() {
return receiver;
}
public void setReceiver(ChatMember receiver) {
this.receiver = receiver;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,67 @@
package kst4contest.model;
public class ClusterMessage {
String timeGenerated;
ChatMember sender;
ChatMember receiver;
String qrgSpotted;
String qraOfSender;
String qraOfReceiver;
String messageInhibited;
boolean receiverWkd;
public boolean isReceiverWkd() {
return receiverWkd;
}
public void setReceiverWkd(boolean receiverWkd) {
this.receiverWkd = receiverWkd;
}
public String getTimeGenerated() {
return timeGenerated;
}
public void setTimeGenerated(String timeGenerated) {
this.timeGenerated = timeGenerated;
}
public ChatMember getSender() {
return sender;
}
public void setSender(ChatMember sender) {
this.sender = sender;
}
public ChatMember getReceiver() {
return receiver;
}
public void setReceiver(ChatMember receiver) {
this.receiver = receiver;
}
public String getQrgSpotted() {
return qrgSpotted;
}
public void setQrgSpotted(String qrgSpotted) {
this.qrgSpotted = qrgSpotted;
}
public String getQraOfSender() {
return qraOfSender;
}
public void setQraOfSender(String qraOfSender) {
this.qraOfSender = qraOfSender;
}
public String getQraOfReceiver() {
return qraOfReceiver;
}
public void setQraOfReceiver(String qraOfReceiver) {
this.qraOfReceiver = qraOfReceiver;
}
public String getMessageInhibited() {
return messageInhibited;
}
public void setMessageInhibited(String messageInhibited) {
this.messageInhibited = messageInhibited;
}
}
@@ -0,0 +1,124 @@
package kst4contest.model;
/**
* Represents a scheduled event or an AirScout opportunity in the future.
* Used for the Timeline View and Priority Calculation.
*
* <p>The base callsign remains the grouping key for scoring and worked-state
* handling. The exact KST login and its chat category are stored separately
* because reminders and external logger handover refer to the selected
* ChatMember entity.</p>
*/
public class ContestSked {
private String targetCallsign;
private String targetChatCallsign;
private ChatCategory targetChatCategory;
private double targetAzimuth;
private long skedTimeEpoch;
private Band band;
// Opportunity potential (0..100). -1 means "unknown".
int opportunityPotentialPercent = -1;
// Status flags to prevent spamming alarms.
private boolean warning3MinSent = false;
private boolean warningNowSent = false;
/**
* Backward-compatible constructor.
*/
public ContestSked(String call, double azimuth, long time, Band band) {
this(call, call, null, azimuth, time, band);
}
/**
* Creates a sked for one exact KST login.
*
* @param callRaw base callsign used for scoring and worked states
* @param chatCallsign exact KST login, including an optional dash suffix
* @param chatCategory category in which the selected login is active
* @param azimuth target azimuth
* @param time sked time in epoch milliseconds
* @param band selected amateur-radio band
*/
public ContestSked(String callRaw,
String chatCallsign,
ChatCategory chatCategory,
double azimuth,
long time,
Band band) {
this.targetCallsign = callRaw;
this.targetChatCallsign = chatCallsign;
this.targetChatCategory = chatCategory;
this.targetAzimuth = azimuth;
this.skedTimeEpoch = time;
this.band = band;
}
/**
* Returns the seconds remaining until the event.
* Negative values mean the event is in the past.
*/
public long getTimeUntilSkedSeconds() {
return (skedTimeEpoch - System.currentTimeMillis()) / 1000;
}
/**
* Returns the base callsign used for scoring and worked-state grouping.
*/
public String getTargetCallsign() {
return targetCallsign;
}
/**
* Returns the exact KST login selected when the sked was created.
*/
public String getTargetChatCallsign() {
if (targetChatCallsign == null || targetChatCallsign.isBlank()) {
return targetCallsign;
}
return targetChatCallsign;
}
public ChatCategory getTargetChatCategory() {
return targetChatCategory;
}
public double getTargetAzimuth() {
return targetAzimuth;
}
public long getSkedTimeEpoch() {
return skedTimeEpoch;
}
public Band getBand() {
return band;
}
public boolean isWarning3MinSent() {
return warning3MinSent;
}
public void setWarning3MinSent(boolean warning3MinSent) {
this.warning3MinSent = warning3MinSent;
}
public boolean isWarningNowSent() {
return warningNowSent;
}
public void setWarningNowSent(boolean warningNowSent) {
this.warningNowSent = warningNowSent;
}
public int getOpportunityPotentialPercent() {
return opportunityPotentialPercent;
}
public void setOpportunityPotentialPercent(int opportunityPotentialPercent) {
this.opportunityPotentialPercent = opportunityPotentialPercent;
}
}
@@ -0,0 +1,133 @@
package kst4contest.model;
import java.util.Objects;
/**
* Descriptor of one operator profile.
*
* <p>A profile always owns its own preferences file. Whether it also owns its own
* worked-station database is decided by {@link #isSharedWorkedDatabase()}: a multi
* operator contest station keeps one common log and therefore shares the database,
* while two operators sharing a private computer usually want their worked data kept
* apart.</p>
*
* <p>The descriptor deliberately carries no file paths. They are derived in exactly one
* place, {@link kst4contest.controller.OperatorProfilePaths}, so a stored path can never
* drift apart from the flag that produced it.</p>
*/
public class OperatorProfile {
/**
* Stable identifier of the profile. It is assigned once and never changes, so
* renaming a profile never moves a directory.
*/
private String profileId;
/**
* Name shown in the profile picker and in the settings window.
*/
private String displayName;
/**
* True for the profile that uses the historic flat installation layout directly.
*/
private boolean rootProfile;
/**
* True if this profile uses the common station worked-station database.
*/
private boolean sharedWorkedDatabase;
/**
* Timestamp of the last activation, used to preselect an entry in the picker.
*/
private long lastUsedEpochMs;
public OperatorProfile() {
// Default constructor for stepwise construction while reading the registry.
}
public OperatorProfile(final String profileId,
final String displayName,
final boolean rootProfile,
final boolean sharedWorkedDatabase) {
this.profileId = profileId;
this.displayName = displayName;
this.rootProfile = rootProfile;
this.sharedWorkedDatabase = sharedWorkedDatabase;
}
public String getProfileId() {
return profileId;
}
public void setProfileId(final String profileId) {
this.profileId = profileId;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(final String displayName) {
this.displayName = displayName;
}
public boolean isRootProfile() {
return rootProfile;
}
public void setRootProfile(final boolean rootProfile) {
this.rootProfile = rootProfile;
}
public boolean isSharedWorkedDatabase() {
return sharedWorkedDatabase;
}
public void setSharedWorkedDatabase(final boolean sharedWorkedDatabase) {
this.sharedWorkedDatabase = sharedWorkedDatabase;
}
public long getLastUsedEpochMs() {
return lastUsedEpochMs;
}
public void setLastUsedEpochMs(final long lastUsedEpochMs) {
this.lastUsedEpochMs = lastUsedEpochMs;
}
@Override
public boolean equals(final Object other) {
if (this == other) {
return true;
}
if (!(other instanceof OperatorProfile)) {
return false;
}
return Objects.equals(profileId, ((OperatorProfile) other).profileId);
}
@Override
public int hashCode() {
return Objects.hashCode(profileId);
}
/**
* Returns the display name so the descriptor can be shown in a list control directly.
*
* @return the display name, or the profile id when no name was set
*/
@Override
public String toString() {
if (displayName == null || displayName.isBlank()) {
return String.valueOf(profileId);
}
return displayName;
}
}
@@ -0,0 +1,70 @@
package kst4contest.model;
import kst4contest.ApplicationConstants;
import kst4contest.utils.ApplicationFileUtils;
import java.util.Objects;
/**
* Resolved runtime view of the active operator profile.
*
* <p>This is the only profile information the rest of the application needs: two file
* names relative to the application directory plus the flag whether a missing
* worked-station database may be seeded from the bundled template. Everything else is
* derived from the descriptor.</p>
*/
public class OperatorProfileSelection {
private final OperatorProfile profile;
private final String preferencesRelativeFileName;
private final String workedDatabaseRelativeFileName;
private final boolean seedWorkedDatabaseFromResource;
public OperatorProfileSelection(final OperatorProfile profile,
final String preferencesRelativeFileName,
final String workedDatabaseRelativeFileName,
final boolean seedWorkedDatabaseFromResource) {
this.profile = Objects.requireNonNull(profile, "profile");
this.preferencesRelativeFileName =
Objects.requireNonNull(preferencesRelativeFileName, "preferencesRelativeFileName");
this.workedDatabaseRelativeFileName =
Objects.requireNonNull(workedDatabaseRelativeFileName, "workedDatabaseRelativeFileName");
this.seedWorkedDatabaseFromResource = seedWorkedDatabaseFromResource;
}
public OperatorProfile getProfile() {
return profile;
}
public String getPreferencesRelativeFileName() {
return preferencesRelativeFileName;
}
public String getWorkedDatabaseRelativeFileName() {
return workedDatabaseRelativeFileName;
}
public boolean isSeedWorkedDatabaseFromResource() {
return seedWorkedDatabaseFromResource;
}
/**
* Returns the absolute preferences path, for display in the settings window.
*
* @return absolute path of the preferences file
*/
public String getPreferencesAbsolutePath() {
return ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, preferencesRelativeFileName);
}
/**
* Returns the absolute worked-station database path, for display in the settings window.
*
* @return absolute path of the worked-station database
*/
public String getWorkedDatabaseAbsolutePath() {
return ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, workedDatabaseRelativeFileName);
}
}
@@ -0,0 +1,103 @@
package kst4contest.model;
/**
* Object for the description of the activity of a Thread to show these information in a View.
* <br/><br/>
* If state is critical, there could be used a further information field for the stacktrace
*/
public class ThreadStateMessage {
String threadNickName;
String threadDescription;
boolean running;
String runningInformationTextDescription;
String runningInformation;
boolean criticalState;
String criticalStateFurtherInfo;
public ThreadStateMessage(String threadNickName, boolean running, String runningInformation, boolean criticalState) {
this.threadNickName = threadNickName;
this.running = running;
this.criticalState = criticalState;
this.runningInformation = runningInformation;
}
/**
* This triggers the message for "Sked armed"
*
* @return
*/
public String getRunningInformationTextDescription() {
// If a custom description was set (e.g. for UI indicator buttons), prefer it.
if (runningInformationTextDescription != null && !runningInformationTextDescription.isBlank()) {
return runningInformationTextDescription;
}
// Fallback (legacy behavior)
if (isRunning()) {
return "on";
} else if (!isRunning() && isCriticalState()) {
return "FAILED";
} else {
return "off";
}
}
public void setRunningInformationTextDescription(String runningInformationTextDescription) {
this.runningInformationTextDescription = runningInformationTextDescription;
}
public String getThreadNickName() {
return threadNickName;
}
public void setThreadNickName(String threadNickName) {
this.threadNickName = threadNickName;
}
public String getThreadDescription() {
return threadDescription;
}
public void setThreadDescription(String threadDescription) {
this.threadDescription = threadDescription;
}
public boolean isRunning() {
return running;
}
public void setRunning(boolean running) {
this.running = running;
}
public String getRunningInformation() {
return runningInformation;
}
public void setRunningInformation(String runningInformation) {
this.runningInformation = runningInformation;
}
public boolean isCriticalState() {
return criticalState;
}
public void setCriticalState(boolean criticalState) {
this.criticalState = criticalState;
}
public String getCriticalStateFurtherInfo() {
return criticalStateFurtherInfo;
}
public void setCriticalStateFurtherInfo(String criticalStateFurtherInfo) {
this.criticalStateFurtherInfo = criticalStateFurtherInfo;
}
}
@@ -0,0 +1,111 @@
package kst4contest.model;
import java.util.ArrayList;
public class UpdateInformation {
// double latestVersionNumberOnServer = 1.26; //dummy value to prevent nullpointerexc
double latestVersionNumberOnServer = 0.0;
String latestSemanticVersionOnServer = "";
String adminMessage ="";
String majorChanges ="";
String latestVersionPathOnWebserver="";
ArrayList<String> needUpdateResourcesSinceLastVersion = new ArrayList<String>();
ArrayList<String[]> featureRequest = new ArrayList<String[]>();
ArrayList<String[]> bugRequests = new ArrayList<String[]>();
ArrayList<String[]> changeLog = new ArrayList<String[]>();
ArrayList<String[]> bugList = new ArrayList<String[]>();
public ArrayList<String[]> getBugList() {
return bugList;
}
public void setBugList(ArrayList<String[]> bugList) {
this.bugList = bugList;
}
public ArrayList<String[]> getChangeLog() {
return changeLog;
}
public void setChangeLog(ArrayList<String[]> changeLog) {
this.changeLog = changeLog;
}
public double getLatestVersionNumberOnServer() {
return latestVersionNumberOnServer;
}
public void setLatestVersionNumberOnServer(double latestVersionNumberOnServer) {
this.latestVersionNumberOnServer = latestVersionNumberOnServer;
}
public String getLatestSemanticVersionOnServer() {
return latestSemanticVersionOnServer;
}
public void setLatestSemanticVersionOnServer(String latestSemanticVersionOnServer) {
this.latestSemanticVersionOnServer =
latestSemanticVersionOnServer == null ? "" : latestSemanticVersionOnServer.trim();
}
public boolean hasSemanticVersion() {
return !latestSemanticVersionOnServer.isBlank();
}
public String getLatestVersionForDisplay() {
if (hasSemanticVersion()) {
return latestSemanticVersionOnServer;
}
return Double.toString(latestVersionNumberOnServer);
}
public String getAdminMessage() {
return adminMessage;
}
public void setAdminMessage(String adminMessage) {
this.adminMessage = adminMessage;
}
public String getMajorChanges() {
return majorChanges;
}
public void setMajorChanges(String majorChanges) {
this.majorChanges = majorChanges;
}
public String getLatestVersionPathOnWebserver() {
return latestVersionPathOnWebserver;
}
public void setLatestVersionPathOnWebserver(String latestVersionPathOnWebserver) {
this.latestVersionPathOnWebserver = latestVersionPathOnWebserver;
}
public ArrayList<String> getNeedUpdateResourcesSinceLastVersion() {
return needUpdateResourcesSinceLastVersion;
}
public void setNeedUpdateResourcesSinceLastVersion(ArrayList<String> needUpdateResourcesSinceLastVersion) {
this.needUpdateResourcesSinceLastVersion = needUpdateResourcesSinceLastVersion;
}
public ArrayList<String[]> getFeatureRequest() {
return featureRequest;
}
public void setFeatureRequest(ArrayList<String[]> featureRequest) {
this.featureRequest = featureRequest;
}
public ArrayList<String[]> getBugRequests() {
return bugRequests;
}
public void setBugRequests(ArrayList<String[]> bugRequests) {
this.bugRequests = bugRequests;
}
}
@@ -0,0 +1,22 @@
package kst4contest.observe;
/**
* Runs every task on the calling thread. Intended for tests and for headless
* use; it is not a user-interface thread in any real sense.
*/
public class DirectUiDispatcher implements UiDispatcher {
@Override
public void runOnUi(Runnable task) {
try {
task.run();
} catch (RuntimeException e) {
System.err.println("[observe] UI task failed: " + e);
}
}
@Override
public boolean isUiThread() {
return true;
}
}
@@ -0,0 +1,11 @@
package kst4contest.observe;
/** An {@link ObservableValue} that can be written to. */
public interface MutableValue<T> extends ObservableValue<T> {
/**
* Replaces the value and notifies the listeners. Setting a value that
* equals the current one notifies nobody.
*/
void set(T value);
}
@@ -0,0 +1,33 @@
package kst4contest.observe;
import java.util.List;
import java.util.function.Consumer;
/**
* An observable collection that hands out immutable snapshots.
*
* <p>There is deliberately no incremental change protocol: consumers receive
* the whole new content and decide for themselves what to do with it. That
* matches how a declarative user interface redraws, and it keeps the domain
* layer testable without a running toolkit.
*
* <p>Listeners run on the thread that performed the mutation. Anything that
* touches the user interface must hand over through {@link UiDispatcher}.
*/
public interface ObservableRoster<T> {
/** An immutable view of the current content. */
List<T> snapshot();
int size();
default boolean isEmpty() {
return size() == 0;
}
/** Registers a listener that is called with the new content after each change. */
void addListener(Consumer<List<T>> listener);
/** Removes a previously registered listener. Unknown listeners are ignored. */
void removeListener(Consumer<List<T>> listener);
}
@@ -0,0 +1,22 @@
package kst4contest.observe;
import java.util.function.Consumer;
/**
* A value that can be observed for changes.
*
* <p>Deliberately minimal: this replaces the JavaFX property types in the
* domain layer without pulling in a reactive framework. {@code null} means the
* value is unavailable, never a default.
*/
public interface ObservableValue<T> {
/** The current value, or {@code null} when it is unavailable. */
T get();
/** Registers a listener that is called with the new value after each change. */
void addListener(Consumer<T> listener);
/** Removes a previously registered listener. Unknown listeners are ignored. */
void removeListener(Consumer<T> listener);
}
@@ -0,0 +1,119 @@
package kst4contest.observe;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
/**
* The default {@link ObservableRoster}. Writes are serialised on the roster
* itself; reads hand out an immutable copy taken under the same lock.
*/
public class SimpleRoster<T> implements ObservableRoster<T> {
private final Object lock = new Object();
private final List<T> items = new ArrayList<>();
private final List<Consumer<List<T>>> listeners = new CopyOnWriteArrayList<>();
/**
* The last handed-out snapshot, reused until the content changes. Several
* consumers read the same roster per batch — four message tabs, for one — and
* copying the whole store for each of them is wasted work.
*/
private volatile List<T> snapshotCache;
@Override
public List<T> snapshot() {
List<T> cached = snapshotCache;
if (cached != null) {
return cached;
}
synchronized (lock) {
if (snapshotCache == null) {
snapshotCache = newSnapshot();
}
return snapshotCache;
}
}
/*
* Collections.unmodifiableList and not List.copyOf: the JavaFX list this
* replaces carried null entries, and List.copyOf rejects them. A NullPointerException
* raised here would be swallowed by the listener guard and the mirror would stop
* updating without a word.
*/
private List<T> newSnapshot() {
return Collections.unmodifiableList(new ArrayList<>(items));
}
@Override
public int size() {
synchronized (lock) {
return items.size();
}
}
public void add(T item) {
mutate(list -> list.add(item));
}
public void addAll(Collection<? extends T> newItems) {
mutate(list -> list.addAll(newItems));
}
public void remove(T item) {
mutate(list -> list.remove(item));
}
public void setAll(Collection<? extends T> newItems) {
mutate(list -> {
list.clear();
list.addAll(newItems);
});
}
public void clear() {
mutate(List::clear);
}
/**
* Applies several changes and notifies listeners once. This is what keeps
* a batch of several thousand chat members from producing a snapshot and a
* listener round per element.
*/
public void mutate(Consumer<List<T>> change) {
Objects.requireNonNull(change, "change");
List<T> snapshot;
synchronized (lock) {
change.accept(items);
snapshot = newSnapshot();
snapshotCache = snapshot;
}
notifyListeners(snapshot);
}
private void notifyListeners(List<T> snapshot) {
for (Consumer<List<T>> listener : listeners) {
try {
listener.accept(snapshot);
} catch (RuntimeException e) {
// A broken listener must not keep the remaining ones from running,
// and must not terminate the message-processing thread.
System.err.println("[observe] roster listener failed: " + e);
}
}
}
@Override
public void addListener(Consumer<List<T>> listener) {
listeners.add(Objects.requireNonNull(listener, "listener"));
}
@Override
public void removeListener(Consumer<List<T>> listener) {
listeners.remove(listener);
}
}
@@ -0,0 +1,57 @@
package kst4contest.observe;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
/**
* The default {@link MutableValue}. Safe to read and write from any thread;
* listeners run on the thread that calls {@link #set}, so anything touching the
* user interface must hand over through {@link UiDispatcher}.
*/
public class SimpleValue<T> implements MutableValue<T> {
private final List<Consumer<T>> listeners = new CopyOnWriteArrayList<>();
private volatile T value;
public SimpleValue() {
this(null);
}
public SimpleValue(T initial) {
this.value = initial;
}
@Override
public T get() {
return value;
}
@Override
public void set(T newValue) {
if (Objects.equals(value, newValue)) {
return;
}
value = newValue;
for (Consumer<T> listener : listeners) {
try {
listener.accept(newValue);
} catch (RuntimeException e) {
// A broken listener must not keep the remaining ones from running,
// and must not terminate the thread that produced the value.
System.err.println("[observe] value listener failed: " + e);
}
}
}
@Override
public void addListener(Consumer<T> listener) {
listeners.add(Objects.requireNonNull(listener, "listener"));
}
@Override
public void removeListener(Consumer<T> listener) {
listeners.remove(listener);
}
}
@@ -0,0 +1,18 @@
package kst4contest.observe;
/**
* Hands work over to the thread that owns the user interface.
*
* <p>This is the boundary the project has always had, named explicitly: worker
* threads never mutate user-interface state themselves, they hand it over.
* Before this interface existed the boundary was {@code Platform.runLater};
* the rule is unchanged, only the dependency is gone.
*/
public interface UiDispatcher {
/** Runs the task on the user-interface thread, now or later. */
void runOnUi(Runnable task);
/** Whether the calling thread is the user-interface thread. */
boolean isUiThread();
}
@@ -0,0 +1,74 @@
package kst4contest.service.path;
/**
* Utility methods for Fresnel zone calculations.
*
* <p>This helper intentionally contains only pure mathematical functions.
* It has no dependency on UI code or terrain providers.</p>
*/
public final class FresnelMathUtils {
/**
* Speed of light in vacuum in meters per second.
*/
public static final double SPEED_OF_LIGHT_METERS_PER_SECOND = 299_792_458.0;
private FresnelMathUtils() {
// Utility class
}
/**
* Computes the wavelength in meters for the given frequency.
*
* @param frequencyHz signal frequency in Hz
* @return wavelength in meters, or 0 if the input is invalid
*/
public static double computeWavelengthMeters(final double frequencyHz) {
if (frequencyHz <= 0.0) {
return 0.0;
}
return SPEED_OF_LIGHT_METERS_PER_SECOND / frequencyHz;
}
/**
* Computes the radius of the first Fresnel zone at a specific point
* along the path.
*
* <p>Formula:
* r = sqrt(lambda * d1 * d2 / (d1 + d2))</p>
*
* @param frequencyHz signal frequency in Hz
* @param distanceFromTxMeters distance from TX to the current point in meters
* @param totalPathDistanceMeters full TX-to-RX path length in meters
* @return Fresnel radius in meters, or 0 at invalid inputs / path ends
*/
public static double computeFirstFresnelRadiusMeters(
final double frequencyHz,
final double distanceFromTxMeters,
final double totalPathDistanceMeters) {
if (frequencyHz <= 0.0 || totalPathDistanceMeters <= 0.0) {
return 0.0;
}
final double clampedDistanceFromTxMeters = Math.max(
0.0,
Math.min(distanceFromTxMeters, totalPathDistanceMeters)
);
final double distanceFromPointToRxMeters = totalPathDistanceMeters - clampedDistanceFromTxMeters;
if (clampedDistanceFromTxMeters <= 0.0 || distanceFromPointToRxMeters <= 0.0) {
return 0.0;
}
final double wavelengthMeters = computeWavelengthMeters(frequencyHz);
return Math.sqrt(
wavelengthMeters
* clampedDistanceFromTxMeters
* distanceFromPointToRxMeters
/ totalPathDistanceMeters
);
}
}
@@ -0,0 +1,256 @@
package kst4contest.test;
import java.io.*;
import java.net.*;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
public class MockKstServer {
private static final int PORT = 23001;
private static final String CHAT_ID = "2"; // 2 = 144/432 MHz
// Thread-sichere Liste aller verbundenen Clients (OutputStreams)
private final List<PrintWriter> clients = new CopyOnWriteArrayList<>();
// Permanente User (Ihre Test-Callsigns)
private final Map<String, User> onlineUsers = new HashMap<>();
// Historien müssen synchronisiert werden
private final List<String> historyChat = Collections.synchronizedList(new ArrayList<>());
private final List<String> historyDx = Collections.synchronizedList(new ArrayList<>());
private boolean running = false;
private ServerSocket serverSocket;
public MockKstServer() {
// Initiale Permanente User
addUser("DK5EW", "Erwin", "JN47NX");
addUser("DL1TEST", "TestOp", "JO50XX");
addUser("ON4KST", "Alain", "JO20HI");
addUser("PA9R-2", "2", "JO20HI");
addUser("PA9R-70", "70", "JO20HI");
addUser("PA9R", "general", "JO20HI");
}
// Startet den Server im Hintergrund (Non-Blocking)
public void start() {
if (running) return;
running = true;
new Thread(() -> {
try {
serverSocket = new ServerSocket(PORT);
System.out.println("[Server] ON4KST Simulation gestartet auf Port " + PORT);
// Startet den Simulator für Zufallstraffic
new Thread(this::simulationLoop).start();
while (running) {
Socket clientSocket = serverSocket.accept();
System.out.println("[Server] Neuer Client verbunden: " + clientSocket.getInetAddress());
new Thread(new ClientHandler(clientSocket)).start();
}
} catch (IOException e) {
if (running) e.printStackTrace();
}
}).start();
}
public void stop() {
running = false;
try {
if (serverSocket != null) serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
private void addUser(String call, String name, String loc) {
onlineUsers.put(call, new User(call, name, loc));
}
private void removeUser(String call) {
onlineUsers.remove(call);
}
// Sendet Nachricht an ALLE verbundenen Clients (inkl. Sender)
private void broadcast(String message) {
if (!message.endsWith("\r\n")) message += "\r\n";
String finalMsg = message;
for (PrintWriter writer : clients) {
try {
writer.print(finalMsg);
writer.flush(); // WICHTIG: Sofort senden!
} catch (Exception e) {
// Client wohl weg, wird beim nächsten Schreibversuch oder im Handler entfernt
}
}
}
// --- Innere Logik: Client Handler ---
private class ClientHandler implements Runnable {
private Socket socket;
private PrintWriter out;
private BufferedReader in;
private String myCall = "MYCLIENT"; // Default, wird bei LOGIN überschrieben
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
// ISO-8859-1 ist Standard für KST/Telnet Cluster
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "ISO-8859-1"));
out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "ISO-8859-1"), true);
clients.add(out);
String line;
boolean loginComplete = false;
while ((line = in.readLine()) != null) {
// System.out.println("[RECV] " + line); // Debugging aktivieren falls nötig
String[] parts = line.split("\\|");
String cmd = parts[0];
if (cmd.equals("LOGIN") || cmd.equals("LOGINC")) {
// Protokoll: LOGIN|callsign|password|... [cite: 21]
if (parts.length > 1) myCall = parts[1];
// 1. Login Bestätigung
// Format: LOGSTAT|100|chat id|client software version|session key|config|dx option|
send("LOGSTAT|100|" + CHAT_ID + "|JavaSim|KEY123|Config|3|");
// Bei LOGIN senden wir die Daten sofort
// Bei LOGINC warten wir eigentlich auf SDONE, senden hier aber vereinfacht direkt
if (cmd.equals("LOGIN")) {
sendInitialData();
loginComplete = true;
}
}
else if (cmd.equals("SDONE")) {
// Abschluss der Settings (bei LOGINC) [cite: 34]
sendInitialData();
loginComplete = true;
}
else if (cmd.equals("MSG")) {
// MSG|chat id|destination|command|0| [cite: 42]
if (parts.length >= 4) {
String text = parts[3];
// Nachricht sofort als CH Frame an alle verteilen (Echo)
handleChatMessage(myCall, "Me", text);
}
}
else if (cmd.equals("CK")) {
// Keepalive [cite: 20]
// Server muss nicht zwingend antworten, aber Connection bleibt offen
}
}
} catch (IOException e) {
// System.out.println("Client getrennt");
} finally {
clients.remove(out);
try { socket.close(); } catch (IOException e) {}
}
}
private void send(String msg) {
if (!msg.endsWith("\r\n")) msg += "\r\n";
out.print(msg);
out.flush();
}
private void sendInitialData() {
// 1. User Liste UA0 [cite: 14]
for (User u : onlineUsers.values()) {
send("UA0|" + CHAT_ID + "|" + u.call + "|" + u.name + "|" + u.loc + "|0|");
}
// 2. Chat History CR [cite: 7]
synchronized(historyChat) {
for (String h : historyChat) send(h);
}
// 3. DX History DL [cite: 10]
synchronized(historyDx) {
for (String d : historyDx) send(d);
}
// 4. Ende User Liste UE [cite: 15]
send("UE|" + CHAT_ID + "|" + onlineUsers.size() + "|");
}
}
// --- Hilfsmethoden für Traffic ---
private void handleChatMessage(String call, String name, String text) {
// CH|chat id|date|callsign|firstname|destination|msg|highlight|
String date = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String frame = String.format("CH|%s|%s|%s|%s|0|%s|0|", CHAT_ID, date, call, name, text);
synchronized(historyChat) {
historyChat.add(frame);
if (historyChat.size() > 50) historyChat.remove(0);
}
broadcast(frame);
}
private void handleDxSpot(String spotter, String dx, String freq) {
// DL|Unix time|dx utc|spotter|qrg|dx|info|spotter locator|dx locator| [cite: 10]
long unixTime = System.currentTimeMillis() / 1000;
String utc = new SimpleDateFormat("HHmm").format(new Date());
// Simple Dummy Locators
String frame = String.format("DL|%d|%s|%s|%s|%s|Simulated|JO00|JO99|",
unixTime, utc, spotter, freq, dx);
synchronized(historyDx) {
historyDx.add(frame);
if (historyDx.size() > 20) historyDx.remove(0);
}
broadcast(frame);
}
private void simulationLoop() {
String[] randomCalls = {"PA0GUS", "F6APE", "OH8K", "OZ2M", "G4CBW"};
String[] msgs = {"CQ 144.300", "Tnx for QSO", "Any sked?", "QRV 432.200"};
Random rand = new Random();
while (running) {
try {
Thread.sleep(3000 + rand.nextInt(5000)); // 3-8 Sek Pause
int action = rand.nextInt(10);
String call = randomCalls[rand.nextInt(randomCalls.length)];
if (action < 4) { // 40% Chat
handleChatMessage(call, "SimOp", msgs[rand.nextInt(msgs.length)]);
} else if (action < 7) { // 30% DX Spot
handleDxSpot(call, randomCalls[rand.nextInt(randomCalls.length)], "144." + rand.nextInt(400));
} else if (action == 8) { // Login Simulation UA5
if (!onlineUsers.containsKey(call)) {
addUser(call, "SimOp", "JO11");
broadcast("UA5|" + CHAT_ID + "|" + call + "|SimOp|JO11|2|");
}
} else if (action == 9) { // Logout Simulation UR6
if (onlineUsers.containsKey(call) && !call.equals("DK5EW")) { // DK5EW nicht kicken
removeUser(call);
broadcast("UR6|" + CHAT_ID + "|" + call + "|");
}
}
// Ping ab und zu
if (rand.nextInt(5) == 0) broadcast("CK|");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// Kleine Datenklasse
private static class User {
String call, name, loc;
User(String c, String n, String l) { this.call=c; this.name=n; this.loc=l; }
}
}
@@ -0,0 +1,120 @@
package kst4contest.utils;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.FileHandler;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
/** Controls the persistent application log without redirecting stdout/stderr. */
public final class ApplicationFileLogging implements AutoCloseable {
/** Logger namespace owned by the application. */
private static final String APP_LOGGER_NAME = "kst4contest";
/** Runtime switch for additional application diagnostics. */
private final AtomicBoolean debugEnabled = new AtomicBoolean();
/** Strong reference to the root logger that owns the file handler. */
private final Logger rootLogger = Logger.getLogger("");
/** Strong reference to the application logger whose level is managed. */
private final Logger appLogger = Logger.getLogger(APP_LOGGER_NAME);
/** Application logger level that must be restored when this handler closes. */
private final Level previousAppLevel;
/** File handler installed on the root logger. */
private final Handler handler;
/**
* Installs one append-only file handler.
*
* @param logFile target log file
* @param debugEnabled whether INFO/FINE application diagnostics are included
* @throws IOException if the handler cannot be created
*/
public ApplicationFileLogging(
final Path logFile,
final boolean debugEnabled
)
throws IOException {
final Path parent = logFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
this.debugEnabled.set(debugEnabled);
previousAppLevel = appLogger.getLevel();
appLogger.setLevel(Level.FINE);
final FileHandler fileHandler =
new FileHandler(logFile.toString(), true);
fileHandler.setLevel(Level.ALL);
fileHandler.setFilter(this::isLoggable);
fileHandler.setFormatter(new UtcLogFormatter());
handler = fileHandler;
rootLogger.addHandler(handler);
}
/** Applies the runtime debug preference to subsequent file log records. */
public void setDebugEnabled(final boolean enabled) {
debugEnabled.set(enabled);
}
private boolean isLoggable(final LogRecord record) {
boolean loggable = record.getThrown() != null
|| record.getLevel().intValue() >= Level.WARNING.intValue();
if (!loggable) {
final String loggerName = record.getLoggerName();
final boolean appRecord = loggerName != null
&& (APP_LOGGER_NAME.equals(loggerName)
|| loggerName.startsWith(APP_LOGGER_NAME + "."));
loggable = debugEnabled.get()
&& appRecord
&& record.getLevel().intValue() >= Level.FINE.intValue();
}
return loggable;
}
/** Removes and closes this handler. */
@Override
public void close() {
rootLogger.removeHandler(handler);
handler.close();
appLogger.setLevel(previousAppLevel);
}
/** Formats file-log records with comparable UTC millisecond timestamps. */
private static final class UtcLogFormatter extends Formatter {
/** Timestamp format for every file-log record. */
private static final DateTimeFormatter TIMESTAMP =
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'")
.withZone(ZoneOffset.UTC);
@Override
public String format(final LogRecord record) {
final StringBuilder line = new StringBuilder()
.append(TIMESTAMP.format(Instant.ofEpochMilli(
record.getMillis())))
.append('\t')
.append(record.getLevel().getName())
.append('\t')
.append(record.getLoggerName())
.append('\t')
.append(formatMessage(record))
.append(System.lineSeparator());
if (record.getThrown() != null) {
final StringWriter stackTrace = new StringWriter();
record.getThrown().printStackTrace(new PrintWriter(stackTrace));
line.append(stackTrace);
}
return line.toString();
}
}
}
@@ -0,0 +1,72 @@
package kst4contest.utils;
import kst4contest.controller.DBController;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* This class has utility methods to handle application files inside the home directory.
*/
public class ApplicationFileUtils {
private static final Logger LOGGER = Logger.getLogger(ApplicationFileUtils.class.getName());
/**
* Gets the path of a file inside the home directory of the user.
* @param applicationName Name off the application which is used for the hidden directory
* @param fileName Name of the file.
* @return The full path inside the user home directory.
*/
public static String getFilePath(final String applicationName, final String fileName) {
return Path.of(System.getProperty("user.home"),
"." + applicationName,
fileName).toString();
}
/**
* Copies a resource to the application folder if required.
* @param applicationName Name of the application.
* @param resourcePath Path of the resource.
* @param fileName Name of the target file.
*/
public static void copyResourceIfRequired(final String applicationName, final String resourcePath, final String fileName) {
File file = new File(getFilePath(applicationName, fileName));
if (file.exists()) {
return;
}
File parentDir = file.getParentFile();
if (!parentDir.exists()) {
parentDir.mkdirs();
}
copyResourceToFile(resourcePath, file.getPath());
}
/**
* Copies a given resource at a resourcePath to the file path.
* @param resourcePath
* @param filePath
*/
public static void copyResourceToFile(String resourcePath, String filePath) {
try (InputStream resourceStream = ApplicationFileUtils.class.getResourceAsStream(resourcePath);
FileOutputStream fileOutputStream = new FileOutputStream(filePath)) {
if (resourceStream == null) {
throw new IllegalArgumentException("Resource not found: " + resourcePath);
}
resourceStream.transferTo(fileOutputStream);
} catch (IOException ex) {
LOGGER.log(Level.SEVERE, "Exception when copying Application file: " + ex.getMessage(), ex);
}
}
}
@@ -0,0 +1,29 @@
package kst4contest.utils;
import java.util.Locale;
/**
* Small helpers for operating-system specific UI behaviour.
*/
public final class PlatformUtils {
private PlatformUtils() {
}
/**
* @return true if the running JVM reports macOS as operating system
*/
public static boolean isMacOs() {
return isMacOs(System.getProperty("os.name"));
}
/**
* Checks an {@code os.name} value for macOS. A missing value is treated as "not macOS".
*/
public static boolean isMacOs(String osName) {
if (osName == null) {
return false;
}
return osName.toLowerCase(Locale.ROOT).startsWith("mac");
}
}
@@ -0,0 +1,491 @@
package kst4contest.utils;
import javazoom.jl.decoder.JavaLayerException;
import javazoom.jl.player.Player;
import kst4contest.ApplicationConstants;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* This part of the client drives the sounds. Its a singleton instance. All audio outs are directed to this instance.<br>
* <br>
* */
public class PlayAudioUtils {
/**
* Default constructor initializes the sound files and copies it to the project home folder
*/
public PlayAudioUtils() {
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3", "NOISESTARTUP.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/NOISECQWINDOW.mp3", "NOISECQWINDOW.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/NOISEPMWINDOW.mp3", "NOISEPMWINDOW.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/NOISEERROR.mp3", "NOISEERROR.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/NOISENOTIFY.mp3", "NOISENOTIFY.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/tick.mp3", "tick.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRA.mp3", "LTTRA.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRB.mp3", "LTTRB.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRC.mp3", "LTTRC.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRD.mp3", "LTTRD.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRE.mp3", "LTTRE.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRF.mp3", "LTTRF.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRG.mp3", "LTTRG.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRH.mp3", "LTTRH.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRI.mp3", "LTTRI.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRJ.mp3", "LTTRJ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRK.mp3", "LTTRK.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRL.mp3", "LTTRL.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRM.mp3", "LTTRM.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRN.mp3", "LTTRN.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRO.mp3", "LTTRO.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRP.mp3", "LTTRP.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRQ.mp3", "LTTRQ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRR.mp3", "LTTRR.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRS.mp3", "LTTRS.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRT.mp3", "LTTRT.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRU.mp3", "LTTRU.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRV.mp3", "LTTRV.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRW.mp3", "LTTRW.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRX.mp3", "LTTRX.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRY.mp3", "LTTRY.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRZ.mp3", "LTTRZ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR0.mp3", "LTTR0.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR1.mp3", "LTTR1.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR2.mp3", "LTTR2.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR3.mp3", "LTTR3.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR4.mp3", "LTTR4.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR5.mp3", "LTTR5.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR6.mp3", "LTTR6.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR7.mp3", "LTTR7.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR8.mp3", "LTTR8.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR9.mp3", "LTTR9.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRSTROKE.mp3", "LTTRSTROKE.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRSPACE.mp3", "LTTRSPACE.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEA.mp3", "VOICEA.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEB.mp3", "VOICEB.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEC.mp3", "VOICEC.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICED.mp3", "VOICED.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEE.mp3", "VOICEE.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEF.mp3", "VOICEF.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEG.mp3", "VOICEG.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEH.mp3", "VOICEH.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEI.mp3", "VOICEI.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEJ.mp3", "VOICEJ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEK.mp3", "VOICEK.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEL.mp3", "VOICEL.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEM.mp3", "VOICEM.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEN.mp3", "VOICEN.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEO.mp3", "VOICEO.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEP.mp3", "VOICEP.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEQ.mp3", "VOICEQ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICER.mp3", "VOICER.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICES.mp3", "VOICES.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICET.mp3", "VOICET.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEU.mp3", "VOICEU.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEV.mp3", "VOICEV.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEW.mp3", "VOICEW.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEX.mp3", "VOICEX.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEY.mp3", "VOICEY.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEZ.mp3", "VOICEZ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE0.mp3", "VOICE0.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE1.mp3", "VOICE1.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE2.mp3", "VOICE2.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE3.mp3", "VOICE3.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE4.mp3", "VOICE4.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE5.mp3", "VOICE5.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE6.mp3", "VOICE6.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE7.mp3", "VOICE7.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE8.mp3", "VOICE8.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE9.mp3", "VOICE9.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKE.mp3", "VOICESTROKE.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEBELL.mp3", "VOICEBELL.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEYOUGOTMAIL.mp3", "VOICEYOUGOTMAIL.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEHELLO.mp3", "VOICEHELLO.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE73.mp3", "VOICE73.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKEPORTABLE.mp3", "VOICESTROKEPORTABLE.mp3");
}
private final Queue<String> musicList = new ConcurrentLinkedQueue<>();
/** True once audio fails; prevents repeated error logs and further play attempts. */
private final AtomicBoolean audioUnavailable = new AtomicBoolean(false);
/** True while the drain loop is running; prevents duplicate concurrent drains. */
private final AtomicBoolean playing = new AtomicBoolean(false);
private final ExecutorService audioThread = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "audio-player");
t.setDaemon(true);
return t;
});
/**
* Plays notification sounds out of the windws 95 box by given action character<br/>
*<br/>
*
* case '!': Startup<br/>
* case '-': tick<br/>
* case 'C': CQ Window new entry<br/>
* case 'P': PM Window new entry<br/>
* case 'E': Error occured<br/>
* case 'N': other notification sounds<br/>
*
* @param actionChar
*/
public void playNoiseLauncher(char actionChar) {
switch (actionChar){
case '-':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/tick.mp3"));
break;
case '!':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3"));
break;
case 'C':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISECQWINDOW.mp3"));
break;
case 'P':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEPMWINDOW.mp3"));
break;
case 'E':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEERROR.mp3"));
break;
case 'N':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISENOTIFY.mp3"));
break;
default:
System.out.println("[KST4ContestApp, warning, letter not defined!]");
}
playMusic();
}
/**
* Plays all chars of a given String-parameter as CW Sound out of the speaker.
* As a workaround for delay problems at the beginning of playing, there are added 2x pause chars to the string.
*
* @param playThisChars
*/
public void playCWLauncher(String playThisChars) {
char[] playThisInCW = playThisChars.toUpperCase().toCharArray();
for (char letterToPlay: playThisInCW){
switch (letterToPlay){
case 'A':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRA.mp3"));
break;
case 'B':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRB.mp3"));
break;
case 'C':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRC.mp3"));
break;
case 'D':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRD.mp3"));
break;
case 'E':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRE.mp3"));
break;
case 'F':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRF.mp3"));
break;
case 'G':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRG.mp3"));
break;
case 'H':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRH.mp3"));
break;
case 'I':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRI.mp3"));
break;
case 'J':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRJ.mp3"));
break;
case 'K':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRK.mp3"));
break;
case 'L':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRL.mp3"));
break;
case 'M':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRM.mp3"));
break;
case 'N':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRN.mp3"));
break;
case 'O':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRO.mp3"));
break;
case 'P':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRP.mp3"));
break;
case 'Q':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRQ.mp3"));
break;
case 'R':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRR.mp3"));
break;
case 'S':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRS.mp3"));
break;
case 'T':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRT.mp3"));
break;
case 'U':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRU.mp3"));
break;
case 'V':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRV.mp3"));
break;
case 'W':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRW.mp3"));
break;
case 'X':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRX.mp3"));
break;
case 'Y':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRY.mp3"));
break;
case 'Z':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRZ.mp3"));
break;
case '1':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR1.mp3"));
break;
case '2':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR2.mp3"));
break;
case '3':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR3.mp3"));
break;
case '4':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR4.mp3"));
break;
case '5':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR5.mp3"));
break;
case '6':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR6.mp3"));
break;
case '7':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR7.mp3"));
break;
case '8':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR8.mp3"));
break;
case '9':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR9.mp3"));
break;
case '0':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR0.mp3"));
break;
case '/':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSTROKE.mp3"));
break;
case ' ':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSPACE.mp3"));
break;
default:
System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(playThisInCW));
}
}
playMusic();
}
/**
*
* Plays a voice file for each char in the string (only EN alphabetic and numbers) except some specials: <br/><br/>
* <b>Note that the audio settings (ChatPreferences) must be switched on in order to make the sounds playing.</b><br/><br/>
* case '!': BELL<br/>
* case '?': YOUGOTMAIL<br/>
* case '#': HELLO<br/>
* case '*': 73 bye<br/>
* case '$': STROKEPORTABLE<br/>
* @param playThisChars
*/
public void playVoiceLauncher(String playThisChars) {
char[] spellThisWithVoice = playThisChars.toUpperCase().toCharArray();
for (char letterToPlay: spellThisWithVoice){
switch (letterToPlay){
case '!':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEBELL.mp3"));
break;
case '?':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEYOUGOTMAIL.mp3"));
break;
case '#':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEHELLO.mp3"));
break;
case '*':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE73.mp3"));
break;
case '$':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKEPORTABLE.mp3"));
break;
case 'A':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEA.mp3"));
break;
case 'B':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEB.mp3"));
break;
case 'C':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEC.mp3"));
break;
case 'D':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICED.mp3"));
break;
case 'E':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEE.mp3"));
break;
case 'F':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEF.mp3"));
break;
case 'G':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEG.mp3"));
break;
case 'H':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEH.mp3"));
break;
case 'I':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEI.mp3"));
break;
case 'J':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEJ.mp3"));
break;
case 'K':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEK.mp3"));
break;
case 'L':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEL.mp3"));
break;
case 'M':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEM.mp3"));
break;
case 'N':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEN.mp3"));
break;
case 'O':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEO.mp3"));
break;
case 'P':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEP.mp3"));
break;
case 'Q':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEQ.mp3"));
break;
case 'R':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICER.mp3"));
break;
case 'S':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICES.mp3"));
break;
case 'T':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICET.mp3"));
break;
case 'U':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEU.mp3"));
break;
case 'V':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEV.mp3"));
break;
case 'W':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEW.mp3"));
break;
case 'X':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEX.mp3"));
break;
case 'Y':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEY.mp3"));
break;
case 'Z':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEZ.mp3"));
break;
case '1':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE1.mp3"));
break;
case '2':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE2.mp3"));
break;
case '3':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE3.mp3"));
break;
case '4':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE4.mp3"));
break;
case '5':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE5.mp3"));
break;
case '6':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE6.mp3"));
break;
case '7':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE7.mp3"));
break;
case '8':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE8.mp3"));
break;
case '9':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE9.mp3"));
break;
case '0':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE0.mp3"));
break;
case '/':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKE.mp3"));
break;
default:
System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(spellThisWithVoice));
}
}
playMusic();
}
private void playMusic() {
if (audioUnavailable.get()) {
musicList.clear();
return;
}
// Only start a drain loop if none is running; the running loop will pick up new items itself.
if (!playing.compareAndSet(false, true)) return;
audioThread.submit(() -> {
String path;
while ((path = musicList.poll()) != null) {
if (audioUnavailable.get()) break;
try (FileInputStream fis = new FileInputStream(path);
BufferedInputStream bis = new BufferedInputStream(fis)) {
new Player(bis).play();
} catch (IOException | JavaLayerException e) {
audioUnavailable.set(true);
musicList.clear();
System.out.println("[KST4ContestApp, warning, audio playback disabled (could not create media player): " + e + "]");
break;
}
}
playing.set(false);
// Items may have been enqueued between the last poll() and playing.set(false);
// restart the drain loop if so.
if (!musicList.isEmpty() && !audioUnavailable.get()) {
playMusic();
}
});
}
}
@@ -0,0 +1,68 @@
package kst4contest.utils;
public final class VersionUtils {
private VersionUtils() {
}
/**
* Compares numeric release versions such as 1.41, 1.41.1 and 1.41.10.
*
* Pre-release and build suffixes are ignored because the update feed
* currently publishes stable releases only.
*/
public static int compareStableVersions(String left, String right) {
int[] leftParts = parseVersion(left);
int[] rightParts = parseVersion(right);
int partCount = Math.max(leftParts.length, rightParts.length);
for (int index = 0; index < partCount; index++) {
int leftPart = index < leftParts.length ? leftParts[index] : 0;
int rightPart = index < rightParts.length ? rightParts[index] : 0;
int comparison = Integer.compare(leftPart, rightPart);
if (comparison != 0) {
return comparison;
}
}
return 0;
}
private static int[] parseVersion(String version) {
if (version == null || version.isBlank()) {
throw new IllegalArgumentException("Version must not be empty");
}
String normalized = version.trim();
if (normalized.startsWith("v") || normalized.startsWith("V")) {
normalized = normalized.substring(1);
}
int hyphenIndex = normalized.indexOf('-');
int plusIndex = normalized.indexOf('+');
int suffixIndex;
if (hyphenIndex < 0) {
suffixIndex = plusIndex;
} else if (plusIndex < 0) {
suffixIndex = hyphenIndex;
} else {
suffixIndex = Math.min(hyphenIndex, plusIndex);
}
if (suffixIndex >= 0) {
normalized = normalized.substring(0, suffixIndex);
}
String[] textParts = normalized.split("\\.");
int[] numericParts = new int[textParts.length];
for (int index = 0; index < textParts.length; index++) {
numericParts[index] = Integer.parseInt(textParts[index]);
}
return numericParts;
}
}
@@ -0,0 +1,36 @@
package kst4contest.view.map;
import java.util.List;
/**
* Tries terrain providers in order and returns the first usable profile.
*/
public final class ChainedTerrainProfileProvider implements TerrainProfileProvider {
private final List<TerrainProfileProvider> providers;
public ChainedTerrainProfileProvider(List<TerrainProfileProvider> providers) {
this.providers = providers == null ? List.of() : List.copyOf(providers);
}
@Override
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
TerrainProfileData lastResult = TerrainProfileData.empty("No terrain provider");
for (TerrainProfileProvider provider : providers) {
if (provider == null) {
continue;
}
TerrainProfileData currentResult = provider.loadProfile(request);
if (currentResult != null) {
lastResult = currentResult;
if (currentResult.hasUsableProfile()) {
return currentResult;
}
}
}
return lastResult;
}
}
@@ -0,0 +1,293 @@
package kst4contest.view.map;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.image.Raster;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Offline terrain provider for locally extracted Copernicus GLO-30 DGED DEM tiles.
*
* <p>Assumptions of this reader:
* <ul>
* <li>local tiles already exist on disk</li>
* <li>official DGED GeoTIFF filenames are used</li>
* <li>tiles represent 1° x 1° geocells</li>
* <li>the raster uses RasterPixelIsPoint semantics</li>
* </ul>
*
* <p>The active improvement step uses great-circle interpolation for the
* sampled path points. This avoids the path distortion of simple linear
* latitude/longitude interpolation on longer Europe-wide paths.</p>
*/
public final class CopernicusGlo30TerrainProfileProvider implements TerrainProfileProvider {
private static final String SOURCE_NAME = "Copernicus GLO-30 offline DEM";
private static final double NODATA_VALUE = -32767.0;
private static final int MAX_LOADED_TILES = 8;
private final Supplier<String> demRootDirectorySupplier;
private final OfflineDemManager offlineDemManager;
private final Map<Path, LoadedTile> loadedTileCache =
new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Path, LoadedTile> eldest) {
return size() > MAX_LOADED_TILES;
}
};
public CopernicusGlo30TerrainProfileProvider(Supplier<String> demRootDirectorySupplier,
OfflineDemManager offlineDemManager) {
this.demRootDirectorySupplier = Objects.requireNonNull(demRootDirectorySupplier, "demRootDirectorySupplier");
this.offlineDemManager = Objects.requireNonNull(offlineDemManager, "offlineDemManager");
}
@Override
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) {
return TerrainProfileData.empty(SOURCE_NAME);
}
OfflineDemManager.OfflineDemIndex demIndex =
offlineDemManager.inspectAndIndex(demRootDirectorySupplier.get(), DemDataset.COPERNICUS_GLO_30);
if (!demIndex.usable()) {
return TerrainProfileData.empty(SOURCE_NAME + " unavailable");
}
int sampleCount = Math.max(2, request.requestedSampleCount());
List<PathProfilePoint> points = new ArrayList<>(sampleCount);
for (int i = 0; i < sampleCount; i++) {
double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1);
PathGeometryUtils.GeoPoint interpolatedPoint =
PathGeometryUtils.interpolateGreatCirclePoint(
request.fromLatitudeDeg(),
request.fromLongitudeDeg(),
request.toLatitudeDeg(),
request.toLongitudeDeg(),
t
);
double latitudeDeg = interpolatedPoint.latitudeDeg();
double longitudeDeg = interpolatedPoint.longitudeDeg();
double distanceKm = request.totalDistanceKm() * t;
if (!Double.isFinite(latitudeDeg) || !Double.isFinite(longitudeDeg)) {
return TerrainProfileData.empty(SOURCE_NAME + " path interpolation failed");
}
Path tilePath = demIndex.findTilePath(latitudeDeg, longitudeDeg);
if (tilePath == null) {
return TerrainProfileData.empty(String.format(
Locale.US,
"%s missing required tile(s) near %.5f / %.5f",
SOURCE_NAME,
latitudeDeg,
longitudeDeg
));
}
LoadedTile loadedTile = getOrLoadTile(tilePath);
if (loadedTile == null) {
return TerrainProfileData.empty(SOURCE_NAME + " tile read failed");
}
double elevationMeters = sampleElevationMeters(loadedTile, latitudeDeg, longitudeDeg);
if (!Double.isFinite(elevationMeters)) {
return TerrainProfileData.empty(String.format(
Locale.US,
"%s contains no-data sample(s) near %.5f / %.5f",
SOURCE_NAME,
latitudeDeg,
longitudeDeg
));
}
points.add(new PathProfilePoint(
distanceKm,
latitudeDeg,
longitudeDeg,
elevationMeters
));
}
return new TerrainProfileData(points, SOURCE_NAME, false);
}
private synchronized LoadedTile getOrLoadTile(Path tilePath) {
LoadedTile cachedTile = loadedTileCache.get(tilePath);
if (cachedTile != null) {
return cachedTile;
}
LoadedTile loadedTile = loadTile(tilePath);
if (loadedTile != null) {
loadedTileCache.put(tilePath, loadedTile);
}
return loadedTile;
}
private LoadedTile loadTile(Path tilePath) {
if (tilePath == null || !Files.isRegularFile(tilePath)) {
return null;
}
try (ImageInputStream imageInputStream = ImageIO.createImageInputStream(tilePath.toFile())) {
if (imageInputStream == null) {
return null;
}
Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInputStream);
if (!readers.hasNext()) {
return null;
}
ImageReader imageReader = readers.next();
try {
imageReader.setInput(imageInputStream, true, true);
Raster raster = imageReader.readRaster(0, null);
if (raster == null || raster.getWidth() < 2 || raster.getHeight() < 2) {
return null;
}
return new LoadedTile(
tilePath,
raster,
raster.getWidth(),
raster.getHeight(),
parseSouthDeg(tilePath.getFileName().toString()),
parseWestDeg(tilePath.getFileName().toString())
);
} finally {
imageReader.dispose();
}
} catch (IOException exception) {
System.err.println("[StationMap] Could not read DEM tile " + tilePath + ": " + exception.getMessage());
return null;
}
}
private int parseSouthDeg(String filename) {
ParsedTileKey key = ParsedTileKey.fromFilename(filename);
return key == null ? 0 : key.southDeg();
}
private int parseWestDeg(String filename) {
ParsedTileKey key = ParsedTileKey.fromFilename(filename);
return key == null ? 0 : key.westDeg();
}
/**
* Samples one DEM tile using bilinear interpolation.
*
* <p>The current reader assumes 1° x 1° geocells and derives raster
* coordinates directly from the sample latitude/longitude.</p>
*
* @param tile loaded DEM tile
* @param latitudeDeg sample latitude in degrees
* @param longitudeDeg sample longitude in degrees
* @return interpolated elevation in meters or NaN
*/
private double sampleElevationMeters(LoadedTile tile, double latitudeDeg, double longitudeDeg) {
if (tile == null) {
return Double.NaN;
}
double x = (longitudeDeg - tile.westDeg()) * (tile.width() - 1);
double y = ((tile.southDeg() + 1.0) - latitudeDeg) * (tile.height() - 1);
x = clamp(x, 0.0, tile.width() - 1.0);
y = clamp(y, 0.0, tile.height() - 1.0);
int x0 = (int) Math.floor(x);
int y0 = (int) Math.floor(y);
int x1 = Math.min(x0 + 1, tile.width() - 1);
int y1 = Math.min(y0 + 1, tile.height() - 1);
double q11 = readSample(tile.raster(), x0, y0);
double q21 = readSample(tile.raster(), x1, y0);
double q12 = readSample(tile.raster(), x0, y1);
double q22 = readSample(tile.raster(), x1, y1);
if (!Double.isFinite(q11) || !Double.isFinite(q21) || !Double.isFinite(q12) || !Double.isFinite(q22)) {
double nearest = readSample(tile.raster(), (int) Math.round(x), (int) Math.round(y));
return Double.isFinite(nearest) ? nearest : Double.NaN;
}
double dx = x - x0;
double dy = y - y0;
double top = q11 + (q21 - q11) * dx;
double bottom = q12 + (q22 - q12) * dx;
return top + (bottom - top) * dy;
}
private double readSample(Raster raster, int x, int y) {
double value = raster.getSampleDouble(x, y, 0);
if (!Double.isFinite(value) || value <= NODATA_VALUE) {
return Double.NaN;
}
return value;
}
private double clamp(double value, double minValue, double maxValue) {
return Math.max(minValue, Math.min(maxValue, value));
}
private record LoadedTile(
Path path,
Raster raster,
int width,
int height,
int southDeg,
int westDeg
) {
}
private record ParsedTileKey(int southDeg, int westDeg) {
private static final java.util.regex.Pattern TILE_PATTERN =
java.util.regex.Pattern.compile("(?i)^Copernicus_[A-Z]{3}_10_([NS])(\\d{2})_(\\d{2})_([EW])(\\d{3})_(\\d{2})_DEM\\.tif$");
static ParsedTileKey fromFilename(String filename) {
if (filename == null) {
return null;
}
var matcher = TILE_PATTERN.matcher(filename);
if (!matcher.matches()) {
return null;
}
int south = signed(matcher.group(1), matcher.group(2));
int west = signed(matcher.group(4), matcher.group(5));
return new ParsedTileKey(south, west);
}
private static int signed(String direction, String degrees) {
int value = Integer.parseInt(degrees);
if ("S".equalsIgnoreCase(direction) || "W".equalsIgnoreCase(direction)) {
return -value;
}
return value;
}
}
}
@@ -0,0 +1,45 @@
package kst4contest.view.map;
import java.util.Locale;
/**
* Supported offline DEM datasets.
*
* First real offline target:
* Copernicus DEM GLO-30 DGED GeoTIFF.
*/
public enum DemDataset {
COPERNICUS_GLO_30("copernicus_glo_30", "Copernicus DEM GLO-30");
private final String id;
private final String displayName;
DemDataset(String id, String displayName) {
this.id = id;
this.displayName = displayName;
}
public String id() {
return id;
}
public String displayName() {
return displayName;
}
public static DemDataset fromId(String id) {
if (id == null || id.isBlank()) {
return COPERNICUS_GLO_30;
}
String normalized = id.trim().toLowerCase(Locale.ROOT);
for (DemDataset dataset : values()) {
if (dataset.id.equals(normalized)) {
return dataset;
}
}
return COPERNICUS_GLO_30;
}
}
@@ -0,0 +1,48 @@
package kst4contest.view.map;
import java.util.Objects;
/**
* Terrain provider wrapper that tries a primary source first and falls back
* to a secondary provider when the primary source returns no usable profile.
*/
public final class FallbackTerrainProfileProvider implements TerrainProfileProvider {
private final TerrainProfileProvider primaryProvider;
private final TerrainProfileProvider fallbackProvider;
public FallbackTerrainProfileProvider(TerrainProfileProvider primaryProvider,
TerrainProfileProvider fallbackProvider) {
this.primaryProvider = Objects.requireNonNull(primaryProvider, "primaryProvider");
this.fallbackProvider = Objects.requireNonNull(fallbackProvider, "fallbackProvider");
}
@Override
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
TerrainProfileData primaryData = safeLoad(primaryProvider, request);
if (primaryData.hasUsableProfile()) {
return primaryData;
}
TerrainProfileData fallbackData = safeLoad(fallbackProvider, request);
if (fallbackData.hasUsableProfile()) {
return fallbackData;
}
return fallbackData.profilePoints().isEmpty() ? primaryData : fallbackData;
}
private TerrainProfileData safeLoad(TerrainProfileProvider provider, TerrainProfileRequest request) {
try {
TerrainProfileData result = provider.loadProfile(request);
return result == null
? TerrainProfileData.empty(provider.getClass().getSimpleName())
: result;
} catch (Exception exception) {
System.err.println("[StationMap] Terrain provider failed: "
+ provider.getClass().getSimpleName()
+ " -> " + exception.getMessage());
return TerrainProfileData.empty(provider.getClass().getSimpleName());
}
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff. Show more