From 7ce31e110b17eaa39f35831cc9f30f93bb274dc4 Mon Sep 17 00:00:00 2001 From: Marc Froehlich Date: Fri, 7 Aug 2026 22:28:51 +0200 Subject: [PATCH] fix(wintest): add band-aware sked QRG resolution, preserve portable callsigns, strip KST suffixes and correct ADDSKED timestamps --- .../controller/ChatController.java | 597 ++++++++++++++---- .../controller/WinTestSkedSender.java | 127 ++-- .../java/kst4contest/model/ContestSked.java | 102 ++- .../view/Kst4ContestApplication.java | 200 ++++-- 4 files changed, 783 insertions(+), 243 deletions(-) diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index 601f8ae..11a553b 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -318,6 +318,85 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList } + /** + * Chooses a useful initial band for a new sked. + * + *

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.

+ */ + private Band resolveDefaultSkedBand(ChatMember selectedMember, + EnumSet enabledBands) { + + if (selectedMember == null || enabledBands == null || enabledBands.isEmpty()) { + return null; + } + + List variants = + chatcontroller.findActiveChatMembersByRawCall( + selectedMember.getCallSignRaw() + ); + + if (variants.isEmpty()) { + variants = List.of(selectedMember); + } + + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve( + variants, + System.currentTimeMillis() + ); + + EnumSet 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 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() { if (rotatorClient != null) { @@ -773,24 +852,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList private final Map lastInboundCategoryByCallSignRaw = 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 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 ScheduledExecutorService scoreScheduler; 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). - * Runs on a background thread to avoid blocking the UI. + * Pushes a sked to Win-Test via UDP broadcast. + * + *

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.

*/ 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(); + Double frequencyKHz = resolveSkedFrequencyKHz(sked); - WinTestSkedSender sender = new WinTestSkedSender(stationName, broadcastAddr, port, this); - - // Frequency resolution: - // Compare WHO sent a QRG most recently in the PM conversation: - // - OM sent their QRG last → use OM's Last Known QRG (ChatMember.frequency) - // - 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 - 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) { } + if (frequencyKHz == null) { + reportSkippedWinTestSked( + sked, + "no recent or configured QRG matches " + + sked.getBand().getDisplayLabel() + ); + return; } - // Fallback A: OM's Last Known QRG from KST field (if no PM QRG exchange found at all) - if (freqKHz < 0 && targetMember != null) { - try { - String memberQrg = targetMember.getFrequency().get(); - if (memberQrg != null && !memberQrg.isBlank()) { - double mhz = Double.parseDouble(memberQrg.trim()); - freqKHz = mhz * 1000.0; - System.out.println("[ChatController] SKED freq: fallback Last Known QRG → " - + mhz + " MHz → " + freqKHz + " kHz"); - } - } catch (NumberFormatException ignored) { } + String winTestCallsign = + toWinTestSkedCallsign( + sked.getTargetChatCallsign() + ); + + if (winTestCallsign == null || winTestCallsign.isBlank()) { + reportSkippedWinTestSked( + sked, + "the target callsign could not be converted" + ); + return; } - // Fallback B: hardcoded default - if (freqKHz < 0) { - freqKHz = 144300.0; - } + InetAddress broadcastAddress = InetAddress.getByName( + chatPreferences + .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"; - if (targetLocator != null && !targetLocator.isBlank() && 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); + + if (targetLocator != null + && !targetLocator.isBlank() + && 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) { - 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 modeStr = chatPreferences.getLogsynch_wintestSkedMode(); - int modeOverride = -1; // AUTO - if ("CW".equalsIgnoreCase(modeStr)) modeOverride = 0; - else if ("SSB".equalsIgnoreCase(modeStr)) modeOverride = 1; + String modeText = + chatPreferences.getLogsynch_wintestSkedMode(); - sender.pushSkedToWinTest(sked, freqKHz, notes, modeOverride); - } catch (Exception e) { - System.out.println("[ChatController] Error pushing sked to Win-Test: " + e.getMessage()); - e.printStackTrace(); + int modeOverride = -1; + + if ("CW".equalsIgnoreCase(modeText)) { + 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(); } - private ChatMember resolveSkedTargetMember(String targetCallsignRaw) { - if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) { + /** + * Resolves the frequency used for the Win-Test sked. + * + *
    + *
  1. Newest QRG detected for the target station on the selected band
  2. + *
  3. Own QRG belonging to the target's chat category
  4. + *
  5. No result: do not send the Win-Test sked
  6. + *
+ */ + private Double resolveSkedFrequencyKHz(ContestSked sked) { + if (sked == null || sked.getBand() == null) { return null; } - List matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw); - return matchingMembers.isEmpty() ? null : matchingMembers.get(0); - } + Double targetFrequencyKHz = + resolveRecentTargetFrequencyKHz(sked); - private String resolveSkedTargetLocator(String targetCallsignRaw) { - if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) { - return null; + if (targetFrequencyKHz != null) { + System.out.println( + "[ChatController] SKED frequency from target: " + + targetFrequencyKHz + + " kHz on " + + sked.getBand() + ); + return targetFrequencyKHz; } - for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) { - String locator = member.getQra(); - if (locator != null && !locator.isBlank()) { - return locator.trim().toUpperCase(Locale.ROOT); - } + String ownQrg = + resolveOwnQrgForSkedCategory( + sked.getTargetChatCategory() + ); + + 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; } -// private ChatMember resolveSkedTargetMember(String targetCallsignRaw) { -// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) { -// return null; -// } -// -// List matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw); -// return matchingMembers.isEmpty() ? null : matchingMembers.get(0); -// -// } -// -// private String resolveSkedTargetLocator(String targetCallsignRaw) { -// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) { -// return null; -// } -// -// String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw); -// -// for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) { -// String locator = member.getQra(); -// if (locator != null && !locator.isBlank()) { -// return locator.trim().toUpperCase(Locale.ROOT); -// } -// } -// -// return null; -// } + /** + * Returns the newest QRG detected on the selected band across all active + * suffix and category variants of the base callsign. + */ + private Double resolveRecentTargetFrequencyKHz(ContestSked sked) { + List variants = + findActiveChatMembersByRawCall( + sked.getTargetCallsign() + ); + + long now = System.currentTimeMillis(); + long newestTimestamp = Long.MIN_VALUE; + Double newestFrequencyKHz = null; + + for (ChatMember member : variants) { + if (member == null || member.getKnownActiveBands() == null) { + continue; + } + + ChatMember.ActiveFrequencyInfo frequencyInfo = + member.getKnownActiveBands().get( + sked.getBand() + ); + + 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. + * + *

Examples: 144.300, 144.300.03, 144300 and 144300.0.

+ */ + 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. + * + *

Examples: + * DN9APW-2 -> DN9APW + * EA5/G8MBI/P-70 -> EA5/G8MBI/P + * DN9APW-2/P -> DN9APW/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() { return stationMetricsService; diff --git a/src/main/java/kst4contest/controller/WinTestSkedSender.java b/src/main/java/kst4contest/controller/WinTestSkedSender.java index cfc1e06..1c87fd3 100644 --- a/src/main/java/kst4contest/controller/WinTestSkedSender.java +++ b/src/main/java/kst4contest/controller/WinTestSkedSender.java @@ -39,26 +39,61 @@ public class WinTestSkedSender { } /** - * Pushes a ContestSked into Win-Test by sending the LOCKSKED / ADDSKED / UNLOCKSKED - * sequence via UDP broadcast. + * Pushes a ContestSked into Win-Test by sending the + * LOCKSKED / ADDSKED / UNLOCKSKED sequence. * - * @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") + * @param sked sked to push + * @param targetCallsign callsign prepared for Win-Test + * @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 { sendLockSked(); - sendAddSked(sked, frequencyKHz, notes, modeOverride); + + sendAddSked( + sked, + targetCallsign, + frequencyKHz, + notes, + modeOverride + ); + 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(); + reportStatus( + "Sked pushed to WT: " + targetCallsign, + false + ); + + System.out.println( + "[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. - *

- * 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. + * + *

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.

*/ - private void sendAddSked(ContestSked sked, double frequencyKHz, String notes, int modeOverride) throws Exception { - // Win-Test timestamp: epoch seconds with 60s offset - long epochSeconds = sked.getSkedTimeEpoch() / 1000; - long wtTimestamp = epochSeconds + 60; + private void sendAddSked(ContestSked sked, + String targetCallsign, + double frequencyKHz, + String notes, + int modeOverride) throws Exception { - // Frequency in 0.1 kHz units (Win-Test convention): multiply kHz by 10 - long freqTenthKHz = Math.round(frequencyKHz * 10.0); + long wtTimestamp = + sked.getSkedTimeEpoch() / 1000L; - // Win-Test band ID - int bandId = toWinTestBandId(sked.getBand()); + // Frequency in 0.1 kHz units. + long frequencyTenthKHz = + Math.round(frequencyKHz * 10.0); + + int bandId = + toWinTestBandId(sked.getBand()); - // Mode: -1 = auto-detect from frequency, 0 = CW, 1 = SSB int mode; + if (modeOverride >= 0) { mode = modeOverride; } else { - mode = isInSsbSegment(frequencyKHz) ? 1 : 0; + mode = isInSsbSegment(frequencyKHz) + ? 1 + : 0; } - String data = wtTimestamp - + " " + freqTenthKHz - + " " + bandId - + " " + mode - + " \"" + sked.getTargetCallsign() + "\"" - + " \"" + (notes != null ? notes : "") + "\""; + String data = + wtTimestamp + + " " + frequencyTenthKHz + + " " + bandId + + " " + mode + + " \"" + targetCallsign + "\"" + + " \"" + (notes != null ? notes : "") + "\""; - WinTestMessage msg = new WinTestMessage( + WinTestMessage message = new WinTestMessage( WinTestMessage.MessageType.ADDSKED, - stationName, "", - data); - sendUdp(msg); + stationName, + "", + data + ); + + sendUdp(message); } /** diff --git a/src/main/java/kst4contest/model/ContestSked.java b/src/main/java/kst4contest/model/ContestSked.java index bb3c576..97929e3 100644 --- a/src/main/java/kst4contest/model/ContestSked.java +++ b/src/main/java/kst4contest/model/ContestSked.java @@ -3,25 +3,58 @@ package kst4contest.model; /** * Represents a scheduled event or an AirScout opportunity in the future. * Used for the Timeline View and Priority Calculation. + * + *

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.

*/ public class ContestSked { private String targetCallsign; - private double targetAzimuth; // Required for Antenna-Visuals - private long skedTimeEpoch; // The peak time (e.g., AP) + 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 + // Status flags to prevent spamming alarms. private boolean warning3MinSent = 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.skedTimeEpoch = time; - this.band = b; + this.band = band; } /** @@ -32,15 +65,54 @@ public class ContestSked { return (skedTimeEpoch - System.currentTimeMillis()) / 1000; } - // Getters and Setters... - public String getTargetCallsign() { return targetCallsign; } - public double getTargetAzimuth() { return targetAzimuth; } - public long getSkedTimeEpoch() { return skedTimeEpoch; } - public Band getBand() { return band; } - 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 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; diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index a02305b..68f8b80 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -667,56 +667,155 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL selectedCallSignDownerSiteGridPane.add(priorityRow, 0, 5, 1, 1); - ChoiceBox cbSkedMinutes = new ChoiceBox<>(FXCollections.observableArrayList(2, 3, 4, 5, 6,7,8,9, 10,11,12,13,14, 15, 20)); + ChoiceBox 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)); - ChoiceBox cbSkedMode = new ChoiceBox<>(FXCollections.observableArrayList("AUTO", "SSB", "CW")); - String configuredSkedMode = this.chatcontroller.getChatPreferences().getLogsynch_wintestSkedMode(); + EnumSet enabledSkedBands = + BandOpportunityResolver.getEnabledStationBands( + chatcontroller.getChatPreferences() + ); + + ChoiceBox 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 cbSkedMode = new ChoiceBox<>( + FXCollections.observableArrayList("AUTO", "SSB", "CW") + ); + + String configuredSkedMode = + chatcontroller.getChatPreferences().getLogsynch_wintestSkedMode(); + if (configuredSkedMode == null || configuredSkedMode.isBlank()) { configuredSkedMode = "AUTO"; } - String configuredSkedModeUpper = configuredSkedMode.trim().toUpperCase(java.util.Locale.ROOT); + + String configuredSkedModeUpper = + configuredSkedMode.trim().toUpperCase(Locale.ROOT); + if (!"AUTO".equals(configuredSkedModeUpper) && !"SSB".equals(configuredSkedModeUpper) && !"CW".equals(configuredSkedModeUpper)) { configuredSkedModeUpper = "AUTO"; } + cbSkedMode.setValue(configuredSkedModeUpper); cbSkedMode.setTooltip(new Tooltip("Mode for Win-Test ADDSKED packets")); cbSkedMode.setOnAction(e -> - chatcontroller.getChatPreferences().setLogsynch_wintestSkedMode(cbSkedMode.getValue())); + chatcontroller.getChatPreferences() + .setLogsynch_wintestSkedMode(cbSkedMode.getValue()) + ); - ChoiceBox cbReminderOffsets = new ChoiceBox<>(FXCollections.observableArrayList("2+1", "5+2+1", "10+5+2+1")); + ChoiceBox cbReminderOffsets = new ChoiceBox<>( + FXCollections.observableArrayList( + "2+1", + "5+2+1", + "10+5+2+1" + ) + ); cbReminderOffsets.getSelectionModel().select("2+1"); CheckBox chkPmReminders = new CheckBox("Remind-PM in "); 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 -> { - ChatMember sel = chatcontroller.getScoreService().selectedChatMemberProperty().get(); - if (sel == null) return; + ChatMember selectedMember = + chatcontroller.getScoreService() + .selectedChatMemberProperty() + .get(); - if (cbSkedMode.getValue() != null) { - chatcontroller.getChatPreferences().setLogsynch_wintestSkedMode(cbSkedMode.getValue()); + if (selectedMember == null) { + return; } - int minutes = cbSkedMinutes.getValue() == null ? 5 : cbSkedMinutes.getValue(); - long skedTime = System.currentTimeMillis() + minutes * 60_000L; + Band selectedBand = cbSkedBand.getValue(); + 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 - Band band = Band.B_144; // if you want, replace with a real dropdown later - ContestSked sked = new ContestSked(sel.getCallSignRaw(), az, skedTime, band); + int minutes = + cbSkedMinutes.getValue() == null + ? 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.getScoreService().requestRecompute("sked-created"); + chatcontroller.getScoreService() + .requestRecompute("sked-created"); if (chkPmReminders.isSelected()) { - List offsets = parseMinuteOffsets(cbReminderOffsets.getValue()); - chatcontroller.getSkedReminderService().armReminders(sel.getCallSignRaw(), sel.getChatCategory(), skedTime, offsets); + List 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); + HBox skedBandGroup = new HBox( + 4, + new Label("Band"), + cbSkedBand + ); + skedBandGroup.setAlignment(Pos.CENTER_LEFT); + HBox skedModeGroup = new HBox( 4, new Label("Mode"), @@ -750,12 +856,19 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL skedRow.setAlignment(Pos.CENTER_LEFT); skedRow.getChildren().addAll( skedTimeGroup, + skedBandGroup, skedModeGroup, btnCreateSked, skedReminderGroup ); - selectedCallSignDownerSiteGridPane.add(skedRow, 0, 6, 2, 1); + selectedCallSignDownerSiteGridPane.add( + skedRow, + 0, + 6, + 2, + 1 + ); GridPane.setHgrow(skedRow, Priority.ALWAYS); @@ -5453,23 +5566,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL 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) { // short text for the button; full text in tooltip @@ -6417,8 +6514,28 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL bPaneChatWindow.setTop(flwpne_StatusBar); 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); + initBandUpgradeIndicatorButton(); flwpne_StatusBar.getChildren().add(btnBandUpgradeIndicator); @@ -11384,13 +11501,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL 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 Platform.runLater(this::updateTimelineVisuals); @@ -11651,10 +11762,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL updateStatusButton(key, threadStateMessage); }); - maybeShowSkedWarnIndicator(key, threadStateMessage); maybeShowBandUpgradeIndicator(key, threadStateMessage); - - //if we receive a threadstatemessage for sked warning, enable the sked warning