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