Add Win-Test SKED push via UDP (ported from wtKST)

Implements sending SKEDs to Win-Test via the LOCKSKED/ADDSKED/UNLOCKSKED
UDP protocol sequence, ported from the C# wtSked class in wtKST.

New files:
- WinTestMessage.java: Win-Test network message format with checksum
- WinTestSkedSender.java: UDP broadcast sender for SKED messages

Modified:
- ChatController: addSked() now pushes to Win-Test when enabled
- ChatPreferences: new settings for broadcast address and sked push toggle

The feature is disabled by default (logsynch_wintestNetworkSkedPushEnabled=false).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 00:30:52 +01:00
committed by Philipp Wagner
parent 1f3aa031c3
commit c0b8aa61a9
4 changed files with 341 additions and 1 deletions

View File

@@ -824,7 +824,50 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
Platform.runLater(() -> { Platform.runLater(() -> {
this.activeSkeds.add(sked); this.activeSkeds.add(sked);
scoreService.requestRecompute("sked-added"); 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() { public StationMetricsService getStationMetricsService() {

View File

@@ -0,0 +1,80 @@
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 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.
* <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;
}
}

View File

@@ -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.
* <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 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.
* <p>
* Win-Test ADDSKED data format (from wtKST):
* <pre>
* {epoch_seconds} {freq_in_0.1kHz} {bandId} {mode} "{callsign}" "{notes}"
* </pre>
* <p>
* 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.
* <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_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));
}
}
}

View File

@@ -201,6 +201,8 @@ public class ChatPreferences {
int logsynch_wintestNetworkStationIDOfKST = 55555; int logsynch_wintestNetworkStationIDOfKST = 55555;
int logsynch_wintestNetworkPort = 9871; int logsynch_wintestNetworkPort = 9871;
boolean logsynch_wintestNetworkListenerEnabled = true; // default true = bisheriges Verhalten 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; 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() { public String getStn_loginLocatorSecondCat() {
return stn_loginLocatorSecondCat; return stn_loginLocatorSecondCat;
} }
@@ -1299,6 +1317,14 @@ public class ChatPreferences {
logsynch_wintestNetworkListenerEnabled.setTextContent(this.logsynch_wintestNetworkListenerEnabled + ""); logsynch_wintestNetworkListenerEnabled.setTextContent(this.logsynch_wintestNetworkListenerEnabled + "");
logsynch.appendChild(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 * trxSynchUCX
@@ -1858,6 +1884,15 @@ public class ChatPreferences {
logsynch_wintestNetworkListenerEnabled, logsynch_wintestNetworkListenerEnabled,
"logsynch_wintestNetworkListenerEnabled"); "logsynch_wintestNetworkListenerEnabled");
logsynch_wintestNetworkBroadcastAddress = getText(
logsynchEl,
logsynch_wintestNetworkBroadcastAddress,
"logsynch_wintestNetworkBroadcastAddress");
logsynch_wintestNetworkSkedPushEnabled = getBoolean(
logsynchEl,
logsynch_wintestNetworkSkedPushEnabled,
"logsynch_wintestNetworkSkedPushEnabled");
System.out.println( System.out.println(
"[ChatPreferences, info]: file based worked-call interpreter: " + logsynch_fileBasedWkdCallInterpreterEnabled); "[ChatPreferences, info]: file based worked-call interpreter: " + logsynch_fileBasedWkdCallInterpreterEnabled);