From 00496b56e969f74fba18f7eb534329fc3c717783 Mon Sep 17 00:00:00 2001 From: Marc Froehlich Date: Sat, 25 Jul 2026 03:01:37 +0200 Subject: [PATCH] Fixed AS preferences implementation, DXcluster Server and the documentation --- .../client_settings_window_notification.png | Bin 0 -> 83502 bytes github_docs/de-DX-Cluster-Server.md | 203 ++- github_docs/de-Funktionen.md | 14 +- github_docs/de-Konfiguration.md | 87 +- ...outPeriodicalAPReflectionInquirerTask.java | 280 +++-- .../controller/ChatController.java | 191 ++- .../DXClusterThreadPooledServer.java | 467 ++++--- .../ReadUDPbyAirScoutMessageThread.java | 368 +++--- .../kst4contest/model/ChatPreferences.java | 228 +++- .../view/Kst4ContestApplication.java | 1107 +++++++++++------ 10 files changed, 2010 insertions(+), 935 deletions(-) create mode 100644 github_docs/client_settings_window_notification.png diff --git a/github_docs/client_settings_window_notification.png b/github_docs/client_settings_window_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..73ccb5930b88c1d557a0cd8bd6bc7c21ec57ceb2 GIT binary patch literal 83502 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%0AG~;=q8i7@kp@r40Drb~XSDtsBZya9 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 XSq^FS$v%C5PS8}>dxU!UEcCws7ZxLe literal 0 HcmV?d00001 diff --git a/github_docs/de-DX-Cluster-Server.md b/github_docs/de-DX-Cluster-Server.md index 2d3a95a..f46dac9 100644 --- a/github_docs/de-DX-Cluster-Server.md +++ b/github_docs/de-DX-Cluster-Server.md @@ -2,75 +2,202 @@ > 🇬🇧 [English version](en-DX-Cluster-Server) | 🇩🇪 Du liest gerade die deutsche Version -Ab **Version 1.23** enthält KST4Contest einen integrierten DX-Cluster-Server. Dieser sendet Spots direkt an das Logprogramm, wenn eine Richtungs-Warnung ausgelöst wird. +Seit Version 1.23 enthält KST4Contest einen lokalen DX-Cluster-Server. Er übergibt erkannte Richtungsgelegenheiten und die dazugehörige Frequenz an den DX-Cluster-Client eines Logprogramms. -*(Idee von OM0AAO, Viliam Petrik – danke!)* +Die Idee dazu stammt von OM0AAO, Viliam Petrik. Vielen Dank! --- -## Wozu dient der integrierte DX-Cluster-Server? +## Warum überhaupt ein eigener DX-Cluster-Server? -Wenn KST4Contest erkennt, dass eine Station aus der eigenen Richtung ein Sked anfragt und gleichzeitig eine QRG bekannt ist, wird **automatisch ein DX-Cluster-Spot generiert** und an den Cluster-Client des Logprogramms gesendet. +Eine interessante Frequenz im Chat zu erkennen ist nur der erste Schritt. Im Contest muss diese Information dort ankommen, wo sie unmittelbar verwendet werden kann: im Logprogramm und dessen Bandmap. -Das Logprogramm zeigt den Spot in der Bandkarte an. Ein Klick auf den Spot stellt Frequenz und Mode des Transceivers direkt ein – ohne manuelles Eintippen. +KST4Contest verbindet deshalb zwei bereits vorhandene Informationen: + +1. Aus einer gerichteten Chat-Nachricht lässt sich abschätzen, in welche Richtung die sendende Station ihre Antenne wahrscheinlich ausgerichtet hat. +2. Aus derselben oder einer vorherigen Nachricht kann eine Frequenz der Station bekannt sein. + +Treffen beide Informationen zusammen, erzeugt KST4Contest einen lokalen DX-Cluster-Spot. Der Logger kann diesen Spot in seiner Bandmap anzeigen und – abhängig von seiner eigenen Konfiguration – den Transceiver nach einem Klick auf die entsprechende Frequenz einstellen. + +Im Klartext: Die Information muss nicht erst im Chat gefunden, gelesen, gemerkt und anschließend erneut in den Logger eingetragen werden. Genau diese kleinen Unterbrechungen kosten im Contest überraschend viel Aufmerksamkeit. --- -## Einrichtung +## Wie wird eine Richtungsgelegenheit hergeleitet? -### In KST4Contest +Angenommen, Station A schreibt eine gerichtete Nachricht an Station B. KST4Contest geht in diesem Fall davon aus, dass Station A ihre Antenne zumindest ungefähr in Richtung von Station B ausgerichtet hat. -In den Preferences → **DX-Cluster-Server-Einstellungen**: +Anschließend werden zwei Richtungen verglichen: -1. **Port** des internen Servers eintragen (z. B. 7300 oder 8000 – muss mit dem Logprogramm übereinstimmen). -2. **Spotter-Rufzeichen** eintragen – **unbedingt ein anderes Rufzeichen als das Contest-Rufzeichen verwenden!** - - Grund: Logprogramme filtern Spots, die vom eigenen Rufzeichen stammen, als „gearbeitet" heraus. Wenn der Spotter dasselbe Rufzeichen hat, werden die Spots nicht angezeigt. -3. **Angenommene MHz** eintragen: Bei Frequenzangaben wie „.205" im Chat muss KST4Contest entscheiden, ob 144.205, 432.205 oder 1296.205 gemeint ist. Bei Einband-Contests einfach die entsprechende Bandmitte eintragen. Vollständige Frequenzangaben wie „144.205" oder „1296.338" im Chat werden immer korrekt erkannt. +- die Richtung von Station A zu Station B, +- die Richtung von Station A zur eigenen Station. -### In UCXLog +Liegt die eigene Station innerhalb des konfigurierten Antennen-Öffnungswinkels und befindet sich Station A innerhalb des eingestellten maximalen QRB, wird die Situation als Richtungsgelegenheit behandelt. -- Verbindung zu einem DX-Cluster-Server konfigurieren: - - Host: `127.0.0.1` (oder IP des KST4Contest-Computers) - - Port: Wie in KST4Contest konfiguriert - - Passwort: kann leer bleiben -- Über die Schaltfläche **„Send a test message to your log"** kann die Verbindung getestet werden. +Ein DX-Cluster-Spot wird nur erzeugt, wenn alle folgenden Bedingungen erfüllt sind: -### In N1MM+ +1. Die Nachricht ist an eine konkrete andere Station gerichtet. +2. Für Absender und Empfänger sind gültige Locator bekannt. +3. Der Absender liegt innerhalb des konfigurierten maximalen QRB. +4. Die eigene Station liegt aus Sicht des Absenders innerhalb des konfigurierten Antennen-Öffnungswinkels. +5. Für den Absender ist eine verwertbare Frequenz bekannt. +6. Der lokale DX-Cluster-Server ist aktiviert. -Ähnliche Einstellungen: -- Host: `127.0.0.1` (oder IP des KST4Contest-Computers) -- Port: Wie in KST4Contest konfiguriert +Das Verfahren ist bewusst eine geometrische Herleitung. Es beweist weder, dass die Gegenstation tatsächlich mit genau dieser Antennenrichtung arbeitet, noch ersetzt es eine Ausbreitungsberechnung. Es erkennt eine plausible Gelegenheit. Mehr sollte man aus einer Chat-Nachricht auch nicht herauslesen. --- -## Funktionsweise +## Welche Frequenz wird verwendet? -Ein Spot wird generiert, wenn **beide** Bedingungen erfüllt sind: +Vollständige Frequenzangaben können direkt verarbeitet werden, beispielsweise: -1. Eine **Richtungs-Warnung** wurde ausgelöst (Station macht ein Sked in die eigene Richtung). -2. **QRG der Station ist bekannt** (aus dem Chat ausgelesen oder manuell eingetragen). +```text +144.205 +432.088 +1296.338 +``` -Der generierte Spot enthält: -- Rufzeichen der Station -- Frequenz -- Spotterzeit +Im Chat werden jedoch häufig nur relative Angaben geschrieben: -Das Logprogramm kann den Spot dann in der Bandkarte anzeigen und den TRX per Mausklick auf die Frequenz abstimmen. +```text +205 +.205 +338 +``` + +In diesem Fall fehlt das Band. KST4Contest ergänzt deshalb das unter **Fallback band in MHz** konfigurierte Bandpräfix. + +Beispiel: + +```text +Fallback-Band: 144 +Chat-Frequenz: .205 +DX-Cluster-Frequenz: 144205.0 kHz +``` + +Bei einem Einband-Contest ist diese Annahme normalerweise eindeutig. Bei gleichzeitigem Betrieb mehrerer Bänder kann sie falsch sein. Das Fallback-Band sollte deshalb zu dem Band passen, das in der betreffenden Chat-Kategorie überwiegend verwendet wird. + +Die weitergehende Frage, ob sich das Band künftig aus der konkreten Arbeitsfrequenz oder dem Stationskontext ableiten lässt, wird nach Abschluss des Manuals gesondert geprüft. --- -## Multi-Computer-Setup +## Einrichtung in KST4Contest -Wenn KST4Contest auf einem separaten Computer läuft (nicht auf dem Log-Computer): +Öffne den Reiter **Notification** in den Preferences. -- Host im Logprogramm: IP des KST4Contest-Computers (nicht `127.0.0.1`) -- Entspricht der Konfiguration der QSO-UDP-Broadcast-Pakete (siehe [Log-Synchronisation](de-Log-Synchronisation)) +![Benachrichtigungen und lokaler DX-Cluster-Server](client_settings_window_notification.png) + +Konfiguriere anschließend: + +1. **Enable the local DX Cluster server …** aktivieren. +2. Einen freien **TCP port** eintragen. Standard ist `8000`. +3. Das passende **Fallback band in MHz** eintragen. +4. Ein **Spotter callsign** festlegen. + +Für das Spotter-Rufzeichen sollte nach Möglichkeit ein anderes Rufzeichen als das Contest-Rufzeichen verwendet werden. Einige Logger filtern Spots, die scheinbar von der eigenen Station stammen. Das Ergebnis wäre technisch korrekt erzeugt, aber in der Bandmap trotzdem unsichtbar – eine besonders unproduktive Art von Erfolg. + +Änderungen am Aktivierungsstatus und am TCP-Port werden während einer laufenden Chat-Verbindung sofort angewendet. Bei einem Portwechsel werden vorhandene DX-Cluster-Verbindungen getrennt und müssen vom Logger neu aufgebaut werden. + +Die Einstellungen werden erst mit **Save Settings** dauerhaft in der `preferences.xml` gespeichert. + +--- + +## Einrichtung im Logprogramm + +Das Logprogramm wird als DX-Cluster-Client mit KST4Contest verbunden. + +| Einstellung | KST4Contest und Logger auf demselben Computer | Logger auf einem anderen Computer | +|---|---|---| +| Host | `127.0.0.1` | IP-Adresse des KST4Contest-Computers | +| Port | In KST4Contest konfigurierter TCP-Port | In KST4Contest konfigurierter TCP-Port | +| Login | Beliebiges Rufzeichen, falls der Logger eines verlangt | Beliebiges Rufzeichen, falls der Logger eines verlangt | +| Passwort | Nicht erforderlich | Nicht erforderlich | + +KST4Contest wertet den vom Logger gesendeten Login nicht zur Authentifizierung aus. Die Verbindung ist für ein lokales oder vertrauenswürdiges Stationsnetz vorgesehen. + +Wenn der Logger auf einem anderen Computer läuft, muss dessen Verbindung durch die lokale Firewall des KST4Contest-Computers zugelassen werden. Der Port sollte nicht ohne weiteren Schutz aus dem Internet erreichbar sein. + +Mehrere DX-Cluster-Clients können gleichzeitig verbunden werden. Ein erzeugter Spot wird an alle aktuell verbundenen Clients gesendet. + +--- + +## Verbindung testen + +Die Schaltfläche **Send test spot** erzeugt einen neutralen Testeintrag: + +```text +Spotted callsign: DO5AMF +Comment: KST4CONTEST TEST +Frequency: .300 des konfigurierten Fallback-Bandes +``` + +Bei einem Fallback-Band von `144` erscheint der Spot daher auf ungefähr `144.300 MHz`. + +Vor dem Test müssen drei Bedingungen erfüllt sein: + +1. KST4Contest ist mit dem ON4KST-Chat verbunden. +2. Der lokale DX-Cluster-Server ist aktiviert. +3. Der DX-Cluster-Client des Logprogramms ist mit KST4Contest verbunden. + +Fehlt die Client-Verbindung, zeigt KST4Contest eine entsprechende Meldung an. Ein erfolgreich ausgeführter Test bedeutet damit tatsächlich, dass mindestens ein Client den Spot erhalten hat. + +--- + +## Inhalt eines erzeugten Spots + +Ein Spot enthält: + +- das konfigurierte Spotter-Rufzeichen, +- die normalisierte Frequenz, +- das Rufzeichen der erkannten Station, +- den Locator, +- Flugzeug-Scatter-Informationen, falls vorhanden, +- die aktuelle UTC-Zeit. + +Wenn für die Station aktuelle Aircraft-Scatter-Informationen vorliegen, kann KST4Contest diese als zusätzliche AP-Information in den Kommentar des Spots aufnehmen. + +--- + +## Wenn kein Spot erscheint + +### Der Testspot kommt nicht im Logger an + +Prüfe: + +- Ist KST4Contest mit dem Chat verbunden? +- Ist der lokale DX-Cluster-Server aktiviert? +- Verwendet der Logger denselben TCP-Port? +- Verwendet der Logger bei lokalem Betrieb `127.0.0.1`? +- Blockiert eine Firewall die Verbindung? +- Ist im Logger das DX-Cluster-Fenster beziehungsweise die Bandmap aktiviert? + +### Testspot funktioniert, aber reale Spots fehlen + +Dann funktioniert die Verbindung grundsätzlich. Für die betreffende Chat-Situation war wahrscheinlich mindestens eine fachliche Bedingung nicht erfüllt: + +- kein gerichteter Nachrichtenaustausch, +- fehlender Locator, +- Station außerhalb des maximalen QRB, +- Richtung außerhalb des konfigurierten Öffnungswinkels, +- keine erkannte Frequenz. + +KST4Contest sendet absichtlich nicht jede gefundene Frequenz an den Logger. Andernfalls würde aus einer Arbeitserleichterung sehr schnell eine lokale Spot-Schleuder. + +### Der Spot erscheint auf dem falschen Band + +Prüfe zuerst das konfigurierte **Fallback band in MHz**. Es wird nur benötigt, wenn im Chat keine vollständige Frequenz angegeben wurde. + +### Der Spot wird vom Logger ausgeblendet + +Verwende ein Spotter-Rufzeichen, das nicht mit dem eigenen Contest-Rufzeichen identisch ist. Abhängig vom Logger können eigene Spots gefiltert oder besonders behandelt werden. --- ## Getestete Logprogramme -- **UCXLog** ✓ -- **N1MM+** ✓ +Die Schnittstelle wurde mit folgenden Logprogrammen verwendet: -Weitere Testergebnisse sind willkommen – bitte per E-Mail an DO5AMF melden. +- UCXLog +- N1MM+ + +Weitere Logger können funktionieren, wenn sie eine normale TCP-Verbindung zu einem DX-Cluster-Server unterstützen. diff --git a/github_docs/de-Funktionen.md b/github_docs/de-Funktionen.md index 78aad44..a6e8d9e 100644 --- a/github_docs/de-Funktionen.md +++ b/github_docs/de-Funktionen.md @@ -34,7 +34,13 @@ Die Berechnung berücksichtigt keine topografischen Wegberechnungen – das ist ## Sked-Richtungs-Spots (Integrierter DX-Cluster) -Ab **v1.23**: Richtungs-Warnungen werden als DX-Cluster-Spots an das Logprogramm weitergeleitet, wenn eine QRG bekannt ist. Details: [DX-Cluster-Server](de-DX-Cluster-Server). +Seit Version 1.23 kann KST4Contest erkannte Richtungsgelegenheiten als DX-Cluster-Spots an ein verbundenes Logprogramm weitergeben. + +Aus einer gerichteten Chat-Nachricht wird zunächst die wahrscheinliche Antennenrichtung des Absenders hergeleitet. Liegt die eigene Station innerhalb des konfigurierten Öffnungswinkels und ist eine Frequenz bekannt, erscheint die Station als Spot in der Bandmap des Logprogramms. + +KST4Contest überträgt damit nicht jede gefundene Frequenz, sondern nur Situationen, die für die eigene Station geometrisch plausibel sind. + +Details: [Integrierter DX-Cluster-Server](de-DX-Cluster-Server). --- @@ -149,9 +155,11 @@ Aktivierung: FurtherInfo-Panel der entsprechenden Station. --- -## QSO-Sniffer (ab v1.31) +## QSO-Monitoring (ab v1.31) -Der QSO-Sniffer überwacht den Chat auf Nachrichten von einer konfigurierbaren Rufzeichen-Liste und leitet diese automatisch in das **PM-Fenster** weiter. So gehen keine relevanten Nachrichten im allgemeinen Chat-Rauschen unter. +Für ausgewählte Rufzeichen kann KST4Contest gerichtete Nachrichten zusätzlich in der PM-Tabelle anzeigen. Dabei werden sowohl Nachrichten berücksichtigt, die das überwachte Rufzeichen sendet, als auch Nachrichten, die an dieses Rufzeichen gerichtet sind. + +Die Nachricht bleibt gleichzeitig in ihrer ursprünglichen Tabelle erhalten und wird im PM-Fenster mit Absender und Empfänger als überwachte Kommunikation gekennzeichnet. Konfiguration: [Konfiguration – Sniffer-Einstellungen](de-Konfiguration#sniffer-einstellungen-ab-v131) diff --git a/github_docs/de-Konfiguration.md b/github_docs/de-Konfiguration.md index c07450b..e0aa9f6 100644 --- a/github_docs/de-Konfiguration.md +++ b/github_docs/de-Konfiguration.md @@ -14,7 +14,7 @@ Nach dem ersten Start öffnet sich das **Einstellungsfenster** – dieses ist de ### Login und Chat-Kategorien -Hier werden die Zugangsdaten für den ON4KST-Chat eingetragen (Rufzeichen und Passwort). +Hier werden die Zugangsdaten für den ON4KST-Chat eingetragen (Rufzeichen und Passwort). Zudem wird die **primäre Chat-Kategorie** (z. B. IARU Region 1 VHF/Microwave) ausgewählt. Mit der Option für einen **zweiten Chat** (Multi-Channel-Login) kann man sich gleichzeitig in eine weitere Kategorie (z. B. UHF/SHF) einloggen. Beide Chats werden dann parallel überwacht. Hier kann optional auch ein abweichender Login-Name für den zweiten Chat vergeben werden (nützlich für Opposite Station Multi-Callsign Logging). @@ -87,11 +87,77 @@ Konfiguration der Schnittstelle zu AirScout für die Flugzeug-Scatter-Erkennung. ## Notification Settings (Benachrichtigungen) -Drei Benachrichtigungstypen stehen zur Wahl: +![Benachrichtigungen, DX-Cluster-Ausgabe und QSO-Monitoring](client_settings_window_notification.png) -1. **Einfache Sounds**: TADA-Sound für eingehende Nachrichten, Tick für Sked-Richtungserkennung usw. -2. **CW-Ansage**: Das Rufzeichen einer Station, die eine Privatnachricht sendet, wird als CW-Signal ausgegeben. -3. **Phonetische Ansage**: Das Rufzeichen wird phonetisch ausgesprochen. +Im Reiter **Notification** werden nicht nur akustische Hinweise konfiguriert. Hier befinden sich auch die Einstellungen für den lokalen DX-Cluster-Server, den Band-Upgrade-Hinweis und das QSO-Monitoring. + +### Akustische Hinweise + +Die drei Audiofunktionen arbeiten unabhängig voneinander: + +- **Play notification sounds …** aktiviert kurze Hinweistöne für neue Privatnachrichten, erkannte Richtungsgelegenheiten, Sked-Erinnerungen und Band-Upgrade-Hinweise. +- **Spell the sender's callsign in CW …** gibt das Rufzeichen des Absenders einer neuen Privatnachricht als CW-Signal aus. +- **Speak the sender's callsign phonetically …** spricht das Rufzeichen des Absenders phonetisch aus. + +CW- und Sprachausgabe können gleichzeitig aktiviert werden. Das ist technisch möglich, im Contest aber nicht zwingend hilfreich. In der Praxis sollte nur die Ausgabe eingeschaltet werden, die im eigenen Stationsbetrieb tatsächlich wahrgenommen werden kann, ohne den Operator dauerhaft zu beschäftigen. + +### Local DX Cluster output + +KST4Contest kann erkannte Richtungsgelegenheiten als DX-Cluster-Spots an ein Logprogramm weitergeben. Der praktische Nutzen liegt auf der Hand: Eine im Chat erkannte Frequenz erscheint direkt in der Bandmap des Logprogramms und muss nicht erst von Hand übertragen werden. + +Die Checkbox **Enable the local DX Cluster server …** startet beziehungsweise beendet den lokalen TCP-Server. Bei einer laufenden Chat-Verbindung wird die Änderung sofort wirksam. + +Folgende Einstellungen sind erforderlich: + +- **TCP port**: Port, auf dem KST4Contest Verbindungen von DX-Cluster-Clients annimmt. Der Standardwert ist `8000`. Wird der Port während einer laufenden Verbindung geändert, startet KST4Contest den Server auf dem neuen Port neu. Der Logger muss sich anschließend ebenfalls mit dem neuen Port verbinden. +- **Fallback band in MHz**: Bandpräfix für relative Frequenzangaben. Aus `205` oder `.205` wird bei einem Fallback-Band von `144` die Frequenz `144.205 MHz`. Vollständige Angaben wie `432.205` oder `1296.338` benötigen diesen Fallback nicht. +- **Spotter callsign**: Rufzeichen, das im erzeugten DX-Cluster-Spot als Spotter erscheint. Hier sollte ein anderes Rufzeichen als das im Contest verwendete Stationsrufzeichen eingetragen werden. Einige Logprogramme filtern Spots des eigenen Rufzeichens oder behandeln sie anders als fremde Spots. +- **Send test spot**: Sendet einen Testspot für `DL0TEST` auf `.300` des eingestellten Fallback-Bandes. Der Test funktioniert nur, wenn KST4Contest mit dem Chat verbunden, der lokale DX-Cluster-Server aktiviert und mindestens ein DX-Cluster-Client verbunden ist. + +KST4Contest erzeugt nicht bei jeder im Chat gefundenen Frequenz automatisch einen Spot. Ein Spot entsteht nur dann, wenn eine gerichtete Nachricht zwischen zwei Stationen auf eine für die eigene Station interessante Antennenrichtung schließen lässt und für den Absender eine nutzbare Frequenz bekannt ist. + +Die vollständige Herleitung und die Einrichtung des Logprogramms sind im Kapitel [Integrierter DX-Cluster-Server](de-DX-Cluster-Server) beschrieben. + +### Band-Upgrade-Hinweis nach einem Logeintrag + +Nach einem über UCXLog oder Win-Test empfangenen Logeintrag kann KST4Contest prüfen, ob die gerade gearbeitete Station auf einem weiteren gemeinsamen, aber noch nicht gearbeiteten Band aktiv ist. + +Dafür werden drei Informationen miteinander verglichen: + +1. die in den Stationseinstellungen aktivierten eigenen Bänder, +2. die innerhalb der letzten 30 Minuten erkannten Bänder der Gegenstation, +3. die bereits pro Band gespeicherten Worked-Markierungen. + +Bleibt danach mindestens ein gemeinsames, noch nicht gearbeitetes Band übrig, erscheint im Hauptfenster ein blinkender **BAND+**-Hinweis. Ist die allgemeine Soundausgabe aktiviert, wird zusätzlich ein Hinweiston abgespielt. + +Die beiden Optionen haben unterschiedliche Aufgaben: + +- **Blink + sound …** aktiviert den eigentlichen Band-Upgrade-Hinweis. +- **Priority boost …** erhöht zusätzlich die Priorität entsprechender Stationen, damit sie in den Kandidatenlisten besser sichtbar bleiben. Der Boost garantiert keinen bestimmten Listenplatz; er ist nur ein zusätzlicher Faktor innerhalb der gesamten Prioritätsberechnung. + +Der Hinweis setzt eine Log-Synchronisation mit Bandinformation voraus. Der einfache dateibasierte Callsign-Interpreter kann nur Rufzeichen erkennen und liefert deshalb keine ausreichende Grundlage für diese Prüfung. + +### Sniffer-Einstellungen (ab v1.31) + +Das QSO-Monitoring ist für Stationen gedacht, deren Kommunikation man gezielt verfolgen möchte. Das kann beispielsweise eine seltene Station, eine DXpedition oder eine andere Station des eigenen Contest-Teams sein. + +Für jedes eingetragene Rufzeichen zeigt KST4Contest Nachrichten zusätzlich in der PM-Tabelle an, wenn das Rufzeichen entweder Absender oder Empfänger der Nachricht ist. Die ursprüngliche Nachricht wird dabei nicht aus ihrer normalen Tabelle entfernt. + +Überwachte Nachrichten werden in der PM-Tabelle eindeutig gekennzeichnet: + +```text +Sniffed: (SENDER > RECEIVER) Nachrichtentext +``` + +So wird sichtbar, dass die Nachricht nicht an die eigene Station gerichtet war. + +Rufzeichen werden folgendermaßen verwaltet: + +1. Mit **Add monitored callsign** ein neues Rufzeichen hinzufügen. +2. Ein vorhandenes Rufzeichen per Doppelklick bearbeiten und die Änderung mit `Enter` übernehmen. +3. Zum Entfernen den Inhalt einer Tabellenzelle löschen und mit `Enter` bestätigen. + +Doppelte oder syntaktisch ungültige Rufzeichen werden nicht übernommen. Die Liste wird mit **Save Settings** in der `preferences.xml` gespeichert und beim nächsten Programmstart wiederhergestellt. --- @@ -163,17 +229,6 @@ Einstellungen: --- -## Sniffer-Einstellungen (ab v1.31) - -Der QSO-Sniffer filtert Chat-Nachrichten von konfigurierbaren Rufzeichen und leitet sie ins PM-Fenster weiter. - -Einstellungen: -- **Rufzeichen-Liste**: Kommagetrennte Liste von Rufzeichen, deren Nachrichten immer in das PM-Fenster weitergeleitet werden sollen. - -Anwendungsfall: Wichtige Stationen (z. B. DX-Peditionen oder feste Verbündete im Contest) im Auge behalten, ohne den Haupt-Chat ständig zu beobachten. - ---- - ## Worked Station Database Settings (Gearbeitete-Stationen-Datenbank) Die interne Worked-Datenbank enthält: diff --git a/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java b/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java index 00fa248..6e26c16 100644 --- a/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java +++ b/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java @@ -1,150 +1,198 @@ package kst4contest.controller; - import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; -import java.net.NoRouteToHostException; -import java.net.SocketException; -import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.TimerTask; import java.util.logging.Level; import java.util.logging.Logger; -import javafx.collections.ObservableList; -import kst4contest.locatorUtils.Location; import kst4contest.model.ChatMember; - +/** + * Sends periodical path requests and an AirScout watchlist for the currently + * active ON4KST stations. + */ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask { - private static final Logger LOGGER = Logger.getLogger(AirScoutPeriodicalAPReflectionInquirerTask.class.getName()); - private ChatController client; + private static final Logger LOGGER = Logger.getLogger( + AirScoutPeriodicalAPReflectionInquirerTask.class.getName() + ); - public AirScoutPeriodicalAPReflectionInquirerTask(ChatController client) { + private static final String BROADCAST_ADDRESS = "255.255.255.255"; + private final ChatController client; + + public AirScoutPeriodicalAPReflectionInquirerTask( + ChatController client + ) { this.client = client; - } @Override public void run() { - - Thread.currentThread().setName("AirscoutPeriodicalReflectionInquirierTask"); + Thread.currentThread().setName( + "AirscoutPeriodicalReflectionInquirerTask" + ); - String KSTClientsNameForQuery = this.client.getChatPreferences().getAirScout_asClientNameString(); - String ASServerNameStringForAnswer = this.client.getChatPreferences().getAirScout_asServerNameString(); - - //TODO: Manage prefixes kst and as via preferences file and instance - //TODO: Check if locator is changeable via the preferences object, need to be correct if it changes - DatagramSocket dsocket; - -// String prefix_asSetpath ="ASSETPATH: \"KST\" \"AS\" "; //working original -// 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 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) { - LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Error parsing callsign", e); - } - String myCallAndMyLocString = ownCallSign + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //bugfix, Airscout do not process 9A1W-2 but 9A1W like formatted calls - - - String suffix = ""; //"FOREIGNCALL,FOREIGNLOC " -- dont forget the space at the end!!! - String asWatchListString = prefix_asWatchList + bandString + "," + myCallAndMyLocString; - String asWatchListStringSuffix = asWatchListString; - - String host = "255.255.255.255"; -// int port = 9872; - - int port = client.getChatPreferences().getAirScout_asCommunicationPort(); -// byte[] message = "ASSETPATH: \"KST\" \"AS\" 1440000,DO5AMF,JN49GL,OK1MZM,JN89IW ".getBytes(); Original, ging - InetAddress address; - - - /** - * Iterate over chatmemberlist and asking airscout for plane reflection information - * To avoid a concurrentmodifyexception, we have to convert the original list to an array at first - * since the iterator brakes if the list changing during the iteration time + /* + * Keep the scheduled task installed so that AirScout can be enabled at + * runtime, but do not send anything while the integration is disabled. */ - ObservableList praktiKSTActiveUserList = this.client.getLst_chatMemberList(); - - - ChatMember[] ary_threadSafeChatMemberArray = new ChatMember[praktiKSTActiveUserList.size()]; - praktiKSTActiveUserList.toArray(ary_threadSafeChatMemberArray); - - for (ChatMember i : ary_threadSafeChatMemberArray) { + if (!client.getChatPreferences().isAirScout_asUDPListenerEnabled()) { + return; + } - 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() + " "; + String clientIdentifier = + client.getChatPreferences().getAirScout_asClientNameString(); + String serverIdentifier = + client.getChatPreferences().getAirScout_asServerNameString(); + String bandValue = + client.getChatPreferences().getAirScout_asBandString(); - String queryStringToAirScout = ""; + String ownCallSign = normalizeOwnCallSign( + client.getChatPreferences().getStn_loginCallSign() + ); + String ownLocator = + client.getChatPreferences().getStn_loginLocatorMainCat(); - queryStringToAirScout += prefix_asSetpath + bandString + "," + myCallAndMyLocString + "," + suffix; + if (ownCallSign == null + || ownCallSign.isBlank() + || ownLocator == null + || ownLocator.isBlank()) { + LOGGER.warning( + "AirScout queries were skipped because the own callsign " + + "or locator is missing." + ); + return; + } - byte[] queryStringToAirScoutMSG = queryStringToAirScout.getBytes(); + String setPathPrefix = + "ASSETPATH: \"" + clientIdentifier + + "\" \"" + serverIdentifier + "\" "; - try { - address = InetAddress.getByName("255.255.255.255"); - DatagramPacket packet = new DatagramPacket(queryStringToAirScoutMSG, queryStringToAirScoutMSG.length, address, port); - dsocket = new DatagramSocket(); - dsocket.setBroadcast(true); - dsocket.send(packet); - dsocket.close(); - } catch (UnknownHostException e1) { - LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Unknown host", e1); - } catch (NoRouteToHostException e) { - LOGGER.log(Level.SEVERE, "[ASPERIODICAL] No route to host", e); - } catch (SocketException e) { - LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Socket error", e); - } catch (IOException e) { - LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending query", e); + String watchListPrefix = + "ASWATCHLIST: \"" + clientIdentifier + + "\" \"" + serverIdentifier + "\" "; + + String ownStation = ownCallSign + "," + ownLocator; + StringBuilder watchListMessage = new StringBuilder( + watchListPrefix + + bandValue + + "," + + ownStation + ); + + List activeMembers = client.snapshotChatMembers(); + int port = client.getChatPreferences() + .getAirScout_asCommunicationPort(); + + try ( + DatagramSocket socket = new DatagramSocket() + ) { + socket.setBroadcast(true); + InetAddress broadcastAddress = + InetAddress.getByName(BROADCAST_ADDRESS); + + for (ChatMember member : activeMembers) { + if (!isUsableAirScoutTarget(member)) { + continue; } - // System.out.println("[ASUDPTask, info:] sent query " + queryStringToAirScout); - asWatchListStringSuffix += "," + i.getCallSign() + "," + i.getQra(); + if (member.getQrb() + >= client.getChatPreferences().getStn_maxQRBDefault()) { + continue; + } + + String targetStation = + member.getCallSign() + "," + member.getQra(); + + String pathQuery = + setPathPrefix + + bandValue + + "," + + ownStation + + "," + + targetStation + + " "; + + sendPacket( + socket, + broadcastAddress, + port, + pathQuery + ); + + watchListMessage + .append(",") + .append(targetStation); } + + watchListMessage.append(" "); + + sendPacket( + socket, + broadcastAddress, + port, + watchListMessage.toString() + ); + } catch (IOException exception) { + LOGGER.log( + Level.WARNING, + "Could not send the periodical AirScout queries.", + exception + ); } - - /** - * As next we will set the ASWatchlist. All stations in chat will be watched by airscout causing following code.\n\n - * ASWATCHLIST: "KST" "AS" 4320000,DO5AMF,JN49GL,DF9QX,JO42HD,DG2KBC,JN58MI,DJ0PY,JO32MF,DL1YDI,JO42FA,DL6BF,JO32QI,F1NZC,JN15MR,F4TXU,JN23CX,F5GHP,IN96LE,F6HTJ,JN12KQ,G0GGG,IO81VE,G0JCC,IO82MA,G0JDL,JO02SI,G0MBL,JO01QH,G4AEP,IO91MB,G4CLA,IO92JL,G4DCV,IO91OF,G4LOH,IO70JC,G4MKF,IO91HJ,G4TRA,IO81WN,G8GXP,IO93FQ,G8VHI,IO92FM,GW0RHC,IO71UN,HA4ND,JN97MJ,I5/HB9SJV/P,JN52JS,IW2DAL,JN45NN,OK1FPR,JO80CE,OK6M,JN99CR,OV3T,JO46CM,OZ2M,JO65FR,PA0V,JO33II,PA2RU,JO32LT,PA3DOL,JO22MT,PA9R,JO22JK,PE1EVX,JO22MP,S51AT,JN75GW,SM7KOJ,JO66ND,SP9TTG,JO90KW� - * The watchlist-String is bult by the for loop which builds the AP queries - */ - asWatchListStringSuffix += " "; - - byte[] queryStringToAirScoutMSG = asWatchListStringSuffix.getBytes(); - - try { - address = InetAddress.getByName("255.255.255.255"); - DatagramPacket packet = new DatagramPacket(queryStringToAirScoutMSG, queryStringToAirScoutMSG.length, address, port); - dsocket = new DatagramSocket(); - dsocket.setBroadcast(true); - dsocket.send(packet); - dsocket.close(); - } catch (IOException e) { - LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending watchlist", e); - } - -// System.out.println("[ASUDPTask, info:] set watchlist: " + asWatchListStringSuffix); - - } -} + /** + * Removes the ON4KST login suffix because AirScout expects the actual + * station callsign, for example 9A1W instead of 9A1W-2. + * + * @param callSign configured ON4KST login callsign + * @return callsign without an ON4KST login suffix + */ + private String normalizeOwnCallSign(String callSign) { + if (callSign == null) { + return null; + } + + String normalizedCallSign = callSign.trim(); + int suffixSeparator = normalizedCallSign.indexOf("-"); + + if (suffixSeparator > 0) { + return normalizedCallSign.substring(0, suffixSeparator); + } + + return normalizedCallSign; + } + + private boolean isUsableAirScoutTarget(ChatMember member) { + return member != null + && member.getCallSign() != null + && !member.getCallSign().isBlank() + && member.getQra() != null + && !member.getQra().isBlank(); + } + + private void sendPacket( + DatagramSocket socket, + InetAddress address, + int port, + String message + ) throws IOException { + byte[] payload = message.getBytes(StandardCharsets.UTF_8); + + DatagramPacket packet = new DatagramPacket( + payload, + payload.length, + address, + port + ); + + socket.send(packet); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index 0e1f2cf..8c959d6 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -32,6 +32,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Predicate; +import java.nio.charset.StandardCharsets; @@ -472,66 +473,106 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList * @param remoteChatMember with callsign of the foreign station */ - public void airScout_SendAsShowPathPacket(ChatMember remoteChatMember) { + /** + * Requests AirScout to display the path between the own station and the + * selected remote station. + * + * The configured server identifier, client identifier, band and UDP port are + * used for every request. This allows several AirScout servers or clients to + * coexist in the same network. + * + * @param remoteChatMember selected remote station + */ + public void airScout_SendAsShowPathPacket( + ChatMember remoteChatMember + ) { + if (!chatPreferences.isAirScout_asUDPListenerEnabled()) { + System.out.println( + "[AirScout, info]: Show-path request ignored because " + + "the AirScout integration is disabled." + ); + return; + } - DatagramSocket dsocket; + if (remoteChatMember == null + || remoteChatMember.getCallSign() == null + || remoteChatMember.getCallSign().isBlank() + || remoteChatMember.getQra() == null + || remoteChatMember.getQra().isBlank()) { + System.out.println( + "[AirScout, warning]: Show-path request ignored because " + + "the selected station has no usable callsign or locator." + ); + return; + } - String prefix_asSetpath ="ASSHOWPATH: \""+ this.getChatPreferences().getAirScout_asClientNameString()+ "\" \"" + this.getChatPreferences().getAirScout_asServerNameString() + "\" "; + String ownCallSign = chatPreferences.getStn_loginCallSign(); + if (ownCallSign == null || ownCallSign.isBlank()) { + return; + } -// String prefix_asSetpath ="ASSHOWPATH: \"KST\" \"AS\" "; Old hard coded - String bandString = "1440000"; -// String myCallAndMyLocString = chatPreferences.getStn_loginCallSign() + "," + chatPreferences.getStn_loginLocatorMainCat(); // original b4 bugfix 1266 - String remoteCallAndLocString = remoteChatMember.getCallSign() +"," + remoteChatMember.getQra(); + int suffixSeparator = ownCallSign.indexOf("-"); + if (suffixSeparator > 0) { + ownCallSign = ownCallSign.substring(0, suffixSeparator); + } - 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 ownLocator = + chatPreferences.getStn_loginLocatorMainCat(); - String myCallAndMyLocString = ownCallSign + "," + chatPreferences.getStn_loginLocatorMainCat(); // original b4 bugfix 1266 + if (ownLocator == null || ownLocator.isBlank()) { + return; + } - String host = "255.255.255.255"; -// int port = 9872; - int port = chatPreferences.getAirScout_asCommunicationPort(); -// System.out.println("<<<<<<<<<<<<<<<<<<< lst_toOtherMessageList = new FilteredList<>(lst_globalChatMessageList); - private ObservableList lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList(); +// private ObservableList lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList(); +private ObservableList + lstNotify_QSOSniffer_sniffedCallSignList; /** * 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 @@ -1864,6 +1905,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList chatPreferences = new ChatPreferences(); chatPreferences.readPreferencesFromXmlFile(); // this.statusListener = listener; + lstNotify_QSOSniffer_sniffedCallSignList = + chatPreferences + .getLstNotify_QSOSniffer_sniffedCallSignList(); String dnsFromPrefs = chatPreferences.getStn_on4kstServersDns(); if (dnsFromPrefs != null && !dnsFromPrefs.isEmpty()) { @@ -2004,7 +2048,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList }); - lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB"); +// lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB"); lst_toMeMessageList.setPredicate(chatFilterPredicate); //sniffed callsign filter predicate is here! @@ -2147,6 +2191,38 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList return dxClusterServer; } + public synchronized void startDxClusterServerIfEnabled() { + if (!chatPreferences.isNotify_dxClusterServerEnabled() + || dxClusterServer != null) { + return; + } + + dxClusterServer = new DXClusterThreadPooledServer( + chatPreferences.getNotify_dxclusterServerPort(), + this, + this + ); + + Thread serverThread = new Thread(dxClusterServer); + serverThread.setName("DXCluster-thread-pooled-server"); + serverThread.setDaemon(true); + serverThread.start(); + } + + public synchronized void stopDxClusterServer() { + DXClusterThreadPooledServer serverToStop = dxClusterServer; + dxClusterServer = null; + + if (serverToStop != null) { + serverToStop.stop(); + } + } + + public synchronized void restartDxClusterServerIfEnabled() { + stopDxClusterServer(); + startDxClusterServerIfEnabled(); + } + // public void setChatMemberTable(Hashtable chatMemberTable) { // this.chatMemberTable = chatMemberTable; // } @@ -2230,7 +2306,11 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList messageProcessor.setName("messagebusManagementThread"); messageProcessor.start(); - airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread(chatPreferences.getAirScout_asCommunicationPort(), this, this.getChatPreferences().getAirScout_asServerNameString(), this.getChatPreferences().getAirScout_asServerNameString(), this); //working original + airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread( + chatPreferences.getAirScout_asCommunicationPort(), + this, + this + ); airScoutUDPReaderThread.setName("airscoutudpreaderThread"); airScoutUDPReaderThread.start(); @@ -2249,8 +2329,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList /** * DX cluster service running config */ - dxClusterServer = new DXClusterThreadPooledServer(this.getChatPreferences().getNotify_dxclusterServerPort(), this, this); - new Thread(dxClusterServer).start(); + startDxClusterServerIfEnabled(); this.setConnectedAndLoggedIn(true); diff --git a/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java b/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java index d43e89b..e5f98ed 100644 --- a/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java +++ b/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java @@ -1,238 +1,363 @@ package kst4contest.controller; import kst4contest.model.ChatMember; -import kst4contest.model.ChatPreferences; import kst4contest.model.ThreadStateMessage; -import java.io.*; +import java.io.IOException; +import java.io.OutputStream; import java.net.ServerSocket; import java.net.Socket; -import java.util.*; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; -public class DXClusterThreadPooledServer implements Runnable{ +public class DXClusterThreadPooledServer implements Runnable { - private static final Logger LOGGER = Logger.getLogger(DXClusterThreadPooledServer.class.getName()); - private List clientSockets = Collections.synchronizedList(new ArrayList<>()); //list of all connected clients + private static final Logger LOGGER = + Logger.getLogger(DXClusterThreadPooledServer.class.getName()); + private static final String THREAD_NICKNAME = "DXCluster-Server"; - private ThreadStatusCallback callBackToController; - private String ThreadNickName = "DXCluster-Server"; - ChatController chatController = null; - protected int serverPort = 8080; - protected ServerSocket serverSocket = null; - protected boolean isStopped = false; - protected Thread runningThread= null; - protected ExecutorService threadPool = + private final List clientSockets = + Collections.synchronizedList(new ArrayList<>()); + + private final ChatController chatController; + private final ThreadStatusCallback callBackToController; + private final int serverPort; + + private final ExecutorService threadPool = Executors.newFixedThreadPool(10); - Socket clientSocket; - public DXClusterThreadPooledServer(int port, ChatController chatController, ThreadStatusCallback callback){ + private final ScheduledExecutorService keepAliveExecutor = + Executors.newSingleThreadScheduledExecutor(); + + private volatile boolean stopped; + private ServerSocket serverSocket; + + public DXClusterThreadPooledServer( + int port, + ChatController chatController, + ThreadStatusCallback callback + ) { this.serverPort = port; this.chatController = chatController; this.callBackToController = callback; } - public void run(){ + @Override + public void run() { + Thread.currentThread().setName("DXCluster-thread-pooled-server"); - ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false); - callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); + try { + serverSocket = new ServerSocket(serverPort); - synchronized(this){ - this.runningThread = Thread.currentThread(); - runningThread.setName("DXCluster-thread-pooled-server"); - } - openServerSocket(); - while(! isStopped()){ - clientSocket = null; - try { - clientSocket = this.serverSocket.accept(); - - synchronized(clientSockets) { - clientSockets.add(clientSocket); // add dx cluster client to the "clients list" for broadcasting - } - - } catch (IOException e) { - if(isStopped()) { - System.out.println("Server Stopped.") ; - break; - } - throw new RuntimeException( - "Error accepting client connection", e); + if (stopped) { + return; } - DXClusterServerWorkerRunnable worker = new DXClusterServerWorkerRunnable(clientSocket, "Thread Pooled DXCluster Server ", chatController, clientSockets, chatController); + callBackToController.onThreadStatus( + THREAD_NICKNAME, + new ThreadStateMessage( + THREAD_NICKNAME, + true, + "Listening on TCP port " + serverPort, + false + ) + ); - this.threadPool.execute(worker); + keepAliveExecutor.scheduleAtFixedRate( + this::sendKeepAlive, + 30, + 30, + TimeUnit.SECONDS + ); - } - this.threadPool.shutdown(); - System.out.println("Server Stopped.") ; - } + while (!stopped) { + try { + Socket clientSocket = serverSocket.accept(); + clientSockets.add(clientSocket); - private synchronized boolean isStopped() { - return this.isStopped; - } - - public synchronized void stop(){ - this.isStopped = true; - try { - this.serverSocket.close(); - synchronized(clientSockets) { - for (Socket socket : clientSockets) { - socket.close(); // close all client connections + threadPool.execute( + new DXClusterServerWorkerRunnable( + clientSocket, + clientSockets + ) + ); + } catch (IOException exception) { + if (!stopped) { + LOGGER.log( + Level.SEVERE, + "Error accepting DX Cluster client connection", + exception + ); + } } } - } catch (IOException e) { - throw new RuntimeException("DXCCSERVER Error closing server", e); + } catch (IOException exception) { + if (!stopped) { + LOGGER.log( + Level.SEVERE, + "Cannot open DX Cluster TCP port " + serverPort, + exception + ); + + callBackToController.onThreadStatus( + THREAD_NICKNAME, + new ThreadStateMessage( + THREAD_NICKNAME, + false, + "Cannot open TCP port " + + serverPort + + ": " + + exception.getMessage(), + true + ) + ); + } + } finally { + closeServerSocket(); + closeClientSockets(); + keepAliveExecutor.shutdownNow(); + threadPool.shutdownNow(); } } - private void openServerSocket() { - try { - this.serverSocket = new ServerSocket(this.serverPort); - } catch (IOException e) { - throw new RuntimeException("DXCCSERVER Cannot open port ", e); + public synchronized void stop() { + stopped = true; + closeServerSocket(); + closeClientSockets(); + keepAliveExecutor.shutdownNow(); + threadPool.shutdownNow(); + } + + public boolean hasConnectedClients() { + synchronized (clientSockets) { + removeClosedClients(); + return !clientSockets.isEmpty(); } } /** - * Sends a DX cluster message to ALL connected log programs via telnet, returns true if sent + * Sends one DX Cluster spot to all currently connected clients. * - * @param aChatMember - * @return boolean true if message had been sent + * @return true if the spot was delivered to at least one client */ - public boolean broadcastSingleDXClusterEntryToLoggers(ChatMember aChatMember) { - synchronized(clientSockets) { + public boolean broadcastSingleDXClusterEntryToLoggers( + ChatMember chatMember + ) { + final String clusterMessage; - System.out.println("DXClusterSrvr: broadcasting message to clients: " + clientSockets.size()); + try { + String frequency = Utils4KST.normalizeFrequencyString( + chatMember.getFrequency().getValue(), + chatController + .getChatPreferences() + .getNotify_optionalFrequencyPrefix() + ); - try { + clusterMessage = + "DX de " + + chatController + .getChatPreferences() + .getNotify_DXCSrv_SpottersCallSign() + .getValue() + + ": " + + frequency + + " " + + chatMember.getCallSign().toUpperCase() + + " " + + chatMember.getQra().toUpperCase() + + " " + + new Utils4KST() + .time_generateCurrenthhmmZTimeStringForClusterMessage() + + ((char) 7) + + ((char) 7) + + "\r\n"; + } catch (Exception exception) { + LOGGER.log( + Level.SEVERE, + "Cannot build DX Cluster message", + exception + ); + return false; + } - System.out.println("-------------> ORIGINALEE VAL: " + aChatMember.getFrequency().getValue()); - System.out.println("-------------> NORMALIZED VAL: " + Utils4KST.normalizeFrequencyString(aChatMember.getFrequency().getValue(), chatController.getChatPreferences().getNotify_optionalFrequencyPrefix()) + " "); - } catch (Exception e) { - LOGGER.log(Level.SEVERE, "DXCThPooledServer: Error accessing value in chatmember object", e); - } + int deliveredClients = 0; - for (Socket socket : clientSockets) { + synchronized (clientSockets) { + Iterator iterator = clientSockets.iterator(); + + while (iterator.hasNext()) { + Socket socket = iterator.next(); + + if (socket == null || socket.isClosed()) { + iterator.remove(); + continue; + } try { + OutputStream output = socket.getOutputStream(); + output.write( + clusterMessage.getBytes( + StandardCharsets.US_ASCII + ) + ); + output.flush(); + deliveredClients++; + } catch (IOException exception) { + LOGGER.log( + Level.WARNING, + "DX Cluster client disconnected while sending a spot", + exception + ); - OutputStream output = socket.getOutputStream(); - - String singleDXClusterMessage = "DX de "; - -// singleDXClusterMessage += chatController.getChatPreferences().getLoginCallSign() + ": "; - - - - - singleDXClusterMessage += this.chatController.getChatPreferences().getNotify_DXCSrv_SpottersCallSign().getValue() + ": "; - singleDXClusterMessage += Utils4KST.normalizeFrequencyString(aChatMember.getFrequency().getValue(), chatController.getChatPreferences().getNotify_optionalFrequencyPrefix()) + " "; - singleDXClusterMessage += aChatMember.getCallSign().toUpperCase() + " "; //we need such an amount of spaces for n1mm to work, otherwise bullshit happens - singleDXClusterMessage += aChatMember.getQra().toUpperCase() + " "; - singleDXClusterMessage += new Utils4KST().time_generateCurrenthhmmZTimeStringForClusterMessage() + ((char)7) + ((char)7) + "\r\n"; - -// singleDXClusterMessage += chatController.getChatPreferences().getLoginCallSign() + ": "; -// singleDXClusterMessage += Utils4KST.normalizeFrequencyString(aChatMember.getFrequency().getValue(), chatController.getChatPreferences().getNotify_optionalFrequencyPrefix()) + " "; -// singleDXClusterMessage += aChatMember.getCallSign().toUpperCase() + " "; -// singleDXClusterMessage += aChatMember.getQra().toUpperCase() + " "; -// singleDXClusterMessage += new Utils4KST().time_generateCurrenthhmmZTimeStringForClusterMessage() + ((char)7) + ((char)7) + "\r\n"; - - 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) { - LOGGER.log(Level.SEVERE, "[DXClusterSrvr] broadcasting DXC-message to clients went wrong", e); - return false; + closeSocket(socket); + iterator.remove(); } } } - return true; //if message had been sent, return true for "ok" + + if (deliveredClients > 0) { + callBackToController.onThreadStatus( + THREAD_NICKNAME, + new ThreadStateMessage( + THREAD_NICKNAME, + true, + "Last spot sent to " + + deliveredClients + + " DX Cluster client(s):\n" + + clusterMessage, + false + ) + ); + } + + return deliveredClients > 0; } + private void sendKeepAlive() { + synchronized (clientSockets) { + Iterator iterator = clientSockets.iterator(); + + while (iterator.hasNext()) { + Socket socket = iterator.next(); + + if (socket == null || socket.isClosed()) { + iterator.remove(); + continue; + } + + try { + OutputStream output = socket.getOutputStream(); + output.write( + "\r\n".getBytes(StandardCharsets.US_ASCII) + ); + output.flush(); + } catch (IOException exception) { + closeSocket(socket); + iterator.remove(); + } + } + } + } + + private void removeClosedClients() { + clientSockets.removeIf( + socket -> socket == null || socket.isClosed() + ); + } + + private synchronized void closeServerSocket() { + if (serverSocket == null || serverSocket.isClosed()) { + return; + } + + try { + serverSocket.close(); + } catch (IOException exception) { + LOGGER.log( + Level.WARNING, + "Error closing DX Cluster server socket", + exception + ); + } + } + + private void closeClientSockets() { + synchronized (clientSockets) { + for (Socket socket : clientSockets) { + closeSocket(socket); + } + + clientSockets.clear(); + } + } + + private static void closeSocket(Socket socket) { + if (socket == null || socket.isClosed()) { + return; + } + + try { + socket.close(); + } catch (IOException ignored) { + // The connection is already unusable. + } + } } -class DXClusterServerWorkerRunnable implements Runnable{ +class DXClusterServerWorkerRunnable implements Runnable { - private static final Logger LOGGER = Logger.getLogger(DXClusterServerWorkerRunnable.class.getName()); - protected Socket clientSocket = null; - protected String serverText = null; - private ChatController client = null; - private List dxClusterClientSocketsConnectedList; - private ThreadStatusCallback callBackToController; - private String ThreadNickName = "DXCluster-Server"; + private static final Logger LOGGER = + Logger.getLogger(DXClusterServerWorkerRunnable.class.getName()); - public DXClusterServerWorkerRunnable(Socket clientSocket, String serverText, ChatController chatController, List clientSockets, ThreadStatusCallback callback) { + private final Socket clientSocket; + private final List clientSockets; + + DXClusterServerWorkerRunnable( + Socket clientSocket, + List clientSockets + ) { this.clientSocket = clientSocket; - this.serverText = serverText; - this.client = chatController; - this.dxClusterClientSocketsConnectedList = clientSockets; - this.callBackToController = callback; - + this.clientSockets = clientSockets; } + @Override public void run() { try { OutputStream output = clientSocket.getOutputStream(); - dxClusterClientSocketsConnectedList.add(clientSocket); + output.write( + "login: ".getBytes(StandardCharsets.US_ASCII) + ); + output.flush(); - Timer dXCkeepAliveTimer = new Timer(); - dXCkeepAliveTimer.schedule(new TimerTask() { + System.out.println( + "[DXClusterServer] New client connected: " + + clientSocket.getInetAddress() + ); + } catch (IOException exception) { + LOGGER.log( + Level.WARNING, + "Cannot initialise DX Cluster client connection", + exception + ); - @Override - public void run() { + synchronized (clientSockets) { + clientSockets.remove(clientSocket); + } - 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()); - - } catch (IOException e) { - LOGGER.log(Level.SEVERE, "[DXClusterSrvr] keep-alive broadcast to client failed", e); - dXCkeepAliveTimer.purge(); - - try { - socket.close(); - } catch (IOException ex) { - LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error closing client socket", ex); - } - finally { - this.cancel(); - } - dxClusterClientSocketsConnectedList.remove(socket); //if socket is closed by client, remove it from the broadcast list and close it - } - } - -// ThreadStateMessage threadStateMessage = new ThreadStateMessage(ThreadNickName, true, "Connected clients: " + connectedClients.toString(), false); -// callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); - - } - }, 30000, 30000); - - - output.write(("login: ").getBytes()); //say hello to the client, it will answer with a callsign - System.out.println("[DXClusterThreadPooledServer, Info:] New cluster client connected! "); //TODO: maybe integrate non blocking reader for client identification - - } catch (IOException e) { - LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error in worker runnable", e); - } finally { - synchronized(dxClusterClientSocketsConnectedList) { - dxClusterClientSocketsConnectedList.remove(clientSocket); // Entferne den Client nach Verarbeitung + try { + clientSocket.close(); + } catch (IOException ignored) { + // The connection is already unusable. } } } - - - -} +} \ No newline at end of file diff --git a/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java b/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java index 8fde94a..adef126 100644 --- a/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java +++ b/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java @@ -1,9 +1,13 @@ package kst4contest.controller; -import java.io.*; -import java.net.*; -import java.util.ArrayList; +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetSocketAddress; +import java.net.SocketException; +import java.net.SocketTimeoutException; import java.util.Comparator; +import java.util.List; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -21,188 +25,245 @@ import kst4contest.model.ThreadStateMessage; * @author www.codejava.net */ public class ReadUDPbyAirScoutMessageThread extends Thread { - private BufferedReader reader; - private Socket socket; - private ChatController client; - private int localPort; - private String ASIdentificator, ChatClientIdentificator; - 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, ThreadStatusCallback callback) { + private final ChatController client; + private final int localPort; + private final ThreadStatusCallback callBackToController; - this.callBackToController = callback; + private final String threadNickName = "AirScout msg"; + + private DatagramSocket socket; + + public ReadUDPbyAirScoutMessageThread( + int localPort, + ChatController client, + ThreadStatusCallback callback + ) { this.localPort = localPort; this.client = client; - this.ASIdentificator = ASIdentificator; - this.ChatClientIdentificator = ChatClientIdentificator; + this.callBackToController = callback; } @Override public void interrupt() { - System.out.println("ReadUDP"); super.interrupt(); - try { - if (this.socket != null) { - - this.socket.close(); - } - } catch (Exception e) { - // TODO Auto-generated catch block - e.printStackTrace(); + + if (socket != null && !socket.isClosed()) { + socket.close(); } } - private void callThreadStateToUi (ThreadStateMessage threadStateMessage) { - if (callBackToController != null) { - //update the visual control of running thread - callBackToController.onThreadStatus("AirScout", threadStateMessage); - } - } + /** + * Checks whether an AirScout response is addressed to the currently + * configured server and client identifiers. + * + * Outgoing message: + * ASSETPATH: "client" "server" ... + * + * Corresponding response: + * ASNEAREST: "server" "client" ... + * + * The comparison is deliberately case-sensitive. In a setup with several + * clients, KST-A and kst-a must not silently become the same destination. + * + * @param message received UDP message + * @return true if the response belongs to this KST4Contest instance + */ + private boolean isMessageForConfiguredClient(String message) { + if (message == null || !message.startsWith("ASNEAREST:")) { + return false; + } - + String[] quotedParts = message.split("\""); + if (quotedParts.length < 4) { + return false; + } + + String receivedServerIdentifier = quotedParts[1].trim(); + String receivedClientIdentifier = quotedParts[3].trim(); + + String configuredServerIdentifier = + client.getChatPreferences() + .getAirScout_asServerNameString(); + String configuredClientIdentifier = + client.getChatPreferences() + .getAirScout_asClientNameString(); + + if (configuredServerIdentifier == null + || configuredClientIdentifier == null) { + return false; + } + + return configuredServerIdentifier.equals( + receivedServerIdentifier + ) && configuredClientIdentifier.equals( + receivedClientIdentifier + ); + } + + @Override public void run() { - Thread.currentThread().setName("ReadUDPByAirScoutThread"); - - DatagramSocket socket = null; - boolean running; - byte[] buf = new byte[1777]; - DatagramPacket packet; -// DatagramPacket packet = new DatagramPacket(buf, buf.length); //changed due to save memory - packet = new DatagramPacket(buf, buf.length); + Thread.currentThread().setName( + "ReadUDPByAirScoutThread" + ); try { - socket = new DatagramSocket(null); socket.setReuseAddress(true); socket.bind(new InetSocketAddress(localPort)); - socket.receive(packet); socket.setSoTimeout(3000); - } catch (SocketException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - - } catch (IOException e) { - // TODO Auto-generated catch block -// e.printStackTrace(); - } - - - while (true) { -// packet = new DatagramPacket(buf, buf.length); -// DatagramPacket packet = new DatagramPacket(SRPDefinitions.BYTE_BUFFER_MAX_LENGTH); - try { - if (this.client.isDisconnectionPerformedByUser()) { - break;//TODO: what if it´s not the finally closage but a band channel change? + while (!Thread.currentThread().isInterrupted()) { + if (client.isDisconnectionPerformedByUser()) { + break; } - - socket.receive(packet); + byte[] buffer = new byte[1777]; + DatagramPacket packet = new DatagramPacket( + buffer, + buffer.length + ); - - - - - } catch (SocketTimeoutException e2) { - // this will catch the repeating Sockettimeoutexception...nothing to do -// e2.printStackTrace(); - } - catch (IOException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - - InetAddress address = packet.getAddress(); - int port = packet.getPort(); -// packet = new DatagramPacket(buf, buf.length, address, port); - String received = new String(packet.getData(), packet.getOffset(), packet.getLength()); - received = received.trim(); - - if (received.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) { - System.out.println("ReadUdpByASMsgTh, Info: got poison, now dieing...."); try { - terminateConnection(); - } catch (Exception e) { - System.out.println("ASUDPRDR: catched error " + e.getMessage()); - } - break; - } - - - if (received.contains("ASSETPATH") || received.contains("ASWATCHLIST")) { - // do nothing, that is your own message - } else if (received.contains("ASNEAREST:")) { //answer by airscout - -// processASUDPMessage(received); //TODO: 2025-11-Zeile deaktiviert. Fand hier Doppelberechnung statt?! - - AirPlaneReflectionInfo apReflectInfoForChatMember; - - apReflectInfoForChatMember = processASUDPMessage(received); - if (!this.client.getLst_chatMemberList().isEmpty()) { - - try { - -// 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"); - - /** - * CK| MSGBUS BGFX Listactualizer Exception in thread "Thread-10" - * java.util.ConcurrentModificationException at - * java.base/java.util.AbstractList$Itr.checkForComodification(AbstractList.java:399) - * at java.base/java.util.AbstractList$Itr.next(AbstractList.java:368) at - * kst4contest.controller.ChatController.checkListForChatMemberIndexByCallSign(ChatController.java:173) - * at - * kst4contest.controller.ReadUDPbyAirScoutMessageThread.run(ReadUDPbyAirScoutMessageThread.java:93) - * - */ -// System.out.println("[ReadUdpByASth, AP-Info catched: ] " + apReflectInfoForChatMember.toString()); -// } - } catch (Exception e) { - - System.out.println("ReadUdpByAsMsgTh, Warning:" - + apReflectInfoForChatMember.getReceiver().getCallSign() - + " is not in the Chatmemberlist or the Chatmemberlist is modified by another 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); + socket.receive(packet); + } catch (SocketTimeoutException timeoutException) { + /* + * AirScout may remain silent for some time. Continue with a + * new packet instead of processing the previous packet again. + */ + continue; } - } -// packet = null; //reset packet - buf = new byte[1777]; // reset buffer for future smaller packets + String received = new String( + packet.getData(), + packet.getOffset(), + packet.getLength() + ).trim(); + if (received.contains( + ApplicationConstants.DISCONNECT_RDR_POISONPILL + )) { + System.out.println( + "[AirScout UDP, info]: Received shutdown packet." + ); + break; + } + + /* + * The socket remains bound so that AirScout can be enabled + * without reconnecting. Disabled means that received data is + * discarded and no station state is changed. + */ + if (!client.getChatPreferences() + .isAirScout_asUDPListenerEnabled()) { + continue; + } + + if (!isMessageForConfiguredClient(received)) { + continue; + } + + processAirScoutResponse(received); + } + } catch (SocketException exception) { + if (!Thread.currentThread().isInterrupted()) { + System.out.println( + "[AirScout UDP, error]: Could not use UDP port " + + localPort + + ": " + + exception.getMessage() + ); + } + } catch (IOException exception) { + if (!Thread.currentThread().isInterrupted()) { + System.out.println( + "[AirScout UDP, error]: Communication failed: " + + exception.getMessage() + ); + } + } finally { + if (socket != null && !socket.isClosed()) { + socket.close(); + } + + socket = null; } - } + /** + * Parses an AirScout response and applies it to every active category + * instance of the reported station. + * + * @param received received ASNEAREST message + */ + private void processAirScoutResponse(String received) { + try { + AirPlaneReflectionInfo reflectionInfo = + processASUDPMessage(received); + + if (reflectionInfo == null + || reflectionInfo.getReceiver() == null) { + return; + } + + String receiverCallSign = + reflectionInfo.getReceiver().getCallSignRaw(); + + if (receiverCallSign == null + || receiverCallSign.isBlank()) { + receiverCallSign = + reflectionInfo.getReceiver().getCallSign(); + } + + if (receiverCallSign == null + || receiverCallSign.isBlank()) { + return; + } + + List matchingMembers = + client.findActiveChatMembersByRawCall( + receiverCallSign + ); + + for (ChatMember matchingMember : matchingMembers) { + matchingMember.setAirPlaneReflectInfo( + reflectionInfo + ); + } + + if (!matchingMembers.isEmpty()) { + client.getScoreService().requestRecompute( + "airscout-update" + ); + } + + if (callBackToController != null) { + ThreadStateMessage threadStateMessage = + new ThreadStateMessage( + threadNickName, + true, + "Received AirScout response\n" + + reflectionInfo, + false + ); + + callBackToController.onThreadStatus( + threadNickName, + threadStateMessage + ); + } + } catch (RuntimeException exception) { + System.out.println( + "[AirScout UDP, warning]: Could not process response: " + + exception.getMessage() + ); + } + } public AirPlaneReflectionInfo processASUDPMessage(String udpStringToProcess) { // System.out.println("RDUDPAS RECV: " + udpStringToProcess); - // TODO: filter messages which are directed to another client + /* * Example mesage: ASNEAREST: "AS" "KST" @@ -304,11 +365,8 @@ public class ReadUDPbyAirScoutMessageThread extends Thread { } public boolean terminateConnection() { - - try { - this.socket.close(); - } catch (Exception e) { - System.out.println("udpbyas: catched " + e.getMessage()); + if (socket != null && !socket.isClosed()) { + socket.close(); } return true; diff --git a/src/main/java/kst4contest/model/ChatPreferences.java b/src/main/java/kst4contest/model/ChatPreferences.java index 26f947a..b3d8cc7 100644 --- a/src/main/java/kst4contest/model/ChatPreferences.java +++ b/src/main/java/kst4contest/model/ChatPreferences.java @@ -50,7 +50,7 @@ public class ChatPreferences { * 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; + public static final int CONFIG_VERSION = 4; // Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility. private static final String TAG_CONFIG_VERSION = "configVersion"; @@ -240,9 +240,11 @@ public class ChatPreferences { /** * AirScout prefs */ - boolean AirScout_asUDPListenerEnabled; - String AirScout_asServerNameString, AirScout_asClientNameString, AirScout_asBandString; - int AirScout_asCommunicationPort; + boolean AirScout_asUDPListenerEnabled = true; + String AirScout_asServerNameString = "AS"; + String AirScout_asClientNameString = "KST"; + String AirScout_asBandString = "1440000"; + int AirScout_asCommunicationPort = 9872; /** * Notification prefs @@ -265,6 +267,9 @@ public class ChatPreferences { boolean notify_DXClusterServerTriggerBearing; boolean notify_DXClusterServerTriggerOnQRGDetect; + ObservableList + lstNotify_QSOSniffer_sniffedCallSignList = + FXCollections.observableArrayList(); // ObservableList lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList(); ObservableList lstNotify_QSOSniffer_sniffedWordsList = FXCollections.observableArrayList(); ObservableList lstNotify_QSOSniffer_sniffedPrefixLocList = FXCollections.observableArrayList(); @@ -453,6 +458,11 @@ public class ChatPreferences { this.stn_on4kstServersDns = stn_on4kstServersDns; } + public ObservableList + getLstNotify_QSOSniffer_sniffedCallSignList() { + return lstNotify_QSOSniffer_sniffedCallSignList; + } + public ObservableList getLstNotify_QSOSniffer_sniffedWordsList() { return lstNotify_QSOSniffer_sniffedWordsList; } @@ -799,8 +809,16 @@ public class ChatPreferences { return notify_dxclusterServerPort; } - public void setNotify_dxclusterServerPort(int notify_dxclusterServerPort) { - this.notify_dxclusterServerPort = notify_dxclusterServerPort; + public void setNotify_dxclusterServerPort( + int notify_dxclusterServerPort + ) { + if (notify_dxclusterServerPort < 1 + || notify_dxclusterServerPort > 65535) { + this.notify_dxclusterServerPort = 8000; + } else { + this.notify_dxclusterServerPort = + notify_dxclusterServerPort; + } } public double[] getGUIscn_ChatwindowMainSceneSizeHW() { @@ -921,35 +939,100 @@ public class ChatPreferences { return stn_loginCallSignRaw; } + + /** + * Normalizes an AirScout routing identifier. + * + * AirScout encloses the identifiers in quotation marks. Empty identifiers, + * quotation marks and line breaks would therefore produce an invalid protocol + * message and are replaced with the supplied default value. + * + * @param identifier configured identifier + * @param defaultIdentifier fallback value + * @return normalized identifier + */ + private String normalizeAirScoutIdentifier( + String identifier, + String defaultIdentifier + ) { + if (identifier == null) { + return defaultIdentifier; + } + + String normalizedIdentifier = identifier.trim(); + + if (normalizedIdentifier.isEmpty() + || normalizedIdentifier.contains("\"") + || normalizedIdentifier.contains("\r") + || normalizedIdentifier.contains("\n")) { + return defaultIdentifier; + } + + return normalizedIdentifier; + } + public String getAirScout_asBandString() { return AirScout_asBandString; } public void setAirScout_asBandString(String airScout_asBandString) { - AirScout_asBandString = airScout_asBandString; + if (airScout_asBandString == null) { + AirScout_asBandString = "1440000"; + return; + } + + try { + long parsedBandValue = Long.parseLong( + airScout_asBandString.trim() + ); + + AirScout_asBandString = parsedBandValue > 0 + ? Long.toString(parsedBandValue) + : "1440000"; + } catch (NumberFormatException exception) { + AirScout_asBandString = "1440000"; + } } public String getAirScout_asServerNameString() { return AirScout_asServerNameString; } - public void setAirScout_asServerNameString(String airScout_asServerNameString) { - AirScout_asServerNameString = airScout_asServerNameString; + public void setAirScout_asServerNameString( + String airScout_asServerNameString + ) { + AirScout_asServerNameString = normalizeAirScoutIdentifier( + airScout_asServerNameString, + "AS" + ); } public String getAirScout_asClientNameString() { return AirScout_asClientNameString; } - public void setAirScout_asClientNameString(String airScout_asClientNameString) { - AirScout_asClientNameString = airScout_asClientNameString; + public void setAirScout_asClientNameString( + String airScout_asClientNameString + ) { + AirScout_asClientNameString = normalizeAirScoutIdentifier( + airScout_asClientNameString, + "KST" + ); } public int getAirScout_asCommunicationPort() { return AirScout_asCommunicationPort; } - public void setAirScout_asCommunicationPort(int airScout_asCommunicationPort) { + public void setAirScout_asCommunicationPort( + int airScout_asCommunicationPort + ) { + if (airScout_asCommunicationPort < 1 + || airScout_asCommunicationPort > 65535) { + AirScout_asCommunicationPort = 9872; + return; + } + AirScout_asCommunicationPort = airScout_asCommunicationPort; } @@ -957,10 +1040,13 @@ public class ChatPreferences { return AirScout_asUDPListenerEnabled; } - public void setAirScout_asUDPListenerEnabled(boolean airScout_asUDPListenerEnabled) { + public void setAirScout_asUDPListenerEnabled( + boolean airScout_asUDPListenerEnabled + ) { AirScout_asUDPListenerEnabled = airScout_asUDPListenerEnabled; } + public String getChatState() { return chatState; } @@ -1667,6 +1753,18 @@ public class ChatPreferences { snifferWords.appendChild(temp); } + Element snifferCallSigns = + doc.createElement("snifferCallSigns"); + rootElement.appendChild(snifferCallSigns); + + for (String callSign + : lstNotify_QSOSniffer_sniffedCallSignList) { + Element temp = doc.createElement("callSign"); + temp.setTextContent(callSign); + snifferCallSigns.appendChild(temp); + } + + Element snifferPrefixes = doc.createElement("snifferPrefixes"); rootElement.appendChild(snifferPrefixes); @@ -2265,7 +2363,13 @@ public class ChatPreferences { 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"); + setNotify_dxclusterServerPort( + getInt( + notificationsEl, + notify_dxclusterServerPort, + "notify_dxclusterServerPort" + ) + ); String spotter = getText(notificationsEl, null, "notify_DXCSrv_SpottersCallSign"); if (spotter != null) { @@ -2316,15 +2420,58 @@ public class ChatPreferences { 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"); + setAirScout_asUDPListenerEnabled( + getBoolean( + airScoutEl, + AirScout_asUDPListenerEnabled, + "asQry_airScoutCommunicationEnabled" + ) + ); + + setAirScout_asServerNameString( + getText( + airScoutEl, + AirScout_asServerNameString, + "asQry_airScoutServerName" + ) + ); + + setAirScout_asClientNameString( + getText( + airScoutEl, + AirScout_asClientNameString, + "asQry_airScoutClientName" + ) + ); + + setAirScout_asCommunicationPort( + getInt( + airScoutEl, + AirScout_asCommunicationPort, + "asQry_airScoutUDPPort" + ) + ); + + setAirScout_asBandString( + getText( + airScoutEl, + AirScout_asBandString, + "asQry_airScoutBandValue" + ) + ); System.out.println( - "[ChatPreferences, info]: AirScout querier enabled=" + AirScout_asUDPListenerEnabled - + ", band=" + AirScout_asBandString); + "[ChatPreferences, info]: AirScout integration enabled=" + + AirScout_asUDPListenerEnabled + + ", server identifier=" + + AirScout_asServerNameString + + ", client identifier=" + + AirScout_asClientNameString + + ", port=" + + AirScout_asCommunicationPort + + ", band=" + + AirScout_asBandString + ); } /** @@ -2374,6 +2521,45 @@ public class ChatPreferences { /** * Case QSO-sniffer lists (added later; older configs won't have them) */ + + list = doc.getElementsByTagName("snifferCallSigns"); + + if (list != null && list.getLength() != 0) { + lstNotify_QSOSniffer_sniffedCallSignList.clear(); + + for (int temp = 0; temp < list.getLength(); temp++) { + Node node = list.item(temp); + + if (node.getNodeType() == Node.ELEMENT_NODE) { + NodeList children = node.getChildNodes(); + + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + + if (child.getNodeType() + == Node.ELEMENT_NODE) { + String callSign = + child.getTextContent(); + + if (callSign != null + && !callSign.isBlank()) { + String normalizedCallSign = + callSign + .trim() + .toUpperCase(); + + if (!lstNotify_QSOSniffer_sniffedCallSignList + .contains(normalizedCallSign)) { + lstNotify_QSOSniffer_sniffedCallSignList + .add(normalizedCallSign); + } + } + } + } + } + } + } + list = doc.getElementsByTagName("snifferWords"); if (list != null && list.getLength() != 0) { // reset to avoid duplicates when reloading diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index 65badca..9042978 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -4407,57 +4407,93 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL 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")); + TableView table = new TableView<>(); + table.setTooltip(new Tooltip( + "Messages sent by or addressed to a monitored " + + "callsign are also shown in the PM table." + )); + TableColumn callSignColumn = + new TableColumn<>("Monitored callsign"); - TableColumn callSignCol = new TableColumn("Sniff QSO of Callsign"); - callSignCol.setCellValueFactory(new Callback, ObservableValue>() { + callSignColumn.setCellValueFactory( + cellData -> + new SimpleStringProperty( + cellData.getValue() + ) + ); - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - callSign.setValue(cellDataFeatures.getValue()); - return callSign; - } - }); - callSignCol.setCellFactory(TextFieldTableCell.forTableColumn()); + callSignColumn.setCellFactory( + TextFieldTableCell.forTableColumn() + ); - callSignCol.setOnEditCommit(new EventHandler>() { - @Override - public void handle(CellEditEvent t) { + callSignColumn.setOnEditCommit(event -> { + int row = event.getTablePosition().getRow(); - String newValue = t.getNewValue().toUpperCase(); //its better as all callsigns in the chat are uppercase + String newValue = + event.getNewValue() == null + ? "" + : event.getNewValue() + .trim() + .toUpperCase(Locale.ROOT); + if (newValue.isBlank()) { + event.getTableView() + .getItems() + .remove(row); + return; + } - t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); + if (!GuiUtils.isCallSignSyntax(newValue)) { + alertWindowEvent( + "Please enter a valid callsign." + ); + event.getTableView().refresh(); + return; + } - if (newValue == "") { // delete lines which had been cleared - t.getTableView().getItems().remove(t.getTablePosition().getRow()); - } else { - if (GuiUtils.isCallSignSyntax(newValue)) { + boolean duplicate = false; - } else { - alertWindowEvent("Please try again with correct callsign syntax"); - t.getTableView().getItems().remove(t.getTablePosition().getRow()); - } - } + for (int i = 0; + i < event.getTableView().getItems().size(); + i++) { - //TODO: Observe logic - add to the filters list! -// flwPane_textSnippets.getChildren().clear(); -// flwPane_textSnippets.getChildren() -// .addAll(buttonFactory(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())); - } - }); + if (i == row) { + continue; + } - tbl_notifyTxtCallSign.getColumns().addAll(callSignCol); + String existing = + event.getTableView() + .getItems() + .get(i); - tbl_notifyTxtCallSign.setEditable(true); -// tbl_notifyTxtCallSign.setItems(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()); //TODO: Init aus Speicher muss noch her + if (existing != null + && existing.equalsIgnoreCase(newValue)) { + duplicate = true; + break; + } + } - return tbl_notifyTxtCallSign; - } // TODO: Callsign sniffer table + if (duplicate) { + alertWindowEvent( + "This callsign is already " + + "in the monitoring list." + ); + event.getTableView().refresh(); + return; + } + + event.getTableView() + .getItems() + .set(row, newValue); + }); + + table.getColumns().add(callSignColumn); + table.setEditable(true); + + return table; + } private TableView initTextSnippetsTable() { @@ -9407,141 +9443,242 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL grdPnlAirScout.setVgap(5); grdPnlAirScout.setHgap(5); - Label lblASEnableUDPMsgbyAS = new Label("Enable Airscout integration (needs AirScout >= V 0.9.9.5)"); - Label lblASServerName = new Label("Servername (AS options>network>UDP Server settings) [AS]:"); - Label lblASChatClientName = new Label("ChatClient name for returning server answers [KST]:"); - Label lblASUdpPort = new Label("Network-UDP-Port for Airscout-Server communication [9872]:"); - Label lblASBandName = new Label("Band-Setting for Airscout-Queries [1440000]:"); + Label lblASEnableUDPMsgbyAS = + new Label( + "Enable AirScout UDP integration " + + "(AirScout 0.9.9.5 or newer)" + ); + + Label lblASServerName = + new Label("AirScout server identifier [AS]:"); + + Label lblASChatClientName = + new Label("KST4Contest client identifier [KST]:"); + + Label lblASUdpPort = + new Label("AirScout UDP port [9872] — reconnect after changing:"); + + Label lblASBandName = + new Label("AirScout band value [1440000 = 144 MHz]:"); CheckBox chkBxEnableUDPMsgbyAS = new CheckBox(); - chkBxEnableUDPMsgbyAS.setSelected(this.chatcontroller.getChatPreferences().isAirScout_asUDPListenerEnabled()); - - chkBxEnableUDPMsgbyAS.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - + chkBxEnableUDPMsgbyAS.setSelected( chatcontroller.getChatPreferences() - .setAirScout_asUDPListenerEnabled(chkBxEnableUDPMsgbyAS.isSelected()); - System.out.println("[Main.java, Info]: AS communication enabled: " + newValue); - } - }); + .isAirScout_asUDPListenerEnabled() + ); + chkBxEnableUDPMsgbyAS.setTooltip( + new Tooltip( + "When disabled, KST4Contest neither sends AirScout queries " + + "nor processes AirScout responses." + ) + ); + chkBxEnableUDPMsgbyAS.selectedProperty().addListener( + (observable, oldValue, newValue) -> + chatcontroller.getChatPreferences() + .setAirScout_asUDPListenerEnabled(newValue) + ); TextField txtFld_asServerNameString = new TextField( - chatcontroller.getChatPreferences().getAirScout_asServerNameString()); + chatcontroller.getChatPreferences() + .getAirScout_asServerNameString() + ); txtFld_asServerNameString.setFocusTraversable(false); - txtFld_asServerNameString.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { -// System.out.println("Textfield on focus"); - // Do nothing until field loses focus, user will enter his frequency - } else { -// if (GuiUtils.isNumeric(txtFldUDPPortforUCX.getText())) { + txtFld_asServerNameString.setTooltip( + new Tooltip( + "Logical identifier of the target AirScout server. " + + "Use different identifiers if several AirScout " + + "servers share the network." + ) + ); + txtFld_asServerNameString.focusedProperty().addListener( + (observable, oldValue, newValue) -> { + if (newValue) { + return; + } - System.out.println( - "[Main.java, Info]: AS server name reset to: " + txtFld_asServerNameString.getText()); - chatcontroller.getChatPreferences() - .setAirScout_asServerNameString(txtFld_asServerNameString.getText()); + String enteredIdentifier = + txtFld_asServerNameString.getText().trim(); -// } else { -// txtFldUDPPortforUCX.setText(txtFldUDPPortforUCX.getText() + " is an invalid Port"); -// } + if (isValidAirScoutIdentifier(enteredIdentifier)) { + chatcontroller.getChatPreferences() + .setAirScout_asServerNameString( + enteredIdentifier + ); + } else { + showUserInputErrorWindow( + "The AirScout server identifier must not be empty " + + "and must not contain quotation marks " + + "or line breaks." + ); + txtFld_asServerNameString.setText( + chatcontroller.getChatPreferences() + .getAirScout_asServerNameString() + ); + } } - } - }); + ); TextField txtFld_asClientNameString = new TextField( - chatcontroller.getChatPreferences().getAirScout_asClientNameString()); + chatcontroller.getChatPreferences() + .getAirScout_asClientNameString() + ); txtFld_asClientNameString.setFocusTraversable(false); - txtFld_asClientNameString.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { -// System.out.println("Textfield on focus"); - // Do nothing until field loses focus, user will enter his frequency - } else { -// if (GuiUtils.isNumeric(txtFldUDPPortforUCX.getText())) { + txtFld_asClientNameString.setTooltip( + new Tooltip( + "Identifier of this KST4Contest instance. Assign a unique " + + "identifier to every client in a multi-client setup." + ) + ); + txtFld_asClientNameString.focusedProperty().addListener( + (observable, oldValue, newValue) -> { + if (newValue) { + return; + } - System.out.println( - "[Main.java, Info]: AS client name reset to: " + txtFld_asClientNameString.getText()); -// chatcontroller.getChatPreferences() -// .setAirScout_asServerNameString(txtFld_asClientNameString.getText()); - chatcontroller.getChatPreferences() - .setAirScout_asClientNameString(txtFld_asClientNameString.getText());//TODO Hotfix caused g1ybb + String enteredIdentifier = + txtFld_asClientNameString.getText().trim(); -// } else { -// txtFldUDPPortforUCX.setText(txtFldUDPPortforUCX.getText() + " is an invalid Port"); -// } + if (isValidAirScoutIdentifier(enteredIdentifier)) { + chatcontroller.getChatPreferences() + .setAirScout_asClientNameString( + enteredIdentifier + ); + } else { + showUserInputErrorWindow( + "The AirScout client identifier must not be empty " + + "and must not contain quotation marks " + + "or line breaks." + ); + txtFld_asClientNameString.setText( + chatcontroller.getChatPreferences() + .getAirScout_asClientNameString() + ); + } } - } - }); + ); TextField txtFld_asUDPPortInt = new TextField( - chatcontroller.getChatPreferences().getAirScout_asCommunicationPort() + ""); + Integer.toString( + chatcontroller.getChatPreferences() + .getAirScout_asCommunicationPort() + ) + ); txtFld_asUDPPortInt.setFocusTraversable(false); - txtFld_asUDPPortInt.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { -// System.out.println("Textfield on focus"); - // Do nothing until field loses focus, user will enter his frequency - } else { - if (GuiUtils.isNumeric(txtFld_asUDPPortInt.getText())) { - - System.out.println("[Main.java, Info]: AS port reset to: " + txtFld_asUDPPortInt.getText()); - chatcontroller.getChatPreferences() - .setAirScout_asCommunicationPort((Integer.parseInt(txtFld_asUDPPortInt.getText()))); - - } else { - txtFld_asUDPPortInt.setText(txtFld_asUDPPortInt.getText() + " is an invalid Port"); + txtFld_asUDPPortInt.setTooltip( + new Tooltip( + "The UDP port must match the AirScout network settings. " + + "Reconnect KST4Contest after changing it." + ) + ); + txtFld_asUDPPortInt.focusedProperty().addListener( + (observable, oldValue, newValue) -> { + if (newValue) { + return; } - } - } - }); + try { + int configuredPort = Integer.parseInt( + txtFld_asUDPPortInt.getText().trim() + ); - TextField txtFld_asQRGInt = new TextField(chatcontroller.getChatPreferences().getAirScout_asBandString() + ""); + if (configuredPort < 1 || configuredPort > 65535) { + throw new NumberFormatException(); + } + + chatcontroller.getChatPreferences() + .setAirScout_asCommunicationPort( + configuredPort + ); + } catch (NumberFormatException exception) { + showUserInputErrorWindow( + "\"" + txtFld_asUDPPortInt.getText() + + "\" is not a valid UDP port. " + + "Enter a value between 1 and 65535." + ); + + txtFld_asUDPPortInt.setText( + Integer.toString( + chatcontroller.getChatPreferences() + .getAirScout_asCommunicationPort() + ) + ); + } + } + ); + + TextField txtFld_asQRGInt = new TextField( + chatcontroller.getChatPreferences() + .getAirScout_asBandString() + ); txtFld_asQRGInt.setFocusTraversable(false); - txtFld_asQRGInt.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { -// System.out.println("Textfield on focus"); - // Do nothing until field loses focus, user will enter his frequency - } else { - if (GuiUtils.isNumeric(txtFld_asQRGInt.getText())) { - - System.out.println("[Main.java, Info]: AS qrg reset to: " + txtFld_asQRGInt.getText()); - chatcontroller.getChatPreferences() - .setAirScout_asBandString(((Integer.parseInt(txtFld_asQRGInt.getText()))) + ""); - ; - - } else { - txtFld_asQRGInt.setText(txtFld_asQRGInt.getText() + " is an invalid bandvalue"); + txtFld_asQRGInt.setTooltip( + new Tooltip( + "AirScout protocol band value, for example 1440000 for " + + "144 MHz or 4320000 for 432 MHz." + ) + ); + txtFld_asQRGInt.focusedProperty().addListener( + (observable, oldValue, newValue) -> { + if (newValue) { + return; } - } - } - }); + try { + long configuredBandValue = Long.parseLong( + txtFld_asQRGInt.getText().trim() + ); - grdPnlAirScout.add(generateLabeledSeparator(100, "Settings for Airscout-network-communication"), 0, 0, 2, 1); + if (configuredBandValue <= 0) { + throw new NumberFormatException(); + } + + chatcontroller.getChatPreferences() + .setAirScout_asBandString( + Long.toString(configuredBandValue) + ); + } catch (NumberFormatException exception) { + showUserInputErrorWindow( + "\"" + txtFld_asQRGInt.getText() + + "\" is not a valid AirScout band value." + ); + + txtFld_asQRGInt.setText( + chatcontroller.getChatPreferences() + .getAirScout_asBandString() + ); + } + } + ); + + Label lblASChangeNote = new Label( + "Server identifier, client identifier and band are applied " + + "immediately. Reconnect after changing the UDP port." + ); + lblASChangeNote.setWrapText(true); + + grdPnlAirScout.add( + generateLabeledSeparator( + 100, + "AirScout UDP communication" + ), + 0, + 0, + 2, + 1 + ); grdPnlAirScout.add(lblASEnableUDPMsgbyAS, 0, 1); grdPnlAirScout.add(chkBxEnableUDPMsgbyAS, 1, 1); grdPnlAirScout.add(lblASServerName, 0, 2); grdPnlAirScout.add(txtFld_asServerNameString, 1, 2); grdPnlAirScout.add(lblASChatClientName, 0, 3); grdPnlAirScout.add(txtFld_asClientNameString, 1, 3); - grdPnlAirScout.add(lblASUdpPort, 0, 4); grdPnlAirScout.add(txtFld_asUDPPortInt, 1, 4); grdPnlAirScout.add(lblASBandName, 0, 5); grdPnlAirScout.add(txtFld_asQRGInt, 1, 5); + grdPnlAirScout.add(lblASChangeNote, 0, 6, 2, 1); VBox vbxAirScout = new VBox(); vbxAirScout.setPadding(new Insets(10, 10, 10, 10)); @@ -9561,9 +9698,21 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // "Switch bands, prefix worked by others alert, direction notifications, notification pattern matchers"); // CheckBox chkBxEnableTRXMsgbyUCX = new CheckBox(); - Label lblNotifyEnableSimpleSounds = new Label("Enable simple audio notifications at: new personal message, new sked in ur dir, other"); - Label lblNotifyEnableCWSounds = new Label("Enable CW callsign spelling for new personal messages"); - Label lblNotifyEnableVoiceSounds = new Label("Enable phonetic callsign spelling for new personal messages"); + Label lblNotifyEnableSimpleSounds = new Label( + "Play notification sounds for pm, " + + "directional opportunities, sked reminders " + + "and band hints" + ); + + Label lblNotifyEnableCWSounds = new Label( + "Spell the sender's callsign in CW " + + "for new private messages" + ); + + Label lblNotifyEnableVoiceSounds = new Label( + "Speak the sender's callsign phonetically " + + "for new private messages" + ); CheckBox chkBxEnableNotifySimpleSounds = new CheckBox(); @@ -9606,211 +9755,296 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - Label lblNotifyEnableDXClusterServer = new Label("Enable DXCluster server"); + Label lblNotifyEnableDXClusterServer = new Label( + "Enable the local DX Cluster server and forward " + + "detected directional opportunities" + ); + + CheckBox chkBxNotifyEnableDXClusterServer = + new CheckBox(); + + chkBxNotifyEnableDXClusterServer.setSelected( + this.chatcontroller + .getChatPreferences() + .isNotify_dxClusterServerEnabled() + ); - CheckBox chkBxNotifyEnableDXClusterServer = new CheckBox(); chkBxNotifyEnableDXClusterServer - .setSelected(this.chatcontroller.getChatPreferences().isLogsynch_ucxUDPWkdCallListenerEnabled()); - chkBxNotifyEnableDXClusterServer.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { -// chk2.setSelected(!newValue); - chatcontroller.getChatPreferences() - .setNotify_dxClusterServerEnabled(chkBxNotifyEnableDXClusterServer.isSelected()); - System.out.println("[Main.java, Info]: setted the DXCluster server to: " - + chatcontroller.getChatPreferences().isNotify_dxClusterServerEnabled()); - } - }); + .selectedProperty() + .addListener( + (observable, oldValue, newValue) -> { + chatcontroller + .getChatPreferences() + .setNotify_dxClusterServerEnabled( + newValue + ); - TextField txtFld_notify_DXclusterServerPortSetting = new TextField(); - txtFld_notify_DXclusterServerPortSetting.setText(this.chatcontroller.getChatPreferences().getNotify_dxclusterServerPort() + ""); + if (chatcontroller + .isConnectedAndLoggedIn()) { + if (newValue) { + chatcontroller + .startDxClusterServerIfEnabled(); + } else { + chatcontroller + .stopDxClusterServer(); + } + } - txtFld_notify_DXclusterServerPortSetting.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { -// System.out.println("Textfield on focus"); - // Do nothing until field loses focus, user will enter his frequency - } else { - if (GuiUtils.isNumeric(txtFld_notify_DXclusterServerPortSetting.getText())) { + System.out.println( + "[Kst4ContestApplication] " + + "DX Cluster server enabled: " + + newValue + ); + } + ); -// chatcontroller.getChatPreferences().setMYQRGasd(txt_ownqrg.getText()); - chatcontroller.getChatPreferences() - .setNotify_dxclusterServerPort((Integer.parseInt(txtFld_notify_DXclusterServerPortSetting.getText()))); - System.out.println("[Main.java, Info]: setted DXCluster server port to: " - + txtFld_notify_DXclusterServerPortSetting.getText()); + TextField txtFld_notify_DXclusterServerPortSetting = + new TextField( + Integer.toString( + this.chatcontroller + .getChatPreferences() + .getNotify_dxclusterServerPort() + ) + ); - } else { - txtFld_notify_DXclusterServerPortSetting.setText(txtFld_notify_DXclusterServerPortSetting.getText() + " is an invalid port value"); + txtFld_notify_DXclusterServerPortSetting + .focusedProperty() + .addListener( + (observable, oldValue, focused) -> { + if (focused) { + return; + } + + String enteredPort = + txtFld_notify_DXclusterServerPortSetting + .getText() + .trim(); + + int previousPort = + chatcontroller + .getChatPreferences() + .getNotify_dxclusterServerPort(); + + try { + int port = + Integer.parseInt(enteredPort); + + if (port < 1 || port > 65535) { + throw new NumberFormatException( + "Port outside valid range" + ); + } + + chatcontroller + .getChatPreferences() + .setNotify_dxclusterServerPort( + port + ); + + txtFld_notify_DXclusterServerPortSetting + .setText( + Integer.toString(port) + ); + + if (port != previousPort + && chatcontroller + .isConnectedAndLoggedIn() + && chatcontroller + .getChatPreferences() + .isNotify_dxClusterServerEnabled()) { + chatcontroller + .restartDxClusterServerIfEnabled(); + } + } catch (NumberFormatException exception) { + showUserInputErrorWindow( + "\"" + + enteredPort + + "\" is not a valid TCP port. " + + "Enter a value from 1 to 65535." + ); + + txtFld_notify_DXclusterServerPortSetting + .setText( + Integer.toString( + previousPort + ) + ); + } + } + ); + + TextField txtFld_notify_DXclusterServerFrequencyPrefix = + new TextField( + this.chatcontroller + .getChatPreferences() + .getNotify_optionalFrequencyPrefix() + .getValue() + ); + + txtFld_notify_DXclusterServerFrequencyPrefix + .focusedProperty() + .addListener( + (observable, oldValue, focused) -> { + if (focused) { + return; + } + + String bandInMHz = + txtFld_notify_DXclusterServerFrequencyPrefix + .getText() + .trim(); + + if (bandInMHz.matches( + "[1-9]\\d{1,4}" + )) { + chatcontroller + .getChatPreferences() + .setNotify_optionalFrequencyPrefix( + bandInMHz + ); + + txtFld_notify_DXclusterServerFrequencyPrefix + .setText(bandInMHz); + } else { + showUserInputErrorWindow( + "\"" + + bandInMHz + + "\" is not a valid fallback band. " + + "Enter the MHz part as an integer, " + + "for example 144, 432 or 1296." + ); + + txtFld_notify_DXclusterServerFrequencyPrefix + .setText( + chatcontroller + .getChatPreferences() + .getNotify_optionalFrequencyPrefix() + .getValue() + ); + } + } + ); + + TextField txtFld_notify_DXclusterServerSpottersCallSign = + new TextField( + this.chatcontroller + .getChatPreferences() + .getNotify_DXCSrv_SpottersCallSign() + .getValue() + ); + + txtFld_notify_DXclusterServerSpottersCallSign + .focusedProperty() + .addListener( + (observable, oldValue, focused) -> { + if (focused) { + return; + } + + String spotterCallSign = + txtFld_notify_DXclusterServerSpottersCallSign + .getText() + .trim() + .toUpperCase(Locale.ROOT); + + if (GuiUtils.isCallSignSyntax( + spotterCallSign + )) { + chatcontroller + .getChatPreferences() + .setNotify_DXCSrv_SpottersCallSign( + spotterCallSign + ); + + txtFld_notify_DXclusterServerSpottersCallSign + .setText(spotterCallSign); + } else { + showUserInputErrorWindow( + "\"" + + spotterCallSign + + "\" is not a valid " + + "spotter callsign." + ); + + txtFld_notify_DXclusterServerSpottersCallSign + .setText( + chatcontroller + .getChatPreferences() + .getNotify_DXCSrv_SpottersCallSign() + .getValue() + ); + } + } + ); + + Button btn_notify_clusterServerTestMessage = + new Button("Send test spot"); + + btn_notify_clusterServerTestMessage.setOnAction( + event -> { + var dxClusterServer = + chatcontroller.getDxClusterServer(); + + if (!chatcontroller.isConnectedAndLoggedIn() + || dxClusterServer == null) { + Alert alert = + new Alert(AlertType.INFORMATION); + + alert.setTitle("DX Cluster test"); + alert.setHeaderText( + "Connect KST4Contest and enable " + + "the local DX Cluster server first." + ); + alert.show(); + return; } - } - } - }); + if (!dxClusterServer.hasConnectedClients()) { + Alert alert = + new Alert(AlertType.INFORMATION); - TextField txtFld_notify_DXclusterServerFrequencyPrefix = new TextField(); - txtFld_notify_DXclusterServerFrequencyPrefix.setDisable(false); - txtFld_notify_DXclusterServerFrequencyPrefix.setText(this.chatcontroller.getChatPreferences().getNotify_optionalFrequencyPrefix().getValue() + ""); - - txtFld_notify_DXclusterServerFrequencyPrefix.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { - // Do nothing until field loses focus, user will enter his frequency - } else { - if (GuiUtils.isNumeric(txtFld_notify_DXclusterServerFrequencyPrefix.getText())) { - -// chatcontroller.getChatPreferences().setMYQRGasd(txt_ownqrg.getText()); - chatcontroller.getChatPreferences() - .setNotify_optionalFrequencyPrefix(new SimpleStringProperty(txtFld_notify_DXclusterServerFrequencyPrefix.getText())); - System.out.println("[Main.java, Info]: setted QRG prefix value to: " - + txtFld_notify_DXclusterServerFrequencyPrefix.getText()); - - } else { - showUserInputErrorWindow("\"" + txtFld_notify_DXclusterServerFrequencyPrefix.getText() + "\""+ " is an invalid Band prefix! Try again."); - txtFld_notify_DXclusterServerFrequencyPrefix.setText(chatcontroller.getChatPreferences().getNotify_optionalFrequencyPrefix().getValue()); + alert.setTitle("DX Cluster test"); + alert.setHeaderText( + "No DX Cluster client is connected " + + "to KST4Contest." + ); + alert.setContentText( + "Connect the logger to TCP port " + + chatcontroller + .getChatPreferences() + .getNotify_dxclusterServerPort() + + " and try again." + ); + alert.show(); + return; } - } - } - }); + ChatMember testSpot = new ChatMember(); + testSpot.setFrequency( + new SimpleStringProperty("300") + ); + testSpot.setQra("Congrats, you donated $100"); + testSpot.setCallSign("DO5AMF"); + if (!dxClusterServer + .broadcastSingleDXClusterEntryToLoggers( + testSpot + )) { + Alert alert = + new Alert(AlertType.INFORMATION); - TextField txtFld_notify_DXclusterServerSpottersCallSign = new TextField(); - txtFld_notify_DXclusterServerSpottersCallSign.setDisable(false); - txtFld_notify_DXclusterServerSpottersCallSign.setText(this.chatcontroller.getChatPreferences().getNotify_DXCSrv_SpottersCallSign().getValue()); - - txtFld_notify_DXclusterServerSpottersCallSign.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { - // Do nothing until field loses focus, user will enter his frequency - } else { - if (GuiUtils.isCallSignSyntax(txtFld_notify_DXclusterServerSpottersCallSign.getText())) { - -// chatcontroller.getChatPreferences().setMYQRGasd(txt_ownqrg.getText()); - chatcontroller.getChatPreferences() - .setNotify_DXCSrv_SpottersCallSign(new SimpleStringProperty(txtFld_notify_DXclusterServerSpottersCallSign.getText())); - System.out.println("[Main.java, Info]: setted Spotters Callsign value to: " - + txtFld_notify_DXclusterServerSpottersCallSign.getText()); - - } else { - showUserInputErrorWindow("\"" + txtFld_notify_DXclusterServerSpottersCallSign.getText() + "\""+ " is an invalid CallSign-Value! Try again."); - txtFld_notify_DXclusterServerSpottersCallSign.setText(chatcontroller.getChatPreferences().getNotify_DXCSrv_SpottersCallSign().getValue()); + alert.setTitle("DX Cluster test"); + alert.setHeaderText( + "The test spot could not be delivered." + ); + alert.setContentText( + "Check the logger connection " + + "and try again." + ); + alert.show(); } - } - } - }); - - - Button btn_notify_clusterServerTestMessage = new Button("Send a testmessage to your log"); - btn_notify_clusterServerTestMessage.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - -// 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(dummyCopy); - -// dummy = new ChatMember(); -// dummy.setFrequency(new SimpleStringProperty("144366")); -// dummy.setQra("IO02XX"); -// dummy.setCallSign("G9XYL"); -// -// chatcontroller.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(dummy); -// -// dummy = new ChatMember(); -// dummy.setFrequency(new SimpleStringProperty("144277")); -// dummy.setQra("IO02AA"); -// dummy.setCallSign("G3M"); -// -// chatcontroller.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(dummy); -// -// dummy = new ChatMember(); -// dummy.setFrequency(new SimpleStringProperty("144244")); -// dummy.setQra("JO50KQ"); -// dummy.setCallSign("DL200BIER"); -// -// chatcontroller.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(dummy); - - - } catch (Exception ex) { - - System.out.println("DXC sending failed"); - Alert a = new Alert(AlertType.INFORMATION); - - a.setTitle("Testmessage"); - a.setHeaderText("Something went wrong, maybe you did not click connect first? --> \n\n" + ex.getMessage()); - a.show(); - } - - }; - }); - - Label lblNotifyDXClusterServerTriggerBearing = new Label("Trigger DXCluster message if someone is bearing to me"); - - - - CheckBox chkBxNotifyDXClusterServerTriggerBearing = new CheckBox(); //TODO: maybe implement - chkBxNotifyDXClusterServerTriggerBearing.setSelected(true); -// chkBxNotifyDXClusterServerTriggerBearing.setDisable(true); - - - - Label lblNotifyDXClusterServerTriggerOnEveryQRGDetect = new Label("Trigger DXCluster message on every frequency detect (SPAMMY!)"); - - CheckBox chkBxNotifyDXClusterServerTriggerOnEveryQRGDetect = new CheckBox(); - chkBxNotifyDXClusterServerTriggerOnEveryQRGDetect.setSelected(false); - chkBxNotifyDXClusterServerTriggerOnEveryQRGDetect.setDisable(true); - - chkBxNotifyDXClusterServerTriggerBearing.selectedProperty().addListener(new ChangeListener() { //build a toggle group by hand - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - - chatcontroller.getChatPreferences().setNotify_DXClusterServerTriggerBearing(true); - chkBxNotifyDXClusterServerTriggerOnEveryQRGDetect.setSelected(true); - } else { - chatcontroller.getChatPreferences().setNotify_DXClusterServerTriggerBearing(false); - chkBxNotifyDXClusterServerTriggerOnEveryQRGDetect.setSelected(false); - } - - } - }); - - chkBxNotifyDXClusterServerTriggerOnEveryQRGDetect.selectedProperty().addListener(new ChangeListener() {//build a toggle group by hand part 2 - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - - chatcontroller.getChatPreferences().setNotify_DXClusterServerTriggerOnQRGDetect(true); - chkBxNotifyDXClusterServerTriggerBearing.setSelected(true); - } else { - chatcontroller.getChatPreferences().setNotify_DXClusterServerTriggerOnQRGDetect(false); - chkBxNotifyDXClusterServerTriggerBearing.setSelected(false); - } - - } - }); + ); grdPnlNotify.add(lblNotifyEnableSimpleSounds, 0, 1); @@ -9824,28 +10058,90 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL grdPnlNotify.add(new Label(""), 0, 4); //placeholder before seperator - grdPnlNotify.add(generateLabeledSeparator(100, "KST4Contest DXCluster Server settings (sending detected QRG to your log)"), 0, 5, 2, 1); //seperator for cluster notify - grdPnlNotify.add(lblNotifyEnableDXClusterServer, 0, 6); - grdPnlNotify.add(chkBxNotifyEnableDXClusterServer, 1, 6); + grdPnlNotify.add( + generateLabeledSeparator( + 100, + "Local DX Cluster output" + ), + 0, + 5, + 2, + 1 + ); - grdPnlNotify.add(new Label("DXCluster Server port [default 8000]: "), 0, 7); - grdPnlNotify.add(txtFld_notify_DXclusterServerPortSetting, 1, 7); + grdPnlNotify.add( + lblNotifyEnableDXClusterServer, + 0, + 6 + ); + grdPnlNotify.add( + chkBxNotifyEnableDXClusterServer, + 1, + 6 + ); - grdPnlNotify.add(new Label("DXCluster Band MHz-value if unknown [default 144]: "), 0, 8); - grdPnlNotify.add(txtFld_notify_DXclusterServerFrequencyPrefix, 1, 8); + grdPnlNotify.add( + new Label("TCP port [default: 8000]:"), + 0, + 7 + ); + grdPnlNotify.add( + txtFld_notify_DXclusterServerPortSetting, + 1, + 7 + ); - grdPnlNotify.add(new Label("DXCluster Spotters CallSign [default DO5AMF], should not be the contest callsign!: "), 0, 9); - grdPnlNotify.add(txtFld_notify_DXclusterServerSpottersCallSign, 1, 9); + grdPnlNotify.add( + new Label( + "Fallback band in MHz for relative " + + "frequencies [default: 144]:" + ), + 0, + 8 + ); + grdPnlNotify.add( + txtFld_notify_DXclusterServerFrequencyPrefix, + 1, + 8 + ); + grdPnlNotify.add( + new Label( + "Spotter callsign — use a callsign " + + "different from the contest callsign:" + ), + 0, + 9 + ); + grdPnlNotify.add( + txtFld_notify_DXclusterServerSpottersCallSign, + 1, + 9 + ); - grdPnlNotify.add(btn_notify_clusterServerTestMessage, 1, 10); + Label lblNotifyDXClusterTriggerExplanation = + new Label( + "Spots are generated only for detected " + + "directional opportunities " + + "with a known frequency." + ); - grdPnlNotify.add(lblNotifyDXClusterServerTriggerBearing, 0, 11); - grdPnlNotify.add(chkBxNotifyDXClusterServerTriggerBearing, 1, 11); + lblNotifyDXClusterTriggerExplanation.setWrapText(true); - grdPnlNotify.add(lblNotifyDXClusterServerTriggerOnEveryQRGDetect, 0, 12); - grdPnlNotify.add(chkBxNotifyDXClusterServerTriggerOnEveryQRGDetect, 1, 12); + grdPnlNotify.add( + lblNotifyDXClusterTriggerExplanation, + 0, + 10, + 2, + 1 + ); + + grdPnlNotify.add( + btn_notify_clusterServerTestMessage, + 1, + 11 + ); grdPnlNotify.add(generateLabeledSeparator(100, "Band-upgrade hint (after log entry)"), 0, 13, 2, 1); @@ -9871,31 +10167,100 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // grdPnlNotify.add(generateLabeledSeparator(100, "QSO Sniffing tool"), 0, 14, 2, 1); - grdPnlNotify.add(generateLabeledSeparator(100, "QSO Sniffing tool"), 0, 17, 2, 1); + grdPnlNotify.add( + generateLabeledSeparator( + 100, + "QSO monitoring" + ), + 0, + 17, + 2, + 1 + ); + TableView tblVw_notify_sniffCallSigns = + initNotifyAtCallSignTable(); - TableView tblVw_notify_sniffCallSigns = new TableView(); - tblVw_notify_sniffCallSigns = initNotifyAtCallSignTable(); - tblVw_notify_sniffCallSigns.setItems(this.chatcontroller.getLstNotify_QSOSniffer_sniffedCallSignList()); + 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); - } - }); + Button btn_notifySniffCall_addLine = + new Button("Add monitored callsign"); -// grdPnlNotify.add(tblVw_notify_sniffCallSigns,0,15); -// grdPnlNotify.add(btn_notifySniffCall_addLine, 0, 16); + btn_notifySniffCall_addLine.setOnAction( + event -> { + TextInputDialog dialog = + new TextInputDialog(); - grdPnlNotify.add(tblVw_notify_sniffCallSigns,0,18); - grdPnlNotify.add(btn_notifySniffCall_addLine, 0, 19); + dialog.setTitle("QSO monitoring"); + dialog.setHeaderText( + "Add a callsign to the monitoring list" + ); + dialog.setContentText("Callsign:"); + + dialog.showAndWait().ifPresent( + input -> { + String callSign = + input + .trim() + .toUpperCase( + Locale.ROOT + ); + + if (!GuiUtils.isCallSignSyntax( + callSign + )) { + alertWindowEvent( + "Please enter " + + "a valid callsign." + ); + return; + } + + boolean duplicate = + chatcontroller + .getLstNotify_QSOSniffer_sniffedCallSignList() + .stream() + .anyMatch( + existing -> + existing + .equalsIgnoreCase( + callSign + ) + ); + + if (duplicate) { + alertWindowEvent( + "This callsign is already " + + "in the monitoring list." + ); + return; + } + + chatcontroller + .getLstNotify_QSOSniffer_sniffedCallSignList() + .add(callSign); + } + ); + } + ); + + 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)); - vbxNotify.getChildren().addAll(grdPnlNotify); + vbxNotify.getChildren().add(grdPnlNotify); /************************************************************************************* * shorts & snippets tab @@ -11238,6 +11603,30 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return null; } + + /** + * Checks whether a value can safely be used as an AirScout routing + * identifier. + * + * Spaces are allowed because AirScout encloses the identifier in quotation + * marks. Quotation marks and line breaks would break the protocol message. + * + * @param identifier entered server or client identifier + * @return true if the identifier can be used + */ + private boolean isValidAirScoutIdentifier(String identifier) { + if (identifier == null) { + return false; + } + + String normalizedIdentifier = identifier.trim(); + + return !normalizedIdentifier.isEmpty() + && !normalizedIdentifier.contains("\"") + && !normalizedIdentifier.contains("\r") + && !normalizedIdentifier.contains("\n"); + } + /** * Helper for creating station double preferences textfields * @param initialValue