fix(wintest): add band-aware sked QRG resolution, preserve portable callsigns, strip KST suffixes and correct ADDSKED timestamps

This commit is contained in:
Marc Froehlich
2026-08-07 22:28:51 +02:00
parent 92804a622a
commit 7ce31e110b
4 changed files with 783 additions and 243 deletions
@@ -318,6 +318,85 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} }
/**
* Chooses a useful initial band for a new sked.
*
* <p>Recent frequency evidence has priority over station-name information.
* Manual NOT-QRV exclusions are respected. The operator can still select
* another locally enabled band from the dropdown.</p>
*/
private Band resolveDefaultSkedBand(ChatMember selectedMember,
EnumSet<Band> enabledBands) {
if (selectedMember == null || enabledBands == null || enabledBands.isEmpty()) {
return null;
}
List<ChatMember> variants =
chatcontroller.findActiveChatMembersByRawCall(
selectedMember.getCallSignRaw()
);
if (variants.isEmpty()) {
variants = List.of(selectedMember);
}
BandOpportunityResolver.Resolution resolution =
BandOpportunityResolver.resolve(
variants,
System.currentTimeMillis()
);
EnumSet<Band> availableBands = resolution.getAvailableBands();
availableBands.retainAll(enabledBands);
Band newestFrequencyBand = null;
long newestTimestamp = Long.MIN_VALUE;
long now = System.currentTimeMillis();
for (ChatMember member : variants) {
if (member == null || member.getKnownActiveBands() == null) {
continue;
}
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> entry
: member.getKnownActiveBands().entrySet()) {
Band band = entry.getKey();
ChatMember.ActiveFrequencyInfo info = entry.getValue();
if (band == null
|| info == null
|| !availableBands.contains(band)
|| !band.isPlausible(info.frequency)) {
continue;
}
long ageMs = now - info.timestampEpoch;
if (ageMs < 0L
|| ageMs > BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS) {
continue;
}
if (info.timestampEpoch > newestTimestamp) {
newestTimestamp = info.timestampEpoch;
newestFrequencyBand = band;
}
}
}
if (newestFrequencyBand != null) {
return newestFrequencyBand;
}
if (!availableBands.isEmpty()) {
return availableBands.iterator().next();
}
return enabledBands.iterator().next();
}
public void stopRotator() { public void stopRotator() {
if (rotatorClient != null) { if (rotatorClient != null) {
@@ -773,24 +852,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
private final Map<String, ChatCategory> lastInboundCategoryByCallSignRaw = private final Map<String, ChatCategory> lastInboundCategoryByCallSignRaw =
new java.util.concurrent.ConcurrentHashMap<>(); new java.util.concurrent.ConcurrentHashMap<>();
/** Tracks the last time WE sent a message containing a QRG to a specific callsign (UPPERCASE).
* Compared against knownActiveBands.timestampEpoch to decide whose QRG to use in a SKED. */
private final Map<String, Long> lastSentQRGToCallsign =
new java.util.concurrent.ConcurrentHashMap<>();
/** Call this whenever we send a PM to {@code receiverCallsign} that contains our QRG. */
public void recordOutboundQRG(String receiverCallsign) {
if (receiverCallsign == null) return;
lastSentQRGToCallsign.put(receiverCallsign.trim().toUpperCase(), System.currentTimeMillis());
System.out.println("[ChatController] Recorded outbound QRG to: " + receiverCallsign);
}
/** Returns epoch-ms of when we last sent our QRG to this callsign, or 0 if never. */
public long getLastSentQRGTimestamp(String callsign) {
if (callsign == null) return 0L;
return lastSentQRGToCallsign.getOrDefault(callsign.trim().toUpperCase(), 0L);
}
private final ScoreService scoreService = new ScoreService(this, new PriorityCalculator(), 15); private final ScoreService scoreService = new ScoreService(this, new PriorityCalculator(), 15);
private ScheduledExecutorService scoreScheduler; private ScheduledExecutorService scoreScheduler;
private final StationMetricsService stationMetricsService = new StationMetricsService(); private final StationMetricsService stationMetricsService = new StationMetricsService();
@@ -814,158 +875,414 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} }
/** /**
* Pushes a sked to Win-Test via UDP broadcast (LOCKSKED / ADDSKED / UNLOCKSKED). * Pushes a sked to Win-Test via UDP broadcast.
* Runs on a background thread to avoid blocking the UI. *
* <p>The internal sked is independent from this handover. If no frequency can
* be resolved safely for the selected band, the internal sked remains active,
* but no misleading Win-Test entry is created.</p>
*/ */
private void pushSkedToWinTest(ContestSked sked) { private void pushSkedToWinTest(ContestSked sked) {
new Thread(() -> { new Thread(() -> {
try { try {
InetAddress broadcastAddr = InetAddress.getByName( Double frequencyKHz = resolveSkedFrequencyKHz(sked);
chatPreferences.getLogsynch_wintestNetworkBroadcastAddress());
int port = chatPreferences.getLogsynch_wintestNetworkPort();
String stationName = chatPreferences.getLogsynch_wintestNetworkStationNameOfKST();
WinTestSkedSender sender = new WinTestSkedSender(stationName, broadcastAddr, port, this); if (frequencyKHz == null) {
reportSkippedWinTestSked(
// Frequency resolution: sked,
// Compare WHO sent a QRG most recently in the PM conversation: "no recent or configured QRG matches "
// - OM sent their QRG last → use OM's Last Known QRG (ChatMember.frequency) + sked.getBand().getDisplayLabel()
// - WE sent our QRG last → use our own Win-Test QRG (MYQRG) );
// Fallback chain if no timestamps exist: OM's Last Known QRG → hardcoded default return;
double freqKHz = -1.0;
final long SKED_FREQ_MAX_AGE_MS = 60 * 60 * 1000L; // 60 minutes
ChatMember targetMember = resolveSkedTargetMember(sked.getTargetCallsign());
// Collect timestamps: when did the OM last mention their QRG? When did WE last send ours?
long omLastQRGTimestamp = 0L;
double omLastQRGMhz = 0.0;
if (targetMember != null && sked.getBand() != null) {
ChatMember.ActiveFrequencyInfo fi = targetMember.getKnownActiveBands().get(sked.getBand());
if (fi != null && fi.frequency > 0
&& (System.currentTimeMillis() - fi.timestampEpoch) <= SKED_FREQ_MAX_AGE_MS) {
omLastQRGTimestamp = fi.timestampEpoch;
omLastQRGMhz = fi.frequency;
}
}
long ourLastQRGTimestamp = getLastSentQRGTimestamp(sked.getTargetCallsign());
// Decision: who was more recent?
if (omLastQRGTimestamp > 0 && omLastQRGTimestamp >= ourLastQRGTimestamp) {
// OM mentioned their QRG MORE RECENTLY (or at same time) → use their QRG
freqKHz = omLastQRGMhz * 1000.0;
System.out.println("[ChatController] SKED freq: OM sent last → "
+ omLastQRGMhz + " MHz → " + freqKHz + " kHz");
} else if (ourLastQRGTimestamp > 0) {
// WE sent our QRG more recently → use our Win-Test QRG
try {
String qrgStr = chatPreferences.getMYQRGFirstCat().get();
if (qrgStr != null && !qrgStr.isBlank()) {
String cleaned = qrgStr.trim().replace(".", "");
double parsed = Double.parseDouble(cleaned) / 100.0;
if (parsed > 50000) {
freqKHz = parsed;
System.out.println("[ChatController] SKED freq: WE sent last → "
+ freqKHz + " kHz (raw: " + qrgStr + ")");
}
}
} catch (NumberFormatException ignored) { }
} }
// Fallback A: OM's Last Known QRG from KST field (if no PM QRG exchange found at all) String winTestCallsign =
if (freqKHz < 0 && targetMember != null) { toWinTestSkedCallsign(
try { sked.getTargetChatCallsign()
String memberQrg = targetMember.getFrequency().get(); );
if (memberQrg != null && !memberQrg.isBlank()) {
double mhz = Double.parseDouble(memberQrg.trim()); if (winTestCallsign == null || winTestCallsign.isBlank()) {
freqKHz = mhz * 1000.0; reportSkippedWinTestSked(
System.out.println("[ChatController] SKED freq: fallback Last Known QRG → " sked,
+ mhz + " MHz → " + freqKHz + " kHz"); "the target callsign could not be converted"
} );
} catch (NumberFormatException ignored) { } return;
} }
// Fallback B: hardcoded default InetAddress broadcastAddress = InetAddress.getByName(
if (freqKHz < 0) { chatPreferences
freqKHz = 144300.0; .getLogsynch_wintestNetworkBroadcastAddress()
} );
int port =
chatPreferences.getLogsynch_wintestNetworkPort();
String stationName =
chatPreferences
.getLogsynch_wintestNetworkStationNameOfKST();
WinTestSkedSender sender = new WinTestSkedSender(
stationName,
broadcastAddress,
port,
this
);
String targetLocator =
resolveSkedTargetLocator(
sked.getTargetCallsign()
);
// Build notes string with target locator/azimuth info like reference: [JO02OB - 279°]
String targetLocator = resolveSkedTargetLocator(sked.getTargetCallsign());
String notes = "sked via KST4Contest"; String notes = "sked via KST4Contest";
if (targetLocator != null && !targetLocator.isBlank() && sked.getTargetAzimuth() > 0) {
notes = String.format("[%s - %.0f°] %s", targetLocator, sked.getTargetAzimuth(), notes); if (targetLocator != null
} else if (targetLocator != null && !targetLocator.isBlank()) { && !targetLocator.isBlank()
notes = String.format("[%s] %s", targetLocator, notes); && sked.getTargetAzimuth() > 0) {
notes = String.format(
"[%s - %.0f°] %s",
targetLocator,
sked.getTargetAzimuth(),
notes
);
} else if (targetLocator != null
&& !targetLocator.isBlank()) {
notes = String.format(
"[%s] %s",
targetLocator,
notes
);
} else if (sked.getTargetAzimuth() > 0) { } else if (sked.getTargetAzimuth() > 0) {
notes = String.format("[%.0f°] %s", sked.getTargetAzimuth(), notes);
notes = String.format(
"[%.0f°] %s",
sked.getTargetAzimuth(),
notes
);
} }
// Determine mode: -1 = auto-detect, 0 = CW, 1 = SSB String modeText =
String modeStr = chatPreferences.getLogsynch_wintestSkedMode(); chatPreferences.getLogsynch_wintestSkedMode();
int modeOverride = -1; // AUTO
if ("CW".equalsIgnoreCase(modeStr)) modeOverride = 0;
else if ("SSB".equalsIgnoreCase(modeStr)) modeOverride = 1;
sender.pushSkedToWinTest(sked, freqKHz, notes, modeOverride); int modeOverride = -1;
} catch (Exception e) {
System.out.println("[ChatController] Error pushing sked to Win-Test: " + e.getMessage()); if ("CW".equalsIgnoreCase(modeText)) {
e.printStackTrace(); modeOverride = 0;
} else if ("SSB".equalsIgnoreCase(modeText)) {
modeOverride = 1;
}
sender.pushSkedToWinTest(
sked,
winTestCallsign,
frequencyKHz,
notes,
modeOverride
);
} catch (Exception exception) {
String message =
"Error pushing sked to Win-Test: "
+ exception.getMessage();
System.out.println(
"[ChatController] " + message
);
onThreadStatus(
"WT-SkedSend",
new ThreadStateMessage(
"WT-SkedSend",
false,
message,
true
)
);
exception.printStackTrace();
} }
}, "WinTestSkedPush").start(); }, "WinTestSkedPush").start();
} }
private ChatMember resolveSkedTargetMember(String targetCallsignRaw) { /**
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) { * Resolves the frequency used for the Win-Test sked.
*
* <ol>
* <li>Newest QRG detected for the target station on the selected band</li>
* <li>Own QRG belonging to the target's chat category</li>
* <li>No result: do not send the Win-Test sked</li>
* </ol>
*/
private Double resolveSkedFrequencyKHz(ContestSked sked) {
if (sked == null || sked.getBand() == null) {
return null; return null;
} }
List<ChatMember> matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw); Double targetFrequencyKHz =
return matchingMembers.isEmpty() ? null : matchingMembers.get(0); resolveRecentTargetFrequencyKHz(sked);
}
private String resolveSkedTargetLocator(String targetCallsignRaw) { if (targetFrequencyKHz != null) {
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) { System.out.println(
return null; "[ChatController] SKED frequency from target: "
+ targetFrequencyKHz
+ " kHz on "
+ sked.getBand()
);
return targetFrequencyKHz;
} }
for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) { String ownQrg =
String locator = member.getQra(); resolveOwnQrgForSkedCategory(
if (locator != null && !locator.isBlank()) { sked.getTargetChatCategory()
return locator.trim().toUpperCase(Locale.ROOT); );
}
Double ownFrequencyKHz =
parseSkedFrequencyKHz(
ownQrg,
sked.getBand()
);
if (ownFrequencyKHz != null) {
System.out.println(
"[ChatController] SKED frequency from own category QRG: "
+ ownFrequencyKHz
+ " kHz on "
+ sked.getBand()
);
return ownFrequencyKHz;
} }
return null; return null;
} }
// private ChatMember resolveSkedTargetMember(String targetCallsignRaw) { /**
// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) { * Returns the newest QRG detected on the selected band across all active
// return null; * suffix and category variants of the base callsign.
// } */
// private Double resolveRecentTargetFrequencyKHz(ContestSked sked) {
// List<ChatMember> matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw); List<ChatMember> variants =
// return matchingMembers.isEmpty() ? null : matchingMembers.get(0); findActiveChatMembersByRawCall(
// sked.getTargetCallsign()
// } );
//
// private String resolveSkedTargetLocator(String targetCallsignRaw) { long now = System.currentTimeMillis();
// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) { long newestTimestamp = Long.MIN_VALUE;
// return null; Double newestFrequencyKHz = null;
// }
// for (ChatMember member : variants) {
// String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw); if (member == null || member.getKnownActiveBands() == null) {
// continue;
// for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) { }
// String locator = member.getQra();
// if (locator != null && !locator.isBlank()) { ChatMember.ActiveFrequencyInfo frequencyInfo =
// return locator.trim().toUpperCase(Locale.ROOT); member.getKnownActiveBands().get(
// } sked.getBand()
// } );
//
// return null; if (frequencyInfo == null
// } || frequencyInfo.frequency <= 0.0
|| !sked.getBand().isPlausible(
frequencyInfo.frequency
)) {
continue;
}
long ageMs = now - frequencyInfo.timestampEpoch;
if (ageMs < 0L
|| ageMs > BandOpportunityResolver
.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS) {
continue;
}
if (frequencyInfo.timestampEpoch > newestTimestamp) {
newestTimestamp = frequencyInfo.timestampEpoch;
newestFrequencyKHz =
frequencyInfo.frequency * 1000.0;
}
}
return newestFrequencyKHz;
}
/**
* Returns the own QRG belonging to the chat category in which the target
* station was selected.
*/
private String resolveOwnQrgForSkedCategory(
ChatCategory targetCategory) {
if (sameChatCategory(
targetCategory,
getChatCategorySecondChat())) {
return chatPreferences
.getMYQRGSecondCat()
.get();
}
return chatPreferences
.getMYQRGFirstCat()
.get();
}
private boolean sameChatCategory(ChatCategory first,
ChatCategory second) {
return first != null
&& second != null
&& first.getCategoryNumber()
== second.getCategoryNumber();
}
/**
* Parses the QRG formats used by KST4Contest and Win-Test.
*
* <p>Examples: 144.300, 144.300.03, 144300 and 144300.0.</p>
*/
private Double parseSkedFrequencyKHz(String value,
Band expectedBand) {
if (value == null
|| value.isBlank()
|| expectedBand == null) {
return null;
}
String normalized = value
.trim()
.replace(',', '.')
.replaceAll("\\s+", "");
try {
java.util.regex.Matcher groupedFrequency =
java.util.regex.Pattern.compile(
"^(\\d{2,5})\\.(\\d{3})(?:\\.(\\d{1,2}))?$"
).matcher(normalized);
if (groupedFrequency.matches()) {
double frequencyKHz =
Integer.parseInt(groupedFrequency.group(1))
* 1000.0
+ Integer.parseInt(
groupedFrequency.group(2)
);
String subKHzPart =
groupedFrequency.group(3);
if (subKHzPart != null) {
double subKHz =
Integer.parseInt(subKHzPart);
frequencyKHz +=
subKHzPart.length() == 1
? subKHz / 10.0
: subKHz / 100.0;
}
return expectedBand.isPlausible(
frequencyKHz / 1000.0
) ? frequencyKHz : null;
}
double numericValue =
Double.parseDouble(normalized);
if (expectedBand.isPlausible(numericValue)) {
return numericValue * 1000.0;
}
if (expectedBand.isPlausible(
numericValue / 1000.0)) {
return numericValue;
}
} catch (NumberFormatException ignored) {
// Invalid or unsupported QRG format.
}
return null;
}
/**
* Removes KST dash suffixes while retaining ordinary amateur-radio slash
* notation such as /P, /M or a country prefix.
*
* <p>Examples:
* DN9APW-2 -> DN9APW
* EA5/G8MBI/P-70 -> EA5/G8MBI/P
* DN9APW-2/P -> DN9APW/P</p>
*/
private String toWinTestSkedCallsign(String chatCallsign) {
if (chatCallsign == null || chatCallsign.isBlank()) {
return null;
}
String normalized =
chatCallsign.trim().toUpperCase(Locale.ROOT);
return normalized.replaceAll(
"-[^/]*(?=/|$)",
""
);
}
private void reportSkippedWinTestSked(ContestSked sked,
String reason) {
String target =
sked == null
? "unknown station"
: sked.getTargetChatCallsign();
String message =
"Win-Test sked not sent for "
+ target
+ ": "
+ reason
+ ". The internal KST4Contest sked remains active.";
System.out.println(
"[ChatController] " + message
);
onThreadStatus(
"WT-SkedSend",
new ThreadStateMessage(
"WT-SkedSend",
false,
message,
true
)
);
}
private String resolveSkedTargetLocator(
String targetCallsignRaw) {
if (targetCallsignRaw == null
|| targetCallsignRaw.isBlank()) {
return null;
}
for (ChatMember member
: findActiveChatMembersByRawCall(
targetCallsignRaw)) {
String locator = member.getQra();
if (locator != null && !locator.isBlank()) {
return locator
.trim()
.toUpperCase(Locale.ROOT);
}
}
return null;
}
public StationMetricsService getStationMetricsService() { public StationMetricsService getStationMetricsService() {
return stationMetricsService; return stationMetricsService;
@@ -39,26 +39,61 @@ public class WinTestSkedSender {
} }
/** /**
* Pushes a ContestSked into Win-Test by sending the LOCKSKED / ADDSKED / UNLOCKSKED * Pushes a ContestSked into Win-Test by sending the
* sequence via UDP broadcast. * LOCKSKED / ADDSKED / UNLOCKSKED sequence.
* *
* @param sked the sked to push * @param sked sked to push
* @param frequencyKHz current operating frequency in kHz (e.g. 144321.0) * @param targetCallsign callsign prepared for Win-Test
* @param notes free-text notes (e.g. "[JO62QM - 123°] sked via KST") * @param frequencyKHz operating frequency in kHz
* @param notes optional notes
* @param modeOverride -1 for AUTO, 0 for CW, 1 for SSB
*/ */
public void pushSkedToWinTest(ContestSked sked, double frequencyKHz, String notes, int modeOverride) { public void pushSkedToWinTest(ContestSked sked,
String targetCallsign,
double frequencyKHz,
String notes,
int modeOverride) {
try { try {
sendLockSked(); sendLockSked();
sendAddSked(sked, frequencyKHz, notes, modeOverride);
sendAddSked(
sked,
targetCallsign,
frequencyKHz,
notes,
modeOverride
);
sendUnlockSked(); sendUnlockSked();
reportStatus("Sked pushed to WT: " + sked.getTargetCallsign(), false); reportStatus(
System.out.println("[WinTestSkedSender] Sked pushed: " + sked.getTargetCallsign() "Sked pushed to WT: " + targetCallsign,
+ " at " + frequencyKHz + " kHz, band=" + sked.getBand()); false
} catch (Exception e) { );
reportStatus("ERROR pushing sked: " + e.getMessage(), true);
System.out.println("[WinTestSkedSender] Error pushing sked: " + e.getMessage()); System.out.println(
e.printStackTrace(); "[WinTestSkedSender] Sked pushed: "
+ targetCallsign
+ " at "
+ frequencyKHz
+ " kHz, band="
+ sked.getBand()
);
} catch (Exception exception) {
reportStatus(
"ERROR pushing sked: "
+ exception.getMessage(),
true
);
System.out.println(
"[WinTestSkedSender] Error pushing sked: "
+ exception.getMessage()
);
exception.printStackTrace();
} }
} }
@@ -86,46 +121,54 @@ public class WinTestSkedSender {
/** /**
* Sends an ADDSKED message with the sked details. * Sends an ADDSKED message with the sked details.
* <p> *
* Win-Test ADDSKED data format (from wtKST): * <p>The wtKST implementation subtracts a reference time of
* <pre> * 1970-01-01 00:01:00 UTC and subsequently adds 60 seconds. Both
* {epoch_seconds} {freq_in_0.1kHz} {bandId} {mode} "{callsign}" "{notes}" * operations cancel each other out. The transmitted value is therefore
* </pre> * an ordinary Unix timestamp and must not receive another offset here.</p>
* <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, int modeOverride) throws Exception { private void sendAddSked(ContestSked sked,
// Win-Test timestamp: epoch seconds with 60s offset String targetCallsign,
long epochSeconds = sked.getSkedTimeEpoch() / 1000; double frequencyKHz,
long wtTimestamp = epochSeconds + 60; String notes,
int modeOverride) throws Exception {
// Frequency in 0.1 kHz units (Win-Test convention): multiply kHz by 10 long wtTimestamp =
long freqTenthKHz = Math.round(frequencyKHz * 10.0); sked.getSkedTimeEpoch() / 1000L;
// Win-Test band ID // Frequency in 0.1 kHz units.
int bandId = toWinTestBandId(sked.getBand()); long frequencyTenthKHz =
Math.round(frequencyKHz * 10.0);
int bandId =
toWinTestBandId(sked.getBand());
// Mode: -1 = auto-detect from frequency, 0 = CW, 1 = SSB
int mode; int mode;
if (modeOverride >= 0) { if (modeOverride >= 0) {
mode = modeOverride; mode = modeOverride;
} else { } else {
mode = isInSsbSegment(frequencyKHz) ? 1 : 0; mode = isInSsbSegment(frequencyKHz)
? 1
: 0;
} }
String data = wtTimestamp String data =
+ " " + freqTenthKHz wtTimestamp
+ " " + bandId + " " + frequencyTenthKHz
+ " " + mode + " " + bandId
+ " \"" + sked.getTargetCallsign() + "\"" + " " + mode
+ " \"" + (notes != null ? notes : "") + "\""; + " \"" + targetCallsign + "\""
+ " \"" + (notes != null ? notes : "") + "\"";
WinTestMessage msg = new WinTestMessage( WinTestMessage message = new WinTestMessage(
WinTestMessage.MessageType.ADDSKED, WinTestMessage.MessageType.ADDSKED,
stationName, "", stationName,
data); "",
sendUdp(msg); data
);
sendUdp(message);
} }
/** /**
@@ -3,25 +3,58 @@ package kst4contest.model;
/** /**
* Represents a scheduled event or an AirScout opportunity in the future. * Represents a scheduled event or an AirScout opportunity in the future.
* Used for the Timeline View and Priority Calculation. * 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 { public class ContestSked {
private String targetCallsign; private String targetCallsign;
private double targetAzimuth; // Required for Antenna-Visuals private String targetChatCallsign;
private long skedTimeEpoch; // The peak time (e.g., AP) private ChatCategory targetChatCategory;
private double targetAzimuth;
private long skedTimeEpoch;
private Band band; private Band band;
// Opportunity potential (0..100). -1 means "unknown". // Opportunity potential (0..100). -1 means "unknown".
int opportunityPotentialPercent = -1; int opportunityPotentialPercent = -1;
// Status flags to prevent spamming alarms // Status flags to prevent spamming alarms.
private boolean warning3MinSent = false; private boolean warning3MinSent = false;
private boolean warningNowSent = false; private boolean warningNowSent = false;
public ContestSked(String call, double azimuth, long time, Band b) { /**
this.targetCallsign = call; * 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.targetAzimuth = azimuth;
this.skedTimeEpoch = time; this.skedTimeEpoch = time;
this.band = b; this.band = band;
} }
/** /**
@@ -32,15 +65,54 @@ public class ContestSked {
return (skedTimeEpoch - System.currentTimeMillis()) / 1000; return (skedTimeEpoch - System.currentTimeMillis()) / 1000;
} }
// Getters and Setters... /**
public String getTargetCallsign() { return targetCallsign; } * Returns the base callsign used for scoring and worked-state grouping.
public double getTargetAzimuth() { return targetAzimuth; } */
public long getSkedTimeEpoch() { return skedTimeEpoch; } public String getTargetCallsign() {
public Band getBand() { return band; } return targetCallsign;
public boolean isWarning3MinSent() { return warning3MinSent; } }
public void setWarning3MinSent(boolean b) { this.warning3MinSent = b; }
public boolean isWarningNowSent() { return warningNowSent; } /**
public void setWarningNowSent(boolean b) { this.warningNowSent = b; } * 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() { public int getOpportunityPotentialPercent() {
return opportunityPotentialPercent; return opportunityPotentialPercent;
@@ -667,56 +667,155 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
selectedCallSignDownerSiteGridPane.add(priorityRow, 0, 5, 1, 1); selectedCallSignDownerSiteGridPane.add(priorityRow, 0, 5, 1, 1);
ChoiceBox<Integer> cbSkedMinutes = new ChoiceBox<>(FXCollections.observableArrayList(2, 3, 4, 5, 6,7,8,9, 10,11,12,13,14, 15, 20)); ChoiceBox<Integer> cbSkedMinutes = new ChoiceBox<>(
FXCollections.observableArrayList(
2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 20
)
);
cbSkedMinutes.getSelectionModel().select(Integer.valueOf(5)); cbSkedMinutes.getSelectionModel().select(Integer.valueOf(5));
ChoiceBox<String> cbSkedMode = new ChoiceBox<>(FXCollections.observableArrayList("AUTO", "SSB", "CW")); EnumSet<Band> enabledSkedBands =
String configuredSkedMode = this.chatcontroller.getChatPreferences().getLogsynch_wintestSkedMode(); BandOpportunityResolver.getEnabledStationBands(
chatcontroller.getChatPreferences()
);
ChoiceBox<Band> cbSkedBand = new ChoiceBox<>(
FXCollections.observableArrayList(enabledSkedBands)
);
cbSkedBand.setConverter(new StringConverter<>() {
@Override
public String toString(Band band) {
return band == null ? "" : bandToHumanLabel(band);
}
@Override
public Band fromString(String value) {
return null;
}
});
cbSkedBand.setPrefWidth(75);
cbSkedBand.setTooltip(new Tooltip(
"Band for this sked. The initial value is derived from recent "
+ "QRG information or the station name. Only bands enabled "
+ "for your own station are offered."
));
Band defaultSkedBand =
resolveDefaultSkedBand(
selectedCallSignInfoStageChatMember,
enabledSkedBands
);
if (defaultSkedBand != null) {
cbSkedBand.setValue(defaultSkedBand);
}
ChoiceBox<String> cbSkedMode = new ChoiceBox<>(
FXCollections.observableArrayList("AUTO", "SSB", "CW")
);
String configuredSkedMode =
chatcontroller.getChatPreferences().getLogsynch_wintestSkedMode();
if (configuredSkedMode == null || configuredSkedMode.isBlank()) { if (configuredSkedMode == null || configuredSkedMode.isBlank()) {
configuredSkedMode = "AUTO"; configuredSkedMode = "AUTO";
} }
String configuredSkedModeUpper = configuredSkedMode.trim().toUpperCase(java.util.Locale.ROOT);
String configuredSkedModeUpper =
configuredSkedMode.trim().toUpperCase(Locale.ROOT);
if (!"AUTO".equals(configuredSkedModeUpper) if (!"AUTO".equals(configuredSkedModeUpper)
&& !"SSB".equals(configuredSkedModeUpper) && !"SSB".equals(configuredSkedModeUpper)
&& !"CW".equals(configuredSkedModeUpper)) { && !"CW".equals(configuredSkedModeUpper)) {
configuredSkedModeUpper = "AUTO"; configuredSkedModeUpper = "AUTO";
} }
cbSkedMode.setValue(configuredSkedModeUpper); cbSkedMode.setValue(configuredSkedModeUpper);
cbSkedMode.setTooltip(new Tooltip("Mode for Win-Test ADDSKED packets")); cbSkedMode.setTooltip(new Tooltip("Mode for Win-Test ADDSKED packets"));
cbSkedMode.setOnAction(e -> cbSkedMode.setOnAction(e ->
chatcontroller.getChatPreferences().setLogsynch_wintestSkedMode(cbSkedMode.getValue())); chatcontroller.getChatPreferences()
.setLogsynch_wintestSkedMode(cbSkedMode.getValue())
);
ChoiceBox<String> cbReminderOffsets = new ChoiceBox<>(FXCollections.observableArrayList("2+1", "5+2+1", "10+5+2+1")); ChoiceBox<String> cbReminderOffsets = new ChoiceBox<>(
FXCollections.observableArrayList(
"2+1",
"5+2+1",
"10+5+2+1"
)
);
cbReminderOffsets.getSelectionModel().select("2+1"); cbReminderOffsets.getSelectionModel().select("2+1");
CheckBox chkPmReminders = new CheckBox("Remind-PM in "); CheckBox chkPmReminders = new CheckBox("Remind-PM in ");
Button btnCreateSked = new Button("Create sked"); Button btnCreateSked = new Button("Create sked");
btnCreateSked.setTooltip(new Tooltip("Creates a sked entry and boosts priority (ramp-up).")); btnCreateSked.setTooltip(new Tooltip(
"Creates a sked entry and boosts priority during the approach."
));
btnCreateSked.setOnAction(e -> { btnCreateSked.setOnAction(e -> {
ChatMember sel = chatcontroller.getScoreService().selectedChatMemberProperty().get(); ChatMember selectedMember =
if (sel == null) return; chatcontroller.getScoreService()
.selectedChatMemberProperty()
.get();
if (cbSkedMode.getValue() != null) { if (selectedMember == null) {
chatcontroller.getChatPreferences().setLogsynch_wintestSkedMode(cbSkedMode.getValue()); return;
} }
int minutes = cbSkedMinutes.getValue() == null ? 5 : cbSkedMinutes.getValue(); Band selectedBand = cbSkedBand.getValue();
long skedTime = System.currentTimeMillis() + minutes * 60_000L; if (selectedBand == null) {
showUserInputErrorWindow(
"No sked band is available. Enable at least one band "
+ "under \"My station uses ...\" before creating a sked."
);
return;
}
double az = sel.getQTFdirection() != null ? sel.getQTFdirection() : 0.0; if (cbSkedMode.getValue() != null) {
chatcontroller.getChatPreferences()
.setLogsynch_wintestSkedMode(cbSkedMode.getValue());
}
// band is not strictly required for scoring; keep current category context int minutes =
Band band = Band.B_144; // if you want, replace with a real dropdown later cbSkedMinutes.getValue() == null
ContestSked sked = new ContestSked(sel.getCallSignRaw(), az, skedTime, band); ? 5
: cbSkedMinutes.getValue();
long skedTime =
System.currentTimeMillis() + minutes * 60_000L;
double azimuth =
selectedMember.getQTFdirection() != null
? selectedMember.getQTFdirection()
: 0.0;
ContestSked sked = new ContestSked(
selectedMember.getCallSignRaw(),
selectedMember.getCallSign(),
selectedMember.getChatCategory(),
azimuth,
skedTime,
selectedBand
);
chatcontroller.addSked(sked); chatcontroller.addSked(sked);
chatcontroller.getScoreService().requestRecompute("sked-created"); chatcontroller.getScoreService()
.requestRecompute("sked-created");
if (chkPmReminders.isSelected()) { if (chkPmReminders.isSelected()) {
List<Integer> offsets = parseMinuteOffsets(cbReminderOffsets.getValue()); List<Integer> offsets =
chatcontroller.getSkedReminderService().armReminders(sel.getCallSignRaw(), sel.getChatCategory(), skedTime, offsets); parseMinuteOffsets(cbReminderOffsets.getValue());
chatcontroller.getSkedReminderService().armReminders(
sked.getTargetChatCallsign(),
sked.getTargetChatCategory(),
skedTime,
offsets
);
} }
}); });
@@ -728,6 +827,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
); );
skedTimeGroup.setAlignment(Pos.CENTER_LEFT); skedTimeGroup.setAlignment(Pos.CENTER_LEFT);
HBox skedBandGroup = new HBox(
4,
new Label("Band"),
cbSkedBand
);
skedBandGroup.setAlignment(Pos.CENTER_LEFT);
HBox skedModeGroup = new HBox( HBox skedModeGroup = new HBox(
4, 4,
new Label("Mode"), new Label("Mode"),
@@ -750,12 +856,19 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
skedRow.setAlignment(Pos.CENTER_LEFT); skedRow.setAlignment(Pos.CENTER_LEFT);
skedRow.getChildren().addAll( skedRow.getChildren().addAll(
skedTimeGroup, skedTimeGroup,
skedBandGroup,
skedModeGroup, skedModeGroup,
btnCreateSked, btnCreateSked,
skedReminderGroup skedReminderGroup
); );
selectedCallSignDownerSiteGridPane.add(skedRow, 0, 6, 2, 1); selectedCallSignDownerSiteGridPane.add(
skedRow,
0,
6,
2,
1
);
GridPane.setHgrow(skedRow, Priority.ALWAYS); GridPane.setHgrow(skedRow, Priority.ALWAYS);
@@ -5453,23 +5566,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
btnSkedWarnIndicator.setTooltip(tipSkedWarnIndicator); btnSkedWarnIndicator.setTooltip(tipSkedWarnIndicator);
} }
private void maybeShowSkedWarnIndicator(String key, ThreadStateMessage msg) {
if (msg == null) return;
String text = msg.getRunningInformationTextDescription();
if (text == null || text.isBlank()) text = msg.getRunningInformation();
if (text == null || text.isBlank()) return;
String nick = msg.getThreadNickName() == null ? "" : msg.getThreadNickName().toLowerCase(Locale.ROOT);
String k = key == null ? "" : key.toLowerCase(Locale.ROOT);
String t = text.toLowerCase(Locale.ROOT);
boolean isSkedRelated = k.contains("sked") || nick.contains("sked") || t.contains("reminder");
if (!isSkedRelated) return;
final String finalText = text;
Platform.runLater(() -> showBlinkingSkedWarnIndicator(finalText + " SKED!"));
}
private void showBlinkingSkedWarnIndicator(String text) { private void showBlinkingSkedWarnIndicator(String text) {
// short text for the button; full text in tooltip // short text for the button; full text in tooltip
@@ -6417,8 +6514,28 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
bPaneChatWindow.setTop(flwpne_StatusBar); bPaneChatWindow.setTop(flwpne_StatusBar);
initSkedWarnIndicatorButton(); initSkedWarnIndicatorButton();
chatcontroller.lastUiReminderEventProperty().addListener(
(observable, oldValue, reminderEvent) -> {
if (reminderEvent == null) {
return;
}
String text = "REMINDER: "
+ reminderEvent.getCallSignRaw()
+ " T-"
+ reminderEvent.getMinutesBefore()
+ "m";
Platform.runLater(
() -> showBlinkingSkedWarnIndicator(text)
);
}
);
flwpne_StatusBar.getChildren().add(btnSkedWarnIndicator); flwpne_StatusBar.getChildren().add(btnSkedWarnIndicator);
initBandUpgradeIndicatorButton(); initBandUpgradeIndicatorButton();
flwpne_StatusBar.getChildren().add(btnBandUpgradeIndicator); flwpne_StatusBar.getChildren().add(btnBandUpgradeIndicator);
@@ -11384,13 +11501,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
settingsStage.show(); settingsStage.show();
chatcontroller.lastUiReminderEventProperty().addListener((obs, oldVal, ev) -> {
if (ev == null) return;
String text = "REMINDER: " + ev.getCallSignRaw() + " T-" + ev.getMinutesBefore() + "m";
Platform.runLater(() -> showBlinkingSkedWarnIndicator(text));
});
//initialize the timeline //initialize the timeline
Platform.runLater(this::updateTimelineVisuals); Platform.runLater(this::updateTimelineVisuals);
@@ -11651,10 +11762,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
updateStatusButton(key, threadStateMessage); updateStatusButton(key, threadStateMessage);
}); });
maybeShowSkedWarnIndicator(key, threadStateMessage);
maybeShowBandUpgradeIndicator(key, threadStateMessage); maybeShowBandUpgradeIndicator(key, threadStateMessage);
//if we receive a threadstatemessage for sked warning, enable the sked warning //if we receive a threadstatemessage for sked warning, enable the sked warning