diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index 36ab72e..f5ae14a 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -824,7 +824,50 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList Platform.runLater(() -> { this.activeSkeds.add(sked); scoreService.requestRecompute("sked-added"); - }); //TODO: Addsked muss noch genutzt werden + }); + + // Push sked to Win-Test via UDP if enabled + if (chatPreferences.isLogsynch_wintestNetworkSkedPushEnabled() + && chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) { + pushSkedToWinTest(sked); + } + } + + /** + * Pushes a sked to Win-Test via UDP broadcast (LOCKSKED / ADDSKED / UNLOCKSKED). + * Runs on a background thread to avoid blocking the UI. + */ + private void pushSkedToWinTest(ContestSked sked) { + new Thread(() -> { + try { + InetAddress broadcastAddr = InetAddress.getByName( + chatPreferences.getLogsynch_wintestNetworkBroadcastAddress()); + int port = chatPreferences.getLogsynch_wintestNetworkPort(); + String stationName = chatPreferences.getLogsynch_wintestNetworkStationNameOfKST(); + + WinTestSkedSender sender = new WinTestSkedSender(stationName, broadcastAddr, port, this); + + // Get current frequency from QRG property (set by Win-Test STATUS or user) + double freqKHz = 144300.0; // fallback default + try { + String qrgStr = chatPreferences.getMYQRGFirstCat().get(); + if (qrgStr != null && !qrgStr.isBlank()) { + freqKHz = Double.parseDouble(qrgStr.trim()); + } + } catch (NumberFormatException ignored) { } + + // Build notes string with locator/azimuth info + String notes = "sked via KST4Contest"; + if (sked.getTargetAzimuth() > 0) { + notes = String.format("[%.0f°] %s", sked.getTargetAzimuth(), notes); + } + + sender.pushSkedToWinTest(sked, freqKHz, notes); + } catch (Exception e) { + System.out.println("[ChatController] Error pushing sked to Win-Test: " + e.getMessage()); + e.printStackTrace(); + } + }, "WinTestSkedPush").start(); } public StationMetricsService getStationMetricsService() { diff --git a/src/main/java/kst4contest/controller/WinTestMessage.java b/src/main/java/kst4contest/controller/WinTestMessage.java new file mode 100644 index 0000000..5443fab --- /dev/null +++ b/src/main/java/kst4contest/controller/WinTestMessage.java @@ -0,0 +1,80 @@ +package kst4contest.controller; + +import java.nio.charset.StandardCharsets; + +/** + * Represents a Win-Test network protocol message. + *
+ * Ported from the C# wtMessage class in wtKST. + *
+ * Win-Test uses a simple ASCII-based UDP protocol with a checksum byte. + * Message format (for sending): + *
+ * MESSAGETYPE: "src" "dst" data{checksum}\0
+ *
+ * The checksum is calculated over all bytes before the checksum position,
+ * then OR'd with 0x80.
+ */
+public class WinTestMessage {
+
+ /** Win-Test message types relevant for SKED management. */
+ public enum MessageType {
+ 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.
+ * + * Format: {@code MESSAGETYPE: "src" "dst" data{checksum}\0} + *
+ * 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; + } +} diff --git a/src/main/java/kst4contest/controller/WinTestSkedSender.java b/src/main/java/kst4contest/controller/WinTestSkedSender.java new file mode 100644 index 0000000..8366057 --- /dev/null +++ b/src/main/java/kst4contest/controller/WinTestSkedSender.java @@ -0,0 +1,182 @@ +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. + *
+ * Ported from the C# wtSked class in wtKST. + *
+ * 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 via UDP broadcast. + * + * @param sked the sked to push + * @param frequencyKHz current operating frequency in kHz (e.g. 144321.0) + * @param notes free-text notes (e.g. "[JO62QM - 123°] sked via KST") + */ + public void pushSkedToWinTest(ContestSked sked, double frequencyKHz, String notes) { + try { + sendLockSked(); + sendAddSked(sked, frequencyKHz, notes); + sendUnlockSked(); + + reportStatus("Sked pushed to WT: " + sked.getTargetCallsign(), false); + System.out.println("[WinTestSkedSender] Sked pushed: " + sked.getTargetCallsign() + + " at " + frequencyKHz + " kHz, band=" + sked.getBand()); + } catch (Exception e) { + reportStatus("ERROR pushing sked: " + e.getMessage(), true); + System.out.println("[WinTestSkedSender] Error pushing sked: " + e.getMessage()); + e.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. + *
+ * Win-Test ADDSKED data format (from wtKST): + *
+ * {epoch_seconds} {freq_in_0.1kHz} {bandId} {mode} "{callsign}" "{notes}"
+ *
+ * + * Win-Test uses a timestamp reference of 1970-01-01 00:01:00 UTC (60s offset from Unix epoch). + * The C# code adds 60 seconds to compensate. + */ + private void sendAddSked(ContestSked sked, double frequencyKHz, String notes) throws Exception { + // Win-Test timestamp: epoch seconds with 60s offset + long epochSeconds = sked.getSkedTimeEpoch() / 1000; + long wtTimestamp = epochSeconds + 60; + + // Frequency in 0.1 kHz units (Win-Test convention): multiply kHz by 10 + long freqTenthKHz = Math.round(frequencyKHz * 10.0); + + // Win-Test band ID + int bandId = toWinTestBandId(sked.getBand()); + + // Mode: 0 = CW, 1 = SSB. Default to CW; detect SSB from frequency. + int mode = (frequencyKHz > 144_000 || isInSsbSegment(frequencyKHz)) ? 0 : 0; + // Simple heuristic: could be refined with actual mode info later + + String data = wtTimestamp + + " " + freqTenthKHz + + " " + bandId + + " " + mode + + " \"" + sked.getTargetCallsign() + "\"" + + " \"" + (notes != null ? notes : "") + "\""; + + WinTestMessage msg = new WinTestMessage( + WinTestMessage.MessageType.ADDSKED, + stationName, "", + data); + sendUdp(msg); + } + + /** + * 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. + *
+ * 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_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; + }; + } + + /** + * Very simple SSB segment heuristic. + * A more complete implementation would check actual mode from Win-Test STATUS. + */ + private boolean isInSsbSegment(double frequencyKHz) { + // Example: 144.300+ is typically SSB on 2m + if (frequencyKHz >= 144.300 && frequencyKHz <= 144.400) return true; + if (frequencyKHz >= 432.200 && frequencyKHz <= 432.400) return true; + return false; + } + + private void reportStatus(String text, boolean isError) { + if (callback != null) { + callback.onThreadStatus(THREAD_NICKNAME, + new ThreadStateMessage(THREAD_NICKNAME, !isError, text, isError)); + } + } +} diff --git a/src/main/java/kst4contest/model/ChatPreferences.java b/src/main/java/kst4contest/model/ChatPreferences.java index f162bf7..a8ff831 100644 --- a/src/main/java/kst4contest/model/ChatPreferences.java +++ b/src/main/java/kst4contest/model/ChatPreferences.java @@ -201,6 +201,8 @@ public class ChatPreferences { int logsynch_wintestNetworkStationIDOfKST = 55555; int logsynch_wintestNetworkPort = 9871; boolean logsynch_wintestNetworkListenerEnabled = true; // default true = bisheriges Verhalten + String logsynch_wintestNetworkBroadcastAddress = "255.255.255.255"; // UDP broadcast address for sending to Win-Test + boolean logsynch_wintestNetworkSkedPushEnabled = false; // push SKEDs to Win-Test via UDP @@ -454,6 +456,22 @@ public class ChatPreferences { this.logsynch_wintestNetworkListenerEnabled = logsynch_wintestNetworkListenerEnabled; } + public String getLogsynch_wintestNetworkBroadcastAddress() { + return logsynch_wintestNetworkBroadcastAddress; + } + + public void setLogsynch_wintestNetworkBroadcastAddress(String logsynch_wintestNetworkBroadcastAddress) { + this.logsynch_wintestNetworkBroadcastAddress = logsynch_wintestNetworkBroadcastAddress; + } + + public boolean isLogsynch_wintestNetworkSkedPushEnabled() { + return logsynch_wintestNetworkSkedPushEnabled; + } + + public void setLogsynch_wintestNetworkSkedPushEnabled(boolean logsynch_wintestNetworkSkedPushEnabled) { + this.logsynch_wintestNetworkSkedPushEnabled = logsynch_wintestNetworkSkedPushEnabled; + } + public String getStn_loginLocatorSecondCat() { return stn_loginLocatorSecondCat; } @@ -1299,6 +1317,14 @@ public class ChatPreferences { logsynch_wintestNetworkListenerEnabled.setTextContent(this.logsynch_wintestNetworkListenerEnabled + ""); logsynch.appendChild(logsynch_wintestNetworkListenerEnabled); + Element logsynch_wintestNetworkBroadcastAddress = doc.createElement("logsynch_wintestNetworkBroadcastAddress"); + logsynch_wintestNetworkBroadcastAddress.setTextContent(this.logsynch_wintestNetworkBroadcastAddress); + logsynch.appendChild(logsynch_wintestNetworkBroadcastAddress); + + Element logsynch_wintestNetworkSkedPushEnabled = doc.createElement("logsynch_wintestNetworkSkedPushEnabled"); + logsynch_wintestNetworkSkedPushEnabled.setTextContent(this.logsynch_wintestNetworkSkedPushEnabled + ""); + logsynch.appendChild(logsynch_wintestNetworkSkedPushEnabled); + /** * trxSynchUCX @@ -1858,6 +1884,15 @@ public class ChatPreferences { logsynch_wintestNetworkListenerEnabled, "logsynch_wintestNetworkListenerEnabled"); + logsynch_wintestNetworkBroadcastAddress = getText( + logsynchEl, + logsynch_wintestNetworkBroadcastAddress, + "logsynch_wintestNetworkBroadcastAddress"); + + logsynch_wintestNetworkSkedPushEnabled = getBoolean( + logsynchEl, + logsynch_wintestNetworkSkedPushEnabled, + "logsynch_wintestNetworkSkedPushEnabled"); System.out.println( "[ChatPreferences, info]: file based worked-call interpreter: " + logsynch_fileBasedWkdCallInterpreterEnabled);