45 changed files with 4875 additions and 643 deletions
+48 -1
View File
@@ -7,7 +7,9 @@ on:
workflow_dispatch:
permissions:
actions: read
contents: write
issues: read
packages: write
env:
@@ -698,6 +700,9 @@ jobs:
- publish-flatpak-repo
steps:
- name: Checkout release source
uses: actions/checkout@v4.1.7
- name: Download Windows artifact
uses: actions/download-artifact@v4.1.3
with:
@@ -767,4 +772,46 @@ jobs:
release-assets/flatpakref/*.flatpakref,
release-assets/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg,
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf,
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
# The update feed is generated only after GitHub has published the
# release. Otherwise the Releases API cannot return the release notes
# belonging to the tag which triggered this workflow.
- name: Set up Node.js for website build
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: website/package-lock.json
- name: Build and validate website after release publication
working-directory: website
run: |
npm ci
npm test
npm run build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify Stable release in update feed
if: ${{ !startsWith(github.ref_name, 'beta-') }}
working-directory: website
run: npm run validate:version-info
env:
EXPECTED_STABLE_VERSION: ${{ github.ref_name }}
- name: Attach verified version info to tagged release
run: >-
gh release upload "${GITHUB_REF_NAME}"
website/_site/kst4ContestVersionInfo.xml
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload verified website artifact
uses: actions/upload-artifact@v4.3.4
with:
name: kst4contest-website-${{ github.ref_name }}
path: website/_site/
if-no-files-found: error
retention-days: 14
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+1 -1
View File
@@ -107,7 +107,7 @@ Die Flugzeugdaten können direkt in Nachrichten eingefügt werden:
- `FIRSTAP` → z. B. `a very big AP in 1 min`
- `SECONDAP` → z. B. `Next big AP in 9 min`
Details: [Makros und Variablen](Makros-und-Variablen#variablen)
Details: [Makros und Variablen](de-Makros-und-Variablen#variablen)
---
+1 -1
View File
@@ -389,7 +389,7 @@ Die automatische Bandvorauswahl ist eine Herleitung aus vorhandenen Chatinformat
Bedienung: [Stationsinfo-Panel](de-Benutzeroberflaeche#stationsinfo-panel-further-info)
Darstellung: [AP- und Sked-Timeline](#ap-und-sked-timeline)
Darstellung: [AP- und Sked-Timeline](#ap--und-sked-timeline)
Win-Test-Übergabe: [Log-Synchronisation Win-Test](de-Log-Synchronisation#win-test)
+18 -48
View File
@@ -64,43 +64,7 @@ Der Wert sollte zum eigenen Stationsaufbau und zum vorgesehenen Contestbetrieb p
### Streckenanalyse und Link-Budget
Die Streckenanalyse der Stationskarte verwendet einige Angaben aus der Stationskonfiguration. Diese Werte beschreiben das eigene Stationssetup und soweit keine individuellen Daten der Gegenstation vorliegen ein angenommenes Setup der Gegenstation.
Folgende Einstellungen werden berücksichtigt:
- **Own antenna height AGL [m]** gibt die Höhe der eigenen Antenne über dem lokalen Gelände an. Die Angabe bezieht sich auf *Above Ground Level* und nicht auf die Höhe über dem Meeresspiegel. KST4Contest addiert diesen Wert zur Geländehöhe am eigenen Standort.
- **Own TX power [W]** gibt die verwendete Sendeleistung der eigenen Station in Watt an.
- **Own ant. gain [dBi]** gibt den Antennengewinn der eigenen Station in dBi an.
- **DX OM TX power [W]** gibt die für die Gegenstation angenommene Sendeleistung in Watt an.
- **DX OM ant. gain [dBi]** gibt den für die Gegenstation angenommenen Antennengewinn in dBi an.
Für die Antennenhöhe der Gegenstation verwendet KST4Contest derzeit einen festen Wert von 10 m über dem lokalen Gelände. Die Leistungs- und Antennendaten der Gegenstation sind globale Annahmen. Sie ersetzen keine individuell bekannten Stationsdaten, ermöglichen aber eine einheitliche Abschätzung, wenn keine genaueren Informationen vorliegen.
Die Antennengewinne müssen in dBi angegeben werden. Falls ein Wert in dBd vorliegt, kann er näherungsweise wie folgt umgerechnet werden:
`dBi = dBd + 2.15`
Auch die aktuelle Antennenrichtung, die konfigurierte Strahlbreite, das maximale QRB und die für die eigene Station aktivierten Bänder beeinflussen die Darstellung oder Auswertung auf der Stationskarte. Die aktuelle QTF und die Strahlbreite bestimmen beispielsweise den eingezeichneten Antennensektor und die Hervorhebung von Stationen innerhalb dieses Bereichs.
Für die Berücksichtigung der Erdkrümmung verwendet die Streckenanalyse einen festen Faktor von `k = 4/3` für den effektiven Erdradius. Das ist eine übliche Näherung für eine durchschnittliche troposphärische Refraktion. Tatsächliche Ausbreitungsbedingungen können davon deutlich abweichen.
Das Link-Budget berücksichtigt unter anderem:
- die Entfernung zwischen beiden Stationen,
- die verwendete Frequenz,
- die konfigurierte Sendeleistung,
- die Antennengewinne,
- geschätzte Speiseleitungsverluste,
- den Freiraumverlust sowie
- eine grobe Zusatzdämpfung durch Hindernisse im Streckenprofil.
Die daraus berechnete Empfangsleistung und SSB- beziehungsweise CW-Marge sind technische Abschätzungen. Sie sollen dabei helfen, mögliche Verbindungen einzuordnen. Sie sind keine vollständige Feldstärkeprognose und können insbesondere aktuelle Wetterbedingungen, lokale Abschattungen, Mehrwegeausbreitung oder andere nicht bekannte Stationsparameter nicht vollständig berücksichtigen.
---
### Streckenanalyse und Link-Budget
Die Stationskarte verwendet mehrere Werte aus dem Reiter **Station**, um das Geländeprofil und das Link-Budget zur ausgewählten Gegenstation zu berechnen.
Die Stationskarte verwendet mehrere Werte aus dem Reiter **Station**, um das Geländeprofil und das Link-Budget zur ausgewählten Gegenstation zu berechnen. Die Angaben zum eigenen Stationsaufbau sollten deshalb möglichst realistisch sein. Bei der Gegenstation handelt es sich dagegen um globale Annahmen, solange keine genaueren Daten vorliegen.
| Einstellung | Verwendung |
|---|---|
@@ -110,28 +74,34 @@ Die Stationskarte verwendet mehrere Werte aus dem Reiter **Station**, um das Gel
| **DX OM TX power W** | Angenommene Sendeleistung der Gegenstation in Watt |
| **DX OM ant. gain dBi** | Angenommener Antennengewinn der Gegenstation in dBi |
**AGL** bedeutet „above ground level. Trage hier nicht die Höhe über dem Meeresspiegel ein. Die Geländehöhe am eigenen Standort stammt bereits aus dem abgerufenen Höhenprofil; die konfigurierte Antennenhöhe wird zu diesem Wert addiert.
**AGL** bedeutet *Above Ground Level*. Trage hier nicht die Höhe über dem Meeresspiegel ein. Die Geländehöhe am eigenen Standort stammt bereits aus dem Höhenprofil; KST4Contest addiert die konfigurierte Antennenhöhe zu diesem Wert.
Für die Antennenhöhe der Gegenstation verwendet KST4Contest derzeit einen festen Standardwert von 10 Metern über Grund. Eine stationsbezogene Antennenhöhe wird im ON4KST-Chat nicht übertragen.
Für die Gegenstation verwendet KST4Contest derzeit eine feste Antennenhöhe von 10 Metern über dem lokalen Gelände. Sendeleistung und Antennengewinn der Gegenstation stammen aus den beiden **DX OM**-Feldern. Diese Werte sind bewusst nur Annahmen: Der ON4KST-Chat überträgt weder die tatsächliche Antennenhöhe noch die vollständigen Stationsdaten der Gegenstation.
Antennengewinne müssen in `dBi` eingetragen werden. Liegt ein Wert in `dBd` vor, gilt:
Antennengewinne müssen in `dBi` eingetragen werden. Liegt ein Wert in `dBd` vor, muss vor der Eingabe `2.15 dB` addiert werden:
```text
dBi = dBd + 2,15 dB
dBi = dBd + 2.15 dB
```
Die aktuelle QTF, der konfigurierte Antennen-Öffnungswinkel und das Standard-Maximum-QRB beeinflussen die Darstellung der Stationskarte. Die für die eigene Station aktivierten Bänder und die für die Gegenstation hergeleitete Frequenz wirken sich zusätzlich auf die Auswahl der Analysefrequenz aus.
Für das Geländeprofil berücksichtigt KST4Contest die Antennenhöhen, das Höhenmodell und die Erdkrümmung mit einem festen effektiven Erdradiusfaktor von `k = 4/3`. Das Link-Budget verwendet außerdem:
## Server-Einstellungen (ab v1.31)
- die Entfernung und die aktuelle Analysefrequenz,
- die Sendeleistungen und Antennengewinne beider Stationen,
- frequenzabhängig geschätzte Speiseleitungsverluste,
- die Freiraumdämpfung und
- eine grobe zusätzliche Dämpfung durch das maßgebliche Hindernis im Geländeprofil.
Der Chat-Server-DNS und -Port sind in den Preferences konfigurierbar:
Die Berechnung erfolgt für beide Übertragungsrichtungen. Für die gemeinsame SSB- beziehungsweise CW-Marge ist die ungünstigere Richtung maßgeblich. Zu optimistische Leistungs- oder Antennenwerte verbessern daher zwar das angezeigte Ergebnis, nicht aber den realen Funkweg.
- **Server-DNS**: Standard `www.on4kst.org` (ab v1.31 geändert von `www.on4kst.info`).
- **Port**: Standardport des ON4KST-Servers.
Eine Änderung ist nur notwendig, wenn der Server umzieht oder ein alternativer Endpunkt genutzt wird.
Die Werte bleiben technische Abschätzungen. Aktuelle Ausbreitungsbedingungen, lokale Abschattungen, Bewuchs, Gebäude, Störungen und nicht bekannte Stationsparameter können das tatsächliche Ergebnis deutlich verändern. Bedienung, Frequenzauswahl und Grenzen der Berechnung sind unter [Stationskarte und Streckenanalyse](de-Funktionen#stationskarte-und-streckenanalyse-ab-v141) beschrieben.
---
## Log-Sync-Einstellungen
Drei Methoden stehen zur Verfügung, um gearbeitete Stationen automatisch zu markieren. Details: [Log-Synchronisation](de-Log-Synchronisation).
@@ -283,7 +253,7 @@ Doppelte oder syntaktisch ungültige Rufzeichen werden nicht übernommen. Die Li
## Shortcut Settings (Schnellzugriff-Schaltflächen)
Konfiguration von Schnellzugriff-Schaltflächen, die direkt im Hauptfenster erscheinen. Ein Klick auf eine Schaltfläche fügt den konfigurierten Text in das Sendfeld ein. Alle [Variablen](Makros-und-Variablen#variablen) können verwendet werden.
Konfiguration von Schnellzugriff-Schaltflächen, die direkt im Hauptfenster erscheinen. Ein Klick auf eine Schaltfläche fügt den konfigurierten Text in das Sendfeld ein. Alle [Variablen](de-Makros-und-Variablen#variablen) können verwendet werden.
---
+1 -1
View File
@@ -107,7 +107,7 @@ Aircraft data can be inserted directly into messages:
- `FIRSTAP` → e.g. `a very big AP in 1 min`
- `SECONDAP` → e.g. `Next big AP in 9 min`
Details: [Macros and Variables](Macros-and-Variables#variables)
Details: [Macros and Variables](en-Macros-and-Variables#variables)
---
+51 -35
View File
@@ -40,7 +40,7 @@ After a change, click **Save Settings** and restart KST4Contest. Band columns an
### Antenna Beamwidth
Enter a realistic value for your antenna's beamwidth (in degrees). This value is used for the [Sked Direction Highlighting](Features#sked-direction-highlighting). A test value of 50° has proven effective; DM5M uses quads with 69°.
Enter a realistic value for your antenna's beamwidth (in degrees). This value is used for the [Sked Direction Highlighting](en-Features#sked-direction-highlighting). A test value of 50° has proven effective; DM5M uses quads with 69°.
> **Do not** enter fantasy values the direction calculations will become useless.
@@ -50,66 +50,82 @@ Maximum distance (in km) for which direction warnings should be triggered. A rea
### Path Analysis and Link Budget
The station map uses several values from the station settings for its path analysis. These values describe the local station setup and, where no individual information is available, an assumed setup for the remote station.
The station map uses several values from the **Station** tab to calculate the terrain profile and link budget for the selected remote station. Enter the local station data as realistically as possible. The remote-station values remain global assumptions unless more accurate information is available.
The following settings are used:
| Setting | Use |
|---|---|
| **Own antenna height AGL** | Height of the local antenna above the surrounding terrain in metres |
| **Own TX power W** | Local transmit power in watts |
| **Own ant. gain dBi** | Gain of the local antenna in dBi |
| **DX OM TX power W** | Assumed transmit power of the remote station in watts |
| **DX OM ant. gain dBi** | Assumed antenna gain of the remote station in dBi |
- **Own antenna height AGL [m]** specifies the height of the local antenna above the surrounding terrain. The value is *Above Ground Level*, not height above sea level. KST4Contest adds it to the terrain elevation at the local station.
- **Own TX power [W]** specifies the transmit power of the local station in watts.
- **Own ant. gain [dBi]** specifies the antenna gain of the local station in dBi.
- **DX OM TX power [W]** specifies the transmit power assumed for the remote station.
- **DX OM ant. gain [dBi]** specifies the antenna gain assumed for the remote station.
**AGL** means *Above Ground Level*. Do not enter the height above sea level. The terrain elevation at the local station already comes from the elevation profile; KST4Contest adds the configured antenna height to this value.
KST4Contest currently uses a fixed antenna height of 10 metres above the local terrain for the remote station. The remote power and antenna-gain values are global assumptions. They cannot replace individually known station data, but they provide a consistent basis when no better information is available.
For the remote station, KST4Contest currently uses a fixed antenna height of 10 metres above the local terrain. Its transmit power and antenna gain come from the two **DX OM** fields. These values are deliberately assumptions: ON4KST provides neither the actual antenna height nor the complete station data of the remote operator.
Antenna gains must be entered in dBi. If a value is specified in dBd, it can be converted approximately as follows:
Antenna gains must be entered in `dBi`. Add `2.15 dB` before entering a value specified in `dBd`:
`dBi = dBd + 2.15`
```text
dBi = dBd + 2.15 dB
```
The current antenna direction, configured beamwidth, maximum QRB and bands enabled for the local station also affect the map display or its assessment. The current QTF and beamwidth, for example, determine the displayed antenna sector and the highlighting of stations inside that sector.
The current QTF, configured antenna beamwidth and default maximum QRB affect the map display. The bands enabled for the local station and the frequency derived for the remote station also affect the selection of the analysis frequency.
The path analysis uses a fixed effective Earth-radius factor of `k = 4/3`. This is a common approximation for average tropospheric refraction. Actual propagation conditions can differ considerably.
For the terrain profile, KST4Contest uses the antenna heights, elevation model and Earth curvature with a fixed effective Earth-radius factor of `k = 4/3`. The link budget additionally includes:
The link budget takes into account:
- the distance between both stations,
- the analysis frequency,
- the configured transmit powers,
- the antenna gains,
- estimated feeder losses,
- distance and the current analysis frequency,
- transmit powers and antenna gains in both directions,
- frequency-dependent estimated feeder losses,
- free-space path loss, and
- a rough additional loss caused by obstructions in the terrain profile.
- a rough additional loss caused by the relevant obstruction in the terrain profile.
The resulting received power and SSB or CW margins are technical estimates. They help to classify a possible path, but they are not a complete field-strength prediction. Current weather conditions, local obstructions, multipath propagation and unknown station parameters cannot be represented completely.
The calculation is performed in both directions. The weaker direction determines the common SSB or CW margin. Optimistic power or antenna values therefore improve the displayed result, but they do not improve the real radio path.
The results remain engineering estimates. Current propagation conditions, local obstructions, vegetation, buildings, interference and unknown station parameters can change the actual result considerably. Operation, frequency selection and the limits of the calculation are described under [Station Map and Path Analysis](en-Features#station-map-and-path-analysis-from-v141).
---
## Server Settings (from v1.31)
The chat server DNS and port are configurable in the Preferences:
The connection details for the ON4KST chat server are shown at the top of the **Station** tab. They normally do not need to be changed.
- **Server DNS**: Default `www.on4kst.org` (changed from `www.on4kst.info` in v1.31 hotfix).
- **Port**: Default port of the ON4KST server.
| Setting | Meaning |
|---|---|
| **ON4KST server** | DNS name or IP address of the chat server; the software default is `www.on4kst.org` |
| **Port** | TCP port of the chat server; the default is `23001` |
A change is only needed if the server moves or an alternative endpoint is used.
`www.on4kst.info` may also remain in an existing, working configuration. There is no reason to change it merely because the displayed name differs from the software default, provided that the chat connection remains reliable.
These values apply only to the outgoing ON4KST chat connection. They do not change the local DX cluster server or any connection to AirScout, PSTRotator or logging software.
The port must be between `1` and `65535`. An invalid entry is rejected and replaced with the last valid value. An incorrect server name or port, on the other hand, prevents KST4Contest from connecting to the chat.
Changing either value does not alter an existing TCP connection. Disconnect and reconnect the chat, or restart KST4Contest. Then use **Save Settings** so that the values are retained for the next program start.
---
## Log Sync Settings
Three methods are available for automatically marking worked stations. Details: [Log Synchronisation](en-Log-Sync).
The **Log sync** tab selects the sources from which KST4Contest imports worked stations. The three input paths provide different levels of detail:
### Universal File Based Callsign Interpreter (Simplelogfile)
![Log synchronisation settings](client_settings_window_logsync.png)
Interprets any log file using regex for callsign patterns. No band information is available. Suitable as a fallback or for log programs that are not directly supported.
| Input path | Data used | Result in KST4Contest |
|---|---|---|
| **Simplelogfile** | Callsigns read from a selected file | global Worked status, but no band or locator information |
| **General QSO UDP listener** | QSO packets from UCXLog, QARTest, N1MM+ and DXLog.net | global and per-band Worked status plus worked grid square where both band and locator are transmitted |
| **Win-Test network listener** | native Win-Test network packets | global and per-band Worked status, locator information and, depending on the settings, QRG synchronisation and sked handover |
### Network Listener for Logger's QSO UDP Broadcast
The file-based interpreter is mainly useful when the logging application provides no supported network interface. A callsign match alone, however, contains neither a reliable band nor a locator. Use one of the network listeners wherever possible if per-band information is required.
**Recommended method.** KST4Contest listens for UDP packets sent by the logging software to the broadcast address when a QSO is saved. Stations are marked with band information. UDP port: default **12060**. (Used by UCXLog, N1MM+, QARTest, DXLog.net, etc.).
The general QSO UDP listener is the recommended interface for UCXLog, QARTest, N1MM+ and DXLog.net. QSO and `RadioInfo` packets use the same configurable UDP port; the default is `12060`. Separate options in **Log sync** and **TRX sync** determine whether the received QSO and frequency information is processed.
### Win-Test Network Listener (Additional UDP Listener)
Win-Test uses its own network protocol and therefore has a separate listener. Its default port is `9871`. If this port is changed while the listener is enabled, KST4Contest restarts the Win-Test listener on the new port. After changing the shared UDP port `12060`, KST4Contest must instead be restarted completely.
A dedicated network listener for Win-Test. KST4Contest receives and processes Win-Test-specific UDP packets (including sked handovers) on the configured port.
All enabled input paths may be used in parallel. Their Worked information is merged into the same internal database; identical reports do not create separate Worked states. KST4Contest must be running when a QSO is saved unless the logging application can resend the existing log.
Configuration of the individual logging applications, band and locator handling, and the Win-Test sked handover are described under [Log Synchronisation](en-Log-Sync).
---
@@ -188,7 +204,7 @@ The two options serve different purposes:
The Priority Boost is only one factor in the complete calculation. Distance, antenna direction, recent activity, AirScout data, skeds and negative hints may still change the final order. Enabling the option therefore guarantees neither a particular score nor a particular position in the priority list.
The other score weights currently have no separate user-interface controls. Several existing settings nevertheless provide input data for the calculation, particularly the [enabled bands](#enabled-bands), [antenna beamwidth](#antenna-beamwidth), [default maximum QRB](#default-maximum-qrb) and [AirScout settings](#airscout-settings).
The other score weights currently have no separate user-interface controls. Several existing settings nevertheless provide input data for the calculation, particularly the [active bands](#active-bands), [antenna beamwidth](#antenna-beamwidth), [default maximum QRB](#default-maximum-qrb) and [AirScout settings](#airscout-settings).
The complete calculation is described under [Priority Score and Priority List](en-Features#priority-score-and-priority-list-from-v140).
@@ -201,7 +217,7 @@ Further explanation: [Band Upgrade Hint after a Log Entry](en-Features#band-upgr
## Shortcut Settings
Configuration of quick-access buttons that appear directly in the main window. Clicking a button inserts the configured text into the send field. All [variables](Macros-and-Variables#variables) can be used.
Configuration of quick-access buttons that appear directly in the main window. Clicking a button inserts the configured text into the send field. All [variables](en-Macros-and-Variables#variables) can be used.
---
+3 -3
View File
@@ -28,7 +28,7 @@ The calculation is based on the following logic:
The calculation does not include topographic path calculations this is a deliberate simplification. It may be added in a future version.
> Configuration: [Configuration Antenna Beamwidth](Configuration#antenna-beamwidth)
> Configuration: [Configuration Antenna Beamwidth](en-Configuration#antenna-beamwidth)
---
@@ -443,7 +443,7 @@ In practical terms, the score does not replace the operators decision. It pre
Related settings:
- [Enabled Bands](en-Configuration#enabled-bands)
- [Active Bands](en-Configuration#active-bands)
- [Antenna Beamwidth](en-Configuration#antenna-beamwidth)
- [Default Maximum QRB](en-Configuration#default-maximum-qrb)
- [AirScout Settings](en-Configuration#airscout-settings)
@@ -503,7 +503,7 @@ The timeline is a preview. AirScout data can change, and a stored sked guarantee
## Interval Beacon
Automatic CQ messages in the public channel at a configurable interval. Recommended: use the `MYQRG` variable so the current frequency is always accurate. Details: [Configuration Beacon Settings](Configuration#beacon-settings).
Automatic CQ messages in the public channel at a configurable interval. Recommended: use the `MYQRG` variable so the current frequency is always accurate. Details: [Configuration Beacon Settings](en-Configuration#beacon-settings).
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

+1013
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -445,6 +445,7 @@
<addmodule>java.sql</addmodule>
<addmodule>java.net.http</addmodule>
<addmodule>jdk.crypto.ec</addmodule>
<addmodule>jdk.net</addmodule>
</addmodules>
<mainclass>${main.class}</mainclass>
<input>${project.build.directory}/modules</input>
@@ -34,7 +34,9 @@ import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.nio.charset.StandardCharsets;
import kst4contest.logic.FrequencyTextParser;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -57,6 +59,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
*
*/
private static final Logger LOGGER =
Logger.getLogger(ChatController.class.getName());
private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint
@@ -86,6 +91,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
boolean disconnected;
boolean disconnectionPerformedByUser = false;
private final On4KstConnectionManager on4KstConnectionManager =
new On4KstConnectionManager(this);
public boolean isDisconnectionPerformedByUser() {
return disconnectionPerformedByUser;
@@ -140,6 +148,15 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return disconnected;
}
/**
* Returns the authoritative ON4KST lifecycle state.
*
* @return current connection, authentication or synchronization state
*/
public On4KstConnectionState getOn4KstConnectionState() {
return on4KstConnectionManager.getState();
}
public void setDisconnected(boolean disconnected) {
this.disconnected = disconnected;
}
@@ -161,6 +178,112 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} else System.out.println("ERRRRRRRRRRRRRRRRRRRRRRRRRRRÖRRRRRRRRRRRRRRRRRRR");
}
/**
* Publishes one authoritative connection-state transition to legacy controller
* flags, generic worker status listeners and the dedicated UI callback.
*
* <p>The compatibility flags remain derived values. No caller may set them to
* infer socket health; only the connection manager owns that decision.</p>
*
* @param state new lifecycle state
* @param detail human-readable progress or failure reason
* @param critical whether the transition should be emphasized as an error
*/
void updateOn4KstConnectionState(
On4KstConnectionState state,
String detail,
boolean critical
) {
setConnectedAndLoggedIn(state == On4KstConnectionState.ONLINE);
setConnectedAndNOTLoggedIn(
state.isConnectionAttemptActive()
&& state != On4KstConnectionState.ONLINE);
setDisconnected(state == On4KstConnectionState.DISCONNECTED);
ThreadStateMessage status = new ThreadStateMessage(
"ON4KST", state.isConnectionAttemptActive(), detail, critical);
status.setRunningInformationTextDescription(state.name());
onThreadStatus("ON4KST", status);
if (statusListener != null) {
statusListener.onConnectionStateChanged(state, detail);
}
}
/**
* Installs all resources belonging to one successfully opened connection
* generation.
*
* <p>The session-id check prevents a slow connection attempt from overwriting a
* newer socket and its queues.</p>
*/
synchronized void installOn4KstSession(
long connectionSessionId,
Socket sessionSocket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LinkedBlockingQueue<ChatMessage> transmitQueue,
ReadThread sessionReadThread,
WriteThread sessionWriteThread,
MessageBusManagementThread sessionMessageProcessor
) {
if (!on4KstConnectionManager.isActiveSession(connectionSessionId)) {
return;
}
this.socket = sessionSocket;
this.messageRXBus = receiveQueue;
this.messageTXBus = transmitQueue;
this.readThread = sessionReadThread;
this.writeThread = sessionWriteThread;
this.messageProcessor = sessionMessageProcessor;
}
void onOn4KstLogstat(long connectionSessionId, String[] fields) {
on4KstConnectionManager.onLogstat(connectionSessionId, fields);
}
void stageInitialOn4KstChatMember(
long connectionSessionId,
ChatMember member
) {
on4KstConnectionManager.stageInitialChatMember(
connectionSessionId, member);
}
void onOn4KstInitialUserListCompleted(
long connectionSessionId,
ChatCategory category
) {
on4KstConnectionManager.onInitialUserListCompleted(
connectionSessionId, category);
}
void onOn4KstConnectionOnline() {
scheduleBeaconTimer(INITIAL_BEACON_DELAY_MILLIS);
}
void onOn4KstConnectionLost() {
stopBeaconTimer();
}
void onOn4KstOutboundFrameRejected(String reason) {
onOn4KstConnectionWarning("Outbound frame rejected locally: " + reason);
}
/**
* Receives a non-fatal ON4KST diagnostic, writes it to the persistent warning
* log and forwards it to the status UI.
*
* @param warning sanitized diagnostic text; passwords and raw chat frames must
* never be included
*/
void onOn4KstConnectionWarning(String warning) {
LOGGER.log(Level.WARNING, "ON4KST warning: {0}", warning);
ThreadStateMessage status = new ThreadStateMessage(
"ON4KST", true, warning, false);
status.setRunningInformationTextDescription("WARNING");
onThreadStatus("ON4KST", status);
}
/********************************************************************************
* PSTRotator controlling
@@ -573,6 +696,16 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
*/
public void disconnect(String action) {
/*
* New connections are owned by the session manager. Keep the historic cleanup
* below as a compatibility fallback, but do not let it manipulate resources
* belonging to a replacement session.
*/
if (on4KstConnectionManager != null) {
disconnectManaged(action);
return;
}
// stopContextLoop(); //stops thread for calculating sked priorities
stopScoreScheduler();
@@ -757,10 +890,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (Exception e2) {
// TODO Auto-generated catch block
e2.printStackTrace();
}
@@ -768,6 +899,69 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
private void disconnectManaged(String action) {
setDisconnectionPerformedByUser(true);
on4KstConnectionManager.stopByUser();
clearActiveChatMembers();
runOnFxThread(() -> lst_clusterMemberList.clear());
stopBeaconTimer();
stopScoreScheduler();
stopDxClusterServer();
cancelTimer(userActualizationtimer);
userActualizationtimer = null;
cancelTimer(ASQueryTimer);
ASQueryTimer = null;
stopUdpReader(readUDPbyUCXThread,
chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort());
readUDPbyUCXThread = null;
stopUdpReader(readUDPByWintestThread,
chatPreferences.getLogsynch_wintestNetworkPort());
stopWintestUdpListener();
stopUdpReader(airScoutUDPReaderThread,
chatPreferences.getAirScout_asCommunicationPort());
airScoutUDPReaderThread = null;
if (ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE.equals(action)) {
if (dbHandler != null) {
dbHandler.closeDBConnection();
}
if (rotatorClient != null) {
rotatorClient.stopRotor();
rotatorClient.stop();
rotatorClient = null;
}
}
}
private void cancelTimer(Timer timer) {
if (timer != null) {
timer.cancel();
timer.purge();
}
}
private void stopUdpReader(Thread readerThread, int listenerPort) {
if (readerThread != null) {
readerThread.interrupt();
}
try (DatagramSocket datagramSocket = new DatagramSocket()) {
datagramSocket.setBroadcast(true);
byte[] poison = ApplicationConstants.DISCONNECT_RDR_POISONPILL.getBytes(
StandardCharsets.UTF_8);
DatagramPacket packet = new DatagramPacket(
poison, poison.length,
InetAddress.getByName("255.255.255.255"), listenerPort);
datagramSocket.send(packet);
} catch (IOException exception) {
System.out.println("[ChatController, warning]: could not wake UDP reader on port "
+ listenerPort + ": " + exception.getMessage());
}
}
// private ObservableList<ContestSked> activeSkeds = FXCollections.observableArrayList();
// public ObservableList<ContestSked> getActiveSkeds() {
// return activeSkeds;
@@ -1416,6 +1610,10 @@ private ObservableList<String>
private final List<ChatMessage> pendingChatMessages = new ArrayList<>();
private boolean chatMessageFlushScheduled = false;
private static final int RECENT_INBOUND_MESSAGE_KEYS_MAX = 5_000;
private final LinkedHashMap<String, Boolean> recentInboundMessageKeys =
new LinkedHashMap<>();
/*
* Same idea for DXCluster messages.
*/
@@ -1490,6 +1688,38 @@ private ObservableList<String>
);
}
/**
* Uses one unambiguous explicit QRG from the current station name as the
* initial compatibility frequency.
*
* <p>Multiple explicit QRGs are valid band evidence, but there is no safe
* assumption which one is the current run frequency. Therefore the legacy
* frequency property is initialized only when exactly one explicit QRG exists.</p>
*/
private void initializeFrequencyFromStationNameIfUnambiguous(
ChatMember member
) {
if (member == null) {
return;
}
List<FrequencyTextParser.DetectedFrequency> detectedFrequencies =
FrequencyTextParser.findExplicitFrequencies(
member.getName()
);
if (detectedFrequencies.size() != 1) {
return;
}
member.initializeFrequencyIfEmpty(
detectedFrequencies
.get(0)
.getFrequencyMHz()
);
}
/**
* Adds or replaces an active chat member in the worker-thread model and mirrors
* that change to the JavaFX list. This is the only supported path for ON4KST
@@ -1501,6 +1731,8 @@ private ObservableList<String>
return;
}
initializeFrequencyFromStationNameIfUnambiguous(member);
activeChatMembersByCallAndCategory.put(key, member);
runOnFxThread(() -> {
@@ -1538,6 +1770,55 @@ private ObservableList<String>
runOnFxThread(() -> lst_chatMemberList.clear());
}
/**
* Atomically replaces one category after its terminating UE frame arrived.
* Partial UA0 snapshots are never exposed to the TableView.
*
* @param connectionSessionId session that produced the completed snapshot
* @param category chat category whose members are being replaced
* @param completeMembers validated members staged before the UE terminator
*/
public void replaceActiveChatMembersForCategory(
long connectionSessionId,
ChatCategory category,
Collection<ChatMember> completeMembers
) {
if (category == null
|| !on4KstConnectionManager.isActiveSession(connectionSessionId)) {
return;
}
int categoryNumber = category.getCategoryNumber();
List<ChatMember> safeMembers = completeMembers == null
? List.of() : new ArrayList<>(completeMembers);
for (ChatMember member : safeMembers) {
initializeFrequencyFromStationNameIfUnambiguous(member);
}
activeChatMembersByCallAndCategory.entrySet().removeIf(entry -> {
ChatMember member = entry.getValue();
return member != null && member.getChatCategory() != null
&& member.getChatCategory().getCategoryNumber() == categoryNumber;
});
for (ChatMember member : safeMembers) {
String key = buildActiveChatMemberKey(member);
if (key != null) {
activeChatMembersByCallAndCategory.put(key, member);
}
}
runOnFxThread(() -> {
if (!on4KstConnectionManager.isActiveSession(connectionSessionId)) {
return;
}
lst_chatMemberList.removeIf(member -> member != null
&& member.getChatCategory() != null
&& member.getChatCategory().getCategoryNumber() == categoryNumber);
lst_chatMemberList.addAll(safeMembers);
fireUserListUpdate("Complete ON4KST user list received");
});
}
/**
* Resolves a member from the thread-safe active model. This avoids reading the
* TableView backing list from MessageBusManagementThread.
@@ -1683,6 +1964,9 @@ private ObservableList<String>
}
activeMember.setName(updatedMember.getName());
initializeFrequencyFromStationNameIfUnambiguous(
activeMember
);
activeMember.setQra(updatedMember.getQra());
activeMember.setState(updatedMember.getState());
activeMember.setLastActivity(updatedMember.getLastActivity());
@@ -1876,6 +2160,9 @@ private ObservableList<String>
if (message == null) {
return;
}
if (isDuplicateInboundMessage(message)) {
return;
}
synchronized (pendingChatMessagesLock) {
pendingChatMessages.add(message);
@@ -1890,6 +2177,47 @@ private ObservableList<String>
Platform.runLater(this::flushPendingChatMessagesToUi);
}
/**
* Suppresses the small replay overlap deliberately requested after a reconnect.
*
* <p>The manager asks for messages beginning one timestamp before the last known
* message so that a boundary message cannot be lost. This bounded key cache
* removes the expected duplicate without growing for the lifetime of the
* application.</p>
*/
private boolean isDuplicateInboundMessage(ChatMessage message) {
String timestamp = message.getMessageGeneratedTime();
if (timestamp == null || timestamp.isBlank()) {
return false;
}
String sender = message.getSender() == null
? "" : String.valueOf(message.getSender().getCallSign());
String receiver = message.getReceiver() == null
? "" : String.valueOf(message.getReceiver().getCallSign());
int category = message.getChatCategory() == null
? -1 : message.getChatCategory().getCategoryNumber();
String key = category + "|" + timestamp + "|" + sender + "|"
+ receiver + "|" + String.valueOf(message.getMessageText());
synchronized (recentInboundMessageKeys) {
if (recentInboundMessageKeys.containsKey(key)) {
return true;
}
recentInboundMessageKeys.put(key, Boolean.TRUE);
while (recentInboundMessageKeys.size()
> RECENT_INBOUND_MESSAGE_KEYS_MAX) {
Iterator<String> iterator = recentInboundMessageKeys.keySet().iterator();
if (!iterator.hasNext()) {
break;
}
iterator.next();
iterator.remove();
}
return false;
}
}
private void flushPendingChatMessagesToUi() {
List<ChatMessage> batch;
@@ -2578,6 +2906,10 @@ private ObservableList<String>
*/
public void execute() throws InterruptedException, IOException {
if (on4KstConnectionManager != null) {
executeManaged();
return;
}
chatController = this;
@@ -2800,6 +3132,50 @@ private ObservableList<String>
}
private synchronized void executeManaged() throws IOException {
chatController = this;
setDisconnectionPerformedByUser(false);
startScoreScheduler();
if (readUDPbyUCXThread == null || !readUDPbyUCXThread.isAlive()) {
readUDPbyUCXThread = new ReadUDPbyUCXMessageThread(
chatPreferences.getLogsynch_ucxUDPWkdCallListenerPort(),
this, this);
readUDPbyUCXThread.setName("readUDPbyUCXThread");
readUDPbyUCXThread.start();
}
if (chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) {
startWintestUdpListener();
}
if (airScoutUDPReaderThread == null || !airScoutUDPReaderThread.isAlive()) {
airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread(
chatPreferences.getAirScout_asCommunicationPort(),
this, this);
airScoutUDPReaderThread.setName("airscoutudpreaderThread");
airScoutUDPReaderThread.start();
}
cancelTimer(userActualizationtimer);
userActualizationtimer = new Timer("UserActualizationTimer", true);
userActualizationtimer.schedule(
new UserActualizationTask(this), 4_000L, 60_000L);
cancelTimer(ASQueryTimer);
ASQueryTimer = new Timer("AirScoutQueryTimer", true);
ASQueryTimer.schedule(
new AirScoutPeriodicalAPReflectionInquirerTask(this),
10_000L, 60_000L);
if (chatPreferences.isStn_pstRotatorEnabled() && rotatorClient == null) {
initRotor();
}
startDxClusterServerIfEnabled();
on4KstConnectionManager.start();
}
/**
* Returns the background reachability service used by the station table.
@@ -2927,6 +3303,11 @@ private ObservableList<String>
*/
public void initialize23001() throws InterruptedException, IOException {
if (on4KstConnectionManager != null) {
on4KstConnectionManager.start();
return;
}
messageTXBus.clear();
ChatMessage message = new ChatMessage();
@@ -18,6 +18,9 @@ import kst4contest.ApplicationConstants;
import kst4contest.locatorUtils.DirectionUtils;
import kst4contest.locatorUtils.Location;
import kst4contest.model.*;
import kst4contest.logic.FrequencyTextParser;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.LongPredicate;
/**
*
@@ -36,6 +39,11 @@ public class MessageBusManagementThread extends Thread {
private PrintWriter writer;
// private Socket socket;
private ChatController client;
private final long connectionSessionId;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LongPredicate connectionSessionIsActive;
// private File fileLogRAW;
// private TimerTask userActualizationTask; // Is used as a temporary userout-print
// private TimerTask userActualizationTask; //kst4contest.test 4 23001
@@ -58,7 +66,7 @@ public class MessageBusManagementThread extends Thread {
* would be converted into plausible but incorrect frequencies.
*/
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
"(?<![\\d])(\\d{3,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])"
"(?<![\\d])(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])"
+ "|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])"
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
);
@@ -117,9 +125,22 @@ public class MessageBusManagementThread extends Thread {
}
public MessageBusManagementThread(ChatController client, ThreadStatusCallback callBack) {
this(client, callBack, 0L, client.getMessageRXBus(), ignored -> true);
}
public MessageBusManagementThread(
ChatController client,
ThreadStatusCallback callBack,
long connectionSessionId,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LongPredicate connectionSessionIsActive
) {
this.callBackToController = callBack;
this.client = client;
this.connectionSessionId = connectionSessionId;
this.receiveQueue = receiveQueue;
this.connectionSessionIsActive = connectionSessionIsActive;
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
@@ -319,16 +340,28 @@ public class MessageBusManagementThread extends Thread {
try {
String reconstructed =
candidateBand.getPrefix() + "." + foundRaw;
double candidateFrequency = Double.parseDouble(
normalizeFrequencyString(reconstructed)
);
candidateBand.getPrefix()
+ "."
+ foundRaw;
if (candidateBand.isPlausible(candidateFrequency)
FrequencyTextParser.DetectedFrequency detectedFrequency =
FrequencyTextParser.parseExplicitFrequency(
reconstructed
);
if (detectedFrequency != null
&& detectedFrequency.getBand() == candidateBand
&& info.timestampEpoch > bestTimestamp) {
finalDetectedFrequency = candidateFrequency;
finalDetectedBand = candidateBand;
bestTimestamp = info.timestampEpoch;
finalDetectedFrequency =
detectedFrequency.getFrequencyMHz();
finalDetectedBand =
candidateBand;
bestTimestamp =
info.timestampEpoch;
}
} catch (NumberFormatException ignored) {
// Try the next known band.
@@ -357,27 +390,40 @@ public class MessageBusManagementThread extends Thread {
try {
String reconstructed =
fallbackBand.getPrefix() + "." + foundRaw;
double candidateFrequency = Double.parseDouble(
normalizeFrequencyString(reconstructed)
);
fallbackBand.getPrefix()
+ "."
+ foundRaw;
if (fallbackBand.isPlausible(candidateFrequency)) {
finalDetectedFrequency = candidateFrequency;
finalDetectedBand = fallbackBand;
FrequencyTextParser.DetectedFrequency detectedFrequency =
FrequencyTextParser.parseExplicitFrequency(
reconstructed
);
if (detectedFrequency != null
&& detectedFrequency.getBand() == fallbackBand) {
finalDetectedFrequency =
detectedFrequency.getFrequencyMHz();
finalDetectedBand =
fallbackBand;
}
} catch (NumberFormatException ignored) {
// The matched value cannot be converted into a frequency.
}
}
} else {
try {
finalDetectedFrequency = Double.parseDouble(
normalizeFrequencyString(foundRaw)
);
finalDetectedBand = Band.fromFrequency(finalDetectedFrequency);
} catch (NumberFormatException ignored) {
// Continue with the next possible match in the message.
FrequencyTextParser.DetectedFrequency detectedFrequency =
FrequencyTextParser.parseExplicitFrequency(
foundRaw
);
if (detectedFrequency != null) {
finalDetectedFrequency =
detectedFrequency.getFrequencyMHz();
finalDetectedBand =
detectedFrequency.getBand();
}
}
@@ -462,22 +508,22 @@ public class MessageBusManagementThread extends Thread {
* Example: "144.210.10" -> "144.21010"
* Example: "144.210" -> "144.210"
*/
private String normalizeFrequencyString(String rawInput) {
// Input is already guaranteed to have only dots as separators (commas replaced earlier)
int firstDotIndex = rawInput.indexOf(".");
if (firstDotIndex != -1) {
// Check if there are more dots after the first one
String decimalPart = rawInput.substring(firstDotIndex + 1);
if (decimalPart.contains(".")) {
// Remove all subsequent dots to make it a valid double
decimalPart = decimalPart.replace(".", "");
return rawInput.substring(0, firstDotIndex) + "." + decimalPart;
}
}
return rawInput;
}
// private String normalizeFrequencyString(String rawInput) {
// // Input is already guaranteed to have only dots as separators (commas replaced earlier)
//
// int firstDotIndex = rawInput.indexOf(".");
//
// if (firstDotIndex != -1) {
// // Check if there are more dots after the first one
// String decimalPart = rawInput.substring(firstDotIndex + 1);
// if (decimalPart.contains(".")) {
// // Remove all subsequent dots to make it a valid double
// decimalPart = decimalPart.replace(".", "");
// return rawInput.substring(0, firstDotIndex) + "." + decimalPart;
// }
// }
// return rawInput;
// }
/**
@@ -496,7 +542,8 @@ public class MessageBusManagementThread extends Thread {
messageToProcess.setMessageText(reduce);
if (messageToProcess.getMessageText().isEmpty()) {
if (messageToProcess.getMessageText() == null
|| messageToProcess.getMessageText().isEmpty()) {
// System.out.println("[MSGBUSMGTT:] ###################### no processable data");
} else {
@@ -728,6 +775,50 @@ public class MessageBusManagementThread extends Thread {
return fallbackReceiver;
}
private boolean validateInboundUserFrame(String[] fields) {
if (fields == null || fields.length < 6) {
logRejectedInboundUserFrame(
"Ignoring truncated ON4KST user frame", fields);
return false;
}
if (fields[2] == null || fields[2].isBlank()) {
logRejectedInboundUserFrame(
"Ignoring ON4KST user frame with empty callsign", fields);
return false;
}
try {
On4KstProtocol.category(Integer.parseInt(fields[1]));
On4KstProtocol.locator(fields[4]);
Integer.parseInt(fields[5]);
return true;
} catch (IllegalArgumentException invalidUser) {
logRejectedInboundUserFrame(
"Ignoring malformed ON4KST user '" + fields[2]
+ "': " + invalidUser.getMessage(), fields);
return false;
}
}
/**
* Records a rejected user frame without writing the complete raw frame or user
* name to the diagnostic log.
*
* <p>The opcode, category and callsign are sufficient to identify the offending
* list position. Omitting the remaining fields avoids unnecessary disclosure of
* free-form profile text.</p>
*/
private void logRejectedInboundUserFrame(String reason, String[] fields) {
String opcode = fields != null && fields.length > 0 ? fields[0] : "UNKNOWN";
String category = fields != null && fields.length > 1 ? fields[1] : "UNKNOWN";
String callsign = fields != null && fields.length > 2 ? fields[2] : "UNKNOWN";
client.onOn4KstConnectionWarning(
reason + "; opcode=" + opcode
+ ", category=" + category
+ ", callsign=" + callsign
+ ", fieldCount=" + (fields == null ? 0 : fields.length));
}
/**
* Processes received messages via port 23001 (improved telnet Interface)
*
@@ -782,23 +873,33 @@ public class MessageBusManagementThread extends Thread {
* here we have a helper list for identifying questions for my qrg which can be autoanswered later
*/
if (messageToProcess.getMessageText().isEmpty()) {
// System.out.println("[MSGBUSMGTT:] no processable data");
if (messageToProcess.getMessageText() == null
|| messageToProcess.getMessageText().isEmpty()) {
// No processable data.
} else {
if (messageToProcess.getMessageText().contains(SRVR_LOGSTAT)) {
String logstatMessage[];
logstatMessage = messageToProcess.getMessageText().split("\\|");
if (logstatMessage[1].contains(SRVR_LOGINOK)) {
this.client.setConnectedAndLoggedIn(true);
} else {
this.client.setConnectedAndNOTLoggedIn(true);
this.client.setConnectedAndLoggedIn(false);
}
if (messageToProcess.getMessageText().startsWith(SRVR_LOGSTAT + "|")) {
String[] logstatMessage =
messageToProcess.getMessageText().split("\\|", -1);
this.client.onOn4KstLogstat(
connectionSessionId,
logstatMessage);
}
String splittedMessageLine[] = messageToProcess.getMessageText().split("\\|");
String[] splittedMessageLine =
messageToProcess.getMessageText().split("\\|");
String opcode = splittedMessageLine.length == 0
? ""
: splittedMessageLine[0];
if ((INITIALUSERLISTENTRY.equals(opcode)
|| USERENTEREDCHAT.equals(opcode)
|| USERENTEREDCHAT2.equals(opcode))
&& !validateInboundUserFrame(splittedMessageLine)) {
return;
}
// String splittedMessageLine[] = messageToProcess.getMessageText().split("\\|");
/**
* Initializes the Userlist if entry fits UA0
@@ -806,7 +907,7 @@ public class MessageBusManagementThread extends Thread {
*
*
*/
if (splittedMessageLine[0].contains(INITIALUSERLISTENTRY)) {
if (splittedMessageLine[0].equals(INITIALUSERLISTENTRY)) {
// System.out.println("MSGBUS: User detected");
ChatMember newMember = new ChatMember();
@@ -827,7 +928,9 @@ public class MessageBusManagementThread extends Thread {
if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) {
this.client.addOrUpdateActiveChatMember(newMember); // the own call will not be in the list
this.client.stageInitialOn4KstChatMember(
connectionSessionId,
newMember);
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
// Reachability is calculated on demand only: map click, selected station, or manual request.
}
@@ -851,7 +954,8 @@ public class MessageBusManagementThread extends Thread {
* UA2|2|W5ADD|Parker|EM40WL|2|
*
*/
if (splittedMessageLine[0].contains(USERENTEREDCHAT) || splittedMessageLine[0].contains(USERENTEREDCHAT2)) {
if (splittedMessageLine[0].equals(USERENTEREDCHAT)
|| splittedMessageLine[0].equals(USERENTEREDCHAT2)) {
// System.out.println("MSGBUS: User detected");
@@ -1614,18 +1718,29 @@ public class MessageBusManagementThread extends Thread {
/**
* Userinfo-update: UE|2|22562|
*/
if (splittedMessageLine[0].contains(SRVR_USERLISTEND)) {
if (SRVR_USERLISTEND.equals(opcode)) {
if (splittedMessageLine.length < 2) {
System.out.println(
"[MSGBUSMGT, Warning:] Ignoring malformed UE frame: "
+ messageToProcess.getMessageText());
return;
}
// No worthy information, count of users
} else
this.client.onOn4KstInitialUserListCompleted(
connectionSessionId,
util_getChatCategoryByCategoryNrString(
splittedMessageLine[1]));
if (splittedMessageLine[0].contains(SRVR_DXCEND)) {
} else if (SRVR_DXCEND.equals(opcode)) {
// No worthy information, count of users
} else
// DF marks the end of the initial DX-cluster data.
// The frame contains no data that needs to be published.
} else if (SRVR_COMMUNICATIONK.equals(opcode)) {
// CK is a regular server delimiter/acknowledgement.
// It is intentionally accepted without further processing.
if (splittedMessageLine[0].contains(SRVR_COMMUNICATIONK)) {
// No worthy information, end of srvrmsgs
} else
//-> LOGSTAT|114|Wrong password!|
@@ -1905,13 +2020,17 @@ public class MessageBusManagementThread extends Thread {
while (true) {
try {
messageTextRaw = client.getMessageRXBus().take();
messageTextRaw = receiveQueue.take();
if (messageTextRaw.getMessageText().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL) && messageTextRaw.getMessageSenderName().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
client.getMessageRXBus().clear();
if (ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(messageTextRaw.getMessageText())
&& ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(messageTextRaw.getMessageSenderName())) {
receiveQueue.clear();
break;
}
else {
if (!connectionSessionIsActive.test(connectionSessionId)) {
break;
}
messageLine = messageTextRaw.getMessageText();
/***********************************************
@@ -0,0 +1,837 @@
package kst4contest.controller;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Level;
import java.util.logging.Logger;
import jdk.net.ExtendedSocketOptions;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
/**
* Owns the complete lifecycle of the single ON4KST TCP session.
*
* <p>Every reader, writer, queue and parser belongs to an immutable session id.
* A delayed failure from an old socket can therefore never close or consume data
* from its replacement.</p>
*/
final class On4KstConnectionManager {
private static final Logger LOGGER =
Logger.getLogger(On4KstConnectionManager.class.getName());
private static final DateTimeFormatter LIVE_MESSAGE_TIMESTAMP =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
static final int CONNECT_TIMEOUT_MILLIS = 10_000; //TCP-Connect-Timeout
static final long LOGIN_FALLBACK_MILLIS = 2_000L; //Login-Fallback
static final long HANDSHAKE_TIMEOUT_MILLIS = 45_000L; //Handshake-Timeout
static final long APPLICATION_HEARTBEAT_AFTER_MILLIS = 90_000L; //Application-Heartbeat
static final long INBOUND_STALE_AFTER_MILLIS = 210_000L; //Stale-Timeout - time without rxed data
static final List<Long> RECONNECT_DELAYS_MILLIS =
List.of(2_000L, 5_000L, 10_000L, 20_000L, 30_000L); //Reconnect-Backoff if no connection possible
private final ChatController controller;
private final ScheduledExecutorService scheduler;
private final AtomicLong generation = new AtomicLong();
private final AtomicLong lastReceivedMessageTimestamp = new AtomicLong();
private volatile Session activeSession;
private volatile On4KstConnectionState state =
On4KstConnectionState.DISCONNECTED;
private volatile boolean stopRequested = true;
private int reconnectAttempt;
On4KstConnectionManager(ChatController controller) {
this.controller = controller;
this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "On4KstConnectionSupervisor");
thread.setDaemon(true);
return thread;
});
this.scheduler.scheduleAtFixedRate(
this::monitorActiveSession, 5L, 5L, TimeUnit.SECONDS);
LOGGER.fine("ON4KST connection supervisor initialized");
}
/**
* Returns the last lifecycle state published by the connection supervisor.
*
* @return current immutable connection-state value
*/
On4KstConnectionState getState() {
return state;
}
/**
* Verifies that a callback still belongs to the currently installed session.
*
* <p>Every reconnect receives a new id. Late EOF, write or parser callbacks from
* an obsolete socket therefore become harmless instead of closing the replacement
* connection.</p>
*
* @param sessionId id captured by the calling worker
* @return {@code true} only for the current, open and non-stopped session
*/
boolean isActiveSession(long sessionId) {
Session session = activeSession;
return session != null
&& session.id == sessionId
&& !session.closed
&& !stopRequested;
}
/**
* Starts a non-blocking connection attempt.
*
* <p>Configuration is validated before a socket is opened. A duplicate Connect
* action is ignored while another attempt or session is active. Connection work
* runs on the supervisor executor, so an unreachable server cannot block the
* JavaFX application thread.</p>
*/
void start() {
long token;
synchronized (this) {
if (!stopRequested && state.isConnectionAttemptActive()) {
return;
}
try {
validateConfiguration();
} catch (IllegalArgumentException invalidConfiguration) {
stopRequested = true;
transition(On4KstConnectionState.DISCONNECTED,
"Invalid ON4KST configuration: "
+ invalidConfiguration.getMessage(), true);
return;
}
stopRequested = false;
reconnectAttempt = 0;
token = generation.incrementAndGet();
transition(On4KstConnectionState.CONNECTING,
"Opening ON4KST connection", false);
}
scheduler.execute(() -> openConnection(token));
}
/**
* Stops the current session and invalidates every scheduled callback or reconnect
* belonging to it.
*/
void stopByUser() {
Session oldSession;
synchronized (this) {
stopRequested = true;
generation.incrementAndGet();
transition(On4KstConnectionState.STOPPING,
"Disconnecting from ON4KST", false);
oldSession = activeSession;
activeSession = null;
}
closeSession(oldSession);
controller.onOn4KstConnectionLost();
transition(On4KstConnectionState.DISCONNECTED,
"Disconnected by user", false);
}
/**
* Records one received protocol line as proof of application-level liveness.
*
* <p>TCP's {@code isConnected()} only states that a connection once succeeded.
* It does not prove that the peer is still reachable. Updating the inbound
* timestamp here gives the monitor a meaningful end-to-end signal.</p>
*
* @param sessionId immutable source-session id
* @param line complete protocol line received from ON4KST
*/
void onInboundActivity(long sessionId, String line) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
long now = System.currentTimeMillis();
session.lastInboundMillis.set(now);
session.lastProgressMillis.set(now);
String opcode = opcode(line);
if ("CK".equals(opcode)) {
sendHeartbeat(session);
}
if (!session.loginSent
&& line != null
&& line.toLowerCase(Locale.ROOT).contains("login")) {
scheduler.execute(() -> sendLogin(sessionId));
}
if ("CH".equals(opcode) || "CR".equals(opcode)) {
recordHistoryTimestamp(line);
}
}
void onLogstat(long sessionId, String[] fields) {
String[] copy = fields == null ? new String[0] : fields.clone();
scheduler.execute(() -> handleLogstat(sessionId, copy));
}
void stageInitialChatMember(long sessionId, ChatMember member) {
Session session = activeSession;
if (session == null || session.id != sessionId || member == null
|| member.getChatCategory() == null || member.getCallSign() == null) {
return;
}
int category = member.getChatCategory().getCategoryNumber();
session.initialMembers
.computeIfAbsent(category, ignored -> new ConcurrentHashMap<>())
.put(member.getCallSign().trim().toUpperCase(Locale.ROOT), member);
session.lastProgressMillis.set(System.currentTimeMillis());
}
void onInitialUserListCompleted(long sessionId, ChatCategory category) {
if (category == null) {
return;
}
scheduler.execute(() -> completeInitialUserList(
sessionId, category.getCategoryNumber()));
}
private void openConnection(long token) {
if (!mayOpen(token)) {
return;
}
LOGGER.log(Level.INFO,
"Opening ON4KST TCP session {0}", token);
Socket socket = new Socket();
try {
ChatPreferences preferences = controller.getChatPreferences();
socket.connect(new InetSocketAddress(
preferences.getStn_on4kstServersDns(),
preferences.getStn_on4kstServersPort()),
CONNECT_TIMEOUT_MILLIS);
configureSocket(socket);
LOGGER.log(Level.INFO,
"ON4KST TCP session {0} connected to {1}",
new Object[] {token, socket.getRemoteSocketAddress()});
LinkedBlockingQueue<ChatMessage> receiveQueue =
new LinkedBlockingQueue<>();
LinkedBlockingQueue<ChatMessage> transmitQueue =
new LinkedBlockingQueue<>();
Session session = new Session(token, socket, receiveQueue, transmitQueue);
ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession,
line -> onInboundActivity(token, line),
failure -> onConnectionFailure(token, failure));
WriteThread writeThread = new WriteThread(
token, socket, transmitQueue,
controller.getChatPreferences().getLoginChatCategoryMain()
.getCategoryNumber(),
this::isActiveSession,
failure -> onConnectionFailure(token, failure),
controller::onOn4KstOutboundFrameRejected);
MessageBusManagementThread messageProcessor =
new MessageBusManagementThread(
controller, controller, token, receiveQueue,
this::isActiveSession);
session.readThread = readThread;
session.writeThread = writeThread;
session.messageProcessor = messageProcessor;
synchronized (this) {
if (!mayOpen(token)) {
closeSession(session);
return;
}
activeSession = session;
controller.installOn4KstSession(
token, socket, receiveQueue, transmitQueue,
readThread, writeThread, messageProcessor);
transition(On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
"TCP connected; waiting for ON4KST login prompt", false);
}
messageProcessor.start();
writeThread.start();
readThread.start();
scheduler.schedule(
() -> sendLogin(token), LOGIN_FALLBACK_MILLIS,
TimeUnit.MILLISECONDS);
} catch (Exception exception) {
try {
socket.close();
} catch (IOException ignored) {
// The original connection exception is more useful.
}
scheduler.execute(() -> handleOpenFailure(token, exception));
}
}
private boolean mayOpen(long token) {
return !stopRequested && generation.get() == token;
}
private void sendLogin(long sessionId) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.loginSent
|| session.closed || stopRequested) {
return;
}
try {
ChatPreferences preferences = controller.getChatPreferences();
int mainCategory = preferences.getLoginChatCategoryMain()
.getCategoryNumber();
long historyFrom = Math.max(
0L, lastReceivedMessageTimestamp.get() - 1L);
String login = On4KstProtocol.login(
preferences.getStn_loginCallSign(),
preferences.getStn_loginPassword(),
mainCategory,
"KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION,
historyFrom);
session.loginSent = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"Sending ON4KST login for session {0}, main category {1}",
new Object[] {sessionId, mainCategory});
transition(On4KstConnectionState.AUTHENTICATING,
"ON4KST login sent", false);
sendControl(session, login);
} catch (IllegalArgumentException invalidConfiguration) {
failPermanently(session,
"Invalid ON4KST login configuration: "
+ invalidConfiguration.getMessage());
}
}
private void handleLogstat(long sessionId, String[] fields) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
String code = fields.length > 1 ? fields[1] : "";
if (!"100".equals(code)) {
String serverText = fields.length > 2 ? fields[2] : "Login rejected";
failPermanently(session,
"ON4KST login rejected (" + code + "): " + serverText);
return;
}
if (session.authenticated) {
return;
}
session.authenticated = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"ON4KST login accepted for session {0}", sessionId);
int mainCategory = controller.getChatPreferences()
.getLoginChatCategoryMain().getCategoryNumber();
transition(On4KstConnectionState.SYNCING_MAIN_CHAT,
"Login accepted; loading main chat", false);
sendControl(session, On4KstProtocol.settingsDone(mainCategory));
}
/**
* Publishes the initial user snapshot for one chat category exactly once.
*
* <p>ON4KST can send further {@code UE} frames after live user updates or
* after commands such as {@code SETNAME} and {@code BACK}. Those frames do
* not announce a new, empty snapshot. Treating them as another initial-list
* completion would remove the already published members because the staging
* map was consumed by the first {@code UE} frame.</p>
*
* <p>The completed-category set is updated before the staging map is removed.
* This makes the operation idempotent even if completion callbacks should
* later be invoked from more than one thread. A genuinely empty initial list
* remains valid: the first {@code UE} for a category is always processed,
* even when no preceding valid {@code UA0} frame was staged.</p>
*
* @param sessionId immutable id of the socket session that received the frame
* @param categoryNumber numeric ON4KST category terminated by {@code UE}
*/
private void completeInitialUserList(long sessionId, int categoryNumber) {
Session session = activeSession;
if (session == null || session.id != sessionId || session.closed) {
return;
}
if (!session.completedInitialUserLists.add(categoryNumber)) {
LOGGER.log(Level.FINE,
"ON4KST session {0}: ignoring duplicate user-list end "
+ "marker for category {1}; the initial snapshot "
+ "has already been published",
new Object[] {sessionId, categoryNumber});
return;
}
Map<String, ChatMember> staged =
session.initialMembers.remove(categoryNumber);
Collection<ChatMember> completeMembers = staged == null
? List.of()
: new ArrayList<>(staged.values());
LOGGER.log(Level.INFO,
"ON4KST session {0}: complete user list for category {1} "
+ "contains {2} valid users",
new Object[] {
sessionId,
categoryNumber,
completeMembers.size()
});
controller.replaceActiveChatMembersForCategory(
sessionId,
new ChatCategory(categoryNumber),
completeMembers);
ChatPreferences preferences = controller.getChatPreferences();
int mainCategory =
preferences.getLoginChatCategoryMain().getCategoryNumber();
if (categoryNumber == mainCategory && !session.mainListComplete) {
session.mainListComplete = true;
configureMainChat(session);
if (hasDistinctSecondChat(preferences)) {
int secondCategory =
preferences.getLoginChatCategorySecond()
.getCategoryNumber();
transition(
On4KstConnectionState.SYNCING_SECOND_CHAT,
"Main chat ready; loading second chat",
false);
sendControl(
session,
On4KstProtocol.addChat(
secondCategory,
Math.max(
0L,
lastReceivedMessageTimestamp.get() - 1L)));
} else {
markOnline(session);
}
return;
}
if (hasDistinctSecondChat(preferences)
&& categoryNumber
== preferences.getLoginChatCategorySecond()
.getCategoryNumber()
&& !session.secondListComplete) {
session.secondListComplete = true;
configureSecondChat(session);
markOnline(session);
}
}
private void configureMainChat(Session session) {
ChatPreferences preferences = controller.getChatPreferences();
int category = preferences.getLoginChatCategoryMain().getCategoryNumber();
sendControl(session, On4KstProtocol.setLocator(
category, preferences.getStn_loginLocatorMainCat()));
if (preferences.getStn_loginNameMainCat() != null
&& !preferences.getStn_loginNameMainCat().isBlank()) {
sendControl(session, On4KstProtocol.setName(
category, preferences.getStn_loginNameMainCat()));
}
sendControl(session, On4KstProtocol.back(category));
String secondLocator = preferences.getStn_loginLocatorSecondCat();
String mainLocator = preferences.getStn_loginLocatorMainCat();
if (preferences.isLoginToSecondChatEnabled()
&& secondLocator != null && !secondLocator.isBlank()
&& !secondLocator.equalsIgnoreCase(mainLocator)) {
controller.onOn4KstConnectionWarning(
"ON4KST uses one locator per TCP session. The second-chat locator '"
+ secondLocator + "' is ignored; using '" + mainLocator + "'.");
}
}
private void configureSecondChat(Session session) {
ChatPreferences preferences = controller.getChatPreferences();
int category = preferences.getLoginChatCategorySecond().getCategoryNumber();
if (preferences.getStn_loginNameSecondCat() != null
&& !preferences.getStn_loginNameSecondCat().isBlank()) {
sendControl(session, On4KstProtocol.setName(
category, preferences.getStn_loginNameSecondCat()));
}
sendControl(session, On4KstProtocol.back(category));
}
private void markOnline(Session session) {
if (!isActiveSession(session.id)) {
return;
}
reconnectAttempt = 0;
session.online = true;
session.lastProgressMillis.set(System.currentTimeMillis());
LOGGER.log(Level.INFO,
"ON4KST session {0} is authenticated and synchronized",
session.id);
transition(On4KstConnectionState.ONLINE,
"ON4KST session is authenticated and synchronized", false);
controller.onOn4KstConnectionOnline();
}
private void sendControl(Session session, String frame) {
if (session == null || !isActiveSession(session.id)) {
return;
}
ChatMessage message = new ChatMessage();
message.setMessageDirectedToServer(true);
message.setMessageText(frame);
session.transmitQueue.offer(message);
}
private void sendHeartbeat(Session session) {
if (session == null || !isActiveSession(session.id)) {
return;
}
long now = System.currentTimeMillis();
session.lastHeartbeatMillis.set(now);
LOGGER.log(Level.FINE,
"Sending application heartbeat for ON4KST session {0}",
session.id);
ChatMessage heartbeat = new ChatMessage();
heartbeat.setMessageDirectedToServer(true);
heartbeat.setMessageText("");
session.transmitQueue.offer(heartbeat);
}
private void onConnectionFailure(long sessionId, Throwable failure) {
scheduler.execute(() -> failSession(sessionId, failure));
}
private void failSession(long sessionId, Throwable failure) {
Session failedSession;
synchronized (this) {
failedSession = activeSession;
if (failedSession == null || failedSession.id != sessionId
|| failedSession.closed) {
return;
}
activeSession = null;
failedSession.closed = true;
}
closeSession(failedSession);
controller.onOn4KstConnectionLost();
if (stopRequested) {
transition(On4KstConnectionState.DISCONNECTED,
"ON4KST connection stopped", false);
return;
}
scheduleReconnect(failure);
}
private void handleOpenFailure(long token, Throwable failure) {
if (!mayOpen(token)) {
return;
}
controller.onOn4KstConnectionLost();
scheduleReconnect(failure);
}
private void scheduleReconnect(Throwable failure) {
if (stopRequested) {
transition(On4KstConnectionState.DISCONNECTED,
"ON4KST connection stopped", false);
return;
}
String reason = describeFailure(failure);
LOGGER.log(Level.WARNING,
"ON4KST connection lost; automatic reconnect scheduled", failure);
long delay = RECONNECT_DELAYS_MILLIS.get(Math.min(
reconnectAttempt, RECONNECT_DELAYS_MILLIS.size() - 1));
reconnectAttempt++;
transition(On4KstConnectionState.RECONNECT_WAIT,
"Connection lost (" + reason + "); reconnecting in "
+ Duration.ofMillis(delay).toSeconds() + " s", true);
long nextToken = generation.incrementAndGet();
scheduler.schedule(() -> {
if (!mayOpen(nextToken)) {
return;
}
transition(On4KstConnectionState.CONNECTING,
"Reconnecting to ON4KST", false);
openConnection(nextToken);
}, delay, TimeUnit.MILLISECONDS);
}
private void failPermanently(Session session, String reason) {
if (session == null || !isActiveSession(session.id)) {
return;
}
stopRequested = true;
generation.incrementAndGet();
activeSession = null;
session.closed = true;
closeSession(session);
controller.onOn4KstConnectionLost();
LOGGER.log(Level.WARNING, reason);
transition(On4KstConnectionState.DISCONNECTED, reason, true);
}
private void monitorActiveSession() {
try {
Session session = activeSession;
if (session == null || session.closed || stopRequested) {
return;
}
if (session.socket.isClosed()) {
failSession(session.id,
new SocketException("Socket is closed"));
return;
}
long now = System.currentTimeMillis();
if (!session.online
&& now - session.lastProgressMillis.get()
> HANDSHAKE_TIMEOUT_MILLIS) {
failSession(session.id,
new SocketException("ON4KST handshake timed out"));
return;
}
long inboundIdle = now - session.lastInboundMillis.get();
if (inboundIdle > INBOUND_STALE_AFTER_MILLIS) {
failSession(session.id,
new SocketException("No ON4KST data received for "
+ inboundIdle / 1_000L + " seconds"));
return;
}
if (inboundIdle > APPLICATION_HEARTBEAT_AFTER_MILLIS
&& session.lastHeartbeatMillis.get()
< session.lastInboundMillis.get()) {
sendHeartbeat(session);
}
} catch (RuntimeException exception) {
LOGGER.log(Level.WARNING,
"ON4KST connection monitor failed", exception);
}
}
private void validateConfiguration() {
ChatPreferences preferences = controller.getChatPreferences();
On4KstProtocol.login(
preferences.getStn_loginCallSign(),
preferences.getStn_loginPassword(),
preferences.getLoginChatCategoryMain().getCategoryNumber(),
"KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION,
0L);
On4KstProtocol.locator(preferences.getStn_loginLocatorMainCat());
if (preferences.getStn_loginNameMainCat() != null
&& !preferences.getStn_loginNameMainCat().isBlank()) {
On4KstProtocol.field(
preferences.getStn_loginNameMainCat(), "main chat name");
}
if (preferences.isLoginToSecondChatEnabled()) {
if (preferences.getLoginChatCategorySecond() == null) {
throw new IllegalArgumentException("Second chat has no category");
}
On4KstProtocol.category(
preferences.getLoginChatCategorySecond().getCategoryNumber());
if (preferences.getLoginChatCategorySecond().getCategoryNumber()
== preferences.getLoginChatCategoryMain().getCategoryNumber()) {
controller.onOn4KstConnectionWarning(
"Second ON4KST chat equals the main chat and will not be added twice.");
}
if (preferences.getStn_loginNameSecondCat() != null
&& !preferences.getStn_loginNameSecondCat().isBlank()) {
On4KstProtocol.field(
preferences.getStn_loginNameSecondCat(), "second chat name");
}
}
}
private boolean hasDistinctSecondChat(ChatPreferences preferences) {
return preferences.isLoginToSecondChatEnabled()
&& preferences.getLoginChatCategorySecond() != null
&& preferences.getLoginChatCategorySecond().getCategoryNumber()
!= preferences.getLoginChatCategoryMain().getCategoryNumber();
}
private void configureSocket(Socket socket) throws IOException {
socket.setTcpNoDelay(true);
socket.setKeepAlive(true);
try {
socket.setOption(ExtendedSocketOptions.TCP_KEEPIDLE, 45);
socket.setOption(ExtendedSocketOptions.TCP_KEEPINTERVAL, 15);
socket.setOption(ExtendedSocketOptions.TCP_KEEPCOUNT, 3);
} catch (UnsupportedOperationException | IOException exception) {
LOGGER.log(Level.INFO,
"Platform does not support configurable TCP keepalive; "
+ "application heartbeat remains active", exception);
}
}
private void closeSession(Session session) {
if (session == null) {
return;
}
LOGGER.log(Level.FINE,
"Closing ON4KST session {0}", session.id);
session.closed = true;
if (session.readThread != null) {
session.readThread.interrupt();
}
if (session.writeThread != null) {
session.writeThread.interrupt();
}
if (session.messageProcessor != null) {
session.messageProcessor.interrupt();
}
try {
session.socket.close();
} catch (IOException exception) {
LOGGER.log(Level.FINE, "Error closing obsolete ON4KST socket", exception);
}
}
private void transition(
On4KstConnectionState newState,
String detail,
boolean critical
) {
On4KstConnectionState previousState = state;
state = newState;
Level level = critical
|| newState == On4KstConnectionState.DISCONNECTED
|| newState == On4KstConnectionState.RECONNECT_WAIT
? Level.WARNING : Level.INFO;
LOGGER.log(level,
"ON4KST state {0} -> {1}; detail: {2}",
new Object[] {previousState, newState, detail});
controller.updateOn4KstConnectionState(newState, detail, critical);
}
private void recordHistoryTimestamp(String line) {
long timestamp = parseMessageTimestamp(line);
if (timestamp > 0L) {
lastReceivedMessageTimestamp.accumulateAndGet(timestamp, Math::max);
}
}
static long parseMessageTimestamp(String line) {
String[] fields = line == null ? new String[0] : line.split("\\|", -1);
if (fields.length < 3) {
return 0L;
}
try {
long numeric = Long.parseLong(fields[2]);
long now = System.currentTimeMillis() / 1_000L;
if (numeric > 0L && numeric <= now + 86_400L) {
return numeric;
}
} catch (NumberFormatException ignored) {
return 0L;
}
try {
return LocalDateTime.parse(fields[2], LIVE_MESSAGE_TIMESTAMP)
.toEpochSecond(ZoneOffset.UTC);
} catch (DateTimeParseException ignored) {
return 0L;
}
}
private String opcode(String line) {
if (line == null) {
return "";
}
int separator = line.indexOf('|');
return (separator < 0 ? line : line.substring(0, separator))
.trim().toUpperCase(Locale.ROOT);
}
private String describeFailure(Throwable failure) {
if (failure == null) {
return "unknown error";
}
String message = failure.getMessage();
return message == null || message.isBlank()
? failure.getClass().getSimpleName() : message;
}
private static final class Session {
private final Set<Integer> completedInitialUserLists =
ConcurrentHashMap.newKeySet();
private final long id;
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
private final long connectedMillis = System.currentTimeMillis();
private final AtomicLong lastInboundMillis =
new AtomicLong(connectedMillis);
private final AtomicLong lastProgressMillis =
new AtomicLong(connectedMillis);
private final AtomicLong lastHeartbeatMillis = new AtomicLong();
private final Map<Integer, Map<String, ChatMember>> initialMembers =
new ConcurrentHashMap<>();
private volatile ReadThread readThread;
private volatile WriteThread writeThread;
private volatile MessageBusManagementThread messageProcessor;
private volatile boolean loginSent;
private volatile boolean authenticated;
private volatile boolean mainListComplete;
private volatile boolean secondListComplete;
private volatile boolean online;
private volatile boolean closed;
private Session(
long id,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LinkedBlockingQueue<ChatMessage> transmitQueue
) {
this.id = id;
this.socket = socket;
this.receiveQueue = receiveQueue;
this.transmitQueue = transmitQueue;
}
}
}
@@ -0,0 +1,50 @@
package kst4contest.controller;
/**
* Observable lifecycle of the ON4KST TCP session.
*
* <p>A connected TCP socket is deliberately not synonymous with an authenticated
* chat session. The intermediate states make that distinction visible to the UI
* and prevent application messages from being sent in the wrong protocol context.</p>
*/
public enum On4KstConnectionState {
DISCONNECTED,
CONNECTING,
WAITING_FOR_LOGIN_PROMPT,
AUTHENTICATING,
SYNCING_MAIN_CHAT,
SYNCING_SECOND_CHAT,
ONLINE,
RECONNECT_WAIT,
STOPPING;
/**
* Returns whether the complete application-level ON4KST handshake has finished.
*
* @return {@code true} only after authentication and all requested user lists
* have been synchronized
*/
public boolean isOnline() {
return this == ONLINE;
}
/**
* Returns whether a connection attempt or usable session is currently owned by
* the connection manager.
*
* <p>This is intentionally broader than {@link #isOnline()}. The UI uses it to
* prevent a second Connect action while authentication, synchronization or a
* scheduled reconnect is already in progress.</p>
*
* @return {@code true} while connecting, synchronizing, online or waiting for
* an automatic reconnect
*/
public boolean isConnectionAttemptActive() {
return switch (this) {
case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING,
SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT, ONLINE,
RECONNECT_WAIT -> true;
default -> false;
};
}
}
@@ -0,0 +1,192 @@
package kst4contest.controller;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Builds ON4KST port-23001 frames and rejects values that could break framing or
* put the server into an invalid chat context.
*
* <p>All outbound protocol construction is concentrated here. User-controlled
* values may therefore never introduce a field separator or a second line, and
* category and locator validation happens before the frame reaches the socket.</p>
*/
final class On4KstProtocol {
private static final Pattern LOCATOR_6 =
Pattern.compile("^[A-Ra-r]{2}[0-9]{2}[A-Xa-x]{2}$");
private On4KstProtocol() {
}
/**
* Builds the initial authenticated login frame.
*
* @param callsign login callsign
* @param password ON4KST password; never logged by this class
* @param category primary chat category
* @param clientName client identification sent to the server
* @param lastMessageTimestamp earliest history timestamp to request
* @return validated frame without CR/LF terminator
*/
static String login(
String callsign,
String password,
int category,
String clientName,
long lastMessageTimestamp
) {
return "LOGINC|" + field(callsign, "callsign")
+ "|" + password(password)
+ "|" + category(category)
+ "|" + field(clientName, "client name")
+ "|25|0|1|" + Math.max(0L, lastMessageTimestamp) + "|0|";
}
/** Builds the settings-complete frame for the supplied chat category. */
static String settingsDone(int category) {
return "SDONE|" + category(category) + "|";
}
/** Builds the frame used to add a distinct second chat to the same session. */
static String addChat(int category, long lastMessageTimestamp) {
return "ACHAT|" + category(category)
+ "|25|10|2|" + Math.max(0L, lastMessageTimestamp)
+ "|0|";
}
/** Builds a category-qualified locator command after validating Maidenhead syntax. */
static String setLocator(int category, String locator) {
return command(category, "/SETLOC " + locator(locator));
}
/** Builds a category-qualified chat-name command. */
static String setName(int category, String name) {
return command(category, "/SETNAME " + field(name, "chat name"));
}
/** Builds the command that changes the operator state back to available. */
static String back(int category) {
return command(category, "/BACK");
}
/**
* Wraps one validated slash command in an ON4KST message frame.
*
* @return frame without CR/LF terminator
*/
static String command(int category, String command) {
return "MSG|" + category(category) + "|0|"
+ messageText(command) + "|0|";
}
/**
* Wraps one operator chat message in a category-qualified ON4KST frame.
*
* @return frame without CR/LF terminator
*/
static String chatMessage(int category, String text) {
return "MSG|" + category(category) + "|0|"
+ messageText(text) + "|0|";
}
/**
* Removes trailing line terminators from a legacy raw frame while rejecting an
* embedded line break that could inject a second server command.
*
* @param frame legacy raw frame, possibly with trailing CR/LF
* @return exactly one normalized protocol line
* @throws IllegalArgumentException if the value is {@code null} or contains an
* embedded line break
*/
static String normalizeRawFrame(String frame) {
if (frame == null) {
throw new IllegalArgumentException("ON4KST frame must not be null");
}
int end = frame.length();
while (end > 0) {
char last = frame.charAt(end - 1);
if (last != '\r' && last != '\n') {
break;
}
end--;
}
String normalized = frame.substring(0, end);
if (normalized.indexOf('\r') >= 0 || normalized.indexOf('\n') >= 0) {
throw new IllegalArgumentException(
"ON4KST frame contains an embedded line break");
}
return normalized;
}
/**
* Validates and normalizes a six-character Maidenhead locator.
*
* @return upper-case locator
*/
static String locator(String locator) {
String normalized = field(locator, "locator").toUpperCase(Locale.ROOT);
if (!LOCATOR_6.matcher(normalized).matches()) {
throw new IllegalArgumentException(
"Locator must be a six-character Maidenhead locator: " + normalized);
}
return normalized;
}
/** Rejects message text containing an ON4KST field or line delimiter. */
static String messageText(String text) {
String value = field(text, "message text");
if (value.indexOf('|') >= 0) {
throw new IllegalArgumentException(
"Message text contains the ON4KST field separator '|'");
}
return value;
}
/**
* Validates one required, non-password protocol field.
*
* @param value field value
* @param label diagnostic label used in validation errors
* @return trimmed value
*/
static String field(String value, String label) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(label + " must not be empty");
}
if (value.indexOf('|') >= 0
|| value.indexOf('\r') >= 0
|| value.indexOf('\n') >= 0) {
throw new IllegalArgumentException(
label + " contains an ON4KST frame delimiter");
}
return value.trim();
}
private static String password(String value) {
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException("password must not be empty");
}
if (value.indexOf('|') >= 0
|| value.indexOf('\r') >= 0
|| value.indexOf('\n') >= 0) {
throw new IllegalArgumentException(
"password contains an ON4KST frame delimiter");
}
return value;
}
/**
* Validates the category range supported by ON4KST.
*
* @return the unchanged category for convenient inline use
*/
static int category(int category) {
if (category < 1 || category > 12) {
throw new IllegalArgumentException(
"Unsupported ON4KST chat category: " + category);
}
return category;
}
}
@@ -0,0 +1,69 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
class On4KstProtocolTest {
@Test
void buildsLoginWithReplayOverlap() {
assertEquals(
"LOGINC|DL1ABC|secret|2|KST4Contest v1.2.3|25|0|1|12344|0|",
On4KstProtocol.login(
"DL1ABC", "secret", 2,
"KST4Contest v1.2.3", 12_344L));
}
@Test
void buildsContextSafeSecondChatFrames() {
assertEquals("SDONE|2|", On4KstProtocol.settingsDone(2));
assertEquals("ACHAT|3|25|10|2|100|0|",
On4KstProtocol.addChat(3, 100L));
assertEquals("MSG|2|0|/SETLOC JO31AA|0|",
On4KstProtocol.setLocator(2, "jo31aa"));
assertEquals("MSG|3|0|/SETNAME 10G 10368.200|0|",
On4KstProtocol.setName(3, "10G 10368.200"));
}
@Test
void stripsOnlyTrailingLineEndings() {
assertEquals("CK|", On4KstProtocol.normalizeRawFrame("CK|\r\n"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.normalizeRawFrame("CK|\rBROKEN"));
}
@Test
void rejectsValuesThatCouldCreateASecondProtocolFrame() {
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.chatMessage(2, "hello|0|"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.chatMessage(2, "hello\r\nQUIT|"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.login(
"DL1ABC", "bad|password", 2, "client", 0L));
}
@Test
void rejectsInvalidLocatorAndCategoryBeforeTheyReachTheServer() {
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.setLocator(2, "JO31"));
assertThrows(IllegalArgumentException.class,
() -> On4KstProtocol.settingsDone(99));
}
@Test
void convertsBothHistoryAndLiveMessageTimestampsForReconnect() {
assertEquals(1_186_819_108L,
On4KstConnectionManager.parseMessageTimestamp(
"CR|2|1186819108|EA6VQ|Gabriel|0|msg|0|"));
assertEquals(
LocalDateTime.of(2026, 8, 13, 12, 34, 56)
.toEpochSecond(ZoneOffset.UTC),
On4KstConnectionManager.parseMessageTimestamp(
"CH|2|20260813123456|DL1ABC|Op|0|msg|0|"));
}
}
@@ -0,0 +1,100 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.BufferedReader;
import java.io.OutputStreamWriter;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import kst4contest.model.ChatMessage;
class On4KstSocketThreadTest {
@Test
@Timeout(5)
void eofIsReportedImmediately() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<Void> serverDone = CompletableFuture.runAsync(() -> {
try (Socket accepted = server.accept();
OutputStreamWriter out = new OutputStreamWriter(
accepted.getOutputStream(), StandardCharsets.UTF_8)) {
out.write("CK|\r\n");
out.flush();
} catch (Exception exception) {
throw new RuntimeException(exception);
}
});
try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
LinkedBlockingQueue<ChatMessage> queue = new LinkedBlockingQueue<>();
AtomicBoolean active = new AtomicBoolean(true);
CompletableFuture<Throwable> failure = new CompletableFuture<>();
ReadThread reader = new ReadThread(
7L, client, queue, ignored -> active.get(), ignored -> { },
failure::complete);
reader.start();
assertEquals("CK|", queue.poll(2, TimeUnit.SECONDS).getMessageText());
failure.get(2, TimeUnit.SECONDS);
active.set(false);
reader.join(Duration.ofSeconds(2).toMillis());
}
serverDone.get(2, TimeUnit.SECONDS);
}
}
@Test
@Timeout(5)
void writerUsesOneExactCrLfPerFrameIncludingHeartbeat() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<String> firstLine = new CompletableFuture<>();
CompletableFuture<String> secondLine = new CompletableFuture<>();
CompletableFuture<Void> serverDone = CompletableFuture.runAsync(() -> {
try (Socket accepted = server.accept();
BufferedReader in = new BufferedReader(new InputStreamReader(
accepted.getInputStream(), StandardCharsets.UTF_8))) {
firstLine.complete(in.readLine());
secondLine.complete(in.readLine());
} catch (Exception exception) {
throw new RuntimeException(exception);
}
});
try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
LinkedBlockingQueue<ChatMessage> queue = new LinkedBlockingQueue<>();
AtomicBoolean active = new AtomicBoolean(true);
WriteThread writer = new WriteThread(
11L, client, queue, 2, ignored -> active.get(),
ignored -> { }, ignored -> { });
writer.start();
queue.add(serverFrame(""));
queue.add(serverFrame("SDONE|2|\r"));
assertEquals("", firstLine.get(2, TimeUnit.SECONDS));
assertEquals("SDONE|2|", secondLine.get(2, TimeUnit.SECONDS));
active.set(false);
writer.interrupt();
writer.join(Duration.ofSeconds(2).toMillis());
}
serverDone.get(2, TimeUnit.SECONDS);
}
}
private ChatMessage serverFrame(String text) {
ChatMessage message = new ChatMessage();
message.setMessageDirectedToServer(true);
message.setMessageText(text);
return message;
}
}
@@ -1,119 +1,130 @@
package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.io.BufferedReader;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.Consumer;
import java.util.function.LongPredicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.ChatMessage;
/**
* This thread is responsible for reading telnet servers input at port 23001 and printing it
* to the console.
* It runs in an infinite loop until the client disconnects from the server.
* Reads exactly one immutable ON4KST connection session.
*
* @author www.codejava.net
* <p>EOF is a connection-loss event, not an empty chat message. Every line is
* associated with the session id captured by this reader, so a delayed exception
* from an obsolete socket cannot affect a newer reconnect.</p>
*/
public class ReadThread extends Thread {
private static final Logger LOGGER = Logger.getLogger(ReadThread.class.getName());
private BufferedReader reader;
private Socket socket;
private ChatController client;
public boolean accidentalDisconnected;
public boolean isAccidentalDisconnected() {
return accidentalDisconnected;
}
public void setAccidentalDisconnected(boolean accidentalDisconnected) {
this.accidentalDisconnected = accidentalDisconnected;
}
private final long sessionId;
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LongPredicate sessionIsActive;
private final Consumer<String> inboundActivity;
private final Consumer<Throwable> connectionFailure;
private final BufferedReader reader;
// private boolean readingFinished = true; //kst4contest.test 4 23001
private boolean readingFinished = true;
InputStream input;
public ReadThread(Socket socket, ChatController client) {
/**
* Compatibility constructor for the pre-session controller path.
*
* @deprecated new connections should be created by
* {@link On4KstConnectionManager}
*/
@Deprecated
public ReadThread(Socket socket, ChatController client) throws IOException {
this(0L, socket, client.getMessageRXBus(), ignored -> true,
ignored -> { }, ignored -> { });
}
/**
* Creates the reader for one connection generation.
*
* @param sessionId immutable id of the owning socket session
* @param socket connected ON4KST socket
* @param receiveQueue private receive queue belonging to this session
* @param sessionIsActive guard against callbacks from an obsolete session
* @param inboundActivity callback used for liveness and protocol progress
* @param connectionFailure callback for EOF, I/O and unexpected runtime errors
* @throws IOException if the socket input stream cannot be opened
*/
public ReadThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LongPredicate sessionIsActive,
Consumer<String> inboundActivity,
Consumer<Throwable> connectionFailure
) throws IOException {
this.sessionId = sessionId;
this.socket = socket;
this.client = client;
try {
input = socket.getInputStream();
reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
} catch (IOException ex) {
LOGGER.log(Level.SEVERE, "Error getting input stream", ex);
}
this.receiveQueue = receiveQueue;
this.sessionIsActive = sessionIsActive;
this.inboundActivity = inboundActivity;
this.connectionFailure = connectionFailure;
this.reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(), StandardCharsets.UTF_8));
}
@Override
public void run() {
Thread.currentThread().setName("ReadFromTelnetThread");
ChatMessage message; //bugfix leak, moved out of while
while (true) {
// System.out.println("rdth");
try {
String response = reader.readLine();
message = new ChatMessage();
message.setMessageText(response);
// message.setDirectedToServer(false);
// message.setDirectedToServer(false);
// message.setDirectedToServer(false);
if (response != null) {
client.getMessageRXBus().put(message);
// System.out.println("[RT]: read message and added it to msgrxqueue --- " + response + " ---");
} else {
System.out.println("[RT]: read message responsed a nullstring, do nothing, buffersize = " + socket.getReceiveBufferSize() + ", reader ready? "
+ reader.ready());
// reader = new BufferedReader(new InputStreamReader(input));
// response = reader.readLine();
this.client.getSocket().close();
this.interrupt();
Thread.currentThread().setName("ReadFromOn4Kst-" + sessionId);
LOGGER.log(Level.FINE,
"ON4KST reader started for session {0}", sessionId);
try {
while (!isInterrupted() && sessionIsActive.test(sessionId)) {
String response = reader.readLine();
if (response == null) {
throw new EOFException("ON4KST closed the TCP connection");
}
}
catch (Exception sexc) {
LOGGER.log(Level.SEVERE, "[ReadThread] Socket closed unexpectedly", sexc);
try {
this.client.getSocket().close();
this.interrupt();
break;
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "[ReadThread] Error closing socket", e);
}
inboundActivity.accept(response);
if (!sessionIsActive.test(sessionId)) {
break;
}
ChatMessage message = new ChatMessage();
message.setMessageText(response);
receiveQueue.put(message);
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (IOException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.FINE,
"ON4KST read failed for session " + sessionId,
exception);
connectionFailure.accept(exception);
}
} catch (RuntimeException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.SEVERE, "Unexpected ON4KST reader failure", exception);
connectionFailure.accept(exception);
}
} finally {
LOGGER.log(Level.FINE,
"ON4KST reader stopped for session {0}", sessionId);
}
}
/**
* Interrupts the read loop and closes the session socket.
*
* @return always {@code true} after a successful close
* @throws IOException if closing the reader or socket fails
*/
public boolean terminateConnection() throws IOException {
this.reader.close();
this.input.close();
this.socket.close();
return true;
interrupt();
reader.close();
socket.close();
return true;
}
public boolean isReadingFinished() {
return readingFinished;
}
public void setReadingFinished(boolean readingReady) {
this.readingFinished = readingReady;
}
}
@@ -17,4 +17,20 @@ public interface StatusUpdateListener {
void onUserListUpdated(String reason);
// new: userlist-update
}
/**
* Called whenever the authoritative ON4KST session changes lifecycle state.
*
* <p>The callback may originate from a background connection supervisor. A UI
* implementation must marshal control changes onto its application thread.</p>
*
* @param state new connection, authentication or synchronization state
* @param detail human-readable progress or failure reason
*/
default void onConnectionStateChanged(
On4KstConnectionState state,
String detail
) {
// Optional for non-UI listeners.
}
}
@@ -1,289 +1,173 @@
package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.nio.charset.Charset;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.Consumer;
import java.util.function.LongPredicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
/**
* This thread is responsible for sending content to the chat. As we only use
* the tx function, there is no content in run() method
*
* Serializes and writes exactly one immutable ON4KST connection session.
*
* <p>The writer owns one private queue and appends exactly one CR/LF terminator
* per frame. All category selection and delimiter validation happens before bytes
* reach the socket.</p>
*/
public class WriteThread extends Thread {
private PrintWriter writer;
private Socket socket;
private ChatController client;
private OutputStream output;
private ChatMessage messageToBeSend;
public WriteThread(Socket socket, ChatController client) throws InterruptedException {
this.socket = socket;
this.client = client;
try {
output = socket.getOutputStream();
writer = new PrintWriter(output, true, StandardCharsets.UTF_8);
} catch (IOException ex) {
System.out.println("Error getting output stream: " + ex.getMessage());
ex.printStackTrace();
}
}
private static final Logger LOGGER =
Logger.getLogger(WriteThread.class.getName());
private final long sessionId;
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
private final LongPredicate sessionIsActive;
private final Consumer<Throwable> connectionFailure;
private final Consumer<String> rejectedFrame;
private final BufferedWriter writer;
private final int defaultCategory;
/**
* This method is used to send a message to the server, raw formatted. E.g. for
* the keepalive message. This method sends only in the main message-Category. To send it in a category
* "defined by Chatmessage", use txByRxmsgCatOrigin(Chatmessage "toBeSend")
*
* @param messageToServer
* @throws InterruptedException
*/
public void tx(ChatMessage messageToServer) throws InterruptedException {
// writer.println(messageToServer.getMessage()); //kst4contest.test 4 23001
// writer.flush(); //kst4contest.test 4 23001
System.out.println(messageToServer.getMessageText() + "< sended to the writer");
writer.println(messageToServer.getMessageText());
}
/**
* This method is used to send a message directly to a receiver in a special chatcategory. The receivers category
* will be read out of the Chatmessage.getChatCategory method. <b> The message text will be modified to fit kst
* messageformat</b>
* Compatibility constructor for the pre-session controller path.
*
* @param messageToServer
* @throws InterruptedException
* @deprecated new connections should be created by
* {@link On4KstConnectionManager}
*/
public void txByRxmsgCatOrigin(ChatMessage messageToServer) throws InterruptedException {
// writer.println(messageToServer.getMessage()); //kst4contest.test 4 23001
// writer.flush(); //kst4contest.test 4 23001
String originalMessageText = messageToServer.getMessageText() + "";
String newMessageText = "";
newMessageText = ("MSG|" + messageToServer.getChatCategory().getCategoryNumber()
+ "|0|" + originalMessageText + "|0|"); //original before 1.26
System.out.println(newMessageText + "< sended to the writer (DIRECTED REPLY)");
writer.println(newMessageText);
@Deprecated
public WriteThread(Socket socket, ChatController client) throws IOException {
this(0L, socket, client.getMessageTXBus(),
client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber(),
ignored -> true,
ignored -> { }, System.out::println);
}
/**
* This method gets a textmessage to the chat and adds some characters to hit
* the neccessarry format to send a message in the on4kst chat either to another
* station or to the public.
*
* @param messageToServer
* @throws InterruptedException
* Creates the writer for one connection generation.
*
* @param sessionId immutable id of the owning socket session
* @param socket connected ON4KST socket
* @param transmitQueue private transmit queue belonging to this session
* @param defaultCategory fallback category for unqualified chat messages
* @param sessionIsActive guard against writes from an obsolete session
* @param connectionFailure callback for socket and unexpected runtime errors
* @param rejectedFrame callback for locally rejected protocol content
* @throws IOException if the socket output stream cannot be opened
*/
public void txKSTFormatted(ChatMessage messageToServer) throws InterruptedException {
public WriteThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> transmitQueue,
int defaultCategory,
LongPredicate sessionIsActive,
Consumer<Throwable> connectionFailure,
Consumer<String> rejectedFrame
) throws IOException {
this.sessionId = sessionId;
this.socket = socket;
this.transmitQueue = transmitQueue;
this.defaultCategory = defaultCategory;
this.sessionIsActive = sessionIsActive;
this.connectionFailure = connectionFailure;
this.rejectedFrame = rejectedFrame;
this.writer = new BufferedWriter(new OutputStreamWriter(
socket.getOutputStream(), StandardCharsets.UTF_8));
}
// writer.println(messageToServer.getMessageText());
messageToBeSend = messageToServer;
@Override
public void run() {
Thread.currentThread().setName("WriteToOn4Kst-" + sessionId);
LOGGER.log(Level.FINE,
"ON4KST writer started for session {0}", sessionId);
try {
messageToBeSend = client.getMessageTXBus().take();
// this.client.getmesetChatsetServerready(true);
} catch (InterruptedException e) {
e.printStackTrace();
}
String messageLine = messageToBeSend.getMessageText();
if (messageToBeSend.isMessageDirectedToServer()) {
/**
* We have to check if we only commands the server (keepalive) or want do talk
* to the community
*/
try {
tx(messageToBeSend);
System.out.println("BUS: tx: " + messageToBeSend.getMessageText());
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
} else {
ChatMessage ownMSG = new ChatMessage();
// ownMSG.setMessageText(
// "MSG|" + this.client.getCategory().getCategoryNumber() + "|0|" + messageLine + "|0|");
ownMSG.setMessageText("MSG|" + this.client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber()
+ "|0|" + messageLine + "|0|"); //original before 1.26
try {
tx(ownMSG);
System.out.println("BUS: tx: " + ownMSG.getMessageText());
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
if (messageToBeSend.equals("/QUIT")) {
try {
this.client.getReadThread().terminateConnection();
this.client.getReadThread().interrupt();
this.client.getWriteThread().terminateConnection();
this.client.getWriteThread().interrupt();
this.interrupt();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public boolean terminateConnection() throws IOException {
this.output.close();
this.socket.close();
return true;
}
public void run() {
Thread.currentThread().setName("WriteToTelnetThread");
while (true) {
try {
messageToBeSend = client.getMessageTXBus().take();
if (messageToBeSend.getMessageText().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)
&& messageToBeSend.getMessageSenderName().equals(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
client.getMessageRXBus().clear();
this.interrupt();
while (!isInterrupted() && sessionIsActive.test(sessionId)) {
ChatMessage message = transmitQueue.take();
if (isPoisonPill(message)) {
break;
}
if (!sessionIsActive.test(sessionId)) {
break;
} else {
String messageLine = messageToBeSend.getMessageText();
if (messageToBeSend.isMessageDirectedToServer()) {
/**
* We have to check if we only commands the server (keepalive) or want do talk
* to the community
*/
try {
tx(messageToBeSend);
System.out.println("BUS: tx: " + messageToBeSend.getMessageText());
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
} else { //message is not directed to the server, it´s directed to all or to a station
if (messageToBeSend.getChatCategory() == this.client.getChatCategoryMain() || messageToBeSend.getChatCategory() == this.client.getChatCategorySecondChat()) {
txByRxmsgCatOrigin(messageToBeSend);
} else { //default bhv if destination cat is not detectable
ChatMessage ownMSG = new ChatMessage();
ownMSG.setMessageText(
"MSG|" + this.client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber() + "|0|"
+ messageLine + "|0|");
try {
tx(ownMSG);
System.out.println("WT: tx (raw): " + ownMSG.getMessageText());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
System.out.println("WritheTh: got message out of the queue: " + messageToBeSend.getMessageText());
// this.client.getmesetChatsetServerready(true);
} catch (InterruptedException e) {
e.printStackTrace();
client.getMessageTXBus().clear();
}
// String messageLine = messageTextRaw.getMessageText();
//
// if (messageTextRaw.isMessageDirectedToServer()) {
// /**
// * We have to check if we only commands the server (keepalive) or want do talk
// * to the community
// */
//
// try {
// tx(messageTextRaw);
// System.out.println("BUS: tx: " + messageTextRaw.getMessageText());
//
// } catch (InterruptedException e) {
// // TODO Auto-generated catch block
// e.printStackTrace();
// }
//
// } else {
//
// ChatMessage ownMSG = new ChatMessage();
//
//// ownMSG.setMessageText(
//// "MSG|" + this.client.getCategory().getCategoryNumber() + "|0|" + messageLine + "|0|");
//
// ownMSG.setMessageText(
// "MSG|" + this.client.getChatPreferences().getLoginChatCategory().getCategoryNumber() + "|0|"
// + messageLine + "|0|");
//
// try {
// tx(ownMSG);
// System.out.println("BUS: tx: " + ownMSG.getMessageText());
//
// } catch (InterruptedException e) {
// // TODO Auto-generated catch block
// e.printStackTrace();
// }
// }
try {
writeFrame(formatFrame(message));
} catch (IllegalArgumentException invalidFrame) {
LOGGER.log(Level.FINE,
"Rejected outbound ON4KST frame in session {0}: {1}",
new Object[] {sessionId, invalidFrame.getMessage()});
rejectedFrame.accept(invalidFrame.getMessage());
}
}
// if (messageTextRaw.equals("/QUIT")) {
// try {
// this.client.getReadThread().terminateConnection();
// this.client.getReadThread().interrupt();
// this.client.getWriteThread().terminateConnection();
// this.client.getWriteThread().interrupt();
// this.interrupt();
//
// } catch (IOException e) {
// // TODO Auto-generated catch block
// e.printStackTrace();
// }
// }
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (IOException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.FINE,
"ON4KST write failed for session " + sessionId,
exception);
connectionFailure.accept(exception);
}
} catch (RuntimeException exception) {
if (sessionIsActive.test(sessionId)) {
LOGGER.log(Level.SEVERE,
"Unexpected ON4KST writer failure for session "
+ sessionId,
exception);
connectionFailure.accept(exception);
}
} finally {
LOGGER.log(Level.FINE,
"ON4KST writer stopped for session {0}", sessionId);
}
}
// while (true) {
//
// }
private String formatFrame(ChatMessage message) {
if (message == null) {
throw new IllegalArgumentException("Cannot send an empty ON4KST message");
}
if (message.isMessageDirectedToServer()) {
return On4KstProtocol.normalizeRawFrame(message.getMessageText());
}
ChatCategory category = message.getChatCategory();
return On4KstProtocol.chatMessage(
category == null ? defaultCategory : category.getCategoryNumber(),
message.getMessageText());
}
private void writeFrame(String frame) throws IOException {
writer.write(frame);
writer.write("\r\n");
writer.flush();
}
private boolean isPoisonPill(ChatMessage message) {
return message != null
&& ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(
message.getMessageText())
&& ApplicationConstants.DISCONNECT_RDR_POISONPILL.equals(
message.getMessageSenderName());
}
/**
* Interrupts the write loop and closes the session socket.
*
* @return always {@code true} after a successful close
* @throws IOException if closing the writer or socket fails
*/
public boolean terminateConnection() throws IOException {
interrupt();
writer.close();
socket.close();
return true;
}
}
@@ -4,6 +4,13 @@ import java.util.TimerTask;
import kst4contest.model.ChatMessage;
/**
* Enqueues the empty application-level reply expected by ON4KST keepalive
* handling.
*
* <p>The writer owns line termination and appends exactly one CR/LF sequence.
* Supplying an empty payload here avoids the historic double-terminator frame.</p>
*/
public class keepAliveMessageSenderTask extends TimerTask {
private ChatController client;
@@ -16,13 +23,14 @@ public class keepAliveMessageSenderTask extends TimerTask {
@Override
public void run() {
Thread.currentThread().setName("KeepAliveMessageSenderTask");
// System.out.println("[keepalive: ] Thread runned now");
ChatMessage keepAliveMSG = new ChatMessage();
keepAliveMSG.setMessageText("\r");
// WriteThread appends exactly one CRLF. An empty frame is the protocol reply.
keepAliveMSG.setMessageText("");
keepAliveMSG.setMessageDirectedToServer(true);
// System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + " [keepaliveTask]: Sending keepalive: "
@@ -33,4 +41,4 @@ public class keepAliveMessageSenderTask extends TimerTask {
this.client.getMessageTXBus().add(keepAliveMSG);
}
}
}
@@ -113,12 +113,32 @@ public final class BandOpportunityResolver {
return detectedBands;
}
/*
* Explicit band descriptions:
* 2m, 70cm, 23cm, 432, 1296, ...
*/
for (Map.Entry<Band, Pattern> entry : STATION_NAME_BAND_PATTERNS.entrySet()) {
if (entry.getValue().matcher(stationName).find()) {
detectedBands.add(entry.getKey());
}
}
/*
* Explicit full QRGs in name field:
* 432.357 -> 432 MHz
* 1296.210 -> 1296 MHz
* etc.
*/
for (FrequencyTextParser.DetectedFrequency detectedFrequency
: FrequencyTextParser.findExplicitFrequencies(
stationName
)) {
detectedBands.add(
detectedFrequency.getBand()
);
}
return detectedBands;
}
@@ -0,0 +1,191 @@
package kst4contest.logic;
import kst4contest.model.Band;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Common parser for explicit amateur-radio frequencies embedded in text.
*
* <p>This parser deliberately handles only complete frequencies such as
* 144.300, 432.357 or 10368.100. Relative forms such as ".210" or ambiguous
* bare values such as "210" require additional message context and remain the
* responsibility of the chat-message parser.</p>
*/
public final class FrequencyTextParser {
/*
* Examples:
* 50.150
* 144.300
* 432,357
* 10368.100
* 144.300.03
*
* At least two digits are required before the decimal separator. This
* intentionally prevents "1.2" from being interpreted as a frequency.
*/
private static final Pattern EXPLICIT_FREQUENCY_PATTERN = Pattern.compile(
"(?<![A-Z0-9])"
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)"
+ "(?!\\d)",
Pattern.CASE_INSENSITIVE
);
private FrequencyTextParser() {
}
/**
* Finds all distinct explicit frequencies that fall into one of the bands
* supported by {@link Band}.
*/
public static List<DetectedFrequency> findExplicitFrequencies(String text) {
if (text == null || text.isBlank()) {
return List.of();
}
Matcher matcher = EXPLICIT_FREQUENCY_PATTERN.matcher(text);
Map<String, DetectedFrequency> uniqueFrequencies =
new LinkedHashMap<>();
while (matcher.find()) {
DetectedFrequency detected =
parseExplicitFrequency(matcher.group(1));
if (detected == null) {
continue;
}
String uniqueKey =
detected.getBand().name()
+ "|"
+ Double.toString(
detected.getFrequencyMHz()
);
uniqueFrequencies.putIfAbsent(
uniqueKey,
detected
);
}
return List.copyOf(
new ArrayList<>(uniqueFrequencies.values())
);
}
/**
* Parses one complete frequency.
*
* @return detected band/frequency or {@code null} when the value is invalid
* or outside the supported amateur bands
*/
public static DetectedFrequency parseExplicitFrequency(
String rawFrequency
) {
if (rawFrequency == null || rawFrequency.isBlank()) {
return null;
}
String normalized =
normalizeFrequencyString(
rawFrequency
.trim()
.replace(',', '.')
);
try {
double frequencyMHz =
Double.parseDouble(normalized);
if (!Double.isFinite(frequencyMHz)) {
return null;
}
Band band =
Band.fromFrequency(frequencyMHz);
if (band == null) {
return null;
}
return new DetectedFrequency(
band,
frequencyMHz,
rawFrequency.trim()
);
} catch (NumberFormatException ignored) {
return null;
}
}
/**
* Normalizes KST-style frequency strings with an optional second decimal
* separator.
*
* Examples:
* 144.300.03 -> 144.30003
* 144.300 -> 144.300
*/
private static String normalizeFrequencyString(
String rawInput
) {
int firstDotIndex = rawInput.indexOf('.');
if (firstDotIndex < 0) {
return rawInput;
}
String decimalPart =
rawInput.substring(firstDotIndex + 1);
if (!decimalPart.contains(".")) {
return rawInput;
}
decimalPart =
decimalPart.replace(".", "");
return rawInput.substring(0, firstDotIndex + 1)
+ decimalPart;
}
/**
* Immutable result of one explicit-frequency detection.
*/
public static final class DetectedFrequency {
private final Band band;
private final double frequencyMHz;
private final String sourceText;
private DetectedFrequency(
Band band,
double frequencyMHz,
String sourceText
) {
this.band = band;
this.frequencyMHz = frequencyMHz;
this.sourceText = sourceText;
}
public Band getBand() {
return band;
}
public double getFrequencyMHz() {
return frequencyMHz;
}
public String getSourceText() {
return sourceText;
}
}
}
@@ -26,6 +26,7 @@ public final class PropagationFrequencyResolver {
/** Explains why a frequency was selected. */
public enum Source {
CURRENT_QRG,
STATION_NAME_QRG,
STATION_NAME,
DUAL_CATEGORY_FALLBACK,
CHAT_CATEGORY
@@ -90,6 +91,20 @@ public final class PropagationFrequencyResolver {
);
}
FrequencyCandidate stationNameQrg =
findUniqueStationNameQrg(
supportedVariants,
usableBands
);
if (stationNameQrg != null) {
return new Resolution(
stationNameQrg.band,
stationNameQrg.frequencyMHz,
Source.STATION_NAME_QRG
);
}
EnumSet<Band> nameBands = EnumSet.noneOf(Band.class);
for (ChatMember variant : supportedVariants) {
nameBands.addAll(
@@ -172,6 +187,73 @@ public final class PropagationFrequencyResolver {
return latest;
}
/**
* Resolves one exact station-name QRG only when the evidence is unambiguous.
*
* <p>The same QRG repeated in several category variants counts only once.
* If different explicit QRGs are advertised, no run frequency is guessed and
* the caller continues with normal band-name/category resolution.</p>
*/
private static FrequencyCandidate findUniqueStationNameQrg(
List<ChatMember> variants,
EnumSet<Band> usableBands
) {
java.util.LinkedHashMap<String, FrequencyCandidate> uniqueCandidates =
new java.util.LinkedHashMap<>();
for (ChatMember variant : variants) {
if (variant == null) {
continue;
}
for (FrequencyTextParser.DetectedFrequency detectedFrequency
: FrequencyTextParser.findExplicitFrequencies(
variant.getName()
)) {
Band band =
detectedFrequency.getBand();
if (!usableBands.contains(band)) {
continue;
}
double frequencyMHz =
detectedFrequency.getFrequencyMHz();
String key =
band.name()
+ "|"
+ Double.toString(frequencyMHz);
uniqueCandidates.putIfAbsent(
key,
new FrequencyCandidate(
band,
frequencyMHz,
Long.MIN_VALUE
)
);
/*
* More than one different exact QRG means that we cannot safely
* identify one run frequency.
*/
if (uniqueCandidates.size() > 1) {
return null;
}
}
}
return uniqueCandidates.size() == 1
? uniqueCandidates.values()
.iterator()
.next()
: null;
}
private static boolean isSupportedVariant(ChatMember member) {
if (member == null || member.getChatCategory() == null) {
return false;
@@ -518,6 +518,7 @@ public class ChatMember {
this.frequency = frequency;
}
public long getActivityTimeLastInEpoch() {
return activityTimeLastInEpoch;
}
@@ -595,6 +596,40 @@ public class ChatMember {
}
/**
* Initializes the compatibility frequency only when no frequency is known yet.
*
* <p>This is used for an explicit QRG contained in the ON4KST station name.
* A subsequently detected chat QRG may overwrite this initial value, but a
* station-name QRG never replaces an already known frequency.</p>
*
* @param frequencyMhz explicit station-name frequency
* @return true when the frequency was initialized
*/
public boolean initializeFrequencyIfEmpty(double frequencyMhz) {
if (!Double.isFinite(frequencyMhz)
|| frequencyMhz <= 0.0) {
return false;
}
if (frequency == null) {
frequency = new SimpleStringProperty();
}
String currentFrequency = frequency.get();
if (currentFrequency != null
&& !currentFrequency.isBlank()) {
return false;
}
frequency.set(
Double.toString(frequencyMhz)
);
return true;
}
/**
* Sets all worked information of this object to false. Scope: GUI, Reset Button
* for worked info, called by appcontroller
@@ -0,0 +1,123 @@
package kst4contest.test;
import kst4contest.logic.FrequencyTextParser;
import kst4contest.model.Band;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FrequencyTextParserTest {
@Test
void detectsExplicitStationNameFrequencies() {
List<FrequencyTextParser.DetectedFrequency> detected =
FrequencyTextParser.findExplicitFrequencies(
"Phil 432.357"
);
assertEquals(1, detected.size());
assertEquals(
Band.B_432,
detected.get(0).getBand()
);
assertEquals(
432.357,
detected.get(0).getFrequencyMHz(),
0.000_001
);
}
@Test
void detectsCommaAndMicrowaveFrequencies() {
List<FrequencyTextParser.DetectedFrequency> detected =
FrequencyTextParser.findExplicitFrequencies(
"QRV 1296,210 / 10368.100"
);
assertEquals(2, detected.size());
assertEquals(
Band.B_1296,
detected.get(0).getBand()
);
assertEquals(
Band.B_10G,
detected.get(1).getBand()
);
}
@Test
void detectsFiftyAndSeventyMhzFrequencies() {
assertEquals(
Band.B_50,
FrequencyTextParser
.findExplicitFrequencies("50.150")
.get(0)
.getBand()
);
assertEquals(
Band.B_70,
FrequencyTextParser
.findExplicitFrequencies("70.200")
.get(0)
.getBand()
);
}
@Test
void ignoresRelativeAndAmbiguousValues() {
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"Mike .180"
)
.isEmpty()
);
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"Mike 180"
)
.isEmpty()
);
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"David 1.2"
)
.isEmpty()
);
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"RST 599"
)
.isEmpty()
);
}
@Test
void normalizesSubKhzNotation() {
FrequencyTextParser.DetectedFrequency detected =
FrequencyTextParser
.findExplicitFrequencies(
"144.300.03"
)
.get(0);
assertEquals(Band.B_144, detected.getBand());
assertEquals(
144.30003,
detected.getFrequencyMHz(),
0.000_001
);
}
}
@@ -1,5 +1,6 @@
package kst4contest.view;
import kst4contest.utils.VersionUtils;
import kst4contest.controller.On4KstConnectionState;
import javafx.scene.image.Image;
import java.io.File;
@@ -81,6 +82,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// Enables optional color highlighting of the QRA/grid cell.
// The status text itself remains visible independently of this flag.
private static final Logger LOGGER = Logger.getLogger(
Kst4ContestApplication.class.getName());
private boolean gridSquareHighlightEnabled = false;
@@ -113,6 +118,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private StationMapView stationMapView; //view class for the avl stn map
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
private final Button btnConnectionStateIndicator = new Button("LINK");
private final Tooltip tipConnectionStateIndicator = new Tooltip();
private On4KstConnectionState lastDisplayedConnectionState;
private String lastDisplayedConnectionDetail = "";
private final Button btnBandUpgradeIndicator = new Button("BAND+");
private final Tooltip tipBandUpgradeIndicator = new Tooltip();
private Timeline bandUpgradeBlinkTimeline;
@@ -5320,8 +5330,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
menuItemOptionsAwayBack.setDisable(false);
menuItemOptionsSetFrequencyAsName.setDisable(false);
chatcontroller.setConnectedAndLoggedIn(true);
chatcontroller.setDisconnected(false);
// chatcontroller.setConnectedAndLoggedIn(true);
// chatcontroller.setDisconnected(false);
} catch (InterruptedException | IOException e) {
e.printStackTrace();
@@ -5643,6 +5653,121 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
btnSkedWarnIndicator.setTooltip(tipSkedWarnIndicator);
}
private void initConnectionStateIndicatorButton() {
LOGGER.fine("Initializing compact ON4KST connection-state indicator");
btnConnectionStateIndicator.setMouseTransparent(true);
btnConnectionStateIndicator.setFocusTraversable(false);
btnConnectionStateIndicator.setMnemonicParsing(false);
btnConnectionStateIndicator.setMinSize(44, 22);
btnConnectionStateIndicator.setPrefSize(44, 22);
btnConnectionStateIndicator.setMaxSize(44, 22);
btnConnectionStateIndicator.setTooltip(tipConnectionStateIndicator);
btnConnectionStateIndicator.setAccessibleText(
"ON4KST connection state");
FlowPane.setMargin(
btnConnectionStateIndicator, new Insets(2, 4, 2, 4));
updateConnectionStateIndicator(
chatcontroller.getOn4KstConnectionState(),
"No ON4KST connection");
}
private void updateConnectionStateIndicator(
On4KstConnectionState state,
String detail
) {
if (!Platform.isFxApplicationThread()) {
LOGGER.warning(
"Connection indicator update arrived outside the JavaFX thread; "
+ "rescheduling it safely");
Platform.runLater(() -> updateConnectionStateIndicator(state, detail));
return;
}
On4KstConnectionState effectiveState = state == null
? On4KstConnectionState.DISCONNECTED : state;
String stateDetail = detail == null || detail.isBlank()
? effectiveState.name() : detail;
logConnectionIndicatorTransition(effectiveState, stateDetail);
tipConnectionStateIndicator.setText(
"ON4KST link: " + effectiveState.name() + "\n" + stateDetail);
btnConnectionStateIndicator.setAccessibleHelp(stateDetail);
String commonStyle =
"-fx-font-size: 10px;"
+ "-fx-font-weight: bold;"
+ "-fx-padding: 1 5 1 5;"
+ "-fx-background-radius: 6;"
+ "-fx-border-radius: 6;"
+ "-fx-border-width: 2;";
switch (effectiveState) {
case ONLINE -> {
btnConnectionStateIndicator.setText("LINK");
btnConnectionStateIndicator.setStyle(
commonStyle
+ "-fx-background-color: #238636;"
+ "-fx-border-color: #56d364;"
+ "-fx-text-fill: white;"
+ "-fx-effect: dropshadow(three-pass-box, "
+ "rgba(35,134,54,0.55), 5, 0.25, 0, 0);");
}
case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING,
SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT, STOPPING -> {
btnConnectionStateIndicator.setText("LINK…");
btnConnectionStateIndicator.setStyle(
commonStyle
+ "-fx-background-color: #ffb300;"
+ "-fx-border-color: #ffe082;"
+ "-fx-text-fill: #1b1b1b;"
+ "-fx-effect: dropshadow(three-pass-box, "
+ "rgba(255,179,0,0.65), 6, 0.3, 0, 0);");
}
case DISCONNECTED, RECONNECT_WAIT -> {
btnConnectionStateIndicator.setText("LINK!");
btnConnectionStateIndicator.setStyle(
commonStyle
+ "-fx-background-color: #d50000;"
+ "-fx-border-color: #ff6b6b;"
+ "-fx-text-fill: white;"
+ "-fx-effect: dropshadow(three-pass-box, "
+ "rgba(255,0,0,0.95), 10, 0.55, 0, 0);");
}
}
}
private void logConnectionIndicatorTransition(
On4KstConnectionState newState,
String newDetail
) {
boolean stateChanged = newState != lastDisplayedConnectionState;
boolean detailChanged = !Objects.equals(
newDetail, lastDisplayedConnectionDetail);
if (!stateChanged && !detailChanged) {
return;
}
On4KstConnectionState previousState = lastDisplayedConnectionState;
Level logLevel = switch (newState) {
case DISCONNECTED, RECONNECT_WAIT -> Level.WARNING;
default -> Level.INFO;
};
LOGGER.log(logLevel,
"ON4KST connection indicator: {0} -> {1}; detail: {2}",
new Object[] {
previousState == null ? "UNINITIALIZED" : previousState,
newState,
newDetail
});
lastDisplayedConnectionState = newState;
lastDisplayedConnectionDetail = newDetail;
}
private void showBlinkingSkedWarnIndicator(String text) {
@@ -6590,6 +6715,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
flwpne_StatusBar.getChildren().add(mainScreenMenuBar);
bPaneChatWindow.setTop(flwpne_StatusBar);
initConnectionStateIndicatorButton();
flwpne_StatusBar.getChildren().add(btnConnectionStateIndicator);
initSkedWarnIndicatorButton();
chatcontroller.lastUiReminderEventProperty().addListener(
@@ -6949,9 +7077,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
else {
chatState = "DISCONNECTED!";
On4KstConnectionState connectionState =
chatcontroller.getOn4KstConnectionState();
chatState = switch (connectionState) {
case RECONNECT_WAIT ->
"CONNECTION LOST reconnect scheduled";
case CONNECTING ->
"Connecting to ON4KST…";
case WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING ->
"Connected authenticating with ON4KST…";
case SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT ->
"Connected synchronizing ON4KST chat data…";
default ->
"DISCONNECTED!";
};
chatcontroller.getChatPreferences().setChatState(chatState);
}
if (chatcontroller.isDisconnected()) {
chatState = "DISCONNECTED!";
chatcontroller.getChatPreferences().setChatState(chatState);
@@ -11578,8 +11722,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
txtFldstn_maxQRBDefault.setDisable(true);
btnOptionspnlConnect.setDisable(true);
btnOptionspnlDisconnect.setDisable(false);
chatcontroller.setConnectedAndLoggedIn(true);
chatcontroller.setDisconnected(false);
// chatcontroller.setConnectedAndLoggedIn(true);
// chatcontroller.setDisconnected(false);
station_chkBxEnableSecondChat.setDisable(true);
stn_choiceBxChatChategorySecond.setDisable(true);
}
@@ -11882,7 +12026,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
String logDir = Path.of(System.getProperty("user.home"), ".praktiKST").toString();
new File(logDir).mkdirs();
FileHandler fileHandler = new FileHandler(logDir + "/kst4contest-errors.log", true);
fileHandler.setLevel(Level.SEVERE);
// Connection loss/reconnect diagnostics are warnings, not fatal crashes.
fileHandler.setLevel(Level.WARNING);
fileHandler.setFormatter(new SimpleFormatter());
Logger rootLogger = Logger.getLogger("");
rootLogger.addHandler(fileHandler);
@@ -11892,17 +12037,77 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
@Override
public void onThreadStatusChanged(String key, ThreadStateMessage threadStateMessage) {
public void onThreadStatusChanged(
String key,
ThreadStateMessage threadStateMessage
) {
if (threadStateMessage == null) {
LOGGER.log(Level.WARNING,
"Ignoring empty thread-status update from source {0}",
key == null ? "UNKNOWN" : key);
return;
}
if (key == null || key.isBlank()) {
LOGGER.log(Level.WARNING,
"Ignoring thread-status update without a source key. Detail: {0}",
threadStateMessage.getRunningInformation());
return;
}
Platform.runLater(() -> {
updateStatusButton(key, threadStateMessage);
});
if ("ON4KST".equalsIgnoreCase(key)) {
String detail = threadStateMessage.getRunningInformation();
Platform.runLater(() -> updateConnectionStateIndicator(
chatcontroller.getOn4KstConnectionState(), detail));
return;
}
Platform.runLater(() -> updateStatusButton(key, threadStateMessage));
maybeShowBandUpgradeIndicator(key, threadStateMessage);
//if we receive a threadstatemessage for sked warning, enable the sked warning
}
}
public void onConnectionStateChanged(
On4KstConnectionState state,
String detail
) {
On4KstConnectionState effectiveState = state;
if (effectiveState == null) {
LOGGER.log(Level.WARNING,
"Received ON4KST connection callback without a state; "
+ "treating it as disconnected. Detail: {0}",
detail);
effectiveState = On4KstConnectionState.DISCONNECTED;
}
On4KstConnectionState stateToDisplay = effectiveState;
Platform.runLater(() -> {
boolean active = stateToDisplay.isConnectionAttemptActive();
boolean online = stateToDisplay.isOnline();
updateConnectionStateIndicator(stateToDisplay, detail);
if (menuItemFileConnect != null) {
menuItemFileConnect.setDisable(active);
}
if (menuItemFileDisconnect != null) {
menuItemFileDisconnect.setDisable(!active);
}
if (menuItemOptionsAwayBack != null) {
menuItemOptionsAwayBack.setDisable(!online);
}
if (menuItemOptionsSetFrequencyAsName != null) {
menuItemOptionsSetFrequencyAsName.setDisable(!online);
}
if (btnOptionspnlConnect != null) {
btnOptionspnlConnect.setDisable(active);
}
if (sendButton != null) {
sendButton.setDisable(!online);
}
if (txt_chatMessageUserInput != null) {
txt_chatMessageUserInput.setDisable(!online);
}
});
}
/**
@@ -15,6 +15,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import kst4contest.logic.FrequencyTextParser;
/**
* Builds immutable map snapshots from the currently visible chat members.
@@ -204,6 +205,92 @@ public final class MapCallsignRawSnapshotBuilder {
));
}
}
}
/*
* A QRG explicitly contained in the current station name does not expire.
*
* Recent dynamic QRG evidence has priority. Therefore station-name QRGs fill
* only bands for which no recent dynamic frequency is available.
*
* Different explicit QRGs for the same band are considered ambiguous and are
* not reduced to one arbitrary run frequency.
*/
Map<Band, Map<String, FrequencyTextParser.DetectedFrequency>>
stationNameFrequenciesByBand =
new EnumMap<>(Band.class);
for (ChatMember variant : variants) {
if (variant == null) {
continue;
}
for (FrequencyTextParser.DetectedFrequency detectedFrequency
: FrequencyTextParser.findExplicitFrequencies(
variant.getName()
)) {
Band band =
detectedFrequency.getBand();
if (availableBands == null
|| !availableBands.contains(band)) {
continue;
}
stationNameFrequenciesByBand
.computeIfAbsent(
band,
ignored -> new LinkedHashMap<>()
)
.putIfAbsent(
Double.toString(
detectedFrequency.getFrequencyMHz()
),
detectedFrequency
);
}
}
for (Map.Entry<
Band,
Map<String, FrequencyTextParser.DetectedFrequency>>
entry : stationNameFrequenciesByBand.entrySet()) {
Band band = entry.getKey();
/*
* A recent QRG detected from chat always wins.
*/
if (latestByBand.containsKey(band)) {
continue;
}
/*
* Do not guess when several different QRGs were published for the
* same band.
*/
if (entry.getValue().size() != 1) {
continue;
}
FrequencyTextParser.DetectedFrequency detectedFrequency =
entry.getValue()
.values()
.iterator()
.next();
latestByBand.put(
band,
new FrequencyCandidate(
band,
formatFrequency(
detectedFrequency.getFrequencyMHz()
),
Long.MIN_VALUE
)
);
}
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
@@ -79,6 +79,8 @@ public final class StationMapBridge {
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
stationMapView.setOnResetView(this::handleMapReset);
chatController.getLst_chatMemberSortedFilteredList().addListener(
(ListChangeListener<ChatMember>) change -> scheduleRefresh()
);
@@ -98,6 +100,35 @@ public final class StationMapBridge {
requestImmediateRefresh();
}
private void handleMapReset() {
Runnable resetAction = () -> {
/*
* Ignore an analysis result that may still arrive for the
* previously selected station.
*/
pathAnalysisGeneration.incrementAndGet();
lastPathAnalysisRequestSignature = "";
/*
* Clear the application's central station selection.
*/
chatController.getScoreService().setSelectedChatMember(null);
/*
* Keep the main station table synchronized with the central selection.
*/
chatMemberTable.getSelectionModel().clearSelection();
requestImmediateRefresh();
};
if (Platform.isFxApplicationThread()) {
resetAction.run();
} else {
Platform.runLater(resetAction);
}
}
public void showWindow() {
stationMapView.showWindow();
requestImmediateRefresh();
@@ -87,14 +87,15 @@ public final class StationMapView {
private final Button pathAnalysisVisibilityButton = new Button();
private final Tooltip pathAnalysisVisibilityTooltip = new Tooltip();
private final Button resetViewButton = new Button("Reset view");
private final Tooltip statusTooltip = new Tooltip();
private Runnable onResetView;
private double lastDetailDividerPosition = 0.65;
private final Label detailCallsignValue = new Label("-");
private final Label detailLocatorValue = new Label("-");
private final Label detailQrbValue = new Label("-");
private final Label detailQtfValue = new Label("-");
private final Label detailBandsValue = new Label("-");
private final TextArea detailFrequenciesArea = new TextArea();
private final Label detailAirplanesValue = new Label("-");
private final Button triggerClusterSpotButton = new Button("Trigger cluster spot");
private final Label detailPathFromLocatorValue = new Label("-");
@@ -210,6 +211,11 @@ public final class StationMapView {
this.onTriggerClusterSpot = onTriggerClusterSpot;
}
public void setOnResetView(Runnable onResetView) {
this.onResetView = onResetView;
}
public void showWindow() {
applyThemeFromPreferences();
@@ -282,10 +288,6 @@ public final class StationMapView {
stage.setTitle("Station Map");
detailFrequenciesArea.setEditable(false);
detailFrequenciesArea.setWrapText(true);
detailFrequenciesArea.setPrefRowCount(4);
detailPathEndpointsValue.setWrapText(true);
detailPathEndpointsValue.setMaxWidth(Double.MAX_VALUE);
@@ -299,12 +301,23 @@ public final class StationMapView {
detailPathMechanismsValue.setMaxWidth(Double.MAX_VALUE);
triggerClusterSpotButton.setDisable(true);
triggerClusterSpotButton.setVisible(false);
triggerClusterSpotButton.setManaged(false);
triggerClusterSpotButton.setMinWidth(Region.USE_PREF_SIZE);
triggerClusterSpotButton.setOnAction(event -> {
if (detailCallsignRaw != null && onTriggerClusterSpot != null) {
onTriggerClusterSpot.accept(detailCallsignRaw);
}
});
resetViewButton.setMinWidth(Region.USE_PREF_SIZE);
resetViewButton.setOnAction(event -> {
if (onResetView != null) {
onResetView.run();
}
});
pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE);
pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip);
pathAnalysisVisibilityButton.setOnAction(event ->
@@ -377,7 +390,7 @@ public final class StationMapView {
);
pathAnalysisSection = createPathAnalysisSection();
detailPane = new VBox(10, createSelectedStationSection(), pathAnalysisSection);
detailPane = new VBox(10, pathAnalysisSection);
detailPane.setPadding(new Insets(10));
@@ -489,20 +502,25 @@ public final class StationMapView {
* must always have an obvious way to restore a previously hidden analysis.
*/
private HBox createMapHeader() {
// The status may be shortened before the show/hide control is ever clipped.
statusLabel.setMinWidth(0);
statusLabel.setMaxWidth(Double.MAX_VALUE);
statusLabel.setTextOverrun(OverrunStyle.ELLIPSIS);
statusLabel.setTooltip(statusTooltip);
HBox header = new HBox(
10,
statusLabel,
triggerClusterSpotButton,
resetViewButton,
pathAnalysisHiddenHintLabel,
pathAnalysisVisibilityButton
);
header.setAlignment(Pos.CENTER_LEFT);
header.setPadding(new Insets(8));
HBox.setHgrow(statusLabel, Priority.ALWAYS);
return header;
}
@@ -523,6 +541,7 @@ public final class StationMapView {
pathAnalysisSection.setVisible(visible);
pathAnalysisSection.setManaged(visible);
updateDetailPanePresence(visible);
pathAnalysisHiddenHintLabel.setVisible(!visible);
pathAnalysisHiddenHintLabel.setManaged(!visible);
@@ -567,45 +586,6 @@ public final class StationMapView {
: MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS;
}
private VBox createSelectedStationSection() {
GridPane detailGrid = new GridPane();
detailGrid.setHgap(8);
detailGrid.setVgap(6);
configureCompactGrid(detailGrid);
int row = 0;
detailGrid.add(new Label("Station:"), 0, row);
detailGrid.add(detailCallsignValue, 1, row++);
detailGrid.add(new Label("Locator:"), 0, row);
detailGrid.add(detailLocatorValue, 1, row++);
detailGrid.add(new Label("Path:"), 0, row);
Label compactPathValue = new Label();
compactPathValue.textProperty().bind(
detailQrbValue.textProperty()
.concat(" / ")
.concat(detailQtfValue.textProperty())
);
detailGrid.add(compactPathValue, 1, row++);
detailGrid.add(new Label("Bands:"), 0, row);
detailGrid.add(detailBandsValue, 1, row++);
// Frequencies are useful, but they consume vertical space. Keep them compact.
detailFrequenciesArea.setPrefRowCount(2);
detailGrid.add(new Label("QRG:"), 0, row);
detailGrid.add(detailFrequenciesArea, 1, row++);
return new VBox(8,
new Label("Selected station"),
new Separator(Orientation.HORIZONTAL),
detailGrid,
triggerClusterSpotButton
);
}
/**
* ensures that the labels remains visible
@@ -950,58 +930,107 @@ public final class StationMapView {
private void updateStatusLabel() {
StringBuilder text = new StringBuilder();
text.append("Showing ").append(lastSnapshots.size()).append(" visible stations");
text.append("Showing ")
.append(lastSnapshots.size())
.append(" visible stations");
if (filteredViewActive) {
text.append(" | filtered view active");
}
statusLabel.setText(text.toString());
MapCallsignRawSnapshot selectedSnapshot = lastSelectedSnapshot;
if (selectedSnapshot != null) {
text.append(" | Selected: ")
.append(selectedSnapshot.displayCallSign());
if (!selectedSnapshot.locator6().isBlank()) {
text.append(" | ")
.append(selectedSnapshot.locator6());
}
text.append(" | ")
.append(String.format(
Locale.US,
"%.0f km / %.0f°",
selectedSnapshot.qrbKm(),
selectedSnapshot.qtfDeg()
));
String bandText = selectedSnapshot.bandSummary().isBlank()
? "-"
: selectedSnapshot.bandSummary();
if (selectedSnapshot.offersSelectedBand()) {
bandText += " B+";
}
text.append(" | Bands: ")
.append(bandText);
String frequencies = selectedSnapshot.detailFrequencyText();
if (frequencies != null && !frequencies.isBlank()) {
frequencies = frequencies
.replace('\n', ' ')
.replace('\r', ' ')
.replaceAll("\\s+", " ")
.trim();
text.append(" | QRG: ")
.append(frequencies);
}
}
String statusText = text.toString();
statusLabel.setText(statusText);
statusTooltip.setText(statusText);
}
private void updateDetailPanel(MapCallsignRawSnapshot selectedSnapshot) {
if (selectedSnapshot == null) {
clearSelectedStationPanel();
detailCallsignRaw = null;
triggerClusterSpotButton.setDisable(true);
triggerClusterSpotButton.setVisible(false);
triggerClusterSpotButton.setManaged(false);
clearPathAnalysisPanel();
return;
}
updateSelectedStationPanel(selectedSnapshot);
if (selectedSnapshot == null) {
updatePathAnalysisPanel(PathAnalysisResult.waitingForSelection(homeLocator6));
} else {
updatePathAnalysisPanel(lastPathAnalysisResult);
}
}
private void clearSelectedStationPanel() {
detailCallsignRaw = null;
detailCallsignValue.setText("-");
detailLocatorValue.setText("-");
detailQrbValue.setText("-");
detailQtfValue.setText("-");
detailBandsValue.setText("-");
detailFrequenciesArea.setText("-");
detailAirplanesValue.setText("-");
triggerClusterSpotButton.setDisable(true);
}
private void updateSelectedStationPanel(MapCallsignRawSnapshot selectedSnapshot) {
detailCallsignRaw = selectedSnapshot.callSignRaw();
detailCallsignValue.setText(selectedSnapshot.displayCallSign());
detailLocatorValue.setText(selectedSnapshot.locator6());
detailQrbValue.setText(String.format(Locale.US, "%.0f km", selectedSnapshot.qrbKm()));
detailQtfValue.setText(String.format(Locale.US, "%.0f°", selectedSnapshot.qtfDeg()));
String bandText = selectedSnapshot.bandSummary().isBlank() ? "-" : selectedSnapshot.bandSummary();
if (selectedSnapshot.offersSelectedBand()) {
bandText += " B+";
}
detailBandsValue.setText(bandText);
detailFrequenciesArea.setText(selectedSnapshot.detailFrequencyText());
detailAirplanesValue.setText(String.valueOf(selectedSnapshot.reachableAirplanes()));
triggerClusterSpotButton.setDisable(false);
triggerClusterSpotButton.setVisible(true);
triggerClusterSpotButton.setManaged(true);
updatePathAnalysisPanel(lastPathAnalysisResult);
}
private void updateDetailPanePresence(boolean visible) {
if (mainSplitPane == null || detailScrollPane == null) {
return;
}
if (visible) {
if (!mainSplitPane.getItems().contains(detailScrollPane)) {
mainSplitPane.getItems().add(detailScrollPane);
Platform.runLater(() ->
mainSplitPane.setDividerPositions(lastDetailDividerPosition)
);
}
} else {
if (!mainSplitPane.getDividers().isEmpty()) {
lastDetailDividerPosition =
mainSplitPane.getDividers().get(0).getPosition();
}
mainSplitPane.getItems().remove(detailScrollPane);
}
}
private void clearPathAnalysisPanel() {
@@ -1331,13 +1360,13 @@ public final class StationMapView {
mainSplitPane.setStyle("-fx-background-color: #2b3035;");
detailPane.setStyle("-fx-background-color: #31373c; -fx-border-color: #4c565c; -fx-border-width: 0 0 0 1;");
statusLabel.setStyle("-fx-background-color: #373e43; -fx-text-fill: lightgray; -fx-padding: 8 10 8 10; -fx-background-radius: 4;");
detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;");
// detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;");
} else {
rootPane.setStyle("-fx-background-color: #f2f2f2;");
mainSplitPane.setStyle("-fx-background-color: #f2f2f2;");
detailPane.setStyle("-fx-background-color: #f7f7f7; -fx-border-color: #d0d0d0; -fx-border-width: 0 0 0 1;");
statusLabel.setStyle("-fx-background-color: #f7f7f7; -fx-text-fill: #333333; -fx-padding: 8 10 8 10; -fx-background-radius: 4;");
detailFrequenciesArea.setStyle("");
// detailFrequenciesArea.setStyle("");
}
}
+1
View File
@@ -10,6 +10,7 @@ module praktiKST {
requires java.net.http;
requires java.desktop;
requires jdk.crypto.ec;
requires jdk.net;
requires org.junit.jupiter.api;
requires org.mockito;
exports kst4contest.controller.interfaces;
@@ -14,6 +14,39 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class BandOpportunityResolverTest {
@Test
void explicitStationNameFrequencyProvidesBandEvidence() {
ChatMember station = new ChatMember();
station.setName("Phil 432.357");
BandOpportunityResolver.Resolution resolution =
BandOpportunityResolver.resolve(
List.of(station),
System.currentTimeMillis()
);
assertEquals(
EnumSet.of(Band.B_432),
resolution.getOfferedBands()
);
}
@Test
void relativeStationNameFrequencyDoesNotProvideBandEvidence() {
ChatMember station = new ChatMember();
station.setName("Mike .180");
BandOpportunityResolver.Resolution resolution =
BandOpportunityResolver.resolve(
List.of(station),
System.currentTimeMillis()
);
assertTrue(
resolution.getOfferedBands().isEmpty()
);
}
@Test
void resolvesCommonShorthandBandsFromStationName() {
EnumSet<Band> bands = BandOpportunityResolver.detectBandsFromStationName(
@@ -15,6 +15,70 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class MapCallsignRawSnapshotBuilderTest {
@Test
void explicitStationNameQrgIsShownInMapSnapshot() {
ChatMember station =
buildStation(
"G0JSB",
"Phil 432.357",
"IO91AA",
1_000L
);
MapCallsignRawSnapshot snapshot =
new MapCallsignRawSnapshotBuilder()
.buildSnapshots(
List.of(station),
null,
EnumSet.of(Band.B_432)
)
.get(0);
assertEquals(
"432",
snapshot.bandSummary()
);
assertEquals(
"432.357",
snapshot
.lastKnownFrequenciesByBand()
.get("432")
);
}
@Test
void recentDynamicQrgWinsOverStationNameQrgInMap() {
ChatMember station =
buildStation(
"G0JSB",
"Phil 432.357",
"IO91AA",
1_000L
);
station.addKnownFrequency(
Band.B_432,
432.335
);
MapCallsignRawSnapshot snapshot =
new MapCallsignRawSnapshotBuilder()
.buildSnapshots(
List.of(station),
null,
EnumSet.of(Band.B_432)
)
.get(0);
assertEquals(
"432.335",
snapshot
.lastKnownFrequenciesByBand()
.get("432")
);
}
@Test
void marksSnapshotWhenNameAdvertisesSelectedBand() {
ChatMember station = buildStation("DL1ABC", "QRV 2-70-23", "JN58TD", 1_000L);
@@ -16,6 +16,113 @@ class PropagationFrequencyResolverTest {
private static final long NOW = 10_000_000L;
@Test
void exactStationNameQrgWinsOverBandAndCategoryFallback() {
ChatMember station =
station(
ChatCategory.VUHF,
"Phil 432.357"
);
PropagationFrequencyResolver.Resolution resolution =
resolve(
List.of(station),
EnumSet.of(
Band.B_144,
Band.B_432
)
);
assertEquals(
Band.B_432,
resolution.getBand()
);
assertEquals(
432.357,
resolution.getAnalysisFrequencyMHz(),
0.000_001
);
assertEquals(
PropagationFrequencyResolver.Source.STATION_NAME_QRG,
resolution.getSource()
);
}
@Test
void recentChatQrgWinsOverExactStationNameQrg() {
ChatMember station =
station(
ChatCategory.VUHF,
"Phil 432.357"
);
addCurrentQrg(
station,
Band.B_432,
432.335,
NOW - 1_000L
);
PropagationFrequencyResolver.Resolution resolution =
resolve(
List.of(station),
EnumSet.of(Band.B_432)
);
assertEquals(
432.335,
resolution.getAnalysisFrequencyMHz(),
0.000_001
);
assertEquals(
PropagationFrequencyResolver.Source.CURRENT_QRG,
resolution.getSource()
);
}
@Test
void multipleStationNameQrgsAreNotTreatedAsOneRunFrequency() {
ChatMember station =
station(
ChatCategory.EMEJT65,
"QRV 432.357 1296.210"
);
PropagationFrequencyResolver.Resolution resolution =
resolve(
List.of(station),
EnumSet.of(
Band.B_432,
Band.B_1296
)
);
/*
* Both bands are known, but no exact run QRG is guessed.
* Existing station-name band selection therefore chooses the
* lowest usable advertised band.
*/
assertEquals(
Band.B_432,
resolution.getBand()
);
assertEquals(
432.0,
resolution.getAnalysisFrequencyMHz(),
0.000_001
);
assertEquals(
PropagationFrequencyResolver.Source.STATION_NAME,
resolution.getSource()
);
}
@Test
void currentQrgWinsOverNameAndCategoryFallback() {
ChatMember station = station(ChatCategory.MICROWAVE, "QRV 3cm");
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

+5 -1
View File
@@ -2382,4 +2382,8 @@ OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432fa
OZ6TY;Henning;JO55XE;StringProperty [value: 144.196]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
DF7KF;Dithmar;JO30FK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
DH1NFJ;Jochen;JO50QL;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
PA2RU;René;JO32LT;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
PA2RU;René;JO32LT;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
SM6VTZ;Chris .135;JO58UJ;StringProperty [value: 144.135]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
SM7EYW;Torleif 432,205;JO65NK;StringProperty [value: 144.205]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
DJ8MS;Tor_70cm;JO54UC;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
DK0MM;Jens/Alex;JN49IU;StringProperty [value: 432.305]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
+9
View File
@@ -71,6 +71,14 @@ function getManualPageOrder(lang, slug) {
return index >= 0 ? index : 999;
}
function githubCompatibleSlug(value) {
return (value || "")
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
.replace(/\s+/g, "-");
}
function rewriteManualLinks(content, lang) {
return (content || "")
@@ -157,6 +165,7 @@ module.exports = function (eleventyConfig) {
linkify: true,
typographer: true
}).use(markdownItAnchor, {
slugify: githubCompatibleSlug,
permalink: markdownItAnchor.permalink.headerLink()
});
+3 -1
View File
@@ -4,7 +4,9 @@
"private": true,
"scripts": {
"start": "eleventy --serve",
"build": "eleventy"
"build": "eleventy && npm run validate:version-info",
"validate:version-info": "node scripts/validate-version-info.js",
"test": "node --test test/*.test.js"
},
"devDependencies": {
"@11ty/eleventy": "^3.0.0"
+280
View File
@@ -0,0 +1,280 @@
const fs = require("fs");
const path = require("path");
const DEFAULT_FILE = path.join(
__dirname,
"..",
"_site",
"kst4ContestVersionInfo.xml"
);
function fail(message) {
throw new Error(`[versionInfo validation] ${message}`);
}
function normaliseVersion(value) {
return String(value || "")
.trim()
.replace(/^v/, "")
.split("-")[0]
.split("+")[0];
}
function extractTag(xml, tagName) {
const match = xml.match(
new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`)
);
return match ? match[1].trim() : null;
}
function assertWellFormedStructure(xml) {
if (/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-f]+;)/i.test(xml)) {
fail("the XML contains an unescaped ampersand");
}
const withoutCommentsAndDeclaration = xml
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<\?[\s\S]*?\?>/g, "");
const tagPattern =
/<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\s*\/?>/g;
const stack = [];
let match;
while (
(match = tagPattern.exec(withoutCommentsAndDeclaration)) !== null
) {
const fullTag = match[0];
const tagName = match[1];
if (fullTag.startsWith("</")) {
const openTag = stack.pop();
if (openTag !== tagName) {
fail(
`closing tag </${tagName}> does not match `
+ `<${openTag || "none"}>`
);
}
} else if (!fullTag.endsWith("/>")) {
stack.push(tagName);
}
}
if (stack.length > 0) {
fail(`unclosed tag <${stack[stack.length - 1]}>`);
}
const remainingMarkup =
withoutCommentsAndDeclaration.replace(tagPattern, "");
// A plain ">" is legal character data and occurs in historical
// changelog notation such as "->". A remaining "<" cannot be legal
// after all valid tags have been removed.
if (/</.test(remainingMarkup)) {
fail("the XML contains malformed markup");
}
}
function assertContainsTag(block, tagName) {
const pattern =
new RegExp(`<${tagName}>[\\s\\S]*?<\\/${tagName}>`);
if (!pattern.test(block)) {
fail(`required element <${tagName}> is missing`);
}
}
function validateVersionInfo(xml, expectedStableVersion = "") {
if (!xml || Buffer.byteLength(xml, "utf8") < 500) {
fail("the generated file is empty or implausibly small");
}
assertWellFormedStructure(xml);
const completeDocumentPattern =
/^<\?xml[^>]*>\s*<praktiKST>[\s\S]*<\/praktiKST>\s*$/;
if (!completeDocumentPattern.test(xml)) {
fail(
"the document does not contain one complete "
+ "<praktiKST> root element"
);
}
const latestVersionBlock = extractTag(xml, "latestVersion");
if (latestVersionBlock === null) {
fail("<latestVersion> is missing");
}
for (const tagName of [
"versionNumber",
"semanticVersion",
"adminMessage",
"majorChanges",
"latestVersionPathOnWebserver"
]) {
assertContainsTag(latestVersionBlock, tagName);
}
const legacyVersion =
extractTag(latestVersionBlock, "versionNumber");
const semanticVersion =
extractTag(latestVersionBlock, "semanticVersion");
const releaseUrl =
extractTag(
latestVersionBlock,
"latestVersionPathOnWebserver"
);
if (
!legacyVersion
|| !/^\d+(?:\.\d+)?$/.test(legacyVersion)
) {
fail(
"<versionNumber> is not a valid legacy numeric version"
);
}
if (
!semanticVersion
|| !/^\d+\.\d+(?:\.\d+)?$/.test(semanticVersion)
) {
fail("<semanticVersion> is not a valid Stable version");
}
if (
!releaseUrl
|| !releaseUrl.startsWith(
"https://github.com/praktimarc/"
+ "kst4contest/releases/tag/"
)
) {
fail(
"<latestVersionPathOnWebserver> is not "
+ "a KST4Contest release URL"
);
}
const changeLogs = [
...xml.matchAll(
/<changeLog>([\s\S]*?)<\/changeLog>/g
)
].map((match) => match[1]);
if (changeLogs.length === 0) {
fail(
"the document does not contain any "
+ "<changeLog> entries"
);
}
for (const entry of changeLogs) {
for (const tagName of [
"changedVersionNumber",
"date",
"description",
"added",
"changed",
"fixed",
"removed"
]) {
assertContainsTag(entry, tagName);
}
}
const expected = normaliseVersion(expectedStableVersion);
if (expected) {
if (semanticVersion !== expected) {
fail(
`latest Stable version ${semanticVersion} `
+ `does not match expected release ${expected}`
);
}
const releaseEntryExists = changeLogs.some(
(entry) =>
extractTag(
entry,
"changedVersionNumber"
) === expected
);
if (!releaseEntryExists) {
fail(
"the changelog does not contain the expected "
+ `Stable release ${expected}`
);
}
}
return {
semanticVersion,
changeLogEntries: changeLogs.length
};
}
function parseArguments(argv) {
const result = {
file: DEFAULT_FILE,
expectedStableVersion:
process.env.EXPECTED_STABLE_VERSION || ""
};
for (let index = 0; index < argv.length; index++) {
if (
argv[index] === "--file"
&& argv[index + 1]
) {
result.file = path.resolve(argv[++index]);
} else if (
argv[index] === "--expected-stable"
&& argv[index + 1]
) {
result.expectedStableVersion = argv[++index];
} else {
fail(
`unknown or incomplete argument: ${argv[index]}`
);
}
}
return result;
}
if (require.main === module) {
try {
const options =
parseArguments(process.argv.slice(2));
const xml =
fs.readFileSync(options.file, "utf8");
const result =
validateVersionInfo(
xml,
options.expectedStableVersion
);
console.log(
`[versionInfo validation] OK: `
+ `Stable ${result.semanticVersion}, `
+ `${result.changeLogEntries} changelog entries, `
+ options.file
);
} catch (err) {
console.error(err.message);
process.exitCode = 1;
}
}
module.exports = {
normaliseVersion,
validateVersionInfo
};
+143 -20
View File
@@ -5,16 +5,93 @@ const REPO = "praktimarc/kst4contest";
const API = `https://api.github.com/repos/${REPO}`;
const LABEL_MAP = { enhancement: "added", bug: "fixed" };
const GITHUB_API_ATTEMPTS = 3;
function wait(milliseconds) {
return new Promise(
(resolve) => setTimeout(resolve, milliseconds)
);
}
async function githubGet(urlPath) {
const headers = { Accept: "application/vnd.github+json" };
const headers = {
Accept: "application/vnd.github+json"
};
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
headers.Authorization =
`Bearer ${process.env.GITHUB_TOKEN}`;
}
const res = await fetch(`${API}${urlPath}`, { headers });
if (!res.ok) {
throw new Error(`GitHub API ${urlPath} failed: ${res.status}`);
let lastError = null;
for (
let attempt = 1;
attempt <= GITHUB_API_ATTEMPTS;
attempt++
) {
try {
const res = await fetch(
`${API}${urlPath}`,
{
headers,
signal: AbortSignal.timeout(15000)
}
);
if (res.ok) {
return res.json();
}
const rateLimitRemaining =
res.headers.get("x-ratelimit-remaining");
const rateLimitReset =
res.headers.get("x-ratelimit-reset");
const rateLimitInfo =
rateLimitRemaining === null
? ""
: `, rate limit remaining `
+ rateLimitRemaining
+ (
rateLimitReset
? `, reset ${rateLimitReset}`
: ""
);
lastError = new Error(
`GitHub API ${urlPath} failed with `
+ `HTTP ${res.status}${rateLimitInfo}`
);
// Authentication and permission errors do not
// become valid by retrying.
if (
![408, 429].includes(res.status)
&& res.status < 500
) {
throw lastError;
}
} catch (err) {
lastError = err;
// Do not hide an invalid or expired token behind
// repeated requests.
if (
/HTTP (401|403|404)/.test(err.message)
) {
throw err;
}
}
if (attempt < GITHUB_API_ATTEMPTS) {
await wait(attempt * 500);
}
}
return res.json();
throw lastError
|| new Error(`GitHub API ${urlPath} failed`);
}
// UpdateChecker.java parses <versionNumber> with Double.parseDouble() and
@@ -108,25 +185,64 @@ function loadLegacySections() {
// UpdateChecker.java) from GitHub releases + closed issues, falling back to
// version-history.xml for releases that predate GitHub Releases.
module.exports = async function () {
const fallback = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<praktiKST></praktiKST>';
try {
const historyEntries = loadHistoryEntries();
const rawReleases = await githubGet("/releases?per_page=100");
const rawReleases =
await githubGet("/releases?per_page=100");
if (!Array.isArray(rawReleases)) {
throw new Error(
"GitHub releases response was not an array"
);
}
const releases = rawReleases
.map((r) => ({
tagName: r.tag_name,
publishedAt: r.published_at || "",
name: r.name || r.tag_name,
body: r.body || "",
isPrerelease: r.prerelease
.map((release) => ({
tagName: release.tag_name,
publishedAt: release.published_at || "",
name:
release.name
|| release.tag_name,
body: release.body || "",
isPrerelease: release.prerelease,
isDraft: release.draft
}))
.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1));
.sort(
(first, second) =>
first.publishedAt
< second.publishedAt
? 1
: -1
);
const stableReleases = releases.filter(
(release) =>
!release.isPrerelease
&& !release.isDraft
);
const stableReleases = releases.filter((r) => !r.isPrerelease);
const stable = stableReleases[0];
const ghVersions = new Set(stableReleases.map((r) => toAppVersionNumber(r.tagName)));
if (
!stable
|| !stable.tagName
|| !stable.publishedAt
) {
throw new Error(
"GitHub did not return "
+ "a published Stable release"
);
}
const ghVersions = new Set(
stableReleases.map(
(release) =>
toAppVersionNumber(
release.tagName
)
)
);
const parts = [];
parts.push('<?xml version="1.0" encoding="UTF-8" standalone="no"?>');
@@ -203,7 +319,14 @@ module.exports = async function () {
parts.push("</praktiKST>");
return parts.join("\n");
} catch (err) {
console.warn(`[versionInfo] Could not generate version info XML, using empty fallback: ${err.message}`);
return fallback;
throw new Error(
"[versionInfo] Could not generate "
+ "a complete update feed. "
+ "The website build has been aborted "
+ "so that the existing production feed "
+ "remains untouched: "
+ err.message,
{ cause: err }
);
}
};
-1
View File
@@ -6,7 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#08110b">
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/assets/favicon.ico" sizes="any">
<meta name="description" content="{{ description or 'KST4Contest connects ON4KST chat, candidate selection, sked planning and station software for VHF, UHF and SHF contest operation.' }}">
<link rel="canonical" href="https://kst4contest.hamradioonline.de{{ page.url }}">
+93
View File
@@ -0,0 +1,93 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const generateVersionInfo =
require("../src/_data/versionInfo");
const {
validateVersionInfo
} = require("../scripts/validate-version-info");
const VALID_XML =
`<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<praktiKST>
<latestVersion>
<versionNumber>1.411</versionNumber>
<semanticVersion>1.41.1</semanticVersion>
<adminMessage></adminMessage>
<majorChanges>Hotfix</majorChanges>
<latestVersionPathOnWebserver>https://github.com/praktimarc/kst4contest/releases/tag/v1.41.1</latestVersionPathOnWebserver>
</latestVersion>
<needUpdateSinceLastVersion>
<filename>nothing</filename>
</needUpdateSinceLastVersion>
<changeLog>
<changedVersionNumber>1.41.1</changedVersionNumber>
<date>2026-07-08</date>
<description>Hotfix</description>
<added></added>
<changed></changed>
<fixed>Text input handling</fixed>
<removed></removed>
</changeLog>
</praktiKST>`;
test("accepts a complete update feed", () => {
const result =
validateVersionInfo(VALID_XML, "v1.41.1");
assert.equal(result.semanticVersion, "1.41.1");
assert.equal(result.changeLogEntries, 1);
});
test("rejects the former empty fallback", () => {
assert.throws(
() =>
validateVersionInfo(
'<?xml version="1.0" '
+ 'encoding="UTF-8"?>'
+ "<praktiKST></praktiKST>"
),
/empty or implausibly small/
);
});
test(
"rejects a feed which does not contain "
+ "the expected Stable release",
() => {
assert.throws(
() =>
validateVersionInfo(
VALID_XML,
"v1.42.0"
),
/does not match expected release/
);
}
);
test(
"aborts generation when GitHub rejects "
+ "the API request",
async () => {
const originalFetch = global.fetch;
global.fetch = async () => ({
ok: false,
status: 401,
headers: {
get: () => null
}
});
try {
await assert.rejects(
generateVersionInfo(),
/website build has been aborted.*HTTP 401/i
);
} finally {
global.fetch = originalFetch;
}
}
);