diff --git a/.gitignore b/.gitignore index b2ca9e2..b0c841c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,19 @@ target debug.out .DS_Store +#Logfiles +SimpleLogFile.txt +udpReaderBackup.txt + +#tempfiles +.idea/ +out/ + +#targetfiles - mvn wrapper +target/ + +#builds +build/ + +#zip files for local backups +*.zip \ No newline at end of file diff --git a/src/main/java/kst4contest/ApplicationConstants.java b/src/main/java/kst4contest/ApplicationConstants.java index c4f9193..6aa35e6 100644 --- a/src/main/java/kst4contest/ApplicationConstants.java +++ b/src/main/java/kst4contest/ApplicationConstants.java @@ -1,6 +1,17 @@ package kst4contest; +import java.util.Random; + public class ApplicationConstants { + + /** + * default constructor generates runtime id + */ + ApplicationConstants() { + sessionRuntimeUniqueId = generateRuntimeId(); + }; + + public static int sessionRuntimeUniqueId = generateRuntimeId(); /** * Name of the Application. */ @@ -9,7 +20,7 @@ public class ApplicationConstants { /** * Name of file to store preferences in. */ - public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.263; + public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.33; public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://do5amf.funkerportal.de/kst4ContestVersionInfo.xml"; public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml"; @@ -21,6 +32,23 @@ public class ApplicationConstants { public static final String DISCSTRING_DISCONNECT_DUE_PAWWORDERROR = "JUSTDSICCAUSEPWWRONG"; public static final String DISCSTRING_DISCONNECTONLY = "ONLYDISCONNECT"; - public static final String DISCONNECT_RDR_POISONPILL = "POISONPILL_KILLTHREAD"; //whereever a (blocking) udp or tcp reader in an infinite loop gets this message, it will break this loop +// public static final String DISCONNECT_RDR_POISONPILL = "POISONPILL_KILLTHREAD: " + sessionRuntimeUniqueId; //whereever a (blocking) udp or tcp reader in an infinite loop gets this message, it will break this loop + public static final String DISCONNECT_RDR_POISONPILL = "UNKNOWN: KST4C KILL POISONPILL_KILLTHREAD=: " + sessionRuntimeUniqueId; //whereever a (blocking) udp or tcp reader in an infinite loop gets this message, it will break this loop + + public static final String AUTOANSWER_PREFIX = "[KST4C Automsg] "; // hard-coded marker (user can't remove it) + + + + /** + * generates a unique runtime id per session. Its used to feed the poison pill in order to kill only this one and + * only instance if the program and not multiple instances + * @return + */ + public static int generateRuntimeId() { + + Random ran = new Random(); + + return ran.nextInt(6) + 100; + } } diff --git a/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java b/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java index 8756613..64b1d58 100644 --- a/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java +++ b/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java @@ -41,10 +41,25 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask { // String prefix_asWatchList = "ASWATCHLIST: \"KST\" \"AS\" "; //working original String prefix_asSetpath ="ASSETPATH: \"" + this.client.getChatPreferences().getAirScout_asClientNameString() + "\" \"" + this.client.getChatPreferences().getAirScout_asServerNameString() + "\" "; - String prefix_asWatchList = "ASWATCHLIST:\" "+ this.client.getChatPreferences().getAirScout_asClientNameString()+ "\" \"" + this.client.getChatPreferences().getAirScout_asServerNameString() + "\" "; + String prefix_asWatchList = "ASWATCHLIST: \""+ this.client.getChatPreferences().getAirScout_asClientNameString()+ "\" \"" + this.client.getChatPreferences().getAirScout_asServerNameString() + "\" "; + + String bandString = "1440000"; //TODO: this must variable in case of higher bands! ... default: 1440000 +// String myCallAndMyLocString = this.client.getChatPreferences().getStn_loginCallSign() + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //before fix 1.266 + + + String ownCallSign = this.client.getChatPreferences().getStn_loginCallSign(); + try { + if (this.client.getChatPreferences().getStn_loginCallSign().contains("-")) { + ownCallSign = this.client.getChatPreferences().getStn_loginCallSign().split("-")[0]; + } else { + ownCallSign = this.client.getChatPreferences().getStn_loginCallSign(); + } + } catch (Exception e) { + System.out.println("[ASPERIODICAL, Error]: " + e.getMessage()); + } + String myCallAndMyLocString = ownCallSign + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //bugfix, Airscout do not process 9A1W-2 but 9A1W like formatted calls + - String bandString = "1440000"; - String myCallAndMyLocString = this.client.getChatPreferences().getStn_loginCallSign() + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); String suffix = ""; //"FOREIGNCALL,FOREIGNLOC " -- dont forget the space at the end!!! String asWatchListString = prefix_asWatchList + bandString + "," + myCallAndMyLocString; String asWatchListStringSuffix = asWatchListString; @@ -70,10 +85,9 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask { for (ChatMember i : ary_threadSafeChatMemberArray) { - - if (i.getQrb() < this.client.getChatPreferences().getStn_maxQRBDefault()) //Here: check if maximum distance to the chatmember is reached, only ask AS if distance is lower! + //this counts for AS request and Aswatchlist { suffix = i.getCallSign() + "," + i.getQra() + " "; diff --git a/src/main/java/kst4contest/controller/BeaconTask.java b/src/main/java/kst4contest/controller/BeaconTask.java index 95054c9..8086226 100644 --- a/src/main/java/kst4contest/controller/BeaconTask.java +++ b/src/main/java/kst4contest/controller/BeaconTask.java @@ -1,8 +1,10 @@ package kst4contest.controller; +import java.util.Arrays; import java.util.TimerTask; import kst4contest.model.ChatMessage; +import kst4contest.model.ThreadStateMessage; /** * This class is for sending beacons intervalled to the public chat. Gets all @@ -20,16 +22,23 @@ import kst4contest.model.ChatMessage; public class BeaconTask extends TimerTask { private ChatController chatController; + private ThreadStatusCallback callBackToController; + private String ThreadNickName = "MyBeacon"; - public BeaconTask(ChatController client) { - + public BeaconTask(ChatController client, ThreadStatusCallback callback) { + this.callBackToController = callback; this.chatController = client; } @Override public void run() { - Thread.currentThread().setName("BeaconTask"); + + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + + Thread.currentThread().setName("BeaconTask"); + ChatMessage beaconMSG = new ChatMessage(); @@ -75,8 +84,12 @@ public class BeaconTask extends TimerTask { + " [BeaconTask, Info]: Sending CQ: " + beaconMSG.getMessageText()); this.chatController.getMessageTXBus().add(beaconMSG); + threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 1", true, "on", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + } else { - //do nothing, CQ is disabled + threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 1", false, "off", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); } /** @@ -94,14 +107,15 @@ public class BeaconTask extends TimerTask { + " [BeaconTask, Info]: Sending CQ 2nd Cat: " + beaconMSG2.getMessageText()); this.chatController.getMessageTXBus().add(beaconMSG2); + threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 2", true, "on", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + } else { - //do nothing, CQ is disabled + threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 2", false, "off", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); } } - - - } } diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index 65c6b98..36ab72e 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -6,19 +6,30 @@ import java.nio.channels.Selector; import java.sql.SQLException; import java.time.OffsetDateTime; import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; +import javafx.application.Platform; +import javafx.beans.property.*; import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.collections.transformation.FilteredList; import javafx.collections.transformation.SortedList; import kst4contest.ApplicationConstants; +import kst4contest.controller.interfaces.PstRotatorEventListener; +import kst4contest.locatorUtils.DirectionUtils; +import kst4contest.logic.PriorityCalculator; import kst4contest.model.*; +import kst4contest.test.MockKstServer; import kst4contest.utils.PlayAudioUtils; +import kst4contest.view.Kst4ContestApplication; import java.io.*; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import java.util.function.Predicate; /** @@ -29,7 +40,7 @@ import java.util.function.Predicate; * SINGLETON * */ -public class ChatController { +public class ChatController implements ThreadStatusCallback, PstRotatorEventListener { /** * Chat selection ? 50/70 MHz..............1 144/432 MHz............2 @@ -39,9 +50,23 @@ public class ChatController { * MHz................12 Your choice : * */ -// private int category = ChatCategory.VUHF; - private UpdateInformation updateInformation; + private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint + + + private PstRotatorClient rotatorClient; + private Consumer viewRotorCallback; + + private Kst4ContestApplication view; //effectively final, for recoupling of the controller to the view + + private StatusUpdateListener statusListener; //update info interface for the threads + + + public void setView(Kst4ContestApplication view) { + this.view = view; + } + + private UpdateInformation updateInformation; private ChatPreferences chatPreferences; private ChatCategory chatCategoryMain; @@ -52,7 +77,7 @@ public class ChatController { boolean disconnectionPerformedByUser = false; - public boolean isDisconnectionPerformedByUser() { + public boolean isDisconnectionPerformedByUser() { return disconnectionPerformedByUser; } @@ -109,15 +134,367 @@ public class ChatController { this.disconnected = disconnected; } - public void airScout_SendAsShowPathPacket(ChatMember remoteChatMember) { + + public StatusUpdateListener getStatusListener() { + return statusListener; + } + + public void setStatusListener(StatusUpdateListener statusListener) { + this.statusListener = statusListener; + } + + @Override + public void onThreadStatus(String threadName, ThreadStateMessage threadStateMessage) { + // Weiterleiten an die View + if (statusListener != null) { + statusListener.onThreadStatusChanged(threadName, threadStateMessage); + } else System.out.println("ERRRRRRRRRRRRRRRRRRRRRRRRRRRÖRRRRRRRRRRRRRRRRRRR"); + } + + + /******************************************************************************** + * PSTRotator controlling + *******************************************************************************/ + + + + + public void initRotor() { + // Beispiel: PSTRotator läuft lokal auf Port 12060 + // Der Client wird automatisch auf 12061 hören. + rotatorClient = new PstRotatorClient("127.0.0.1", 12000, this, this); //TODO: IP anpassen, Port auch aus den prefs holen, default 12000 + + // Startet den Thread und das Polling + rotatorClient.start(); + } + + /** + * sets rotator to "AZ DEGREE" by button click

+ * Note that there is a workaround for spid rotators:
+ * The AZ will be set, after 'time' secs it will be controlled if the rotator started, If not, the rotator will
+ * be homed to 0 deg for very shord period, then the AZ value will be set again. + *
+ * @param azimuth + */ + public void rotateTo(double azimuth) { + + double beforeRotateAzWas = chatPreferences.getActualQTF().getValue(); + + if (rotatorClient != null) { + rotatorClient.setTrackingMode(false); + System.out.println("Chatcontroller, Info: turning ant to " + azimuth + " by user request"); + rotatorClient.setAzimuth(azimuth); + + Object lockDelay = new Object(); + synchronized (lockDelay) { + try{ + + TimeUnit.SECONDS.sleep(2);; //wait 2s, then check if rotator does anything due SPID + // sometimes does simply not accept a rotating value for first try! + } catch (InterruptedException e) { + + } + } + + if (chatPreferences.getActualQTF().getValue() == beforeRotateAzWas) { + rotatorClient.setAzimuth(0); //do some reset + rotatorClient.setAzimuth(azimuth); //then rotate + } + + } + } + + /** + * Called when an external logger (Win-Test or UCXLog interface) reports that a QSO was logged. + * + * Process goal: + * 1) Detect whether the logged station is *still active (QRV)* on at least one *other* band + * that is enabled for "my station" (stn_bandActive[Band]) AND not worked yet (worked144/432/...). + * 2) If yes: trigger an on-screen hint (blinking status button) and play the existing sked-notification sound. + * 3) Request a score recompute so the station can become visible again (optional boost is applied in PriorityCalculator). + * + * IMPORTANT: + * - We do NOT use ChatMember.worked (UI-only filter flag) for scoring decisions. + * - We only use per-band worked flags (worked144, worked432, ...). + * - "QRV on band" is derived from recent entries in ChatMember.knownActiveBands. + */ + public void onExternalLogEntryReceived(String callSignRaw) { + + if (callSignRaw == null || callSignRaw.isBlank()) return; + if (chatPreferences == null) return; + if (!chatPreferences.isNotify_bandUpgradeHintOnLogEnabled()) return; + + final String callRaw = normalizeCallRaw(callSignRaw); + + if (DEBUG_BAND_UPGRADE_HINT) { + System.out.println("[BandUpgradeHint] LOG received for call=" + callRaw); + } + + // 1) Determine which bands I am active on (configured at startup via stn_bandActive* flags) + EnumSet myEnabledBands = getMyEnabledBandsFromPrefs(chatPreferences); + if (myEnabledBands.isEmpty()) return; + + // 2) Determine which bands the station was recently seen active on (from Smart Frequency Extraction history) + final long now = System.currentTimeMillis(); + final long maxAgeMs = TimeUnit.MINUTES.toMillis(30); // keep consistent with "recent activity" semantics + EnumSet stationOfferedBands = collectStationOfferedBandsFromHistory(callRaw, now, maxAgeMs); + if (stationOfferedBands.isEmpty()) return; + + // 3) Keep only bands that I can actually work + stationOfferedBands.retainAll(myEnabledBands); + if (stationOfferedBands.isEmpty()) return; + + // 4) Determine already worked bands (per-band flags only) + EnumSet workedBands = collectWorkedBands(callRaw); + + // 5) Remaining bands = offered ∩ enabled - worked + EnumSet remainingBands = EnumSet.copyOf(stationOfferedBands); + remainingBands.removeAll(workedBands); + if (remainingBands.isEmpty()) return; + + if (DEBUG_BAND_UPGRADE_HINT) { + System.out.println("[BandUpgradeHint] call=" + callRaw + + " enabled=" + formatBandsHuman(myEnabledBands) + + " offered=" + formatBandsHuman(stationOfferedBands) + + " worked=" + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands)) + + " remaining=" + formatBandsHuman(remainingBands)); + } + + // 6) Build UI text (button + tooltip) + String remainingHuman = formatBandsHuman(remainingBands); + String shortText = "BAND+ " + callRaw + " " + remainingHuman; + + String tooltip = "Logged " + callRaw + ", but station is still QRV on additional band(s): " + + remainingHuman + + "\n(Enabled: " + formatBandsHuman(myEnabledBands) + + " | Worked: " + (workedBands.isEmpty() ? "-" : formatBandsHuman(workedBands)) + ")"; + + ThreadStateMessage msg = new ThreadStateMessage("BandUpgradeHint", true, tooltip, false); + msg.setRunningInformationTextDescription(shortText); + + // 7) Trigger status update -> View will blink a dedicated indicator button + onThreadStatus("BandUpgradeHint", msg); + + // 8) Sound (re-use existing sked notification sound) - respects global simple-sound flag + if (chatPreferences.isNotify_playSimpleSounds()) { + try { + getPlayAudioUtils().playNoiseLauncher('!'); // same as SkedReminderService + } catch (Exception e) { + System.out.println("[ChatController, warning]: failed to play band-upgrade hint sound: " + e.getMessage()); + } + } + + // 9) Make sure score reacts quickly (boost is applied in PriorityCalculator if enabled) + if (getScoreService() != null) { + getScoreService().requestRecompute("BandUpgradeHint"); + } + } + + /** Normalize callsign raw to a stable key for comparisons. */ + private static String normalizeCallRaw(String callRaw) { + return callRaw.trim().toUpperCase(Locale.ROOT); + } + + /** Helper: create enabled-band set from preferences. */ + private static EnumSet getMyEnabledBandsFromPrefs(ChatPreferences prefs) { + EnumSet s = EnumSet.noneOf(Band.class); + if (prefs.isStn_bandActive144()) s.add(Band.B_144); + if (prefs.isStn_bandActive432()) s.add(Band.B_432); + if (prefs.isStn_bandActive1240()) s.add(Band.B_1296); + if (prefs.isStn_bandActive2300()) s.add(Band.B_2320); + if (prefs.isStn_bandActive3400()) s.add(Band.B_3400); + if (prefs.isStn_bandActive5600()) s.add(Band.B_5760); + if (prefs.isStn_bandActive10G()) s.add(Band.B_10G); + return s; + } + + /** + * Helper: union of all recently detected "QRV on band" entries across *all* ChatMember instances + * having the same callSignRaw (because a callsign may exist multiple times with different categories). + */ + private EnumSet collectStationOfferedBandsFromHistory(String callRaw, long nowMs, long maxAgeMs) { + + EnumSet offered = EnumSet.noneOf(Band.class); + + synchronized (getLst_chatMemberList()) { + for (ChatMember cm : getLst_chatMemberList()) { + if (cm == null || cm.getCallSignRaw() == null) continue; + if (!normalizeCallRaw(cm.getCallSignRaw()).equals(callRaw)) continue; + + Map map = cm.getKnownActiveBands(); + if (map == null || map.isEmpty()) continue; + + for (Map.Entry e : map.entrySet()) { + if (e.getKey() == null || e.getValue() == null) continue; + + long age = nowMs - e.getValue().timestampEpoch; + if (age >= 0 && age <= maxAgeMs) { + offered.add(e.getKey()); + } + + if (DEBUG_BAND_UPGRADE_HINT) { + System.out.println("[BandUpgradeHint] history call=" + callRaw + + " band=" + e.getKey() + + " freq=" + e.getValue().frequency + + " ageMs=" + age); + } + + + } + } + } + return offered; + } + + /** + * Helper: union of per-band worked flags across all ChatMember instances for the same call. + * IMPORTANT: ChatMember.worked is UI-only and NOT used here. + */ + private EnumSet collectWorkedBands(String callRaw) { + + EnumSet worked = EnumSet.noneOf(Band.class); + + synchronized (getLst_chatMemberList()) { + for (ChatMember cm : getLst_chatMemberList()) { + if (cm == null || cm.getCallSignRaw() == null) continue; + if (!normalizeCallRaw(cm.getCallSignRaw()).equals(callRaw)) continue; + + if (cm.isWorked144()) worked.add(Band.B_144); + if (cm.isWorked432()) worked.add(Band.B_432); + if (cm.isWorked1240()) worked.add(Band.B_1296); + if (cm.isWorked2300()) worked.add(Band.B_2320); + if (cm.isWorked3400()) worked.add(Band.B_3400); + if (cm.isWorked5600()) worked.add(Band.B_5760); + if (cm.isWorked10G()) worked.add(Band.B_10G); + if (cm.isWorked24G()) worked.add(Band.B_24G); // optional, only if your Band enum supports it + } + } + return worked; + } + + private static String formatBandsHuman(EnumSet bands) { + if (bands == null || bands.isEmpty()) return "-"; + return bands.stream().map(ChatController::bandToHumanLabel).sorted().reduce((a, b) -> a + ", " + b).orElse("-"); + } + + private static String bandToHumanLabel(Band b) { + if (b == null) return "?"; + return switch (b) { + case B_144 -> "2m"; + case B_432 -> "70cm"; + case B_1296 -> "23cm"; + case B_2320 -> "13cm"; + case B_3400 -> "9cm"; + case B_5760 -> "6cm"; + case B_10G -> "3cm"; + case B_24G -> "1.2cm"; + default -> b.name(); + }; + } + + + + public void stopRotator() { + if (rotatorClient != null) { + rotatorClient.stop(); + } + } + + @Override + public void onAzimuthUpdate(double azimuth) { + // We are in the rotor client thread. JavaFX properties must be updated on the FX thread. + Runnable fxUpdate = () -> chatPreferences.getActualQTF().setValue(azimuth); + + if (Platform.isFxApplicationThread()) { + fxUpdate.run(); + } else { + Platform.runLater(fxUpdate); + } + } + + @Override + public void onElevationUpdate(double elevation) { +// System.out.println("Neue Elevation: " + elevation); + //not used in first version + } + + @Override + public void onModeUpdate(boolean isTracking) { +// System.out.println("Modus: " + (isTracking ? "Tracking" : "Manuell")); + //not used in first version + } + + @Override + public void onMessageReceived(String raw) { + // Logging + } + + + + + /** + * Helping + * @param targetCallSignRaw + * @param preferredCategory + * @param messageAfterCq + */ + public void queuePrivateCqMessage(String targetCallSignRaw, ChatCategory preferredCategory, String messageAfterCq) { + if (targetCallSignRaw == null || targetCallSignRaw.isBlank()) return; + + ChatCategory categoryToUse = preferredCategory; + if (categoryToUse == null) { + ChatCategory last = lastInboundCategoryByCallSignRaw.get(targetCallSignRaw.trim().toUpperCase()); + categoryToUse = (last != null) ? last : chatCategoryMain; + } + + String text = "/cq " + targetCallSignRaw.trim().toUpperCase() + " " + (messageAfterCq == null ? "" : messageAfterCq); + + ChatMessage msg = new ChatMessage(); + msg.setChatCategory(categoryToUse); + msg.setMessageText(text); + msg.setMessageDirectedToServer(false); + + messageTXBus.add(msg); + + // Metrics: treat this as an outbound ping + stationMetricsService.tryRecordOutboundCq(text, System.currentTimeMillis()); + + // Scoring should react quickly to outbound actions + if (scoreService != null) { + scoreService.requestRecompute("outbound-cq"); + } + } + + + + /** + * + * @param remoteChatMember with callsign of the foreign station + */ + + public void airScout_SendAsShowPathPacket(ChatMember remoteChatMember) { DatagramSocket dsocket; - String prefix_asSetpath ="ASSHOWPATH: \"KST\" \"AS\" "; + String prefix_asSetpath ="ASSHOWPATH: \""+ this.getChatPreferences().getAirScout_asClientNameString()+ "\" \"" + this.getChatPreferences().getAirScout_asServerNameString() + "\" "; + +// String prefix_asSetpath ="ASSHOWPATH: \"KST\" \"AS\" "; Old hard coded String bandString = "1440000"; - String myCallAndMyLocString = chatPreferences.getStn_loginCallSign() + "," + chatPreferences.getStn_loginLocatorMainCat(); +// String myCallAndMyLocString = chatPreferences.getStn_loginCallSign() + "," + chatPreferences.getStn_loginLocatorMainCat(); // original b4 bugfix 1266 String remoteCallAndLocString = remoteChatMember.getCallSign() +"," + remoteChatMember.getQra(); + String ownCallSign=""; + try { + if (chatPreferences.getStn_loginCallSign().contains("-")) { + ownCallSign = chatPreferences.getStn_loginCallSign().split("-")[0]; + } else { + ownCallSign = chatPreferences.getStn_loginCallSign(); + } + } catch (Exception e) { + System.out.println("[ASPERIODICAL, Error]: " + e.getMessage()); + } + + String myCallAndMyLocString = ownCallSign + "," + chatPreferences.getStn_loginLocatorMainCat(); // original b4 bugfix 1266 String host = "255.255.255.255"; // int port = 9872; @@ -157,6 +534,38 @@ public class ChatController { } + /** + * starts the calculation scheduler for scores / priorities of skeds to be made + */ + private void startScoreScheduler() { + + if (scoreScheduler != null && !scoreScheduler.isShutdown()) return; + + scoreScheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r); + t.setName("ScoreServiceScheduler"); + t.setDaemon(true); + return t; + }); + + scoreScheduler.scheduleAtFixedRate(() -> { + try { + scoreService.tick(); + } catch (Exception e) { + System.err.println("[ChatController] CRITICAL ERROR in ScoreService tick:"); + e.printStackTrace(); + } + }, 1, 3, TimeUnit.SECONDS); + + scoreService.requestRecompute("startup"); + System.out.println("[ChatController] ScoreService scheduler started."); + } + + private void stopScoreScheduler() { + if (scoreScheduler != null) scoreScheduler.shutdownNow(); + scoreScheduler = null; + } + /** * Handles the disconnect of either the chat (Case DISCONNECTONLY) or the @@ -169,13 +578,17 @@ public class ChatController { */ public void disconnect(String action) { +// stopContextLoop(); //stops thread for calculating sked priorities + + stopScoreScheduler(); + this.dxClusterServer.stop(); this.setDisconnectionPerformedByUser(true); try { /** - * Kill UCX packetreader by sending poison pill to the reader thread + * Kill UCX and Wintest packetreader by sending poison pill to the reader threads */ DatagramSocket dsocket; @@ -185,13 +598,21 @@ public class ChatController { address = InetAddress.getByName("255.255.255.255"); DatagramPacket packet = new DatagramPacket(ApplicationConstants.DISCONNECT_RDR_POISONPILL.getBytes(), ApplicationConstants.DISCONNECT_RDR_POISONPILL.length(), address, port); - dsocket = new DatagramSocket(); + DatagramPacket killWintestReaderPacket = new DatagramPacket(ApplicationConstants.DISCONNECT_RDR_POISONPILL.getBytes(), ApplicationConstants.DISCONNECT_RDR_POISONPILL.length(), address, chatPreferences.getLogsynch_wintestNetworkPort()); + + dsocket = new DatagramSocket(); dsocket.setBroadcast(true); dsocket.send(packet); -// dsocket.send(packet); dsocket.close(); + dsocket = new DatagramSocket(); + dsocket.setBroadcast(true); + dsocket.send(killWintestReaderPacket); + dsocket.close(); + readUDPbyUCXThread.interrupt(); + stopWintestUdpListener(); + } catch (Exception error) { System.out.println("Chatcrontroller, ERROR: unable to send poison pill to ucxThread"); @@ -220,6 +641,8 @@ public class ChatController { if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE)) { +// rotatorClient. + this.lst_chatMemberList.clear();; this.lst_clusterMemberList.clear(); @@ -232,8 +655,8 @@ public class ChatController { keepAliveTimer.purge(); ChatMessage killThreadPoisonPillMsg = new ChatMessage(); - killThreadPoisonPillMsg.setMessageText("POISONPILL_KILLTHREAD"); - killThreadPoisonPillMsg.setMessageSenderName("POISONPILL_KILLTHREAD"); + killThreadPoisonPillMsg.setMessageText(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + killThreadPoisonPillMsg.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL); messageRXBus.clear(); messageTXBus.clear(); @@ -260,6 +683,9 @@ public class ChatController { messageProcessor.interrupt(); readUDPbyUCXThread.interrupt(); + stopWintestUdpListener(); + + airScoutUDPReaderThread.interrupt(); @@ -267,6 +693,9 @@ public class ChatController { dxClusterServer.stop(); + rotatorClient.stopRotor(); + rotatorClient.stop(); + try { if (socket != null) { @@ -295,8 +724,8 @@ public class ChatController { keepAliveTimer.purge(); ChatMessage killThreadPoisonPillMsg = new ChatMessage(); - killThreadPoisonPillMsg.setMessageText("POISONPILL_KILLTHREAD"); - killThreadPoisonPillMsg.setMessageSenderName("POISONPILL_KILLTHREAD"); + killThreadPoisonPillMsg.setMessageText(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + killThreadPoisonPillMsg.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL); messageRXBus.clear(); messageTXBus.clear(); @@ -320,6 +749,8 @@ public class ChatController { // messageProcessor.interrupt(); readUDPbyUCXThread.interrupt(); //need poisonpill? + stopWintestUdpListener(); + airScoutUDPReaderThread.interrupt(); //need poisonpill? // dbHandler.closeDBConnection(); @@ -344,6 +775,100 @@ public class ChatController { } +// private ObservableList activeSkeds = FXCollections.observableArrayList(); +// public ObservableList getActiveSkeds() { +// return activeSkeds; +// } + + // SIGNAL: Ein Property, das wir hochzählen, um der GUI zu sagen "Daten haben sich geändert" +// private LongProperty uiRefreshSignal = new SimpleLongProperty(0); +// public LongProperty uiRefreshSignalProperty() { +// return uiRefreshSignal; +// } + +// public void addSked(ContestSked sked) { +// Platform.runLater(() -> { +// this.activeSkeds.add(sked); +// runContextLoopCycle(); // Trigger sofort +// }); +// } + +// private PriorityCalculator priorityCalculator = new PriorityCalculator(); +// private ScheduledExecutorService contextLoopService; + + + private ObservableList activeSkeds = + FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); + + public ObservableList getActiveSkeds() { + return activeSkeds; + } + + /** + * Priority score pipeline (replaces the former 1-second ContextLoop). + */ + private final Map lastInboundCategoryByCallSignRaw = + new java.util.concurrent.ConcurrentHashMap<>(); + + private final ScoreService scoreService = new ScoreService(this, new PriorityCalculator(), 15); + private ScheduledExecutorService scoreScheduler; + private final StationMetricsService stationMetricsService = new StationMetricsService(); + private final SkedReminderService skedReminderService = new SkedReminderService(this); + + + public ScoreService getScoreService() { + return scoreService; + } + + public void addSked(ContestSked sked) { + Platform.runLater(() -> { + this.activeSkeds.add(sked); + scoreService.requestRecompute("sked-added"); + }); //TODO: Addsked muss noch genutzt werden + } + + public StationMetricsService getStationMetricsService() { + return stationMetricsService; + } + + public SkedReminderService getSkedReminderService() { + return skedReminderService; + } + + /** + * saves the last recognized chat category for a chatmember, for example when we seen a message + * @param callSignRaw + * @param category + */ + public void rememberLastInboundCategory(String callSignRaw, ChatCategory category) { + if (callSignRaw == null || category == null) return; + lastInboundCategoryByCallSignRaw.put(callSignRaw.trim().toUpperCase(), category); + } + + public Map snapshotLastInboundCategoryMap() { + return new HashMap<>(lastInboundCategoryByCallSignRaw); + } + + public List snapshotChatMembers() { + synchronized (getLst_chatMemberList()) { + return new ArrayList<>(getLst_chatMemberList()); + } + } + + public List snapshotActiveSkeds() { + synchronized (activeSkeds) { + return new ArrayList<>(activeSkeds); + } + } + + public void requestRemoveExpiredSkeds(long nowEpochMs) { + Platform.runLater(() -> { + synchronized (activeSkeds) { + activeSkeds.removeIf(sked -> (nowEpochMs - sked.getSkedTimeEpoch()) > 300_000); + } + }); + } + private String userName; private String password; private String showedName; @@ -351,12 +876,14 @@ public class ChatController { private String chatState; - private String hostname = "109.90.0.130"; +// private String hostname = "109.90.0.130"; + private String hostname; // private String praktiKSTVersion = "praktiKST 1.0"; private String praktiKSTVersionInfo = "2022-10 - 2022-12\ndeveloped by DO5AMF, Marc\nContact: praktimarc@gmail.com\nDonations via paypal are welcome"; - private int port = 23001; // kst4contest.test 4 23001 + private int port = 23001; // kst4contest.test 4 23001 //TODO: auslagern in Chatprefs private ReadUDPbyUCXMessageThread readUDPbyUCXThread; + private ReadUDPByWintestThread readUDPByWintestThread; private WriteThread writeThread; private ReadThread readThread; private InputReaderThread consoleReader; @@ -366,13 +893,13 @@ public class ChatController { private ReadUDPbyAirScoutMessageThread airScoutUDPReaderThread; private DXClusterThreadPooledServer dxClusterServer; - private PlayAudioUtils playAudioUtils = new PlayAudioUtils(); public PlayAudioUtils getPlayAudioUtils() { return playAudioUtils; } + private TimerTask userActualizationTask; private TimerTask keepAliveMessageSenderTask; @@ -419,6 +946,7 @@ public class ChatController { // mine private FilteredList lst_toOtherMessageList = new FilteredList<>(lst_globalChatMessageList); + private ObservableList lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList(); /** * we do some trick here with the chatmemberlist to not make it neccessary to change all boolean properties if the * chatmember object to observables. We trigger the list for changes on an object which we change whenever a list @@ -428,6 +956,7 @@ public class ChatController { // in chat private ObservableList chatMemberList = FXCollections.observableArrayList(); // List of active stations + private ObservableList lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List // of active stn in chat private FilteredList lst_chatMemberListFiltered = new FilteredList(chatMemberList); @@ -441,6 +970,10 @@ public class ChatController { // ****************************************************************************************************************************************** + + + + /** * checks if the callsign-String of a given chatmember instance and a given list * instance is in the list (multiple entries are possible to find by this method!
@@ -460,7 +993,7 @@ public class ChatController { // System.out.println("[ChatCtrl] ERROR: null Value for Chatmember detected! Member cannot be in the list!"); return resultingIndexes; - } else if (lookForThis.getCallSign() == null) { + } else if (lookForThis.getCallSignRaw() == null) { System.out.println("[ChatCtrl] ERROR: null Value in Callsign detected! Member cannot be in the list!"); return resultingIndexes; } @@ -468,8 +1001,8 @@ public class ChatController { for (Iterator iterator = lst_chatMemberList.iterator(); iterator.hasNext();) { ChatMember chatMember = (ChatMember) iterator.next(); if (chatMember.getCallSignRaw().equals(lookForThis.getCallSignRaw())) { //Change for stations with -2 or -70 in logincallsign - System.out - .println("chtctrlr: Found raw " + chatMember.getCallSignRaw() + " // " + lookForThis.getCallSign()); +// System.out +// .println("chtctrlr: Found raw " + chatMember.getCallSignRaw() + " // " + lookForThis.getCallSign()); resultingIndexes.add(lst_chatMemberList.indexOf(chatMember)); @@ -481,37 +1014,45 @@ public class ChatController { } - /** - * checks if the callsign-String of a given chatmember instance and a given list - * instance is in the list. If yes, returns the index in the List, if not, - * returns -1. - * - * @param lookForThis - * @return Integer (index), -1 for not found - */ - public int checkListForChatMemberIndexByCallSign(ChatMember lookForThis) { - if (lookForThis == null) { + public void fireUserListUpdate(String reason) { + if (statusListener != null) { + // Da UI Updates im JavaFX Thread passieren müssen, hier oder im Listener Platform.runLater nutzen + statusListener.onUserListUpdated(reason); + } + } -// System.out.println("[ChatCtrl] ERROR: null Value for Chatmember detected! Member cannot be in the list!"); - return -1; - } else if (lookForThis.getCallSign() == null) { - System.out.println("[ChatCtrl] ERROR: null Value in Callsign detected! Member cannot be in the list!"); - return -1; - } - - for (Iterator iterator = lst_chatMemberList.iterator(); iterator.hasNext();) { - ChatMember chatMember = (ChatMember) iterator.next(); -// if (chatMember.getCallSign().equals(lookForThis.getCallSign())) { - if (chatMember.getCallSignRaw().equals(lookForThis.getCallSignRaw())) { //TODO: Change for stations with -2 or -70 in logincallsign -// System.out -// .println("chtctrlr: Found raw " + chatMember.getCallSignRaw() + " // " + lookForThis.getCallSign()); - - return lst_chatMemberList.indexOf(chatMember); - } else { - - } - } +// /** +// * checks if the callsign-String of a given chatmember instance and a given list +// * instance is in the list. If yes, returns the index in the List, if not, +// * returns -1. +// * +// * @param lookForThis +// * @return Integer (index), -1 for not found +// */ +// public int checkListForChatMemberIndexByCallSign(ChatMember lookForThis) { +// +// if (lookForThis == null) { +// +//// System.out.println("[ChatCtrl] ERROR: null Value for Chatmember detected! Member cannot be in the list!"); +// return -1; +// } else if (lookForThis.getCallSign() == null) { +// System.out.println("[ChatCtrl] ERROR: null Value in Callsign detected! Member cannot be in the list!"); +// return -1; +// } +// +// for (Iterator iterator = lst_chatMemberList.iterator(); iterator.hasNext();) { +// ChatMember chatMember = (ChatMember) iterator.next(); +//// if (chatMember.getCallSign().equals(lookForThis.getCallSign())) { +// if (chatMember.getCallSignRaw().equals(lookForThis.getCallSignRaw())) { //TODO: Change for stations with -2 or -70 in logincallsign +//// System.out +//// .println("chtctrlr: Found raw " + chatMember.getCallSignRaw() + " // " + lookForThis.getCallSign()); +// +// return lst_chatMemberList.indexOf(chatMember); +// } else { +// +// } +// } /** * At this point we know, the callsign is not active in the chat. */ @@ -522,9 +1063,9 @@ public class ChatController { // System.out.println(lst_chatMemberList.indexOf(lookForThis) + ": " + chatMember.getCallSign()); // } - return -1; +// return -1; - } +// } public FilteredList getLst_selectedCallSignInfofilteredMessageList() { return lst_selectedCallSignInfofilteredMessageList; @@ -685,7 +1226,7 @@ public class ChatController { // this.category = ChatCategory.VUHF; this.userName = ownChatMemberObject.getName(); - this.hostname = "www.on4kst.info"; +// this.hostname = "www.on4kst.org"; this.port = port; } @@ -695,46 +1236,29 @@ public class ChatController { * * @param setOwnChatMemberObject */ - public ChatController(ChatMember setOwnChatMemberObject) { + public ChatController(ChatMember setOwnChatMemberObject,StatusUpdateListener listener) { super(); + chatPreferences = new ChatPreferences(); + chatPreferences.readPreferencesFromXmlFile(); +// this.statusListener = listener; + + String dnsFromPrefs = chatPreferences.getStn_on4kstServersDns(); + if (dnsFromPrefs != null && !dnsFromPrefs.isEmpty()) { + this.hostname = dnsFromPrefs; + } else { + this.hostname = "109.90.0.130"; + } + UpdateChecker checkForUpdates = new UpdateChecker(this); if (checkForUpdates.downloadLatestVersionInfoXML()) { updateInformation = checkForUpdates.parseUpdateXMLFile(); }; - lst_toMeMessageList.setPredicate(new Predicate() { - @Override - public boolean test(ChatMessage chatMessage) { + initLst_toMeMessageList(); - try { - if (chatMessage.getReceiver().getCallSign().equals(getChatPreferences().getStn_loginCallSign())) { - return true; //messages addressed to you - } - if ((chatMessage.getSender().getCallSign().equals(getChatPreferences().getStn_loginCallSign())) && (!chatMessage.getReceiver().getCallSign().equals("ALL"))){ - return true; //your own echo except texts to all (CQ) - } - - String ignoreCaseString = chatMessage.getMessageText(); - - if ((chatMessage.getMessageText().contains(chatPreferences.getStn_loginCallSign().toLowerCase()) || (chatMessage.getMessageText().contains(chatPreferences.getStn_loginCallSign().toUpperCase()))) - && (!chatMessage.getSender().getCallSign().equals(getChatPreferences().getStn_loginCallSign()))) { - return true; //if someone writes about you, you will get the mail, too, except you are the sender...! - } - - else { - return false; - } - } - catch (Exception nullPointerExc) { - nullPointerExc.printStackTrace(); - System.out.println("ChatController, <<>>: maybe the receiver was null, message received b4 user entered chatmessage...!" + nullPointerExc.getMessage()); - return false; - } - } - }); lst_toAllMessageList.setPredicate(new Predicate() { @Override @@ -767,7 +1291,7 @@ public class ChatController { } else return false; } catch (Exception nullPointerExc) { - nullPointerExc.printStackTrace(); +// nullPointerExc.printStackTrace(); System.out.println("ChatController, <<>>: maybe the receiver was null!"); return false; } @@ -776,8 +1300,8 @@ public class ChatController { dbHandler = new DBController(); - chatPreferences = new ChatPreferences(); - chatPreferences.readPreferencesFromXmlFile(); // set the praktikst Prefs by file or default if file is corrupted +// chatPreferences = new ChatPreferences(); +// chatPreferences.readPreferencesFromXmlFile(); // set the praktikst Prefs by file or default if file is corrupted chatCategoryMain = chatPreferences.getLoginChatCategoryMain(); chatCategorySecondChat = chatPreferences.getLoginChatCategorySecond(); @@ -788,12 +1312,160 @@ public class ChatController { // this.userName = ownChatMemberObject.getName(); // this.password = ownChatMemberObject.getPassword(); - this.hostname = "www.on4kst.info"; - this.port = port; + this.hostname = this.getChatPreferences().getStn_on4kstServersDns(); //default: www.on4kst.org + } + + private void initLst_toMeMessageList() { +// ObservableList sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList(); + + Predicate chatFilterPredicate = chatMessage -> { + // Sicherheits-Checks gegen NullPointer (statt try-catch) + if (chatMessage == null || chatMessage.getSender() == null || chatMessage.getReceiver() == null) { + return false; + } + + String myCallSign = getChatPreferences().getStn_loginCallSign(); + String senderCall = chatMessage.getSender().getCallSign(); + String receiverCall = chatMessage.getReceiver().getCallSign(); + String msgText = chatMessage.getMessageText(); + + // --- NEUE LOGIK: Sniffer Liste prüfen --- + // Wenn Absender ODER Empfänger in der Beobachtungsliste stehen -> Anzeigen + if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) || + lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) && + (!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) { + + msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText; + chatMessage.setMessageText(msgText); + return true; + } + + // --- BESTEHENDE LOGIK --- + + // 1. Nachrichten direkt an dich + if (receiverCall.equals(myCallSign)) { + return true; + } + + // 2. Deine eigenen Nachrichten (außer an ALL) + if (senderCall.equals(myCallSign) && !receiverCall.equals("ALL")) { + return true; + } + + // 3. Mentions im Text (jemand schreibt über dich) + // Nur prüfen, wenn Text nicht null ist und du nicht selbst der Absender bist + if (msgText != null && !senderCall.equals(myCallSign)) { + // containsIgnoreCase Logik (etwas robuster als deine Variante) + if (msgText.toLowerCase().contains(myCallSign.toLowerCase())) { + return true; + } + } + + return false; + }; + + lstNotify_QSOSniffer_sniffedCallSignList.addListener((ListChangeListener) c -> { + +// System.out.println(c.toString()); + + // Wir zwingen die FilteredList zum Neuscannen, indem wir das Prädikat neu setzen. + lst_toMeMessageList.setPredicate(null); // kurz resetten (manchmal nötig in älteren JavaFX Versionen) + lst_toMeMessageList.setPredicate(chatFilterPredicate); + + }); + + lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB"); + + + lst_toMeMessageList.setPredicate(chatFilterPredicate); //sniffed callsign filter predicate is here! + +// lst_toMeMessageList.setPredicate(new Predicate() { +// @Override +// public boolean test(ChatMessage chatMessage) { +// +// try { +// +// if (chatMessage.getReceiver().getCallSign().equals(getChatPreferences().getStn_loginCallSign())) { +// return true; //messages addressed to you +// } +// if ((chatMessage.getSender().getCallSign().equals(getChatPreferences().getStn_loginCallSign())) && (!chatMessage.getReceiver().getCallSign().equals("ALL"))){ +// return true; //your own echo except texts to all (CQ) +// } +// +// String ignoreCaseString = chatMessage.getMessageText(); +// +// if ((chatMessage.getMessageText().contains(chatPreferences.getStn_loginCallSign().toLowerCase()) || (chatMessage.getMessageText().contains(chatPreferences.getStn_loginCallSign().toUpperCase()))) +// && (!chatMessage.getSender().getCallSign().equals(getChatPreferences().getStn_loginCallSign()))) { +// return true; //if someone writes about you, you will get the mail, too, except you are the sender...! +// } +// +// else { +// return false; +// } +// } +// catch (Exception nullPointerExc) { +// nullPointerExc.printStackTrace(); +// System.out.println("ChatController, <<>>: maybe the receiver was null, message received b4 user entered chatmessage...!" + nullPointerExc.getMessage()); +// return false; +// } +// } +// }); + + + + } + + /** + * starts wintest udp listener thread + */ + public synchronized void startWintestUdpListener() { + if (readUDPByWintestThread != null && readUDPByWintestThread.isAlive()) { + return; + } + + readUDPByWintestThread = new ReadUDPByWintestThread(this, this); + readUDPByWintestThread.setName("readUDPByWintestThread"); + readUDPByWintestThread.start(); + + System.out.println("[ChatController] Win-Test UDP listener started."); } - public ChatPreferences getChatPreferences() { + /** + * stops wintest udp listener thread + */ + public synchronized void stopWintestUdpListener() { + if (readUDPByWintestThread == null) return; + + try { + readUDPByWintestThread.interrupt(); + } catch (Exception ignored) { } + + readUDPByWintestThread = null; + System.out.println("[ChatController] Win-Test UDP listener stopped."); + } + + /** + * restarts wintest udp listener thread + */ + public synchronized void restartWintestUdpListenerIfEnabled() { + stopWintestUdpListener(); + if (chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) { + startWintestUdpListener(); + } + } + + + + public ObservableList getLstNotify_QSOSniffer_sniffedCallSignList() { + return lstNotify_QSOSniffer_sniffedCallSignList; + } + + public void setLstNotify_QSOSniffer_sniffedCallSignList(ObservableList lstNotify_QSOSniffer_sniffedCallSignList) { + this.lstNotify_QSOSniffer_sniffedCallSignList = lstNotify_QSOSniffer_sniffedCallSignList; + } + + public ChatPreferences getChatPreferences() { return chatPreferences; } @@ -862,16 +1534,18 @@ public class ChatController { this.dbHandler = dbHandler; } + /** + * execute is the main entry point where the application starts. + * @throws InterruptedException + * @throws IOException + */ public void execute() throws InterruptedException, IOException { -// messageBus = new SimpleStringProperty("_____est connection"); - -// ObservableStringValue test = new SimpleStringProperty("test"); - -// eventBus = test; chatController = this; +// ApplicationConstants constants = new ApplicationConstants(); + // This block constructs a sample message // ChatMessage Test = new ChatMessage(); // Test.setMessage("kst4contest.test"); @@ -885,17 +1559,16 @@ public class ChatController { try { setDisconnectionPerformedByUser(false); -// dbHandler = new DBController(); //TODO: old place to instantiuate the dbcontroller + startScoreScheduler(); + //runs sked priority thread messageRXBus = new LinkedBlockingQueue(); messageTXBus = new LinkedBlockingQueue(); -// messageBus.add(""); - socket = new Socket(hostname, port);//socket for the on4kst chat server +// socket = new Socket(hostname, port);//socket for the on4kst chat server + socket = new Socket(chatController.chatPreferences.getStn_on4kstServersDns(), port);//socket for the on4kst chat server System.out.println("Connected to the chat server: " + socket.isConnected()); -// cluster_telnetServerSocket = new ServerSocket(8000); //TODO: Port customization have do be made - ByteBuffer buffer = ByteBuffer.allocate(1024); Selector selector = Selector.open(); @@ -910,37 +1583,42 @@ public class ChatController { writeThread.setName("Writethread-telnetwriter"); writeThread.start(); - readUDPbyUCXThread = new ReadUDPbyUCXMessageThread(chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort(), this); + readUDPbyUCXThread = new ReadUDPbyUCXMessageThread(chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort(), this, this); readUDPbyUCXThread.setName("readUDPbyUCXThread"); readUDPbyUCXThread.start(); - messageProcessor = new MessageBusManagementThread(this); + if (chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) { + startWintestUdpListener(); + } else { + System.out.println("[ChatController] Win-Test listener disabled by preference -> not starting."); + } + + messageProcessor = new MessageBusManagementThread(this, this); messageProcessor.setName("messagebusManagementThread"); messageProcessor.start(); -// airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread(chatPreferences.getAirScout_asCommunicationPort(), this, "AS", "KST"); //working original - airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread(chatPreferences.getAirScout_asCommunicationPort(), this, this.getChatPreferences().getAirScout_asServerNameString(), this.getChatPreferences().getAirScout_asServerNameString()); //working original + airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread(chatPreferences.getAirScout_asCommunicationPort(), this, this.getChatPreferences().getAirScout_asServerNameString(), this.getChatPreferences().getAirScout_asServerNameString(), this); //working original airScoutUDPReaderThread.setName("airscoutudpreaderThread"); airScoutUDPReaderThread.start(); - userActualizationtimer = new Timer(); userActualizationtimer.schedule(new UserActualizationTask(this), 4000, 60000);// TODO: Temporary userlistoutput known qrgs keepAliveTimer = new Timer(); keepAliveTimer.schedule(new keepAliveMessageSenderTask(this), 4000, 60000);// + if (chatPreferences.isStn_pstRotatorEnabled()) { + initRotor(); + } else { + System.out.println("[ChatController, info]: PSTRotator disabled by user preference -> not starting rotator client."); + } /** - * Since here: DX cluster service running config + * DX cluster service running config */ - dxClusterServer = new DXClusterThreadPooledServer(this.getChatPreferences().getNotify_dxclusterServerPort(), this); + dxClusterServer = new DXClusterThreadPooledServer(this.getChatPreferences().getNotify_dxclusterServerPort(), this, this); new Thread(dxClusterServer).start(); - /** - * Till here: DX cluster service running config - */ - this.setConnectedAndLoggedIn(true); @@ -950,7 +1628,7 @@ public class ChatController { */ // Timer beaconTimer; beaconTimer = new Timer(); - beaconTimer.schedule(new BeaconTask(this), 10000, + beaconTimer.schedule(new BeaconTask(this, this), 10000, this.getChatPreferences().getBcn_beaconIntervalInMinutesMainCat() * 60000); // 60000 * intervalInMinutes = IntervalInMillis @@ -988,12 +1666,12 @@ public class ChatController { System.out.println("[Chatcontroller, Warning: ] Socket closed or disconnected"); ChatMessage killThreadPoisonPillMsg = new ChatMessage(); - killThreadPoisonPillMsg.setMessageText("POISONPILL_KILLTHREAD"); - killThreadPoisonPillMsg.setMessageSenderName("POISONPILL_KILLTHREAD"); + killThreadPoisonPillMsg.setMessageText(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + killThreadPoisonPillMsg.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL); ChatMessage killThreadPoisonPillMsg2 = new ChatMessage(); - killThreadPoisonPillMsg2.setMessageText("POISONPILL_KILLTHREAD"); - killThreadPoisonPillMsg2.setMessageSenderName("POISONPILL_KILLTHREAD"); + killThreadPoisonPillMsg2.setMessageText(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + killThreadPoisonPillMsg2.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL); messageRXBus.add(killThreadPoisonPillMsg); @@ -1028,7 +1706,7 @@ public class ChatController { chatController.setWriteThread(new WriteThread(socket, chatController)); chatController.writeThread.start(); - messageProcessor = new MessageBusManagementThread(chatController); + messageProcessor = new MessageBusManagementThread(chatController, chatController); messageProcessor.start(); System.out.println("[Chatcontroller, info: initialized new socket, is connected? ] " @@ -1116,10 +1794,16 @@ public class ChatController { Thread.currentThread().setName("LoginStringTimer"); + //this is the original loginC without history abonnement +// String loginString = ""; +// loginString = "LOGINC|" + chatPreferences.getStn_loginCallSign() + "|" + chatPreferences.getStn_loginPassword() +// + "|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber() + "|praktiKST v" + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER +// + "|25|0|1|" + getCurrentEpochTime() + "|0|"; + String loginString = ""; loginString = "LOGINC|" + chatPreferences.getStn_loginCallSign() + "|" + chatPreferences.getStn_loginPassword() + "|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber() + "|praktiKST v" + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER - + "|25|0|1|" + getCurrentEpochTime() + "|0|"; + + "|25|0|1|" + "0" + "|0|"; // System.out.println(loginString); ChatMessage message = new ChatMessage(); @@ -1247,89 +1931,86 @@ public class ChatController { } - new Timer().schedule(new TimerTask() { - HashMap getWorkedDataFromDb; @Override public void run() { - Thread.currentThread().setName("fetchWorkedFromDBTimer"); - - try { - getWorkedDataFromDb = dbHandler.fetchChatMemberWkdDataFromDB(); - } catch (SQLException e) { - System.out.println("[Chatctrl, Error: ] got no worked data from DB due to communication error"); - } - - for (Iterator iterator = getLst_chatMemberList().iterator(); iterator.hasNext();) { - ChatMember chatMember = (ChatMember) iterator.next(); - System.out.println("[Chatctrl]: Marking ChatMembers wkd information: " - + getWorkedDataFromDb.get(chatMember.getCallSign()).getCallSign()); - chatMember.setWorked(getWorkedDataFromDb.get(chatMember.getCallSign()).isWorked()); - chatMember.setWorked144(getWorkedDataFromDb.get(chatMember.getCallSign()).isWorked144()); - ; - chatMember.setWorked432(getWorkedDataFromDb.get(chatMember.getCallSign()).isWorked432()); - ; - chatMember.setWorked1240(getWorkedDataFromDb.get(chatMember.getCallSign()).isWorked1240()); - ; - chatMember.setWorked2300(getWorkedDataFromDb.get(chatMember.getCallSign()).isWorked2300()); - ; - chatMember.setWorked3400(getWorkedDataFromDb.get(chatMember.getCallSign()).isWorked3400()); - ; - chatMember.setWorked5600(getWorkedDataFromDb.get(chatMember.getCallSign()).isWorked5600()); - ; - chatMember.setWorked10G(getWorkedDataFromDb.get(chatMember.getCallSign()).isWorked10G()); - /** - * v1.2 since here - * TODO: Change that, this ins not generative - */ - - chatMember.setQrv144(getWorkedDataFromDb.get(chatMember.getCallSign()).isQrv144()); - ; - chatMember.setQrv432(getWorkedDataFromDb.get(chatMember.getCallSign()).isQrv432()); - ; - chatMember.setQrv1240(getWorkedDataFromDb.get(chatMember.getCallSign()).isQrv1240()); - ; - chatMember.setQrv2300(getWorkedDataFromDb.get(chatMember.getCallSign()).isQrv2300()); - ; - chatMember.setQrv3400(getWorkedDataFromDb.get(chatMember.getCallSign()).isQrv3400()); - ; - chatMember.setQrv5600(getWorkedDataFromDb.get(chatMember.getCallSign()).isQrv5600()); - ; - chatMember.setQrv10G(getWorkedDataFromDb.get(chatMember.getCallSign()).isQrv10G()); - ; - } - - /** - * - * This creates the list of the worked stations which had to be displayed in the - * settings menu. TODO: May make this List editable - * - */ - - getWorkedDataFromDb.forEach((key, value) -> { - - chatController.getLst_DBBasedWkdCallSignList().add(value); - -// System.out.println("Key=" + key + ", Value=" + value); - }); - -// for (Iterator iterator = getWorkedDataFromDb.entrySet().iterator(); iterator.hasNext();) { -// ChatMember chatMember = (ChatMember) iterator.next(); -// getLst_DBBasedWkdCallSignList().add(chatMember); -// } - - /* Try the not exceptional way to iterate */ -// for (ChatMember chatMemberAvl : new ArrayList(getLst_chatMemberList())) { -// if (getWorkedDataFromDb.containsKey(chatMemberAvl.getCallSign())) { -// -// } -// } - + refreshWorkedStateAndDatabaseListFromDatabase(); } }, 10000); +// new Timer().schedule(new TimerTask() { +// HashMap getWorkedDataFromDb; +// +// @Override +// public void run() { +// +// Thread.currentThread().setName("fetchWorkedFromDBTimer"); +// +// try { +// getWorkedDataFromDb = dbHandler.fetchChatMemberWkdDataFromDB(); +// } catch (SQLException e) { +// System.out.println("[Chatctrl, Error: ] got no worked data from DB due to communication error"); +// } +// +// for (Iterator iterator = getLst_chatMemberList().iterator(); iterator.hasNext();) { +// +// +// ChatMember chatMember = (ChatMember) iterator.next(); +// System.out.println("[Chatctrl]: Marking ChatMembers wkd information: " +// + getWorkedDataFromDb.get(chatMember.getCallSign()).getCallSign()); +// chatMember.setWorked(getWorkedDataFromDb.get(chatMember.getCallSign()).isWorked()); +// chatMember.setWorked144(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked144()); +// ; +// chatMember.setWorked432(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked432()); +// ; +// chatMember.setWorked1240(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked1240()); +// ; +// chatMember.setWorked2300(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked2300()); +// ; +// chatMember.setWorked3400(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked3400()); +// ; +// chatMember.setWorked5600(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked5600()); +// ; +// chatMember.setWorked10G(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isWorked10G()); +// /** +// * v1.2 since here +// * TODO: Change that, this ins not generative +// */ +// +// chatMember.setQrv144(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv144()); +// ; +// chatMember.setQrv432(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv432()); +// ; +// chatMember.setQrv1240(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv1240()); +// ; +// chatMember.setQrv2300(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv2300()); +// ; +// chatMember.setQrv3400(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv3400()); +// ; +// chatMember.setQrv5600(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv5600()); +// ; +// chatMember.setQrv10G(getWorkedDataFromDb.get(chatMember.getCallSignRaw()).isQrv10G()); +// ; +// } +// +// /** +// * +// * This creates the list of the worked stations which had to be displayed in the +// * settings menu. TODO: May make this List editable +// * +// */ +// +// getWorkedDataFromDb.forEach((key, value) -> { +// +// chatController.getLst_DBBasedWkdCallSignList().add(value); +// +//// System.out.println("Key=" + key + ", Value=" + value); +// }); +// } +// }, 10000); + // message = new ChatMessage(); // message.setMessageText("MSG|2|0|/SETNAME " + ownChatMemberObject.getName() + "|0|\r"); // message.setMessageDirectedToServer(true); @@ -1340,20 +2021,109 @@ public class ChatController { } - - public void resetWorkedInfoInGuiLists() { - - this.chatController.getLst_chatMemberList().forEach( - chatMember -> chatMember.resetWorkedInformationAtAllBands()); - + /** + * Reloads the worked/not-QRV state from the internal database and applies the + * result both to the active chatmember list and to the database table list in the + * settings dialog. UI-bound list modifications are executed on the JavaFX thread. + */ + public void refreshWorkedStateAndDatabaseListFromDatabase() { + + HashMap workedDataFromDatabase; + + try { + workedDataFromDatabase = dbHandler.fetchChatMemberWkdDataFromDB(); + } catch (SQLException e) { + System.out.println("[Chatctrl, Error: ] got no worked data from DB due to communication error"); + e.printStackTrace(); + return; + } + + HashMap finalWorkedDataFromDatabase = workedDataFromDatabase; + + Platform.runLater(() -> { + helper_applyWorkedAndQrvStateFromDatabase(finalWorkedDataFromDatabase); + getLst_DBBasedWkdCallSignList().setAll(finalWorkedDataFromDatabase.values()); + fireUserListUpdate("Worked database state refreshed"); + }); } + /** + * Applies the worked and not-QRV state from the database snapshot to all active + * chatmember objects that are currently visible in the live chat list. + * + * @param workedDataFromDatabase map keyed by normalized raw callsign + */ + private void helper_applyWorkedAndQrvStateFromDatabase(HashMap workedDataFromDatabase) { + + for (Iterator iterator = getLst_chatMemberList().iterator(); iterator.hasNext();) { + + ChatMember activeChatMember = (ChatMember) iterator.next(); + ChatMember storedChatMemberState = workedDataFromDatabase.get(activeChatMember.getCallSignRaw()); + + if (storedChatMemberState == null) { + continue; + } + + activeChatMember.setWorked(storedChatMemberState.isWorked()); + activeChatMember.setWorked144(storedChatMemberState.isWorked144()); + activeChatMember.setWorked432(storedChatMemberState.isWorked432()); + activeChatMember.setWorked1240(storedChatMemberState.isWorked1240()); + activeChatMember.setWorked2300(storedChatMemberState.isWorked2300()); + activeChatMember.setWorked3400(storedChatMemberState.isWorked3400()); + activeChatMember.setWorked5600(storedChatMemberState.isWorked5600()); + activeChatMember.setWorked10G(storedChatMemberState.isWorked10G()); + activeChatMember.setQrv144(storedChatMemberState.isQrv144()); + activeChatMember.setQrv432(storedChatMemberState.isQrv432()); + activeChatMember.setQrv1240(storedChatMemberState.isQrv1240()); + activeChatMember.setQrv2300(storedChatMemberState.isQrv2300()); + activeChatMember.setQrv3400(storedChatMemberState.isQrv3400()); + activeChatMember.setQrv5600(storedChatMemberState.isQrv5600()); + activeChatMember.setQrv10G(storedChatMemberState.isQrv10G()); + } + } + + /** + * Resets all worked flags in the live GUI chatmember list. + */ + public void resetWorkedInfoInGuiLists() { + + this.chatController.getLst_chatMemberList().forEach( + chatMember -> chatMember.resetWorkedInformationAtAllBands()); + } + + /** + * Resets all not-QRV flags in the live GUI chatmember list. + */ public void resetQRVInfoInGuiLists() { this.chatController.getLst_chatMemberList().forEach( chatMember -> chatMember.resetQRVInformationAtAllBands()); - } + + /** + * Resets both worked and not-QRV flags in the live GUI chatmember list. + */ + public void resetWorkedAndQrvInfoInGuiLists() { + resetWorkedInfoInGuiLists(); + resetQRVInfoInGuiLists(); + } + + + + +// public void resetWorkedInfoInGuiLists() { +// +// this.chatController.getLst_chatMemberList().forEach( +// chatMember -> chatMember.resetWorkedInformationAtAllBands()); +// +// } +// +// public void resetQRVInfoInGuiLists() { +// +// this.chatController.getLst_chatMemberList().forEach( +// chatMember -> chatMember.resetQRVInformationAtAllBands()); +// +// } /** * Setting the initial parameters at the chat @@ -1402,4 +2172,54 @@ public class ChatController { // will done by another Thread } + public static final class UiReminderEvent { + private final String callSignRaw; + private final int minutesBefore; + private final long epochMs; + + public UiReminderEvent(String callSignRaw, int minutesBefore, long epochMs) { + this.callSignRaw = callSignRaw; + this.minutesBefore = minutesBefore; + this.epochMs = epochMs; + } + + public String getCallSignRaw() { return callSignRaw; } + public int getMinutesBefore() { return minutesBefore; } + public long getEpochMs() { return epochMs; } + } + + public void fireUiReminderEvent(String callSignRaw, int minutesBefore) { + final String raw = callSignRaw == null ? null : callSignRaw.trim().toUpperCase(); + final long now = System.currentTimeMillis(); + + // Ensure property updates happen on FX thread + if (Platform.isFxApplicationThread()) { + lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now)); + } else { + Platform.runLater(() -> lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now))); + } + } + + + private final ObjectProperty lastUiReminderEvent = new SimpleObjectProperty<>(null); + + public ReadOnlyObjectProperty lastUiReminderEventProperty() { + return lastUiReminderEvent; + } + + /** + * Helper method to check if a chatmember is in my beam(range) + * + * @param member + * @return + */ + public boolean isChatMemberInMyBeam(ChatMember member) { + if (member == null || member.getQTFdirection() == null) return false; + + double targetAz = member.getQTFdirection(); + double myAz = getChatPreferences().getActualQTF().get(); + double beamWidth = getChatPreferences().getStn_antennaBeamWidthDeg(); + + return DirectionUtils.isAngleInRange(targetAz, myAz, beamWidth); + } } \ No newline at end of file diff --git a/src/main/java/kst4contest/controller/DBController.java b/src/main/java/kst4contest/controller/DBController.java index f3d8955..893a747 100644 --- a/src/main/java/kst4contest/controller/DBController.java +++ b/src/main/java/kst4contest/controller/DBController.java @@ -7,6 +7,8 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; import kst4contest.ApplicationConstants; import kst4contest.model.ChatMember; @@ -20,26 +22,20 @@ public class DBController { public static final String DATABASE_FILE = "praktiKST.db"; /** - * Resource path for the database + * Resource path for the database. */ public static final String DATABASE_RESOURCE = "/praktiKST.db"; + /** + * Number of milliseconds after which worked/not-QRV data is considered outdated + * and therefore automatically reset. + */ + private static final long WORKED_DATA_EXPIRATION_IN_MILLISECONDS = 65L * 60L * 60L * 1000L; + private static final DBController dbcontroller = new DBController(); private static Connection connection; -// private static final String DB_PATH = System.getProperty("praktiKST.db"); private static String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE); -/* - static { - try { - Class.forName("org.sqlite.JDBC"); - } catch (ClassNotFoundException e) { - System.err.println("Fehler beim Laden des JDBC-Treibers"); - e.printStackTrace(); - } - } - */ - public DBController() { initDBConnection(); } @@ -49,18 +45,23 @@ public class DBController { } /** - * Closes the db connecttion and all statements + * Closes the database connection if it is still open. */ - public void closeDBConnection() { + public synchronized void closeDBConnection() { try { - this.connection.close(); + if (connection != null && !connection.isClosed()) { + connection.close(); + } } catch (SQLException e) { - // TODO Auto-generated catch block e.printStackTrace(); } } - private void initDBConnection() { + /** + * Initializes the SQLite connection and ensures that an existing legacy database + * is upgraded before normal runtime access starts. + */ + private synchronized void initDBConnection() { System.out.println("DBH: initiate new db connection"); @@ -70,22 +71,21 @@ public class DBController { DATABASE_RESOURCE, DATABASE_FILE ); - if (connection != null) + + if (connection != null && !connection.isClosed()) { return; + } + System.out.println("Creating Connection to Database..."); - DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE); - connection = DriverManager.getConnection("jdbc:sqlite:" + DB_PATH); - - //connection = DriverManager.getConnection("jdbc:sqlite:" + "C:\\Users\\prakt\\.praktiKST\\praktiKST.db"); - - - + DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE); + connection = DriverManager.getConnection("jdbc:sqlite:" + DB_PATH); System.out.println("[DBH, Info]: Path = " + DB_PATH); - if (!connection.isClosed()) + if (!connection.isClosed()) { System.out.println("...Connection established"); + } } catch (SQLException e) { throw new RuntimeException(e); } @@ -93,10 +93,12 @@ public class DBController { Runtime.getRuntime().addShutdownHook(new Thread() { public void run() { try { - if (!connection.isClosed() && connection != null) { + if (connection != null && !connection.isClosed()) { connection.close(); - if (connection.isClosed()) + + if (connection.isClosed()) { System.out.println("Connection to Database closed"); + } } } catch (SQLException e) { e.printStackTrace(); @@ -104,617 +106,727 @@ public class DBController { } }); - versionUpdateOfDBCheckAndChangeV11ToV12(); //TODO: newer version DB update should be called here - + ensureChatMemberTableCompatibility(); } /** - * While the first version of this software has other needs to the db tables than the 1.2 and following versions - * this method will check if the database file of the user is compatible and make it compatible if it´s not. - *
- * v1.1 -> v1.2: Chatmember entities will get additional fields for not-QRV-band-info - *
- * I check only the first field "notqrv144", if it does not exist, I creating all fields neccessarry for v1.2 + * Ensures that the ChatMember table exists, that all required columns are + * available for newer software versions, that existing old callsign keys are + * normalized to callsignRaw semantics and that outdated worked data is removed. */ - public void versionUpdateOfDBCheckAndChangeV11ToV12() { + private synchronized void ensureChatMemberTableCompatibility() { + createChatMemberTableIfRequired(); + versionUpdateOfDBCheckAndChangeV11ToV12(); + versionUpdateOfDBCheckAndChangeV12ToV13(); + normalizeStoredCallsignsToRawCallsigns(); + resetExpiredWorkedDataIfRequired(); + } - try { - Statement stmt = connection.createStatement(); - ResultSet rs = stmt.executeQuery( - "SELECT * FROM ChatMember where notQRV144 != 0;"); - stmt.close(); - } catch (SQLException ex) { + /** + * Creates the ChatMember table if it does not exist yet. This keeps the program + * robust even if the resource database was missing or replaced by a user. + */ + private synchronized void createChatMemberTableIfRequired() { - System.out.println("DBH, Info: updating DB fields for version change v1.1 -> v1.2"); + String createTableSql = + "CREATE TABLE IF NOT EXISTS ChatMember (" + + "callsign TEXT NOT NULL PRIMARY KEY, " + + "qra TEXT, " + + "name TEXT, " + + "lastActivityDateTime TEXT, " + + "worked BOOLEAN DEFAULT 0, " + + "worked144 BOOLEAN DEFAULT 0, " + + "worked432 BOOLEAN DEFAULT 0, " + + "worked1240 BOOLEAN DEFAULT 0, " + + "worked2300 BOOLEAN DEFAULT 0, " + + "worked3400 BOOLEAN DEFAULT 0, " + + "worked5600 BOOLEAN DEFAULT 0, " + + "worked10G BOOLEAN DEFAULT 0, " + + "notQRV144 BOOLEAN DEFAULT 0, " + + "notQRV432 BOOLEAN DEFAULT 0, " + + "notQRV1240 BOOLEAN DEFAULT 0, " + + "notQRV2300 BOOLEAN DEFAULT 0, " + + "notQRV3400 BOOLEAN DEFAULT 0, " + + "notQRV5600 BOOLEAN DEFAULT 0, " + + "notQRV10G BOOLEAN DEFAULT 0, " + + "lastFlagsChangeEpochMs INTEGER DEFAULT 0" + + ");"; - try { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(createTableSql); + } catch (SQLException e) { + throw new RuntimeException("[DBH, ERROR:] Could not create ChatMember table", e); + } + } - PreparedStatement ps = connection.prepareStatement( - "ALTER TABLE ChatMember ADD notQRV144 BOOLEAN DEFAULT 0" + ";"); - ps.addBatch(); - ps.executeBatch(); + /** + * Updates old v1.1 databases to the v1.2 schema by adding the not-QRV fields if + * they are missing. + */ + public synchronized void versionUpdateOfDBCheckAndChangeV11ToV12() { - ps = connection.prepareStatement( - "ALTER TABLE ChatMember ADD notQRV432 BOOLEAN DEFAULT 0" + ";"); - ps.addBatch(); - ps.executeBatch(); + try { + ensureColumnExists("ChatMember", "notQRV144", "BOOLEAN DEFAULT 0"); + ensureColumnExists("ChatMember", "notQRV432", "BOOLEAN DEFAULT 0"); + ensureColumnExists("ChatMember", "notQRV1240", "BOOLEAN DEFAULT 0"); + ensureColumnExists("ChatMember", "notQRV2300", "BOOLEAN DEFAULT 0"); + ensureColumnExists("ChatMember", "notQRV3400", "BOOLEAN DEFAULT 0"); + ensureColumnExists("ChatMember", "notQRV5600", "BOOLEAN DEFAULT 0"); + ensureColumnExists("ChatMember", "notQRV10G", "BOOLEAN DEFAULT 0"); + } catch (SQLException e) { + throw new RuntimeException("[DBH, ERROR:] Could not migrate database from v1.1 to v1.2", e); + } + } - ps = connection.prepareStatement( - "ALTER TABLE ChatMember ADD notQRV1240 BOOLEAN DEFAULT 0" + ";"); - ps.addBatch(); - ps.executeBatch(); + /** + * Updates old v1.2 databases to the v1.3 schema by adding a timestamp column + * which is used for automatic worked-data expiration. + */ + public synchronized void versionUpdateOfDBCheckAndChangeV12ToV13() { - ps = connection.prepareStatement( - "ALTER TABLE ChatMember ADD notQRV2300 BOOLEAN DEFAULT 0" + ";"); - ps.addBatch(); - ps.executeBatch(); - - ps = connection.prepareStatement( - "ALTER TABLE ChatMember ADD notQRV3400 BOOLEAN DEFAULT 0" + ";"); - ps.addBatch(); - ps.executeBatch(); - - ps = connection.prepareStatement( - "ALTER TABLE ChatMember ADD notQRV5600 BOOLEAN DEFAULT 0" + ";"); - ps.addBatch(); - ps.executeBatch(); - - ps = connection.prepareStatement( - "ALTER TABLE ChatMember ADD notQRV10G BOOLEAN DEFAULT 0" + ";"); - ps.addBatch(); - ps.executeBatch(); - - connection.setAutoCommit(false); - connection.setAutoCommit(true); - } catch (SQLException e) { - - } - } + try { + ensureColumnExists("ChatMember", "lastFlagsChangeEpochMs", "INTEGER DEFAULT 0"); + } catch (SQLException e) { + throw new RuntimeException("[DBH, ERROR:] Could not migrate database from v1.2 to v1.3", e); + } + } + /** + * Adds a missing column to an existing table. This method is used for safe schema + * upgrades on customer systems which still contain older database files. + * + * @param tableName table to inspect + * @param columnName column which must exist + * @param columnDefinition SQL definition used for ALTER TABLE + * @throws SQLException if the metadata lookup or ALTER TABLE fails + */ + private synchronized void ensureColumnExists(String tableName, String columnName, String columnDefinition) throws SQLException { + if (helper_checkIfColumnExists(tableName, columnName)) { + return; } -// private void handleDB() { -// try { -// Statement stmt = connection.createStatement(); -// stmt.executeUpdate("DROP TABLE IF EXISTS books;"); -// stmt.executeUpdate("CREATE TABLE books (author, title, publication, pages, price);"); -// stmt.execute( -// "INSERT INTO books (author, title, publication, pages, price) VALUES ('Paulchen Paule', 'Paul der Penner', " -// + Date.valueOf("2001-05-06") + ", '1234', '5.67')"); -// -// PreparedStatement ps = connection.prepareStatement("INSERT INTO books VALUES (?, ?, ?, ?, ?);"); -// -// ps.setString(1, "Willi Winzig"); -// ps.setString(2, "Willi's Wille"); -// ps.setDate(3, Date.valueOf("2011-05-16")); -// ps.setInt(4, 432); -// ps.setDouble(5, 32.95); -// ps.addBatch(); -// -// ps.setString(1, "Anton Antonius"); -// ps.setString(2, "Anton's Alarm"); -// ps.setDate(3, Date.valueOf("2009-10-01")); -// ps.setInt(4, 123); -// ps.setDouble(5, 98.76); -// ps.addBatch(); -// -// connection.setAutoCommit(false); -// ps.executeBatch(); -// connection.setAutoCommit(true); -// -// ResultSet rs = stmt.executeQuery("SELECT * FROM books;"); -// while (rs.next()) { -// System.out.println("Autor = " + rs.getString("author")); -// System.out.println("Titel = " + rs.getString("title")); -// System.out.println("Erscheinungsdatum = " + rs.getDate("publication")); -// System.out.println("Seiten = " + rs.getInt("pages")); -// System.out.println("Preis = " + rs.getDouble("price")); -// } -// rs.close(); -// connection.close(); -// } catch (SQLException e) { -// System.err.println("Couldn't handle DB-Query"); -// e.printStackTrace(); -// } -// } + System.out.println("DBH, Info: adding missing column " + columnName + " to table " + tableName); - /************************************************************** - * - * Stores a chatmember with its data to the database.
- * It will not insert a callsign entry, if that exists already but update - * locator, name and activity-timer. Callsign is unique and pk!
- *
- * Structure is like following
- * - * "callsign" TEXT NOT NULL UNIQUE,
- * "qra" TEXT,
- * "name" TEXT,
- * "lastActivityDateTime" TEXT,
- * "worked" BOOLEAN,
- * "worked144" BOOLEAN,
- * "worked432" BOOLEAN,
- * "worked1240" BOOLEAN,
- * "worked2300" BOOLEAN,
- * "worked3400" BOOLEAN,
- * "worked5600" BOOLEAN,
- * "worked10G" BOOLEAN,
- *
!!! since v1.2 there is a not-qrv info for each band, too !!! - * - * @throws SQLException + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("ALTER TABLE " + tableName + " ADD " + columnName + " " + columnDefinition + ";"); + } + } + + /** + * Checks via PRAGMA metadata whether a certain column is already available in the + * database. + * + * @param tableName table to inspect + * @param columnName column to look for + * @return true if the column exists already + * @throws SQLException if the metadata query fails */ - public void storeChatMember(ChatMember chatMemberToStore) throws SQLException { - try { - Statement stmt = connection.createStatement(); -// ResultSet rs = stmt.executeQuery( -// "SELECT * FROM ChatMember where callsign = '" + chatMemberToStore.getCallSign() + "';"); + private synchronized boolean helper_checkIfColumnExists(String tableName, String columnName) throws SQLException { -// if (!rs.next()) { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("PRAGMA table_info(" + tableName + ");")) { - PreparedStatement ps = connection.prepareStatement( - "INSERT OR IGNORE INTO ChatMember VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(callsign) DO UPDATE SET qra = '" - + chatMemberToStore.getQra() + "', name = '" + chatMemberToStore.getName() - + "', lastActivityDateTime = '" + chatMemberToStore.getLastActivity() - + "' where callsign = '" + chatMemberToStore.getCallSign() + "';"); + while (resultSet.next()) { + if (columnName.equalsIgnoreCase(resultSet.getString("name"))) { + return true; + } + } + } - ps.setString(1, chatMemberToStore.getCallSign()); // primary key! Not null! - ps.setString(2, chatMemberToStore.getQra()); - ps.setString(3, chatMemberToStore.getName()); - ps.setString(4, chatMemberToStore.getLastActivity().toString()); - ps.setInt(5, helper_booleanIntConverter(chatMemberToStore.isWorked())); - ps.setInt(6, helper_booleanIntConverter(chatMemberToStore.isWorked144())); - ps.setInt(7, helper_booleanIntConverter(chatMemberToStore.isWorked432())); - ps.setInt(8, helper_booleanIntConverter(chatMemberToStore.isWorked1240())); - ps.setInt(9, helper_booleanIntConverter(chatMemberToStore.isWorked2300())); - ps.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400())); - ps.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600())); - ps.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G())); - /** - * Here starts v1.2 - */ - ps.setInt(13, helper_booleanIntConverter(!chatMemberToStore.isQrv144())); - ps.setInt(14, helper_booleanIntConverter(!chatMemberToStore.isQrv432())); - ps.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv1240())); - ps.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv2300())); - ps.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv3400())); - ps.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv5600())); - ps.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv10G())); + return false; + } - ps.addBatch(); + /** + * Rebuilds the ChatMember table so that every stored row uses the normalized raw + * callsign as primary key. Old keys like "EA5/G8MBI/P" or "OK2M-70" are merged to + * "G8MBI" and "OK2M". This prevents duplicate logical stations and fixes legacy + * databases created by earlier software versions. + */ + private synchronized void normalizeStoredCallsignsToRawCallsigns() { - connection.setAutoCommit(false); - ps.executeBatch(); - connection.setAutoCommit(true); - stmt.close(); -// } else { + Map normalizedChatMembersByRawCallsign = new LinkedHashMap<>(); -// System.out.println("DBC: nothing to do"); - // Will not store the callsign entry in the database, it exists already -// } + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT * FROM ChatMember ORDER BY callsign ASC;")) { -// ResultSet rs = stmt.executeQuery("SELECT * FROM ChatMember;"); -// rs.close(); -// connection.close(); + while (resultSet.next()) { + ChatMember currentChatMemberFromDatabase = helper_buildChatMemberFromResultSet(resultSet); + String currentRawCallsign = currentChatMemberFromDatabase.getCallSignRaw(); + + if (currentRawCallsign == null || currentRawCallsign.isBlank()) { + continue; + } + + ChatMember existingNormalizedChatMember = normalizedChatMembersByRawCallsign.get(currentRawCallsign); + + if (existingNormalizedChatMember == null) { + normalizedChatMembersByRawCallsign.put(currentRawCallsign, currentChatMemberFromDatabase); + } else { + helper_mergeChatMemberDatabaseState(existingNormalizedChatMember, currentChatMemberFromDatabase); + } + } + } catch (SQLException e) { + throw new RuntimeException("[DBH, ERROR:] Could not normalize stored callsigns", e); + } + + try (Statement deleteStatement = connection.createStatement()) { + deleteStatement.executeUpdate("DELETE FROM ChatMember;"); + } catch (SQLException e) { + throw new RuntimeException("[DBH, ERROR:] Could not clear ChatMember table for callsign normalization", e); + } + + for (ChatMember normalizedChatMember : normalizedChatMembersByRawCallsign.values()) { + helper_upsertCompleteChatMemberRow(normalizedChatMember); + } + } + + /** + * Merges the database state of two rows that represent the same normalized raw + * callsign. Worked and not-QRV information is combined conservatively via logical + * OR so that no positive state is lost during migration. + * + * @param targetChatMember target row that remains after merge + * @param sourceChatMember source row that is merged into the target row + */ + private synchronized void helper_mergeChatMemberDatabaseState(ChatMember targetChatMember, ChatMember sourceChatMember) { + + if ((targetChatMember.getQra() == null || targetChatMember.getQra().isBlank()) + && sourceChatMember.getQra() != null && !sourceChatMember.getQra().isBlank()) { + targetChatMember.setQra(sourceChatMember.getQra()); + } + + if ((targetChatMember.getName() == null || targetChatMember.getName().isBlank()) + && sourceChatMember.getName() != null && !sourceChatMember.getName().isBlank()) { + targetChatMember.setName(sourceChatMember.getName()); + } + + if (targetChatMember.getLastActivity() == null && sourceChatMember.getLastActivity() != null) { + targetChatMember.setLastActivity(sourceChatMember.getLastActivity()); + } + + targetChatMember.setWorked(targetChatMember.isWorked() || sourceChatMember.isWorked()); + targetChatMember.setWorked144(targetChatMember.isWorked144() || sourceChatMember.isWorked144()); + targetChatMember.setWorked432(targetChatMember.isWorked432() || sourceChatMember.isWorked432()); + targetChatMember.setWorked1240(targetChatMember.isWorked1240() || sourceChatMember.isWorked1240()); + targetChatMember.setWorked2300(targetChatMember.isWorked2300() || sourceChatMember.isWorked2300()); + targetChatMember.setWorked3400(targetChatMember.isWorked3400() || sourceChatMember.isWorked3400()); + targetChatMember.setWorked5600(targetChatMember.isWorked5600() || sourceChatMember.isWorked5600()); + targetChatMember.setWorked10G(targetChatMember.isWorked10G() || sourceChatMember.isWorked10G()); + + targetChatMember.setQrv144(targetChatMember.isQrv144() && sourceChatMember.isQrv144()); + targetChatMember.setQrv432(targetChatMember.isQrv432() && sourceChatMember.isQrv432()); + targetChatMember.setQrv1240(targetChatMember.isQrv1240() && sourceChatMember.isQrv1240()); + targetChatMember.setQrv2300(targetChatMember.isQrv2300() && sourceChatMember.isQrv2300()); + targetChatMember.setQrv3400(targetChatMember.isQrv3400() && sourceChatMember.isQrv3400()); + targetChatMember.setQrv5600(targetChatMember.isQrv5600() && sourceChatMember.isQrv5600()); + targetChatMember.setQrv10G(targetChatMember.isQrv10G() && sourceChatMember.isQrv10G()); + + targetChatMember.setLastFlagsChangeEpochMs( + Math.max(targetChatMember.getLastFlagsChangeEpochMs(), sourceChatMember.getLastFlagsChangeEpochMs())); + } + + /** + * Removes outdated worked and not-QRV flags if their last change timestamp is + * older than the configured contest lifetime. + */ + public synchronized void resetExpiredWorkedDataIfRequired() { + + long expirationThresholdEpochMs = System.currentTimeMillis() - WORKED_DATA_EXPIRATION_IN_MILLISECONDS; + + String resetExpiredDataSql = + "UPDATE ChatMember SET " + + "worked = 0, " + + "worked144 = 0, " + + "worked432 = 0, " + + "worked1240 = 0, " + + "worked2300 = 0, " + + "worked3400 = 0, " + + "worked5600 = 0, " + + "worked10G = 0, " + + "notQRV144 = 0, " + + "notQRV432 = 0, " + + "notQRV1240 = 0, " + + "notQRV2300 = 0, " + + "notQRV3400 = 0, " + + "notQRV5600 = 0, " + + "notQRV10G = 0, " + + "lastFlagsChangeEpochMs = 0 " + + "WHERE lastFlagsChangeEpochMs > 0 AND lastFlagsChangeEpochMs < ?;"; + + try (PreparedStatement preparedStatement = connection.prepareStatement(resetExpiredDataSql)) { + preparedStatement.setLong(1, expirationThresholdEpochMs); + preparedStatement.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException("[DBH, ERROR:] Could not reset expired worked data", e); + } + } + + /** + * Stores a chatmember with its metadata in the database. The unique key is always + * the normalized raw callsign. Existing worked/not-QRV flags are preserved on + * conflicts so that a normal member refresh does not delete contest state. + * + * @param chatMemberToStore chatmember to insert or update + * @throws SQLException if the database write fails + */ + public synchronized void storeChatMember(ChatMember chatMemberToStore) throws SQLException { + + if (chatMemberToStore == null || chatMemberToStore.getCallSignRaw() == null || chatMemberToStore.getCallSignRaw().isBlank()) { + return; + } + + String insertOrUpdateSql = + "INSERT INTO ChatMember (" + + "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, " + + "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, lastFlagsChangeEpochMs" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(callsign) DO UPDATE SET " + + "qra = excluded.qra, " + + "name = excluded.name, " + + "lastActivityDateTime = excluded.lastActivityDateTime;"; + + long resolvedLastFlagsChangeEpochMs = helper_resolveLastFlagsChangeEpochMsForStore(chatMemberToStore); + + try (PreparedStatement preparedStatement = connection.prepareStatement(insertOrUpdateSql)) { + preparedStatement.setString(1, chatMemberToStore.getCallSignRaw()); + preparedStatement.setString(2, chatMemberToStore.getQra()); + preparedStatement.setString(3, chatMemberToStore.getName()); + preparedStatement.setString(4, chatMemberToStore.getLastActivity() == null ? null : chatMemberToStore.getLastActivity().toString()); + preparedStatement.setInt(5, helper_booleanIntConverter(chatMemberToStore.isWorked())); + preparedStatement.setInt(6, helper_booleanIntConverter(chatMemberToStore.isWorked144())); + preparedStatement.setInt(7, helper_booleanIntConverter(chatMemberToStore.isWorked432())); + preparedStatement.setInt(8, helper_booleanIntConverter(chatMemberToStore.isWorked1240())); + preparedStatement.setInt(9, helper_booleanIntConverter(chatMemberToStore.isWorked2300())); + preparedStatement.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400())); + preparedStatement.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600())); + preparedStatement.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G())); + preparedStatement.setInt(13, helper_booleanIntConverter(!chatMemberToStore.isQrv144())); + preparedStatement.setInt(14, helper_booleanIntConverter(!chatMemberToStore.isQrv432())); + preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv1240())); + preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv2300())); + preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv3400())); + preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv5600())); + preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv10G())); + preparedStatement.setLong(20, resolvedLastFlagsChangeEpochMs); + preparedStatement.executeUpdate(); } catch (SQLException e) { System.err.println("[DBH, ERROR:] Chatmember could not been stored."); e.printStackTrace(); -// connection.close(); //Todo commented out due to errors + throw e; } } - /************************************************************** - * - * This method does a select and build a hashmap of chatmembers whith all worked - * band data, which are stored at the database
- * Usage: one time after startup, for synching the live list with the stored - * list, e.g. after program ended
- *
- * Structure is like following
- * - * "callsign" TEXT NOT NULL UNIQUE,
- * "qra" TEXT,
- * "name" TEXT,
- * "lastActivityDateTime" TEXT,
- * "worked" BOOLEAN,
- * "worked144" BOOLEAN,
- * "worked432" BOOLEAN,
- * "worked1240" BOOLEAN,
- * "worked2300" BOOLEAN,
- * "worked3400" BOOLEAN,
- * "worked5600" BOOLEAN,
- * "worked10G" BOOLEAN,
- * - * @throws SQLException + /** + * Fetches all stored chatmember rows from the database and returns them in a map + * keyed by the normalized raw callsign. + * + * @return map of raw callsign to chatmember database state + * @throws SQLException if the database read fails */ - public HashMap fetchChatMemberWkdDataFromDB() throws SQLException { + public synchronized HashMap fetchChatMemberWkdDataFromDB() throws SQLException { - HashMap fetchedWorkeddata = new HashMap<>(); + resetExpiredWorkedDataIfRequired(); - try { - Statement stmt = connection.createStatement(); + HashMap fetchedWorkedData = new LinkedHashMap<>(); - ResultSet rs = stmt.executeQuery("SELECT * FROM ChatMember;"); - - ChatMember updateWkdData; - - while (rs.next()) { - - updateWkdData = new ChatMember(); - - updateWkdData.setCallSign(rs.getString("callsign")); - updateWkdData.setWorked(helper_IntToBooleanConverter(rs.getInt("worked"))); - updateWkdData.setWorked144(helper_IntToBooleanConverter(rs.getInt("worked144"))); - updateWkdData.setWorked432(helper_IntToBooleanConverter(rs.getInt("worked432"))); - updateWkdData.setWorked1240(helper_IntToBooleanConverter(rs.getInt("worked1240"))); - updateWkdData.setWorked2300(helper_IntToBooleanConverter(rs.getInt("worked2300"))); - updateWkdData.setWorked3400(helper_IntToBooleanConverter(rs.getInt("worked3400"))); - updateWkdData.setWorked5600(helper_IntToBooleanConverter(rs.getInt("worked5600"))); - updateWkdData.setWorked10G(helper_IntToBooleanConverter(rs.getInt("worked10G"))); - - /** - * v1.2 since here - */ - - updateWkdData.setQrv144(!helper_IntToBooleanConverter(rs.getInt("notQRV144"))); - updateWkdData.setQrv432(!helper_IntToBooleanConverter(rs.getInt("notQRV432"))); - updateWkdData.setQrv1240(!helper_IntToBooleanConverter(rs.getInt("notQRV1240"))); - updateWkdData.setQrv2300(!helper_IntToBooleanConverter(rs.getInt("notQRV2300"))); - updateWkdData.setQrv3400(!helper_IntToBooleanConverter(rs.getInt("notQRV3400"))); - updateWkdData.setQrv5600(!helper_IntToBooleanConverter(rs.getInt("notQRV5600"))); - updateWkdData.setQrv10G(!helper_IntToBooleanConverter(rs.getInt("notQRV10G"))); - - fetchedWorkeddata.put(updateWkdData.getCallSign(), updateWkdData); - -// System.out.println( -// "[DBH, Info:] providing callsign wkd info, wkd, 144, 432, ... : " + updateWkdData.toString()); + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT * FROM ChatMember ORDER BY callsign ASC;")) { + while (resultSet.next()) { + ChatMember updatedWorkedData = helper_buildChatMemberFromResultSet(resultSet); + fetchedWorkedData.put(updatedWorkedData.getCallSignRaw(), updatedWorkedData); } - stmt.close(); - rs.close(); - return fetchedWorkeddata; - -// connection.close(); + return fetchedWorkedData; } catch (SQLException e) { System.err.println("[DBH, ERROR:] Couldn't handle DB-Query"); e.printStackTrace(); - connection.close(); + throw e; } - return fetchedWorkeddata; // TODO: what to do if its empty? - } - /************************************************************** - * - * This method does a select and build a hashmap of chatmembers whith all worked - * band data, which are stored at the database for restore worked state in the - * chat after someone disconnected and reconnected.
- * Usage: MessagebusManagementThread, every time after a new User connects to - * the chat
- *
- * - * @return a modified version of the chatmember-object, which the method takes - * - * @throws SQLException + /** + * Fetches the worked and not-QRV state for a single chatmember from the database. + * The lookup is always performed with the normalized raw callsign. + * + * @param checkForThis chatmember instance that should receive the stored flags + * @return the same chatmember instance with updated flags */ - public ChatMember fetchChatMemberWkdDataForOnlyOneCallsignFromDB(ChatMember checkForThis) { + public synchronized ChatMember fetchChatMemberWkdDataForOnlyOneCallsignFromDB(ChatMember checkForThis) { - try { - Statement stmt = connection.createStatement(); - - ResultSet rs = stmt - .executeQuery("SELECT * FROM ChatMember where callsign = '" + checkForThis.getCallSign() + "' ;"); - -// System.out.println("DBH stmt: " + rs.getStatement().toString()); - - while (rs.next()) { - - checkForThis.setWorked(helper_IntToBooleanConverter(rs.getInt("worked"))); - checkForThis.setWorked144(helper_IntToBooleanConverter(rs.getInt("worked144"))); - checkForThis.setWorked432(helper_IntToBooleanConverter(rs.getInt("worked432"))); - checkForThis.setWorked1240(helper_IntToBooleanConverter(rs.getInt("worked1240"))); - checkForThis.setWorked2300(helper_IntToBooleanConverter(rs.getInt("worked2300"))); - checkForThis.setWorked3400(helper_IntToBooleanConverter(rs.getInt("worked3400"))); - checkForThis.setWorked5600(helper_IntToBooleanConverter(rs.getInt("worked5600"))); - checkForThis.setWorked10G(helper_IntToBooleanConverter(rs.getInt("worked10G"))); - - /** - * v1.2 since here - */ - - checkForThis.setQrv144(!helper_IntToBooleanConverter(rs.getInt("notQRV144"))); - checkForThis.setQrv432(!helper_IntToBooleanConverter(rs.getInt("notQRV432"))); - checkForThis.setQrv1240(!helper_IntToBooleanConverter(rs.getInt("notQRV1240"))); - checkForThis.setQrv2300(!helper_IntToBooleanConverter(rs.getInt("notQRV2300"))); - checkForThis.setQrv3400(!helper_IntToBooleanConverter(rs.getInt("notQRV3400"))); - checkForThis.setQrv5600(!helper_IntToBooleanConverter(rs.getInt("notQRV5600"))); - checkForThis.setQrv10G(!helper_IntToBooleanConverter(rs.getInt("notQRV10G"))); - -// System.out.println( -// "[DBH, Info:] providing callsign wkd info, wkd, 144, 432, ....... for UA5 new chatmember : " -// + checkForThis.toString()); - - } - -// rs.gets - - rs.close(); - stmt.close(); - + if (checkForThis == null || checkForThis.getCallSignRaw() == null || checkForThis.getCallSignRaw().isBlank()) { return checkForThis; + } -// connection.close(); + resetExpiredWorkedDataIfRequired(); + + String selectSingleChatMemberSql = "SELECT * FROM ChatMember WHERE callsign = ?;"; + + try (PreparedStatement preparedStatement = connection.prepareStatement(selectSingleChatMemberSql)) { + preparedStatement.setString(1, checkForThis.getCallSignRaw()); + + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (resultSet.next()) { + helper_copyWorkedAndQrvFlags(helper_buildChatMemberFromResultSet(resultSet), checkForThis); + } + } + + return checkForThis; } catch (SQLException e) { System.err.println("[DBH, ERROR:] Couldn't handle DB-Query"); e.printStackTrace(); - - try { - connection.close(); - } catch (SQLException e1) { - - e1.printStackTrace(); - } + return checkForThis; } - return checkForThis; // TODO: what to do if its empty? - } - /************************************************************** - * - * This method removes all worked band data for each callsign in the - * database.
- * Usage: User triggered after User clicked the reset-wkd button, may in each - * new contest period
- *
+ /** + * Removes all worked and not-QRV information from the database. The callsign rows + * remain in place so that the table view still shows known stations afterwards. * - * modified for work with v1.2 - * - * @return true if reset was successful - * - * @throws SQLException + * @return number of affected database rows, or -1 on error */ - public int resetWorkedDataInDB() { + public synchronized int resetWorkedDataInDB() { - try { - Statement stmt = connection.createStatement(); - - int affected = stmt.executeUpdate("update ChatMember set worked = 0, worked144 = 0, worked432 = 0, worked1240 = 0, worked2300 = 0, worked3400 = 0, worked5600 = 0, worked10G = 0" + - ", notQrv144 = 0, notQrv432 = 0, notQrv1240 = 0, notQrv2300 = 0, notQrv3400 = 0, notQrv5600 = 0, notQrv10G = 0;"); - - stmt.close(); - - return affected; + String resetAllWorkedDataSql = + "UPDATE ChatMember SET " + + "worked = 0, worked144 = 0, worked432 = 0, worked1240 = 0, worked2300 = 0, worked3400 = 0, worked5600 = 0, worked10G = 0, " + + "notQRV144 = 0, notQRV432 = 0, notQRV1240 = 0, notQRV2300 = 0, notQRV3400 = 0, notQRV5600 = 0, notQRV10G = 0, " + + "lastFlagsChangeEpochMs = 0;"; + try (Statement statement = connection.createStatement()) { + return statement.executeUpdate(resetAllWorkedDataSql); } catch (SQLException e) { System.err.println("[DBH, ERROR:] Couldn't reset the worked data"); e.printStackTrace(); - - try { - connection.close(); - } catch (SQLException e1) { - e1.printStackTrace(); - return -1; - } return -1; } -// return checkForThis; // TODO: what to do if its empty? - } - /************************************************************** - * - * Updates the worked-information for a chatmember at the db
- * It will not revert wkd info, only add wkd info for a band (only new worked - * bands will be stored, no worked info will be removed).
- * The wkd fields should be cleaned by the user at the begin of a new - * contest
- * - * // This will update the worked info on a worked chatmember. The DBHandler - * will check, if an entry at the db had been modified. If not, then the worked - * station had not been stored yet and have to be stored. The informations have - * to be stored, then by calling the store method of the DBHandler (it´s up - * to you, guy!)!
- *
- * - * @return true if an entry had been modified, false if not - * - * - *
- *
- * Structure is like following
- * - * "callsign" TEXT NOT NULL UNIQUE,
- * "qra" TEXT,
- * "name" TEXT,
- * "lastActivityDateTime" TEXT,
- * "worked" BOOLEAN,
- * "worked144" BOOLEAN,
- * "worked432" BOOLEAN,
- * "worked1240" BOOLEAN,
- * "worked2300" BOOLEAN,
- * "worked3400" BOOLEAN,
- * "worked5600" BOOLEAN,
- * "worked10G" BOOLEAN,
- * - * @throws SQLException + /** + * Updates the worked-information of a chatmember row. The method only succeeds if + * a row with the normalized raw callsign exists already. + * + * @param chatMemberToStore chatmember that contains the worked band information + * @return true if an existing row was updated, otherwise false + * @throws SQLException if the database write fails */ - public boolean updateWkdInfoOnChatMember(ChatMember chatMemberToStore) throws SQLException { - try { - Statement stmt = connection.createStatement(); -// stmt.close(); + public synchronized boolean updateWkdInfoOnChatMember(ChatMember chatMemberToStore) throws SQLException { - /** - * at first, mark the station as worked, always - */ - PreparedStatement ps = connection.prepareStatement("UPDATE ChatMember set worked = ? WHERE CallSign = ?"); + if (chatMemberToStore == null || chatMemberToStore.getCallSignRaw() == null || chatMemberToStore.getCallSignRaw().isBlank()) { + return false; + } - ps.setInt(1, 1); // 1st variable will be set - ps.setString(2, chatMemberToStore.getCallSign()); + String workedBandColumnName = helper_resolveWorkedBandColumnName(chatMemberToStore); - ps.addBatch(); + if (workedBandColumnName == null) { + System.out.println("[DBCtrl, Error]: unknown at which band the qso had been!"); + return false; + } - connection.setAutoCommit(false); - ps.executeBatch(); - connection.setAutoCommit(true); + String updateWorkedSql = + "UPDATE ChatMember SET worked = 1, " + workedBandColumnName + " = 1, lastFlagsChangeEpochMs = ? WHERE callsign = ?;"; - /** - * Then, handle the update information of received worked udp message - */ + try (PreparedStatement preparedStatement = connection.prepareStatement(updateWorkedSql)) { + preparedStatement.setLong(1, System.currentTimeMillis()); + preparedStatement.setString(2, chatMemberToStore.getCallSignRaw()); - String bandVariable = "worked"; + int affectedRows = preparedStatement.executeUpdate(); + return affectedRows > 0; + } catch (SQLException e) { + System.err.println("[DBH, ERROR:] Couldn't handle DB-Query"); + e.printStackTrace(); + throw e; + } + } - if (chatMemberToStore.isWorked144()) { - bandVariable = "worked144"; - } else if (chatMemberToStore.isWorked432()) { - bandVariable = "worked432"; - } else if (chatMemberToStore.isWorked1240()) { - bandVariable = "worked1240"; - } else if (chatMemberToStore.isWorked2300()) { - bandVariable = "worked2300"; - } else if (chatMemberToStore.isWorked3400()) { - bandVariable = "worked3400"; - } else if (chatMemberToStore.isWorked5600()) { - bandVariable = "worked5600"; - } else if (chatMemberToStore.isWorked10G()) { - bandVariable = "worked10G"; - } else { - System.out.println("[DBCtrl, Error]: unknown at which band the qso had been!"); + /** + * Updates all not-QRV flags for a chatmember row. The method uses the normalized + * raw callsign and updates the timestamp so that automatic contest cleanup can + * reset the flags later. + * + * @param chatMemberToStore chatmember with the not-QRV information to persist + * @return true if an existing row was updated or inserted + * @throws SQLException if the database write fails + */ + public synchronized boolean updateNotQRVInfoOnChatMember(ChatMember chatMemberToStore) throws SQLException { + + if (chatMemberToStore == null || chatMemberToStore.getCallSignRaw() == null || chatMemberToStore.getCallSignRaw().isBlank()) { + return false; + } + + String updateNotQrvSql = + "UPDATE ChatMember SET " + + "notQRV144 = ?, " + + "notQRV432 = ?, " + + "notQRV1240 = ?, " + + "notQRV2300 = ?, " + + "notQRV3400 = ?, " + + "notQRV5600 = ?, " + + "notQRV10G = ?, " + + "lastFlagsChangeEpochMs = ? " + + "WHERE callsign = ?;"; + + try (PreparedStatement preparedStatement = connection.prepareStatement(updateNotQrvSql)) { + preparedStatement.setInt(1, helper_booleanIntConverter(!chatMemberToStore.isQrv144())); + preparedStatement.setInt(2, helper_booleanIntConverter(!chatMemberToStore.isQrv432())); + preparedStatement.setInt(3, helper_booleanIntConverter(!chatMemberToStore.isQrv1240())); + preparedStatement.setInt(4, helper_booleanIntConverter(!chatMemberToStore.isQrv2300())); + preparedStatement.setInt(5, helper_booleanIntConverter(!chatMemberToStore.isQrv3400())); + preparedStatement.setInt(6, helper_booleanIntConverter(!chatMemberToStore.isQrv5600())); + preparedStatement.setInt(7, helper_booleanIntConverter(!chatMemberToStore.isQrv10G())); + preparedStatement.setLong(8, System.currentTimeMillis()); + preparedStatement.setString(9, chatMemberToStore.getCallSignRaw()); + + int affectedRows = preparedStatement.executeUpdate(); + + if (affectedRows == 0) { + chatMemberToStore.setLastFlagsChangeEpochMs(System.currentTimeMillis()); + storeChatMember(chatMemberToStore); } - PreparedStatement ps2 = connection - .prepareStatement("UPDATE ChatMember set " + bandVariable + " = ? WHERE CallSign = ?"); - - ps2.setInt(1, 1); // 1st variable will be set - ps2.setString(2, chatMemberToStore.getCallSign()); - - ps2.addBatch(); - - connection.setAutoCommit(false); - ps2.executeBatch(); - connection.setAutoCommit(true); - - stmt.close(); - - - System.out.println("updated count of cols: " + ps2.getUpdateCount()); - if (ps2.getUpdateCount() != 0) { - return true; - } else - return false; // no entry to update: user had not been stored at a chatmember in table -// } else { - -// System.out.println("DBC: nothing to do"); - // Will not store the callsign entry in the database, it exists already -// } - -// ResultSet rs = stmt.executeQuery("SELECT * FROM ChatMember;"); -// rs.close(); -// connection.close(); - + return true; } catch (SQLException e) { System.err.println("[DBH, ERROR:] Couldn't handle DB-Query"); e.printStackTrace(); - connection.close(); - return false; + throw e; } } - public boolean updateNotQRVInfoOnChatMember(ChatMember chatMemberToStore) throws SQLException { - try { - Statement stmt = connection.createStatement(); + /** + * Writes a complete ChatMember row including worked state and timestamp back into + * the database. This helper is used during migration when the complete table is + * rebuilt with normalized raw callsign keys. + * + * @param chatMemberToStore chatmember state to write completely + */ + private synchronized void helper_upsertCompleteChatMemberRow(ChatMember chatMemberToStore) { - /** - * at first, mark the station as worked, always - */ - PreparedStatement ps = connection.prepareStatement("UPDATE ChatMember set notQrv144 = ? WHERE CallSign = ?"); - - ps.setInt(1, helper_booleanIntConverter(!chatMemberToStore.isQrv144())); - ps.setString(2, chatMemberToStore.getCallSign()); - ps.addBatch(); - ps.executeBatch(); - - ps = connection.prepareStatement("UPDATE ChatMember set notQrv432 = ? WHERE CallSign = ?"); - - ps.setInt(1, helper_booleanIntConverter(!chatMemberToStore.isQrv432())); - ps.setString(2, chatMemberToStore.getCallSign()); - ps.addBatch(); - ps.executeBatch(); - - ps = connection.prepareStatement("UPDATE ChatMember set notQrv1240 = ? WHERE CallSign = ?"); - - ps.setInt(1, helper_booleanIntConverter(!chatMemberToStore.isQrv1240())); - ps.setString(2, chatMemberToStore.getCallSign()); - ps.addBatch(); - ps.executeBatch(); - - ps = connection.prepareStatement("UPDATE ChatMember set notQrv2300 = ? WHERE CallSign = ?"); - - ps.setInt(1, helper_booleanIntConverter(!chatMemberToStore.isQrv2300())); - ps.setString(2, chatMemberToStore.getCallSign()); - ps.addBatch(); - ps.executeBatch(); - - ps = connection.prepareStatement("UPDATE ChatMember set notQrv3400 = ? WHERE CallSign = ?"); - - ps.setInt(1, helper_booleanIntConverter(!chatMemberToStore.isQrv3400())); - ps.setString(2, chatMemberToStore.getCallSign()); - ps.addBatch(); - ps.executeBatch(); - - ps = connection.prepareStatement("UPDATE ChatMember set notQrv5600 = ? WHERE CallSign = ?"); - - ps.setInt(1, helper_booleanIntConverter(!chatMemberToStore.isQrv5600())); - ps.setString(2, chatMemberToStore.getCallSign()); - ps.addBatch(); - ps.executeBatch(); - - ps = connection.prepareStatement("UPDATE ChatMember set notQrv10G = ? WHERE CallSign = ?"); - - ps.setInt(1, helper_booleanIntConverter(!chatMemberToStore.isQrv10G())); - ps.setString(2, chatMemberToStore.getCallSign()); - ps.addBatch(); - ps.executeBatch(); - - connection.setAutoCommit(false); - connection.setAutoCommit(true); - - - stmt.close(); + String upsertCompleteRowSql = + "INSERT INTO ChatMember (" + + "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, " + + "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, lastFlagsChangeEpochMs" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(callsign) DO UPDATE SET " + + "qra = excluded.qra, " + + "name = excluded.name, " + + "lastActivityDateTime = excluded.lastActivityDateTime, " + + "worked = excluded.worked, " + + "worked144 = excluded.worked144, " + + "worked432 = excluded.worked432, " + + "worked1240 = excluded.worked1240, " + + "worked2300 = excluded.worked2300, " + + "worked3400 = excluded.worked3400, " + + "worked5600 = excluded.worked5600, " + + "worked10G = excluded.worked10G, " + + "notQRV144 = excluded.notQRV144, " + + "notQRV432 = excluded.notQRV432, " + + "notQRV1240 = excluded.notQRV1240, " + + "notQRV2300 = excluded.notQRV2300, " + + "notQRV3400 = excluded.notQRV3400, " + + "notQRV5600 = excluded.notQRV5600, " + + "notQRV10G = excluded.notQRV10G, " + + "lastFlagsChangeEpochMs = excluded.lastFlagsChangeEpochMs;"; + try (PreparedStatement preparedStatement = connection.prepareStatement(upsertCompleteRowSql)) { + preparedStatement.setString(1, chatMemberToStore.getCallSignRaw()); + preparedStatement.setString(2, chatMemberToStore.getQra()); + preparedStatement.setString(3, chatMemberToStore.getName()); + preparedStatement.setString(4, chatMemberToStore.getLastActivity() == null ? null : chatMemberToStore.getLastActivity().toString()); + preparedStatement.setInt(5, helper_booleanIntConverter(chatMemberToStore.isWorked())); + preparedStatement.setInt(6, helper_booleanIntConverter(chatMemberToStore.isWorked144())); + preparedStatement.setInt(7, helper_booleanIntConverter(chatMemberToStore.isWorked432())); + preparedStatement.setInt(8, helper_booleanIntConverter(chatMemberToStore.isWorked1240())); + preparedStatement.setInt(9, helper_booleanIntConverter(chatMemberToStore.isWorked2300())); + preparedStatement.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400())); + preparedStatement.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600())); + preparedStatement.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G())); + preparedStatement.setInt(13, helper_booleanIntConverter(!chatMemberToStore.isQrv144())); + preparedStatement.setInt(14, helper_booleanIntConverter(!chatMemberToStore.isQrv432())); + preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv1240())); + preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv2300())); + preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv3400())); + preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv5600())); + preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv10G())); + preparedStatement.setLong(20, chatMemberToStore.getLastFlagsChangeEpochMs()); + preparedStatement.executeUpdate(); } catch (SQLException e) { - System.err.println("[DBH, ERROR:] Couldn't handle DB-Query"); - e.printStackTrace(); - connection.close(); - return false; + throw new RuntimeException("[DBH, ERROR:] Could not rebuild normalized ChatMember row", e); } - return true; } + /** + * Builds a ChatMember object from a database result row. + * + * @param resultSet current database row + * @return chatmember filled with worked/not-QRV database state + * @throws SQLException if a column cannot be read + */ + private synchronized ChatMember helper_buildChatMemberFromResultSet(ResultSet resultSet) throws SQLException { + + ChatMember builtChatMember = new ChatMember(); + + builtChatMember.setCallSign(resultSet.getString("callsign")); + builtChatMember.setQra(resultSet.getString("qra")); + builtChatMember.setName(resultSet.getString("name")); + builtChatMember.setWorked(helper_IntToBooleanConverter(resultSet.getInt("worked"))); + builtChatMember.setWorked144(helper_IntToBooleanConverter(resultSet.getInt("worked144"))); + builtChatMember.setWorked432(helper_IntToBooleanConverter(resultSet.getInt("worked432"))); + builtChatMember.setWorked1240(helper_IntToBooleanConverter(resultSet.getInt("worked1240"))); + builtChatMember.setWorked2300(helper_IntToBooleanConverter(resultSet.getInt("worked2300"))); + builtChatMember.setWorked3400(helper_IntToBooleanConverter(resultSet.getInt("worked3400"))); + builtChatMember.setWorked5600(helper_IntToBooleanConverter(resultSet.getInt("worked5600"))); + builtChatMember.setWorked10G(helper_IntToBooleanConverter(resultSet.getInt("worked10G"))); + builtChatMember.setQrv144(!helper_IntToBooleanConverter(resultSet.getInt("notQRV144"))); + builtChatMember.setQrv432(!helper_IntToBooleanConverter(resultSet.getInt("notQRV432"))); + builtChatMember.setQrv1240(!helper_IntToBooleanConverter(resultSet.getInt("notQRV1240"))); + builtChatMember.setQrv2300(!helper_IntToBooleanConverter(resultSet.getInt("notQRV2300"))); + builtChatMember.setQrv3400(!helper_IntToBooleanConverter(resultSet.getInt("notQRV3400"))); + builtChatMember.setQrv5600(!helper_IntToBooleanConverter(resultSet.getInt("notQRV5600"))); + builtChatMember.setQrv10G(!helper_IntToBooleanConverter(resultSet.getInt("notQRV10G"))); + builtChatMember.setLastFlagsChangeEpochMs(resultSet.getLong("lastFlagsChangeEpochMs")); + + return builtChatMember; + } + + /** + * Copies the worked and not-QRV state from one ChatMember object to another one. + * + * @param sourceChatMember source state + * @param targetChatMember target object that should receive the state + */ + private synchronized void helper_copyWorkedAndQrvFlags(ChatMember sourceChatMember, ChatMember targetChatMember) { + + targetChatMember.setWorked(sourceChatMember.isWorked()); + targetChatMember.setWorked144(sourceChatMember.isWorked144()); + targetChatMember.setWorked432(sourceChatMember.isWorked432()); + targetChatMember.setWorked1240(sourceChatMember.isWorked1240()); + targetChatMember.setWorked2300(sourceChatMember.isWorked2300()); + targetChatMember.setWorked3400(sourceChatMember.isWorked3400()); + targetChatMember.setWorked5600(sourceChatMember.isWorked5600()); + targetChatMember.setWorked10G(sourceChatMember.isWorked10G()); + targetChatMember.setQrv144(sourceChatMember.isQrv144()); + targetChatMember.setQrv432(sourceChatMember.isQrv432()); + targetChatMember.setQrv1240(sourceChatMember.isQrv1240()); + targetChatMember.setQrv2300(sourceChatMember.isQrv2300()); + targetChatMember.setQrv3400(sourceChatMember.isQrv3400()); + targetChatMember.setQrv5600(sourceChatMember.isQrv5600()); + targetChatMember.setQrv10G(sourceChatMember.isQrv10G()); + targetChatMember.setLastFlagsChangeEpochMs(sourceChatMember.getLastFlagsChangeEpochMs()); + } + + /** + * Determines which worked-band column must be updated for a given chatmember. + * + * @param chatMemberToStore worked chatmember update + * @return database column name, or null if no worked band is marked + */ + private synchronized String helper_resolveWorkedBandColumnName(ChatMember chatMemberToStore) { + + if (chatMemberToStore.isWorked144()) { + return "worked144"; + } else if (chatMemberToStore.isWorked432()) { + return "worked432"; + } else if (chatMemberToStore.isWorked1240()) { + return "worked1240"; + } else if (chatMemberToStore.isWorked2300()) { + return "worked2300"; + } else if (chatMemberToStore.isWorked3400()) { + return "worked3400"; + } else if (chatMemberToStore.isWorked5600()) { + return "worked5600"; + } else if (chatMemberToStore.isWorked10G()) { + return "worked10G"; + } + + return null; + } + + /** + * Resolves the timestamp that should be written into the timestamp column when a + * complete row is inserted. Normal member metadata rows keep the value 0, while + * rows with existing worked/not-QRV state receive the current timestamp. + * + * @param chatMemberToStore row that is about to be inserted + * @return timestamp to write into the database + */ + private synchronized long helper_resolveLastFlagsChangeEpochMsForStore(ChatMember chatMemberToStore) { + + if (chatMemberToStore.getLastFlagsChangeEpochMs() > 0) { + return chatMemberToStore.getLastFlagsChangeEpochMs(); + } + + if (helper_hasAnyWorkedOrNotQrvState(chatMemberToStore)) { + return System.currentTimeMillis(); + } + + return 0L; + } + + /** + * Checks whether a ChatMember row currently contains any persisted worked or + * not-QRV information. + * + * @param chatMemberToStore chatmember to inspect + * @return true if any database-relevant state is set + */ + private synchronized boolean helper_hasAnyWorkedOrNotQrvState(ChatMember chatMemberToStore) { + return chatMemberToStore.isWorked() + || chatMemberToStore.isWorked144() + || chatMemberToStore.isWorked432() + || chatMemberToStore.isWorked1240() + || chatMemberToStore.isWorked2300() + || chatMemberToStore.isWorked3400() + || chatMemberToStore.isWorked5600() + || chatMemberToStore.isWorked10G() + || !chatMemberToStore.isQrv144() + || !chatMemberToStore.isQrv432() + || !chatMemberToStore.isQrv1240() + || !chatMemberToStore.isQrv2300() + || !chatMemberToStore.isQrv3400() + || !chatMemberToStore.isQrv5600() + || !chatMemberToStore.isQrv10G(); + } + + /** + * Converts a boolean value into the integer representation used in the SQLite + * table. + * + * @param convertToInt boolean value + * @return 1 for true, 0 for false + */ private int helper_booleanIntConverter(boolean convertToInt) { if (convertToInt) { return 1; - } else + } else { return 0; - + } } + /** + * Converts the integer representation from SQLite into a boolean value. + * + * @param valueFromDBField integer value from the database + * @return true if the value is not zero + */ private boolean helper_IntToBooleanConverter(int valueFromDBField) { -// System.out.println(">>>>>>>>>>>>>>> DBC: " + valueFromDBField + " -> "); - if (valueFromDBField != 0) { - System.out.println("true"); return true; - } else - System.out.println("false"); + } else { return false; - + } } + /** + * Small manual test entry point for local experiments on the database helper. + * + * @param args CLI arguments + * @throws SQLException if a database operation fails + */ public static void main(String[] args) throws SQLException { DBController dbc = DBController.getInstance(); -// dbc.initDBConnection(); ChatMember dummy = new ChatMember(); dummy.setCallSign("DM5M"); @@ -723,15 +835,7 @@ public class DBController { dummy.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat()); dummy.setWorked5600(true); -// dbc.versionUpdateOfDBCheckAndChangeV11ToV12(); -// dbc.fetchChatMemberNOTQRVBandInfoForOnlyOneCallsignFromDB(); -// dbc.updateNOTQRVBandInfoOnChatMember(); -// dummy.setWorked432(true); - -// dbc.storeChatMember(dummy); - -// dbc.updateWkdInfoOnChatMember(dummy); - -// dbc.handleDB(); + // dbc.storeChatMember(dummy); + // dbc.updateWkdInfoOnChatMember(dummy); } } diff --git a/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java b/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java index fcfa1c9..7b60164 100644 --- a/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java +++ b/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java @@ -2,6 +2,7 @@ package kst4contest.controller; import kst4contest.model.ChatMember; import kst4contest.model.ChatPreferences; +import kst4contest.model.ThreadStateMessage; import java.io.*; import java.net.ServerSocket; @@ -14,6 +15,8 @@ public class DXClusterThreadPooledServer implements Runnable{ private List clientSockets = Collections.synchronizedList(new ArrayList<>()); //list of all connected clients + private ThreadStatusCallback callBackToController; + private String ThreadNickName = "DXCluster-Server"; ChatController chatController = null; protected int serverPort = 8080; protected ServerSocket serverSocket = null; @@ -23,13 +26,17 @@ public class DXClusterThreadPooledServer implements Runnable{ Executors.newFixedThreadPool(10); Socket clientSocket; - public DXClusterThreadPooledServer(int port, ChatController chatController){ + public DXClusterThreadPooledServer(int port, ChatController chatController, ThreadStatusCallback callback){ this.serverPort = port; this.chatController = chatController; + this.callBackToController = callback; } public void run(){ + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + synchronized(this){ this.runningThread = Thread.currentThread(); runningThread.setName("DXCluster-thread-pooled-server"); @@ -53,7 +60,7 @@ public class DXClusterThreadPooledServer implements Runnable{ "Error accepting client connection", e); } - DXClusterServerWorkerRunnable worker = new DXClusterServerWorkerRunnable(clientSocket, "Thread Pooled DXCluster Server ", chatController, clientSockets); + DXClusterServerWorkerRunnable worker = new DXClusterServerWorkerRunnable(clientSocket, "Thread Pooled DXCluster Server ", chatController, clientSockets, chatController); this.threadPool.execute(worker); @@ -111,6 +118,7 @@ public class DXClusterThreadPooledServer implements Runnable{ for (Socket socket : clientSockets) { try { + OutputStream output = socket.getOutputStream(); String singleDXClusterMessage = "DX de "; @@ -134,6 +142,9 @@ public class DXClusterThreadPooledServer implements Runnable{ output.write((singleDXClusterMessage).getBytes()); + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "Last msg to " + clientSockets.size() + " Cluster Clients:\n" + singleDXClusterMessage, false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + } catch (IOException e) { e.printStackTrace(); System.out.println("[DXClusterSrvr, Error:] broadcasting DXC-message to clients went wrong!"); @@ -152,12 +163,16 @@ class DXClusterServerWorkerRunnable implements Runnable{ protected String serverText = null; private ChatController client = null; private List dxClusterClientSocketsConnectedList; + private ThreadStatusCallback callBackToController; + private String ThreadNickName = "DXCluster-Server"; - public DXClusterServerWorkerRunnable(Socket clientSocket, String serverText, ChatController chatController, List clientSockets) { + public DXClusterServerWorkerRunnable(Socket clientSocket, String serverText, ChatController chatController, List clientSockets, ThreadStatusCallback callback) { this.clientSocket = clientSocket; this.serverText = serverText; this.client = chatController; this.dxClusterClientSocketsConnectedList = clientSockets; + this.callBackToController = callback; + } public void run() { @@ -171,8 +186,12 @@ class DXClusterServerWorkerRunnable implements Runnable{ @Override public void run() { + StringBuilder connectedClients = new StringBuilder(); //only for statistics + for (Socket socket : dxClusterClientSocketsConnectedList) { + connectedClients.append(socket.getInetAddress()).append("\n"); + try { OutputStream output = socket.getOutputStream(); output.write(("\r\n").getBytes()); @@ -194,6 +213,9 @@ class DXClusterServerWorkerRunnable implements Runnable{ } } +// ThreadStateMessage threadStateMessage = new ThreadStateMessage(ThreadNickName, true, "Connected clients: " + connectedClients.toString(), false); +// callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + } }, 30000, 30000); diff --git a/src/main/java/kst4contest/controller/DXClusterThreadPooledServerTest.java b/src/main/java/kst4contest/controller/DXClusterThreadPooledServerTest.java index f24d5d5..be4c579 100644 --- a/src/main/java/kst4contest/controller/DXClusterThreadPooledServerTest.java +++ b/src/main/java/kst4contest/controller/DXClusterThreadPooledServerTest.java @@ -13,7 +13,7 @@ public class DXClusterThreadPooledServerTest { testPreferences.setStn_loginCallSign("DM5M"); client.setChatPreferences(testPreferences); - DXClusterThreadPooledServer dxClusterServer = new DXClusterThreadPooledServer(8000, client); + DXClusterThreadPooledServer dxClusterServer = new DXClusterThreadPooledServer(8000, client, client); new Thread(dxClusterServer).start(); diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/src/main/java/kst4contest/controller/MessageBusManagementThread.java index e446dbf..87356b6 100644 --- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -5,8 +5,10 @@ import java.io.PrintWriter; import java.sql.SQLException; //import java.net.Socket; //import java.util.ArrayList; +import java.util.ArrayList; import java.util.HashSet; import java.util.Hashtable; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -28,6 +30,9 @@ public class MessageBusManagementThread extends Thread { int index; + private String ThreadNickName = "MessageBus"; + private ThreadStatusCallback callBackToController; + private PrintWriter writer; // private Socket socket; private ChatController client; @@ -40,6 +45,15 @@ public class MessageBusManagementThread extends Thread { private final String PTRN_USERLISTENTRY = "([a-zA-Z0-9]{2}/{1})?([a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z]{0,3})(/p)? [a-zA-Z]{2}[0-9]{2}[a-zA-Z]{2} [ -~]{1,20}"; private final String PTRN_QRG_CAT2 = "(([0-9]{3,4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)"; private final String PTRN_QRG_CAT3 = "(([0-9]{3,5}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)"; + + + // ==== Autoanswer Flood/Pingpong Protection ==== + private static final String AUTOANSWER_PREFIX = ApplicationConstants.AUTOANSWER_PREFIX; // hard-coded marker (user can't remove it) + private static final long AUTOANSWER_COOLDOWN_MS = 45_000L; // 45_000L = 45s + + // Cooldown per opponent station (and ChatCategory) – only setted if this client sends + private final Hashtable lastLocalAutoAnswerPerRemoteMs = new Hashtable<>(); + // BufferedWriter bufwrtrDBGMSGOut; // private String text; @@ -60,10 +74,14 @@ public class MessageBusManagementThread extends Thread { this.serverReady = serverReady; } - public MessageBusManagementThread(ChatController client) { + public MessageBusManagementThread(ChatController client, ThreadStatusCallback callBack) { + this.callBackToController = callBack; this.client = client; + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + } /** @@ -182,6 +200,184 @@ public class MessageBusManagementThread extends Thread { return stringAggregation; } + /** + * Smart Frequency Parser (V1.32) + * Replaces the old RegEx logic. + * Features: + * 1. Handles full frequencies (144.210) and short forms (.210, 210). + * 2. Handles extended precision/weird formatting (144.210.10, 144,210,10). + * 3. Prioritizes USER CONTEXT (History) over GLOBAL CONTEXT (Preferences). + */ + private void smartFrequencyExtraction(ChatMessage message, ChatPreferences prefs) { + + // Regex Explanation: + // Part 1 (Full): Start (not digit), 3-5 digits, sep, 1-3 digits, OPTIONAL (sep, 1-3 digits) + // Matches: 144.210, 144.210.10, 10368.100 + // Part 2 (Short1): Start (not digit), sep, 3 digits, OPTIONAL (sep, 1-3 digits) + // Matches: .210, .210.10, ,210 + // Part 3 (Short2): Whitespace/Start, 3 digits, Whitespace/End + // Matches: " 210 ", " 144 " + String smartPattern = "(? 144.210.10) + foundRaw = foundRaw.replace(",", "."); + + double finalDetectedFrequency = 0.0; + Band finalDetectedBand = null; + boolean isShortForm = false; + + // --- STEP 1: Type Determination (Short or Full?) --- + + // Check if it starts with a dot (e.g. ".210") OR is just 3 digits ("210") + if (foundRaw.startsWith(".") || foundRaw.length() == 3) { + + // It is a short form. + // We strip the leading dot for calculation if present -> "210.10" or "210" + if (foundRaw.startsWith(".")) foundRaw = foundRaw.substring(1); + isShortForm = true; + + } else { + // It is a full frequency (e.g., 144.210.10 or 144.210) + try { + // Normalize "144.210.10" to "144.21010" for Double.parseDouble + String normalizedFull = normalizeFrequencyString(foundRaw); + + finalDetectedFrequency = Double.parseDouble(normalizedFull); + finalDetectedBand = Band.fromFrequency(finalDetectedFrequency); + } catch (NumberFormatException e) { continue; } + } + + // --- STEP 2: Context Resolution (Only needed for Short Forms) --- + if (isShortForm) { + + // A) HISTORY CHECK (Priority 1: What did THIS USER do recently?) + // We search for the most recent band where this short form makes physical sense. + long bestTimestamp = 0; + + // Iterate over all bands where the user is known + // (Assumption: ChatMember has a getter getKnownActiveBands()) + if (sender.getKnownActiveBands() != null) { + for (java.util.Map.Entry entry : sender.getKnownActiveBands().entrySet()) { + + Band candidateBand = entry.getKey(); + ChatMember.ActiveFrequencyInfo info = entry.getValue(); + + // Timeout Check: Info must not be older than 30 mins (1,800,000 ms) + if (System.currentTimeMillis() - info.timestampEpoch > 1800000) continue; + + // Try Reconstruction: Band Prefix + ShortForm + // Example: Band 144 (Prefix "144") + "." + "210.10" -> "144.210.10" + try { + String reconstructedStr = candidateBand.getPrefix() + "." + foundRaw; + String normalizedReconstruction = normalizeFrequencyString(reconstructedStr); + + double attemptFreq = Double.parseDouble(normalizedReconstruction); + + // Does this frequency fit into the candidate band? + if (candidateBand.isPlausible(attemptFreq)) { + // If we have multiple matches, pick the most recent one + if (info.timestampEpoch > bestTimestamp) { + finalDetectedFrequency = attemptFreq; + finalDetectedBand = candidateBand; + bestTimestamp = info.timestampEpoch; + } + } + } catch (Exception e) { /* Ignore parsing errors */ } + } + } + + // B) GLOBAL PREFERENCES CHECK (Priority 2: Fallback if history is empty/old) + if (finalDetectedBand == null) { + // Get standard band from prefs (e.g., "144" or "432") + String defaultPrefix = prefs.getNotify_optionalFrequencyPrefix().get(); + try { + String reconstructedStr = defaultPrefix + "." + foundRaw; + String normalizedReconstruction = normalizeFrequencyString(reconstructedStr); + + double attemptFreq = Double.parseDouble(normalizedReconstruction); + + // Check if this results in a valid amateur radio band + Band defaultBandCandidate = Band.fromFrequency(attemptFreq); + + if (defaultBandCandidate != null) { + finalDetectedFrequency = attemptFreq; + finalDetectedBand = defaultBandCandidate; + } + } catch (NumberFormatException e) { + // Number was likely not a frequency (e.g., "73" or "599") and didn't fit any band + continue; + } + } + } + + // --- STEP 3: Process Result --- + if (finalDetectedBand != null && finalDetectedFrequency > 0) { + + // 1. Store in the new Map (for future context/history) + + sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency); + + //propagate known frequency to all instances of the same callsign (callRaw may exist multiple times) + try { + ArrayList sameCallIdx = client.checkListForChatMemberIndexesByCallSign(sender); + for (int idx : sameCallIdx) { + ChatMember cm = client.getLst_chatMemberList().get(idx); + if (cm != null && cm != sender) { + cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency); + } + } + } catch (Exception e) { + System.out.println("[SmartParser, warning]: failed to propagate known frequency across duplicates: " + e.getMessage()); + } + + + // 2. Set the old String-Property for GUI compatibility + // We assume standard display format (MHz) + sender.setFrequency(new javafx.beans.property.SimpleStringProperty(String.valueOf(finalDetectedFrequency))); + + System.out.println("[SmartParser] Detected for " + sender.getCallSign() + ": " + + finalDetectedFrequency + " MHz (" + finalDetectedBand + ") " + + (isShortForm ? "[derived from " + foundRaw + "]" : "[full match]")); + + // Optional: Trigger Cluster-Spot here if enabled + } + } + } + + /** + * Helper: Normalizes weird frequency formats to valid Double strings. + * Example: "144.210.10" -> "144.21010" + * Example: "144.210" -> "144.210" + */ + private String normalizeFrequencyString(String rawInput) { + // Input is already guaranteed to have only dots as separators (commas replaced earlier) + + int firstDotIndex = rawInput.indexOf("."); + + if (firstDotIndex != -1) { + // Check if there are more dots after the first one + String decimalPart = rawInput.substring(firstDotIndex + 1); + if (decimalPart.contains(".")) { + // Remove all subsequent dots to make it a valid double + decimalPart = decimalPart.replace(".", ""); + return rawInput.substring(0, firstDotIndex) + "." + decimalPart; + } + } + return rawInput; + } + + /** * Builds UserList and gets meta informations out of the chat, as far as it is * possible. \n This is the only place where the Chatmember-List will be written @@ -300,11 +496,14 @@ public class MessageBusManagementThread extends Thread { */ private void processRXMessage23001(ChatMessage messageToProcess) throws IOException, SQLException { + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "Last message processed:\n" + messageToProcess.getMessageText(), false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + final String INITIALUSERLISTENTRY = "UA0"; final String USERENTEREDCHAT = "UA5"; final String USERENTEREDCHAT2 = "UA2"; // seen at 50MHZ Chat final String initialChatHistoryEntry = "CR"; - final String SERVERMESSAGE = "CR"; + final String SERVERMESSAGEHISTORIC = "CR"; //takes messages out of the ON4KST history final String USERLEFTCHAT = "UR6"; final String USERLEFTCHAT2 = "UR7"; final String CHATCHANNELMESSAGE = "CH"; @@ -335,7 +534,7 @@ public class MessageBusManagementThread extends Thread { qrgQuestionTexts.add("your qrg?"); qrgQuestionTexts.add("qrg?"); qrgQuestionTexts.add("freq?"); - qrgQuestionTexts.add("pse QRG"); + qrgQuestionTexts.add("pse qrg"); /** @@ -343,7 +542,7 @@ public class MessageBusManagementThread extends Thread { */ if (messageToProcess.getMessageText().isEmpty()) { - System.out.println("[MSGBUSMGTT:] ######################no processable data"); +// System.out.println("[MSGBUSMGTT:] no processable data"); } else { @@ -364,6 +563,7 @@ public class MessageBusManagementThread extends Thread { * Initializes the Userlist if entry fits UA0 * UA0|3|DL6SAQ|walter not qrv|JN58CK|1| <- RXed * + * */ if (splittedMessageLine[0].contains(INITIALUSERLISTENTRY)) { // System.out.println("MSGBUS: User detected"); @@ -384,16 +584,15 @@ public class MessageBusManagementThread extends Thread { newMember.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());//TODO evt obsolete! newMember.setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime()); -// this.client.getChatMemberTable().put(splittedMessageLine[2], newMember); //TODO: map -> List - //the own call will not be in the list if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) { - this.client.getLst_chatMemberList().add(newMember); + this.client.getLst_chatMemberList().add(newMember); //the own call will not be in the list } this.client.getDbHandler().storeChatMember(newMember); + // bufwrtrDBGMSGOut.write(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() // + "[MSGBUSMGT:] User detected and added to list [" + this.client.getChatMemberTable().size() // + "] :" + newMember.getCallSign() + "\n"); @@ -438,6 +637,8 @@ public class MessageBusManagementThread extends Thread { } + this.client.fireUserListUpdate("User entered the chat"); + // this.client.getChatMemberTable().put(splittedMessageLine[2], newMember); // System.out.println("[MSGBUSMGT:] New entered User detected and added to list [" @@ -462,29 +663,14 @@ public class MessageBusManagementThread extends Thread { this.client.getLst_chatMemberList().remove( checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), newMember)); - //TODO: since 1.26 new method design to detect chatcategory, too! + //since 1.26 new method design to detect chatcategory, too! } catch (Exception e) { System.out.println("[MSGBUSMGT, EXC!, Error:] User sent left chat but had not been there ... [" + this.client.getLst_chatMemberList().size() + "] :" + newMember.getCallSign() + "\n" + e.getStackTrace()); -// e.printStackTrace(); } -// int indexToDelete = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), -// newMember); -// if (indexToDelete != -1) { -// System.out.println("[MSGBUSMGT:] User left Chat and is removed from list [" -// + this.client.getLst_chatMemberList().size() + "] :" + newMember.getCallSign()); -// -// this.client.getLst_chatMemberList().remove(indexToDelete); -// -// } else { -// System.out.println("[MSGBUSMGT:] Error, user sent left chat but had not been there ... [" -// + this.client.getLst_chatMemberList().size() + "] :" + newMember.getCallSign()); -// -// } - } else /** @@ -524,8 +710,28 @@ public class MessageBusManagementThread extends Thread { if (index != -1) { //user not found in the chatmember list try { - newMessageArrived.setSender(this.client.getLst_chatMemberList().get(index)); // set sender to member of - this.client.getLst_chatMemberList().get(index).setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime()); +// newMessageArrived.setSender(this.client.getLst_chatMemberList().get(index)); // set sender to member of +// this.client.getLst_chatMemberList().get(index).setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime()); + + ChatMember senderObj = this.client.getLst_chatMemberList().get(index); + newMessageArrived.setSender(senderObj); + senderObj.setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime()); + + // Remember last inbound category per callsignRaw (required for correct send-routing later) + this.client.rememberLastInboundCategory(senderObj.getCallSignRaw(), senderObj.getChatCategory()); + + // Metrics for scoring: momentum, response-time, no-reply, positive signals + this.client.getStationMetricsService().onInboundMessage( + senderObj.getCallSignRaw(), + System.currentTimeMillis(), + newMessageArrived.getMessageText(), + this.client.getChatPreferences(), + this.client.getChatPreferences().getStn_loginCallSign() + ); + + // Activity/category changes influence priority => request recompute + this.client.getScoreService().requestRecompute("rx-chat-message"); + } catch (Exception exc) { ChatMember aSenderDummy = new ChatMember(); aSenderDummy.setCallSign(splittedMessageLine[3] + "[n/a]"); @@ -611,8 +817,7 @@ public class MessageBusManagementThread extends Thread { if (newMessageArrived.getReceiver().getCallSign() .equals(this.client.getChatPreferences().getStn_loginCallSign())) { -// this.client.getLst_toMeMessageList().add(0, newMessageArrived); //TODO: change, moved to globalmessagelist, original - this.client.getLst_globalChatMessageList().add(0, newMessageArrived); //TODO: change, moved to globalmessagelist, original + this.client.getLst_globalChatMessageList().add(0, newMessageArrived); if (this.client.getChatPreferences().isNotify_playSimpleSounds()) { this.client.getPlayAudioUtils().playNoiseLauncher('P'); @@ -629,49 +834,121 @@ public class MessageBusManagementThread extends Thread { this.client.getPlayAudioUtils().playVoiceLauncher("!"); } } + if (newMessageArrived.getMessageText().toUpperCase().contains("//VER")) { - if (this.client.getChatPreferences().isMsgHandling_autoAnswerEnabled()) { - - ChatMessage automaticAnswer = new ChatMessage(); + ChatMessage versionInfo = new ChatMessage(); ChatMember itsMe = new ChatMember(); itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign()); - automaticAnswer.setSender(itsMe); - automaticAnswer.setReceiver(newMessageArrived.getSender()); - automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " " + this.client.getChatPreferences().getMessageHandling_autoAnswerTextMainCat()); - - this.client.getMessageTXBus().add(automaticAnswer); + versionInfo.setSender(itsMe); + versionInfo.setReceiver(newMessageArrived.getSender()); + versionInfo.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " " + ApplicationConstants.AUTOANSWER_PREFIX + " " + "KST4Contest " + " v" + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER + " by DO5AMF"); + this.client.getMessageTXBus().add(versionInfo); } +// if (this.client.getChatPreferences().isMsgHandling_autoAnswerEnabled()) { +// +// ChatMessage automaticAnswer = new ChatMessage(); +// ChatMember itsMe = new ChatMember(); +// itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign()); +// +// automaticAnswer.setSender(itsMe); +// automaticAnswer.setReceiver(newMessageArrived.getSender()); +// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " " + this.client.getChatPreferences().getMessageHandling_autoAnswerTextMainCat()); +// +// this.client.getMessageTXBus().add(automaticAnswer); +// +// } + /** * auto reply/answer to QRG requests is here */ - if (this.client.getChatPreferences().isMessageHandling_autoAnswerToQRGRequestEnabled()) { +// if (this.client.getChatPreferences().isMessageHandling_autoAnswerToQRGRequestEnabled()) { +// +// for (String lookForQRGString : qrgQuestionTexts) { +// if (newMessageArrived.getMessageText().contains(lookForQRGString)) { +// +// ChatMessage automaticAnswer = new ChatMessage(); +// ChatMember itsMe = new ChatMember(); +// itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign()); +// +// automaticAnswer.setSender(itsMe); +// automaticAnswer.setReceiver(newMessageArrived.getSender()); +// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue()); +// +// if (this.client.getChatPreferences().isLoginToSecondChatEnabled()) { +// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRGs: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue() + " / " + this.client.getChatPreferences().getMYQRGSecondCat().getValue()); +// } else { +// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue()); +// } +// +// this.client.getMessageTXBus().add(automaticAnswer); +// +// } +// } +// } - for (String lookForQRGString : qrgQuestionTexts) { - if (newMessageArrived.getMessageText().contains(lookForQRGString)) { + // ==== Unified Autoanswer (Generic + QRG) with Pingpong-Guard + per-Remote Cooldown ==== + final String incomingText = newMessageArrived.getMessageText(); + final String incomingLower = (incomingText == null) ? "" : incomingText.toLowerCase(Locale.ROOT); - ChatMessage automaticAnswer = new ChatMessage(); - ChatMember itsMe = new ChatMember(); - itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign()); + // 1) Pingpong-security: never ever react to auto generated messages + if (!isAutoMessage(newMessageArrived)) { - automaticAnswer.setSender(itsMe); - automaticAnswer.setReceiver(newMessageArrived.getSender()); - automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue()); + boolean qrgRequested = false; - if (this.client.getChatPreferences().isLoginToSecondChatEnabled()) { - automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRGs: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue() + " / " + this.client.getChatPreferences().getMYQRGSecondCat().getValue()); - } else { - automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue()); + if (this.client.getChatPreferences().isMessageHandling_autoAnswerToQRGRequestEnabled()) { + for (String lookForQRGString : qrgQuestionTexts) { + if (incomingLower.contains(lookForQRGString)) { + qrgRequested = true; + break; } - - this.client.getMessageTXBus().add(automaticAnswer); - } } + + boolean genericEnabled = this.client.getChatPreferences().isMsgHandling_autoAnswerEnabled(); + + // 2) Entscheide, ob überhaupt geantwortet wird (QRG hat Vorrang vor Generic) + String payload = null; + + if (qrgRequested) { + + if (this.client.getChatPreferences().isLoginToSecondChatEnabled()) { + payload = "QRGs: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue() + + " / " + this.client.getChatPreferences().getMYQRGSecondCat().getValue(); + } else { + payload = "QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue(); + } + + } else if (genericEnabled) { + + payload = this.client.getChatPreferences().getMessageHandling_autoAnswerTextMainCat(); + } + + // 3) Cooldown pro Gegenstation: nur wenn DIESER Client jetzt wirklich sendet + if (payload != null && isAutoAnswerAllowedNow(newMessageArrived)) { + + ChatMessage automaticAnswer = new ChatMessage(); + ChatMember itsMe = new ChatMember(); + itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign()); + + automaticAnswer.setSender(itsMe); + automaticAnswer.setReceiver(newMessageArrived.getSender()); + + // Prefix fest + nicht entfernbar, damit Auto↔Auto nicht pingpongt + automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + + " " + AUTOANSWER_PREFIX + " " + payload); + + this.client.getMessageTXBus().add(automaticAnswer); + + // Cooldown wird NUR hier gesetzt (nicht bei 'message sent by me' Echo), + // damit nur lokale Auto-Sends zählen. + markLocalAutoAnswerSent(newMessageArrived); + } } + System.out.println("message directed to me: " + newMessageArrived.getReceiver().getCallSign() + "."); } else if (newMessageArrived.getSender().getCallSign().toUpperCase() @@ -690,7 +967,6 @@ public class MessageBusManagementThread extends Thread { } else { //message sent to other user -// this.client.getLst_toOtherMessageList().add(0, newMessageArrived); //TODO: change, moved to globalmessagelist, original if (DirectionUtils.isInAngleAndRange(client.getChatPreferences().getStn_loginLocatorMainCat(), newMessageArrived.getSender().getQra(), newMessageArrived.getReceiver().getQra(), @@ -709,11 +985,31 @@ public class MessageBusManagementThread extends Thread { if (client.getChatPreferences().isNotify_dxClusterServerEnabled()) { try { if (newMessageArrived.getSender().getFrequency() != null) { - this.client.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(newMessageArrived.getSender()); //tells the DXCluster server to send a DXC message for this member to the logbook software + //TODO: testing for next version 3.33: addinitional information will be displayed in cluster if there is such an information + ChatMember onlyForSpottingObject = new ChatMember(); + onlyForSpottingObject.setCallSign(newMessageArrived.getSender().getCallSign()); + onlyForSpottingObject.setFrequency(newMessageArrived.getSender().getFrequency()); + + if (newMessageArrived.getSender().getAirPlaneReflectInfo().getAirPlanesReachableCntr() > 0) { + onlyForSpottingObject.setQra(newMessageArrived.getSender().getQra() + " , AP: " + + newMessageArrived.getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(0).getArrivingDurationMinutes() + "min, " + + newMessageArrived.getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(0).getPotential() + "%"); + + if (newMessageArrived.getSender().getAirPlaneReflectInfo().getAirPlanesReachableCntr() > 1) { + onlyForSpottingObject.setQra(newMessageArrived.getSender().getQra() + "; " + + newMessageArrived.getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(1).getArrivingDurationMinutes() + "min, " + + newMessageArrived.getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(1).getPotential() + "%"); + } + } else { + + onlyForSpottingObject.setQra(newMessageArrived.getSender().getQra()); + } + + this.client.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(onlyForSpottingObject); //tells the DXCluster server to send a DXC message for this member to the logbook software } } catch (Exception exception) { - System.out.println("[MSGBUSMGT, ERROR:] DXCluster messageserver error while processing spot for 0" + newMessageArrived.getSender().getCallSign() + " // " + exception.getMessage()); - exception.printStackTrace(); + System.out.println("[MSGBUSMGT, ERROR:] DXCluster messageserver error while processing spot for 0: " + newMessageArrived.getSender().getCallSign() + " // " + exception.getMessage()); +// exception.printStackTrace(); } } @@ -755,50 +1051,8 @@ public class MessageBusManagementThread extends Thread { System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage() + "\n" + exceptionOccured.getStackTrace()); } - String locatedFrequencies = checkIfMessageInhibitsFrequency(newMessageArrived); - - SimpleStringProperty qrg = new SimpleStringProperty(locatedFrequencies); - - if (!splittedMessageLine[3].equals("SERVER")) { - - if (locatedFrequencies.equals("")) { - // no qrg found, nothing to do - } else { - - ChatMember temp3 = new ChatMember(); - temp3.setCallSign(splittedMessageLine[3]); - temp3.setChatCategory(chategoryForMessageAndMessageSender); - - int index = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), temp3); - - if (index == -1) { // user is not in the userlist but sent message... - - /** - * CH|2|1664663240|IK7LMX|Gilberto QRO|0|pse ant to jn80|YT5W| Caused this line - */ - System.out.println("[MSGBUSMGT <<>>]:, Frequency for " + splittedMessageLine[3] - + " is not settable, Callsign is not in the Member-list!"); - - //create dummy user to display the message but it wont be hit an existing user object - ChatMember newMember = new ChatMember(); - - newMember.setCallSign(splittedMessageLine[3]); - newMember.setName(splittedMessageLine[4]); - newMember.setFrequency(qrg); - - } else { - /** - * User is in the list... - */ - this.client.getLst_chatMemberList().get(index).setFrequency(qrg); - System.out.println("[MSGBUSMGT:] Frequency for " + splittedMessageLine[3] + " setted: " - + locatedFrequencies); -// this.client.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(this.client.getLst_chatMemberList().get(index)); //tells the DXCluster server to send a DXC message for this member to the logbook software - - } - } - - } + // --- Band/QRG recognition (fills ChatMember.knownActiveBands) --- + smartFrequencyExtraction(newMessageArrived, this.client.getChatPreferences()); // TODO: Next: get frequency infos out of name? } else @@ -1006,6 +1260,214 @@ public class MessageBusManagementThread extends Thread { this.client.getLst_chatMemberList().get(index).setState(stateChangeMember.getState()); } + } else + + /** + * Handled like normal messages, but historic...will not trigger any functions + * + * Chat history line like: + * CR|6|1771165971|DF0GEB|test|0|ok|0| + * ^^hist + * ^chan + * ^^^^^^^^^^time ... + */ + if (splittedMessageLine[0].contains(SERVERMESSAGEHISTORIC)) { + + + ChatMessage newMessageArrived = new ChatMessage(); + ChatCategory chategoryForMessageAndMessageSender; + + newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1])); + + chategoryForMessageAndMessageSender = newMessageArrived.getChatCategory(); + newMessageArrived.setMessageGeneratedTime(splittedMessageLine[2]); + + if (splittedMessageLine[3].equals("SERVER")) { + ChatMember dummy = new ChatMember(); + dummy.setCallSign("SERVER"); + dummy.setName("Sysop"); + newMessageArrived.setSender(dummy); + newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1])); + dummy.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1])); +// System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> servers cat " + newMessageArrived.getChatCategory()); + + } else { + + ChatMember sender = new ChatMember(); + sender.setCallSign(splittedMessageLine[3]); + sender.setChatCategory(chategoryForMessageAndMessageSender); + + int index = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), sender); + + if (index != -1) { + //user not found in the chatmember list + try { +// newMessageArrived.setSender(this.client.getLst_chatMemberList().get(index)); // set sender to member of +// this.client.getLst_chatMemberList().get(index).setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime()); + + ChatMember senderObj = this.client.getLst_chatMemberList().get(index); + newMessageArrived.setSender(senderObj); + senderObj.setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime()); + + // Remember last inbound category per callsignRaw (required for correct send-routing later) + this.client.rememberLastInboundCategory(senderObj.getCallSignRaw(), senderObj.getChatCategory()); + + // Metrics for scoring: momentum, response-time, no-reply, positive signals + this.client.getStationMetricsService().onInboundMessage( + senderObj.getCallSignRaw(), + System.currentTimeMillis(), + newMessageArrived.getMessageText(), + this.client.getChatPreferences(), + this.client.getChatPreferences().getStn_loginCallSign() + ); + + // Activity/category changes influence priority => request recompute + this.client.getScoreService().requestRecompute("rx-chat-message"); + + } catch (Exception exc) { + ChatMember aSenderDummy = new ChatMember(); + aSenderDummy.setCallSign(splittedMessageLine[3] + "[n/a]"); + aSenderDummy.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); + newMessageArrived.setSender(aSenderDummy); + System.out.println("MsgBusmgtT: Catched Error! " + exc.getMessage() + " // " + splittedMessageLine[3] + " is not in the list! Faking sender!"); + exc.printStackTrace(); + } + // b4 init list + } else { + //user not found in chatmember list, mark it, sender can not be set + if (!sender.getCallSign().equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) { + sender.setCallSign("[n/a]" + sender.getCallSign()); + // if someone sent a message without being in the userlist (cause + // on4kst missed implementing....), callsign will be marked + } else { + //that means, message was by own station, broadcasted to all other + ChatMember dummy = new ChatMember(); + dummy.setCallSign("ALL"); + newMessageArrived.setReceiver(dummy); + + AirPlaneReflectionInfo preventNullpointerExc = new AirPlaneReflectionInfo(); + preventNullpointerExc.setAirPlanesReachableCntr(0); + sender.setAirPlaneReflectInfo(preventNullpointerExc); + newMessageArrived.setSender(sender); //my own call is the sender + } + } + +// newMessageArrived.setSender(this.client.getChatMemberTable().get(splittedMessageLine[3])); + } + + newMessageArrived.setMessageSenderName(splittedMessageLine[4]); + newMessageArrived.setMessageText(splittedMessageLine[6]); + + if (splittedMessageLine[7].equals("0")) { + // message is not directed to anyone, move it to the cq messages! + ChatMember dummy = new ChatMember(); + dummy.setCallSign("ALL"); + newMessageArrived.setReceiver(dummy); + + this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List + + } else { + //message is directed to another chatmember, process as such! + + ChatMember receiver = new ChatMember(); + + receiver.setChatCategory(chategoryForMessageAndMessageSender); //got out of message itself + + receiver.setCallSign(splittedMessageLine[7]); + + int index = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), receiver); + + if (index != -1) { + newMessageArrived.setReceiver(this.client.getLst_chatMemberList().get(index));// -1: Member left Chat + // before... + } else { //found in active member list + + if (receiver.getCallSign().equals(client.getChatPreferences().getStn_loginCallSign())) { + /** + * If mycallsign sent a message to the server, server will publish that message and + * send it to all chatmember including me. + * As mycall is not in the userlist, the message would not been displayed if I handle + * it in the next case (marking left user, just for information). But I want an echo. + */ + + receiver.setCallSign(client.getChatPreferences().getStn_loginCallSign()); + newMessageArrived.setReceiver(receiver); + } else { + //this are user which left chat but had been adressed by this message + receiver.setCallSign(receiver.getCallSign() + "(left)"); + newMessageArrived.setReceiver(receiver); + } + } + +// System.out.println("message directed to: " + newMessageArrived.getReceiver().getCallSign() + ". EQ?: " + this.client.getownChatMemberObject().getCallSign() + " sent by: " + newMessageArrived.getSender().getCallSign().toUpperCase() + " -> EQ?: "+ this.client.getChatPreferences().getLoginCallSign().toUpperCase()); + + try { + /** + * message is directed to me, will be put in the "to me" messagelist + */ + if (newMessageArrived.getReceiver().getCallSign() + .equals(this.client.getChatPreferences().getStn_loginCallSign())) { + + this.client.getLst_globalChatMessageList().add(0, newMessageArrived); + + System.out.println("Historic message directed to me: " + newMessageArrived.getReceiver().getCallSign() + "."); + + } else if (newMessageArrived.getSender().getCallSign().toUpperCase() + .equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) { + /** + * message sent by me! + * message from me will appear in the PM window, too, with (>CALLSIGN) before + */ + String originalMessage = newMessageArrived.getMessageText(); + newMessageArrived + .setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage); + this.client.getLst_globalChatMessageList().add(0,newMessageArrived); + + // if you sent the message to another station, it will be sorted in to + // the "to me message list" with modified messagetext, added rxers callsign + + } else { + //message sent to other user + if (DirectionUtils.isInAngleAndRange(client.getChatPreferences().getStn_loginLocatorMainCat(), + newMessageArrived.getSender().getQra(), + newMessageArrived.getReceiver().getQra(), + client.getChatPreferences().getStn_maxQRBDefault(), + client.getChatPreferences().getStn_antennaBeamWidthDeg())) { + + newMessageArrived.getSender().setInAngleAndRange(true); + + } else { + + newMessageArrived.getSender().setInAngleAndRange(false); + } + + this.client.getLst_globalChatMessageList().add(0, newMessageArrived); +// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign()); + } + } catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) { + System.out.println("MSGBS bgfx, <<>>: referenced user left the chat during messageprocessing or message got before user entered chat message: " + referenceDeletedByUserLeftChatDuringMessageprocessing.getStackTrace()); +// referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace(); + } + + // sdtout to me message-List + + } + + try { + + System.out.println("[MSGBUSMGT:] processed message: " + newMessageArrived.getChatCategory().getCategoryNumber() + + " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> " + + newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText()); + } catch (Exception exceptionOccured) { + System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage() + "\n" + exceptionOccured.getStackTrace()); + } + + // --- Band/QRG recognition (fills ChatMember.knownActiveBands) --- + smartFrequencyExtraction(newMessageArrived, this.client.getChatPreferences()); + + + + } else /** @@ -1118,7 +1580,48 @@ public class MessageBusManagementThread extends Thread { super.interrupt(); } - + + + /** + * check if message had been auto generated + * @param msg + * @return + */ + private boolean isAutoMessage(ChatMessage msg) { + return msg != null + && msg.getMessageText() != null + && msg.getMessageText().contains(AUTOANSWER_PREFIX); + } + + private String autoAnswerCooldownKey(ChatMessage incoming) { + + String remoteCall = "UNKNOWN"; + if (incoming != null && incoming.getSender() != null && incoming.getSender().getCallSign() != null) { + remoteCall = incoming.getSender().getCallSign().toUpperCase(); + } + + int cat = 0; // fallback + if (incoming != null && incoming.getSender() != null && incoming.getSender().getChatCategory() != null) { + cat = incoming.getSender().getChatCategory().getCategoryNumber(); + } + + // pro Gegenstation + pro Chat-Kategorie (falls derselbe Call in Cat2/Cat3 PMs macht) + return remoteCall + "|" + cat; + } + + private boolean isAutoAnswerAllowedNow(ChatMessage incoming) { + + String key = autoAnswerCooldownKey(incoming); + Long last = lastLocalAutoAnswerPerRemoteMs.get(key); + + long now = System.currentTimeMillis(); + return last == null || (now - last) >= AUTOANSWER_COOLDOWN_MS; + } + + private void markLocalAutoAnswerSent(ChatMessage incoming) { + lastLocalAutoAnswerPerRemoteMs.put(autoAnswerCooldownKey(incoming), System.currentTimeMillis()); + } + public void run() { @@ -1178,7 +1681,7 @@ public class MessageBusManagementThread extends Thread { try { messageTextRaw = client.getMessageRXBus().take(); - if (messageTextRaw.getMessageText().equals("POISONPILL_KILLTHREAD") && messageTextRaw.getMessageSenderName().equals("POISONPILL_KILLTHREAD")) { + if (messageTextRaw.getMessageText().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL) && messageTextRaw.getMessageSenderName().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) { client.getMessageRXBus().clear(); break; } @@ -1243,24 +1746,24 @@ public class MessageBusManagementThread extends Thread { // this.interrupt(); // client.getMessageRXBus().clear(); } - + // if (client.getMessageRXBus().peek() == null) { -// +// // Timer doNothingTimer = new Timer(); // doNothingTimer.schedule(new TimerTask() { -// +// // @Override // public void run() { -// +// // //do nothing -// +// // } // }, 100);// TODO: Temporary // } -// -// +// +// // if (client.getMessageRXBus().peek() == null && client.getMessageTXBus().peek() == null) { -// +// // if (this.client.isDisconnectionPerformedByUser()) { // break;//TODO: what if it´s not the finally closage but a band channel change? // } @@ -1274,8 +1777,8 @@ public class MessageBusManagementThread extends Thread { //// // TODO Auto-generated catch block //// e2.printStackTrace(); //// } -// } -// else +// } +// else { // messageLine = messageTextRaw.getMessageText(); @@ -1422,7 +1925,6 @@ public class MessageBusManagementThread extends Thread { // } //end tx.peek != null } -// System.out.println("messagebusmgt while performed"); } // while true end System.out.println("Msgbusmgt: interrupt"); diff --git a/src/main/java/kst4contest/controller/PstRotatorClient.java b/src/main/java/kst4contest/controller/PstRotatorClient.java new file mode 100644 index 0000000..81dcdb0 --- /dev/null +++ b/src/main/java/kst4contest/controller/PstRotatorClient.java @@ -0,0 +1,237 @@ +package kst4contest.controller; + +import kst4contest.controller.interfaces.PstRotatorEventListener; +import kst4contest.model.ThreadStateMessage; + +import java.io.IOException; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.*; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class PstRotatorClient implements Runnable { + + private ThreadStatusCallback callBackToController; + private String ThreadNickName = "PSTRotator"; + + private static final Logger LOGGER = Logger.getLogger(PstRotatorClient.class.getName()); + private static final int BUFFER_SIZE = 1024; + + // Konfiguration + private final String host; + private final int remotePort; // Port, auf dem PSTRotator hört (z.B. 12060) + private final int localPort; // Port, auf dem wir hören (z.B. 12061) + + private DatagramSocket socket; + private volatile boolean running = false; + private PstRotatorEventListener listener; + + // Executor für Polling (Status-Abfrage) + private ScheduledExecutorService poller; + + /** + * Konstruktor + * @param host IP Adresse von PSTRotator (meist "127.0.0.1") + * @param remotePort Der Port, der in PSTRotator eingestellt ist (User-Wunsch: 12060) + * @param listener Callback für den Chatcontroller + */ + public PstRotatorClient(String host, int remotePort, PstRotatorEventListener listener, ThreadStatusCallback callBack) { + this.callBackToController = callBack; + this.host = host; + this.remotePort = remotePort; + // Laut Manual antwortet PSTRotator oft auf Port+1. + // Wir binden uns also standardmäßig auf remotePort + 1. + this.localPort = remotePort + 1; + this.listener = listener; + } + + /** + * alternative constructor for seting the remote port explicitely + */ + public PstRotatorClient(String host, int remotePort, int localPort, PstRotatorEventListener listener) { + this.host = host; + this.remotePort = remotePort; + this.localPort = localPort; + this.listener = listener; + } + + /** + * Startet den Empfangs-Thread und das Polling + */ + public void start() { + try { + + // Socket binden +// socket = new DatagramSocket(null); +// socket.setReuseAddress(true); +// socket = new DatagramSocket(localPort); +// + socket = new DatagramSocket(null); + socket.setReuseAddress(true); + socket.bind(new InetSocketAddress(localPort)); //bind to port + + running = true; + + // 1. Empfangs-Thread starten (dieses Runnable) + Thread thread = new Thread(this, "PSTRotator-Listener-" + remotePort); + thread.start(); + + // 2. Polling starten (z.B. alle 2 Sekunden Status abfragen) + poller = Executors.newSingleThreadScheduledExecutor(); + poller.scheduleAtFixedRate(this::pollStatus, 1, 2, TimeUnit.SECONDS); + + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, running, "initialized", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + LOGGER.info("PstRotatorClient started. Remote: " + remotePort + ", Local: " + localPort); + + } catch (SocketException e) { + LOGGER.log(Level.SEVERE, "Fehler beim Öffnen des UDP Sockets", e); + } + } + + /** + * Stopping threads and closing sockets of pstRotator communicator + */ + public void stop() { + running = false; + if (poller != null && !poller.isShutdown()) { + poller.shutdownNow(); + } + if (socket != null && !socket.isClosed()) { + socket.close(); + } + } + + /** + * Main loop in thread which listens fpr PSTrotator packets + */ + @Override + public void run() { + byte[] buffer = new byte[BUFFER_SIZE]; + + while (running && !socket.isClosed()) { + try { + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + socket.receive(packet); // Blockiert bis Daten kommen + + String received = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.US_ASCII).trim(); + + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received line\n" + received, false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + + parseResponse(received); + + } catch (IOException e) { + if (running) { + LOGGER.log(Level.WARNING, "Fehler beim Empfangen des Pakets", e); + } + } + } + } + + + /** + * parses a pst rotatpr message to fit the PST listener interface + * @param msg + */ + + private void parseResponse(String msg) { + // Debug + if (listener != null) listener.onMessageReceived(msg); + + // Example answer: "AZ:145.0", "EL:010.0", "MODE:1" + msg = msg.replace("", "").trim(); + + try { + if (msg.startsWith("AZ:")) { + String val = msg.substring(3); + if (listener != null) listener.onAzimuthUpdate(Double.parseDouble(val)); + } + else if (msg.startsWith("EL:")) { + String val = msg.substring(3); + if (listener != null) listener.onElevationUpdate(Double.parseDouble(val)); + } + else if (msg.startsWith("MODE:")) { + // MODE:1 = Tracking, MODE:0 = Manual + String val = msg.substring(5); + boolean tracking = "1".equals(val); + if (listener != null) listener.onModeUpdate(tracking); + } + else if (msg.startsWith("OK:")) { + // Bestätigung von Befehlen, z.B. OK:STOP:1 + LOGGER.fine("Befehl bestätigt: " + msg); + } + } catch (NumberFormatException e) { + LOGGER.warning("Konnte Wert nicht parsen: " + msg); + } + } + + // --- Sende Methoden (API für den Chatcontroller) --- + + private void sendUdp(String message) { + if (socket == null || socket.isClosed()) return; + + try { + byte[] data = message.getBytes(StandardCharsets.US_ASCII); + InetAddress address = InetAddress.getByName(host); + DatagramPacket packet = new DatagramPacket(data, data.length, address, remotePort); + socket.send(packet); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Fehler beim Senden an PstRotator", e); + } + } + + /** + * Sendet den generischen XML Befehl. + * Bsp: 85 + */ + private void sendCommand(String tag, String value) { + String xml = String.format("<%s>%s", tag, value, tag); + System.out.println("PSTRotatorClient: sent: " + xml); + sendUdp(xml); + } + + // Öffentliche Steuermethoden + + public void setAzimuth(double degrees) { + // Formatierung ohne unnötige Nachkommastellen, falls nötig + sendCommand("AZIMUTH", String.valueOf((int) degrees)); + } + + public void setElevation(double degrees) { + sendCommand("ELEVATION", String.valueOf(degrees)); + } + + public void stopRotor() { + sendCommand("STOP", "1"); + } + + public void park() { + sendCommand("PARK", "1"); + } + + public void setTrackingMode(boolean enable) { + sendCommand("TRACK", enable ? "1" : "0"); + } + + /** + * Method for polling rotators status via PSTRotator software. Asks only for AZ value!
+ * Scheduled in a fixed time by executor + */ + public void pollStatus() { + // PSTRotator Dokumentation: + // AZ? + // EL? + // MODE? + + // Man kann mehrere Befehle in einem Paket senden + String query = ""; + // HINWEIS: Laut Doku ist die Syntax für Abfragen etwas anders: AZ? + // Daher bauen wir den String manuell, da sendCommand Tags schließt. + + sendUdp("AZ?"); +// sendUdp("EL?"); + sendUdp("MODE?"); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java b/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java new file mode 100644 index 0000000..e1f08f0 --- /dev/null +++ b/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java @@ -0,0 +1,328 @@ +package kst4contest.controller; + +import kst4contest.ApplicationConstants; +import kst4contest.model.ChatMember; +import kst4contest.model.ThreadStateMessage; +import kst4contest.view.GuiUtils; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class ReadUDPByWintestThread extends Thread { + + private DatagramSocket socket; + private ChatController client; + + private volatile boolean running = true; + + private int PORT = 9871; //default + + private static final int BUFFER_SIZE = 4096; + + private final Map receivedQsos = new ConcurrentHashMap<>(); + private long lastPacketTime = 0; + + private String myStation = "DO5AMF"; + + private String targetStation = ""; + private String stationID = ""; + private int lastKnownQso = 0; + + private ThreadStatusCallback callBackToController; + private String ThreadNickName = "Wintest-msg"; + + + public ReadUDPByWintestThread(ChatController client, ThreadStatusCallback callback) { + + this.callBackToController = callback; + this.client = client; + this.myStation = client.getChatPreferences().getStn_loginCallSignRaw(); //callsign of the logging stn + this.PORT = client.getChatPreferences().getLogsynch_wintestNetworkPort(); + + } + + @Override + public void interrupt() { + running = false; + if (socket != null && !socket.isClosed()) socket.close(); + super.interrupt(); + } + + @Override + public void run() { + + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + Thread.currentThread().setName("ReadUDPByWintestThread"); + + byte[] buffer = new byte[BUFFER_SIZE]; + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + + try { + socket = new DatagramSocket(null); //first init with null, then make ready for reuse + socket.setReuseAddress(true); +// socket = new DatagramSocket(PORT); + socket.bind(new InetSocketAddress(client.getChatPreferences().getLogsynch_wintestNetworkPort())); + socket.setSoTimeout(3000); + System.out.println("[WinTest UDP listener] started at port: " + PORT); + } catch (SocketException e) { + e.printStackTrace(); + return; + } + + while (running) { + try { + socket.receive(packet); + String msg = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.US_ASCII).trim(); + processWinTestMessage(msg); + } catch (SocketTimeoutException e) { +// checkForMissingQsos(); + } catch (IOException e) { + //TODO: here is something to catch + } + } + } + + private void processWinTestMessage(String msg) { +// System.out.println("Wintest-Message received: " + msg); + + lastPacketTime = System.currentTimeMillis(); + + if (msg.startsWith("HELLO:")) { //Client Signon of wintest + parseHello(msg); + try { +// send_needqso(); + }catch (Exception e) { + System.out.println("Error: "); + e.printStackTrace(); + } + + + } else if (msg.startsWith("ADDQSO:")) { //adding qso to wintest log + try { + + parseAddQso(msg); + } catch (Exception e) { + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "Parsing ERROR: " + Arrays.toString(e.getStackTrace()), true); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + } + + } else if (msg.startsWith("IHAVE:")) { //periodical message of wintest, which qsos are in the log +// parseIHave(msg); //TODO + } + + else if (msg.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) { + System.out.println("ReadUdpByWintest, Info: got poison, now dieing...."); + socket.close(); + running = false; + + } + + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "message received\n" + msg, false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + } + + /** + * parsing of the hello message of wintest: + * "HELLO: "STN1" "" 6667 130 "SLAVE" 1 0 1762201985" + * @param msg + */ + private void parseHello(String msg) { + try { + String[] tokens = msg.split("\""); + if (tokens.length >= 2) { + targetStation = tokens[1]; + System.out.println("[WinTest rcv: found logger instance: " + targetStation); + } + } catch (Exception e) { + System.out.println("[WinTest] ERROR on HELLO-Parsing: " + e.getMessage()); + } + } + + private byte util_calculateChecksum(byte[] bytes) { + int sum = 0; + for (byte b : bytes) sum += b; + return (byte) ((sum | 0x80) & 0xFF); + } + +// private void send_needqso() throws IOException { +// String payload = String.format("NEEDQSO:\"%s\" \"%s\" \"%s\" %d %d?\0", +// "DO5AMF", "STN1", stationID, 1, 9999); +// InetAddress broadcast = InetAddress.getByName("255.255.255.255"); +// byte[] bytes = payload.getBytes(StandardCharsets.US_ASCII); +// bytes[bytes.length - 2] = util_calculateChecksum((bytes)); +// socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871)); +// } + +// private void send_hello() throws IOException { +// String payload = String.format("HELLO:\"%s\" \"%s\" \"%s\" %d %d?\0", +// "DO5AMF", "", stationID, "SLAVE", 1, 14); +// InetAddress broadcast = InetAddress.getByName("255.255.255.255"); +// byte[] bytes = payload.getBytes(StandardCharsets.US_ASCII); +// bytes[bytes.length - 2] = util_calculateChecksum((bytes)); +// socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871)); +// } + + /** + * Catches add-qso messages of wintest if a new qso gets into the log
+ * + * String is like this:

+ *ADDQSO: "STN1" "" "STN1" 1762202297 1440000 0 12 0 0 0 2 2 "DM2RN" "599" "599001" "JO51UM" "" "" 0 "" "" "" 44510 + * + * ^^^^sentby
+ * ^^^^^^^^^^time
+ * ^^^^^^qrg
+ * ^^band
+ * ^^^^^callsign logged
+ * stn-id ^^^^ + * @param msg + */ + private void parseAddQso(String msg) { + + + ChatMember modifyThat = null; + + try { +// int qsoNumber = extractQsoNumber(msg); +// receivedQsos.put(qsoNumber, msg); +// lastKnownQso = Math.max(lastKnownQso, qsoNumber); + String callSignCatched = msg.split("\"") [7]; + + ChatMember workedCall = new ChatMember(); + workedCall.setCallSign(callSignCatched); + workedCall.setWorked(true); //its worked at this place, for sure! + + ArrayList markTheseChattersAsWorked = client.checkListForChatMemberIndexesByCallSign(workedCall); + + String bandId; + bandId = msg.split("\"")[6].split(" ")[4].trim(); + + switch (bandId) { + case "10" -> workedCall.setWorked50(true); + case "11" -> workedCall.setWorked70(true); + case "12" -> workedCall.setWorked144(true); + case "14" -> workedCall.setWorked432(true); + case "16" -> workedCall.setWorked1240(true); + case "17" -> workedCall.setWorked2300(true); + case "18" -> workedCall.setWorked3400(true); + case "19" -> workedCall.setWorked5600(true); + case "20" -> workedCall.setWorked10G(true); + case "21" -> workedCall.setWorked24G(true); + case "22" -> workedCall.setWorked47G(true); + case "23" -> workedCall.setWorked76G(true); + default -> System.out.println("[WinTestUDPRcvr: warning] Unbekannte Band-ID: " + bandId); + } + + if (!markTheseChattersAsWorked.isEmpty()) { + //Worked call is part of the current chatmember list + + for (int index : markTheseChattersAsWorked) { + //iterate through the logged in chatmembers callsigns and set the worked markers + modifyThat = client.getLst_chatMemberList().get(index); + + modifyThat.setWorked(true); //worked its for sure + + if (workedCall.isWorked50()) { + modifyThat.setWorked50(true); + } else if (workedCall.isWorked70()) { + modifyThat.setWorked70(true); + } else if (workedCall.isWorked144()) { + modifyThat.setWorked144(true); + } else if (workedCall.isWorked432()) { + modifyThat.setWorked432(true); + } else if (workedCall.isWorked1240()) { + modifyThat.setWorked1240(true); + } else if (workedCall.isWorked2300()) { + modifyThat.setWorked2300(true); + } else if (workedCall.isWorked3400()) { + modifyThat.setWorked3400(true); + } else if (workedCall.isWorked5600()) { + modifyThat.setWorked5600(true); + } else if (workedCall.isWorked10G()) { + modifyThat.setWorked10G(true); + } else if (workedCall.isWorked24G()) { + modifyThat.setWorked24G(true); + } else if (workedCall.isWorked47G()) { + modifyThat.setWorked47G(true); + } else if (workedCall.isWorked76G()) { + modifyThat.setWorked76G(true); + } else { + System.out.println("[WinTestUDPRcvr: warning] found no new worked-flag for this band: " + workedCall.getCallSignRaw() + bandId); + } + } + + try { + + GuiUtils.triggerGUIFilteredChatMemberListChange(client); //not clean at all + + // trigger band-upgrade hint after log entry (Win-Test) + try { + client.onExternalLogEntryReceived(workedCall.getCallSignRaw()); + } catch (Exception e) { + System.out.println("[WinTestUDPRcvr, warning]: band-upgrade hint failed: " + e.getMessage()); + } + + } catch (Exception IllegalStateException) { + //do nothing, as it works... + } + } + + + boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall); + // This will update the worked info on a worked chatmember. DBHandler will + // check, if an entry at the db had been modified. If not, then the worked + // station had not been stored. DBHandler will store the information then. + if (!isInChat) { + + workedCall.setName("unknown"); + workedCall.setQra("unknown"); + workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat()); + this.client.getDbHandler().storeChatMember(workedCall); + } + + File logUDPMessageToThisFile = new File(this.client.getChatPreferences() + .getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup()); + + FileWriter fileWriterPersistUDPToFile = null; + BufferedWriter bufwrtrRawMSGOut; + + try { + fileWriterPersistUDPToFile = new FileWriter(logUDPMessageToThisFile, true); + + } catch (IOException e1) { + e1.printStackTrace(); + } + + bufwrtrRawMSGOut = new BufferedWriter(fileWriterPersistUDPToFile); + + if (modifyThat != null) { + bufwrtrRawMSGOut.write("\n" + modifyThat.toString()); + bufwrtrRawMSGOut.flush(); + bufwrtrRawMSGOut.close(); + + } else { + bufwrtrRawMSGOut.write("\n" + workedCall.toString()); + bufwrtrRawMSGOut.flush(); + bufwrtrRawMSGOut.close(); + + } + + + System.out.println("[WinTest, Info: Marking Chatmember as worked: " + workedCall.toString()); + +// markChatMemberAsWorked(call, band); //TODO + + } catch (Exception e) { + System.out.println("[WinTest] Fehler beim ADDQSO-Parsing: " + e.getMessage()); + } + } + +} diff --git a/src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java b/src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java new file mode 100644 index 0000000..860d7bd --- /dev/null +++ b/src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java @@ -0,0 +1,18 @@ +package kst4contest.controller; + +import kst4contest.model.ChatPreferences; + +public class ReadUDPByWintestThreadTest { + + public static void main(String[] args) { + ChatController ctrl1 = new ChatController(); + + ChatPreferences prefs = new ChatPreferences(); + ctrl1.setChatPreferences(prefs); + +// ReadUDPByWintestThread test = new ReadUDPByWintestThread(ctrl1); + + +// test.run(); + } +} diff --git a/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java b/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java index 99fd567..8fde94a 100644 --- a/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java +++ b/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java @@ -2,6 +2,7 @@ package kst4contest.controller; import java.io.*; import java.net.*; +import java.util.ArrayList; import java.util.Comparator; import javafx.collections.FXCollections; @@ -10,6 +11,7 @@ import kst4contest.ApplicationConstants; import kst4contest.model.AirPlane; import kst4contest.model.AirPlaneReflectionInfo; import kst4contest.model.ChatMember; +import kst4contest.model.ThreadStateMessage; /** * This thread is responsible for reading server's input and printing it to the @@ -24,15 +26,16 @@ public class ReadUDPbyAirScoutMessageThread extends Thread { private ChatController client; private int localPort; private String ASIdentificator, ChatClientIdentificator; - - public ReadUDPbyAirScoutMessageThread(int localPort) { - this.localPort = localPort; - } + private ThreadStatusCallback callBackToController; + private String ThreadNickName = "AirScout msg"; +// public ReadUDPbyAirScoutMessageThread(int localPort) { +// this.localPort = localPort; +// } public ReadUDPbyAirScoutMessageThread(int localPort, ChatController client, String ASIdentificator, - String ChatClientIdentificator) { - + String ChatClientIdentificator, ThreadStatusCallback callback) { + this.callBackToController = callback; this.localPort = localPort; this.client = client; this.ASIdentificator = ASIdentificator; @@ -54,7 +57,13 @@ public class ReadUDPbyAirScoutMessageThread extends Thread { } } - + private void callThreadStateToUi (ThreadStateMessage threadStateMessage) { + if (callBackToController != null) { + //update the visual control of running thread + callBackToController.onThreadStatus("AirScout", threadStateMessage); + } + } + public void run() { Thread.currentThread().setName("ReadUDPByAirScoutThread"); @@ -128,26 +137,30 @@ public class ReadUDPbyAirScoutMessageThread extends Thread { if (received.contains("ASSETPATH") || received.contains("ASWATCHLIST")) { // do nothing, that is your own message } else if (received.contains("ASNEAREST:")) { //answer by airscout - processASUDPMessage(received); -// System.out.println("[ReadUSPASTh, info:] received AS String " + received); +// processASUDPMessage(received); //TODO: 2025-11-Zeile deaktiviert. Fand hier Doppelberechnung statt?! AirPlaneReflectionInfo apReflectInfoForChatMember; apReflectInfoForChatMember = processASUDPMessage(received); - if (this.client.getLst_chatMemberList().size() != 0) { + if (!this.client.getLst_chatMemberList().isEmpty()) { try { -// if (this.client.checkListForChatMemberIndexByCallSign(apReflectInfoForChatMember.getReceiver()) != -1) { +// this.client.getLst_chatMemberList() +// .get(this.client.checkListForChatMemberIndexByCallSign( +// apReflectInfoForChatMember.getReceiver())) +// .setAirPlaneReflectInfo(apReflectInfoForChatMember); // TODO: here we set the ap info at +// // the central instance of +// // chatmember list .... -1 is a +// // problem! + + ArrayList addApInfoToThese = this.client.checkListForChatMemberIndexesByCallSign(apReflectInfoForChatMember.getReceiver()); + addApInfoToThese.forEach((integerIndex) -> {this.client.getLst_chatMemberList().get(integerIndex).setAirPlaneReflectInfo(apReflectInfoForChatMember); }); + + // AirScout availability strongly affects priority => request recompute the score of the chatmember + this.client.getScoreService().requestRecompute("airscout-update"); - this.client.getLst_chatMemberList() - .get(this.client.checkListForChatMemberIndexByCallSign( - apReflectInfoForChatMember.getReceiver())) - .setAirPlaneReflectInfo(apReflectInfoForChatMember); // TODO: here we set the ap info at - // the central instance of - // chatmember list .... -1 is a - // problem! /** * CK| MSGBUS BGFX Listactualizer Exception in thread "Thread-10" * java.util.ConcurrentModificationException at @@ -158,6 +171,7 @@ public class ReadUDPbyAirScoutMessageThread extends Thread { * kst4contest.controller.ReadUDPbyAirScoutMessageThread.run(ReadUDPbyAirScoutMessageThread.java:93) * */ +// System.out.println("[ReadUdpByASth, AP-Info catched: ] " + apReflectInfoForChatMember.toString()); // } } catch (Exception e) { @@ -167,6 +181,13 @@ public class ReadUDPbyAirScoutMessageThread extends Thread { // TODO: handle exception } +// String[] newState = new String[3]; +// newState[0] = "On"; +// newState[1] = "received line"; +// newState[2] = apReflectInfoForChatMember.toString(); +// callThreadStateToUi(newState); + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received line\n" + apReflectInfoForChatMember.toString(), false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); } } diff --git a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java b/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java index f5b0172..42e690d 100644 --- a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java +++ b/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java @@ -4,6 +4,7 @@ import java.io.*; import java.net.*; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Arrays; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; @@ -11,6 +12,7 @@ import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import kst4contest.ApplicationConstants; +import kst4contest.model.ThreadStateMessage; import kst4contest.view.GuiUtils; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -32,13 +34,19 @@ public class ReadUDPbyUCXMessageThread extends Thread { private BufferedReader reader; private Socket socket; private ChatController client; + private int udpPortNr = 12060; + private ThreadStatusCallback callBackToController; + private String ThreadNickName = "UDP-Log msg"; - public ReadUDPbyUCXMessageThread(int localPort) { +// public ReadUDPbyUCXMessageThread(int localPort , ThreadStatusCallback callback) { +// +//// this.callBackToController = callback; +// } - } - - public ReadUDPbyUCXMessageThread(int localPort, ChatController client) { - this.client = client; + public ReadUDPbyUCXMessageThread(int localPort, ChatController client, ThreadStatusCallback callback) { + this.udpPortNr = localPort; + this.client = client; + this.callBackToController = callback; } @Override @@ -48,6 +56,7 @@ public class ReadUDPbyUCXMessageThread extends Thread { if (this.socket != null) { System.out.println(">>>>>>>>>>>>>>ReadUdpbyUCS: closing socket"); terminateConnection(); +// callBackToController.onThreadStatus("UDPReceiver", new String[]); } } catch (Exception e) { // TODO Auto-generated catch block @@ -57,17 +66,22 @@ public class ReadUDPbyUCXMessageThread extends Thread { public void run() { - - + System.out.println("ReadUDPByUCXLogThread: started Thread for UCXLog getUDP"); Thread.currentThread().setName("ReadUDPByUCXLogThread"); - + + + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + DatagramSocket socket = null; - boolean running; + + boolean running; byte[] buf = new byte[1777]; DatagramPacket packet = new DatagramPacket(buf, buf.length); try { - socket = new DatagramSocket(12060); +// socket = new DatagramSocket(12060); + socket = new DatagramSocket(udpPortNr); socket.setSoTimeout(2000); //TODO try for end properly } @@ -99,8 +113,6 @@ public class ReadUDPbyUCXMessageThread extends Thread { nE.printStackTrace(); System.out.println("ReadUdpByUCXTH: Socket not ready"); - - try { socket = new DatagramSocket(client.getChatPreferences().getLogsynch_ucxUDPWkdCallListenerPort()); socket.setSoTimeout(2000); @@ -136,6 +148,12 @@ public class ReadUDPbyUCXMessageThread extends Thread { System.out.println("ReadUdpByUCX, Info: got poison, now dieing...."); socket.close(); timeOutIndicator = true; + +// threadStatusMessage = new String[2]; +// threadStatusMessage[0] = "stopped"; +// threadStatusMessage[1] = "by poisonpill message (disconnect on purpose)"; + threadStateMessage = new ThreadStateMessage(this.ThreadNickName, false, "stopped by Poisonpill", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); break; } @@ -163,7 +181,17 @@ public class ReadUDPbyUCXMessageThread extends Thread { ChatMember modifyThat = null; -// System.out.println(udpMsg); +// System.out.println("ReadUDPByUCX, message catched: " + udpMsg); + +// String[] threadStatusMessage = new String[2]; +// threadStatusMessage = new String[3]; +// threadStatusMessage[0] = "on"; +// threadStatusMessage[1] = "received message:"; +// threadStatusMessage[2] = udpMsg; + + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received Message\n" + udpMsg, false); + + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); try { @@ -240,7 +268,7 @@ public class ReadUDPbyUCXMessageThread extends Thread { case "10G": { workedCall.setWorked10G(true); - + break; } /** @@ -310,56 +338,41 @@ public class ReadUDPbyUCXMessageThread extends Thread { modifyThat = client.getLst_chatMemberList().get(index); modifyThat.setWorked(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)).setWorked(true); if (workedCall.isWorked144()) { modifyThat.setWorked144(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked144(true); } else if (workedCall.isWorked432()) { modifyThat.setWorked432(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked432(true); } else if (workedCall.isWorked1240()) { modifyThat.setWorked1240(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked1240(true); } else if (workedCall.isWorked2300()) { modifyThat.setWorked2300(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked2300(true); } else if (workedCall.isWorked3400()) { modifyThat.setWorked3400(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked3400(true); } else if (workedCall.isWorked5600()) { modifyThat.setWorked5600(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked5600(true); } else if (workedCall.isWorked10G()) { modifyThat.setWorked10G(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked10G(true); } } try { - GuiUtils.triggerGUIFilteredChatMemberListChange(client); //not clean at all + GuiUtils.triggerGUIFilteredChatMemberListChange(this.client); + // BEGIN PATCH: trigger band-upgrade hint after log entry (UCXLog) + try { + client.onExternalLogEntryReceived(workedCall.getCallSignRaw()); + } catch (Exception e) { + System.out.println("[UCXUDPRcvr, warning]: band-upgrade hint failed: " + e.getMessage()); + } + + } catch (Exception IllegalStateException) { //do nothing, as it works... } @@ -539,7 +552,7 @@ public class ReadUDPbyUCXMessageThread extends Thread { this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG); - System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG); +// System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG); } } @@ -549,9 +562,27 @@ public class ReadUDPbyUCXMessageThread extends Thread { e.printStackTrace(); System.out.println(e.getCause()); System.out.println(e.getMessage()); + +// threadStatusMessage = new String[2]; +// threadStatusMessage[0] = "STOPPED"; +// threadStatusMessage[1] = Arrays.toString(e.getStackTrace()); + threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true); + threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace())); + + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); + threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true); + threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace())); + + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + +// threadStatusMessage = new String[2]; +// threadStatusMessage[0] = "STOPPED"; +// threadStatusMessage[1] = Arrays.toString(e.getStackTrace()); +// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage); } // System.out.println("[ReadUDPbyUCXTh: ] worked size = " + this.client.getMap_ucxLogInfoWorkedCalls().size()); @@ -561,6 +592,14 @@ public class ReadUDPbyUCXMessageThread extends Thread { } public boolean terminateConnection() throws IOException { +// String[] threadStatusMessage = new String[2]; +// threadStatusMessage = new String[2]; +// threadStatusMessage[0] = "STOPPED"; +// threadStatusMessage[1] = "Connection terminated for purpose."; +// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage); + + ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, false, "terminated", false); + callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); this.socket.close(); diff --git a/src/main/java/kst4contest/controller/ScoreService.java b/src/main/java/kst4contest/controller/ScoreService.java new file mode 100644 index 0000000..b4b565e --- /dev/null +++ b/src/main/java/kst4contest/controller/ScoreService.java @@ -0,0 +1,309 @@ +package kst4contest.controller; + +import javafx.application.Platform; +import javafx.beans.property.LongProperty; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.beans.property.ReadOnlyDoubleWrapper; +import javafx.beans.property.SimpleLongProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import kst4contest.logic.PriorityCalculator; +import kst4contest.model.ChatCategory; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatPreferences; +import kst4contest.model.ContestSked; + +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +/** + * Calculates priority scores off the JavaFX thread and publishes a small UI model. + * + * Design goals: + * - No per-member Platform.runLater flooding. + * - Score is computed once per callsignRaw (e.g. "SM6VTZ"), even if it exists in multiple chat categories. + * - A routing hint (preferred ChatCategory) is kept using "last inbound category" if available. + */ +public final class ScoreService { + + public static final int DEFAULT_TOP_N = 15; //how many top places we have? + + /** Force a refresh at least every X ms (some scoring inputs are time dependent). */ + private static final long MAX_SNAPSHOT_AGE_MS = 10_000L; + + private final ChatController controller; + private final PriorityCalculator priorityCalculator; + + private final AtomicBoolean recomputeRequested = new AtomicBoolean(true); + private final AtomicReference latestSnapshot = new AtomicReference<>(ScoreSnapshot.empty()); + + // UI outputs + private final ObservableList topCandidatesFx = FXCollections.observableArrayList(); + private final ReadOnlyDoubleWrapper selectedCallPriorityScore = new ReadOnlyDoubleWrapper(Double.NaN); + private final LongProperty uiPulse = new SimpleLongProperty(0); + + private volatile String selectedCallSignRaw; + private volatile long lastComputedEpochMs = 0L; + private final int topN; + + private final ObjectProperty selectedChatMember = new SimpleObjectProperty<>(null); + + + public ScoreService(ChatController controller, PriorityCalculator priorityCalculator, int topN) { + this.controller = Objects.requireNonNull(controller, "controller"); + this.priorityCalculator = Objects.requireNonNull(priorityCalculator, "priorityCalculator"); + this.topN = topN > 0 ? topN : DEFAULT_TOP_N; + } + + public ObservableList getTopCandidatesFx() { + return topCandidatesFx; + } + + public ReadOnlyDoubleProperty selectedCallPriorityScoreProperty() { + return selectedCallPriorityScore.getReadOnlyProperty(); + } + + /** + * A lightweight UI invalidation signal that increments after every published snapshot. + * Consumers can refresh small panels (timeline/toplist), but should avoid refreshing huge tables. + */ + public LongProperty uiPulseProperty() { + return uiPulse; + } + + public ScoreSnapshot getLatestSnapshot() { + return latestSnapshot.get(); + } + + /** Coalesced recompute request (safe to call frequently from other threads). */ + public void requestRecompute(String reason) { + recomputeRequested.set(true); + } + + /** Called by UI when selection changes. */ + public void setSelectedChatMember(ChatMember member) { + + // keep a central selection for UI actions (FurtherInfo buttons, timeline clicks, etc.) + if (Platform.isFxApplicationThread()) { + selectedChatMember.set(member); + } else { + Platform.runLater(() -> selectedChatMember.set(member)); + } + + selectedCallSignRaw = member == null ? null : normalizeCallRaw(member.getCallSignRaw()); + + // Update score immediately from the latest snapshot + if (Platform.isFxApplicationThread()) { + updateSelectedScoreFromSnapshot(latestSnapshot.get()); + } else { + Platform.runLater(() -> updateSelectedScoreFromSnapshot(latestSnapshot.get())); + } + } + + /** + * Called periodically by the scheduler thread. + * Recomputes only if explicitly requested or if the snapshot is too old. + */ + public void tick() { + long now = System.currentTimeMillis(); + + boolean shouldRecompute = recomputeRequested.getAndSet(false) || (now - lastComputedEpochMs) > MAX_SNAPSHOT_AGE_MS; + if (!shouldRecompute) return; + + try { + + // Apply "no reply" strikes (operator pinged via /cq but no inbound line arrived) + controller.getStationMetricsService().evaluateNoReplyTimeouts(now, controller.getChatPreferences()); + + recompute(now); + } catch (Exception e) { + System.err.println("[ScoreService] CRITICAL error while recomputing scores"); + e.printStackTrace(); + } + } + + private void recompute(long nowEpochMs) { + + // Keep sked list clean (must happen on FX thread) + controller.requestRemoveExpiredSkeds(nowEpochMs); + + final List members = controller.snapshotChatMembers(); + final List activeSkeds = controller.snapshotActiveSkeds(); + final ChatPreferences prefs = controller.getChatPreferences(); + final Map lastInbound = controller.snapshotLastInboundCategoryMap(); + + StationMetricsService.Snapshot metricsSnapshot = + controller.getStationMetricsService().snapshot(nowEpochMs, prefs); + + // 1) Choose one representative per callsignRaw + Map representativeByCallRaw = chooseRepresentativeMembers(members, lastInbound); + + // 2) Compute score once per callsignRaw + Map scoreByCallRaw = new HashMap<>(representativeByCallRaw.size()); + Map preferredCategoryByCallRaw = new HashMap<>(representativeByCallRaw.size()); + List topAll = new ArrayList<>(representativeByCallRaw.size()); + + for (Map.Entry e : representativeByCallRaw.entrySet()) { + String callRaw = e.getKey(); + ChatMember representative = e.getValue(); + if (representative == null) continue; + + double score = priorityCalculator.calculatePriority( + representative, + prefs, + activeSkeds, + metricsSnapshot, + nowEpochMs + ); + + scoreByCallRaw.put(callRaw, score); + preferredCategoryByCallRaw.put(callRaw, representative.getChatCategory()); + + topAll.add(new TopCandidate(callRaw, representative.getCallSign(), representative.getChatCategory(), score)); + } + + // 3) Build Top-N + topAll.sort(Comparator.comparingDouble(TopCandidate::getScore).reversed()); + List topNList = topAll.size() <= topN ? topAll : new ArrayList<>(topAll.subList(0, topN)); + + ScoreSnapshot snap = new ScoreSnapshot( + nowEpochMs, + Collections.unmodifiableMap(scoreByCallRaw), + Collections.unmodifiableMap(preferredCategoryByCallRaw), + Collections.unmodifiableList(topNList) + ); + + latestSnapshot.set(snap); + lastComputedEpochMs = nowEpochMs; + + // 4) Publish to UI in ONE batched runLater + Platform.runLater(() -> { + topCandidatesFx.setAll(snap.getTopCandidates()); + updateSelectedScoreFromSnapshot(snap); + uiPulse.set(uiPulse.get() + 1); + }); + } + + /** + * Picks one ChatMember object per callsignRaw. + * Preference order: + * 1) Variant in last inbound chat category (stable reply routing) + * 2) Most recently active variant (fallback) + */ + private Map chooseRepresentativeMembers( + List members, + Map lastInboundCategoryByCallRaw + ) { + Map> byCallRaw = new HashMap<>(); + + for (ChatMember m : members) { + if (m == null) continue; + String callRaw = normalizeCallRaw(m.getCallSignRaw()); + if (callRaw == null || callRaw.isEmpty()) continue; + byCallRaw.computeIfAbsent(callRaw, k -> new ArrayList<>()).add(m); + } + + Map representative = new HashMap<>(byCallRaw.size()); + + for (Map.Entry> entry : byCallRaw.entrySet()) { + String callRaw = entry.getKey(); + List variants = entry.getValue(); + + ChatCategory preferredCat = lastInboundCategoryByCallRaw.get(callRaw); + ChatMember chosen = null; + + if (preferredCat != null) { + for (ChatMember v : variants) { + if (v != null && v.getChatCategory() == preferredCat) { + chosen = v; + break; + } + } + } + + if (chosen == null) { + chosen = variants.stream() + .filter(Objects::nonNull) + .max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch)) + .orElse(null); + } + + if (chosen != null) representative.put(callRaw, chosen); + } + + return representative; + } + + private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) { + if (snap == null || selectedCallSignRaw == null) { + selectedCallPriorityScore.set(Double.NaN); + return; + } + Double v = snap.getScoreByCallSignRaw().get(selectedCallSignRaw); + selectedCallPriorityScore.set(v == null ? Double.NaN : v); + } + + private static String normalizeCallRaw(String callRaw) { + if (callRaw == null) return null; + return callRaw.trim().toUpperCase(); + } + + // ------------------------- DTOs ------------------------- + + public static final class TopCandidate { + private final String callSignRaw; + private final String displayCallSign; + private final ChatCategory preferredChatCategory; + private final double score; + + public TopCandidate(String callSignRaw, String displayCallSign, ChatCategory preferredChatCategory, double score) { + this.callSignRaw = callSignRaw; + this.displayCallSign = displayCallSign; + this.preferredChatCategory = preferredChatCategory; + this.score = score; + } + + public String getCallSignRaw() { return callSignRaw; } + public String getDisplayCallSign() { return displayCallSign; } + public ChatCategory getPreferredChatCategory() { return preferredChatCategory; } + public double getScore() { return score; } + } + + public static final class ScoreSnapshot { + private final long computedAtEpochMs; + private final Map scoreByCallSignRaw; + private final Map preferredCategoryByCallSignRaw; + private final List topCandidates; + + public ScoreSnapshot(long computedAtEpochMs, + Map scoreByCallSignRaw, + Map preferredCategoryByCallSignRaw, + List topCandidates) { + this.computedAtEpochMs = computedAtEpochMs; + this.scoreByCallSignRaw = scoreByCallSignRaw; + this.preferredCategoryByCallSignRaw = preferredCategoryByCallSignRaw; + this.topCandidates = topCandidates; + } + + public static ScoreSnapshot empty() { + return new ScoreSnapshot(System.currentTimeMillis(), Collections.emptyMap(), Collections.emptyMap(), Collections.emptyList()); + } + + public long getComputedAtEpochMs() { return computedAtEpochMs; } + public Map getScoreByCallSignRaw() { return scoreByCallSignRaw; } + public Map getPreferredCategoryByCallSignRaw() { return preferredCategoryByCallSignRaw; } + public List getTopCandidates() { return topCandidates; } + } + + public ReadOnlyObjectProperty selectedChatMemberProperty() { + return selectedChatMember; + } + + public ChatMember getSelectedChatMember() { + return selectedChatMember.get(); + } +} diff --git a/src/main/java/kst4contest/controller/SkedReminderService.java b/src/main/java/kst4contest/controller/SkedReminderService.java new file mode 100644 index 0000000..99e16f9 --- /dev/null +++ b/src/main/java/kst4contest/controller/SkedReminderService.java @@ -0,0 +1,124 @@ +package kst4contest.controller; + +import javafx.application.Platform; +import kst4contest.model.ChatCategory; +import kst4contest.model.ThreadStateMessage; + + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; + +/** + * Schedules PM reminders for a specific sked time. + * + * Requirements: + * - Reminder goes out as PM to the station (via "/cq CALL ..."). + * - Reminders are armed manually from FurtherInfo. + */ +public final class SkedReminderService { + + private final ChatController controller; + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r); + t.setDaemon(true); + t.setName("SkedReminderService"); + return t; + }); + + private final ConcurrentHashMap>> scheduledByCallRaw = new ConcurrentHashMap<>(); + + public SkedReminderService(ChatController controller) { + this.controller = controller; + } + + /** + * Arms reminders for one sked. Existing reminders for this call are cancelled. + * + * @param callSignRaw target call + * @param preferredCategory where to send (if null, controller resolves via lastInbound category) + * @param skedTimeEpochMs sked time + * @param offsetsMinutes e.g. [5,2,1] => reminders 5,2,1 minutes before + */ + public void armReminders(String callSignRaw, + ChatCategory preferredCategory, + long skedTimeEpochMs, + List offsetsMinutes) { + + String callRaw = normalize(callSignRaw); + if (callRaw == null || callRaw.isBlank()) return; + + cancelReminders(callRaw); + + long now = System.currentTimeMillis(); + List offsets = (offsetsMinutes == null) ? List.of() : offsetsMinutes; + + List> futures = new ArrayList<>(); + for (Integer offMin : offsets) { + if (offMin == null) continue; + + long fireAt = skedTimeEpochMs - (offMin * 60_000L); + long delayMs = fireAt - now; + if (delayMs <= 0) continue; + + ScheduledFuture f = scheduler.schedule( + () -> fireReminder(callRaw, preferredCategory, offMin), + delayMs, + TimeUnit.MILLISECONDS + ); + futures.add(f); + } + + scheduledByCallRaw.put(callRaw, futures); + + controller.onThreadStatus("SkedReminderService", + new ThreadStateMessage("SkedReminder", true, + "Armed for " + callRaw + " (" + offsets + " min before)", false)); + } + + public void cancelReminders(String callSignRaw) { + String callRaw = normalize(callSignRaw); + if (callRaw == null || callRaw.isBlank()) return; + + List> futures = scheduledByCallRaw.remove(callRaw); + if (futures != null) { + for (ScheduledFuture f : futures) { + if (f != null) f.cancel(false); + } + } + } + + private void fireReminder(String callRaw, ChatCategory preferredCategory, int minutesBefore) { + try { + controller.queuePrivateCqMessage(callRaw, preferredCategory, "[KST4C Autoreminder] sked in " + minutesBefore + " min"); + controller.fireUiReminderEvent(callRaw, minutesBefore); //triggers some blingbling in the UI + + ///Local acoustic hint (reuse existing project audio utilities, no AWT, no extra JavaFX modules) + + try { + if (controller.getChatPreferences().isNotify_playSimpleSounds()) { + controller.getPlayAudioUtils().playNoiseLauncher('!'); // choose a suitable char you already use + } + // Optional: voice/cw hint (short, not too intrusive) + // controller.getPlayAudioUtils().playCWLauncher(" SKED " + minutesBefore); + } catch (Exception ignore) { + // never block reminder sending because of audio issues + } + + controller.onThreadStatus("SkedReminderService", + new ThreadStateMessage("SkedReminder", true, + "PM reminder sent to " + callRaw + " (" + minutesBefore + " min)", false)); + } catch (Exception e) { + controller.onThreadStatus("SkedReminderService", + new ThreadStateMessage("SkedReminder", false, + "ERROR sending reminder to " + callRaw + ": " + e.getMessage(), true)); + e.printStackTrace(); + } + } + + private static String normalize(String s) { + if (s == null) return null; + return s.trim().toUpperCase(); + } +} diff --git a/src/main/java/kst4contest/controller/StationMetricsService.java b/src/main/java/kst4contest/controller/StationMetricsService.java new file mode 100644 index 0000000..ced6a3d --- /dev/null +++ b/src/main/java/kst4contest/controller/StationMetricsService.java @@ -0,0 +1,268 @@ +package kst4contest.controller; + +import kst4contest.logic.SignalDetector; +import kst4contest.model.ChatPreferences; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Thread-safe metrics store keyed by normalized callsignRaw (e.g. "SM6VTZ"). + * + * Purpose: + * - Provide inputs for scoring (momentum, reply time, no-reply strikes, manual sked-fail, positive signals). + * - Decouple MessageBus / TX from ScoreService (only data flows, no UI calls here). + */ +public final class StationMetricsService { + + /** /cq ... */ + private static final Pattern OUTBOUND_CQ_PATTERN = Pattern.compile("(?i)^\\s*/cq\\s+([A-Z0-9/]+)\\b.*"); + + /** Rolling window timestamps for momentum scoring. */ + private static final int MAX_STORED_INBOUND_TIMESTAMPS = 32; + + private final ConcurrentHashMap byCallRaw = new ConcurrentHashMap<>(); + + /** + * Called when the operator sends a message. + * If it is a "/cq CALL ..." message, this arms a pending ping for response-time / no-reply tracking. + */ + public Optional tryRecordOutboundCq(String messageText, long nowEpochMs) { + if (messageText == null) return Optional.empty(); + + Matcher m = OUTBOUND_CQ_PATTERN.matcher(messageText.trim()); + if (!m.matches()) return Optional.empty(); + + String callRaw = normalizeCallRaw(m.group(1)); + if (callRaw == null || callRaw.isBlank()) return Optional.empty(); + + StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics()); + synchronized (metrics) { + metrics.pendingCqSentAtEpochMs = nowEpochMs; + metrics.lastOutboundCqEpochMs = nowEpochMs; + } + return Optional.of(callRaw); + } + + /** + * Called for EVERY inbound line from a station (CH or PM). + * "Any line counts as activity" + */ + public void onInboundMessage(String senderCallSignRaw, + long nowEpochMs, + String messageText, + ChatPreferences prefs, + String ownCallSignRaw) { + + String callRaw = normalizeCallRaw(senderCallSignRaw); + if (callRaw == null || callRaw.isBlank()) return; + + // ignore own echoed messages + if (ownCallSignRaw != null && callRaw.equalsIgnoreCase(normalizeCallRaw(ownCallSignRaw))) return; + + StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics()); + synchronized (metrics) { + metrics.lastInboundEpochMs = nowEpochMs; + + // rolling timestamps (momentum) + metrics.recentInboundEpochMs.addLast(nowEpochMs); + while (metrics.recentInboundEpochMs.size() > MAX_STORED_INBOUND_TIMESTAMPS) { + metrics.recentInboundEpochMs.removeFirst(); + } + + // positive signal detection (extendable by prefs) + if (messageText != null && prefs != null) { + if (SignalDetector.containsPositiveSignal(messageText, prefs.getNotify_positiveSignalsPatterns())) { + metrics.lastPositiveSignalEpochMs = nowEpochMs; + } + } + + // response time measurement: any inbound line ends a pending ping + if (metrics.pendingCqSentAtEpochMs > 0) { + long rttMs = Math.max(0, nowEpochMs - metrics.pendingCqSentAtEpochMs); + metrics.pendingCqSentAtEpochMs = 0; + + // EWMA for response time (stable, no spikes) + final double alpha = 0.25; + if (metrics.avgResponseTimeMs <= 0) { + metrics.avgResponseTimeMs = rttMs; + } else { + metrics.avgResponseTimeMs = alpha * rttMs + (1.0 - alpha) * metrics.avgResponseTimeMs; + } + } + } + } + + /** + * Called periodically (e.g. from ScoreService.tick()). + * Applies a "no reply" strike if the pending ping is older than prefs timeout. + */ + public void evaluateNoReplyTimeouts(long nowEpochMs, ChatPreferences prefs) { + if (prefs == null) return; + long timeoutMs = Math.max(1, prefs.getNotify_noReplyPenaltyMinutes()) * 60_000L; + + for (Map.Entry e : byCallRaw.entrySet()) { + StationMetrics metrics = e.getValue(); + if (metrics == null) continue; + + synchronized (metrics) { + if (metrics.pendingCqSentAtEpochMs <= 0) continue; + + long age = nowEpochMs - metrics.pendingCqSentAtEpochMs; + if (age >= timeoutMs) { + metrics.pendingCqSentAtEpochMs = 0; + metrics.noReplyStrikes++; + metrics.lastNoReplyStrikeEpochMs = nowEpochMs; + } + } + } + } + + /** Manual sked fail: permanent until reset. */ + public void markManualSkedFail(String callSignRaw) { + String callRaw = normalizeCallRaw(callSignRaw); + if (callRaw == null || callRaw.isBlank()) return; + + StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics()); + synchronized (metrics) { + metrics.manualSkedFailed = true; + metrics.manualSkedFailCount++; + } + } + + public void resetManualSkedFail(String callSignRaw) { + String callRaw = normalizeCallRaw(callSignRaw); + if (callRaw == null || callRaw.isBlank()) return; + + StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics()); + synchronized (metrics) { + metrics.manualSkedFailed = false; + metrics.manualSkedFailCount = 0; + } + } + + public boolean isManualSkedFailed(String callSignRaw) { + String callRaw = normalizeCallRaw(callSignRaw); + if (callRaw == null || callRaw.isBlank()) return false; + + StationMetrics metrics = byCallRaw.get(callRaw); + if (metrics == null) return false; + + synchronized (metrics) { + return metrics.manualSkedFailed; + } + } + + /** Immutable snapshot for scoring */ + public Snapshot snapshot(long nowEpochMs, ChatPreferences prefs) { + long momentumWindowMs = (prefs != null ? prefs.getNotify_momentumWindowSeconds() : 180) * 1000L; + + Snapshot snap = new Snapshot(nowEpochMs, momentumWindowMs); + for (Map.Entry e : byCallRaw.entrySet()) { + String callRaw = e.getKey(); + StationMetrics m = e.getValue(); + if (m == null) continue; + + synchronized (m) { + snap.byCallRaw.put(callRaw, new Snapshot.Metrics( + m.lastInboundEpochMs, + countRecent(m.recentInboundEpochMs, nowEpochMs, momentumWindowMs), + m.avgResponseTimeMs, + m.noReplyStrikes, + m.manualSkedFailed, + m.manualSkedFailCount, + m.lastPositiveSignalEpochMs + )); + } + } + return snap; + } + + private static int countRecent(Deque timestamps, long nowEpochMs, long windowMs) { + if (timestamps == null || timestamps.isEmpty()) return 0; + int cnt = 0; + for (Long t : timestamps) { + if (t == null) continue; + if (nowEpochMs - t <= windowMs) cnt++; + } + return cnt; + } + + private static String normalizeCallRaw(String s) { + if (s == null) return null; + return s.trim().toUpperCase(); + } + + private static final class StationMetrics { + long lastInboundEpochMs; + long lastOutboundCqEpochMs; + + long pendingCqSentAtEpochMs; // 0 = none + int noReplyStrikes; + long lastNoReplyStrikeEpochMs; + + double avgResponseTimeMs; // EWMA + final Deque recentInboundEpochMs = new ArrayDeque<>(); + + long lastPositiveSignalEpochMs; + + boolean manualSkedFailed; + int manualSkedFailCount; + } + + public static final class Snapshot { + private final long snapshotEpochMs; + private final long momentumWindowMs; + private final ConcurrentHashMap byCallRaw = new ConcurrentHashMap<>(); + + private Snapshot(long snapshotEpochMs, long momentumWindowMs) { + this.snapshotEpochMs = snapshotEpochMs; + this.momentumWindowMs = momentumWindowMs; + } + + public Metrics get(String callSignRaw) { + if (callSignRaw == null) return null; + return byCallRaw.get(normalizeCallRaw(callSignRaw)); + } + + public long getSnapshotEpochMs() { + return snapshotEpochMs; + } + + public long getMomentumWindowMs() { + return momentumWindowMs; + } + + public static final class Metrics { + public final long lastInboundEpochMs; + public final int inboundCountInWindow; + public final double avgResponseTimeMs; + public final int noReplyStrikes; + public final boolean manualSkedFailed; + public final int manualSkedFailCount; + public final long lastPositiveSignalEpochMs; + + public Metrics(long lastInboundEpochMs, + int inboundCountInWindow, + double avgResponseTimeMs, + int noReplyStrikes, + boolean manualSkedFailed, + int manualSkedFailCount, + long lastPositiveSignalEpochMs) { + + this.lastInboundEpochMs = lastInboundEpochMs; + this.inboundCountInWindow = inboundCountInWindow; + this.avgResponseTimeMs = avgResponseTimeMs; + this.noReplyStrikes = noReplyStrikes; + this.manualSkedFailed = manualSkedFailed; + this.manualSkedFailCount = manualSkedFailCount; + this.lastPositiveSignalEpochMs = lastPositiveSignalEpochMs; + } + } + } +} diff --git a/src/main/java/kst4contest/controller/StatusUpdateListener.java b/src/main/java/kst4contest/controller/StatusUpdateListener.java new file mode 100644 index 0000000..b866203 --- /dev/null +++ b/src/main/java/kst4contest/controller/StatusUpdateListener.java @@ -0,0 +1,20 @@ +package kst4contest.controller; + +import kst4contest.model.ThreadStateMessage; + +public interface StatusUpdateListener { + + /** + * Thread (key) will send update status (value) to the view via this interface. + * + */ + void onThreadStatusChanged(String key, ThreadStateMessage threadStateMessage); + + + /** + * Called on change if the userlist to update the UI (sort the chatmembers list) + */ + void onUserListUpdated(String reason); + // new: userlist-update + +} diff --git a/src/main/java/kst4contest/controller/ThreadStatusCallback.java b/src/main/java/kst4contest/controller/ThreadStatusCallback.java new file mode 100644 index 0000000..e45b871 --- /dev/null +++ b/src/main/java/kst4contest/controller/ThreadStatusCallback.java @@ -0,0 +1,8 @@ +package kst4contest.controller; + +import kst4contest.model.ThreadStateMessage; + +public interface ThreadStatusCallback { + void onThreadStatus(String threadName, ThreadStateMessage threadStateMessage); + +} diff --git a/src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java b/src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java index 2e899f7..a2a1ae7 100644 --- a/src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java +++ b/src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java @@ -15,7 +15,10 @@ import kst4contest.model.ChatMember; public class UCXLogFileToHashsetParser { public BufferedReader fileReader; - private final String PTRN_CallSign = "(([a-zA-Z]{1,2}[\\d{1}]?\\/)?(\\d{1}[a-zA-Z][\\d{1}][a-zA-Z]{1,3})((\\/p)|(\\/\\d))?)|(([a-zA-Z0-9]{1,2}[\\d{1}]?\\/)?(([a-zA-Z]{1,2}(\\d{1}[a-zA-Z]{1,4})))((\\/p)|(\\/\\d))?)"; +// private final String PTRN_CallSign = "(([a-zA-Z]{1,2}[\\d{1}]?\\/)?(\\d{1}[a-zA-Z][\\d{1}][a-zA-Z]{1,3})((\\/p)|(\\/\\d))?)|(([a-zA-Z0-9]{1,2}[\\d{1}]?\\/)?(([a-zA-Z]{1,2}(\\d{1}[a-zA-Z]{1,4})))((\\/p)|(\\/\\d))?)"; //OLD, S51AR for example will not work + private final String PTRN_CallSign = "(([a-zA-Z]{1,2}[\\d]{1}?\\/)?(\\d{1}[a-zA-Z][\\d]{1}[a-zA-Z]{1,3})((\\/p)|(\\/\\d))?)|(([a-zA-Z0-9]{1,2}[\\d]{1}?\\/)?(([a-zA-Z]{1,2}(\\d{1}[a-zA-Z]{1,4})))((\\/p)|(\\/\\d))?)|([A-Z]\\d{2}[A-Z]{1,3})"; + + public UCXLogFileToHashsetParser(String filePathAndName) { diff --git a/src/main/java/kst4contest/controller/UserActualizationTask.java b/src/main/java/kst4contest/controller/UserActualizationTask.java index 034576e..4033add 100644 --- a/src/main/java/kst4contest/controller/UserActualizationTask.java +++ b/src/main/java/kst4contest/controller/UserActualizationTask.java @@ -51,21 +51,10 @@ public class UserActualizationTask extends TimerTask { UCXLogFileToHashsetParser getWorkedCallsignsOfUCXLogFile = new UCXLogFileToHashsetParser( this.client.getChatPreferences().getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()); -// UCXLogFileToHashsetParser getWorkedCallsignsOfUDPBackupFile = new UCXLogFileToHashsetParser( -// this.client.getChatPreferences().getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup()); - try { fetchedWorkedSet = getWorkedCallsignsOfUCXLogFile.parse(); -// fetchedWorkedSetUdpBckup = getWorkedCallsignsOfUDPBackupFile.parse(); - -// for (HashMap.Entry entry : fetchedWorkedSet.entrySet()) { -// String key = (String) entry.getKey(); -// Object value = entry.getValue(); -// System.out.println("key " + key); -// } System.out.println("USERACT: fetchedWorkedSet size: " + fetchedWorkedSet.size()); -// System.out.println("USERACT: fetchedWorkedSetudpbckup size: " + fetchedWorkedSetUdpBckup.size()); } catch (IOException e) { // TODO Auto-generated catch block diff --git a/src/main/java/kst4contest/controller/Utils4KST.java b/src/main/java/kst4contest/controller/Utils4KST.java index ffab4c8..3e749cb 100644 --- a/src/main/java/kst4contest/controller/Utils4KST.java +++ b/src/main/java/kst4contest/controller/Utils4KST.java @@ -52,7 +52,8 @@ public class Utils4KST { // Instant instant = Instant.ofEpochSecond(epoch); Date date = new Date(epoch * 1000L); - DateFormat format = new SimpleDateFormat("dd.MM HH:mm:ss"); +// DateFormat format = new SimpleDateFormat("dd.MM HH:mm:ss"); //old value which is too long + DateFormat format = new SimpleDateFormat("H:mm:ss"); format.setTimeZone(TimeZone.getTimeZone("Etc/UTC")); String formatted = format.format(date); diff --git a/src/main/java/kst4contest/controller/WriteThread.java b/src/main/java/kst4contest/controller/WriteThread.java index 53f2bb9..ec76443 100644 --- a/src/main/java/kst4contest/controller/WriteThread.java +++ b/src/main/java/kst4contest/controller/WriteThread.java @@ -5,6 +5,7 @@ import java.net.*; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import kst4contest.ApplicationConstants; import kst4contest.model.ChatMessage; /** @@ -169,8 +170,8 @@ public class WriteThread extends Thread { try { messageToBeSend = client.getMessageTXBus().take(); - if (messageToBeSend.getMessageText().equals("POISONPILL_KILLTHREAD") - && messageToBeSend.getMessageSenderName().equals("POISONPILL_KILLTHREAD")) { + if (messageToBeSend.getMessageText().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL) + && messageToBeSend.getMessageSenderName().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) { client.getMessageRXBus().clear(); this.interrupt(); break; diff --git a/src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java b/src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java new file mode 100644 index 0000000..6db955b --- /dev/null +++ b/src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java @@ -0,0 +1,13 @@ +package kst4contest.controller.interfaces; + +public interface PstRotatorEventListener { + + void onAzimuthUpdate(double azimuth); + void onElevationUpdate(double elevation); + void onModeUpdate(boolean isTracking); // true = Tracking, false = Manual + void onMessageReceived(String rawMessage); // Debugging usage +// void setRotorPosition(double azimuth); + +} + + diff --git a/src/main/java/kst4contest/logic/PriorityCalculator.java b/src/main/java/kst4contest/logic/PriorityCalculator.java new file mode 100644 index 0000000..fd9d3a7 --- /dev/null +++ b/src/main/java/kst4contest/logic/PriorityCalculator.java @@ -0,0 +1,292 @@ +package kst4contest.logic; + +import kst4contest.controller.StationMetricsService; +import kst4contest.model.*; + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; + +/** + * Priority score calculation (off FX-thread). + * + * Notes: + * - Score is computed once per callsignRaw by ScoreService. + * - This calculator MUST be pure (no UI calls) and fast. + */ +public class PriorityCalculator { + + /** Max age for "known active bands" (derived from chat history). */ + private static final long RX_BANDS_MAX_AGE_MS = 30L * 60L * 1000L; // 30 minutes + + public double calculatePriority(ChatMember member, + ChatPreferences prefs, + List activeSkeds, + StationMetricsService.Snapshot metricsSnapshot, + long nowEpochMs) { + + if (member == null || prefs == null) return 0.0; + + final String callRaw = normalize(member.getCallSignRaw()); + if (callRaw == null || callRaw.isBlank()) return 0.0; + + // -------------------------------------------------------------------- + // 1) HARD FILTER: reachable hardware + "already worked on all possible bands" + // -------------------------------------------------------------------- + // -------------------------------------------------------------------- +// 1) HARD FILTER: reachable hardware + "already worked on all possible bands" +// -------------------------------------------------------------------- + EnumSet myEnabledBands = getMyEnabledBands(prefs); + +// "worked" for scoring is derived ONLY from per-band flags (worked144/432/...) +// IMPORTANT: ChatMember.worked is UI-only and NOT used in scoring. + EnumSet workedBandsForScoring = getWorkedBands(member); + +// Remaining bands that are: +// - recently offered by the station (from knownActiveBands history) +// - enabled at our station +// - NOT worked yet (per-band flags) +// If we do not know offered bands (history empty), this remains empty. + EnumSet unworkedPossible = EnumSet.noneOf(Band.class); + + EnumSet stationOfferedBands = getStationOfferedBandsFromHistory(member, nowEpochMs); + EnumSet possibleBands = stationOfferedBands.isEmpty() + ? EnumSet.noneOf(Band.class) // unknown => don't hard-filter + : EnumSet.copyOf(stationOfferedBands); + + if (!possibleBands.isEmpty()) { + possibleBands.retainAll(myEnabledBands); + if (possibleBands.isEmpty()) { + // We know their bands, but none of them are enabled at our station. + return 0.0; + } + + unworkedPossible = EnumSet.copyOf(possibleBands); + unworkedPossible.removeAll(workedBandsForScoring); + + // If already worked on all possible bands => no priority on them anymore (contest logic). + if (unworkedPossible.isEmpty()) { + return 0.0; + } + } + + // -------------------------------------------------------------------- + // 2) BASE SCORE + // -------------------------------------------------------------------- + double score = 100.0; + +// if (!member.isWorked()) { +// score += 200.0; +// } + + //"worked" for scoring is derived ONLY from per-band flags (worked144/432/...) +// EnumSet workedBandsForScoring = getWorkedBands(member); + + if (workedBandsForScoring.isEmpty()) { + score += 200.0; // never worked on any supported band -> higher priority + } else { + score -= 150.0; // already worked on at least one band -> lower base priority + } + + + // Multi-band bonus: if they offer >1 possible band and we worked at least one, prefer them + if (!possibleBands.isEmpty()) { + int bandCount = possibleBands.size(); + score += (bandCount - 1) * 80.0; + } + + // Optional: band-upgrade visibility boost + // If the station is already worked on at least one band, but is still QRV on other unworked enabled band(s), + // we can optionally add a boost so it remains visible in the list. + if (prefs.isNotify_bandUpgradePriorityBoostEnabled() + && !workedBandsForScoring.isEmpty() + && !unworkedPossible.isEmpty()) { + score += 180.0; // tuned visibility boost + } + + // -------------------------------------------------------------------- + // 3) DISTANCE ("Goldilocks Zone") + // -------------------------------------------------------------------- + double distKm = member.getQrb() == null ? 0.0 : member.getQrb(); + + if (distKm > 0) { + if (distKm < 200) { + score *= 0.7; + } else if (distKm > prefs.getStn_maxQRBDefault()) { + score *= 0.3; + } else { + score *= 1.15; + } + } + + // -------------------------------------------------------------------- + // 4) AIRSCOUT BOOST + // -------------------------------------------------------------------- + AirPlaneReflectionInfo apInfo = member.getAirPlaneReflectInfo(); + if (apInfo != null && apInfo.getAirPlanesReachableCntr() > 0) { + score += 200; + + int nextMinutes = findNextAirplaneArrivingMinutes(apInfo); + if (nextMinutes == 0) score += 120; + else if (nextMinutes == 1) score += 60; + else if (nextMinutes == 2) score += 30; + } + + // -------------------------------------------------------------------- + // 5) BOOST IDEA #1: Beam direction match (within beamwidth) + // -------------------------------------------------------------------- + if (member.getQTFdirection() != null) { + double myAz = prefs.getActualQTF().getValue(); + double targetAz = member.getQTFdirection(); + double diff = minimalAngleDiffDeg(myAz, targetAz); + + double halfBeam = Math.max(1.0, prefs.getStn_antennaBeamWidthDeg()) / 2.0; + if (diff <= halfBeam) { + double centerFactor = 1.0 - (diff / halfBeam); // 1.0 center -> 0.0 edge + score += 80.0 + (120.0 * centerFactor); + } + } + + // -------------------------------------------------------------------- + // 6) BOOST IDEA #3: Conversation momentum (recent inbound burst) + // -------------------------------------------------------------------- + if (metricsSnapshot != null) { + StationMetricsService.Snapshot.Metrics mx = metricsSnapshot.get(callRaw); + if (mx != null) { + long ageMs = mx.lastInboundEpochMs > 0 ? (nowEpochMs - mx.lastInboundEpochMs) : Long.MAX_VALUE; + + // "Active now" bonus + if (ageMs < 60_000) score += 120; + else if (ageMs < 3 * 60_000) score += 60; + + // Momentum bonus: multiple lines in the configured window + int cnt = mx.inboundCountInWindow; + if (cnt >= 6) score += 160; + else if (cnt >= 4) score += 110; + else if (cnt >= 2) score += 60; + + // Positive signal (configurable) + if (mx.lastPositiveSignalEpochMs > 0 && (nowEpochMs - mx.lastPositiveSignalEpochMs) < 5 * 60_000) { + score += 120; + } + + // Reply time: prefer fast responders + if (mx.avgResponseTimeMs > 0) { + if (mx.avgResponseTimeMs < 60_000) score += 80; + else if (mx.avgResponseTimeMs < 3 * 60_000) score += 40; + } + + // No-reply penalty (automatic failed attempt) + if (mx.noReplyStrikes > 0) { + score /= (1.0 + (mx.noReplyStrikes * 0.6)); + } + + // Manual sked fail (path likely bad) => strong, permanent penalty until reset + if (mx.manualSkedFailed) { + score *= 0.15; + } + } + } + + // -------------------------------------------------------------------- + // 7) BOOST IDEA #4: Sked commitment ramp-up + // -------------------------------------------------------------------- + if (activeSkeds != null && !activeSkeds.isEmpty()) { + for (ContestSked sked : activeSkeds) { + if (sked == null) continue; + + if (!callRaw.equals(normalize(sked.getTargetCallsign()))) continue; + + long seconds = sked.getTimeUntilSkedSeconds(); + + // Imminent sked: absolute priority (T-3min..T+1min) + if (seconds < 180 && seconds > -60) { + score += 5000; + continue; + } + + // Ramp: 0..15 minutes before => up to +1200 + if (seconds >= 0 && seconds <= 15 * 60) { + double t = (15 * 60 - seconds) / (15.0 * 60.0); // 0.0..1.0 + score += 300 + (900 * t); + } else if (seconds > 15 * 60) { + score += 40; + } + } + } + + // -------------------------------------------------------------------- + // 8) Legacy penalty: failed attempts in ChatMember + // -------------------------------------------------------------------- + if (member.getFailedQSOAttempts() > 0) { + score = score / (member.getFailedQSOAttempts() + 1); + } + + return Math.max(0.0, score); + } + + private static EnumSet getMyEnabledBands(ChatPreferences prefs) { + EnumSet out = EnumSet.noneOf(Band.class); + if (prefs.isStn_bandActive144()) out.add(Band.B_144); + if (prefs.isStn_bandActive432()) out.add(Band.B_432); + if (prefs.isStn_bandActive1240()) out.add(Band.B_1296); + if (prefs.isStn_bandActive2300()) out.add(Band.B_2320); + if (prefs.isStn_bandActive3400()) out.add(Band.B_3400); + if (prefs.isStn_bandActive5600()) out.add(Band.B_5760); + if (prefs.isStn_bandActive10G()) out.add(Band.B_10G); + return out; + } + + private static EnumSet getStationOfferedBandsFromHistory(ChatMember member, long nowEpochMs) { + EnumSet out = EnumSet.noneOf(Band.class); + Map map = member.getKnownActiveBands(); + if (map == null || map.isEmpty()) return out; + + for (Map.Entry e : map.entrySet()) { + if (e == null || e.getKey() == null || e.getValue() == null) continue; + long age = nowEpochMs - e.getValue().timestampEpoch; + if (age <= RX_BANDS_MAX_AGE_MS) { + out.add(e.getKey()); + } + } + return out; + } + + private static EnumSet getWorkedBands(ChatMember member) { + EnumSet out = EnumSet.noneOf(Band.class); + if (member.isWorked144()) out.add(Band.B_144); + if (member.isWorked432()) out.add(Band.B_432); + if (member.isWorked1240()) out.add(Band.B_1296); + if (member.isWorked2300()) out.add(Band.B_2320); + if (member.isWorked3400()) out.add(Band.B_3400); + if (member.isWorked5600()) out.add(Band.B_5760); + if (member.isWorked10G()) out.add(Band.B_10G); + if (member.isWorked24G()) out.add(Band.B_24G); + return out; + } + + private static int findNextAirplaneArrivingMinutes(AirPlaneReflectionInfo apInfo) { + try { + if (apInfo.getRisingAirplanes() == null || apInfo.getRisingAirplanes().isEmpty()) return -1; + + int min = Integer.MAX_VALUE; + for (AirPlane ap : apInfo.getRisingAirplanes()) { + if (ap == null) continue; + min = Math.min(min, ap.getArrivingDurationMinutes()); + } + return min == Integer.MAX_VALUE ? -1 : min; + } catch (Exception ignore) { + return -1; + } + } + + private static double minimalAngleDiffDeg(double a, double b) { + double diff = Math.abs((a - b) % 360.0); + return diff > 180.0 ? 360.0 - diff : diff; + } + + private static String normalize(String s) { + if (s == null) return null; + return s.trim().toUpperCase(); + } +} diff --git a/src/main/java/kst4contest/logic/SignalDetector.java b/src/main/java/kst4contest/logic/SignalDetector.java new file mode 100644 index 0000000..a8eba74 --- /dev/null +++ b/src/main/java/kst4contest/logic/SignalDetector.java @@ -0,0 +1,51 @@ +package kst4contest.logic; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; + +/** + * Lightweight positive-signal detector. + * + * Patterns are configured via a single preference string, delimited by ';' or newlines. + * Examples: "QRV;READY;RX OK;RGR;TNX;TU;HRD" + */ +public final class SignalDetector { + + private static final AtomicReference lastPatterns = new AtomicReference<>(""); + private static final AtomicReference> cached = new AtomicReference<>(List.of()); + + private SignalDetector() {} + + public static boolean containsPositiveSignal(String messageText, String patternsDelimited) { + if (messageText == null || messageText.isBlank()) return false; + List patterns = compileIfChanged(patternsDelimited); + + String txt = messageText.toUpperCase(); + for (Pattern p : patterns) { + if (p.matcher(txt).find()) return true; + } + return false; + } + + private static List compileIfChanged(String patternsDelimited) { + String p = patternsDelimited == null ? "" : patternsDelimited.trim(); + String prev = lastPatterns.get(); + if (p.equals(prev)) return cached.get(); + + List out = new ArrayList<>(); + for (String token : p.split("[;\\n\\r]+")) { + String t = token.trim(); + if (t.isEmpty()) continue; + + // plain substring match, but regex-safe + String regex = Pattern.quote(t.toUpperCase()); + out.add(Pattern.compile(regex)); + } + + lastPatterns.set(p); + cached.set(List.copyOf(out)); + return out; + } +} diff --git a/src/main/java/kst4contest/model/Band.java b/src/main/java/kst4contest/model/Band.java new file mode 100644 index 0000000..9cfd866 --- /dev/null +++ b/src/main/java/kst4contest/model/Band.java @@ -0,0 +1,49 @@ +package kst4contest.model; + +/** + * Represents Amateur Radio Bands and their physical limits. + * Used for plausibility checks in the Smart Parser. + */ +public enum Band { + B_144(144.000, 146.000, "144"), + B_432(432.000, 434.000, "432"), + B_1296(1296.000, 1298.000, "1296"), + B_2320(2320.000, 2322.000, "2320"), + B_3400(3400.000, 3410.000, "3400"), + B_5760(5760.000, 5762.000, "5760"), + B_10G(10368.000, 10370.000, "10368"), + B_24G(24048.000, 24050.000, "24048"); + // more space for future usage + + private final double minFreq; + private final double maxFreq; + private final String prefix; // Default prefix for "short value" parsing (e.g., .210) + + Band(double min, double max, String prefix) { + this.minFreq = min; + this.maxFreq = max; + this.prefix = prefix; + } + + public String getPrefix() { + return prefix; + } + + /** + * Checks if a specific frequency falls within this band's limits. + */ + public boolean isPlausible(double freq) { + return freq >= minFreq && freq <= maxFreq; + } + + /** + * Helper to find the matching Band enum for a given frequency. + * Returns null if no band matches. + */ + public static Band fromFrequency(double freq) { + for (Band b : values()) { + if (b.isPlausible(freq)) return b; + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/model/ChatMember.java b/src/main/java/kst4contest/model/ChatMember.java index 4a0f9c1..7522506 100644 --- a/src/main/java/kst4contest/model/ChatMember.java +++ b/src/main/java/kst4contest/model/ChatMember.java @@ -1,6 +1,10 @@ package kst4contest.model; import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.BooleanProperty; @@ -9,7 +13,10 @@ import javafx.beans.property.StringProperty; public class ChatMember { -// private final BooleanProperty workedInfoChangeFireListEventTrigger = new SimpleBooleanProperty(); + + long lastFlagsChangeEpochMs; // timestamp of the last worked/not-QRV flag change in the internal DB + + // private final BooleanProperty workedInfoChangeFireListEventTrigger = new SimpleBooleanProperty(); AirPlaneReflectionInfo airPlaneReflectInfo; String callSign; String qra; @@ -45,6 +52,12 @@ public class ChatMember { boolean worked3400; boolean worked5600; boolean worked10G; + boolean Worked50; + boolean Worked70; + boolean Worked24G; + boolean Worked47G; + boolean Worked76G; + /** * Chatmember is qrv at all band except we initialize anything other, depending to user entry @@ -58,9 +71,35 @@ public class ChatMember { boolean qrv10G = true; boolean qrvAny = true; + // Stores the last known frequency per band (Context History) + private final Map knownActiveBands = new ConcurrentHashMap<>(); + // --- INNER CLASS FOR QRG HISTORY --- + public class ActiveFrequencyInfo { + public double frequency; + public long timestampEpoch; + public ActiveFrequencyInfo(double freq) { + this.frequency = freq; + this.timestampEpoch = System.currentTimeMillis(); + } + } + + // Counter for failed calls (Penalty Logic) + private int failedQSOAttempts = 0; + + // Calculated Score for sorting the user list + private double currentPriorityScore = 0.0; + + + public long getLastFlagsChangeEpochMs() { + return lastFlagsChangeEpochMs; + } + + public void setLastFlagsChangeEpochMs(long lastFlagsChangeEpochMs) { + this.lastFlagsChangeEpochMs = lastFlagsChangeEpochMs; + } public boolean isInAngleAndRange() { return isInAngleAndRange; @@ -270,8 +309,129 @@ public class ChatMember { return callSign; } + /** + * Sets the original callsign and derives the normalized base callsign which is + * used as the database key. Prefixes like EA5/ and suffixes like /P or -70 are + * ignored for the raw-key handling. + * + * @param callSign callsign as received from chat or database + */ public void setCallSign(String callSign) { - this.callSign = callSign; + + if (callSign == null) { + this.callSign = null; + this.callSignRaw = null; + return; + } + + this.callSign = callSign.trim().toUpperCase(Locale.ROOT); + this.callSignRaw = normalizeCallSignToBaseCallSign(this.callSign); + } + + /** + * Normalizes a callsign to the base callsign which is used as the unique key in + * the internal database. The method removes KST suffixes like "-2", portable + * suffixes like "/P" and prefix additions like "EA5/". + * + * @param callSign callsign to normalize + * @return normalized base callsign in upper case + */ + public static String normalizeCallSignToBaseCallSign(String callSign) { + + if (callSign == null) { + return null; + } + + String normalizedCallSign = callSign.trim().toUpperCase(Locale.ROOT); + + if (normalizedCallSign.isBlank()) { + return normalizedCallSign; + } + + String callSignWithoutDashSuffix = normalizedCallSign.split("-", 2)[0].trim(); + + if (!callSignWithoutDashSuffix.contains("/")) { + return callSignWithoutDashSuffix; + } + + String[] callSignParts = callSignWithoutDashSuffix.split("/"); + String bestMatchingCallsignPart = helper_selectBestCallsignPart(callSignParts); + + if (bestMatchingCallsignPart == null || bestMatchingCallsignPart.isBlank()) { + return callSignWithoutDashSuffix; + } + + return bestMatchingCallsignPart; + } + + /** + * Selects the most plausible base callsign segment from a slash-separated + * callsign. In strings like "EA5/G8MBI/P" the segment "G8MBI" is preferred over + * prefix or portable markers. + * + * @param callSignParts slash-separated callsign parts + * @return best matching base callsign segment + */ + private static String helper_selectBestCallsignPart(String[] callSignParts) { + + String bestLikelyBaseCallsignPart = null; + int bestLikelyBaseCallsignLength = -1; + String bestFallbackCallsignPart = null; + int bestFallbackCallsignLength = -1; + + for (String rawCallsignPart : callSignParts) { + + String currentCallsignPart = rawCallsignPart == null ? "" : rawCallsignPart.trim().toUpperCase(Locale.ROOT); + + if (currentCallsignPart.isBlank()) { + continue; + } + + if (currentCallsignPart.length() > bestFallbackCallsignLength) { + bestFallbackCallsignPart = currentCallsignPart; + bestFallbackCallsignLength = currentCallsignPart.length(); + } + + if (helper_isLikelyBaseCallsignSegment(currentCallsignPart) + && currentCallsignPart.length() > bestLikelyBaseCallsignLength) { + bestLikelyBaseCallsignPart = currentCallsignPart; + bestLikelyBaseCallsignLength = currentCallsignPart.length(); + } + } + + if (bestLikelyBaseCallsignPart != null) { + return bestLikelyBaseCallsignPart; + } + + return bestFallbackCallsignPart; + } + + /** + * Checks whether a slash-separated segment looks like a real base callsign. A + * normal amateur-radio callsign typically contains letters and digits and is + * longer than one-character postfix markers. + * + * @param callsignSegment segment to inspect + * @return true if the segment looks like a base callsign + */ + private static boolean helper_isLikelyBaseCallsignSegment(String callsignSegment) { + + boolean containsLetter = false; + boolean containsDigit = false; + + for (int currentIndex = 0; currentIndex < callsignSegment.length(); currentIndex++) { + char currentCharacter = callsignSegment.charAt(currentIndex); + + if (Character.isLetter(currentCharacter)) { + containsLetter = true; + } + + if (Character.isDigit(currentCharacter)) { + containsDigit = true; + } + } + + return containsLetter && containsDigit && callsignSegment.length() >= 3; } public String getQra() { @@ -313,9 +473,51 @@ public class ChatMember { return worked; } - public void setWorked(boolean worked) { + public boolean isWorked50() { + return Worked50; + } + + public void setWorked50(boolean worked50) { + Worked50 = worked50; + } + + public boolean isWorked70() { + return Worked70; + } + + public void setWorked70(boolean worked70) { + Worked70 = worked70; + } + + public boolean isWorked24G() { + return Worked24G; + } + + public void setWorked24G(boolean worked24G) { + Worked24G = worked24G; + } + + public boolean isWorked47G() { + return Worked47G; + } + + public void setWorked47G(boolean worked47G) { + Worked47G = worked47G; + } + + public boolean isWorked76G() { + return Worked76G; + } + + public void setWorked76G(boolean worked76G) { + Worked76G = worked76G; + } + + public void setWorked(boolean worked) { this.worked = worked; + + } /** @@ -324,13 +526,15 @@ public class ChatMember { */ public String getCallSignRaw() { - String raw = ""; - try { - return this.getCallSign().split("-")[0]; //e.g. OK2M-70, returns only ok2m - } catch (Exception e) { - return getCallSign(); - } + return callSignRaw; +// String raw = ""; +// +// try { +// return this.getCallSign().split("-")[0]; //e.g. OK2M-70, returns only ok2m +// } catch (Exception e) { +// return getCallSign(); +// } } @@ -342,13 +546,20 @@ public class ChatMember { this.setWorked(false); this.setWorked144(false); + this.setWorked50(false); + this.setWorked70(false); this.setWorked432(false); this.setWorked1240(false); this.setWorked2300(false); this.setWorked3400(false); this.setWorked5600(false); this.setWorked10G(false); - } + this.setWorked24G(false); + this.setWorked47G(false); + this.setWorked76G(false); + + + } /** * Sets all worked information of this object to false. Scope: GUI, Reset Button @@ -391,4 +602,56 @@ public class ChatMember { return false; } + /** + * Adds a new recognized frequency by band to the internal band/qrg map + * @param band + * @param freq + */ + public void addKnownFrequency(Band band, double freq) { + this.knownActiveBands.put(band, new ActiveFrequencyInfo(freq)); + } + + /** + * represents a map of bands which are known of this chatmember + * + * @return Band + */ + public Map getKnownActiveBands() { + return knownActiveBands; + } + + + /** + * If a sked fails and the user tells this to the client, this counter will be increased to give the station a + * lower score + */ + public void incrementFailedAttempts() { + this.failedQSOAttempts++; + } + + public void resetFailedAttempts() { + this.failedQSOAttempts = 0; + } + + public int getFailedQSOAttempts() { + return failedQSOAttempts; + } + + /** + * Sets the working-priority score of a chatmember for the "Todo-List" + * @param score + */ + public void setCurrentPriorityScore(double score) { + this.currentPriorityScore = score; + } + + /** + * Gets the working-priority score of a chatmember for the "Todo-List" + * + */ + public double getCurrentPriorityScore() { + return currentPriorityScore; + } + + } \ No newline at end of file diff --git a/src/main/java/kst4contest/model/ChatPreferences.java b/src/main/java/kst4contest/model/ChatPreferences.java index 59bc8fa..f162bf7 100644 --- a/src/main/java/kst4contest/model/ChatPreferences.java +++ b/src/main/java/kst4contest/model/ChatPreferences.java @@ -18,6 +18,7 @@ import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; +import javafx.beans.property.*; import kst4contest.ApplicationConstants; import kst4contest.utils.ApplicationFileUtils; import org.w3c.dom.Document; @@ -26,15 +27,34 @@ import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; -import javafx.beans.property.IntegerProperty; -import javafx.beans.property.SimpleIntegerProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; +/** + * refactored version if ChatPreferences + */ public class ChatPreferences { + + // Main window: right split pane (ChatMember table / Priority list / FurtherInfo) + // 3 items => 2 dividers => 2 positions. + private double[] GUImainWindowRightSplitPane_dividerposition = new double[] { 0.53, 0.78 }; + + // Defaults used for config upgrades / first start. + private final double[] GUImainWindowRightSplitPane_dividerpositionDefault = new double[] { 0.53, 0.78 }; + + + /** + * Bump this when you change the XML schema written by {@link #writePreferencesToXmlFile()}. + *

+ * Reading must stay backwards compatible: missing/unknown tags should fall back to defaults. + */ +// private static final int CONFIG_VERSION = 2; + public static final int CONFIG_VERSION = 3; + + // Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility. + private static final String TAG_CONFIG_VERSION = "configVersion"; + /** * Name of file to store preferences in. */ @@ -50,12 +70,14 @@ public class ChatPreferences { /** * Default constructor will set the default values (also for predefined texts * and shorts) automatically at initialization - * + * * TODO: delete this from the kst4contest.view/Main.java! */ public ChatPreferences() { ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, PREFERENCES_FILE); +// lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB"); + // shortcuts[2] = "pse"; // shortcuts[3] = "turn"; // shortcuts[4] = "ant"; @@ -91,7 +113,7 @@ public class ChatPreferences { // shortcuts[34] = "!"; // shortcuts[35] = ","; // shortcuts[36] = "MYQRG"; -// +// // textSnippets[0] = "Hi OM, try sked?"; // textSnippets[1] = "I am calling cq ur dir, pse lsn to me at "; // textSnippets[2] = "pse ur qrg?"; @@ -99,15 +121,15 @@ public class ChatPreferences { // textSnippets[4] = "Hrd you but many qrm here, pse agn"; // // textSnippets[5] = "I turn my ant to you"; -// -// textSnippets[6] = "Sry, strong qrm by local station there, may try "; +// +// textSnippets[6] = "Sry, strong qrm by local station there, may try "; // textSnippets[7] = "Sry, in qso nw, pse qrx, I will meep you"; -// +// // textSnippets[8] = "Ur ant my dir nw?"; // textSnippets[9] = "nil?"; // textSnippets[10] = "No cw op here, could we use ssb?"; -// textSnippets[11] = "No chance in ssb, could we use cw?"; -// +// textSnippets[11] = "No chance in ssb, could we use cw?"; +// // textSnippets[12] = "Nil till now, are you calling?"; // textSnippets[13] = "Nil, I will look for an ap"; // textSnippets[14] = "Tnx try, maybe later!"; @@ -118,7 +140,7 @@ public class ChatPreferences { * Preferences for the preferences * kst4contest@googlegroups.com * praktimarc+kst4contest@gmail.com - * + * */ String programVersion = "Chat is powered by ON4KST \n\nUsage is free. You are welcome to support: \n\n- my project (donations, bugreports, good ideas are welcome), \n- ON4KST Servers, \n- AirScout developers and \n- OV3T (best AS-data provider of the world). \n\n73 de DO5AMF, Marc (DM5M / DARC X08)"; @@ -131,8 +153,16 @@ public class ChatPreferences { * Station preferences */ + + + String stn_on4kstServersDns = "www.on4kst.org"; + int stn_on4kstServersPort = 23001; + + boolean stn_pstRotatorEnabled = false; + boolean stn_loginAFKState = false; //always start as here String stn_loginCallSign = "do5amf"; + String stn_loginCallSignRaw = "do5amf"; //for example: do5amf instead of logincallsign do5amf-2 String stn_loginPassword = ""; String stn_loginNameMainCat = "KST4Contest"; String stn_loginNameSecondCat = "KST4ContestSHF"; @@ -146,7 +176,7 @@ public class ChatPreferences { ChatCategory loginChatCategoryMain = new ChatCategory(2); ChatCategory loginChatCategorySecond = new ChatCategory(3); boolean loginToSecondChatEnabled; - IntegerProperty actualQTF = new SimpleIntegerProperty(360); // will be updated by user at runtime! + DoubleProperty actualQTF = new SimpleDoubleProperty(360); // will be updated by user at runtime! boolean stn_bandActive144; boolean stn_bandActive432; @@ -165,11 +195,20 @@ public class ChatPreferences { int logsynch_ucxUDPWkdCallListenerPort = 12060; boolean logsynch_ucxUDPWkdCallListenerEnabled = true; + String logsynch_wintestNetworkStationNameOfKST = "KST4Contest"; + String logsynch_wintestNetworkStationNameOfWintestClient1 = "STN1"; + boolean logsynch_wintestNetworkSimulationEnabled = false; + int logsynch_wintestNetworkStationIDOfKST = 55555; + int logsynch_wintestNetworkPort = 9871; + boolean logsynch_wintestNetworkListenerEnabled = true; // default true = bisheriges Verhalten + + + /** * TRX Synch prefs */ StringProperty MYQRGFirstCat = new SimpleStringProperty(); // own qrg will be set by user entry or ucxlog if trx Synch is - // activated + // activated StringProperty MYQRGSecondCat = new SimpleStringProperty(); // own qrg will be set by user entry or ucxlog if trx Synch is activated boolean trxSynch_ucxLogUDPListenerEnabled = false; @@ -185,6 +224,8 @@ public class ChatPreferences { * Notification prefs */ + + //Audio section boolean notify_playSimpleSounds = true; boolean notify_playCWCallsignsOnRxedPMs = true; @@ -200,6 +241,18 @@ public class ChatPreferences { boolean notify_DXClusterServerTriggerBearing; boolean notify_DXClusterServerTriggerOnQRGDetect; + // ObservableList lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList(); + ObservableList lstNotify_QSOSniffer_sniffedWordsList = FXCollections.observableArrayList(); + ObservableList lstNotify_QSOSniffer_sniffedPrefixLocList = FXCollections.observableArrayList(); + + // Scoring / interaction metrics + int notify_noReplyPenaltyMinutes = 13; // if we ping via /cq and no response arrives within X minutes => penalty strike + int notify_momentumWindowSeconds = 180; // momentum window size (count inbound lines) + String notify_positiveSignalsPatterns = "QRV;READY;RX;RGR;RR;OK;YES;TNX;TU;HEARD;LSN"; //TODO: to be continued + + boolean notify_bandUpgradeHintOnLogEnabled = true; // show hint after log entry if station QRV on other unworked enabled band + boolean notify_bandUpgradePriorityBoostEnabled = false; // optional score boost to make it stand out in toplists + /** * Shortcuts and Textsnippets prefs @@ -258,15 +311,18 @@ public class ChatPreferences { * *********************************************************************************/ + private boolean GUI_darkModeActive = false; + private boolean GUI_darkModeActiveByDefault = false; + private double[] GUIscn_ChatwindowMainSceneSizeHW = new double[] {768, 1234}; private double[] GUIclusterAndQSOMonStage_SceneSizeHW = new double[] {700, 500}; - private double[] GUIstage_updateStage_SceneSizeHW = new double[] {640, 480}; + private double[] GUIstage_updateStage_SceneSizeHW = new double[] {580, 480}; private double[] GUIsettingsStageSceneSizeHW = new double[] {720, 768}; private double[] GUIselectedCallSignSplitPane_dividerposition = {0.55}; private double[] GUImainWindowLeftSplitPane_dividerposition = {0.51}; - private double[] GUImessageSectionSplitpane_dividerposition = {0.62, 0.7, 0.75}; //3 deviders now //TODO: more should be possible? - private double[] GUImainWindowRightSplitPane_dividerposition = {0.72}; + private double[] GUImessageSectionSplitpane_dividerposition = {0.62, 0.7, 0.75, 0.9}; //3 deviders now //TODO: more should be possible? +// private double[] GUImainWindowRightSplitPane_dividerposition = {0.72}; private double[] GUIpnl_directedMSGWin_dividerpositionDefault = {0.8}; @@ -318,6 +374,86 @@ public class ChatPreferences { this.stn_loginNameSecondCat = stn_loginNameSecondCat; } + public int getStn_on4kstServersPort() { + return stn_on4kstServersPort; + } + + public void setStn_on4kstServersPort(int stn_on4kstServersPort) { + this.stn_on4kstServersPort = stn_on4kstServersPort; + } + + public String getStn_on4kstServersDns() { + return stn_on4kstServersDns; + } + + public void setStn_on4kstServersDns(String stn_on4kstServersDns) { + this.stn_on4kstServersDns = stn_on4kstServersDns; + } + + public ObservableList getLstNotify_QSOSniffer_sniffedWordsList() { + return lstNotify_QSOSniffer_sniffedWordsList; + } + + public void setLstNotify_QSOSniffer_sniffedWordsList(ObservableList lstNotify_QSOSniffer_sniffedWordsList) { + this.lstNotify_QSOSniffer_sniffedWordsList = lstNotify_QSOSniffer_sniffedWordsList; + } + + public ObservableList getLstNotify_QSOSniffer_sniffedPrefixLocList() { + return lstNotify_QSOSniffer_sniffedPrefixLocList; + } + + public void setLstNotify_QSOSniffer_sniffedPrefixLocList(ObservableList lstNotify_QSOSniffer_sniffedPrefixLocList) { + this.lstNotify_QSOSniffer_sniffedPrefixLocList = lstNotify_QSOSniffer_sniffedPrefixLocList; + } + + public String getLogsynch_wintestNetworkStationNameOfKST() { + return logsynch_wintestNetworkStationNameOfKST; + } + + public void setLogsynch_wintestNetworkStationNameOfKST(String logsynch_wintestNetworkStationNameOfKST) { + this.logsynch_wintestNetworkStationNameOfKST = logsynch_wintestNetworkStationNameOfKST; + } + + public String getLogsynch_wintestNetworkStationNameOfWintestClient1() { + return logsynch_wintestNetworkStationNameOfWintestClient1; + } + + public void setLogsynch_wintestNetworkStationNameOfWintestClient1(String logsynch_wintestNetworkStationNameOfWintestClient1) { + this.logsynch_wintestNetworkStationNameOfWintestClient1 = logsynch_wintestNetworkStationNameOfWintestClient1; + } + + public boolean isLogsynch_wintestNetworkSimulationEnabled() { + return logsynch_wintestNetworkSimulationEnabled; + } + + public void setLogsynch_wintestNetworkSimulationEnabled(boolean logsynch_wintestNetworkSimulationEnabled) { + this.logsynch_wintestNetworkSimulationEnabled = logsynch_wintestNetworkSimulationEnabled; + } + + public int getLogsynch_wintestNetworkStationIDOfKST() { + return logsynch_wintestNetworkStationIDOfKST; + } + + public void setLogsynch_wintestNetworkStationIDOfKST(int logsynch_wintestNetworkStationIDOfKST) { + this.logsynch_wintestNetworkStationIDOfKST = logsynch_wintestNetworkStationIDOfKST; + } + + public int getLogsynch_wintestNetworkPort() { + return logsynch_wintestNetworkPort; + } + + public void setLogsynch_wintestNetworkPort(int logsynch_wintestNetworkPort) { + this.logsynch_wintestNetworkPort = logsynch_wintestNetworkPort; + } + + public boolean isLogsynch_wintestNetworkListenerEnabled() { + return logsynch_wintestNetworkListenerEnabled; + } + + public void setLogsynch_wintestNetworkListenerEnabled(boolean logsynch_wintestNetworkListenerEnabled) { + this.logsynch_wintestNetworkListenerEnabled = logsynch_wintestNetworkListenerEnabled; + } + public String getStn_loginLocatorSecondCat() { return stn_loginLocatorSecondCat; } @@ -458,6 +594,56 @@ public class ChatPreferences { this.notify_dxClusterServerEnabled = notify_dxClusterServerEnabled; } + + public int getNotify_noReplyPenaltyMinutes() { + return notify_noReplyPenaltyMinutes; + } + + public void setNotify_noReplyPenaltyMinutes(int notify_noReplyPenaltyMinutes) { + this.notify_noReplyPenaltyMinutes = notify_noReplyPenaltyMinutes; + } + + public int getNotify_momentumWindowSeconds() { + return notify_momentumWindowSeconds; + } + + public void setNotify_momentumWindowSeconds(int notify_momentumWindowSeconds) { + this.notify_momentumWindowSeconds = notify_momentumWindowSeconds; + } + + public String getNotify_positiveSignalsPatterns() { + return notify_positiveSignalsPatterns; + } + + public void setNotify_positiveSignalsPatterns(String notify_positiveSignalsPatterns) { + this.notify_positiveSignalsPatterns = notify_positiveSignalsPatterns; + } + + + public boolean isNotify_bandUpgradePriorityBoostEnabled() { + return notify_bandUpgradePriorityBoostEnabled; + } + + public void setNotify_bandUpgradePriorityBoostEnabled(boolean notify_bandUpgradePriorityBoostEnabled) { + this.notify_bandUpgradePriorityBoostEnabled = notify_bandUpgradePriorityBoostEnabled; + } + + public boolean isNotify_bandUpgradeHintOnLogEnabled() { + return notify_bandUpgradeHintOnLogEnabled; + } + + public void setNotify_bandUpgradeHintOnLogEnabled(boolean notify_bandUpgradeHintOnLogEnabled) { + this.notify_bandUpgradeHintOnLogEnabled = notify_bandUpgradeHintOnLogEnabled; + } + + public boolean isStn_pstRotatorEnabled() { + return stn_pstRotatorEnabled; + } + + public void setStn_pstRotatorEnabled(boolean stn_pstRotatorEnabled) { + this.stn_pstRotatorEnabled = stn_pstRotatorEnabled; + } + public SimpleStringProperty getNotify_optionalFrequencyPrefix() { return notify_optionalFrequencyPrefix; } @@ -526,18 +712,24 @@ public class ChatPreferences { return GUImessageSectionSplitpane_dividerposition; } - public void setGUImessageSectionSplitpane_dividerposition(double[] GUImessageSectionSplitpane_dividerposition) { - this.GUImessageSectionSplitpane_dividerposition = GUImessageSectionSplitpane_dividerposition; - } - public double[] getGUImainWindowRightSplitPane_dividerposition() { return GUImainWindowRightSplitPane_dividerposition; } - public void setGUImainWindowRightSplitPane_dividerposition(double[] GUImainWindowRightSplitPane_dividerposition) { - this.GUImainWindowRightSplitPane_dividerposition = GUImainWindowRightSplitPane_dividerposition; + public void setGUImessageSectionSplitpane_dividerposition(double[] GUImessageSectionSplitpane_dividerposition) { + this.GUImessageSectionSplitpane_dividerposition = GUImessageSectionSplitpane_dividerposition; } + + + public void setGUImainWindowRightSplitPane_dividerposition(double[] positions) { + this.GUImainWindowRightSplitPane_dividerposition = positions; + } + +// public void setGUImainWindowRightSplitPane_dividerposition(double[] GUImainWindowRightSplitPane_dividerposition) { +// this.GUImainWindowRightSplitPane_dividerposition = GUImainWindowRightSplitPane_dividerposition; +// } + public double[] getGUIpnl_directedMSGWin_dividerpositionDefault() { return GUIpnl_directedMSGWin_dividerpositionDefault; } @@ -582,6 +774,10 @@ public class ChatPreferences { return stn_loginCallSign; } + public String getStn_loginCallSignRaw() { + return stn_loginCallSignRaw; + } + public String getAirScout_asBandString() { return AirScout_asBandString; } @@ -680,11 +876,11 @@ public class ChatPreferences { return MYQRGFirstCat; } - public IntegerProperty getActualQTF() { + public DoubleProperty getActualQTF() { return actualQTF; } - public void setActualQTF(IntegerProperty actualQTF) { + public void setActualQTF(DoubleProperty actualQTF) { this.actualQTF = actualQTF; } @@ -693,7 +889,24 @@ public class ChatPreferences { } public void setStn_loginCallSign(String stn_loginCallSign) { + this.stn_loginCallSign = stn_loginCallSign; + this.stn_loginCallSignRaw = stn_loginCallSign; + + if (stn_loginCallSign.contains("-")) { + + this.stn_loginCallSignRaw = stn_loginCallSign.split("-")[0]; + + } else if (stn_loginCallSign.contains("/")) { + + this.stn_loginCallSignRaw = stn_loginCallSign.split("/")[0]; + + } + + if ((stn_loginCallSign.split("-").length > 2 ) || stn_loginCallSign.split("/").length > 2) { + System.out.println("ChatPreferences, WARNING! Logincallsign is not plausible"); //strange login like do5-amf-2 + } + } public String getStn_loginPassword() { @@ -846,8 +1059,8 @@ public class ChatPreferences { this.bcn_beaconTextMainCat = bcn_beaconTextMainCat; } - - + + public String messageHandling_beaconUnworkedstationsPrefix() { return messageHandling_beaconUnworkedstationsPrefix; } @@ -900,48 +1113,72 @@ public class ChatPreferences { /** - * + * * @return true if the file writing was successful, else false */ public boolean writePreferencesToXmlFile() { - + DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance(); try { - // root elements - DocumentBuilder docBuilder = docFactory.newDocumentBuilder(); - Document doc = docBuilder.newDocument(); - Element rootElement = doc.createElement("praktiKST"); - doc.appendChild(rootElement); - Element station = doc.createElement("station"); - rootElement.appendChild(station); - - Element LoginCallSign = doc.createElement("LoginCallSign"); - LoginCallSign.setTextContent(this.getStn_loginCallSign()); - station.appendChild(LoginCallSign); - + // root elements + DocumentBuilder docBuilder = docFactory.newDocumentBuilder(); + Document doc = docBuilder.newDocument(); + Element rootElement = doc.createElement("praktiKST"); + doc.appendChild(rootElement); - Element LoginPassword = doc.createElement("LoginPassword"); - LoginPassword.setTextContent(this.getStn_loginPassword()); - station.appendChild(LoginPassword); + // Schema version + Element configVersion = doc.createElement("configVersion"); + configVersion.setTextContent(String.valueOf(CONFIG_VERSION)); + rootElement.appendChild(configVersion); - Element LoginDisplayedName = doc.createElement("LoginDisplayedName"); - LoginDisplayedName.setTextContent(this.getStn_loginNameMainCat()); - station.appendChild(LoginDisplayedName); + + Element station = doc.createElement("station"); + rootElement.appendChild(station); + + Element LoginCallSign = doc.createElement("LoginCallSign"); + LoginCallSign.setTextContent(this.getStn_loginCallSign()); + station.appendChild(LoginCallSign); + + // New preferred tag names (closer to variable names). Keep legacy tags above for old versions. + Element stn_loginCallSign = doc.createElement("stn_loginCallSign"); + stn_loginCallSign.setTextContent(this.getStn_loginCallSign()); + station.appendChild(stn_loginCallSign); + + + Element LoginPassword = doc.createElement("LoginPassword"); + LoginPassword.setTextContent(this.getStn_loginPassword()); + station.appendChild(LoginPassword); + + Element stn_loginPassword = doc.createElement("stn_loginPassword"); + stn_loginPassword.setTextContent(this.getStn_loginPassword()); + station.appendChild(stn_loginPassword); + + Element LoginDisplayedName = doc.createElement("LoginDisplayedName"); + LoginDisplayedName.setTextContent(this.getStn_loginNameMainCat()); + station.appendChild(LoginDisplayedName); + + Element stn_loginNameMainCat = doc.createElement("stn_loginNameMainCat"); + stn_loginNameMainCat.setTextContent(this.getStn_loginNameMainCat()); + station.appendChild(stn_loginNameMainCat); Element stn_loginNameSecondCat = doc.createElement("stn_loginNameSecondCat"); stn_loginNameSecondCat.setTextContent(this.getStn_loginNameSecondCat()); station.appendChild(stn_loginNameSecondCat); - Element LoginLocator = doc.createElement("LoginLocator"); - LoginLocator.setTextContent(this.getStn_loginLocatorMainCat()); - station.appendChild(LoginLocator); + Element LoginLocator = doc.createElement("LoginLocator"); + LoginLocator.setTextContent(this.getStn_loginLocatorMainCat()); + station.appendChild(LoginLocator); - Element ChatCategory = doc.createElement("ChatCategory"); - ChatCategory.setTextContent(this.getLoginChatCategoryMain().getCategoryNumber()+""); - station.appendChild(ChatCategory); + Element stn_loginLocatorMainCat = doc.createElement("stn_loginLocatorMainCat"); + stn_loginLocatorMainCat.setTextContent(this.getStn_loginLocatorMainCat()); + station.appendChild(stn_loginLocatorMainCat); + + Element ChatCategory = doc.createElement("ChatCategory"); + ChatCategory.setTextContent(this.getLoginChatCategoryMain().getCategoryNumber()+""); + station.appendChild(ChatCategory); Element ChatCategorySecond = doc.createElement("ChatCategorySecond"); ChatCategorySecond.setTextContent(this.getLoginChatCategorySecond().getCategoryNumber()+""); @@ -991,79 +1228,126 @@ public class ChatPreferences { stn_bandActive10G.setTextContent(this.stn_bandActive10G+""); station.appendChild(stn_bandActive10G); - /** - * LOGSYNCH - */ + Element stn_on4kstServersDns = doc.createElement("stn_on4kstServersDns"); + stn_on4kstServersDns.setTextContent(this.stn_on4kstServersDns); + station.appendChild(stn_on4kstServersDns); - Element logsynch = doc.createElement("logsynch"); - rootElement.appendChild(logsynch); + Element stn_on4kstServersPort = doc.createElement("stn_on4kstServersPort"); + stn_on4kstServersPort.setTextContent(this.stn_on4kstServersPort + ""); + station.appendChild(stn_on4kstServersPort); - Element logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = doc.createElement("logsynch_fileBasedWkdCallInterpreterFileNameReadOnly"); - logsynch_fileBasedWkdCallInterpreterFileNameReadOnly.setTextContent(this.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()); - logsynch.appendChild(logsynch_fileBasedWkdCallInterpreterFileNameReadOnly); + Element stn_loginAFKState = doc.createElement("stn_loginAFKState"); + stn_loginAFKState.setTextContent(this.stn_loginAFKState + ""); + station.appendChild(stn_loginAFKState); - Element logsynch_storeWorkedCallSignsFileNameUDPMessageBackup = doc.createElement("logsynch_storeWorkedCallSignsFileNameUDPMessageBackup"); - logsynch_storeWorkedCallSignsFileNameUDPMessageBackup.setTextContent(this.getLogsynch_storeWorkedCallSignsFileNameUDPMessageBackup()); - logsynch.appendChild(logsynch_storeWorkedCallSignsFileNameUDPMessageBackup); + Element stn_pstRotatorEnabled = doc.createElement("stn_pstRotatorEnabled"); + stn_pstRotatorEnabled.setTextContent(this.stn_pstRotatorEnabled + ""); + station.appendChild(stn_pstRotatorEnabled); - Element logsynch_fileBasedWkdCallInterpreterEnabled = doc.createElement("logsynch_fileBasedWkdCallInterpreterEnabled"); - logsynch_fileBasedWkdCallInterpreterEnabled.setTextContent(this.isLogsynch_fileBasedWkdCallInterpreterEnabled()+""); - logsynch.appendChild(logsynch_fileBasedWkdCallInterpreterEnabled); - Element logsynch_ucxUDPWkdCallListenerPort = doc.createElement("logsynch_ucxUDPWkdCallListenerPort"); - logsynch_ucxUDPWkdCallListenerPort.setTextContent(this.getLogsynch_ucxUDPWkdCallListenerPort()+""); - logsynch.appendChild(logsynch_ucxUDPWkdCallListenerPort); - Element logsynch_ucxUDPWkdCallListenerEnabled = doc.createElement("logsynch_ucxUDPWkdCallListenerEnabled"); - logsynch_ucxUDPWkdCallListenerEnabled.setTextContent(this.isTrxSynch_ucxLogUDPListenerEnabled()+""); - logsynch.appendChild(logsynch_ucxUDPWkdCallListenerEnabled); - - - /** - * trxSynchUCX - */ + /** + * LOGSYNCH + */ - Element trxSynchUCX = doc.createElement("trxSynchUCX"); - rootElement.appendChild(trxSynchUCX); - - Element trxSynch_ucxLogUDPListenerEnabled = doc.createElement("trxSynch_ucxLogUDPListenerEnabled"); - trxSynch_ucxLogUDPListenerEnabled.setTextContent(this.isTrxSynch_ucxLogUDPListenerEnabled()+""); - trxSynchUCX.appendChild(trxSynch_ucxLogUDPListenerEnabled); - - Element trxSynch_defaultMYQRGValue = doc.createElement("trxSynch_defaultMYQRGValue"); - trxSynch_defaultMYQRGValue.setTextContent(this.getMYQRGFirstCat().getValue()); - trxSynchUCX.appendChild(trxSynch_defaultMYQRGValue); + Element logsynch = doc.createElement("logsynch"); + rootElement.appendChild(logsynch); - - - - /** - * AirScout - */ + Element logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = doc.createElement("logsynch_fileBasedWkdCallInterpreterFileNameReadOnly"); + logsynch_fileBasedWkdCallInterpreterFileNameReadOnly.setTextContent(this.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()); + logsynch.appendChild(logsynch_fileBasedWkdCallInterpreterFileNameReadOnly); - Element AirScoutQuerier = doc.createElement("AirScoutQuerier"); - rootElement.appendChild(AirScoutQuerier); - - - Element asQry_airScoutCommunicationEnabled = doc.createElement("asQry_airScoutCommunicationEnabled"); - asQry_airScoutCommunicationEnabled.setTextContent(this.isAirScout_asUDPListenerEnabled()+""); - AirScoutQuerier.appendChild(asQry_airScoutCommunicationEnabled); + Element logsynch_storeWorkedCallSignsFileNameUDPMessageBackup = doc.createElement("logsynch_storeWorkedCallSignsFileNameUDPMessageBackup"); + logsynch_storeWorkedCallSignsFileNameUDPMessageBackup.setTextContent(this.getLogsynch_storeWorkedCallSignsFileNameUDPMessageBackup()); + logsynch.appendChild(logsynch_storeWorkedCallSignsFileNameUDPMessageBackup); - Element asQry_airScoutServerName = doc.createElement("asQry_airScoutServerName"); - asQry_airScoutServerName.setTextContent(this.getAirScout_asServerNameString()); - AirScoutQuerier.appendChild(asQry_airScoutServerName); - - Element asQry_airScoutClientName = doc.createElement("asQry_airScoutClientName"); - asQry_airScoutClientName.setTextContent(this.getAirScout_asClientNameString()); - AirScoutQuerier.appendChild(asQry_airScoutClientName); - - Element asQry_airScoutUDPPort = doc.createElement("asQry_airScoutUDPPort"); - asQry_airScoutUDPPort.setTextContent(this.getAirScout_asCommunicationPort()+""); - AirScoutQuerier.appendChild(asQry_airScoutUDPPort); - - Element asQry_airScoutBandValue = doc.createElement("asQry_airScoutBandValue"); - asQry_airScoutBandValue.setTextContent(this.getAirScout_asBandString()); - AirScoutQuerier.appendChild(asQry_airScoutBandValue); + Element logsynch_fileBasedWkdCallInterpreterEnabled = doc.createElement("logsynch_fileBasedWkdCallInterpreterEnabled"); + logsynch_fileBasedWkdCallInterpreterEnabled.setTextContent(this.isLogsynch_fileBasedWkdCallInterpreterEnabled()+""); + logsynch.appendChild(logsynch_fileBasedWkdCallInterpreterEnabled); + + Element logsynch_ucxUDPWkdCallListenerPort = doc.createElement("logsynch_ucxUDPWkdCallListenerPort"); + logsynch_ucxUDPWkdCallListenerPort.setTextContent(this.getLogsynch_ucxUDPWkdCallListenerPort()+""); + logsynch.appendChild(logsynch_ucxUDPWkdCallListenerPort); + + Element logsynch_ucxUDPWkdCallListenerEnabled = doc.createElement("logsynch_ucxUDPWkdCallListenerEnabled"); + // BUGFIX: this is the log-synch listener flag, not the TRX-synch flag (copy&paste error in older versions) + logsynch_ucxUDPWkdCallListenerEnabled.setTextContent(this.isLogsynch_ucxUDPWkdCallListenerEnabled()+""); + logsynch.appendChild(logsynch_ucxUDPWkdCallListenerEnabled); + + // WinTest Settings + Element logsynch_wintestNetworkStationNameOfKST = doc.createElement("logsynch_wintestNetworkStationNameOfKST"); + logsynch_wintestNetworkStationNameOfKST.setTextContent(this.logsynch_wintestNetworkStationNameOfKST); + logsynch.appendChild(logsynch_wintestNetworkStationNameOfKST); + + Element logsynch_wintestNetworkStationNameOfWintestClient1 = doc.createElement("logsynch_wintestNetworkStationNameOfWintestClient1"); + logsynch_wintestNetworkStationNameOfWintestClient1.setTextContent(this.logsynch_wintestNetworkStationNameOfWintestClient1); + logsynch.appendChild(logsynch_wintestNetworkStationNameOfWintestClient1); + + Element logsynch_wintestNetworkSimulationEnabled = doc.createElement("logsynch_wintestNetworkSimulationEnabled"); + logsynch_wintestNetworkSimulationEnabled.setTextContent(this.logsynch_wintestNetworkSimulationEnabled + ""); + logsynch.appendChild(logsynch_wintestNetworkSimulationEnabled); + + Element logsynch_wintestNetworkStationIDOfKST = doc.createElement("logsynch_wintestNetworkStationIDOfKST"); + logsynch_wintestNetworkStationIDOfKST.setTextContent(this.logsynch_wintestNetworkStationIDOfKST + ""); + logsynch.appendChild(logsynch_wintestNetworkStationIDOfKST); + + Element logsynch_wintestNetworkPort = doc.createElement("logsynch_wintestNetworkPort"); + logsynch_wintestNetworkPort.setTextContent(this.logsynch_wintestNetworkPort + ""); + logsynch.appendChild(logsynch_wintestNetworkPort); + + Element logsynch_wintestNetworkListenerEnabled = doc.createElement("logsynch_wintestNetworkListenerEnabled"); + logsynch_wintestNetworkListenerEnabled.setTextContent(this.logsynch_wintestNetworkListenerEnabled + ""); + logsynch.appendChild(logsynch_wintestNetworkListenerEnabled); + + + /** + * trxSynchUCX + */ + + Element trxSynchUCX = doc.createElement("trxSynchUCX"); + rootElement.appendChild(trxSynchUCX); + + Element trxSynch_ucxLogUDPListenerEnabled = doc.createElement("trxSynch_ucxLogUDPListenerEnabled"); + trxSynch_ucxLogUDPListenerEnabled.setTextContent(this.isTrxSynch_ucxLogUDPListenerEnabled()+""); + trxSynchUCX.appendChild(trxSynch_ucxLogUDPListenerEnabled); + + Element trxSynch_defaultMYQRGValue = doc.createElement("trxSynch_defaultMYQRGValue"); + trxSynch_defaultMYQRGValue.setTextContent(this.getMYQRGFirstCat().getValue()); + trxSynchUCX.appendChild(trxSynch_defaultMYQRGValue); + + Element trxSynch_defaultMYQRG2Value = doc.createElement("trxSynch_defaultMYQRG2Value"); + // Safe null check falls Property noch nicht initialisiert ist + trxSynch_defaultMYQRG2Value.setTextContent(this.getMYQRGSecondCat().getValue() != null ? this.getMYQRGSecondCat().getValue() : "1296.200.00"); + trxSynchUCX.appendChild(trxSynch_defaultMYQRG2Value); + + + /** + * AirScout + */ + + Element AirScoutQuerier = doc.createElement("AirScoutQuerier"); + rootElement.appendChild(AirScoutQuerier); + + + Element asQry_airScoutCommunicationEnabled = doc.createElement("asQry_airScoutCommunicationEnabled"); + asQry_airScoutCommunicationEnabled.setTextContent(this.isAirScout_asUDPListenerEnabled()+""); + AirScoutQuerier.appendChild(asQry_airScoutCommunicationEnabled); + + Element asQry_airScoutServerName = doc.createElement("asQry_airScoutServerName"); + asQry_airScoutServerName.setTextContent(this.getAirScout_asServerNameString()); + AirScoutQuerier.appendChild(asQry_airScoutServerName); + + Element asQry_airScoutClientName = doc.createElement("asQry_airScoutClientName"); + asQry_airScoutClientName.setTextContent(this.getAirScout_asClientNameString()); + AirScoutQuerier.appendChild(asQry_airScoutClientName); + + Element asQry_airScoutUDPPort = doc.createElement("asQry_airScoutUDPPort"); + asQry_airScoutUDPPort.setTextContent(this.getAirScout_asCommunicationPort()+""); + AirScoutQuerier.appendChild(asQry_airScoutUDPPort); + + Element asQry_airScoutBandValue = doc.createElement("asQry_airScoutBandValue"); + asQry_airScoutBandValue.setTextContent(this.getAirScout_asBandString()); + AirScoutQuerier.appendChild(asQry_airScoutBandValue); /** @@ -1071,13 +1355,13 @@ public class ChatPreferences { */ Element notifications = doc.createElement("notifications"); - rootElement.appendChild(notifications); + rootElement.appendChild(notifications); - Element notify_SimpleAudioNotificationsEnabled = doc.createElement("notify_SimpleAudioNotificationsEnabled"); + Element notify_SimpleAudioNotificationsEnabled = doc.createElement("notify_SimpleAudioNotificationsEnabled"); notify_SimpleAudioNotificationsEnabled.setTextContent(this.isNotify_playSimpleSounds()+""); notifications.appendChild(notify_SimpleAudioNotificationsEnabled); - Element notify_CWCallSignAudioNotificationsEnabled = doc.createElement("notify_CWCallsignAudioNotificationsEnabled"); + Element notify_CWCallSignAudioNotificationsEnabled = doc.createElement("notify_CWCallsignAudioNotificationsEnabled"); notify_CWCallSignAudioNotificationsEnabled.setTextContent(this.isNotify_playCWCallsignsOnRxedPMs()+""); notifications.appendChild(notify_CWCallSignAudioNotificationsEnabled); @@ -1109,89 +1393,172 @@ public class ChatPreferences { notify_DXCSrv_SpottersCallSignToFile.setTextContent(this.getNotify_DXCSrv_SpottersCallSign().get()); notifications.appendChild(notify_DXCSrv_SpottersCallSignToFile); - /** - * Shortcuts - */ - - Element shortCuts = doc.createElement("shortCuts"); - rootElement.appendChild(shortCuts); - - for (Iterator iterator = lst_txtShortCutBtnList.iterator(); iterator.hasNext();) { + Element notify_noReplyPenaltyMinutes = doc.createElement("notify_noReplyPenaltyMinutes"); + notify_noReplyPenaltyMinutes.setTextContent(this.getNotify_noReplyPenaltyMinutes() + ""); + notifications.appendChild(notify_noReplyPenaltyMinutes); + + Element notify_momentumWindowSeconds = doc.createElement("notify_momentumWindowSeconds"); + notify_momentumWindowSeconds.setTextContent(this.getNotify_momentumWindowSeconds() + ""); + notifications.appendChild(notify_momentumWindowSeconds); + + Element notify_positiveSignalsPatterns = doc.createElement("notify_positiveSignalsPatterns"); + notify_positiveSignalsPatterns.setTextContent(this.getNotify_positiveSignalsPatterns()); + notifications.appendChild(notify_positiveSignalsPatterns); + + Element notify_bandUpgradeHintOnLogEnabled = doc.createElement("notify_bandUpgradeHintOnLogEnabled"); + notify_bandUpgradeHintOnLogEnabled.setTextContent(this.notify_bandUpgradeHintOnLogEnabled + ""); + notifications.appendChild(notify_bandUpgradeHintOnLogEnabled); + + Element notify_bandUpgradePriorityBoostEnabled = doc.createElement("notify_bandUpgradePriorityBoostEnabled"); + notify_bandUpgradePriorityBoostEnabled.setTextContent(this.notify_bandUpgradePriorityBoostEnabled + ""); + notifications.appendChild(notify_bandUpgradePriorityBoostEnabled); + + + /** + * Shortcuts + */ + + Element shortCuts = doc.createElement("shortCuts"); + rootElement.appendChild(shortCuts); + + for (Iterator iterator = lst_txtShortCutBtnList.iterator(); iterator.hasNext();) { String string = (String) iterator.next(); Element temp = doc.createElement("t"); temp.setTextContent(string); shortCuts.appendChild(temp); - } - - /** - * Textsnippets (right click menu) - */ - - Element textSnippets = doc.createElement("textSnippets"); - rootElement.appendChild(textSnippets); - - for (Iterator iterator = lst_txtSnipList.iterator(); iterator.hasNext();) { + } + + /** + * QSO Sniffer Lists + */ + Element snifferWords = doc.createElement("snifferWords"); + rootElement.appendChild(snifferWords); + + for (String word : lstNotify_QSOSniffer_sniffedWordsList) { + Element temp = doc.createElement("w"); + temp.setTextContent(word); + snifferWords.appendChild(temp); + } + + Element snifferPrefixes = doc.createElement("snifferPrefixes"); + rootElement.appendChild(snifferPrefixes); + + for (String prefix : lstNotify_QSOSniffer_sniffedPrefixLocList) { + Element temp = doc.createElement("p"); + temp.setTextContent(prefix); + snifferPrefixes.appendChild(temp); + } + + /** + * Textsnippets (right click menu) + */ + + Element textSnippets = doc.createElement("textSnippets"); + rootElement.appendChild(textSnippets); + + for (Iterator iterator = lst_txtSnipList.iterator(); iterator.hasNext();) { String string = (String) iterator.next(); Element temp = doc.createElement("t"); temp.setTextContent(string); textSnippets.appendChild(temp); - } - + } - /** - * BeaconCQ - */ - Element beaconCQ = doc.createElement("beaconCQ"); - rootElement.appendChild(beaconCQ); + /** + * BeaconCQ + */ - Element beaconCQText = doc.createElement("beaconCQText"); - beaconCQText.setTextContent(this.getBcn_beaconTextMainCat()); - beaconCQ.appendChild(beaconCQText); - - Element beaconCQIntervalMinutes = doc.createElement("beaconCQIntervalMinutes"); - beaconCQIntervalMinutes.setTextContent(this.getBcn_beaconIntervalInMinutesMainCat()+""); - beaconCQ.appendChild(beaconCQIntervalMinutes); - - Element beaconCQEnabled = doc.createElement("beaconCQEnabled"); - beaconCQEnabled.setTextContent(this.isBcn_beaconsEnabledMainCat()+""); - beaconCQ.appendChild(beaconCQEnabled); + Element beaconCQ = doc.createElement("beaconCQ"); + rootElement.appendChild(beaconCQ); + + Element beaconCQText = doc.createElement("beaconCQText"); + beaconCQText.setTextContent(this.getBcn_beaconTextMainCat()); + beaconCQ.appendChild(beaconCQText); + + // Preferred tag name (close to variable name) + Element bcn_beaconTextMainCat = doc.createElement("bcn_beaconTextMainCat"); + bcn_beaconTextMainCat.setTextContent(this.getBcn_beaconTextMainCat()); + beaconCQ.appendChild(bcn_beaconTextMainCat); + + Element beaconCQIntervalMinutes = doc.createElement("beaconCQIntervalMinutes"); + beaconCQIntervalMinutes.setTextContent(this.getBcn_beaconIntervalInMinutesMainCat()+""); + beaconCQ.appendChild(beaconCQIntervalMinutes); + + Element bcn_beaconIntervalInMinutesMainCat = doc.createElement("bcn_beaconIntervalInMinutesMainCat"); + bcn_beaconIntervalInMinutesMainCat.setTextContent(this.getBcn_beaconIntervalInMinutesMainCat()+""); + beaconCQ.appendChild(bcn_beaconIntervalInMinutesMainCat); + + Element beaconCQEnabled = doc.createElement("beaconCQEnabled"); + beaconCQEnabled.setTextContent(this.isBcn_beaconsEnabledMainCat()+""); + beaconCQ.appendChild(beaconCQEnabled); + + Element bcn_beaconsEnabledMainCat = doc.createElement("bcn_beaconsEnabledMainCat"); + bcn_beaconsEnabledMainCat.setTextContent(this.isBcn_beaconsEnabledMainCat()+""); + beaconCQ.appendChild(bcn_beaconsEnabledMainCat); Element beaconCQTextSecondText = doc.createElement("beaconCQTextSecondText"); beaconCQTextSecondText.setTextContent(this.getBcn_beaconTextSecondCat()); beaconCQ.appendChild(beaconCQTextSecondText); + Element bcn_beaconTextSecondCat = doc.createElement("bcn_beaconTextSecondCat"); + bcn_beaconTextSecondCat.setTextContent(this.getBcn_beaconTextSecondCat()); + beaconCQ.appendChild(bcn_beaconTextSecondCat); + Element beaconCQIntervalMinutesSecondCat = doc.createElement("beaconCQIntervalMinutesSecondCat"); beaconCQIntervalMinutesSecondCat.setTextContent(this.getBcn_beaconIntervalInMinutesSecondCat()+""); beaconCQ.appendChild(beaconCQIntervalMinutesSecondCat); + Element bcn_beaconIntervalInMinutesSecondCat = doc.createElement("bcn_beaconIntervalInMinutesSecondCat"); + bcn_beaconIntervalInMinutesSecondCat.setTextContent(this.getBcn_beaconIntervalInMinutesSecondCat()+""); + beaconCQ.appendChild(bcn_beaconIntervalInMinutesSecondCat); + Element beaconCQEnabledSecondCat = doc.createElement("beaconCQEnabledSecondCat"); beaconCQEnabledSecondCat.setTextContent(this.isBcn_beaconsEnabledSecondCat()+""); beaconCQ.appendChild(beaconCQEnabledSecondCat); - /** - * Messagehandling section / ex Beacon Unworked Stations - */ + Element bcn_beaconsEnabledSecondCat = doc.createElement("bcn_beaconsEnabledSecondCat"); + bcn_beaconsEnabledSecondCat.setTextContent(this.isBcn_beaconsEnabledSecondCat()+""); + beaconCQ.appendChild(bcn_beaconsEnabledSecondCat); - Element beaconUnworkedstations = doc.createElement("beaconUnworkedstations"); - rootElement.appendChild(beaconUnworkedstations); + /** + * Messagehandling section / ex Beacon Unworked Stations + */ - Element beaconUnworkedstationsText = doc.createElement("beaconUnworkedstationsText"); - beaconUnworkedstationsText.setTextContent(this.getMessageHandling_unworkedStnRequesterBeaconsText()); - beaconUnworkedstations.appendChild(beaconUnworkedstationsText); - - Element beaconUnworkedstationsIntervalMinutes = doc.createElement("beaconUnworkedstationsIntervalMinutes"); - beaconUnworkedstationsIntervalMinutes.setTextContent(this.getMessageHandling_unworkedStnRequesterBeaconsInterval()+""); - beaconUnworkedstations.appendChild(beaconUnworkedstationsIntervalMinutes); + Element beaconUnworkedstations = doc.createElement("beaconUnworkedstations"); + rootElement.appendChild(beaconUnworkedstations); - Element beaconUnworkedstationsEnabled = doc.createElement("beaconUnworkedstationsEnabled"); - beaconUnworkedstationsEnabled.setTextContent(this.isMessageHandling_unworkedStnRequesterBeaconsEnabled()+""); - beaconUnworkedstations.appendChild(beaconUnworkedstationsEnabled); - - Element beaconUnworkedstationsPrefix = doc.createElement("beaconUnworkedstationsPrefix"); - beaconUnworkedstationsPrefix.setTextContent(this.messageHandling_beaconUnworkedstationsPrefix()); - beaconUnworkedstations.appendChild(beaconUnworkedstationsPrefix); + Element beaconUnworkedstationsText = doc.createElement("beaconUnworkedstationsText"); + beaconUnworkedstationsText.setTextContent(this.getMessageHandling_unworkedStnRequesterBeaconsText()); + beaconUnworkedstations.appendChild(beaconUnworkedstationsText); + + Element messageHandling_unworkedStnRequesterBeaconsText = doc.createElement("messageHandling_unworkedStnRequesterBeaconsText"); + messageHandling_unworkedStnRequesterBeaconsText.setTextContent(this.getMessageHandling_unworkedStnRequesterBeaconsText()); + beaconUnworkedstations.appendChild(messageHandling_unworkedStnRequesterBeaconsText); + + Element beaconUnworkedstationsIntervalMinutes = doc.createElement("beaconUnworkedstationsIntervalMinutes"); + beaconUnworkedstationsIntervalMinutes.setTextContent(this.getMessageHandling_unworkedStnRequesterBeaconsInterval()+""); + beaconUnworkedstations.appendChild(beaconUnworkedstationsIntervalMinutes); + + Element messageHandling_unworkedStnRequesterBeaconsInterval = doc.createElement("messageHandling_unworkedStnRequesterBeaconsInterval"); + messageHandling_unworkedStnRequesterBeaconsInterval.setTextContent(this.getMessageHandling_unworkedStnRequesterBeaconsInterval()+""); + beaconUnworkedstations.appendChild(messageHandling_unworkedStnRequesterBeaconsInterval); + + Element beaconUnworkedstationsEnabled = doc.createElement("beaconUnworkedstationsEnabled"); + beaconUnworkedstationsEnabled.setTextContent(this.isMessageHandling_unworkedStnRequesterBeaconsEnabled()+""); + beaconUnworkedstations.appendChild(beaconUnworkedstationsEnabled); + + Element messageHandling_unworkedStnRequesterBeaconsEnabled = doc.createElement("messageHandling_unworkedStnRequesterBeaconsEnabled"); + messageHandling_unworkedStnRequesterBeaconsEnabled.setTextContent(this.isMessageHandling_unworkedStnRequesterBeaconsEnabled()+""); + beaconUnworkedstations.appendChild(messageHandling_unworkedStnRequesterBeaconsEnabled); + + Element beaconUnworkedstationsPrefix = doc.createElement("beaconUnworkedstationsPrefix"); + beaconUnworkedstationsPrefix.setTextContent(this.messageHandling_beaconUnworkedstationsPrefix()); + beaconUnworkedstations.appendChild(beaconUnworkedstationsPrefix); + + Element messageHandling_beaconUnworkedstationsPrefix = doc.createElement("messageHandling_beaconUnworkedstationsPrefix"); + messageHandling_beaconUnworkedstationsPrefix.setTextContent(this.messageHandling_beaconUnworkedstationsPrefix()); + beaconUnworkedstations.appendChild(messageHandling_beaconUnworkedstationsPrefix); /***************************************************************** * MESSAGEHANDLING NEW .... BEACONUNWORKED HAVE TO BE REPLACED @@ -1243,6 +1610,25 @@ public class ChatPreferences { guiOptions_defaultFilterPublicMsgs.setTextContent(this.isGuiOptions_defaultFilterPublicMsgs()+""); guiSaveableOptions.appendChild(guiOptions_defaultFilterPublicMsgs); + Element guiOptions_darkModeActive = doc.createElement("guiOptions_darkModeActive"); + guiOptions_darkModeActive.setTextContent(this.GUI_darkModeActive + ""); + guiSaveableOptions.appendChild(guiOptions_darkModeActive); + + Element guiOptions_darkModeActiveByDefault = doc.createElement("guiOptions_darkModeActiveByDefault"); + guiOptions_darkModeActiveByDefault.setTextContent(this.GUI_darkModeActiveByDefault + ""); + guiSaveableOptions.appendChild(guiOptions_darkModeActiveByDefault); + + // --- GUImainWindowRightSplitPane_dividerposition (2 dividers => 2 values) --- NEW + ensureMainWindowRightSplitPaneDividerPositions(2); + + Element eDiv0 = doc.createElement("GUImainWindowRightSplitPane_dividerposition0"); + eDiv0.setTextContent(String.valueOf(GUImainWindowRightSplitPane_dividerposition[0])); + guiSaveableOptions.appendChild(eDiv0); + + Element eDiv1 = doc.createElement("GUImainWindowRightSplitPane_dividerposition1"); + eDiv1.setTextContent(String.valueOf(GUImainWindowRightSplitPane_dividerposition[1])); + guiSaveableOptions.appendChild(eDiv1); + /** * window sizes */ @@ -1294,32 +1680,32 @@ public class ChatPreferences { ****************************************************************************************/ writeXml(doc, System.out); - + // write dom document to a file - try (FileOutputStream output = - new FileOutputStream(storeAndRestorePreferencesFileName)) { - writeXml(doc, output); - } catch (IOException e) { - e.printStackTrace(); - } catch (TransformerException e) { + try (FileOutputStream output = + new FileOutputStream(storeAndRestorePreferencesFileName)) { + writeXml(doc, output); + } catch (IOException e) { + e.printStackTrace(); + } catch (TransformerException e) { // TODO Auto-generated catch block e.printStackTrace(); } - - + + } catch (ParserConfigurationException | TransformerException e1) { // TODO Auto-generated catch block e1.printStackTrace(); } return true; - // root elements + // root elements - //...create XML elements, and others... + //...create XML elements, and others... - // write dom document to a file + // write dom document to a file - } + } // write doc to output stream private static void writeXml(Document doc, OutputStream output) throws TransformerException { @@ -1336,7 +1722,7 @@ public class ChatPreferences { } /** - * + * * @return true if the file reading was successful, else false */ public boolean readPreferencesFromXmlFile() { @@ -1357,405 +1743,207 @@ public class ChatPreferences { DocumentBuilder db = dbf.newDocumentBuilder(); Document doc = db.parse(xmlConfigFile); + NodeList list; + + // --- Schema version (optional) --- + // Missing in older files -> assume version 1. + int xmlVersion = getIntFromDoc(doc, CONFIG_VERSION, "configVersion", "ConfigVersion"); + // Currently we only need the version for debugging / future migrations. + if (xmlVersion > CONFIG_VERSION) { + System.out.println("[ChatPreferences, Info]: preferences.xml version (" + xmlVersion + ") is newer than this application (" + CONFIG_VERSION + "). Trying best-effort load."); + } /** * case station settings - * + * */ - NodeList list = doc.getElementsByTagName("station"); - if (list.getLength() != 0) { + Element stationEl = getFirstElement(doc, "station"); + if (stationEl != null) { + // Prefer tag names close to variable names, but accept legacy tags for backwards compatibility. + stn_loginCallSign = getText(stationEl, stn_loginCallSign, "stn_loginCallSign", "LoginCallSign"); + stn_loginPassword = getText(stationEl, stn_loginPassword, "stn_loginPassword", "LoginPassword"); + stn_loginNameMainCat = getText(stationEl, stn_loginNameMainCat, "stn_loginNameMainCat", "LoginDisplayedName"); + stn_loginNameSecondCat = getText(stationEl, stn_loginNameSecondCat, "stn_loginNameSecondCat"); + stn_loginLocatorMainCat = getText(stationEl, stn_loginLocatorMainCat, "stn_loginLocatorMainCat", "LoginLocator"); - for (int temp = 0; temp < list.getLength(); temp++) { + stn_on4kstServersDns = getText(stationEl, stn_on4kstServersDns, "stn_on4kstServersDns"); + stn_on4kstServersPort = getInt(stationEl, stn_on4kstServersPort, "stn_on4kstServersPort"); + stn_loginAFKState = getBoolean(stationEl, stn_loginAFKState, "stn_loginAFKState"); - Node node = list.item(temp); - - if (node.getNodeType() == Node.ELEMENT_NODE) { - - Element element = (Element) node; - - String call = element.getElementsByTagName("LoginCallSign").item(0).getTextContent(); - stn_loginCallSign = call; - -// call = call.toLowerCase(); - String password = element.getElementsByTagName("LoginPassword").item(0).getTextContent(); - stn_loginPassword = password; - - String loginDisplayedName = element.getElementsByTagName("LoginDisplayedName").item(0) - .getTextContent(); - stn_loginNameMainCat = loginDisplayedName; - - try { - String loginDisplayedNameSecondCat = element.getElementsByTagName("stn_loginNameSecondCat").item(0) - .getTextContent(); - stn_loginNameSecondCat = loginDisplayedNameSecondCat; - } catch (Exception previousVersionExc) { - stn_loginNameSecondCat = "KST4Contest2nd"; - } - - String qra = element.getElementsByTagName("LoginLocator").item(0).getTextContent(); - stn_loginLocatorMainCat = qra; - - String category = element.getElementsByTagName("ChatCategory").item(0).getTextContent(); - - if (isNumeric(category)) { - ChatCategory chatCategory = new ChatCategory(Integer.parseInt(category)); - loginChatCategoryMain = chatCategory; - } else { - - loginChatCategoryMain = new ChatCategory(2); // TODO: Set this default at another place - } - - try { - - String ChatCategorySecond = element.getElementsByTagName("ChatCategorySecond").item(0).getTextContent(); - if (isNumeric(ChatCategorySecond)) { - ChatCategory chatCategory2 = new ChatCategory(Integer.parseInt(ChatCategorySecond)); - loginChatCategorySecond = chatCategory2; - } else { - loginChatCategorySecond = new ChatCategory(3); // TODO: Set this default at another place - } - - String secondCatEnabledOrNot = element - .getElementsByTagName("stn_secondCatEnabled").item(0) - .getTextContent(); - - if (secondCatEnabledOrNot.equals("true")) { - - loginToSecondChatEnabled = true; - } else { - loginToSecondChatEnabled = false; - } - } catch (Exception prevVersionExc){ - loginToSecondChatEnabled = false; //default if setting not found - } - - - - double antennaBeamWidthDeg = Double.parseDouble(element.getElementsByTagName("stn_antennaBeamWidthDeg").item(0).getTextContent()); - stn_antennaBeamWidthDeg = antennaBeamWidthDeg; - double maxQRBDefault = Double.parseDouble(element.getElementsByTagName("stn_maxQRBDefault").item(0).getTextContent()); - stn_maxQRBDefault = maxQRBDefault; - double qtfDefault = Double.parseDouble(element.getElementsByTagName("stn_qtfDefault").item(0).getTextContent()); - stn_qtfDefault = qtfDefault; - - try { - - String stnUses144 = element - .getElementsByTagName("stn_bandActive144").item(0) - .getTextContent(); - - if (stnUses144.equals("true")) { - - stn_bandActive144 = true; - } else { - stn_bandActive144 = false; - } - - String stnUses432 = element - .getElementsByTagName("stn_bandActive432").item(0) - .getTextContent(); - - if (stnUses432.equals("true")) { - - stn_bandActive432 = true; - } else { - stn_bandActive432 = false; - } - - String stnUses1240 = element - .getElementsByTagName("stn_bandActive1240").item(0) - .getTextContent(); - - if (stnUses1240.equals("true")) { - - stn_bandActive1240 = true; - } else { - stn_bandActive1240 = false; - } - - String stnUses2300 = element - .getElementsByTagName("stn_bandActive2300").item(0) - .getTextContent(); - - if (stnUses2300.equals("true")) { - - stn_bandActive2300 = true; - } else { - stn_bandActive2300 = false; - } - - String stnUses3400 = element - .getElementsByTagName("stn_bandActive3400").item(0) - .getTextContent(); - - if (stnUses3400.equals("true")) { - - stn_bandActive3400 = true; - } else { - stn_bandActive3400 = false; - } - - String stnUses5600 = element - .getElementsByTagName("stn_bandActive5600").item(0) - .getTextContent(); - - if (stnUses5600.equals("true")) { - - stn_bandActive5600 = true; - } else { - stn_bandActive5600 = false; - } - - String stnUses10G = element - .getElementsByTagName("stn_bandActive10G").item(0) - .getTextContent(); - - if (stnUses10G.equals("true")) { - - stn_bandActive10G = true; - } else { - stn_bandActive10G = false; - } - - } catch (NullPointerException tooOldConfigFileOrFormatError) { - /** - * In program version 1 there had not been these settings in the xml and not founding em - * would cause an exception and dumb values for the preferences. So we have to initialize - * these variables and later write a proper configfile which can be used correctly then. - */ - stn_bandActive144 = true; - stn_bandActive432 = true; - stn_bandActive1240 = true; - stn_bandActive2300 = true; - stn_bandActive3400 = true; - stn_bandActive5600 = true; - stn_bandActive10G = true; - } - - - System.out.println("[ChatPreferences, info]: Current Element: " + node.getNodeName() - + " --> call: " + call + " / " + password + " / " + loginDisplayedName + " / " + qra - + " / " + category + " / " + antennaBeamWidthDeg + " / " + maxQRBDefault + " / " + qtfDefault + " qrv144: " + stn_bandActive144); - - } + String category = getText(stationEl, null, "loginChatCategoryMain", "ChatCategory"); + if (isNumeric(category)) { + loginChatCategoryMain = new ChatCategory(Integer.parseInt(category)); + } else { + loginChatCategoryMain = new ChatCategory(2); } + + String categorySecond = getText(stationEl, null, "loginChatCategorySecond", "ChatCategorySecond"); + if (isNumeric(categorySecond)) { + loginChatCategorySecond = new ChatCategory(Integer.parseInt(categorySecond)); + } else { + loginChatCategorySecond = new ChatCategory(3); + } + + loginToSecondChatEnabled = getBoolean(stationEl, loginToSecondChatEnabled, "stn_secondCatEnabled"); + + stn_antennaBeamWidthDeg = getDouble(stationEl, stn_antennaBeamWidthDeg, "stn_antennaBeamWidthDeg"); + stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault"); + stn_qtfDefault = getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault"); + + // Band activity flags (introduced later; if missing -> keep defaults) + stn_bandActive144 = getBoolean(stationEl, stn_bandActive144, "stn_bandActive144"); + stn_bandActive432 = getBoolean(stationEl, stn_bandActive432, "stn_bandActive432"); + stn_bandActive1240 = getBoolean(stationEl, stn_bandActive1240, "stn_bandActive1240"); + stn_bandActive2300 = getBoolean(stationEl, stn_bandActive2300, "stn_bandActive2300"); + stn_bandActive3400 = getBoolean(stationEl, stn_bandActive3400, "stn_bandActive3400"); + stn_bandActive5600 = getBoolean(stationEl, stn_bandActive5600, "stn_bandActive5600"); + stn_bandActive10G = getBoolean(stationEl, stn_bandActive10G, "stn_bandActive10G"); + + stn_pstRotatorEnabled = getBoolean(stationEl, stn_pstRotatorEnabled, "stn_pstRotatorEnabled"); + } /** * Case log synchronizatrion */ - list = doc.getElementsByTagName("logsynch"); - if (list.getLength() != 0) { + Element logsynchEl = getFirstElement(doc, "logsynch"); + if (logsynchEl != null) { + logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = getText( + logsynchEl, + logsynch_fileBasedWkdCallInterpreterFileNameReadOnly, + "logsynch_fileBasedWkdCallInterpreterFileNameReadOnly"); + logsynch_storeWorkedCallSignsFileNameUDPMessageBackup = getText( + logsynchEl, + logsynch_storeWorkedCallSignsFileNameUDPMessageBackup, + "logsynch_storeWorkedCallSignsFileNameUDPMessageBackup"); + logsynch_fileBasedWkdCallInterpreterEnabled = getBoolean( + logsynchEl, + logsynch_fileBasedWkdCallInterpreterEnabled, + "logsynch_fileBasedWkdCallInterpreterEnabled"); + logsynch_ucxUDPWkdCallListenerPort = getInt( + logsynchEl, + logsynch_ucxUDPWkdCallListenerPort, + "logsynch_ucxUDPWkdCallListenerPort"); + logsynch_ucxUDPWkdCallListenerEnabled = getBoolean( + logsynchEl, + logsynch_ucxUDPWkdCallListenerEnabled, + "logsynch_ucxUDPWkdCallListenerEnabled"); - for (int temp = 0; temp < list.getLength(); temp++) { + // Optional Win-Test network settings + logsynch_wintestNetworkStationNameOfKST = getText( + logsynchEl, + logsynch_wintestNetworkStationNameOfKST, + "logsynch_wintestNetworkStationNameOfKST"); + logsynch_wintestNetworkStationNameOfWintestClient1 = getText( + logsynchEl, + logsynch_wintestNetworkStationNameOfWintestClient1, + "logsynch_wintestNetworkStationNameOfWintestClient1"); + logsynch_wintestNetworkSimulationEnabled = getBoolean( + logsynchEl, + logsynch_wintestNetworkSimulationEnabled, + "logsynch_wintestNetworkSimulationEnabled"); + logsynch_wintestNetworkStationIDOfKST = getInt( + logsynchEl, + logsynch_wintestNetworkStationIDOfKST, + "logsynch_wintestNetworkStationIDOfKST"); + logsynch_wintestNetworkPort = getInt( + logsynchEl, + logsynch_wintestNetworkPort, + "logsynch_wintestNetworkPort"); - Node node = list.item(temp); + logsynch_wintestNetworkListenerEnabled = getBoolean( + logsynchEl, + logsynch_wintestNetworkListenerEnabled, + "logsynch_wintestNetworkListenerEnabled"); - if (node.getNodeType() == Node.ELEMENT_NODE) { - Element element = (Element) node; - - String logsynchReadFile = element - .getElementsByTagName("logsynch_fileBasedWkdCallInterpreterFileNameReadOnly").item(0) - .getTextContent(); - - logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = logsynchReadFile; - - String UDPMessageBackupFileName = element - .getElementsByTagName("logsynch_storeWorkedCallSignsFileNameUDPMessageBackup").item(0) - .getTextContent(); - - logsynch_storeWorkedCallSignsFileNameUDPMessageBackup = UDPMessageBackupFileName; - -// call = call.toLowerCase(); - String fileBasedLogSynchEnabled = element - .getElementsByTagName("logsynch_fileBasedWkdCallInterpreterEnabled").item(0) - .getTextContent(); - - if (fileBasedLogSynchEnabled.equals("true")) { - - logsynch_fileBasedWkdCallInterpreterEnabled = true; - } else { - logsynch_fileBasedWkdCallInterpreterEnabled = false; - } - - String ucxUDPLogSynchListenerPort = element - .getElementsByTagName("logsynch_ucxUDPWkdCallListenerPort").item(0).getTextContent(); - - if (isNumeric(ucxUDPLogSynchListenerPort)) { - logsynch_ucxUDPWkdCallListenerPort = Integer.parseInt(ucxUDPLogSynchListenerPort); - } else { - logsynch_ucxUDPWkdCallListenerPort = 12060; // TODO: Set default at another place or with - // STATIC VAR - } - - String ucxUDPLogSynchListenerEnabled = element - .getElementsByTagName("logsynch_ucxUDPWkdCallListenerEnabled").item(0).getTextContent(); - - if (ucxUDPLogSynchListenerEnabled.equals("true")) { - logsynch_ucxUDPWkdCallListenerEnabled = true; - } else { - logsynch_ucxUDPWkdCallListenerEnabled = false; - } - - System.out.println( - "[ChatPreferences, info]: Set the Universal file based worked-Call Interpreter to : " - + logsynch_fileBasedWkdCallInterpreterEnabled); - - System.out.println("[ChatPreferences, info]: Set the UCX UDP Worked Call Listener to : " - + logsynch_ucxUDPWkdCallListenerEnabled); - - } - } + System.out.println( + "[ChatPreferences, info]: file based worked-call interpreter: " + logsynch_fileBasedWkdCallInterpreterEnabled); + System.out.println( + "[ChatPreferences, info]: UCX UDP worked-call listener: " + logsynch_ucxUDPWkdCallListenerEnabled); } /** * Case trx synchronizatrion */ - list = doc.getElementsByTagName("trxSynchUCX"); - if (list.getLength() != 0) { + Element trxSynchEl = getFirstElement(doc, "trxSynchUCX"); + if (trxSynchEl != null) { + trxSynch_ucxLogUDPListenerEnabled = getBoolean( + trxSynchEl, + trxSynch_ucxLogUDPListenerEnabled, + "trxSynch_ucxLogUDPListenerEnabled"); - for (int temp = 0; temp < list.getLength(); temp++) { - - Node node = list.item(temp); - - if (node.getNodeType() == Node.ELEMENT_NODE) { - - Element element = (Element) node; - - String trxSynchUCX = element.getElementsByTagName("trxSynch_ucxLogUDPListenerEnabled").item(0) - .getTextContent(); - - if (trxSynchUCX.equals("true")) { - trxSynch_ucxLogUDPListenerEnabled = true; - } else { - trxSynch_ucxLogUDPListenerEnabled = false; - } - - String trxSynch_defaultMYQRGValue = element.getElementsByTagName("trxSynch_defaultMYQRGValue") - .item(0).getTextContent(); - - this.getMYQRGFirstCat().setValue(trxSynch_defaultMYQRGValue); - - String trxSynch_defaultMYQRG2Value; - try{ - trxSynch_defaultMYQRG2Value = element.getElementsByTagName("trxSynch_defaultMYQRG2Value") - .item(0).getTextContent(); - - } catch (Exception notFoundExc) { - trxSynch_defaultMYQRG2Value = "1296.123.00"; //v1.26, new setting - } - - this.getMYQRGSecondCat().setValue(trxSynch_defaultMYQRG2Value); - - System.out.println( - "[ChatPreferences, info]: Set the trx qrg synch to " + trxSynch_ucxLogUDPListenerEnabled - + " and default value to " + this.getMYQRGFirstCat().getValue() + " // " + this.getMYQRGSecondCat().getValue()); - - } + String qrg1 = getText(trxSynchEl, null, "trxSynch_defaultMYQRGValue"); + if (qrg1 != null) { + this.getMYQRGFirstCat().setValue(qrg1); } + String qrg2 = getText(trxSynchEl, "1296.123.00", "trxSynch_defaultMYQRG2Value"); + this.getMYQRGSecondCat().setValue(qrg2); + + System.out.println( + "[ChatPreferences, info]: trx qrg synch=" + trxSynch_ucxLogUDPListenerEnabled + + ", default=" + this.getMYQRGFirstCat().getValue() + " // " + this.getMYQRGSecondCat().getValue()); } /** * Case notifications */ - list = doc.getElementsByTagName("notifications"); - if (list.getLength() != 0) { + Element notificationsEl = getFirstElement(doc, "notifications"); + if (notificationsEl != null) { + notify_playSimpleSounds = getBoolean(notificationsEl, notify_playSimpleSounds, "notify_SimpleAudioNotificationsEnabled"); + notify_playCWCallsignsOnRxedPMs = getBoolean(notificationsEl, notify_playCWCallsignsOnRxedPMs, "notify_CWCallsignAudioNotificationsEnabled"); + notify_playVoiceCallsignsOnRxedPMs = getBoolean(notificationsEl, notify_playVoiceCallsignsOnRxedPMs, "notify_VoiceCallsignAudioNotificationsEnabled"); - for (int temp = 0; temp < list.getLength(); temp++) { + // DXCluster / Monitoring (introduced later) -> keep defaults if absent + notify_dxClusterServerEnabled = getBoolean(notificationsEl, notify_dxClusterServerEnabled, "notify_dxClusterServerEnabled"); + notify_DXClusterServerTriggerBearing = getBoolean(notificationsEl, notify_DXClusterServerTriggerBearing, "notify_DXClusterServerTriggerBearing"); + notify_DXClusterServerTriggerOnQRGDetect = getBoolean(notificationsEl, notify_DXClusterServerTriggerOnQRGDetect, "notify_DXClusterServerTriggerOnQRGDetect"); + notify_dxclusterServerPort = getInt(notificationsEl, notify_dxclusterServerPort, "notify_dxclusterServerPort"); - Node node = list.item(temp); - - if (node.getNodeType() == Node.ELEMENT_NODE) { - - Element element = (Element) node; - - String notify_simpleAudioNotificationsEnabled = element.getElementsByTagName("notify_SimpleAudioNotificationsEnabled").item(0) - .getTextContent(); - - if (notify_simpleAudioNotificationsEnabled.equals("true")) { - notify_playSimpleSounds = true; - } else { - notify_playSimpleSounds = false; - } - - String notify_cwAudioNotificationsEnabled = element.getElementsByTagName("notify_CWCallsignAudioNotificationsEnabled").item(0) - .getTextContent(); - - if (notify_cwAudioNotificationsEnabled.equals("true")) { - notify_playCWCallsignsOnRxedPMs = true; - } else { - notify_playCWCallsignsOnRxedPMs = false; - } - - String notify_voiceAudioNotificationsEnabled = element.getElementsByTagName("notify_VoiceCallsignAudioNotificationsEnabled").item(0) - .getTextContent(); - - if (notify_voiceAudioNotificationsEnabled.equals("true")) { - notify_playVoiceCallsignsOnRxedPMs = true; - } else { - notify_playVoiceCallsignsOnRxedPMs = false; - } - - try { //try catch block since Version 1.23 due to new prefs to save and read - - String notify_dxClusterServerEnabledFromFile = element.getElementsByTagName("notify_dxClusterServerEnabled").item(0) - .getTextContent(); - - if (notify_dxClusterServerEnabledFromFile.equals("true")) { - notify_dxClusterServerEnabled = true; - } else { - notify_dxClusterServerEnabled = false; - } - - String notify_DXClusterServerTriggerBearingFromFile = element.getElementsByTagName("notify_DXClusterServerTriggerBearing").item(0) - .getTextContent(); - - if (notify_DXClusterServerTriggerBearingFromFile.equals("true")) { - notify_DXClusterServerTriggerBearing = true; - } else { - notify_DXClusterServerTriggerBearing = false; - } - - String notify_DXClusterServerTriggerOnQRGDetectFromFile = element.getElementsByTagName("notify_DXClusterServerTriggerOnQRGDetect").item(0) - .getTextContent(); - - if (notify_DXClusterServerTriggerOnQRGDetectFromFile.equals("true")) { - notify_DXClusterServerTriggerOnQRGDetect = true; - } else { - notify_DXClusterServerTriggerOnQRGDetect = false; - } - - String notify_dxclusterServerPortFromFile = element - .getElementsByTagName("notify_dxclusterServerPort").item(0).getTextContent(); - - if (isNumeric(notify_dxclusterServerPortFromFile)) { - notify_dxclusterServerPort = Integer.parseInt(notify_dxclusterServerPortFromFile); - } else { -// notify_dxclusterServerPort = 8000; Default setted on very top of file - } - - String notify_DXCSrv_SpottersCallSignFromFile = element.getElementsByTagName("notify_DXCSrv_SpottersCallSign").item(0).getTextContent(); - notify_DXCSrv_SpottersCallSign.set(notify_DXCSrv_SpottersCallSignFromFile); - - String notify_optionalFrequencyPrefixFromFile = element.getElementsByTagName("notify_optionalFrequencyPrefix").item(0).getTextContent(); - notify_optionalFrequencyPrefix.set(notify_optionalFrequencyPrefixFromFile); - - - } catch (NullPointerException e) { - e.printStackTrace(); - System.out.println("[ChatPreferences, Warning:] some monitoring preferences could not be found in "+ storeAndRestorePreferencesFileName +". Using defaults."); - } - - System.out.println( - "[ChatPreferences, info]: Set the audionotifications simple: " + notify_playSimpleSounds + ", CW: " + notify_playCWCallsignsOnRxedPMs + ", Voice: " + notify_playVoiceCallsignsOnRxedPMs); - - } + String spotter = getText(notificationsEl, null, "notify_DXCSrv_SpottersCallSign"); + if (spotter != null) { + notify_DXCSrv_SpottersCallSign.set(spotter); } + String prefix = getText(notificationsEl, null, "notify_optionalFrequencyPrefix"); + if (prefix != null) { + notify_optionalFrequencyPrefix.set(prefix); + } + + Integer noReply = getInt(notificationsEl, 13, "notify_noReplyPenaltyMinutes"); + if (noReply != null) { + notify_noReplyPenaltyMinutes = noReply; + } + + Integer momentum = getInt(notificationsEl, 666, "notify_momentumWindowSeconds"); + if (momentum != null) { + notify_momentumWindowSeconds = momentum; + } + + String pos = getText(notificationsEl, null, "notify_positiveSignalsPatterns"); + if (pos != null) { + notify_positiveSignalsPatterns = pos; + } + + notify_bandUpgradeHintOnLogEnabled = getBoolean( + notificationsEl, + notify_bandUpgradeHintOnLogEnabled, + "notify_bandUpgradeHintOnLogEnabled" + ); + notify_bandUpgradePriorityBoostEnabled = getBoolean( + notificationsEl, + notify_bandUpgradePriorityBoostEnabled, + "notify_bandUpgradePriorityBoostEnabled" + ); + + + System.out.println( + "[ChatPreferences, info]: audio notifications simple=" + notify_playSimpleSounds + + ", CW=" + notify_playCWCallsignsOnRxedPMs + + ", Voice=" + notify_playVoiceCallsignsOnRxedPMs); } @@ -1763,84 +1951,38 @@ public class ChatPreferences { * Case AirScout querier */ - list = doc.getElementsByTagName("AirScoutQuerier"); - if (list.getLength() != 0) { + Element airScoutEl = getFirstElement(doc, "AirScoutQuerier"); + if (airScoutEl != null) { + AirScout_asUDPListenerEnabled = getBoolean(airScoutEl, AirScout_asUDPListenerEnabled, "asQry_airScoutCommunicationEnabled"); + AirScout_asServerNameString = getText(airScoutEl, AirScout_asServerNameString, "asQry_airScoutServerName"); + AirScout_asClientNameString = getText(airScoutEl, AirScout_asClientNameString, "asQry_airScoutClientName"); + AirScout_asCommunicationPort = getInt(airScoutEl, AirScout_asCommunicationPort, "asQry_airScoutUDPPort"); + AirScout_asBandString = getText(airScoutEl, AirScout_asBandString, "asQry_airScoutBandValue"); - for (int temp = 0; temp < list.getLength(); temp++) { - - Node node = list.item(temp); - - if (node.getNodeType() == Node.ELEMENT_NODE) { - - Element element = (Element) node; - - String asQuerierUDPEnabled = element.getElementsByTagName("asQry_airScoutCommunicationEnabled") - .item(0).getTextContent(); - - if (asQuerierUDPEnabled.equals("true")) { - AirScout_asUDPListenerEnabled = true; - } else { - AirScout_asUDPListenerEnabled = false; - } - - this.setAirScout_asServerNameString( - element.getElementsByTagName("asQry_airScoutServerName").item(0).getTextContent()); - this.setAirScout_asClientNameString( - element.getElementsByTagName("asQry_airScoutClientName").item(0).getTextContent()); - this.setAirScout_asCommunicationPort(Integer.parseInt( - element.getElementsByTagName("asQry_airScoutUDPPort").item(0).getTextContent())); - this.setAirScout_asBandString( - element.getElementsByTagName("asQry_airScoutBandValue").item(0).getTextContent()); -// this.getMYQRG().addListener((observable, oldValue, newValue) -> { -// System.out.println("[Chatprefs.java, Info]: MYQRG changed from " + oldValue + " to " + newValue); -//// this.getMYQRG(). -//// txt_ownqrg.setText(newValue); -// }); - -// - System.out.println("[ChatPreferences, info]: Set the Airscout Querier to " + asQuerierUDPEnabled - + " for qrg " + AirScout_asBandString); - - } - } + System.out.println( + "[ChatPreferences, info]: AirScout querier enabled=" + AirScout_asUDPListenerEnabled + + ", band=" + AirScout_asBandString); } /** * Case shortCuts */ - list = doc.getElementsByTagName("shortCuts"); - String[] shortCutsExtractedOutOfXML = new String[0]; - - if (list.getLength() != 0) { - - ArrayList textShorts = new ArrayList(); - - for (int temp = 0; temp < list.getLength(); temp++) { - - Node node = list.item(temp); - - Element element = (Element) node; - - for (int i = 0; i < element.getChildNodes().getLength(); i++) { - if (element.getChildNodes().item(i).getNodeType() == Node.ELEMENT_NODE) { - textShorts.add(element.getChildNodes().item(i).getTextContent()); - shortCutsExtractedOutOfXML = textShorts.toArray(String[]::new); // String[]::new = API - // Collection.toArray(IntFunction - // generator) -// System.out.println(element.getChildNodes().item(i).getNodeType() + ": " + element.getChildNodes().item(i).getNodeName() + " - " + element.getChildNodes().item(i).getTextContent()); + Element shortCutsEl = getFirstElement(doc, "shortCuts"); + if (shortCutsEl != null) { + lst_txtShortCutBtnList.clear(); + NodeList children = shortCutsEl.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE) { + String v = child.getTextContent(); + if (v != null) { + v = v.trim(); + } + if (v != null && !v.isEmpty()) { + lst_txtShortCutBtnList.add(v); } } - - } - - // if praktiKST found Shortcuts in the configfile, set it to the preferences -// shortcuts = shortCutsExtractedOutOfXML; - // else use defaults (as the initialization vars had been) - for (int i = 0; i < shortCutsExtractedOutOfXML.length; i++) { - lst_txtShortCutBtnList.add(shortCutsExtractedOutOfXML[i]); - System.out.println("[Chatpreferences, Info]: Setting Short " + i + " \"" - + shortCutsExtractedOutOfXML[i] + "\""); } } @@ -1848,38 +1990,73 @@ public class ChatPreferences { * Case textSnippets */ - list = doc.getElementsByTagName("textSnippets"); - String[] textSnippetsExtractedOutOfXML = new String[0]; - - if (list.getLength() != 0) { - - ArrayList textShorts = new ArrayList(); - - for (int temp = 0; temp < list.getLength(); temp++) { - - Node node = list.item(temp); - - Element element = (Element) node; - - for (int i = 0; i < element.getChildNodes().getLength(); i++) { - if (element.getChildNodes().item(i).getNodeType() == Node.ELEMENT_NODE) { - textShorts.add(element.getChildNodes().item(i).getTextContent()); - textSnippetsExtractedOutOfXML = textShorts.toArray(String[]::new); // String[]::new = API - // Collection.toArray(IntFunction - // generator) -// System.out.println(element.getChildNodes().item(i).getNodeType() + ": " + element.getChildNodes().item(i).getNodeName() + " - " + element.getChildNodes().item(i).getTextContent()); + Element textSnippetsEl = getFirstElement(doc, "textSnippets"); + if (textSnippetsEl != null) { + lst_txtSnipList.clear(); + NodeList children = textSnippetsEl.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE) { + String v = child.getTextContent(); + if (v != null) { + v = v.trim(); + } + if (v != null && !v.isEmpty()) { + lst_txtSnipList.add(v); } } - } + } - // if praktiKST found Shortcuts in the configfile, set it to the preferences -// textSnippets = textSnippetsExtractedOutOfXML; - // else use defaults (as the initialization vars had been) - for (int i = 0; i < textSnippetsExtractedOutOfXML.length; i++) { - lst_txtSnipList.add(textSnippetsExtractedOutOfXML[i]); - System.out.println("[Chatpreferences, Info]: Setting Snip " + i + " \"" - + textSnippetsExtractedOutOfXML[i] + "\""); + /** + * Case QSO-sniffer lists (added later; older configs won't have them) + */ + list = doc.getElementsByTagName("snifferWords"); + if (list != null && list.getLength() != 0) { + // reset to avoid duplicates when reloading + lstNotify_QSOSniffer_sniffedWordsList.clear(); + for (int temp = 0; temp < list.getLength(); temp++) { + Node node = list.item(temp); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element element = (Element) node; + NodeList children = element.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE) { + String word = child.getTextContent(); + if (word != null) { + word = word.trim(); + } + if (word != null && !word.isEmpty()) { + lstNotify_QSOSniffer_sniffedWordsList.add(word); + } + } + } + } + } + } + + list = doc.getElementsByTagName("snifferPrefixes"); + if (list != null && list.getLength() != 0) { + lstNotify_QSOSniffer_sniffedPrefixLocList.clear(); + for (int temp = 0; temp < list.getLength(); temp++) { + Node node = list.item(temp); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element element = (Element) node; + NodeList children = element.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE) { + String prefix = child.getTextContent(); + if (prefix != null) { + prefix = prefix.trim(); + } + if (prefix != null && !prefix.isEmpty()) { + lstNotify_QSOSniffer_sniffedPrefixLocList.add(prefix); + } + } + } + } } } @@ -1887,124 +2064,47 @@ public class ChatPreferences { * Case beaconCQ */ - list = doc.getElementsByTagName("beaconCQ"); - if (list.getLength() != 0) { + Element beaconCQEl = getFirstElement(doc, "beaconCQ"); + if (beaconCQEl != null) { + bcn_beaconsEnabledMainCat = getBoolean(beaconCQEl, bcn_beaconsEnabledMainCat, "bcn_beaconsEnabledMainCat", "beaconCQEnabled"); + bcn_beaconIntervalInMinutesMainCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesMainCat, "bcn_beaconIntervalInMinutesMainCat", "beaconCQIntervalMinutes"); + bcn_beaconTextMainCat = getText(beaconCQEl, bcn_beaconTextMainCat, "bcn_beaconTextMainCat", "beaconCQText"); - for (int temp = 0; temp < list.getLength(); temp++) { + bcn_beaconsEnabledSecondCat = getBoolean(beaconCQEl, bcn_beaconsEnabledSecondCat, "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat"); + bcn_beaconIntervalInMinutesSecondCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesSecondCat, "bcn_beaconIntervalInMinutesSecondCat", "beaconCQIntervalMinutesSecondCat"); + bcn_beaconTextSecondCat = getText(beaconCQEl, bcn_beaconTextSecondCat, "bcn_beaconTextSecondCat", "beaconCQTextSecondText"); - Node node = list.item(temp); - - if (node.getNodeType() == Node.ELEMENT_NODE) { - - Element element = (Element) node; - - String beaconCQEnabled = element.getElementsByTagName("beaconCQEnabled").item(0) - .getTextContent(); - - if (beaconCQEnabled.equals("true")) { - - bcn_beaconsEnabledMainCat = true; - } else { - bcn_beaconsEnabledMainCat = false; - } - - String beaconCQIntervalMinutes = element.getElementsByTagName("beaconCQIntervalMinutes").item(0) - .getTextContent(); - - if (isNumeric(beaconCQIntervalMinutes)) { - bcn_beaconIntervalInMinutesMainCat = Integer.parseInt(beaconCQIntervalMinutes); - } else { - bcn_beaconIntervalInMinutesMainCat = 20; // Default value, TODO: Set this in default list - } - - String beaconCQText = element.getElementsByTagName("beaconCQText").item(0).getTextContent(); - this.setBcn_beaconTextMainCat(beaconCQText); - - String beaconCQEnabledSecondCat; - try { - beaconCQEnabledSecondCat = element.getElementsByTagName("beaconCQEnabledSecondCat").item(0) - .getTextContent(); - - if (beaconCQEnabledSecondCat.equals("true")) { - - bcn_beaconsEnabledSecondCat = true; - } else { - bcn_beaconsEnabledSecondCat = false; - } - - String beaconCQIntervalMinutesSecondCat = element.getElementsByTagName("beaconCQIntervalMinutesSecondCat").item(0) - .getTextContent(); - - if (isNumeric(beaconCQIntervalMinutesSecondCat)) { - bcn_beaconIntervalInMinutesSecondCat = Integer.parseInt(beaconCQIntervalMinutesSecondCat); - } else { - bcn_beaconIntervalInMinutesSecondCat = 3; // Default value, TODO: Set this in default list - } - - String beaconCQTextSecondText = element.getElementsByTagName("beaconCQTextSecondText").item(0).getTextContent(); - this.setBcn_beaconTextSecondCat(beaconCQTextSecondText); - - } catch (Exception previousVersionException) { - bcn_beaconsEnabledSecondCat = false; - bcn_beaconIntervalInMinutesSecondCat = 3; - this.setBcn_beaconTextSecondCat(this.getStn_loginCallSign() + " is QRV, pse sked for contact"); - - } -// - System.out.println("[ChatPreferences, info]: set the beacon text to: " + beaconCQText + " and " + this.getBcn_beaconTextSecondCat()); - - } - } + System.out.println("[ChatPreferences, info]: beaconCQ main='" + bcn_beaconTextMainCat + "', second='" + bcn_beaconTextSecondCat + "'"); } /** * Case beaconUnworkedstations - * + * */ - list = doc.getElementsByTagName("beaconUnworkedstations"); - if (list.getLength() != 0) { - - for (int temp = 0; temp < list.getLength(); temp++) { - - Node node = list.item(temp); - - if (node.getNodeType() == Node.ELEMENT_NODE) { - - Element element = (Element) node; -// - String beaconUnworkedstationsText = element.getElementsByTagName("beaconUnworkedstationsText") - .item(0).getTextContent(); - messageHandling_unworkedStnRequesterBeaconsText = beaconUnworkedstationsText; - - String beaconUnworkedstationsIntervalMinutes = element - .getElementsByTagName("beaconUnworkedstationsIntervalMinutes").item(0).getTextContent(); - - if (isNumeric(beaconUnworkedstationsIntervalMinutes)) { - messageHandling_unworkedStnRequesterBeaconsInterval = Integer - .parseInt(beaconUnworkedstationsIntervalMinutes); - } else { - messageHandling_unworkedStnRequesterBeaconsInterval = 20; - } - - String beaconUnworkedstationsEnabled = element - .getElementsByTagName("beaconUnworkedstationsEnabled").item(0).getTextContent(); - - if (beaconUnworkedstationsEnabled.equals("true")) { - messageHandling_unworkedStnRequesterBeaconsEnabled = true; - } else { - messageHandling_unworkedStnRequesterBeaconsEnabled = false; - } - - String beaconUnworkedstationsPrefix = element - .getElementsByTagName("beaconUnworkedstationsPrefix").item(0).getTextContent(); - - messageHandling_beaconUnworkedstationsPrefix = beaconUnworkedstationsPrefix; - - } - } - System.out.println("[ChatPreferences, info]: set the unworked stn beacon text to: " - + messageHandling_unworkedStnRequesterBeaconsText); + Element beaconUnworkedEl = getFirstElement(doc, "beaconUnworkedstations"); + if (beaconUnworkedEl != null) { + messageHandling_unworkedStnRequesterBeaconsText = getText( + beaconUnworkedEl, + messageHandling_unworkedStnRequesterBeaconsText, + "messageHandling_unworkedStnRequesterBeaconsText", + "beaconUnworkedstationsText"); + messageHandling_unworkedStnRequesterBeaconsInterval = getInt( + beaconUnworkedEl, + messageHandling_unworkedStnRequesterBeaconsInterval, + "messageHandling_unworkedStnRequesterBeaconsInterval", + "beaconUnworkedstationsIntervalMinutes"); + messageHandling_unworkedStnRequesterBeaconsEnabled = getBoolean( + beaconUnworkedEl, + messageHandling_unworkedStnRequesterBeaconsEnabled, + "messageHandling_unworkedStnRequesterBeaconsEnabled", + "beaconUnworkedstationsEnabled"); + messageHandling_beaconUnworkedstationsPrefix = getText( + beaconUnworkedEl, + messageHandling_beaconUnworkedstationsPrefix, + "messageHandling_beaconUnworkedstationsPrefix", + "beaconUnworkedstationsPrefix"); + System.out.println("[ChatPreferences, info]: unworked-stations beacon text='" + messageHandling_unworkedStnRequesterBeaconsText + "'"); } @@ -2014,85 +2114,35 @@ public class ChatPreferences { * case messageHandling * ***********************************************/ - list = doc.getElementsByTagName("messageHandling"); - if (list.getLength() != 0) { + Element messageHandlingEl = getFirstElement(doc, "messageHandling"); + if (messageHandlingEl != null) { + messageHandling_autoAnswerTextMainCat = getText( + messageHandlingEl, + messageHandling_autoAnswerTextMainCat, + "messageHandling_autoAnswerTextMainCat", + "autoAnswerText"); + messageHandling_autoAnswerEnabled = getBoolean( + messageHandlingEl, + messageHandling_autoAnswerEnabled, + "messageHandling_autoAnswerEnabled", + "autoAnswerEnabled"); + messageHandling_autoAnswerToQRGRequestEnabled = getBoolean( + messageHandlingEl, + messageHandling_autoAnswerToQRGRequestEnabled, + "messageHandling_autoAnswerToQRGRequestEnabled", + "autoAnswerToQrgRequestEnabled", + "autoAnswerToQRGRequestEnabled"); - for (int temp = 0; temp < list.getLength(); temp++) { - - Node node = list.item(temp); - - if (node.getNodeType() == Node.ELEMENT_NODE) { - - Element element = (Element) node; - - try{ - - String autoAnswerText = element.getElementsByTagName("autoAnswerText").item(0) - .getTextContent(); - - this.setMessageHandling_autoAnswerTextMainCat(autoAnswerText); - - String autoAnswerEnabled = element.getElementsByTagName("autoAnswerEnabled").item(0) - .getTextContent(); - - if (autoAnswerEnabled.equals("true")) { - this.setMessageHandling_autoAnswerEnabled(true); - } else { - this.setMessageHandling_autoAnswerEnabled(false); - } - - String autoAnswerToQrgRequestEnabled = element.getElementsByTagName("autoAnswerToQrgRequestEnabled").item(0) - .getTextContent(); - - if (autoAnswerToQrgRequestEnabled.equals("true")) { - this.setMessageHandling_autoAnswerToQRGRequestEnabled(true); - } else { - this.setMessageHandling_autoAnswerToQRGRequestEnabled(false); - } - - - } - - catch (NullPointerException tooOldConfigFileOrFormatError) { - /** - * In program version 1.24 there had not been these settings in the xml and not founding em - * would cause an exception and dumb values for the preferences. So we have to initialize - * these variables and later write a proper configfile which can be used correctly then. - * - * So THESE ARE DEFAULTS for the new variables - */ - - tooOldConfigFileOrFormatError.printStackTrace(); - this.setMessageHandling_autoAnswerTextMainCat("Hi, sry I am not qrv, just testing new features of KST4Contest " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER); - this.setMessageHandling_autoAnswerEnabled(false); - this.setMessageHandling_autoAnswerToQRGRequestEnabled(true); - } - - try { - String autoAnswerTextSecondCat = element.getElementsByTagName("autoAnswerTextSecondCat").item(0) - .getTextContent(); - - this.setMessageHandling_autoAnswerTextSecondCat(autoAnswerTextSecondCat); - - - String autoAnswerEnabledSecondCat = element.getElementsByTagName("autoAnswerEnabledSecondCat").item(0) - .getTextContent(); - - if (autoAnswerEnabledSecondCat.equals("true")) { - this.setMessageHandling_autoAnswerEnabledSecondCat(true); - } else { - this.setMessageHandling_autoAnswerEnabledSecondCat(false); - } - } catch (Exception prevVersionExc) { - - String autoAnswerTextSecondCat = "[KST4Contest autoreply] change me ... "; - this.setMessageHandling_autoAnswerEnabledSecondCat(false); - - } - - - } - } + messageHandling_autoAnswerTextSecondCat = getText( + messageHandlingEl, + messageHandling_autoAnswerTextSecondCat, + "messageHandling_autoAnswerTextSecondCat", + "autoAnswerTextSecondCat"); + messageHandling_autoAnswerEnabledSecondCat = getBoolean( + messageHandlingEl, + messageHandling_autoAnswerEnabledSecondCat, + "messageHandling_autoAnswerEnabledSecondCat", + "autoAnswerEnabledSecondCat"); } @@ -2119,77 +2169,69 @@ public class ChatPreferences { Element element = (Element) node; - try{ + // Scene sizes (semicolon separated), defaults are already in the arrays. + parseSemicolonDoublesInto(getText(element, null, "GUIscn_ChatwindowMainSceneSizeHW"), this.getGUIscn_ChatwindowMainSceneSizeHW()); + parseSemicolonDoublesInto(getText(element, null, "GUIclusterAndQSOMonStage_SceneSizeHW"), this.getGUIclusterAndQSOMonStage_SceneSizeHW()); + parseSemicolonDoublesInto(getText(element, null, "GUIstage_updateStage_SceneSizeHW"), this.getGUIstage_updateStage_SceneSizeHW()); + parseSemicolonDoublesInto(getText(element, null, "GUIsettingsStageSceneSizeHW"), this.getGUIsettingsStageSceneSizeHW()); - String GUIscn_ChatwindowMainSceneSizeHW = element.getElementsByTagName("GUIscn_ChatwindowMainSceneSizeHW").item(0) - .getTextContent(); - - for (int i = 0; i < (GUIscn_ChatwindowMainSceneSizeHW.split(";").length); i++) { - this.getGUIscn_ChatwindowMainSceneSizeHW()[i] = - Double.parseDouble(GUIscn_ChatwindowMainSceneSizeHW.split(";")[i]); + // Splitpane divider positions + String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition"); + if (s1 != null) { + this.setGUIselectedCallSignSplitPane_dividerposition(csvStringToDoubleArray(s1)); + } + String s2 = getText(element, null, "GUImainWindowLeftSplitPane_dividerposition"); + if (s2 != null) { + this.setGUImainWindowLeftSplitPane_dividerposition(csvStringToDoubleArray(s2)); + } + String s3 = getText(element, null, "GUImessageSectionSplitpane_dividerposition"); + if (s3 != null) { + double[] parsed = csvStringToDoubleArray(s3); + // Config files older than ~1.40 had fewer panes. + if (parsed.length >= 4) { + this.setGUImessageSectionSplitpane_dividerposition(parsed); } + } + // GUImainWindowRightSplitPane divider positions (2 dividers => 2 values) +// Backward compatible: old configs stored a single CSV tag. + String rightLegacy = getText(element, null, "GUImainWindowRightSplitPane_dividerposition"); + String right0 = getText(element, null, "GUImainWindowRightSplitPane_dividerposition0"); + String right1 = getText(element, null, "GUImainWindowRightSplitPane_dividerposition1"); - System.out.println( - "[ChatPreferences, info]: Set the GUIscn_ChatwindowMainSceneSizeHW size to " + GUIclusterAndQSOMonStage_SceneSizeHW); + if (right0 != null || right1 != null) { + // New format: two dedicated tags + double p0 = (right0 != null) ? parseDoubleOrDefault(right0, this.GUImainWindowRightSplitPane_dividerpositionDefault[0]) + : this.GUImainWindowRightSplitPane_dividerpositionDefault[0]; - String GUIclusterAndQSOMonStage_SceneSizeHW = element.getElementsByTagName("GUIclusterAndQSOMonStage_SceneSizeHW").item(0) - .getTextContent(); + double p1 = (right1 != null) ? parseDoubleOrDefault(right1, this.GUImainWindowRightSplitPane_dividerpositionDefault[1]) + : this.GUImainWindowRightSplitPane_dividerpositionDefault[1]; - for (int i = 0; i < (GUIclusterAndQSOMonStage_SceneSizeHW.split(";").length); i++) { - this.getGUIclusterAndQSOMonStage_SceneSizeHW()[i] = - Double.parseDouble(GUIclusterAndQSOMonStage_SceneSizeHW.split(";")[i]); + this.setGUImainWindowRightSplitPane_dividerposition(new double[] { p0, p1 }); + + } else if (rightLegacy != null) { + + // Old format: CSV array (often length 1) + double[] parsed = csvStringToDoubleArray(rightLegacy); + + // Upgrade older config files gracefully + if (parsed.length >= 2) { + this.setGUImainWindowRightSplitPane_dividerposition(new double[] { parsed[0], parsed[1] }); + } else if (parsed.length == 1) { + this.setGUImainWindowRightSplitPane_dividerposition(new double[] { + parsed[0], + this.GUImainWindowRightSplitPane_dividerpositionDefault[1] + }); } - - String GUIselectedCallSignSplitPane_dividerposition = element.getElementsByTagName("GUIselectedCallSignSplitPane_dividerposition").item(0) - .getTextContent(); - this.setGUIselectedCallSignSplitPane_dividerposition(csvStringToDoubleArray(GUIselectedCallSignSplitPane_dividerposition)); - - String GUImainWindowLeftSplitPane_dividerposition = element.getElementsByTagName("GUImainWindowLeftSplitPane_dividerposition").item(0) - .getTextContent(); - this.setGUImainWindowLeftSplitPane_dividerposition(csvStringToDoubleArray(GUImainWindowLeftSplitPane_dividerposition)); - - String GUImessageSectionSplitpane_dividerposition = element.getElementsByTagName("GUImessageSectionSplitpane_dividerposition").item(0) - .getTextContent(); - this.setGUImessageSectionSplitpane_dividerposition(csvStringToDoubleArray(GUImessageSectionSplitpane_dividerposition)); - - String GUImainWindowRightSplitPane_dividerposition = element.getElementsByTagName("GUImainWindowRightSplitPane_dividerposition").item(0) - .getTextContent(); - this.setGUImainWindowRightSplitPane_dividerposition(csvStringToDoubleArray(GUImainWindowRightSplitPane_dividerposition)); - - String GUIpnl_directedMSGWin_dividerpositionDefault = element.getElementsByTagName("GUIpnl_directedMSGWin_dividerpositionDefault").item(0) - .getTextContent(); - this.setGUIpnl_directedMSGWin_dividerpositionDefault(csvStringToDoubleArray(GUIpnl_directedMSGWin_dividerpositionDefault)); - - - - -// System.out.println( -// "[ChatPreferences, info]: Set the GUIclusterAndQSOMonStage_SceneSizeHW size to " + GUIclusterAndQSOMonStage_SceneSizeHW); - } - catch (NullPointerException tooOldConfigFileOrFormatError) { - /** - * In program version 1.2 there had not been these settings in the xml and not founding em - * would cause an exception and dumb values for the preferences. So we have to initialize - * these variables and later write a proper configfile which can be used correctly then. - * - * So THESE ARE DEFAULTS - */ + // Ensure correct length no matter what was in the config (prevents AIOOBE) + ensureMainWindowRightSplitPaneDividerPositions(2); - tooOldConfigFileOrFormatError.printStackTrace(); - GUIscn_ChatwindowMainSceneSizeHW = new double[] {768, 1234}; - GUIclusterAndQSOMonStage_SceneSizeHW = new double[] {700, 500}; - GUIstage_updateStage_SceneSizeHW = new double[] {640, 480}; - GUIsettingsStageSceneSizeHW = new double[] {720, 768}; - GUIselectedCallSignSplitPane_dividerposition = new double[]{0.9}; - setGUImainWindowLeftSplitPane_dividerposition(new double[]{0.7}); - GUImessageSectionSplitpane_dividerposition = new double[]{0.5}; - GUImainWindowRightSplitPane_dividerposition = new double[]{0.8}; - GUIpnl_directedMSGWin_dividerpositionDefault = new double[]{0.8}; -// GUImainWindowLeftSplitPane_dividerposition + String s5 = getText(element, null, "GUIpnl_directedMSGWin_dividerpositionDefault"); + if (s5 != null) { + this.setGUIpnl_directedMSGWin_dividerpositionDefault(csvStringToDoubleArray(s5)); } } } @@ -2200,76 +2242,16 @@ public class ChatPreferences { * case read guiSaveableOptions * ***********************************************/ - list = doc.getElementsByTagName("guiSaveableOptions"); - if (list.getLength() != 0) { + Element guiSaveableOptionsEl = getFirstElement(doc, "guiSaveableOptions"); + if (guiSaveableOptionsEl != null) { + this.setGuiOptions_defaultFilterNothing(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterNothing(), "guiOptions_defaultFilterNothing")); + this.setGuiOptions_defaultFilterPmToMe(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPmToMe(), "guiOptions_defaultFilterPmToMe")); + this.setGuiOptions_defaultFilterPmToOther(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPmToOther(), "guiOptions_defaultFilterPmToOther")); + this.setGuiOptions_defaultFilterPublicMsgs(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_defaultFilterPublicMsgs(), "guiOptions_defaultFilterPublicMsgs")); - for (int temp = 0; temp < list.getLength(); temp++) { - - Node node = list.item(temp); - - if (node.getNodeType() == Node.ELEMENT_NODE) { - - Element element = (Element) node; - - try{ - - String guiOptions_defaultFilterNothing = element.getElementsByTagName("guiOptions_defaultFilterNothing").item(0) - .getTextContent(); - - if (guiOptions_defaultFilterNothing.equals("true")) { - this.setGuiOptions_defaultFilterNothing(true); - } else { - this.setGuiOptions_defaultFilterNothing(false); - } - - String guiOptions_defaultFilterPmToMe = element.getElementsByTagName("guiOptions_defaultFilterPmToMe").item(0) - .getTextContent(); - - if (guiOptions_defaultFilterPmToMe.equals("true")) { - this.setGuiOptions_defaultFilterPmToMe(true); - } else { - this.setGuiOptions_defaultFilterNothing(false); - } - - String guiOptions_defaultFilterPmToOther = element.getElementsByTagName("guiOptions_defaultFilterPmToOther").item(0) - .getTextContent(); - - if (guiOptions_defaultFilterPmToOther.equals("true")) { - this.setGuiOptions_defaultFilterPmToOther(true); - } else { - this.setGuiOptions_defaultFilterPmToOther(false); - } - - String guiOptions_defaultFilterPublicMsgs = element.getElementsByTagName("guiOptions_defaultFilterPublicMsgs").item(0) - .getTextContent(); - - if (guiOptions_defaultFilterPublicMsgs.equals("true")) { - this.setGuiOptions_defaultFilterPublicMsgs(true); - } else { - this.setGuiOptions_defaultFilterPublicMsgs(false); - } - - - } - - - - - - catch (NullPointerException tooOldConfigFileOrFormatError) { - /** - * In program version 1.24 there had not been these settings in the xml and not founding em - * would cause an exception and dumb values for the preferences. So we have to initialize - * these variables and later write a proper configfile which can be used correctly then. - * - * So THESE ARE DEFAULTS for the new variables - */ - - tooOldConfigFileOrFormatError.printStackTrace(); - this.setGuiOptions_defaultFilterPmToMe(true); - } - } - } + // Added in later versions: dark mode flags + this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive"); + this.GUI_darkModeActiveByDefault = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActiveByDefault, "guiOptions_darkModeActiveByDefault"); } @@ -2375,8 +2357,24 @@ public class ChatPreferences { this.stn_bandActive10G = stn_bandActive10G; } + public boolean isGUI_darkModeActive() { + return GUI_darkModeActive; + } + + public void setGUI_darkModeActive(boolean GUI_darkModeActive) { + this.GUI_darkModeActive = GUI_darkModeActive; + } + + public boolean isGUI_darkModeActiveByDefault() { + return GUI_darkModeActiveByDefault; + } + + public void setGUI_darkModeActiveByDefault(boolean GUI_darkModeActiveByDefault) { + this.GUI_darkModeActiveByDefault = GUI_darkModeActiveByDefault; + } + /** - * + * * If the file-reading goes wrong, set the defaults */ public void setPreferencesDefaults() { @@ -2384,10 +2382,130 @@ public class ChatPreferences { } + // --------------------------------------------------------------------- + // XML helper methods + // --------------------------------------------------------------------- + + private static Element getFirstElement(Document doc, String tagName) { + NodeList nl = doc.getElementsByTagName(tagName); + if (nl == null || nl.getLength() == 0) { + return null; + } + Node n = nl.item(0); + return (n instanceof Element) ? (Element) n : null; + } + + /** + * Returns the text content of the first matching child tag (directly under {@code parent}) + * or {@code defaultValue} if the tag does not exist or is empty. + */ + private static String getText(Element parent, String defaultValue, String... tagNames) { + if (parent == null || tagNames == null) { + return defaultValue; + } + for (String t : tagNames) { + if (t == null || t.isEmpty()) { + continue; + } + NodeList nl = parent.getElementsByTagName(t); + if (nl == null || nl.getLength() == 0) { + continue; + } + Node n = nl.item(0); + if (n == null) { + continue; + } + String v = n.getTextContent(); + if (v != null) { + v = v.trim(); + } + if (v != null && !v.isEmpty()) { + return v; + } + } + return defaultValue; + } + + private static boolean getBoolean(Element parent, boolean defaultValue, String... tagNames) { + String v = getText(parent, null, tagNames); + if (v == null) { + return defaultValue; + } + return "true".equalsIgnoreCase(v) || "1".equals(v) || "yes".equalsIgnoreCase(v); + } + + private static int getInt(Element parent, int defaultValue, String... tagNames) { + String v = getText(parent, null, tagNames); + if (v == null) { + return defaultValue; + } + try { + return Integer.parseInt(v.trim()); + } catch (NumberFormatException ignore) { + return defaultValue; + } + } + + private static double getDouble(Element parent, double defaultValue, String... tagNames) { + String v = getText(parent, null, tagNames); + if (v == null) { + return defaultValue; + } + try { + return Double.parseDouble(v.trim()); + } catch (NumberFormatException ignore) { + return defaultValue; + } + } + + private static int getIntFromDoc(Document doc, int defaultValue, String... tagNames) { + if (doc == null) { + return defaultValue; + } + for (String t : tagNames) { + if (t == null || t.isEmpty()) { + continue; + } + NodeList nl = doc.getElementsByTagName(t); + if (nl == null || nl.getLength() == 0) { + continue; + } + Node n = nl.item(0); + if (n == null) { + continue; + } + String v = n.getTextContent(); + if (v == null) { + continue; + } + try { + return Integer.parseInt(v.trim()); + } catch (NumberFormatException ignore) { + // try next + } + } + return defaultValue; + } + + private static void parseSemicolonDoublesInto(String input, double[] target) { + if (input == null || target == null) { + return; + } + String[] parts = input.trim().split(";"); + int len = Math.min(parts.length, target.length); + for (int i = 0; i < len; i++) { + try { + target[i] = Double.parseDouble(parts[i].trim()); + } catch (NumberFormatException ignore) { + // keep existing value in target[i] + } + } + } + /** * Checks wheter the input value of the String is numeric or not, true if yes * TODO: Move to a utils class for checking input values by user... - * + * * @param str * @return */ @@ -2397,14 +2515,63 @@ public class ChatPreferences { // public ObservableList getTxtSnippetsAsObservableList(){ // ObservableList lst_txtSnipList = FXCollections.observableArrayList(); -// +// // for (int i = 0; i < textSnippets.length; i++) { // lst_txtSnipList.add(textSnippets[i]); // System.out.println(textSnippets[i]); // } -// +// // return lst_txtSnipList; -// +// // } + + /** + * Ensures that the divider position array matches the current number of dividers. + * This upgrades older XML configs (e.g. only 1 divider stored) without crashing. + */ + public void ensureMainWindowRightSplitPaneDividerPositions(int requiredDividerCount) { + if (requiredDividerCount < 0) return; + + if (GUImainWindowRightSplitPane_dividerposition != null + && GUImainWindowRightSplitPane_dividerposition.length == requiredDividerCount) { + return; + } + + double[] upgraded = new double[requiredDividerCount]; + + for (int i = 0; i < requiredDividerCount; i++) { + + // Prefer existing stored values if present + if (GUImainWindowRightSplitPane_dividerposition != null + && i < GUImainWindowRightSplitPane_dividerposition.length) { + upgraded[i] = GUImainWindowRightSplitPane_dividerposition[i]; + continue; + } + + // Otherwise use defaults, fallback to even spacing + if (i < GUImainWindowRightSplitPane_dividerpositionDefault.length) { + upgraded[i] = GUImainWindowRightSplitPane_dividerpositionDefault[i]; + } else { + upgraded[i] = (i + 1.0) / (requiredDividerCount + 1.0); + } + } + + GUImainWindowRightSplitPane_dividerposition = upgraded; + } + + private static double parseDoubleOrDefault(String value, double defaultValue) { + if (value == null) return defaultValue; + String v = value.trim(); + if (v.isEmpty()) return defaultValue; + try { + return Double.parseDouble(v); + } catch (Exception ignore) { + return defaultValue; + } + } + } + + + diff --git a/src/main/java/kst4contest/model/ContestSked.java b/src/main/java/kst4contest/model/ContestSked.java new file mode 100644 index 0000000..bb3c576 --- /dev/null +++ b/src/main/java/kst4contest/model/ContestSked.java @@ -0,0 +1,52 @@ +package kst4contest.model; + +/** + * Represents a scheduled event or an AirScout opportunity in the future. + * Used for the Timeline View and Priority Calculation. + */ +public class ContestSked { + + private String targetCallsign; + private double targetAzimuth; // Required for Antenna-Visuals + private long skedTimeEpoch; // The peak time (e.g., AP) + private Band band; + // Opportunity potential (0..100). -1 means "unknown". + int opportunityPotentialPercent = -1; + + // 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; + this.targetAzimuth = azimuth; + this.skedTimeEpoch = time; + this.band = b; + } + + /** + * Returns the seconds remaining until the event. + * Negative values mean the event is in the past. + */ + public long getTimeUntilSkedSeconds() { + 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; } + + public int getOpportunityPotentialPercent() { + return opportunityPotentialPercent; + } + + public void setOpportunityPotentialPercent(int opportunityPotentialPercent) { + this.opportunityPotentialPercent = opportunityPotentialPercent; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/model/ThreadStateMessage.java b/src/main/java/kst4contest/model/ThreadStateMessage.java new file mode 100644 index 0000000..0d2ed89 --- /dev/null +++ b/src/main/java/kst4contest/model/ThreadStateMessage.java @@ -0,0 +1,103 @@ +package kst4contest.model; + +/** + * Object for the description of the activity of a Thread to show these information in a View. + *

+ * If state is critical, there could be used a further information field for the stacktrace + */ +public class ThreadStateMessage { + String threadNickName; + String threadDescription; + boolean running; + String runningInformationTextDescription; + String runningInformation; + + boolean criticalState; + String criticalStateFurtherInfo; + + + + public ThreadStateMessage(String threadNickName, boolean running, String runningInformation, boolean criticalState) { + + this.threadNickName = threadNickName; + this.running = running; + this.criticalState = criticalState; + this.runningInformation = runningInformation; + + } + + /** + * This triggers the message for "Sked armed" + * + * @return + */ + public String getRunningInformationTextDescription() { + + // If a custom description was set (e.g. for UI indicator buttons), prefer it. + if (runningInformationTextDescription != null && !runningInformationTextDescription.isBlank()) { + return runningInformationTextDescription; + } + + // Fallback (legacy behavior) + if (isRunning()) { + return "on"; + } else if (!isRunning() && isCriticalState()) { + return "FAILED"; + } else { + return "off"; + } + } + + public void setRunningInformationTextDescription(String runningInformationTextDescription) { + this.runningInformationTextDescription = runningInformationTextDescription; + } + + public String getThreadNickName() { + return threadNickName; + } + + public void setThreadNickName(String threadNickName) { + this.threadNickName = threadNickName; + } + + public String getThreadDescription() { + return threadDescription; + } + + public void setThreadDescription(String threadDescription) { + this.threadDescription = threadDescription; + } + + public boolean isRunning() { + return running; + } + + public void setRunning(boolean running) { + this.running = running; + } + + public String getRunningInformation() { + return runningInformation; + } + + public void setRunningInformation(String runningInformation) { + this.runningInformation = runningInformation; + } + + public boolean isCriticalState() { + return criticalState; + } + + public void setCriticalState(boolean criticalState) { + this.criticalState = criticalState; + } + + public String getCriticalStateFurtherInfo() { + return criticalStateFurtherInfo; + } + + public void setCriticalStateFurtherInfo(String criticalStateFurtherInfo) { + this.criticalStateFurtherInfo = criticalStateFurtherInfo; + } +} + diff --git a/src/main/java/kst4contest/test/MockKstServer.java b/src/main/java/kst4contest/test/MockKstServer.java new file mode 100644 index 0000000..da8e627 --- /dev/null +++ b/src/main/java/kst4contest/test/MockKstServer.java @@ -0,0 +1,256 @@ +package kst4contest.test; + +import java.io.*; +import java.net.*; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; + +public class MockKstServer { + + private static final int PORT = 23001; + private static final String CHAT_ID = "2"; // 2 = 144/432 MHz + + // Thread-sichere Liste aller verbundenen Clients (OutputStreams) + private final List clients = new CopyOnWriteArrayList<>(); + + // Permanente User (Ihre Test-Callsigns) + private final Map onlineUsers = new HashMap<>(); + // Historien müssen synchronisiert werden + private final List historyChat = Collections.synchronizedList(new ArrayList<>()); + private final List historyDx = Collections.synchronizedList(new ArrayList<>()); + + private boolean running = false; + private ServerSocket serverSocket; + + public MockKstServer() { + // Initiale Permanente User + addUser("DK5EW", "Erwin", "JN47NX"); + addUser("DL1TEST", "TestOp", "JO50XX"); + addUser("ON4KST", "Alain", "JO20HI"); + addUser("PA9R-2", "2", "JO20HI"); + addUser("PA9R-70", "70", "JO20HI"); + addUser("PA9R", "general", "JO20HI"); + } + + // Startet den Server im Hintergrund (Non-Blocking) + public void start() { + if (running) return; + running = true; + + new Thread(() -> { + try { + serverSocket = new ServerSocket(PORT); + System.out.println("[Server] ON4KST Simulation gestartet auf Port " + PORT); + + // Startet den Simulator für Zufallstraffic + new Thread(this::simulationLoop).start(); + + while (running) { + Socket clientSocket = serverSocket.accept(); + System.out.println("[Server] Neuer Client verbunden: " + clientSocket.getInetAddress()); + new Thread(new ClientHandler(clientSocket)).start(); + } + } catch (IOException e) { + if (running) e.printStackTrace(); + } + }).start(); + } + + public void stop() { + running = false; + try { + if (serverSocket != null) serverSocket.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private void addUser(String call, String name, String loc) { + onlineUsers.put(call, new User(call, name, loc)); + } + + private void removeUser(String call) { + onlineUsers.remove(call); + } + + // Sendet Nachricht an ALLE verbundenen Clients (inkl. Sender) + private void broadcast(String message) { + if (!message.endsWith("\r\n")) message += "\r\n"; + String finalMsg = message; + + for (PrintWriter writer : clients) { + try { + writer.print(finalMsg); + writer.flush(); // WICHTIG: Sofort senden! + } catch (Exception e) { + // Client wohl weg, wird beim nächsten Schreibversuch oder im Handler entfernt + } + } + } + + // --- Innere Logik: Client Handler --- + private class ClientHandler implements Runnable { + private Socket socket; + private PrintWriter out; + private BufferedReader in; + private String myCall = "MYCLIENT"; // Default, wird bei LOGIN überschrieben + + public ClientHandler(Socket socket) { + this.socket = socket; + } + + @Override + public void run() { + try { + // ISO-8859-1 ist Standard für KST/Telnet Cluster + in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "ISO-8859-1")); + out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "ISO-8859-1"), true); + + clients.add(out); + + String line; + boolean loginComplete = false; + + while ((line = in.readLine()) != null) { + // System.out.println("[RECV] " + line); // Debugging aktivieren falls nötig + String[] parts = line.split("\\|"); + String cmd = parts[0]; + + if (cmd.equals("LOGIN") || cmd.equals("LOGINC")) { + // Protokoll: LOGIN|callsign|password|... [cite: 21] + if (parts.length > 1) myCall = parts[1]; + + // 1. Login Bestätigung + // Format: LOGSTAT|100|chat id|client software version|session key|config|dx option| + send("LOGSTAT|100|" + CHAT_ID + "|JavaSim|KEY123|Config|3|"); + + // Bei LOGIN senden wir die Daten sofort + // Bei LOGINC warten wir eigentlich auf SDONE, senden hier aber vereinfacht direkt + if (cmd.equals("LOGIN")) { + sendInitialData(); + loginComplete = true; + } + } + else if (cmd.equals("SDONE")) { + // Abschluss der Settings (bei LOGINC) [cite: 34] + sendInitialData(); + loginComplete = true; + } + else if (cmd.equals("MSG")) { + // MSG|chat id|destination|command|0| [cite: 42] + if (parts.length >= 4) { + String text = parts[3]; + // Nachricht sofort als CH Frame an alle verteilen (Echo) + handleChatMessage(myCall, "Me", text); + } + } + else if (cmd.equals("CK")) { + // Keepalive [cite: 20] + // Server muss nicht zwingend antworten, aber Connection bleibt offen + } + } + } catch (IOException e) { + // System.out.println("Client getrennt"); + } finally { + clients.remove(out); + try { socket.close(); } catch (IOException e) {} + } + } + + private void send(String msg) { + if (!msg.endsWith("\r\n")) msg += "\r\n"; + out.print(msg); + out.flush(); + } + + private void sendInitialData() { + // 1. User Liste UA0 [cite: 14] + for (User u : onlineUsers.values()) { + send("UA0|" + CHAT_ID + "|" + u.call + "|" + u.name + "|" + u.loc + "|0|"); + } + // 2. Chat History CR [cite: 7] + synchronized(historyChat) { + for (String h : historyChat) send(h); + } + // 3. DX History DL [cite: 10] + synchronized(historyDx) { + for (String d : historyDx) send(d); + } + // 4. Ende User Liste UE [cite: 15] + send("UE|" + CHAT_ID + "|" + onlineUsers.size() + "|"); + } + } + + // --- Hilfsmethoden für Traffic --- + + private void handleChatMessage(String call, String name, String text) { + // CH|chat id|date|callsign|firstname|destination|msg|highlight| + String date = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); + String frame = String.format("CH|%s|%s|%s|%s|0|%s|0|", CHAT_ID, date, call, name, text); + + synchronized(historyChat) { + historyChat.add(frame); + if (historyChat.size() > 50) historyChat.remove(0); + } + broadcast(frame); + } + + private void handleDxSpot(String spotter, String dx, String freq) { + // DL|Unix time|dx utc|spotter|qrg|dx|info|spotter locator|dx locator| [cite: 10] + long unixTime = System.currentTimeMillis() / 1000; + String utc = new SimpleDateFormat("HHmm").format(new Date()); + // Simple Dummy Locators + String frame = String.format("DL|%d|%s|%s|%s|%s|Simulated|JO00|JO99|", + unixTime, utc, spotter, freq, dx); + + synchronized(historyDx) { + historyDx.add(frame); + if (historyDx.size() > 20) historyDx.remove(0); + } + broadcast(frame); + } + + private void simulationLoop() { + String[] randomCalls = {"PA0GUS", "F6APE", "OH8K", "OZ2M", "G4CBW"}; + String[] msgs = {"CQ 144.300", "Tnx for QSO", "Any sked?", "QRV 432.200"}; + Random rand = new Random(); + + while (running) { + try { + Thread.sleep(3000 + rand.nextInt(5000)); // 3-8 Sek Pause + + int action = rand.nextInt(10); + String call = randomCalls[rand.nextInt(randomCalls.length)]; + + if (action < 4) { // 40% Chat + handleChatMessage(call, "SimOp", msgs[rand.nextInt(msgs.length)]); + } else if (action < 7) { // 30% DX Spot + handleDxSpot(call, randomCalls[rand.nextInt(randomCalls.length)], "144." + rand.nextInt(400)); + } else if (action == 8) { // Login Simulation UA5 + if (!onlineUsers.containsKey(call)) { + addUser(call, "SimOp", "JO11"); + broadcast("UA5|" + CHAT_ID + "|" + call + "|SimOp|JO11|2|"); + } + } else if (action == 9) { // Logout Simulation UR6 + if (onlineUsers.containsKey(call) && !call.equals("DK5EW")) { // DK5EW nicht kicken + removeUser(call); + broadcast("UR6|" + CHAT_ID + "|" + call + "|"); + } + } + + // Ping ab und zu + if (rand.nextInt(5) == 0) broadcast("CK|"); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + // Kleine Datenklasse + private static class User { + String call, name, loc; + User(String c, String n, String l) { this.call=c; this.name=n; this.loc=l; } + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/GuiUtils.java b/src/main/java/kst4contest/view/GuiUtils.java index 9af1d4f..44225ab 100644 --- a/src/main/java/kst4contest/view/GuiUtils.java +++ b/src/main/java/kst4contest/view/GuiUtils.java @@ -44,25 +44,33 @@ public class GuiUtils { public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) { - { - //trick to trigger gui changes on property changes of obects - - Predicate dummyPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - return true; - } - }; - - /** - * //TODO: following 2 lines are a quick fix to making disappear worked chatmembers of the list - * Thats uncomfortable due to this also causes selection changes, - * Better way is to change all worked and qrv values to observables and then trigger the underlying - * list to fire an invalidationevent. Really Todo! - */ - chatController.getLst_chatMemberListFilterPredicates().add(dummyPredicate); - chatController.getLst_chatMemberListFilterPredicates().remove(dummyPredicate); - - } + if (javafx.application.Platform.isFxApplicationThread()) { + triggerUpdate(chatController); + } else{ + javafx.application.Platform.runLater(() -> triggerUpdate(chatController)); + } } + + private static void triggerUpdate(ChatController chatController) { + { + //trick to trigger gui changes on property changes of obects + + Predicate dummyPredicate = new Predicate() { + @Override + public boolean test(ChatMember chatMember) { + return true; + } + }; + + /** + * //TODO: following 2 lines are a quick fix to making disappear worked chatmembers of the list + * Thats uncomfortable due to this also causes selection changes, + * Better way is to change all worked and qrv values to observables and then trigger the underlying + * list to fire an invalidationevent. Really Todo! + */ + chatController.getLst_chatMemberListFilterPredicates().add(dummyPredicate); + chatController.getLst_chatMemberListFilterPredicates().remove(dummyPredicate); + + } + } } diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index 92665c9..0b9578e 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -7,6 +7,15 @@ import java.util.*; import java.util.function.Consumer; import java.util.function.Predicate; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.collections.FXCollections; +import javafx.event.Event; +import kst4contest.view.TimelineView; // The new class we created +import kst4contest.model.ContestSked; // The new model +import javafx.scene.control.TableRow; // For the priority coloring + +import javafx.animation.PauseTransition; import javafx.beans.binding.Bindings; import javafx.css.PseudoClass; import javafx.geometry.*; @@ -15,8 +24,10 @@ import javafx.scene.input.*; import javafx.scene.layout.*; import javafx.scene.media.Media; import javafx.scene.media.MediaPlayer; +import javafx.util.Duration; import kst4contest.ApplicationConstants; import kst4contest.controller.ChatController; +import kst4contest.controller.StatusUpdateListener; import kst4contest.controller.Utils4KST; import javafx.application.Application; import javafx.application.Platform; @@ -48,9 +59,37 @@ import javafx.scene.shape.Polygon; import kst4contest.utils.ApplicationFileUtils; -public class Kst4ContestApplication extends Application { +public class Kst4ContestApplication extends Application implements StatusUpdateListener { // private static final Kst4ContestApplication dbcontroller = new DBController(); + + private final Button btnBandUpgradeIndicator = new Button("BAND+"); + private final Tooltip tipBandUpgradeIndicator = new Tooltip(); + private Timeline bandUpgradeBlinkTimeline; + + private final Button btnSkedWarnIndicator = new Button("SKED"); + private final Tooltip tipSkedWarnIndicator = new Tooltip(); + private Timeline skedWarnBlinkTimeline; + + + // Timeline: show at most N priority markers per minute bucket (minute 0/1 often has many planes) + private static final int TIMELINE_PRIORITY_MARKERS_PER_MINUTE = 2; + + // Timeline: show 2 more in other directions + private static final int TIMELINE_BEAM_MARKERS_PER_MINUTE = 2; + + + // Keep in sync with TimelineView PREVIEW_TIME_MS (currently 30 minutes = 30L * 60L * 1000L) + private static final long TIMELINE_PREVIEW_TIME_MS = 30L * 60L * 1000L; + + + //recoloring of the chatmembers list is turned on and off here + private static final boolean ENABLE_PRIORITY_SCORE_ROW_COLORING = false; + + private TimelineView timelineView; //timeline view above the sendtext-field + + private final Map statusButtons = new HashMap<>(); //there we will place some flickering + public static final String STYLE_DEFAULTCSSDAY_FILE = "KST4ContestDefaultDay.css"; public static final String STYLE_DEFAULTCSSDAY_RESOURCE = "/KST4ContestDefaultDay.css"; @@ -59,16 +98,47 @@ public class Kst4ContestApplication extends Application { String chatState; ChatController chatcontroller; + + Button MYQRGButton; // TODO: clean code? Got the myqrg button out of the factory method to modify // the text later Button MYCALLSetQRGButton; Timer timer_buildWindowTitle; - Timer timer_chatMemberTableSortTimer; // need that because javafx bug, it´s the only way to actualize the table... +// Timer timer_chatMemberTableSortTimer; // need that because javafx bug, it´s the only way to actualize the table... Timer timer_updatePrivatemessageTable; // same here VBox selectedCallSignFurtherInfoPane = new VBox(); - Button[] btnQtfButtonsAvl = new Button[8]; + ToggleButton[] btnQtfButtonsAvl = new ToggleButton[8]; + + /** + * helper DTO for planes and arriving time in minutes. Maybe + */ + private static final class NextApInfo { + final AirPlane plane; + final int arrivingMinutes; + + private NextApInfo(AirPlane plane, int arrivingMinutes) { + this.plane = plane; + this.arrivingMinutes = arrivingMinutes; + } + } + + /** + * Helper DTO for timeline building + */ + private static final class TimelineCandidateTmp { + final kst4contest.controller.ScoreService.TopCandidate top; + final ChatMember representativeMember; + final NextApInfo nextAp; + + TimelineCandidateTmp(kst4contest.controller.ScoreService.TopCandidate top, ChatMember representativeMember, NextApInfo nextAp) { + this.top = top; + this.representativeMember = representativeMember; + this.nextAp = nextAp; + } + } + public static void showUserInputErrorWindow (String message) { @@ -121,6 +191,123 @@ public class Kst4ContestApplication extends Application { return new javafx.scene.Group(arrowLine, arrowhead); } + /** + * Gets thread notifications and makes new statusbuttons at the top + * + * @param sourceName + */ + private void updateStatusButton(String sourceName, ThreadStateMessage threadStateMessage) { + Button button = statusButtons.computeIfAbsent(sourceName, name -> { + Button b = new Button(threadStateMessage.getThreadNickName()); + b.getStyleClass().removeIf(cls -> cls.startsWith("btn-showstate")); + + b.getStyleClass().add("btn-showstate-enabled"); + b.setTooltip(new Tooltip(threadStateMessage.getRunningInformation())); + + this.flwpne_StatusBar.getChildren().add(b); // BorderPane oder HBox o. ä. + return b; + }); + + + button.setText(sourceName + ": " + threadStateMessage.getRunningInformationTextDescription()); + + button.getTooltip().setText(threadStateMessage.getRunningInformation()); + button.getStyleClass().removeIf(cls -> cls.startsWith("btn-showstate")); + button.getStyleClass().add("btn-showstate-enabled-furtherInfo"); +// button.setStyle("-fx-text-fill: red;"); + + + PauseTransition pause = new PauseTransition(Duration.seconds(0.2)); + pause.setOnFinished(e -> { + button.getStyleClass().removeIf(cls -> cls.startsWith("btn-showstate")); + button.getStyleClass().add("btn-showstate-enabled-default"); +// button.setStyle("-fx-text-fill: blue;"); + }); + pause.play(); + + } + + /** + * Helps the view to format the RX Bands for a callsign, using the chatmembers frequencies detected MAP + * @param callSignRaw + * @param maxAgeMs + * @return + */ + private String formatDetectedRxBandsForCallsignRaw(String callSignRaw, long maxAgeMs) { + + if (callSignRaw == null) return "Bands: -"; + + // band -> (freq,timestamp) newest across ALL category-variants + Map newestPerBand = new java.util.EnumMap<>(kst4contest.model.Band.class); + + synchronized (chatcontroller.getLst_chatMemberList()) { + for (ChatMember m : chatcontroller.getLst_chatMemberList()) { + if (m == null) continue; + if (m.getCallSignRaw() == null) continue; + if (!m.getCallSignRaw().equalsIgnoreCase(callSignRaw)) continue; + + Map map = m.getKnownActiveBands(); + if (map == null) continue; + + for (Map.Entry e : map.entrySet()) { + kst4contest.model.Band band = e.getKey(); + ChatMember.ActiveFrequencyInfo info = e.getValue(); + if (band == null || info == null) continue; + + // optional age filter (e.g. last 30 minutes) + if (maxAgeMs > 0 && (System.currentTimeMillis() - info.timestampEpoch) > maxAgeMs) { + continue; + } + + ChatMember.ActiveFrequencyInfo existing = newestPerBand.get(band); + if (existing == null || info.timestampEpoch > existing.timestampEpoch) { + newestPerBand.put(band, info); + } + } + } + } + + if (newestPerBand.isEmpty()) { + return "Bands: -"; + } + + // Render sorted by band enum order + StringBuilder sb = new StringBuilder("Bands: "); + boolean first = true; + + for (kst4contest.model.Band b : kst4contest.model.Band.values()) { + ChatMember.ActiveFrequencyInfo info = newestPerBand.get(b); + if (info == null) continue; + + long ageMin = (System.currentTimeMillis() - info.timestampEpoch) / 60000L; + + if (!first) sb.append(" | "); + first = false; + + sb.append(String.format(java.util.Locale.US, "%.3f", info.frequency)) + .append(" MHz") + .append(" (") + .append(ageMin) + .append(" min ago)"); + } + + return sb.toString(); + } + + private String bandToHumanLabel(kst4contest.model.Band b) { + // Human-friendly labels for VHF/UHF/microwave contesting + return switch (b) { + case B_144 -> "2m"; + case B_432 -> "70cm"; + case B_1296 -> "23cm"; + case B_2320 -> "13cm"; + case B_3400 -> "9cm"; + case B_5760 -> "6cm"; + case B_10G -> "3cm"; + case B_24G -> "24G"; + }; + } + /** * This method generates a BoderPane which shows some additional information about a callsign which had been @@ -158,6 +345,96 @@ public class Kst4ContestApplication extends Application { selectedCallSignDownerSiteGridPane.add(selectedCallSignInfoLblQRBInfo, 0,1,1,1); selectedCallSignDownerSiteGridPane.add(new Label("Last activity: " + new Utils4KST().time_convertEpochToReadable(selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch()+"")), 0,2,1,1); selectedCallSignDownerSiteGridPane.add(new Label(("(" + Utils4KST.time_getSecondsBetweenEpochAndNow(selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch()+"") /60%60) +" min ago)"), 0,3,1,1); + + // Show detected RX bands based on frequency recognition in chat history. + // Default: last 30 minutes (same horizon as Smart Parser history usage) + Label lblDetectedRxBands = new Label( + formatDetectedRxBandsForCallsignRaw(selectedCallSignInfoStageChatMember.getCallSignRaw(), 30L * 60L * 1000L) + ); + lblDetectedRxBands.setWrapText(true); + selectedCallSignDownerSiteGridPane.add(lblDetectedRxBands, 0, 4, 1, 1); + + + Label selectedCallSignInfoLblPriorityScore = new Label(); + selectedCallSignInfoLblPriorityScore.textProperty().bind(Bindings.createStringBinding( + () -> { + double s = chatcontroller.getScoreService().selectedCallPriorityScoreProperty().get(); + if (Double.isNaN(s)) return "Priority score: -"; + return String.format(java.util.Locale.US, "Priority score: %.0f", s); + }, + chatcontroller.getScoreService().selectedCallPriorityScoreProperty())); +// selectedCallSignDownerSiteGridPane.add(selectedCallSignInfoLblPriorityScore, 0,5,1,1); + + Button btnSkedFail = new Button("Sked fail"); + btnSkedFail.setTooltip(new Tooltip("Marks the path as failed (permanent until reset). Strongly reduces priority score.")); + btnSkedFail.setOnAction(e -> { + ChatMember sel = chatcontroller.getScoreService().selectedChatMemberProperty().get(); + if (sel == null) return; + chatcontroller.getStationMetricsService().markManualSkedFail(sel.getCallSignRaw()); + chatcontroller.getScoreService().requestRecompute("manual-sked-fail"); + }); + + Button btnSkedFailReset = new Button("Reset fail"); + btnSkedFailReset.setTooltip(new Tooltip("Resets the manual sked-fail flag for this station.")); + btnSkedFailReset.setOnAction(e -> { + ChatMember sel = chatcontroller.getScoreService().selectedChatMemberProperty().get(); + if (sel == null) return; + chatcontroller.getStationMetricsService().resetManualSkedFail(sel.getCallSignRaw()); + chatcontroller.getScoreService().requestRecompute("manual-sked-fail-reset"); + }); + + HBox priorityRow = new HBox(8, selectedCallSignInfoLblPriorityScore, btnSkedFail, btnSkedFailReset); + priorityRow.setAlignment(Pos.CENTER_LEFT); + + 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)); + cbSkedMinutes.getSelectionModel().select(Integer.valueOf(5)); + + 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.setOnAction(e -> { + ChatMember sel = chatcontroller.getScoreService().selectedChatMemberProperty().get(); + if (sel == null) return; + + int minutes = cbSkedMinutes.getValue() == null ? 5 : cbSkedMinutes.getValue(); + long skedTime = System.currentTimeMillis() + minutes * 60_000L; + + double az = sel.getQTFdirection() != null ? sel.getQTFdirection() : 0.0; + + // 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); + + chatcontroller.addSked(sked); + chatcontroller.getScoreService().requestRecompute("sked-created"); + + if (chkPmReminders.isSelected()) { + List offsets = parseMinuteOffsets(cbReminderOffsets.getValue()); + chatcontroller.getSkedReminderService().armReminders(sel.getCallSignRaw(), sel.getChatCategory(), skedTime, offsets); + } + }); + + HBox skedRow = new HBox(10, + new Label("Sked in"), + cbSkedMinutes, + new Label("min"), + btnCreateSked, + chkPmReminders, + cbReminderOffsets + ); + skedRow.setAlignment(Pos.CENTER_LEFT); + + selectedCallSignDownerSiteGridPane.add(skedRow, 0, 6, 1, 1); + + + Label selectedCallSignChatCategoryLabelDesc = new Label(selectedCallSignInfoStageChatMember.getCallSign() + " in chatcategory: " + selectedCallSignInfoStageChatMember.getChatCategory().getChatCategoryName(selectedCallSignInfoStageChatMember.getChatCategory().getCategoryNumber())); selectedCallSignChatCategoryLabelDesc.getStyleClass().clear(); @@ -399,9 +676,30 @@ public class Kst4ContestApplication extends Application { chatcontroller.airScout_SendAsShowPathPacket(selectedCallSignInfoStageChatMember); } }); - selectedCallSignShowAsPathBtn.setGraphic(createArrow(selectedCallSignInfoStageChatMember.getQTFdirection())); + + Button selectedCallSignTurnAntBtn = new Button("Turn ant1 to " + selectedCallSignInfoStageChatMember.getCallSignRaw()); + selectedCallSignTurnAntBtn.setOnAction(new EventHandler() { + @Override + public void handle(ActionEvent actionEvent) { +// chatcontroller.airScout_SendAsShowPathPacket(selectedCallSignInfoStageChatMember); +// Alert a = new Alert(AlertType.INFORMATION); +// +// a.setTitle("Not yet implemented!"); +// a.setHeaderText("kst4Contest " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER + ": This is a todo!"); +// a.setContentText("Mach mal hinne!"); +// a.show(); +// chatcontroller.stopRotator(); //if it´s running, stop it firstly, then set the new value +// chatcontroller.stopRotator(); + chatcontroller.rotateTo(selectedCallSignInfoStageChatMember.getQTFdirection()); + + + //TODO: Hier muss was hin + } + }); + selectedCallSignTurnAntBtn.setGraphic(createArrow(selectedCallSignInfoStageChatMember.getQTFdirection())); + Button selectedCallSignShowQRZprofile = new Button("Lookup on qrz.com"); selectedCallSignShowQRZprofile.setOnAction(new EventHandler() { @Override @@ -419,8 +717,10 @@ public class Kst4ContestApplication extends Application { }); selectedCallSignDownerSiteGridPane.add(selectedCallSignShowAsPathBtn, 1,0,1,1); - selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZprofile, 1,1,1,1); - selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZCqprofile, 1,2,1,1); + selectedCallSignDownerSiteGridPane.add(selectedCallSignTurnAntBtn, 1,1,1,1); + + selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZprofile, 1,2,1,1); + selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZCqprofile, 1,3,1,1); @@ -476,6 +776,11 @@ public class Kst4ContestApplication extends Application { try { + //message is directed to all and I am not mentioned + if (chatMessage.getReceiver().getCallSign().equals("ALL") && !(chatMessage.getMessageText().toLowerCase().contains(chatcontroller.getChatPreferences().getStn_loginCallSign().toLowerCase()))) { + return false; + } + if (((chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) || (chatMessage.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) ) && ((chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())))) { return true; @@ -612,6 +917,24 @@ public class Kst4ContestApplication extends Application { } + /** + * Helper method for furtherinfoPane + * @param s + * @return + */ + private static List parseMinuteOffsets(String s) { + if (s == null || s.isBlank()) return List.of(); + String[] parts = s.split("\\+"); + List out = new ArrayList<>(); + for (String p : parts) { + try { + out.add(Integer.parseInt(p.trim())); + } catch (Exception ignore) {} + } + return out; + } + + private TableView initChatMemberTable() { TableView tbl_chatMemberTable = new TableView(); @@ -842,7 +1165,7 @@ public class Kst4ContestApplication extends Application { // qrg = (cellDataFeatures.getValue().getFrequency()); // if (!qrg.getValue().equals("")) { -// +// // } return cellDataFeatures.getValue().getFrequency(); @@ -1272,146 +1595,136 @@ public class Kst4ContestApplication extends Application { * the table in intervals to keep the table up to date. */ - timer_chatMemberTableSortTimer = new Timer(); - - timer_chatMemberTableSortTimer.scheduleAtFixedRate(new TimerTask() { - - public void run() { - Thread.currentThread().setName("chatMemberTableSortTimer"); - -// System.out.println("[KST4CApp, Info:] Chatmemberlist-Filterlist predicates size: " + chatcontroller.getLst_chatMemberListFilterPredicates().size()); - -// { -// //trick to trigger gui changes on property changes of obects +// timer_chatMemberTableSortTimer = new Timer(); +// timer_chatMemberTableSortTimer.scheduleAtFixedRate(new TimerTask() { // -// Predicate dummyPredicate = new Predicate() { -// @Override -// public boolean test(ChatMember chatMember) { -// return true; -// } -// }; +// public void run() { +// Thread.currentThread().setName("chatMemberTableSortTimer"); // -// /** -// * //TODO: following 2 lines are a quick fix to making disappear worked chatmembers of the list -// * Thats uncomfortable due to this also causes selection changes, -// * Better way is to change all worked and qrv values to observables and then trigger the underlying -// * list to fire an invalidationevent. Really Todo! -// */ -// chatcontroller.getLst_chatMemberListFilterPredicates().add(dummyPredicate); -//// chatcontroller.getLst_chatMemberListFilterPredicates().remove(dummyPredicate); +// Platform.runLater(() -> { // -// } - -// System.out.println("[KST4CApp, Info:] Deviderpos: " + spl); -// for (int i = 0; i < chatcontroller.getLst_chatMemberListFilterPredicates().size(); i++) { +// try { // -// Predicate test = chatcontroller.getLst_chatMemberListFilterPredicates().get(i); -// test.so -// } - - - Platform.runLater(() -> { - - try { - -// tbl_chatMemberTable.sort(); - - } catch (Exception e) { - System.out.println("[Main.java, Warning:] Table sorting (actualizing) failed this time."); - } - - - tbl_chatMemberTable.refresh(); - -// tbl_chatMemberTable. - - }); - } - }, new Date(), 5000); +//// tbl_chatMemberTable.sort(); +// +// } catch (Exception e) { +// System.out.println("[Main.java, Warning:] Table sorting (actualizing) failed this time."); +// } +// +// +// tbl_chatMemberTable.refresh(); +// +//// tbl_chatMemberTable. +// +// }); +// } +// }, new Date(), 5000); tbl_chatMemberTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY); tbl_chatMemberTable.autosize(); + + /** + * expoerimental, new since 1.40: priorities + */ + + tbl_chatMemberTable.setRowFactory(tv -> new TableRow() { + @Override + protected void updateItem(ChatMember item, boolean empty) { + super.updateItem(item, empty); + + if (!ENABLE_PRIORITY_SCORE_ROW_COLORING) { + + setStyle(""); // Reset style for empty rows + } else { + double score = item.getCurrentPriorityScore(); // Ensure ChatMember has this getter! + + // Color Logic: + // > 1000 = NUCLEAR (Imminent Sked) -> Blinking Red (simulated here with solid red) + // > 200 = High Prio (AirScout / Good Sked) -> Orange + // > 100 = Medium Prio (Unworked / New Multi) -> Light Yellow + // <= 0 = Low Prio / Not Reachable -> Greyed out text + + // Note: Styles need to be adjusted if Dark Mode is active! + + boolean isDark = chatcontroller.getChatPreferences().isGUI_darkModeActive(); + + if (score > 1000) { + // Critical Alert + setStyle("-fx-background-color: #ff4d4d; -fx-text-fill: white; -fx-font-weight: bold;"); + } else if (score >= 200) { + // High Priority + setStyle("-fx-background-color: " + (isDark ? "#cc6600" : "#ffcc00") + "; -fx-text-fill: black;"); + } else if (score >= 100) { + // Medium Priority + setStyle("-fx-background-color: " + (isDark ? "#888800" : "#ffffcc") + "; -fx-text-fill: black;"); + } else if (score <= 0) { + // Penalty / Not Reachable + setStyle("-fx-text-fill: " + (isDark ? "#666666" : "#aaaaaa") + ";"); + } else { + // Standard Reset + setStyle(""); + } + } + } + }); + return tbl_chatMemberTable; } - /** - * Initializes the right click contextmenu for the chatmember-table, sets the - * clickhandler for the contextmenu out of a string array (each menuitam will be - * created out of exact one array-entry). These are initialized by the - * chatpreferences object out of the config-xml - * - * - * @return - */ -// private ContextMenu initChatMemberTableContextMenu(String[] menuTexts) { old mechanic -// -// ContextMenu chatMemberContextMenu = new ContextMenu(); -// -// for (int i = 0; i < menuTexts.length; i++) { -// final MenuItem menuItem = new MenuItem(menuTexts[i]); -// menuItem.setOnAction(new EventHandler() { -// public void handle(ActionEvent event) { -// txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText() + menuItem.getText()); -// } -// }); -// -// chatMemberContextMenu.getItems().add(menuItem); -// } -// -//// MenuItem macro1 = new MenuItem("Pse Sked?"); -//// macro1.setOnAction(new EventHandler() { -//// public void handle(ActionEvent event) { -//// txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText() + macro1.getText()); -//// } -//// }); -//// MenuItem macro10 = new MenuItem("Pse qrg 2m?"); -//// MenuItem macro20 = new MenuItem("Pse Call at "); -//// MenuItem macro30 = new MenuItem("In qso nw, pse qrx, I will meep you"); -//// MenuItem macro40 = new MenuItem("Pse qrg 70cm?"); -//// MenuItem macro50 = new MenuItem("pse qrg 23cm?"); -//// MenuItem macro60 = new MenuItem("____________________________________"); -//// MenuItem macro70 = new MenuItem("Watch QSO history"); -//// -//// chatMemberContextMenu.getItems().add(macro1); -//// chatMemberContextMenu.getItems().add(macro10); -//// chatMemberContextMenu.getItems().add(macro20); -//// chatMemberContextMenu.getItems().add(macro30); -//// chatMemberContextMenu.getItems().add(macro40); -//// chatMemberContextMenu.getItems().add(macro50); -//// chatMemberContextMenu.getItems().add(macro60); -//// chatMemberContextMenu.getItems().add(macro70); -// -// return chatMemberContextMenu; -// -// } + private ChatMember resolveChatMemberForCallRawAndCategory(String callRaw, ChatCategory preferredCategory) { + + if (callRaw == null) return null; + + // 1) Prefer exact (callRaw + category) + synchronized (chatcontroller.getLst_chatMemberList()) { + for (ChatMember m : chatcontroller.getLst_chatMemberList()) { + if (m == null) continue; + if (m.getCallSignRaw() == null) continue; + if (!m.getCallSignRaw().equalsIgnoreCase(callRaw)) continue; + + if (preferredCategory != null && preferredCategory.equals(m.getChatCategory())) { + return m; + } + } + + // 2) Fallback: any variant with same callsignRaw + for (ChatMember m : chatcontroller.getLst_chatMemberList()) { + if (m == null) continue; + if (m.getCallSignRaw() == null) continue; + if (m.getCallSignRaw().equalsIgnoreCase(callRaw)) return m; + } + } + + return null; + } + + private void focusChatMemberAndPrepareCq(ChatMember member) { + if (member == null) return; + + // Try selecting in table if it is visible (nice UX), but do not depend on it + try { + if (tbl_chatMember != null && tbl_chatMember.getItems() != null && tbl_chatMember.getItems().contains(member)) { + tbl_chatMember.getSelectionModel().select(member); + tbl_chatMember.scrollTo(member); + } + } catch (Exception ignored) { + // ignore: table not ready or filtered + } + + // Force selection effects regardless of filters/selection state + selectedCallSignInfoStageChatMember = member; + chatcontroller.getScoreService().setSelectedChatMember(member); + + selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(member)); + + txt_chatMessageUserInput.clear(); + txt_chatMessageUserInput.setText("/cq " + member.getCallSign() + " "); + txt_chatMessageUserInput.requestFocus(); + txt_chatMessageUserInput.selectEnd(); + } + -// private Stage initializeCommunicationOverMyHeadVizalizationStage(ChatMember selectedChatMember) { -// Stage stage_CommunicationOverMyHeadVizalizationStage = new Stage(); -// stage_CommunicationOverMyHeadVizalizationStage.setAlwaysOnTop(true); -// -// MaidenheadLocatorMapPane locatorMapPane = new MaidenheadLocatorMapPane(); -// locatorMapPane.addLocator("JO51IJ", Color.RED); -// locatorMapPane.addLocator("JN39OC", Color.BLUE); -// locatorMapPane.addLocator("JN49GL", Color.GREEN); -// locatorMapPane.connectLocators("JO51IJ", "JN49GL"); -// -// try { -// -// -// BorderPane bp_CommunicationOverMyHeadVizalizationStage = new BorderPane(); -// -// stage_CommunicationOverMyHeadVizalizationStage.setTitle("Further info on "+ selectedChatMember.getCallSign()); -// -// stage_CommunicationOverMyHeadVizalizationStage.setScene(new Scene(locatorMapPane)); -// stage_CommunicationOverMyHeadVizalizationStage.show(); -// -// return stage_CommunicationOverMyHeadVizalizationStage; -// } catch (Exception exception){ -// -// } -// return stage_CommunicationOverMyHeadVizalizationStage; -// } /** * Initializes the right click contextmenu for the chatmember-table, sets the @@ -1709,6 +2022,7 @@ public class Kst4ContestApplication extends Application { TableColumn qrgCol = new TableColumn("Last QRG"); qrgCol.setCellValueFactory(new Callback, ObservableValue>() { + @Override public ObservableValue call(CellDataFeatures cellDataFeatures) { StringProperty qrg = new SimpleStringProperty(); @@ -1725,7 +2039,7 @@ public class Kst4ContestApplication extends Application { } }); - + applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty TableColumn msgCol = new TableColumn("Message"); @@ -1934,6 +2248,8 @@ public class Kst4ContestApplication extends Application { return qrg; } }); + applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty + TableColumn msgCol = new TableColumn("Message"); msgCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -2277,6 +2593,8 @@ public class Kst4ContestApplication extends Application { return qrg; } }); + applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty + TableColumn msgCol = new TableColumn("Message"); msgCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -2553,30 +2871,395 @@ public class Kst4ContestApplication extends Application { flwPane_textSnippets.getChildren().clear(); flwPane_textSnippets.getChildren() .addAll(buttonFactory(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())); - -//TODO: redraw panel -// chatMessageContextMenu = initChatMemberTableContextMenu(chatcontroller.getChatPreferences().getLst_txtSnipList()); // TODO: thats not -// // clean, there had -// // to be a listener -// // triggered update -// // method -// chatMemberContextMenu = initChatMemberTableContextMenu(chatcontroller.getChatPreferences().getLst_txtSnipList()); - } }); tbl_txtShorts.getColumns().addAll(ShortCol); tbl_txtShorts.setEditable(true); -// tbl_txtSnips.set - -// ObservableList lst_textSnipList = ); tbl_txtShorts.setItems(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()); -// tbl_txtSnips.bind return tbl_txtShorts; } // TODO: Textsnippets table + + private BorderPane initTopPriorityListPane(TableView tbl_chatMember, TextField txt_chatMessageUserInput) { + + BorderPane pane = new BorderPane(); + pane.setStyle("-fx-padding: 3;"); + + Label header = new Label("Top priority candidates"); + header.getStyleClass().add("label"); + + ListView listView = new ListView<>(); + listView.setItems(chatcontroller.getScoreService().getTopCandidatesFx()); + + listView.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(kst4contest.controller.ScoreService.TopCandidate item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + return; + } + // Keep it compact; score is mainly evaluated in FurtherInfo + setText(item.getDisplayCallSign() + " | score " + String.format(java.util.Locale.US, "%.0f", item.getScore())); + } + }); + + listView.setOnMouseClicked(evt -> { + if (evt.getClickCount() < 1) return; + kst4contest.controller.ScoreService.TopCandidate c = listView.getSelectionModel().getSelectedItem(); + if (c == null) return; + + ChatMember resolved = resolveChatMemberForTopCandidate(c); + if (resolved == null) return; + + // Try to select in table (reuses existing selection logic) + if (tbl_chatMember.getItems().contains(resolved)) { + tbl_chatMember.getSelectionModel().select(resolved); + tbl_chatMember.scrollTo(resolved); + } else { + // Fallback: if filtered out, still show FurtherInfo + prepare /cq + selectedCallSignInfoStageChatMember = resolved; + chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); + + selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(resolved)); + txt_chatMessageUserInput.clear(); + txt_chatMessageUserInput.setText("/cq " + resolved.getCallSign() + " "); + txt_chatMessageUserInput.requestFocus(); + txt_chatMessageUserInput.selectEnd(); + + // Keep ScoreService selection in sync + chatcontroller.getScoreService().setSelectedChatMember(resolved); + } + }); + + pane.setTop(header); + pane.setCenter(listView); + return pane; + } + + private ChatMember resolveChatMemberForTopCandidate(kst4contest.controller.ScoreService.TopCandidate c) { + + String callRaw = c.getCallSignRaw(); + ChatCategory preferredCategory = c.getPreferredChatCategory(); + + // 1) Prefer exact (callRaw + category) match + synchronized (chatcontroller.getLst_chatMemberList()) { + for (ChatMember m : chatcontroller.getLst_chatMemberList()) { + if (m == null) continue; + if (m.getCallSignRaw() == null) continue; + if (!m.getCallSignRaw().equalsIgnoreCase(callRaw)) continue; + + if (preferredCategory != null && preferredCategory.equals(m.getChatCategory())) { + return m; + } + } + + // 2) Fallback: any variant with the same callsignRaw + for (ChatMember m : chatcontroller.getLst_chatMemberList()) { + if (m == null) continue; + if (m.getCallSignRaw() == null) continue; + if (m.getCallSignRaw().equalsIgnoreCase(callRaw)) return m; + } + } + + return null; + } + + + private void updateTimelineVisuals() { + if (timelineView == null || chatcontroller == null) return; + + if (!Platform.isFxApplicationThread()) { + Platform.runLater(this::updateTimelineVisuals); + return; + } + + List skedsSnapshot = new ArrayList<>(chatcontroller.getActiveSkeds()); + List candidates = buildTimelinePriorityCandidateEvents(); + + timelineView.updateVisuals(skedsSnapshot, candidates); + } + + /** + * Build candidate markers for the timeline: + * - Use ScoreService TopCandidates (already sorted) + * - Resolve representative ChatMember (preferred category if possible) + * - Use "next airplane arriving minute" as time basis + * - Bucket by minute, keep top 1-2 per minute (config above) + */ + private List buildTimelinePriorityCandidateEvents() { + + if (chatcontroller.getScoreService() == null) return Collections.emptyList(); + + long now = System.currentTimeMillis(); + + // Snapshot to avoid concurrent modifications (TopCandidates list is FX observable) + List topSnapshot = + new ArrayList<>(chatcontroller.getScoreService().getTopCandidatesFx()); + + Map> byMinute = new HashMap<>(); + + for (kst4contest.controller.ScoreService.TopCandidate c : topSnapshot) { + + ChatMember representative = resolveChatMemberForTopCandidate(c); + if (representative == null) continue; + + AirPlaneReflectionInfo apInfo = representative.getAirPlaneReflectInfo(); + + // choose airplane by (highest potential) then (shortest time) within preview window + int maxMinutes = (int) (TIMELINE_PREVIEW_TIME_MS / 60_000L); + NextApInfo selectedAp = findBestAirplane(apInfo, maxMinutes); + if (selectedAp == null) continue; + + long timeUntilMs = selectedAp.arrivingMinutes * 60_000L; + if (timeUntilMs < 0 || timeUntilMs > TIMELINE_PREVIEW_TIME_MS) continue; + + int minuteBucket = selectedAp.arrivingMinutes; + + byMinute.computeIfAbsent(minuteBucket, k -> new ArrayList<>()) + .add(new TimelineCandidateTmp(c, representative, selectedAp)); + } + + List out = new ArrayList<>(); + + for (Map.Entry> e : byMinute.entrySet()) { + + int minuteBucket = e.getKey(); + List bucket = e.getValue(); + + // Highest score first + bucket.sort((a, b) -> Double.compare(b.top.getScore(), a.top.getScore())); + + + // 1) Pick top-N in beam -> lanes 0..1 + List inBeam = new ArrayList<>(); + for (TimelineCandidateTmp tmp : bucket) { + if (chatcontroller.isChatMemberInMyBeam(tmp.representativeMember)) { + inBeam.add(tmp); + } + } + + // Avoid duplicates between beam and global selection + Set used = new HashSet<>(); + + int beamTake = Math.min(TIMELINE_BEAM_MARKERS_PER_MINUTE, inBeam.size()); + for (int lane = 0; lane < beamTake; lane++) { + + TimelineCandidateTmp tmp = inBeam.get(lane); + used.add(tmp.top.getCallSignRaw()); + + double az = (tmp.representativeMember.getQTFdirection() != null) ? tmp.representativeMember.getQTFdirection() : 0.0; + long timeUntilMs = minuteBucket * 60_000L; + + String tooltip = buildTimelineCandidateTooltip(tmp, minuteBucket); + int potential = (tmp.nextAp != null && tmp.nextAp.plane != null) ? tmp.nextAp.plane.getPotential() : 0; + + out.add(new TimelineView.CandidateEvent( + tmp.top.getCallSignRaw(), + tmp.top.getDisplayCallSign(), + tmp.top.getPreferredChatCategory(), + timeUntilMs, + minuteBucket, + lane, // lanes 0..1 = in-beam + az, + tmp.top.getScore(), + potential, + tooltip + )); + } + + // 2) Pick top-N global distinct -> lanes 2..3 + int globalAdded = 0; + for (TimelineCandidateTmp tmp : bucket) { + + if (globalAdded >= TIMELINE_PRIORITY_MARKERS_PER_MINUTE) break; + if (used.contains(tmp.top.getCallSignRaw())) continue; + + double az = (tmp.representativeMember.getQTFdirection() != null) ? tmp.representativeMember.getQTFdirection() : 0.0; + long timeUntilMs = minuteBucket * 60_000L; + + String tooltip = buildTimelineCandidateTooltip(tmp, minuteBucket); + int potential = (tmp.nextAp != null && tmp.nextAp.plane != null) ? tmp.nextAp.plane.getPotential() : 0; + + int laneIndex = TIMELINE_BEAM_MARKERS_PER_MINUTE + globalAdded; // lanes 2..3 + + out.add(new TimelineView.CandidateEvent( + tmp.top.getCallSignRaw(), + tmp.top.getDisplayCallSign(), + tmp.top.getPreferredChatCategory(), + timeUntilMs, + minuteBucket, + laneIndex, + az, + tmp.top.getScore(), + potential, + tooltip + )); + + globalAdded++; + } + } + + out.sort(Comparator + .comparingInt(TimelineView.CandidateEvent::getMinuteBucket) + .thenComparingInt(TimelineView.CandidateEvent::getLaneIndex)); + + return out; + } + + private String buildTimelineCandidateTooltip(TimelineCandidateTmp tmp, int minuteBucket) { + AirPlane p = tmp.nextAp.plane; + + String planeStr = "-"; + if (p != null) { + planeStr = p.getApCallSign() + + " | " + p.getPotencialDescriptionAsWord() + + " | pot " + p.getPotential() + + " | dist " + p.getDistanceKm() + " km"; + } + + NextApInfo earliest = findEarliestAirplane( + tmp.representativeMember.getAirPlaneReflectInfo(), + (int) (TIMELINE_PREVIEW_TIME_MS / 60_000L) + ); + + String earliestStr = ""; + if (earliest != null && earliest.plane != null) { + // only show if it differs from the selected/best plane minute + if (earliest.arrivingMinutes != minuteBucket) { + earliestStr = "\nearliest AP: +" + earliest.arrivingMinutes + + " min (pot " + earliest.plane.getPotential() + "%)"; + } + } + + return tmp.top.getDisplayCallSign() + + "\nscore: " + String.format(Locale.US, "%.0f", tmp.top.getScore()) + + "\nbest AP: +" + minuteBucket + " min" + + "\nplane: " + planeStr + + earliestStr; + } + + /** + * Select the airplane that should drive timeline/sked decisions. + * + * Rule: prefer highest potential; if tied, prefer shortest arriving time. + * Only considers planes within [0..maxMinutes] to avoid dropping stations completely. + */ + private NextApInfo findBestAirplane(AirPlaneReflectionInfo apInfo, int maxMinutes) { + if (apInfo == null) return null; + if (apInfo.getRisingAirplanes() == null) return null; + + AirPlane best = null; + int bestMin = Integer.MAX_VALUE; + int bestPot = Integer.MIN_VALUE; + + for (AirPlane p : apInfo.getRisingAirplanes()) { + if (p == null) continue; + + int m = p.getArrivingDurationMinutes(); + if (m < 0 || m > maxMinutes) continue; + + int pot = p.getPotential(); + + // primary: potential DESC, secondary: time ASC + if (best == null || pot > bestPot || (pot == bestPot && m < bestMin)) { + best = p; + bestPot = pot; + bestMin = m; + } + } + + if (best == null) return null; + return new NextApInfo(best, bestMin); + } + + /** + * Select the earliest airplane (used for additional tooltip info). + * Rule: prefer shortest arriving time; if tied, prefer higher potential. + */ + private NextApInfo findEarliestAirplane(AirPlaneReflectionInfo apInfo, int maxMinutes) { + if (apInfo == null) return null; + if (apInfo.getRisingAirplanes() == null) return null; + + AirPlane best = null; + int bestMin = Integer.MAX_VALUE; + + for (AirPlane p : apInfo.getRisingAirplanes()) { + if (p == null) continue; + + int m = p.getArrivingDurationMinutes(); + if (m < 0 || m > maxMinutes) continue; + + if (m < bestMin) { + bestMin = m; + best = p; + } else if (m == bestMin && best != null && p.getPotential() > best.getPotential()) { + best = p; + } + } + + if (best == null || bestMin == Integer.MAX_VALUE) return null; + return new NextApInfo(best, bestMin); + } + + + private TableView initNotifyAtCallSignTable() { + + TableView tbl_notifyTxtCallSign = new TableView(); + tbl_notifyTxtCallSign.setTooltip(new Tooltip("Add Callsigns which you want to observe. Their Communcation will added to your PM Table")); + + + + TableColumn callSignCol = new TableColumn("Sniff QSO of Callsign"); + callSignCol.setCellValueFactory(new Callback, ObservableValue>() { + + @Override + public ObservableValue call(CellDataFeatures cellDataFeatures) { + SimpleStringProperty callSign = new SimpleStringProperty(); + callSign.setValue(cellDataFeatures.getValue()); + return callSign; + } + }); + callSignCol.setCellFactory(TextFieldTableCell.forTableColumn()); + + callSignCol.setOnEditCommit(new EventHandler>() { + @Override + public void handle(CellEditEvent t) { + + String newValue = t.getNewValue().toUpperCase(); //its better as all callsigns in the chat are uppercase + + + t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); + + if (newValue == "") { // delete lines which had been cleared + t.getTableView().getItems().remove(t.getTablePosition().getRow()); + } else { + if (GuiUtils.isCallSignSyntax(newValue)) { + + } else { + alertWindowEvent("Please try again with correct callsign syntax"); + t.getTableView().getItems().remove(t.getTablePosition().getRow()); + } + } + + //TODO: Observe logic - add to the filters list! +// flwPane_textSnippets.getChildren().clear(); +// flwPane_textSnippets.getChildren() +// .addAll(buttonFactory(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())); + } + }); + + tbl_notifyTxtCallSign.getColumns().addAll(callSignCol); + + tbl_notifyTxtCallSign.setEditable(true); +// tbl_notifyTxtCallSign.setItems(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()); //TODO: Init aus Speicher muss noch her + + return tbl_notifyTxtCallSign; + } // TODO: Callsign sniffer table + private TableView initTextSnippetsTable() { TableView tbl_txtSnips = new TableView(); @@ -3013,6 +3696,9 @@ public class Kst4ContestApplication extends Application { scn_ChatwindowMainScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING); clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING); settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING); + + chatcontroller.getChatPreferences().setGUI_darkModeActive(true); + } }); @@ -3030,6 +3716,7 @@ public class Kst4ContestApplication extends Application { scn_ChatwindowMainScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); + chatcontroller.getChatPreferences().setGUI_darkModeActive(false); } }); @@ -3057,7 +3744,6 @@ public class Kst4ContestApplication extends Application { getHostServices().showDocument("https://www.paypal.com/paypalme/do5amf"); - } }); @@ -3125,21 +3811,178 @@ public class Kst4ContestApplication extends Application { } }); -// helpMenu.getItems().add(help1); helpMenu.getItems().addAll(help2, help3, help4, menuItmDonateOV3T, menuItmDonateON4KST, help6, help8, help10); -// helpMenu.getItems().add(help2); -// helpMenu.getItems().add(help4); -// -// helpMenu.getItems().add(help10); - MenuBar menubar = new MenuBar(); menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu); // macromenu deleted return menubar; - } + + /***************************************************** + * Sked warning Initializing and functional section + ****************************************************/ + + /** + * Initializes the button for the Sked Warning (its an non clickable Info button) + */ + private void initSkedWarnIndicatorButton() { + btnSkedWarnIndicator.setVisible(false); + btnSkedWarnIndicator.managedProperty().bind(btnSkedWarnIndicator.visibleProperty()); + + // "no click function" - it is just an indicator + btnSkedWarnIndicator.setMouseTransparent(true); + btnSkedWarnIndicator.setFocusTraversable(false); + + btnSkedWarnIndicator.setStyle( + "-fx-background-color: rgba(255,0,255,0.85);" + + "-fx-text-fill: black;" + + "-fx-font-weight: bold;" + + "-fx-padding: 2 8 2 8;" + + "-fx-background-radius: 6;" + ); + + 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 + String shown = text; + if (shown.length() > 38) shown = shown.substring(0, 35) + "..."; + + btnSkedWarnIndicator.setText(shown); + tipSkedWarnIndicator.setText(text); + + btnSkedWarnIndicator.setVisible(true); + btnSkedWarnIndicator.setOpacity(1.0); + + if (skedWarnBlinkTimeline != null) { + skedWarnBlinkTimeline.stop(); + } + + skedWarnBlinkTimeline = new Timeline( + new KeyFrame(Duration.ZERO, e -> btnSkedWarnIndicator.setOpacity(1.0)), + new KeyFrame(Duration.millis(250), e -> btnSkedWarnIndicator.setOpacity(0.25)), + new KeyFrame(Duration.millis(500), e -> btnSkedWarnIndicator.setOpacity(1.0)) + ); + skedWarnBlinkTimeline.setCycleCount(24); // 12 = 6 seconds + skedWarnBlinkTimeline.setOnFinished(e -> hideSkedWarnIndicator()); + skedWarnBlinkTimeline.playFromStart(); + } + + private void hideSkedWarnIndicator() { + btnSkedWarnIndicator.setOpacity(1.0); + btnSkedWarnIndicator.setVisible(false); + } + + + /***************************************************** + * Band-Upgrade warning (after log entry) section + ****************************************************/ + + /** + * Initializes the button for the Band-Upgrade Hint. + * Non-clickable; it blinks and shows the reason (call + remaining bands). + */ + private void initBandUpgradeIndicatorButton() { + btnBandUpgradeIndicator.setVisible(false); + btnBandUpgradeIndicator.managedProperty().bind(btnBandUpgradeIndicator.visibleProperty()); + + btnBandUpgradeIndicator.setMouseTransparent(true); + btnBandUpgradeIndicator.setFocusTraversable(false); + + btnBandUpgradeIndicator.setStyle( + "-fx-background-color: rgba(255,255,0,0.85);" + + "-fx-text-fill: black;" + + "-fx-font-weight: bold;" + + "-fx-padding: 2 8 2 8;" + + "-fx-background-radius: 6;" + ); + + btnBandUpgradeIndicator.setTooltip(tipBandUpgradeIndicator); + } + + private void maybeShowBandUpgradeIndicator(String key, ThreadStateMessage msg) { + if (msg == null) return; + + String nick = msg.getThreadNickName() == null ? "" : msg.getThreadNickName().toLowerCase(Locale.ROOT); + String k = key == null ? "" : key.toLowerCase(Locale.ROOT); + + boolean isBandUpgrade = k.contains("bandupgrade") || nick.contains("bandupgrade"); + if (!isBandUpgrade) return; + + String buttonText = msg.getRunningInformationTextDescription(); + if (buttonText == null || buttonText.isBlank()) buttonText = "BAND+"; + + String tooltip = msg.getRunningInformation(); + if (tooltip == null || tooltip.isBlank()) tooltip = buttonText; + + final String finalButtonText = buttonText; + final String finalTooltip = tooltip; + + Platform.runLater(() -> showBlinkingBandUpgradeIndicator(finalButtonText, finalTooltip)); + } + + private void showBlinkingBandUpgradeIndicator(String buttonText, String tooltipText) { + + // short text for the button; full text in tooltip + String shown = buttonText; + if (shown.length() > 38) shown = shown.substring(0, 35) + "..."; + + btnBandUpgradeIndicator.setText(shown); + tipBandUpgradeIndicator.setText(tooltipText); + + btnBandUpgradeIndicator.setVisible(true); + btnBandUpgradeIndicator.setOpacity(1.0); + + if (bandUpgradeBlinkTimeline != null) { + bandUpgradeBlinkTimeline.stop(); + } + + bandUpgradeBlinkTimeline = new Timeline( + new KeyFrame(Duration.ZERO, e -> btnBandUpgradeIndicator.setOpacity(1.0)), + new KeyFrame(Duration.millis(250), e -> btnBandUpgradeIndicator.setOpacity(0.25)), + new KeyFrame(Duration.millis(500), e -> btnBandUpgradeIndicator.setOpacity(1.0)) + ); + bandUpgradeBlinkTimeline.setCycleCount(24); // ~12 seconds + bandUpgradeBlinkTimeline.setOnFinished(e -> hideBandUpgradeIndicator()); + bandUpgradeBlinkTimeline.playFromStart(); + } + + private void hideBandUpgradeIndicator() { + btnBandUpgradeIndicator.setOpacity(1.0); + btnBandUpgradeIndicator.setVisible(false); + } + +/** + * End Band-Upgrade section + */ + + + /** + * End Sked warning section + */ + + // SimpleStringProperty messageBusOfChatCtrl = messageBus; Scene scn_ChatwindowMainScene; Scene clusterAndQSOMonScene; @@ -3159,18 +4002,22 @@ public class Kst4ContestApplication extends Application { ContextMenu chatMemberContextMenu;// public due need to update it on modify HBox chatMemberTableFilterQTFAndQRBHbox; + TableView tbl_chatMember = new TableView(); + FlowPane flwPane_textSnippets; + FlowPane flwpne_StatusBar; Stage clusterAndQSOMonStage; // Stage stage_selectedCallSignInfoStage; ChatMember selectedCallSignInfoStageChatMember; BorderPane selectedCallSignInfoBorderPane; - Stage stage_updateStage; - Stage settingsStage; + Stage notify_setSnifferEntitiesStage; + + ChoiceBox stn_choiceBxChatChategorySecond; @@ -3296,8 +4143,8 @@ public class Kst4ContestApplication extends Application { timer_buildWindowTitle.purge(); timer_buildWindowTitle.cancel(); - timer_chatMemberTableSortTimer.purge(); - timer_chatMemberTableSortTimer.cancel(); +// timer_chatMemberTableSortTimer.purge(); +// timer_chatMemberTableSortTimer.cancel(); timer_updatePrivatemessageTable.purge(); timer_updatePrivatemessageTable.cancel(); @@ -3310,6 +4157,9 @@ public class Kst4ContestApplication extends Application { private Queue musicList = new LinkedList(); private MediaPlayer mediaPlayer ; + + + private void playCWLauncher(String playThisChars) { char[] playThisInCW = playThisChars.toUpperCase().toCharArray(); @@ -3625,20 +4475,71 @@ public class Kst4ContestApplication extends Application { @Override public void start(Stage primaryStage) throws InterruptedException, IOException, URISyntaxException { + + + VBox pnl_inputAndSendButtons = new VBox(); //gets the sendtext field, send button and the timeline + timelineView = new TimelineView(); + timelineView.prefWidthProperty().bind(pnl_inputAndSendButtons.widthProperty()); + timelineView.setMinHeight(80); //min height + timelineView.setPrefHeight(80); + timelineView.setStyle("-fx-background-color: #333333; -fx-border-color: red;"); //TODO:Debug! + pnl_inputAndSendButtons.getChildren().add(timelineView); + + timelineView.setSkedTooltipExtraTextProvider(this::buildSkedHoverInfo); + + /** + * if user changing width + */ + timelineView.widthProperty().addListener((obs, oldV, newV) -> { + if (newV.doubleValue() > 10) { + updateTimelineVisuals(); + } + }); + + /** + * if user changing height + */ + timelineView.heightProperty().addListener((obs, oldV, newV) -> { + if (newV.doubleValue() > 10) { + updateTimelineVisuals(); + } + }); + + ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSDAY_RESOURCE, STYLE_DEFAULTCSSDAY_FILE); ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSEVENING_RESOURCE, STYLE_DEFAULTCSSEVENING_FILE); ChatMember ownChatMemberObject = new ChatMember(); - chatcontroller = new ChatController(ownChatMemberObject); // instantiate the Chatcontroller with the user object + chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object + chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual -// this.chatcontroller.getPlayAudioUtils().playNoiseLauncher('!'); + // 1. Timeline an die Sked-Liste binden + chatcontroller.getActiveSkeds().addListener((ListChangeListener) c -> { + updateTimelineVisuals(); + }); -// chatcontroller.execute(); //TODO:THAT IS THE MAIN POINT WHERE THE CHAT WILL BE STARTED --- MOVED TO CONNECT BUTTON EVENTHANDLER + // 1. bind table to the sked list + chatcontroller.getScoreService().uiPulseProperty().addListener((obs, oldVal, newVal) -> { + updateTimelineVisuals(); + }); -// System.out.println(chatcontroller.getChatMemberTable().size()); + // Keep TimelineView antenna azimuth in sync with preferences (rotator / QTF) +// chatcontroller.getChatPreferences().getActualQTF().addListener((obs, oldV, newV) -> { +// timelineView.setCurrentAntennaAzimuth(newV.doubleValue()); +// timelineView.updateVisuals(chatcontroller.getActiveSkeds()); +// }); + + // initial value + timelineView.setCurrentAntennaAzimuth(chatcontroller.getChatPreferences().getActualQTF().get()); + timelineView.setBeamWidthDeg(chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg()); + + // Update visuals when rotor direction changes + chatcontroller.getChatPreferences().getActualQTF().addListener((obs, oldV, newV) -> { + timelineView.setCurrentAntennaAzimuth(newV.doubleValue()); + updateTimelineVisuals(); + }); try { -// txt_ownqrg.setStyle("-fx-text-inner-color: #BA55D3;"); txt_ownqrgMainCategory.getStyleClass().clear(); txt_ownqrgMainCategory.getStyleClass().add("text-input"); @@ -3682,6 +4583,10 @@ public class Kst4ContestApplication extends Application { txt_myQTF.getStyleClass().add("text-input"); txt_myQTF.getStyleClass().add("text-input-MYQRG1"); + txt_myQTF.textProperty().bind(Bindings.createStringBinding( + () -> Double.toString(chatcontroller.getChatPreferences().getActualQTF().get()), + chatcontroller.getChatPreferences().getActualQTF())); + txt_myQTF.focusedProperty().addListener(new ChangeListener() { @Override public void changed(ObservableValue arg0, Boolean oldPropertyValue, @@ -3705,7 +4610,7 @@ public class Kst4ContestApplication extends Application { txt_myQTF.setPrefSize(40, 0); // txt_ownqrg.setMinSize(40, 0); txt_myQTF.setAlignment(Pos.BASELINE_RIGHT); - txt_myQTF.setTooltip(new Tooltip("Enter/update your actual qtf here for using path suggestions")); + txt_myQTF.setTooltip(new Tooltip("This is your current QTF, read out at PSTRotator")); txt_myQTF.setFocusTraversable(false); SplitPane mainWindowLeftSplitPane = new SplitPane(); @@ -3810,11 +4715,19 @@ public class Kst4ContestApplication extends Application { // scene.getStylesheets().add(getClass().getResource("application.css").toExternalForm()); MenuBar mainScreenMenuBar = initMenuBar(); - bPaneChatWindow.setTop(mainScreenMenuBar); +// HPane hbxNorthForStatusBar = new HBox(); + flwpne_StatusBar = new FlowPane(); + + flwpne_StatusBar.getChildren().add(mainScreenMenuBar); + bPaneChatWindow.setTop(flwpne_StatusBar); + + initSkedWarnIndicatorButton(); + flwpne_StatusBar.getChildren().add(btnSkedWarnIndicator); + + initBandUpgradeIndicatorButton(); + flwpne_StatusBar.getChildren().add(btnBandUpgradeIndicator); -// bPaneChatWindow.setLeft(new Label("This will be at the left")); -// bPaneChatWindow.setRight(scrollabeUserListPanel); SplitPane messageSectionSplitpane = new SplitPane(); messageSectionSplitpane.setOrientation(Orientation.VERTICAL); @@ -3823,8 +4736,8 @@ public class Kst4ContestApplication extends Application { // FlowPane textInputFlowPane = new FlowPane(); - sendButton = new Button("send"); - sendButton.setMinSize(60, 0); + sendButton = new Button("TX"); + sendButton.setMinSize(20, 0); sendButton.setOnAction(new EventHandler() { @Override public void handle(ActionEvent event) { @@ -3855,6 +4768,11 @@ public class Kst4ContestApplication extends Application { sendMe.setChatCategory(sendMeInThisCat); //new in 1.26, answer in channel of the selected member sendMe.setMessageText(txt_chatMessageUserInput.getText()); + + // If operator sends "/cq CALL ..." => arm pending ping metrics for reply-time / no-reply tracking + chatcontroller.getStationMetricsService().tryRecordOutboundCq(sendMe.getMessageText(), System.currentTimeMillis()); + chatcontroller.getScoreService().requestRecompute("outbound-tx"); + sendMe.setMessageDirectedToServer(false); chatcontroller.getMessageTXBus().add(sendMe); //move the message to the tx queue @@ -3868,7 +4786,7 @@ public class Kst4ContestApplication extends Application { // sendButton.setMnemonicParsing(true); Button btn_clear = new Button("clear"); - btn_clear.setMinSize(60, 0); + btn_clear.setMinSize(20, 0); btn_clear.setOnAction(new EventHandler() { @Override public void handle(ActionEvent event) { @@ -4051,6 +4969,7 @@ public class Kst4ContestApplication extends Application { } }); + final Separator sepVert1 = new Separator(); sepVert1.setOrientation(Orientation.VERTICAL); sepVert1.setValignment(VPos.CENTER); @@ -4058,14 +4977,14 @@ public class Kst4ContestApplication extends Application { sepVert1.setPrefWidth(30); txt_ownqrgMainCategory.setText("MYQRG"); - txt_ownqrgMainCategory.setPrefSize(80, 0); - txt_ownqrgMainCategory.setAlignment(Pos.BASELINE_RIGHT); + txt_ownqrgMainCategory.setPrefSize(70, 0); + txt_ownqrgMainCategory.setAlignment(Pos.BASELINE_LEFT); txt_ownqrgMainCategory.setFocusTraversable(false); // txt_ownqrgSecondCategory.setText("SECONDQRG"); txt_ownqrgSecondCategory.setText(chatcontroller.getChatPreferences().getMYQRGSecondCat().getValue()); - txt_ownqrgSecondCategory.setPrefSize(140, 0); - txt_ownqrgSecondCategory.setAlignment(Pos.BASELINE_RIGHT); + txt_ownqrgSecondCategory.setPrefSize(70, 0); + txt_ownqrgSecondCategory.setAlignment(Pos.BASELINE_CENTER); txt_ownqrgSecondCategory.setFocusTraversable(false); txt_ownqrgSecondCategory.setTooltip(new Tooltip("Enter frequency for second chat-category here by hand! ")); @@ -4115,23 +5034,21 @@ public class Kst4ContestApplication extends Application { } }, new Date(), 5000); + + + textInputFlowPane.setSpacing(6); + textInputFlowPane.setAlignment(Pos.CENTER_LEFT); + textInputFlowPane.getChildren().addAll(txt_chatMessageUserInput, sendButton, btn_clear, sepVert1, txt_ownqrgMainCategory, txt_ownqrgSecondCategory, txt_myQTF); -// HBox hbx_textSnippets = new HBox(); flwPane_textSnippets = new FlowPane(); flwPane_textSnippets.getChildren() .addAll(buttonFactory(this.chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())); -// hbx_textSnippets.getChildren().add(flwPane_textSnippets); -// hbx_textSnippets.set - TableView privateMessageTable = initChatprivateMSGTable(); -// -// ContextMenu chatMessageContextMenu = initChatMemberTableContextMenu( old mechanic -// this.chatcontroller.getChatPreferences().getTextSnippets()); chatMessageContextMenu = initChatMemberTableContextMenu( this.chatcontroller.getChatPreferences().getLst_txtSnipList()); // new mechanic @@ -4186,15 +5103,23 @@ public class Kst4ContestApplication extends Application { } else { + txt_chatMessageUserInput.clear(); txt_chatMessageUserInput.setText("/cq " + selectedChatMemberPrivateChat.getList().get(0).getSender().getCallSign() + " "); txt_chatMessageUserInput.requestFocus(); txt_chatMessageUserInput.selectEnd(); + + focusChatMemberAndPrepareCq(selectedChatMemberPrivateChat.getList().get(0).getSender()); + + try { selectedCallSignFurtherInfoPane.getChildren().clear(); selectedCallSignInfoStageChatMember = selectedChatMemberPrivateChat.getList().get(0).getSender(); + chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); + + chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); //important after selection change selectedCallSignFurtherInfoPane.getChildren().add(generateFurtherInfoAbtSelectedCallsignBP(selectedCallSignInfoStageChatMember)); txt_chatMessageUserInput.requestFocus(); txt_chatMessageUserInput.selectEnd(); @@ -4256,12 +5181,24 @@ public class Kst4ContestApplication extends Application { + selectedChatMemberGeneralChat.getList().get(0).getSender().getCallSign() + " "); txt_chatMessageUserInput.requestFocus(); txt_chatMessageUserInput.selectEnd(); - System.out.println("privChat selected ChatMember: " + System.out.println("cq chat selected ChatMember: " + selectedChatMemberGeneralChat.getList().get(0).getSender()); + + try { + //scroll the chatmembers table to the entry - try because of sender could be null + focusChatMemberAndPrepareCq(selectedChatMemberGeneralChat.getList().get(0).getSender()); + + } catch (Exception exception) { + System.out.println("KST4CApp, <<>>>: message sender is not in the userlist any more!"); + } + try { selectedCallSignFurtherInfoPane.getChildren().clear(); selectedCallSignInfoStageChatMember = selectedChatMemberGeneralChat.getList().get(0).getSender(); + chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); + + chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); //important after selection change selectedCallSignFurtherInfoPane.getChildren().add(generateFurtherInfoAbtSelectedCallsignBP(selectedCallSignInfoStageChatMember)); txt_chatMessageUserInput.requestFocus(); txt_chatMessageUserInput.selectEnd(); @@ -4276,7 +5213,7 @@ public class Kst4ContestApplication extends Application { - messageSectionSplitpane.getItems().addAll(privateMessageTable, flwPane_textSnippets, textInputFlowPane, + messageSectionSplitpane.getItems().addAll(privateMessageTable, flwPane_textSnippets,pnl_inputAndSendButtons, textInputFlowPane, tbl_generalMessageTable); messageSectionSplitpane.setDividerPositions(chatcontroller.getChatPreferences().getGUImessageSectionSplitpane_dividerposition()); @@ -4302,9 +5239,20 @@ public class Kst4ContestApplication extends Application { bPaneChatWindow.setCenter(mainWindowLeftSplitPane); - TableView tbl_chatMember = new TableView(); + tbl_chatMember = initChatMemberTable(); + timelineView.setOnCandidateClicked(ev -> { + if (ev == null) return; + + ChatMember resolved = resolveChatMemberForCallRawAndCategory(ev.getCallSignRaw(), ev.getPreferredChatCategory()); + if (resolved == null) return; + + // Always prepare /cq + FurtherInfo (even if filtered out in table) + focusChatMemberAndPrepareCq(resolved); + }); + + TableViewSelectionModel selectionModelChatMember = tbl_chatMember.getSelectionModel(); selectionModelChatMember.setSelectionMode(SelectionMode.SINGLE); @@ -4325,6 +5273,7 @@ public class Kst4ContestApplication extends Application { selectedCallSignInfoStageChatMember = selectionModelChatMember.getSelectedItems().get(0); //TODO: temp test 1.26: get selected chatmember out of ist + chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); //important after selection cchange // selectedCallSignInfoStageChatMember = chatcontroller.getLst_chatMemberList() // .get(chatcontroller.checkListForChatMemberIndexByCallSign( @@ -4388,7 +5337,7 @@ public class Kst4ContestApplication extends Application { SplitPane mainWindowRightSplitPane = new SplitPane(); mainWindowRightSplitPane.setOrientation(Orientation.VERTICAL); - mainWindowRightSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition()); +// mainWindowRightSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition()); BorderPane chatMemberTableBorderPane = new BorderPane(); chatMemberTableBorderPane.setCenter(tbl_chatMember); @@ -4475,6 +5424,7 @@ public class Kst4ContestApplication extends Application { if (!newValue.matches("\\d*")) { chatMemberTableFilterQtfTF.setText(newValue.replaceAll("[^\\d]", "")); } + System.out.println("new default QTF: " + newValue); chatMemberTableFilterQtfEnableChkbx.setSelected(false); chatMemberTableFilterQtfEnableChkbx.setSelected(true); } @@ -4517,7 +5467,22 @@ public class Kst4ContestApplication extends Application { - Button qtfNorth = new Button("N"); + ToggleGroup tglGrpQTF = new ToggleGroup(); //Tooglegroup for the qtf filter options + + tglGrpQTF.selectedToggleProperty().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue observableValue, Toggle toggle, Toggle t1) { + if (t1 == null) { + chatMemberTableFilterQtfEnableChkbx.setSelected(false); + } else { + chatMemberTableFilterQtfEnableChkbx.setSelected(true); + } + } + }); + +// Button qtfNorth = new Button("N"); + ToggleButton qtfNorth = new ToggleButton("N"); + qtfNorth.setToggleGroup(tglGrpQTF); btnQtfButtonsAvl[0] = qtfNorth; qtfNorth.setOnAction(new EventHandler() { @@ -4529,7 +5494,8 @@ public class Kst4ContestApplication extends Application { }); - Button qtfNorthEast = new Button("NE"); + ToggleButton qtfNorthEast = new ToggleButton("NE"); + qtfNorthEast.setToggleGroup(tglGrpQTF); btnQtfButtonsAvl[1] = qtfNorthEast; qtfNorthEast.setOnAction(new EventHandler() { @Override @@ -4539,7 +5505,8 @@ public class Kst4ContestApplication extends Application { } }); - Button qtfEast = new Button("E"); + ToggleButton qtfEast = new ToggleButton("E"); + qtfEast.setToggleGroup(tglGrpQTF); btnQtfButtonsAvl[2] = qtfEast; qtfEast.setOnAction(new EventHandler() { @Override @@ -4549,7 +5516,8 @@ public class Kst4ContestApplication extends Application { } }); - Button qtfSouthEast = new Button("SE"); + ToggleButton qtfSouthEast = new ToggleButton("SE"); + qtfSouthEast.setToggleGroup(tglGrpQTF); btnQtfButtonsAvl[3] = qtfSouthEast; qtfSouthEast.setOnAction(new EventHandler() { @Override @@ -4558,7 +5526,9 @@ public class Kst4ContestApplication extends Application { // uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfSouthEast); } }); - Button qtfSouth = new Button("S"); + + ToggleButton qtfSouth = new ToggleButton("S"); + qtfSouth.setToggleGroup(tglGrpQTF); btnQtfButtonsAvl[4] = qtfSouth; qtfSouth.setOnAction(new EventHandler() { @Override @@ -4568,7 +5538,8 @@ public class Kst4ContestApplication extends Application { } }); - Button qtfSouthWest = new Button("SW"); + ToggleButton qtfSouthWest = new ToggleButton("SW"); + qtfSouthWest.setToggleGroup(tglGrpQTF); btnQtfButtonsAvl[5] = qtfSouthWest; qtfSouthWest.setOnAction(new EventHandler() { @Override @@ -4577,7 +5548,8 @@ public class Kst4ContestApplication extends Application { // uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfSouthWest); } }); - Button qtfWest = new Button("W"); + ToggleButton qtfWest = new ToggleButton("W"); + qtfWest.setToggleGroup(tglGrpQTF); btnQtfButtonsAvl[6] = qtfWest; qtfWest.setOnAction(new EventHandler() { @Override @@ -4587,8 +5559,10 @@ public class Kst4ContestApplication extends Application { } }); - Button qtfNorthWest = new Button("NW"); + ToggleButton qtfNorthWest = new ToggleButton("NW"); + qtfNorthWest.setToggleGroup(tglGrpQTF); btnQtfButtonsAvl[7] = qtfNorthWest; + qtfNorthWest.setToggleGroup(tglGrpQTF); qtfNorthWest.setOnAction(new EventHandler() { @Override public void handle(ActionEvent actionEvent) { @@ -4598,7 +5572,6 @@ public class Kst4ContestApplication extends Application { }); - // chatMemberTableFilterQTFHBox.setSpacing(5); chatMemberTableFilterQTFHBox.getChildren().addAll(chatMemberTableFilterQtfTF, new Label("deg +/- " + chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + ""), qtfNorth, qtfNorthEast, qtfEast, qtfSouthEast, qtfSouth, qtfSouthWest, qtfWest, qtfNorthWest); chatMemberTableFilterQTFAndQRBHbox.getChildren().add(chatMemberTableFilterQTFHBox); @@ -4618,8 +5591,6 @@ public class Kst4ContestApplication extends Application { chatcontroller.getLst_chatMemberListFiltered().predicateProperty().bind(Bindings.createObjectBinding(() -> chatcontroller.getLst_chatMemberListFilterPredicates().stream().reduce(x -> true, Predicate::and), chatcontroller.getLst_chatMemberListFilterPredicates())); - - TextField chatMemberTableFilterTextField = new TextField("Find..."); chatMemberTableFilterTextField.setFocusTraversable(false); chatMemberTableFilterTextField.focusedProperty().addListener(new ChangeListener() { @@ -4958,6 +5929,9 @@ public class Kst4ContestApplication extends Application { mainWindowRightSplitPane.getItems().add(chatMemberTableBorderPane); + BorderPane topPriorityListPane = initTopPriorityListPane(tbl_chatMember, txt_chatMessageUserInput); + mainWindowRightSplitPane.getItems().add(topPriorityListPane);//adds priority list panel + mainWindowLeftSplitPane.getItems().addAll(messageSectionSplitpane, mainWindowRightSplitPane); mainWindowLeftSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition()); @@ -4979,28 +5953,15 @@ public class Kst4ContestApplication extends Application { } -/** - * initializing the furter infos of a callsign part of the right splitpane - */ - - - - - - -// selectedCallSignFurtherInfoPane.getChildren().add(generateFurtherInfoAbtSelectedCallsignBP(selectedCallSignInfoStageChatMember)); - - -// selectedCallSignInfoPane.getChildren().add(selectedCallSignInfoBorderPane); - - - - /** - * end of initializing the furter infos of a callsign part of the right splitpane - */ mainWindowRightSplitPane.getItems().add(selectedCallSignFurtherInfoPane); + // Ensure the stored divider array matches the current UI layout (2 dividers = 3 items). + chatcontroller.getChatPreferences().ensureMainWindowRightSplitPaneDividerPositions(mainWindowRightSplitPane.getDividers().size()); + + // Apply persisted divider positions AFTER all items exist. + mainWindowRightSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition()); + //first initialize how much divider positions we need... // chatcontroller.getChatPreferences().setGUImainWindowRightSplitPane_dividerposition(new double[mainWindowRightSplitPane.getDividers().size()]); @@ -5013,7 +5974,19 @@ public class Kst4ContestApplication extends Application { @Override public void changed(ObservableValue observableValue, Number oldDividerPos, Number newDividerPosition) { System.out.println("<<<<<<<<<<<<<<<<<<<>>>>>> devider mainwindowRIGHTsplitpane " + mainWindowRightSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + mainWindowRightSplitPane.getDividers().size()); - chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition()[mainWindowRightSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue(); +// chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition()[mainWindowRightSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue(); + + int dividerIndex = mainWindowRightSplitPane.getDividers().indexOf(divider); + double[] storedPositions = chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition(); + + if (dividerIndex >= 0 && dividerIndex < storedPositions.length) { + storedPositions[dividerIndex] = newDividerPosition.doubleValue(); + } else { + // Avoid crashes if preferences are older than the current UI layout. + System.out.println("WARN: cannot store mainWindowRightSplitPane divider position: index=" + + dividerIndex + ", storedLen=" + storedPositions.length + ", dividerCount=" + + mainWindowRightSplitPane.getDividers().size()); + } } }); @@ -5050,10 +6023,6 @@ public class Kst4ContestApplication extends Application { pnl_directedMSGWin.getItems().addAll(initDXClusterTable(), initChatToOtherMSGTable()); - - //first initialize how much divider positions we need... -// chatcontroller.getChatPreferences().setGUIpnl_directedMSGWin_dividerpositionDefault(new double[pnl_directedMSGWin.getDividers().size()]); - /** * here will follow the Splitpane divider listener to save the user made UI changes, should been made at the very end of all splitpane operations */ @@ -5088,8 +6057,6 @@ public class Kst4ContestApplication extends Application { }); clusterAndQSOMonStage.setScene(clusterAndQSOMonScene); - - clusterAndQSOMonStage.show(); /** @@ -5101,16 +6068,11 @@ public class Kst4ContestApplication extends Application { * Window updates */ stage_updateStage = new Stage(); -// clusterAndQSOMonStage.initStyle(StageStyle.UTILITY); - stage_updateStage.setTitle("Update information"); -// SplitPane pnl_directedMSGWin = new SplitPane(); -// apnl_directedMSGWin.setOrientation(Orientation.VERTICAL); -// pnl_directedMSGWin.getItems().addAll(initDXClusterTable(), initChatToOtherMSGTable()); + stage_updateStage.setTitle("Update information"); try { - stage_updateStage.setAlwaysOnTop(true); Label lblUpdateInfo = new Label("Update aviable!"); @@ -5209,7 +6171,7 @@ public class Kst4ContestApplication extends Application { stage_updateStage.show(); } else { - stage_updateStage.show(); +// stage_updateStage.show(); only for debugging check //nothing to do } @@ -5512,14 +6474,24 @@ public class Kst4ContestApplication extends Application { txtFldstn_qtfDefault.setText(newString.replaceAll("[^\\d]", "")); } - System.out.println("[Main.java, Info]: Setted the QRB: " + txtFldstn_qtfDefault.getText()); - chatcontroller.getChatPreferences().setStn_antennaBeamWidthDeg(Double.parseDouble(txtFldstn_qtfDefault.getText())); + System.out.println("[Main.java, Info]: Setted the QTF: " + txtFldstn_qtfDefault.getText()); + chatcontroller.getChatPreferences().setStn_qtfDefault(Double.parseDouble(txtFldstn_qtfDefault.getText())); // chatMemberTableFilterQTFHBox.getChildren().addAll(chatMemberTableFilterQtfTF, new Label("deg, " + chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + " beamwidth"), qtfNorth, qtfNorthEast, qtfEast, qtfSouthEast, qtfSouth, qtfSouthWest, qtfWest, qtfNorthWest); // chatMemberTableFilterQTFHBox.getChildren().addAll(chatMemberTableFilterQtfTF, new Label("deg, " + chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + " beamwidth"), qtfNorth, qtfNorthEast, qtfEast, qtfSouthEast, qtfSouth, qtfSouthWest, qtfWest, qtfNorthWest); } }); + Label lbl_station_pstRotatorEnabled = new Label("Enable PSTRotator interface (auto QTF):"); + CheckBox chkBx_station_pstRotatorEnabled = new CheckBox(); + chkBx_station_pstRotatorEnabled.setSelected(chatcontroller.getChatPreferences().isStn_pstRotatorEnabled()); + chkBx_station_pstRotatorEnabled.setTooltip(new Tooltip( + "If disabled: no PSTRotator connection is started and antenna direction is ignored in priority scoring." + )); + chkBx_station_pstRotatorEnabled.selectedProperty().addListener((obs, oldV, newV) -> + chatcontroller.getChatPreferences().setStn_pstRotatorEnabled(newV) + ); + grdPnlStation.add(lblCallSign, 0, 0); grdPnlStation.add(txtFldCallSign, 1, 0); @@ -5537,10 +6509,37 @@ public class Kst4ContestApplication extends Application { grdPnlStation.add(txtFldstn_maxQRBDefault, 1, 6); grdPnlStation.add(new Label("Default filter QTF:"), 0, 7); grdPnlStation.add(txtFldstn_qtfDefault, 1, 7); + grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 8); + grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 8); VBox vbxStation = new VBox(); vbxStation.setPadding(new Insets(10, 10, 10, 10)); - vbxStation.getChildren().addAll( + + GridPane grdPanelServerHostName = new GridPane(); + + TextField stn_txtServerDNS = new TextField(this.chatcontroller.getChatPreferences().getStn_on4kstServersDns()); + stn_txtServerDNS.focusedProperty().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue observableValue, Boolean aBoolean, Boolean t1) { + + System.out.println("[Main.java, Info]: Set the Server DNS property by hand to: " + + stn_txtServerDNS.getText()); + chatcontroller.getChatPreferences().setStn_on4kstServersDns(stn_txtServerDNS.getText()); + } + }); + + + grdPanelServerHostName.add(new Label("ON4KST Server [www.on4kst.org]: "), 0,1); + grdPanelServerHostName.add(stn_txtServerDNS, 1,1); + + TextField stn_txtServerPort = new TextField(this.chatcontroller.getChatPreferences().getStn_on4kstServersPort()+""); + + grdPanelServerHostName.add(new Label(" Port [23001]: "), 2,1); + grdPanelServerHostName.add(stn_txtServerPort, 3,1); + + vbxStation.getChildren().addAll(grdPanelServerHostName); + + vbxStation.getChildren().addAll( generateLabeledSeparator(100, "Set your Login Credentials and Station Parameters here"), grdPnlStation); vbxStation.getChildren().addAll(generateLabeledSeparator(50, "! ! ! ! Don´t forget to reset the worked stations information before starting a new contest ! ! ! !")); @@ -5774,10 +6773,72 @@ public class Kst4ContestApplication extends Application { } }); - - - - + + + grdPnlLog.add(generateLabeledSeparator(100, "Win-Test Network-Listener"), 0, 6, 2, 1); + + Label lblEnableWintest = new Label("Receive Win-Test network based UDP log messages"); + CheckBox chkBxEnableWintestUDPReceiver = new CheckBox(); + chkBxEnableWintestUDPReceiver.setSelected( + this.chatcontroller.getChatPreferences().isLogsynch_wintestNetworkListenerEnabled() + ); + + Label lblUDPByWintest = new Label("UDP-Port for Win-Test listener (default is 9871)"); + TextField txtFldUDPPortforWintest = new TextField( + this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkPort() + "" + ); + + txtFldUDPPortforWintest.focusedProperty().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue arg0, Boolean oldPropertyValue, Boolean newPropertyValue) { + if (newPropertyValue) { + // focus gained -> nichts + } else { + if (GuiUtils.isNumeric(txtFldUDPPortforWintest.getText())) { + + chatcontroller.getChatPreferences() + .setLogsynch_wintestNetworkPort(Integer.parseInt(txtFldUDPPortforWintest.getText())); + + // Wenn enabled: Listener auf neuem Port neu starten + if (chatcontroller.getChatPreferences().isLogsynch_wintestNetworkListenerEnabled()) { + chatcontroller.restartWintestUdpListenerIfEnabled(); + } + + System.out.println("[Main.java, Info]: set Win-Test listener port to: " + + txtFldUDPPortforWintest.getText()); + + } else { + txtFldUDPPortforWintest.setText(txtFldUDPPortforWintest.getText() + " is an invalid Port"); + } + } + } + }); + + + chkBxEnableWintestUDPReceiver.selectedProperty().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { + + chatcontroller.getChatPreferences() + .setLogsynch_wintestNetworkListenerEnabled(chkBxEnableWintestUDPReceiver.isSelected()); + + txtFldUDPPortforWintest.setDisable(!chkBxEnableWintestUDPReceiver.isSelected()); + + if (chkBxEnableWintestUDPReceiver.isSelected()) { + chatcontroller.restartWintestUdpListenerIfEnabled(); + } else { + chatcontroller.stopWintestUdpListener(); + } + + System.out.println("[Main.java, Info]: Win-Test UDP listener enabled: " + + chatcontroller.getChatPreferences().isLogsynch_wintestNetworkListenerEnabled()); + } + }); + + + + txtFldUDPPortforWintest.setFocusTraversable(false); + txtFldUDPPortforWintest.setDisable(!chkBxEnableWintestUDPReceiver.isSelected()); // grdPnlLog.add(new Label("Settings for the file interpreter, which can interprete ASCII Callsigns out of all kinds of files by Patternmatching"), 0,0,1,1); grdPnlLog.add(generateLabeledSeparator(100, "File polling for worked callsigns"), 0, 0, 2, 1); @@ -5794,6 +6855,10 @@ public class Kst4ContestApplication extends Application { // grdPnlLog.add(lblUDPbyUCXLogBackupFilePathAndNameTitle, 0, 6); removed due to db usage now // grdPnlLog.add(lblUDPbyUCXLogBackupFilePathAndName, 1, 6); removed due to db usage now // grdPnlLog.add(new Button("Change..."), 2, 6); removed due to db usage now + grdPnlLog.add(lblEnableWintest, 0, 7); + grdPnlLog.add(chkBxEnableWintestUDPReceiver, 1, 7); + grdPnlLog.add(lblUDPByWintest, 0, 8); + grdPnlLog.add(txtFldUDPPortforWintest, 1, 8); VBox vbxLog = new VBox(); vbxLog.setPadding(new Insets(10, 10, 10, 10)); @@ -5811,6 +6876,17 @@ public class Kst4ContestApplication extends Application { Label lblEnableTRXMsgbyUCX = new Label("Receive UCXLog network based UDP trx messages at Port 12060"); CheckBox chkBxEnableTRXMsgbyUCX = new CheckBox(); + CheckBox chkBxEnableXVTRUsage = new CheckBox(); + + Label lblXVTRRFQrg = new Label("XVTR RF QRG in kHz, e.g. \"144000\" for 144 MHz), default = 144000"); + lblXVTRRFQrg.setTooltip(new Tooltip("Where will your xvtr send?")); + + Label lblTRXIFQrg = new Label("TRX IF QRG in kHz, e.g. \"28000\" for 28 MHz), default = 28000"); + lblXVTRRFQrg.setTooltip(new Tooltip("Where will your TRX IF be?")); + +// Label lblRedultingLoQRG = new Label("The value of " + asd + " will be added to the readed QRG of your TRX to show correct QRG"); + + chkBxEnableTRXMsgbyUCX .setSelected(this.chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled()); @@ -6180,14 +7256,24 @@ public class Kst4ContestApplication extends Application { @Override public void handle(ActionEvent event) { - ChatMember dummy = new ChatMember(); - dummy.setFrequency(new SimpleStringProperty("144300")); - dummy.setQra("Congrats, you donated $100"); - dummy.setCallSign("DO5AMF"); +// ChatMember dummyCopy = new ChatMember(); +// dummyCopy.setCallSign(selectedCallSignInfoStageChatMember.getCallSign()); +// dummyCopy.setFrequency(new SimpleStringProperty("144300")); +// dummyCopy.setQra(selectedCallSignInfoStageChatMember.getQra() + " AP: " + +// selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes().get(0).getPotential() + "%, " + +// selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes().get(0).getArrivingDurationMinutes() + "min" + +// ", " + +// selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes().get(1).getPotential() + "%, " + +// selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes().get(1).getArrivingDurationMinutes() + "min"); + + ChatMember dummyCopy = new ChatMember(); + dummyCopy.setFrequency(new SimpleStringProperty("144300")); + dummyCopy.setQra("Congrats, you donated $100"); + dummyCopy.setCallSign("DO5AMF"); try { - chatcontroller.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(dummy); + chatcontroller.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(dummyCopy); // dummy = new ChatMember(); // dummy.setFrequency(new SimpleStringProperty("144366")); @@ -6305,7 +7391,51 @@ public class Kst4ContestApplication extends Application { grdPnlNotify.add(lblNotifyDXClusterServerTriggerOnEveryQRGDetect, 0, 12); grdPnlNotify.add(chkBxNotifyDXClusterServerTriggerOnEveryQRGDetect, 1, 12); + grdPnlNotify.add(generateLabeledSeparator(100, "Band-upgrade hint (after log entry)"), 0, 13, 2, 1); + Label lblNotifyBandUpgradeHint = new Label("Blink + sound if logged station is still QRV on other unworked enabled band(s)"); + CheckBox chkBxNotifyBandUpgradeHint = new CheckBox(); + chkBxNotifyBandUpgradeHint.setSelected(chatcontroller.getChatPreferences().isNotify_bandUpgradeHintOnLogEnabled()); + chkBxNotifyBandUpgradeHint.selectedProperty().addListener((obs, o, n) -> + chatcontroller.getChatPreferences().setNotify_bandUpgradeHintOnLogEnabled(n) + ); + + Label lblNotifyBandUpgradeBoost = new Label("Priority boost for band-upgrade cases (better visibility in toplists)"); + CheckBox chkBxNotifyBandUpgradeBoost = new CheckBox(); + chkBxNotifyBandUpgradeBoost.setSelected(chatcontroller.getChatPreferences().isNotify_bandUpgradePriorityBoostEnabled()); + chkBxNotifyBandUpgradeBoost.selectedProperty().addListener((obs, o, n) -> + chatcontroller.getChatPreferences().setNotify_bandUpgradePriorityBoostEnabled(n) + ); + + grdPnlNotify.add(lblNotifyBandUpgradeHint, 0, 14); + grdPnlNotify.add(chkBxNotifyBandUpgradeHint, 1, 14); + + grdPnlNotify.add(lblNotifyBandUpgradeBoost, 0, 15); + grdPnlNotify.add(chkBxNotifyBandUpgradeBoost, 1, 15); + + +// grdPnlNotify.add(generateLabeledSeparator(100, "QSO Sniffing tool"), 0, 14, 2, 1); + grdPnlNotify.add(generateLabeledSeparator(100, "QSO Sniffing tool"), 0, 17, 2, 1); + + + TableView tblVw_notify_sniffCallSigns = new TableView(); + tblVw_notify_sniffCallSigns = initNotifyAtCallSignTable(); + tblVw_notify_sniffCallSigns.setItems(this.chatcontroller.getLstNotify_QSOSniffer_sniffedCallSignList()); + + Button btn_notifySniffCall_addLine = new Button("Add new CallSign"); + btn_notifySniffCall_addLine.setOnAction(new EventHandler() { + @Override + public void handle(ActionEvent event) { + String newTextSnippet = "Pse change (DOUBLECLICK)"; + chatcontroller.getLstNotify_QSOSniffer_sniffedCallSignList().add(0, newTextSnippet); + } + }); + +// grdPnlNotify.add(tblVw_notify_sniffCallSigns,0,15); +// grdPnlNotify.add(btn_notifySniffCall_addLine, 0, 16); + + grdPnlNotify.add(tblVw_notify_sniffCallSigns,0,18); + grdPnlNotify.add(btn_notifySniffCall_addLine, 0, 19); VBox vbxNotify = new VBox(); vbxNotify.setPadding(new Insets(10, 10, 10, 10)); @@ -6609,6 +7739,17 @@ public class Kst4ContestApplication extends Application { TextField txtFld_messageHandlingAutoAnswer = new TextField(); txtFld_messageHandlingAutoAnswer.setText(this.chatcontroller.getChatPreferences().getMessageHandling_autoAnswerTextMainCat()); + txtFld_messageHandlingAutoAnswer.textProperty().addListener(new ChangeListener() { + + @Override + public void changed(ObservableValue observed, String oldString, String newString) { + System.out.println("[Main.java, Info]: Setted the autoanswer: " + txtFld_messageHandlingAutoAnswer.getText().toUpperCase()); + chatcontroller.getChatPreferences().setMessageHandling_autoAnswerTextMainCat(txtFld_messageHandlingAutoAnswer.getText().toUpperCase()); + chatcontroller.getChatPreferences().setMessageHandling_autoAnswerTextSecondCat(txtFld_messageHandlingAutoAnswer.getText().toUpperCase()); + } + }); + + grdPnlMessageHandlingBeacon.add(txtFld_messageHandlingAutoAnswer,1,2); grdPnlMessageHandlingBeacon.add(chkbx_msgHandlingAutoAnswerEnabled,0,2); @@ -6642,30 +7783,38 @@ public class Kst4ContestApplication extends Application { vbxInternalDB.getChildren().addAll(grdPnlInternalDBPane); TableView tblVw_worked = new TableView(); + final TableView finalTblVwWorked = tblVw_worked; //effectively final variable + tblVw_worked = initWkdStnTable(); // tblVw_worked.setItems(); TODO + Button btn_wkdDB_refresh = new Button("Refresh worked database"); + btn_wkdDB_refresh.setOnAction(new EventHandler() { + @Override + public void handle(ActionEvent event) { + chatcontroller.refreshWorkedStateAndDatabaseListFromDatabase(); + finalTblVwWorked.refresh(); + } + }); + Button btn_wkdDB_reset = new Button("Reset worked-tags and NOT-QRV-tags"); btn_wkdDB_reset.setOnAction(new EventHandler() { @Override public void handle(ActionEvent event) { - // TODO: get the way to the appcontroller, there should be a reset method which - // drives the db and resets the 2 worked lists, also. - int affectedLines; affectedLines = chatcontroller.getDbHandler().resetWorkedDataInDB(); - chatcontroller.resetWorkedInfoInGuiLists(); - - + chatcontroller.resetWorkedAndQrvInfoInGuiLists(); + chatcontroller.refreshWorkedStateAndDatabaseListFromDatabase(); + finalTblVwWorked.refresh(); + if (affectedLines >= 0) { Alert a = new Alert(AlertType.INFORMATION); a.setTitle("Worked data"); - a.setHeaderText("All worked data had been resetted." + affectedLines - + " worked callsign entries resetted."); -// a.setContentText(chatcontroller.getChatPreferences().getProgramVersion()); + a.setHeaderText("All worked data had been resetted. " + affectedLines + + " callsign entries had been updated."); a.show(); } else { @@ -6673,18 +7822,15 @@ public class Kst4ContestApplication extends Application { a.setTitle("Worked data"); a.setHeaderText("Something went wrong, DB have to be rebuilt or other error!"); -// a.setContentText(chatcontroller.getChatPreferences().getProgramVersion()); a.show(); } - -// System.out.println("DB reset via DBHandler needs to be implemented"); } }); HBox hbxwkdShortBtnBox = new HBox(); - grdPnlInternalDBPane.add(hbxwkdShortBtnBox, 0, 2, 2, 1); - hbxwkdShortBtnBox.getChildren().addAll(btn_wkdDB_reset); + hbxwkdShortBtnBox.getChildren().addAll(btn_wkdDB_refresh, btn_wkdDB_reset); + grdPnlInternalDBPane.add(tblVw_worked, 0, 1, 2, 1); @@ -6814,6 +7960,20 @@ public class Kst4ContestApplication extends Application { Tab tbInternalDB = new Tab("Workedstn database", vbxInternalDB); Tab tbGui = new Tab("GUI", vbxGuiOptions); + + /** + * Automatic update of tab contents out of the database + */ + tbInternalDB.setOnSelectionChanged(new EventHandler() { + @Override + public void handle(Event event) { + if (tbInternalDB.isSelected()) { + chatcontroller.refreshWorkedStateAndDatabaseListFromDatabase(); + } + } + }); + + tabPaneOptions.getTabs().addAll(tbStationSettings, tbLogSynchSet, tbTRXSynchSet, tbAirScoutSettings, tbNotify, tbShorts, tbBeacon, tbMsgHandling, tbInternalDB, tbGui); @@ -6999,41 +8159,100 @@ public class Kst4ContestApplication extends Application { 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); + + } /** - * - * resets the style of the not selected direction buttons - * - * REPLACED BY CSS USAGE - * @deprecated - * @param exceptThisButton + * This is a helping class for providing information for the TimeLineView to give full information about the + * Chatmember objects on which the Sked object is referring to. + * @param sked * @return */ - public boolean uiHelper_recolorQtfDirectionButtonsExceptThisOne(Button exceptThisButton) { + private String buildSkedHoverInfo(ContestSked sked) { + if (sked == null || sked.getTargetCallsign() == null) return ""; -// Button[] qtfButtons = new Button[8]; - - for (int i = 0; i < btnQtfButtonsAvl.length; i++) { - -// if (!btnQtfButtonsAvl[i].equals(exceptThisButton)) { -// btnQtfButtonsAvl[i].setStyle(""); -// } else { -// btnQtfButtonsAvl[i].setStyle("-fx-background-color:\n" + -// " linear-gradient(#f0ff35, #a9ff00),\n" + -// " radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);\n" + -// " -fx-background-radius: 6, 5;\n" + -// " -fx-background-insets: 0, 1;\n" + -// " -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );\n" + -// " -fx-text-fill: #395306;"); //Todo fancy button style -// } + String callRaw = sked.getTargetCallsign().trim().toUpperCase(); + ChatMember member = null; + for (ChatMember cm : chatcontroller.getLst_chatMemberList()) { + if (cm == null || cm.getCallSignRaw() == null) continue; + if (callRaw.equals(cm.getCallSignRaw().trim().toUpperCase())) { + member = cm; + break; + } } + if (member == null) return ""; - return true; + AirPlaneReflectionInfo ap = member.getAirPlaneReflectInfo(); + if (ap == null) return ""; + StringBuilder sb = new StringBuilder(); + sb.append("AP reachable: ").append(ap.getAirPlanesReachableCntr()); + + if (ap.getRisingAirplanes() != null && !ap.getRisingAirplanes().isEmpty()) { + AirPlane a0 = ap.getRisingAirplanes().get(0); + sb.append("\nNext: ") + .append(a0.getArrivingDurationMinutes()) + .append(" min (") + .append(a0.getPotential()) + .append("%)"); + + if (ap.getRisingAirplanes().size() > 1) { + AirPlane a1 = ap.getRisingAirplanes().get(1); + sb.append(" / ") + .append(a1.getArrivingDurationMinutes()) + .append(" min (") + .append(a1.getPotential()) + .append("%)"); + } + } + return sb.toString(); } +// /** +// * +// * resets the style of the not selected direction buttons +// * +// * REPLACED BY CSS USAGE +// * @deprecated +// * @param exceptThisButton +// * @return +// */ +// public boolean uiHelper_recolorQtfDirectionButtonsExceptThisOne(Button exceptThisButton) { +// +//// Button[] qtfButtons = new Button[8]; +// +// for (int i = 0; i < btnQtfButtonsAvl.length; i++) { +// +//// if (!btnQtfButtonsAvl[i].equals(exceptThisButton)) { +//// btnQtfButtonsAvl[i].setStyle(""); +//// } else { +//// btnQtfButtonsAvl[i].setStyle("-fx-background-color:\n" + +//// " linear-gradient(#f0ff35, #a9ff00),\n" + +//// " radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);\n" + +//// " -fx-background-radius: 6, 5;\n" + +//// " -fx-background-insets: 0, 1;\n" + +//// " -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );\n" + +//// " -fx-text-fill: #395306;"); //Todo fancy button style +//// } +// +// } +// +// return true; +// +// } + /** * * @param width, left and right of the label @@ -7096,19 +8315,54 @@ public class Kst4ContestApplication extends Application { // if(storageModel.dataSetChanged()) { // if the dataset has changed, alert the user with a popup Alert alert = new Alert(Alert.AlertType.WARNING); - alert.getButtonTypes().remove(ButtonType.OK); +// alert.getButtonTypes().remove(ButtonType.OK); // alert.getButtonTypes().add(ButtonType.CANCEL); // alert.getButtonTypes().add(ButtonType.YES); alert.setTitle("WARNING"); alert.setContentText(String.format(warning)); + alert.show(); + } +// public void updateStatusButtons() { +// //TODO: Hier muss noch was hin +//// get +// } + public static void main(String[] args) { launch(args); } + @Override + public void onThreadStatusChanged(String key, ThreadStateMessage threadStateMessage) { + + Platform.runLater(() -> { + updateStatusButton(key, threadStateMessage); + }); + + maybeShowSkedWarnIndicator(key, threadStateMessage); + maybeShowBandUpgradeIndicator(key, threadStateMessage); + + + //if we receive a threadstatemessage for sked warning, enable the sked warning + + + } + + @Override + public void onUserListUpdated(String reason) { + Platform.runLater(() -> { + +// tbl_chatMember.sort(); + tbl_chatMember.refresh(); + + System.out.println("KST4Capp, UI Update Trigger: " + reason); + }); + } + + // public class MaidenheadLocatorMapPane extends Pane { // // private static final double MAP_WIDTH = 800; @@ -7203,6 +8457,74 @@ public class Kst4ContestApplication extends Application { // } // } + + /** + * helper method, in cases of QRG ending qith 0 as double values, it will fill the ending 0 to the qrg string + * @param raw + * @return + */ + private static String formatQrgForUi(String raw) { + + if (raw == null) return ""; + String s = raw.trim(); + if (s.isEmpty()) return ""; + + // Einheitlich Punkt als Dezimaltrenner + s = s.replace(',', '.'); + + // Wenn es nicht numerisch ist: einfach anzeigen wie es ist + try { + Double.parseDouble(s); + } catch (NumberFormatException e) { + return s; + } + + int dot = s.indexOf('.'); + if (dot < 0) { + // keine Nachkommastellen -> .000 ergänzen + return s + ".000"; + } + + String dec = s.substring(dot + 1); + if (dec.length() >= 3) { + // schon >= 3 Nachkommastellen -> unverändert lassen + return s; + } + + StringBuilder sb = new StringBuilder(s); + while (dec.length() < 3) { + sb.append('0'); + dec += "0"; + } + return sb.toString(); + } + + private static void applyQrgUiFormatting(TableColumn col) { + col.setCellFactory(tc -> new TableCell() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + setText(empty ? "" : formatQrgForUi(item)); + } + }); + } + + /** + * Interface for the chatcontroller to update the timeline + * + * @return + */ + public TimelineView getTimelineView() { + return timelineView; + } + + // NEW: Helper to force-refresh the table (triggering row color update) + public void refreshChatMemberTable() { + if (tbl_chatMember != null) { + tbl_chatMember.refresh(); + } + } + } /** @@ -7262,6 +8584,7 @@ class CheckBoxTableCell extends TableCell { public static Callback, TableCell> forTableColumn(String label, Consumer function) { return param -> new CheckBoxTableCell<>(label, function); } + @Override public void updateItem(T item, boolean empty) { super.updateItem(item, empty); @@ -7271,4 +8594,9 @@ class CheckBoxTableCell extends TableCell { setGraphic(actionCheckBox); } } + + + + + } \ No newline at end of file diff --git a/src/main/java/kst4contest/view/TimelineView.java b/src/main/java/kst4contest/view/TimelineView.java new file mode 100644 index 0000000..838ae35 --- /dev/null +++ b/src/main/java/kst4contest/view/TimelineView.java @@ -0,0 +1,369 @@ +package kst4contest.view; + +import javafx.scene.layout.Pane; +import javafx.scene.shape.Circle; +import javafx.scene.shape.Line; +import javafx.scene.shape.Polygon; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.paint.Color; +import javafx.scene.effect.DropShadow; +import javafx.scene.Node; +import javafx.scene.Group; +import javafx.scene.text.Font; +import kst4contest.model.ContestSked; +import kst4contest.model.ChatCategory; + +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * A custom UI Component that visualizes future events (Skeds/AP). + * It changes opacity based on the current antenna direction. + * + * Extended: + * - Can also render "priority candidates" (ScoreService top list) with time base = next AirScout airplane minute. + * - Clicking a candidate triggers a callback (selection + /cq preparation happens in Kst4ContestApplication). + */ +public class TimelineView extends Pane { + + private double currentAntennaAzimuth = 0; + private double beamWidth = 50.0; // TODO: from Prefs (later) + private final long PREVIEW_TIME_MS = 30 * 60 * 1000; // 30 Minutes Preview + double margin = 30; // enough space for the callsign label + + private Function skedTooltipExtraTextProvider; //used for further info in sked tooltip + + + private Consumer onCandidateClicked; + + public TimelineView() { + this.setPrefHeight(40); + this.setStyle("-fx-background-color: #2b2b2b;"); + // weitere init defaults, falls du welche hattest + } + + /** + * Backward compatibility: if some code still calls the old ctor. + * Potential/tooltip are not view-wide properties; they belong to markers/events. + */ + @Deprecated + public TimelineView(int opportunityPotentialPercent, String tooltipText) { + this(); // ignore args on purpose + } + +// +// public int getOpportunityPotentialPercent() { +// return opportunityPotentialPercent; +// } + + public void setCurrentAntennaAzimuth(double az) { + this.currentAntennaAzimuth = az; + } + + public long getPreviewTimeMs() { + return PREVIEW_TIME_MS; + } + + public void setOnCandidateClicked(Consumer handler) { + this.onCandidateClicked = handler; + } + + /** Backward compatible call (Skeds only) */ + public void updateVisuals(List skeds) { + updateVisuals(skeds, Collections.emptyList()); + } + + /** + * Redraws the timeline based on the list of active skeds AND priority candidates. + */ + public void updateVisuals(List skeds, List candidates) { + this.getChildren().clear(); + + double width = this.getWidth(); + if (width <= 5) { + // Layout not ready yet; will be updated by caller later (uiPulse/list change) + return; + } + + // Draw Axis + double axisY = 30; + Line axis = new Line(0, axisY, width, axisY); + axis.setStroke(Color.GRAY); + this.getChildren().add(axis); + + long now = System.currentTimeMillis(); + + // 1) Draw Priority Candidates (upper lanes) + for (CandidateEvent ev : candidates) { + long timeDiff = ev.getTimeUntilMs(); + if (timeDiff < 0 || timeDiff > PREVIEW_TIME_MS) continue; + + double percent = (double) timeDiff / PREVIEW_TIME_MS; + double xPos = percent * width; + xPos = Math.max(margin, Math.min(this.getWidth() - margin, xPos)); //starting point of the diagram + + + Node marker = createCandidateMarker(ev); + + applyAntennaEffect(marker, ev.getTargetAzimuth()); + + // Upper lanes so they don't overlap skeds + double laneBaseY = 2; + double laneOffsetY = 14.0 * ev.getLaneIndex(); + + marker.setLayoutX(xPos); + marker.setLayoutY(laneBaseY + laneOffsetY); + + this.getChildren().add(marker); + } + + // 2) Draw Skeds (lower lane) + for (ContestSked sked : skeds) { + long timeDiff = sked.getSkedTimeEpoch() - now; + + // Only draw if within the next 30 mins + if (timeDiff >= 0 && timeDiff <= PREVIEW_TIME_MS) { + + double percent = (double) timeDiff / PREVIEW_TIME_MS; + double xPos = percent * width; + xPos = clamp(xPos, 10, width - 10); + + Node marker = createSkedMarker(sked); + + applyAntennaEffect(marker, sked.getTargetAzimuth()); + + marker.setLayoutX(xPos); + marker.setLayoutY(axisY - 18); // below candidate lanes, near axis + this.getChildren().add(marker); + } + } + } + + private double clamp(double v, double min, double max) { + return Math.max(min, Math.min(max, v)); + } + + /** + * Logic: + * If Antenna is ON TARGET -> Bright & Glowing. + * If Antenna is OFF TARGET -> Transparent (Ghost). + */ + private void applyAntennaEffect(Node marker, double targetAz) { + + // invalid azimuth -> keep readable + if (!Double.isFinite(targetAz) || targetAz < 0) { + return; + } + + double delta = Math.abs(currentAntennaAzimuth - targetAz); + if (delta > 180) delta = 360 - delta; + + final boolean onTarget = delta <= (beamWidth / 2.0); + final boolean inBeam = delta <= beamWidth; + + // Rule: only fade when we are clearly NOT pointing there + final double iconOpacity = inBeam ? 1.0 : 0.30; + + if (marker instanceof Group g) { + // Never fade the whole group -> text stays readable + g.setOpacity(1.0); + + for (Node child : g.getChildren()) { + if (child instanceof Label) { + child.setOpacity(1.0); + } else { + child.setOpacity(iconOpacity); + } + } + + // Add glow only if well centered (optional) + if (onTarget) { + g.setEffect(new DropShadow(10, Color.LIMEGREEN)); + g.setScaleX(1.10); + g.setScaleY(1.10); + } else { + g.setEffect(null); + g.setScaleX(1.0); + g.setScaleY(1.0); + } + return; + } + + // fallback + marker.setOpacity(iconOpacity); + marker.setEffect(onTarget ? new DropShadow(10, Color.LIMEGREEN) : null); + } + + public void setSkedTooltipExtraTextProvider(Function provider) { + this.skedTooltipExtraTextProvider = provider; + } + + + /** Existing marker for Skeds (diamond + label) */ + private Node createSkedMarker(ContestSked sked) { + Polygon diamond = new Polygon(0.0, 0.0, 6.0, 6.0, 0.0, 12.0, -6.0, 6.0); + + diamond.setFill(colorForPotential(sked.getOpportunityPotentialPercent())); + + String baseToolTipFallBack = sked.getTargetCallsign() + " (" + sked.getBand() + ")\nAz: " + sked.getTargetAzimuth(); + + if (skedTooltipExtraTextProvider != null) { + String extra = skedTooltipExtraTextProvider.apply(sked); + if (extra != null && !extra.isBlank()) { + baseToolTipFallBack += "\n" + extra; + } + } + + Tooltip t = new Tooltip(baseToolTipFallBack); + Tooltip.install(diamond, t); + + Label lbl = new Label("SKED: " + sked.getTargetCallsign()); +// lbl.setFont(new Font(9)); +// lbl.setTextFill(Color.WHITE); + lbl.setLayoutY(14); + lbl.setLayoutX(-10); + + lbl.setStyle( + "-fx-text-fill: white;" + + "-fx-font-weight: bold;" + + "-fx-background-color: rgba(0,0,0,0.65);" + + "-fx-background-radius: 6;" + + "-fx-padding: 1 4 1 4;" + ); + lbl.setEffect(new DropShadow(2, Color.BLACK)); + + + return new Group(diamond, lbl); + } + + /** + * Give me a color for a given potencial of a AP reflection + * + * @param p AS potencial + * @return + */ + private Color colorForPotential(int p) { + if (p >= 95) return Color.MAGENTA; // ~100% + if (p >= 75) return Color.RED; + if (p >= 50) return Color.YELLOW; + return Color.DEEPSKYBLUE; // low potential + } + + /** New marker for Priority Candidates (triangle + label) */ + private Node createCandidateMarker(CandidateEvent ev) { + + // Color derived from airplane potential (not urgency) + Color markerColor = colorForPotential(ev.getOpportunityPotentialPercent()); + + // small triangle marker (points downwards) + Polygon tri = new Polygon(-6.0, 0.0, 6.0, 0.0, 0.0, 10.0); + tri.setFill(markerColor); + + // Optional: small dot behind triangle (makes it easier to see) + Circle dot = new Circle(4, markerColor); + dot.setLayoutY(5); // center behind triangle + + Label lbl = new Label(ev.getDisplayCallSign()); + lbl.setFont(new Font(9)); + lbl.setTextFill(Color.WHITE); + lbl.setLayoutY(10); + lbl.setLayoutX(-12); + + lbl.setStyle( + "-fx-text-fill: white;" + + "-fx-font-weight: bold;" + + "-fx-background-color: rgba(0,0,0,0.65);" + + "-fx-background-radius: 6;" + + "-fx-padding: 1 4 1 4;" + ); + lbl.setEffect(new DropShadow(8, Color.BLACK)); + + // IMPORTANT: include dot + triangle + label in the Group + Group g = new Group(dot, tri, lbl); + + if (ev.getTooltipText() != null && !ev.getTooltipText().isBlank()) { + Tooltip.install(g, new Tooltip(ev.getTooltipText())); + } + + g.setOnMouseClicked(e -> { + if (onCandidateClicked != null) { + onCandidateClicked.accept(ev); + } + }); + + return g; + } + + + + /** + * Data object rendered by the Timeline ("priority candidate"). + * Created in Kst4ContestApplication from ScoreService TopCandidates + AirScout next-AP minute. + */ + public static class CandidateEvent { + private final String callSignRaw; + private final String displayCallSign; + private final ChatCategory preferredChatCategory; + + private final long timeUntilMs; + private final int minuteBucket; + private final int laneIndex; + + private final double targetAzimuth; + private final double score; + + private final String tooltipText; + + private final int opportunityPotentialPercent; + + + public CandidateEvent( + String callSignRaw, + String displayCallSign, + ChatCategory preferredChatCategory, + long timeUntilMs, + int minuteBucket, + int laneIndex, + double targetAzimuth, + double score, + int opportunityPotentialPercent, + String tooltipText + ) { + this.callSignRaw = callSignRaw; + this.displayCallSign = displayCallSign; + this.preferredChatCategory = preferredChatCategory; + this.timeUntilMs = timeUntilMs; + this.minuteBucket = minuteBucket; + this.laneIndex = laneIndex; + this.targetAzimuth = targetAzimuth; + this.score = score; + this.opportunityPotentialPercent = opportunityPotentialPercent; + this.tooltipText = tooltipText; + } + + public String getCallSignRaw() { return callSignRaw; } + public String getDisplayCallSign() { return displayCallSign; } + public ChatCategory getPreferredChatCategory() { return preferredChatCategory; } + + public long getTimeUntilMs() { return timeUntilMs; } + public int getMinuteBucket() { return minuteBucket; } + public int getLaneIndex() { return laneIndex; } + + public double getTargetAzimuth() { return targetAzimuth; } + public double getScore() { return score; } + + public String getTooltipText() { return tooltipText; } + public int getOpportunityPotentialPercent() { return opportunityPotentialPercent; } + + } + + public void setBeamWidthDeg(double beamWidthDeg) { + if (beamWidthDeg > 0 && Double.isFinite(beamWidthDeg)) { + this.beamWidth = beamWidthDeg; + } + } + +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index a52fe40..ef780bc 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -3,6 +3,7 @@ module praktiKST { requires jdk.xml.dom; requires java.sql; requires javafx.media; + exports kst4contest.controller.interfaces; exports kst4contest.controller; exports kst4contest.locatorUtils; exports kst4contest.model; diff --git a/src/main/resources/KST4ContestDefaultDay.css b/src/main/resources/KST4ContestDefaultDay.css index 737ceea..e4cb534 100644 --- a/src/main/resources/KST4ContestDefaultDay.css +++ b/src/main/resources/KST4ContestDefaultDay.css @@ -6,6 +6,52 @@ -fx-border-color: #ff7777; } +.btn-showstate-enabled-default { + /*-fx-background-color:linear-gradient(#f0ff35, #b8ee36),*/ + /*radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);*/ + -fx-background-radius: 6, 5; + -fx-background-insets: 0, 1; + -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 ); + -fx-text-fill: black; +} +.btn-showstate-enabled-default:hover { + /*-fx-background-color:linear-gradient(#f0ff35, #b8ee36),*/ + /*radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);*/ + -fx-background-radius: 6, 5; + -fx-background-insets: 0, 1; + -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 ); + -fx-text-fill: black; +} + +.btn-showstate-enabled-furtherInfo { + /*-fx-background-color:linear-gradient(#f0ff35, #b8ee36),*/ + /* radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);*/ + -fx-background-radius: 6, 5; + -fx-background-insets: 0, 1; + -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 ); + -fx-text-fill: green; +} + +.btn-showstate-enabled-furtherInfo:hover { + /*-fx-background-color:linear-gradient(#f0ff35, #b8ee36),*/ + /* radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);*/ + -fx-background-radius: 6, 5; + -fx-background-insets: 0, 1; + -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 ); + -fx-text-fill: green; +} + +.btn-showstate-disabled { + -fx-background-color:linear-gradient(#f0ff35, #111111), + radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%); + -fx-background-radius: 6, 5; + -fx-background-insets: 0, 1; + -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 ); + -fx-text-fill: red; +} + + + .toggle-button:selected { -fx-background-color:linear-gradient(#f0ff35, #a9ff00), radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%); @@ -29,6 +75,7 @@ .text-input-MYQRG1 { -fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, orange 0%, red 100%); -fx-font-weight: 300; + -fx-padding: 1,1,1,1; } .button{ diff --git a/src/main/resources/KST4ContestDefaultEvening.css b/src/main/resources/KST4ContestDefaultEvening.css index 3ba9c92..db34147 100644 --- a/src/main/resources/KST4ContestDefaultEvening.css +++ b/src/main/resources/KST4ContestDefaultEvening.css @@ -30,6 +30,8 @@ .text-input-MYQRG1 { -fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, #f98aff 0%, #f98aff 100%); /*purple*/ + -fx-font-weight: 300; + -fx-padding: 1,1,1,1; } @@ -44,6 +46,7 @@ .button:hover{ -fx-text-fill: white; + -fx-border-color: #ff7777; } .separator *.line { @@ -82,6 +85,54 @@ -fx-text-fill: #395306; } +.btn-showstate-enabled-default { + -fx-base: #373e43; + -fx-text-fill: lightgray; + -fx-background-radius: 6, 5; + -fx-background-insets: 0, 1; + -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 ); +} + +.btn-showstate-enabled-default:hover { + -fx-base: #373e43; + -fx-text-fill: black; + -fx-background-radius: 6, 5; + -fx-background-insets: 0, 1; + -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 ); +} + +.btn-showstate-enabled-furtherInfo { + -fx-base: #373e43; + -fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, green 0%, lightgreen 100%); + -fx-background-radius: 6, 5; + + -fx-background-insets: 0, 1; + + -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 ); +} + +.btn-showstate-enabled-furtherInfo:hover { + -fx-base: #373e43; + -fx-text-fill: linear-gradient(from 0% 0% to 100% 200%, green 0%, lightgreen 100%); + -fx-background-radius: 6, 5; + + -fx-background-insets: 0, 1; + + -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 ); +} + +.btn-showstate-disabled { + -fx-base: #373e43 ; + -fx-font-weight: bold; + -fx-text-fill: red; + -fx-background-radius: 6, 5; + + -fx-background-insets: 0, 1; + + -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 ); +} + + .toggle-button:selected { -fx-background-color:linear-gradient(#f0ff35, #a9ff00), diff --git a/src/test/java/kst4contest/test/TestReadUDPASListenerThread.java b/src/test/java/kst4contest/test/TestReadUDPASListenerThread.java index 494d0df..d483396 100644 --- a/src/test/java/kst4contest/test/TestReadUDPASListenerThread.java +++ b/src/test/java/kst4contest/test/TestReadUDPASListenerThread.java @@ -8,8 +8,8 @@ public class TestReadUDPASListenerThread { @Test public static void main(String[] args) { - ReadUDPbyAirScoutMessageThread asUDPReader = new ReadUDPbyAirScoutMessageThread(9872, null, "AS", "KST"); - asUDPReader.start(); +// ReadUDPbyAirScoutMessageThread asUDPReader = new ReadUDPbyAirScoutMessageThread(9872, null, "AS", "KST"); +// asUDPReader.start(); String testThis; diff --git a/src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java b/src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java index e9a1cf6..11d270c 100644 --- a/src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java +++ b/src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java @@ -8,8 +8,8 @@ public class TestReadUDPUCXListenerThread { @Test public static void main(String[] args) { - ReadUDPbyUCXMessageThread ucxUDPReader = new ReadUDPbyUCXMessageThread(12060); - ucxUDPReader.start(); +// ReadUDPbyUCXMessageThread ucxUDPReader = new ReadUDPbyUCXMessageThread(12060); +// ucxUDPReader.start(); String testThis;