Next major release/version1.50 (#89)

* Make DBController work on one database file per instance

The controller was a static singleton: an eagerly created static instance
opened the root database during class initialization, and both the connection
and the path were static fields. A second operator profile in the same process
was therefore impossible, and ChatController's own "new DBController()" never
opened anything - it silently adopted the eagerly opened root connection.

- drop the eager static instance in favour of a lazily created default instance
- turn connection and path into instance state
- add a constructor taking a database file name relative to the application
  directory plus a flag whether a missing file is seeded from the bundled
  template
- create additional profile databases empty instead of seeding them: the
  bundled template carries 3452 foreign callsigns and user_version 0, which
  would show a new operator foreign data and trigger the full callsign
  normalization rebuild. The schema is created by the existing table setup.
- remember the shutdown hook so closeDBConnection can deregister it; otherwise
  every profile switch would leave another hook holding a dead connection

All SQL statements keep referencing the plain field and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Allow preferences and chat controller to be bound to a file set

ChatPreferences gains a constructor taking a preferences file name relative to
the application directory, so "profiles/OP2/preferences.xml" is as valid as the
historic flat "preferences.xml". A missing file is still seeded from the
bundled template, which gives an additional operator the same clean defaults a
first installation gets.

ChatController gains a constructor that passes both relative file names and the
seed flag through to ChatPreferences and DBController. The existing
constructors delegate to the historic file names, so nothing changes yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Introduce the operator profile model and its registry

Adds the descriptor, the path derivation and the registry persistence for
operator profiles. Nothing calls them yet, so behaviour is unchanged.

The descriptor stores only a shared/own flag, never a path. All file names are
derived in OperatorProfilePaths, so a stored path can never drift apart from
the flag that produced it. A profile identifier is a stable, file system safe
slug assigned once, so renaming a profile never moves a directory.

The registry is created lazily. An installation that only has the historic flat
layout gets no registry file and no profiles directory; the root profile is
synthesised in memory instead. That keeps a single operator installation
byte for byte the one it was before, and it keeps a downgrade to an older
release a no-op. A missing, unreadable or malformed registry is logged and
treated like an installation without additional profiles, never as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Select the operator profile at startup

Resolves the active operator profile before the chat controller is built and
passes its two file names on, so preferences, layout and worked data follow the
profile.

The resolution is deliberately quiet for existing installations. With no
registry or exactly one profile nothing is asked and nothing is written, so a
single operator start is unchanged. Only from two profiles on does a small
picker appear with the last used profile preselected, where Enter or a double
click starts immediately. A "--profile" argument, or the equivalent system
property, skips the picker; an unknown name warns and falls back to the normal
selection instead of refusing to start.

The startup decision itself lives in OperatorProfileBootstrap and contains no
user interface code, so it is covered by headless tests. The window title gains
the profile name only when a second profile exists.

Command line parsing happens in init() and is kept in a process wide holder,
because JavaFX only knows the parameters of the instance it launched itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Release the background resources a discarded runtime owns

Several resources outlived a disconnect on purpose, which was harmless while a
process ran exactly one session for its whole life. They are now released when
the controller itself is closed:

- the ON4KST connection supervisor thread, which stopByUser did not touch
- the sked reminder scheduler, which had no shutdown at all
- the reachability executor, whose shutdown method existed but was never called
- the PSTRotator retry scheduler and its pending retry
- the map tile proxy, whose stop method existed but was never called, leaving a
  server socket and a twelve thread pool behind
- the station map bridge listeners and its coalescing animation, which would
  otherwise keep firing into a dead user interface

These are real leaks today; they only become visible when a second runtime is
built in the same process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Switch the operator profile while the application is running

Splits the teardown out of stop() into a reusable, idempotent shutdownRuntime()
and adds File - Switch operator profile..., which tears the current runtime
down and builds a fresh one for the selected profile.

The new runtime is a new application instance rather than a second start() on
the existing one. Many controls are instance fields created once, so reusing
the instance would re-parent mounted nodes and register every listener twice.
A fresh instance is safe because the class keeps no mutable static state.

Closing every window during a switch would end the process under the JavaFX
default, so the application takes over the exit decision: implicit exit is
turned off, the main window gets an explicit close handler, and every exit path
runs through the launcher. That also fixes losing the window layout on exit
after a switch, because JavaFX only calls stop() on the instance it launched.

The two view timers are now cancelled null-safe; stop() used to dereference
them unguarded, which would fail if shutdown happened before they were created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Manage operator profiles from the settings window

Adds a "Profiles" tab, appended last so no established tab position shifts,
offering create, duplicate, rename, delete, a switch between shared and own
worked stations, and activation of another profile.

Duplicating copies the whole configuration except callsign and password. The
antenna, locator, layout and integration settings are exactly the work nobody
wants to enter twice, while the credentials belong to one operator only. The
root profile can neither be deleted nor moved off the common station database,
because its files are the installation itself.

The login callsign and its raw form now default to empty instead of a real
callsign. The XML reader treats an empty element as "not set" and falls back to
the field default, so without this change a profile created without credentials
would come up carrying the callsign compiled into the defaults - and an
operator could transmit under someone else's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Document operator profiles

Adds a manual section in both languages covering where profile files live, the
choice between shared and own worked stations, managing profiles, the startup
selection and the --profile argument, switching while running, and the fact
that passwords stay in clear text so profiles are not an access boundary.

Records the architecture in PROJECT_CONTEXT: lazy registry, derived paths, why
an additional profile database is created empty, why the login callsign default
is empty, and the constraints of rebuilding the runtime for a switch.

Adds a v1.50 changelog entry in both languages including the upgrade notes: no
file is moved, and going back to an older release stays possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Make the terrain profile cache multi operator safe

The cache stored a single owner identity in a meta table and dropped the whole
TerrainProfileCache table whenever the configured callsign or locator differed
from it. With several operator profiles that turns the cache into a permanent
miss: every switch between two operators with different locators would discard
every computed profile.

Entries are separated by owner identity through the primary key already, so the
wipe is replaced by an owner table that simply records which identities are in
use. A different owner now misses the cache instead of clearing everybody's.

The cache also moves out of the worked station database into its own global
terrainprofilecache.db. Terrain profiles are pure geometry derived from two
locators and a sample count; at a multi operator station both operators share
one location, so a per profile copy would only double the traffic against the
terrain service. No migration is needed, the new file refills itself, and the
old tables stay readable for older releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Fail cleanly when the registry path has no directory

SpotBugs flagged a null passed to Files.createTempFile: the null check guarded
only createDirectories, while the temporary file creation would still have
dereferenced it. The path is resolved absolute so this is practically
unreachable, but bailing out with a log line is cheaper than the latent NPE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Code Version 1.50

* Add the in-app update concept for 1.50

Captures the agreed design so it can be handed over later: extended
update feed with per-artifact metadata, a build-time package-type
marker, a testable check service replacing the blocking call in the
ChatController constructor, a non-modal hint suppressed while connected
to ON4KST, verified downloads and platform-specific execution.

Concept only, nothing implemented yet. Line anchors were verified
against this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT8R7zTMoQnmJj6dGbaqqf

* Use one base font size and a per-profile startup design

JavaFX picks a 13px default font on macOS and Linux but 12px on
Windows, so labels in fixed-size controls were cut off outside
Windows. Both stylesheets now set 12px on the root.

Each operator profile now chooses light or dark mode for startup on
the GUI settings tab. The Windows menu entries remain a quick switch
for the running session. All windows, including the update window
and the top candidates list, follow the design, and the main window
no longer starts light while the station map starts dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show a drag-to-install layout in the macOS DMG

The DMG window now has a background with an arrow from the app to
the Applications folder, in the KST4Contest colours. Finder applies
the layout through AppleScript; if that fails, e.g. on a headless
runner, the script warns and builds the plain DMG as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the macOS system menu bar in all windows

On macOS the main menus move into the system menu bar and are shared
with the secondary windows, so they stay available whichever window
has the focus. The connection state indicator becomes a read-only
LINK menu there instead of the status bar button. Other platforms
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List direct commits on main in the release notes

GitHub's generated release notes only cover merged pull requests, so
commits pushed straight to main were missing. The release job now
builds the notes itself: "What's Changed" holds "Commits on main"
(first-parent, without bot and squash-merge commits) and GitHub's PR
list as "Merged PRs".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Build with Gradle in two modules, and free core of JavaFX

Etappen 1 and 2 of the Compose migration (43 commits).

The Maven build, the single source tree and the JPMS module-info went together.
core now holds the domain and app-desktop the user interface, both built by
Gradle, packaged through jpackage from Gradle, with SpotBugs and PMD wired in.
JavaFX moves to the classpath, which is why the launcher is Main and not the
Application subclass. The PR check, the nightly artifacts, the release and the
AUR packages all build from Gradle; the macOS DMG points at its output.
Verified on Linux, macOS and Windows.

core then loses JavaFX entirely. It observes through kst4contest.observe --
ObservableValue, ObservableRoster, and UiDispatcher as the one boundary a
worker thread crosses to touch the interface. The controller collections become
rosters and the visible station list a pure derivation rather than a
FilteredList/SortedList chain, which is also what fixed two focus defects only
a load test had exposed: the keyboard focus stays where the operator put it,
and the send field keeps it after a row click.

The rule that outlives this commit: ObservableRoster is the store and the list
a view is bound to is a mirror of it, never the other way round.

* Draw the whole interface in Compose

Etappen 3 to 6a of the migration (129 commits). Every window the operator sees
is Compose; the JavaFX ones still exist beside them so the two could be
compared against a live session, which is the only way the differences came to
light.

The toolchain first, then the settings window with its eleven tabs and 72 real
preferences; then the multi-column table as a component, and with it the rules
it needs -- stable row identity, a selection that survives a refresh, column
order the operator can drag, and a repaint when the rows change rather than
when the list instance does. Then the update and monitor windows, and the main
window: 22 station columns, the filter bar, the selected-station panel, the
message tabs, the priority bar, the sked controls, the status indicators and
the timeline, assembled behind a splitter because Compose has none.

The map stops being a WebView. A Compose canvas projects WebMercator tiles
itself, with the clustering in core and an asynchronous fetcher in place of the
local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a
reflection call into com.sun.javafx.

Most of the volume here is parity the operator would notice rather than new
behaviour: desktop density instead of Material's phone minimums, colours taken
from the JavaFX stylesheets instead of Material's palette, row dividers, band
columns, grid-square labels and their colours, the home marker and antenna
beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower
than the chart they stack.

Two lessons are worth more than the diff. A control bound to a value Compose
cannot observe writes through and never redraws -- that one shipped repeatedly
with a green build, because no test drew anything. And a layout that clips
instead of wrapping is worse than one that is merely too narrow: half a word
is the worst available outcome.

* Remove JavaFX

The second half of Etappe 6 (61 commits). grep -rl "javafx\." over both main
source trees is empty, nothing imports javafx, neither classpath resolves it,
and org.openjfx is out of the build and the version catalog.

The finding that shaped the work: the JavaFX main window was built on every
start and never shown -- primaryStage's setScene and show were both commented
out. Most of those 11,655 lines were dead weight rather than a port. But not
all of them, and that is the trap this commit is really about: dead-looking
methods still fed live Compose sinks. They were cut at their seams instead of
deleted, and a sweep script that follows calls was written to find them,
because reading the code does not reveal them.

In order: the colour derivation frozen as checked-in test data, so the pin
survives JavaFX being removed instead of silently comparing against itself;
the live feeds lifted out of the never-shown window and given names and tests;
the dead scaffolding deleted in slices; then the lifecycle. UiDispatcher now
delivers to the AWT event dispatch thread, the ten Alerts are modal Compose
dialogs, HostServices gives way to java.awt.Desktop with the mailto/http split
JavaFX did not need, and extends Application, start(Stage), stop and launch are
gone -- main builds the runtime and a shutdown hook replaces stop().

An independent review found two deadlocks in that move, both the same failure
class, both invisible to the build: a blocking wait on the event thread for
work that can only advance on the event thread. The profile picker waited on a
latch and froze the client for good; ComposeWindowHost.close waited on
Thread.join and returned with the window still marked open, which made a
profile switch raise the dead runtime's window and leave the rebuilt profile
without its Connect button. A third came out of fixing them: a ComposeDialog
built off the event thread cannot create its GL context and never paints.

Three defects only a GUI run showed, never the build: an NPE on a deleted
field, a Compose menu still hanging on deleted MenuItems, and a category
bookkeeping field that looked dead but is read by the outgoing-message category
resolution -- deleting it would have sent messages in the wrong category.

The binding docs and the manual no longer describe a JavaFX client.

* Draw the menu bar in Compose, and give the drawing layer a net

What the removal left open (4 commits).

Compose's MenuBar is a javax.swing.JMenuBar set on the window, so Swing draws
it and no theme reaches it; with no look-and-feel configured that meant the
default Metal menu above a themed window. The bar is drawn in the window now,
except on macOS, where the system bar is where that platform expects it. Two
renderers read one description -- mainMenuModel is a plain function of the
state, which also gives the menus their first tests. Closing the main window
quits the application again, and the window writes its size back to the
profile; both had quietly stopped working when the last JavaFX stage went.

Then Etappe 7. Four faults shipped in the settings-window branch alongside a
green suite of 382 tests, because not one of those tests drew anything: a
control that never redrew, a tab strip that clipped mid-word, a form taller
than its window with no way down, and colours that came from Material rather
than the stylesheet. One regression test per class, and the spec's acceptance
criterion is met rather than assumed -- each fault was put back deliberately
and the matching test watched to fail.

The CI had never run the suite at all; it does now, headless. What that took
was not Xvfb: skiko's native library links against libGL and only has to be
loadable, since the drawing tests render in software. Without the package the
whole Compose half of the suite dies with a message that reads like a missing
display. Found by running the job under act rather than on GitHub.

The six tests that open a real window skip headless by their own guard. The
summary step counts from the result XML and fails on failures there, because
this project's rule is never to infer a green suite from an exit code.

* Make the colours adjustable per operator profile

Six colour roles per design, per profile: surface, window surface, field interior,
text, accent and separator line. A change applies at once in every open window and
survives a restart once the settings are saved, like any other preference.

Three sources, later wins role by role: the shipped stylesheet from the classpath,
then the profile's own stylesheet file if there is one, then the overrides stored in
the profile's preferences. resolvePalette is a pure function and reports where every
role's colour came from, which the tab shows per field -- with two sources able to
decide one colour, that is the only thing keeping "why is this colour what it is"
answerable, and the spec calls it mandatory rather than decorative.

Four things this had to get right, each learned the hard way and each pinned:

A role is a selector and a property, not a property alone. Three of the six are
stated outside .root -- text on .label, the separator on .separator *.line -- and
-fx-background-color alone appears a dozen times in the evening sheet. Measured: the
shipped daylight sheet states no colour at all, the evening sheet states five and
implies the sixth.

Kst4ContestTheme reads the palette from the store's state and not from remember.
That read is what makes a change repaint every open window; remember(darkMode) held
the palette until somebody toggled day/evening, so a change reached nothing.

A derived colour follows its source's value, never its provenance. The root
profile's own stylesheet is the one copyResourceIfRequired writes at every startup --
the shipped sheet itself -- so layer 2 declares the accent on every existing
installation with nobody having chosen anything. Keying textAccent on "does this come
from a file" repainted the evening accent from green to blue on upgrade and made
"reset to shipped" disagree with "discard my changes".

The contrast guard measures where colours are actually drawn, read off Theme.kt's
Material mapping rather than off the role names: background and surface are both the
window surface, and primary is textAccent, not accent. It reports a pair only when it
is both under WCAG AA and worse than the shipped sheet -- the shipped sheets have
three pairs under 4.5 themselves, so an absolute floor would warn on an untouched
client and teach the operator to ignore warnings.

The way back out of an unreadable palette is deliberately overbuilt, because it is
the one failure that locks itself in. "Reset to the shipped colours" takes the
shipped palette as a required parameter and reads no composition local, so there is
no path by which it could be drawn in the colours it exists to undo; it looks out of
place under a customised palette, and that is what makes it findable. Ctrl+Z, Ctrl+D
and Ctrl+R reach the three ways back even with the caret in a colour field. A
mistyped colour is refused and the field snaps back.

The profile's stylesheet is the hand-editing route the copy under ~/.praktiKST has
been promising since it was first written and never read. It is read now. A button
writes the shipped template as a starting point and never replaces a file that is
already there. An unreadable, truncated or non-CSS file does not prevent startup --
the role falls back to the shipped design. A sheet stating -fx-base and no
-fx-background states the derived window surface too, the way JavaFX does, or the
most natural hand edit would leave every window the shipped light grey.

101 new tests. All of it runs headless.

* Document the adjustable colours, and record the design and the plan

Both manuals get the Colours tab in full: the six roles, that a change applies at
once but survives a restart only after Save settings, the three ways back with their
keyboard shortcuts, what each provenance label means, and the readability notice that
warns without refusing.

Two things an operator would otherwise have to discover. The reset button is always
drawn in the shipped colours and works when nothing else can be read -- the risk
section of the design says plainly that a keyboard path only helps whoever knows it,
so the manual is where this has to be written down. And the root profile already has
both stylesheet files, because KST4Contest has been writing them at startup for
years, so most of its roles report "from file" rather than "shipped" with identical
values.

PROJECT_CONTEXT.md records the decisions that outlive this stage: the three sources
and their precedence, that a role is a selector and a property, that the theme must
read the store's state rather than remember, where the contrast pairs come from, why
the root profile's file layer always exists, and why the lifeline refuses the active
palette. Also corrects its claim that CSS stays global -- a profile now owns its
stylesheets.

The design document and the implementation plan are kept as they were written,
including the plan's own corrections where measuring the code contradicted it.

* Speak the operator's language

The interface is German or English, chosen per operator profile on the GUI tab, in force at
once in every open window, and remembered across restarts. 376 texts in both languages.

The texts are ordinary .properties files that nobody has to compile: a contributor copies the
English one, translates the values, and opens a pull request. A third module -- build tooling
that never ships -- turns them into Kotlin before anything compiles, which is what makes a
mistyped key and a wrong argument count compile errors rather than things an operator
discovers. It lives in the main build rather than buildSrc because buildSrc's tests do not run
on `./gradlew build`, measured, and a generator whose tests never run is the exact failure it
exists to prevent.

Incompleteness is allowed and incorrectness is not. A missing key warns and falls back to
English, so a partial contributed language can be submitted at all; an orphan key, a
placeholder the translation drops, a duplicate key or an unreadable file fail the build with
the generator's own message naming the file. The argument count always comes from the English
base. German is held complete by a test because it ships; a contributed language is not.

What is deliberately not translated is the harder half. Protocol frames and chat commands
would break the connection or go unread; the beacon texts, auto answers, snippets and
shortcuts go to other radio amateurs and sit in every operator's stored profile; the 273
println calls in core are diagnostics, and a German stack trace is worse for whoever reads the
bug report; and QRA, QRG, QTF, LOS, Fresnel and wkd read the same in either language. Three
tests guard the boundary by measuring behaviour rather than text: the protocol frames built
under three locales, every preference default compared by reflection, and no translation value
being a protocol command. The reflective one is the only one that covers what nobody thought
of.

One insight turned up three times. Anything built outside a composition freezes the language it
was built in: the window title, the settings tab strip and every column header are all
constructed once at startup -- the columns from Java -- so each now holds a function read when
it is drawn. That also fixed something older and unrelated to language: the main window's title
was a fixed "KST4Contest (Compose)" for the whole session, where the JavaFX window had carried
the live connection line. It carries it again.

A column's id is never translated. It is what the remembered widths and order are keyed on.

* Document the two languages, and how to contribute a third

Both manuals get the language picker in full: where it sits, that it applies per profile and
takes effect at once in every open window, and -- at more length -- what is deliberately left
alone and why. An operator who sees an English beacon text in a German client should be able
to read that it is meant that way.

The contribution route is written down in both, because plain text files are only useful if
somebody knows they are there: copy, translate, submit; a partial file is welcome; the build
reports mistakes by file name and writes a worksheet of what is still missing.

PROJECT_CONTEXT records the decisions that outlive this stage -- the four kinds of text and
the rule that the addressee decides, that core produces no display text, that incompleteness
is allowed and incorrectness is not, that anything built outside a composition needs its text
read inside one, and that a column id is never translated.

CLAUDE.md said two modules; there are three, and the third never ships. It also states the
German orthography now, which this session got wrong across a spec and two plans: ss after a
short vowel, so muss and dass.

* Remove the implementation plans now that every stage is executed

Thirteen plans, all of them step-by-step instructions for work that is committed. A plan left
behind reads to the next person like an open intention, and these describe a build that no
longer exists -- the first still explains how to introduce Gradle.

The specifications stay. They say why the architecture is what it is, which the code cannot,
and three of them are still argued from. The live-findings note stays too: CLAUDE.md sends
people to it, and its hardest lesson -- that a blocking wait on the AWT event thread deadlocks
the interface while the build stays green -- is written down nowhere else.

Its sweep script stays as well. The note argues explicitly that the script belongs beside the
finding so the next reader can measure rather than believe, and removing it would contradict
the reasoning it illustrates.

Two references in the note and one in the JavaFX-removal spec pointed at plans and now say
where the reasoning went instead.

* Point the JavaFX-removal spec at where its plan's reasoning went

* Remove the stray release-metadata patch from the working tree

A 416-line patch file that was applied long ago and has sat in the repository root since. It
predates this branch and stays in the shared history, where rewriting it would mean rewriting
commits that are already on main and on origin.

* Check the translation files in CI and say what they hold

classes already runs the generator, so an orphan key, a dropped placeholder
or a duplicate fails the compile job with the generator's own message naming
the file -- verified by breaking strings_de.properties on purpose.

A step summary counts each language against the base, so the author of a
translation PR sees what they covered without reading the build log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse a translation key that would not form a Kotlin name

The generator escaped every translation value but emitted the key verbatim as
the member identifier, and identifierFor strips only . _ - -- so a key carrying
a space, a brace, a quote or a newline from a \uXXXX escape broke out of
val <name>: String get() = lookup(...) into an init block or property
initializer that runs when Strings is instantiated. Translation files are the
contribution surface that arrives by pull request, so this was a path from a
merged .properties change to code in every client.

The generated identifier is now held to letters-then-letters-and-digits before
any source is written, alongside the existing orphan and collision refusals.
Proven by replaying a file-writing payload against the built generator: it now
throws and writes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Port the rebased diagnostics and session logging onto the modules

origin/main landed replayable session logging (MessageHistoryRecorder), file
logging (ApplicationFileLogging) and a user-list refresh split on the old flat
layout. The rebase carried the new files, but three things needed the new
architecture:

- ChatController.fireUserListUpdate() used javafx Platform; core is toolkit-free
  now, so it dispatches through uiDispatcher like every other fire method.
- Two new tests were left in the old src/test/java; moved into core.
- ScoreServiceRefreshTest mocked javafx Platform statically; it now injects the
  DirectUiDispatcher the no-arg constructor installs.

The Leaflet map performance work was dropped with the HTML map it optimised.

classes testClasses and test are green: 1069 tests, 0 failures, 3 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the debug-to-file recording a home in the Compose settings

origin/main added a checkbox to the old JavaFX settings that recorded the whole
ON4KST session to file; the Compose redraw dropped the old settings UI, so the
toggle had no control left even though the preference and its wiring survived
the rebase.

It returns to the message-handling tab: the state writes the preference and asks
the application, through a new SettingsHost callback, to open or close the
error-log handler and the history recorder in the order the old checkbox kept.
The explanatory line and its German translation say that passwords are redacted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix Codex review findings on PR 89: station row key, map selection, sort after filter

- Station row key now includes the chat category, so the same callsign in two
  categories no longer collides.
- Map marker click and Reset view go through the central station selection.
- Sorting now operates on the filtered rows, so filters stay effective.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the map drawing test off the network and the user's tile cache

The test assumed no tile ever arrives, but it used the default TileFetcher,
which reads the real disk cache and the OSM tile server. On CI a tile now and
then landed before the frame was rendered and covered the background the
'unreachable tile source' test asserts on. An empty temporary cache and a
source that always fails make the assumption hold by construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-08 23:22:11 +02:00
1 parent 3e8457eede
commit 2a5c89b943
600 files changed
+46019 -23704

No files matched your search

@@ -1,78 +0,0 @@
package kst4contest;
import java.util.Random;
public class ApplicationConstants {
/**
* default constructor generates runtime id
*/
ApplicationConstants() {
sessionRuntimeUniqueId = generateRuntimeId();
};
public static int sessionRuntimeUniqueId = generateRuntimeId();
/**
* Name of the Application.
*/
public static final String APPLICATION_NAME = "praktiKST";
/**
* Version shown to the user and used for semantic version comparison.
*/
public static final String APPLICATION_CURRENT_VERSION = "1.44.0";
/**
* Legacy numeric representation used only while older update feeds and
* application versions still exist.
*/
@Deprecated
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.44;
public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://kst4contest.hamradioonline.de/kst4ContestVersionInfo.xml";
public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml";
public static final String STYLECSSFILE_DEFAULT_DAYLIGHT = "KST4ContestDefaultDay.css";
public static final String STYLECSSFILE_DEFAULT_EVENING = "KST4ContestDefaultEvening.css";
public static final String DISCSTRING_DISCONNECT_AND_CLOSE = "CLOSEALL";
public static final String DISCSTRING_DISCONNECT_DUE_PAWWORDERROR = "JUSTDSICCAUSEPWWRONG";
public static final String DISCSTRING_DISCONNECTONLY = "ONLYDISCONNECT";
// public static final String DISCONNECT_RDR_POISONPILL = "POISONPILL_KILLTHREAD: " + sessionRuntimeUniqueId; //whereever a (blocking) udp or tcp reader in an infinite loop gets this message, it will break this loop
public static final String DISCONNECT_RDR_POISONPILL = "UNKNOWN: KST4C KILL POISONPILL_KILLTHREAD=: " + sessionRuntimeUniqueId; //whereever a (blocking) udp or tcp reader in an infinite loop gets this message, it will break this loop
public static final String AUTOANSWER_PREFIX = "[KST4C Automsg]"; // hard-coded marker (user cannot remove it)
/**
* UI message retention limits.
*
* The global chat message list is the backing list for several FilteredLists
* and TableViews. It must not grow without limit during long contest runs.
*
* The list is kept in newest-first order:
* index 0 = newest message
* last index = oldest message
*/
public static final int CHAT_MESSAGE_STORE_MAX_SIZE = 30000;
public static final int CHAT_MESSAGE_STORE_TRIM_TO_SIZE = 25000;
/**
* DXCluster table retention limits.
*/
public static final int CLUSTER_MESSAGE_STORE_MAX_SIZE = 10000;
public static final int CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE = 8000;
/**
* generates a unique runtime id per session. Its used to feed the poison pill in order to kill only this one and
* only instance if the program and not multiple instances
* @return
*/
public static int generateRuntimeId() {
Random ran = new Random();
return ran.nextInt(6) + 100;
}
}
@@ -1,315 +0,0 @@
package kst4contest.controller;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TimerTask;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
/**
* Sends periodical path requests and an AirScout watchlist for the currently
* active ON4KST stations.
*/
public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
private static final Logger LOGGER = Logger.getLogger(
AirScoutPeriodicalAPReflectionInquirerTask.class.getName()
);
private static final String BROADCAST_ADDRESS = "255.255.255.255";
private final ChatController client;
/*
* ASWATCHLIST is sent as one common list. Remember one syntactically valid
* AirScout band value so an empty list can still be sent on a later cycle
* to remove stations which are no longer active.
*/
private String lastWatchListBandValue;
public AirScoutPeriodicalAPReflectionInquirerTask(
ChatController client
) {
this.client = client;
}
@Override
public void run() {
Thread.currentThread().setName(
"AirscoutPeriodicalReflectionInquirerTask"
);
/*
* Keep the scheduled task installed so that AirScout can be enabled at
* runtime, but do not send anything while the integration is disabled.
*/
if (!client.getChatPreferences().isAirScout_asUDPListenerEnabled()) {
return;
}
String clientIdentifier =
client.getChatPreferences().getAirScout_asClientNameString();
String serverIdentifier =
client.getChatPreferences().getAirScout_asServerNameString();
String ownCallSign = normalizeOwnCallSign(
client.getChatPreferences().getStn_loginCallSign()
);
String ownLocator =
client.getChatPreferences().getStn_loginLocatorMainCat();
if (ownCallSign == null
|| ownCallSign.isBlank()
|| ownLocator == null
|| ownLocator.isBlank()) {
LOGGER.warning(
"AirScout queries were skipped because the own callsign "
+ "or locator is missing."
);
return;
}
String setPathPrefix =
"ASSETPATH: \"" + clientIdentifier
+ "\" \"" + serverIdentifier + "\" ";
String watchListPrefix =
"ASWATCHLIST: \"" + clientIdentifier
+ "\" \"" + serverIdentifier + "\" ";
String ownStation = ownCallSign + "," + ownLocator;
List<ChatMember> activeMembers = client.snapshotChatMembers();
List<String> watchListTargets = new ArrayList<>();
Set<String> processedCallsigns = new LinkedHashSet<>();
String watchListBandValue = null;
int port = client.getChatPreferences()
.getAirScout_asCommunicationPort();
try (
DatagramSocket socket = new DatagramSocket()
) {
socket.setBroadcast(true);
InetAddress broadcastAddress =
InetAddress.getByName(BROADCAST_ADDRESS);
for (ChatMember member : activeMembers) {
if (!isUsableAirScoutTarget(member)) {
continue;
}
if (member.getQrb() == null
|| member.getQrb()
>= client.getChatPreferences().getStn_maxQRBDefault()) {
continue;
}
String callsignKey = member.getCallSignRaw();
if (callsignKey == null || callsignKey.isBlank()) {
callsignKey = member.getCallSign();
}
if (callsignKey == null
|| !processedCallsigns.add(
callsignKey.trim().toUpperCase(Locale.ROOT)
)) {
continue;
}
/*
* The resolver may deliberately return an exact QRG. AirScout must
* only see a canonical protocol band value such as 4320000.
*/
String bandValue = canonicalizeAirScoutBandValue(
client.resolveAirScoutBandValue(member)
);
if (bandValue == null) {
continue;
}
if (watchListBandValue == null) {
watchListBandValue = bandValue;
}
String targetStation =
member.getCallSign() + "," + member.getQra();
String pathQuery =
setPathPrefix
+ bandValue
+ ","
+ ownStation
+ ","
+ targetStation
+ " ";
sendPacket(
socket,
broadcastAddress,
port,
pathQuery
);
watchListTargets.add(targetStation);
}
/*
* AirScout keeps one watchlist per client/server pair. Do not send
* separate lists for the individual station bands because a later
* list would replace stations from an earlier one.
*
* If there are no targets in this cycle, reuse the last valid band
* token and send an empty list so AirScout can clear stale entries.
*/
if (watchListBandValue == null) {
watchListBandValue = lastWatchListBandValue;
}
if (watchListBandValue != null) {
StringBuilder watchListMessage = new StringBuilder(
watchListPrefix
+ watchListBandValue
+ ","
+ ownStation
);
for (String targetStation : watchListTargets) {
watchListMessage
.append(",")
.append(targetStation);
}
watchListMessage.append(" ");
sendPacket(
socket,
broadcastAddress,
port,
watchListMessage.toString()
);
lastWatchListBandValue = watchListBandValue;
}
} catch (IOException exception) {
LOGGER.log(
Level.WARNING,
"Could not send the periodical AirScout queries.",
exception
);
}
}
/**
* Converts a frequency-like value returned by the station resolver into the
* canonical band token expected by the AirScout UDP protocol.
*
* <p>The internal resolver may keep an exact working frequency for path
* analysis. This method removes that precision only at the AirScout protocol
* boundary. For example, {@code 4321740} is sent to AirScout as
* {@code 4320000}.</p>
*
* @param resolvedValue frequency-like AirScout value produced by the resolver
* @return canonical AirScout band value, or {@code null} if unsupported
*/
private String canonicalizeAirScoutBandValue(String resolvedValue) {
if (resolvedValue == null || resolvedValue.isBlank()) {
return null;
}
String normalizedValue = resolvedValue.trim();
if ("off".equalsIgnoreCase(normalizedValue)
|| "auto".equalsIgnoreCase(normalizedValue)) {
return null;
}
final long numericValue;
try {
numericValue = Long.parseLong(normalizedValue);
} catch (NumberFormatException exception) {
LOGGER.log(
Level.WARNING,
"Unsupported AirScout band value: " + resolvedValue,
exception
);
return null;
}
double frequencyMHz = numericValue / 10_000.0;
Band band = Band.fromFrequency(frequencyMHz);
if (band == null) {
LOGGER.warning(
"AirScout query skipped because frequency "
+ frequencyMHz
+ " MHz does not belong to a supported band."
);
return null;
}
return band.getPrefix() + "0000";
}
/**
* Removes the ON4KST login suffix because AirScout expects the actual
* station callsign, for example 9A1W instead of 9A1W-2.
*
* @param callSign configured ON4KST login callsign
* @return callsign without an ON4KST login suffix
*/
private String normalizeOwnCallSign(String callSign) {
if (callSign == null) {
return null;
}
String normalizedCallSign = callSign.trim();
int suffixSeparator = normalizedCallSign.indexOf("-");
if (suffixSeparator > 0) {
return normalizedCallSign.substring(0, suffixSeparator);
}
return normalizedCallSign;
}
private boolean isUsableAirScoutTarget(ChatMember member) {
return member != null
&& member.getCallSign() != null
&& !member.getCallSign().isBlank()
&& member.getQra() != null
&& !member.getQra().isBlank();
}
private void sendPacket(
DatagramSocket socket,
InetAddress address,
int port,
String message
) throws IOException {
byte[] payload = message.getBytes(StandardCharsets.UTF_8);
DatagramPacket packet = new DatagramPacket(
payload,
payload.length,
address,
port
);
socket.send(packet);
}
}
@@ -1,13 +0,0 @@
package kst4contest.controller;
import kst4contest.view.Kst4ContestApplication;
public class AppRunner {
public static void main(String[] args) {
Kst4ContestApplication.main(args);
}
}
@@ -1,222 +0,0 @@
package kst4contest.controller;
import java.util.TimerTask;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ThreadStateMessage;
/**
* Sends the configured public-chat beacons for both active chat categories.
*
* <p>Both categories deliberately share one timer and therefore the same
* interval. Their enable flags and message templates remain independent. Every
* run reads the current preferences, resolves global message variables and
* sends only the categories which are currently enabled.</p>
*
* <p>Beacon messages use the regular outbound chat-message pipeline. They are
* not assembled as raw ON4KST frames, because that would bypass the common
* category, delimiter and message-text validation.</p>
*/
public class BeaconTask extends TimerTask {
private static final String THREAD_NICKNAME = "MyBeacon";
private final ChatController chatController;
private final ThreadStatusCallback callbackToController;
/**
* Creates one execution of the shared beacon timer.
*
* @param chatController controller providing preferences and the TX queue
* @param callbackToController callback used by the thread-status display
*/
public BeaconTask(
ChatController chatController,
ThreadStatusCallback callbackToController
) {
this.chatController = chatController;
this.callbackToController = callbackToController;
}
@Override
public void run() {
Thread.currentThread().setName("BeaconTask");
reportStatus(THREAD_NICKNAME, true, "initialized", false);
sendMainCategoryBeacon();
sendSecondCategoryBeacon();
}
/**
* Sends the main-category beacon if it is currently enabled.
*/
private void sendMainCategoryBeacon() {
if (!chatController.getChatPreferences()
.isBcn_beaconsEnabledMainCat()) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"off",
false
);
return;
}
ChatMessage beaconMessage = buildBeaconMessage(
chatController.getChatPreferences()
.getLoginChatCategoryMain(),
chatController.getChatPreferences()
.getBcn_beaconTextMainCat(),
"main category"
);
if (beaconMessage == null) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"invalid text",
true
);
return;
}
System.out.println(
new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
+ " [BeaconTask, Info]: Sending main-category CQ: "
+ beaconMessage.getMessageText()
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(
THREAD_NICKNAME + " 1",
true,
"on",
false
);
}
/**
* Sends the second-category beacon if the second login and its beacon are
* currently enabled.
*/
private void sendSecondCategoryBeacon() {
if (!chatController.getChatPreferences()
.isLoginToSecondChatEnabled()
|| !chatController.getChatPreferences()
.isBcn_beaconsEnabledSecondCat()) {
reportStatus(
THREAD_NICKNAME + " 2",
false,
"off",
false
);
return;
}
ChatMessage beaconMessage = buildBeaconMessage(
chatController.getChatPreferences()
.getLoginChatCategorySecond(),
chatController.getChatPreferences()
.getBcn_beaconTextSecondCat(),
"second category"
);
if (beaconMessage == null) {
reportStatus(
THREAD_NICKNAME + " 2",
false,
"invalid text",
true
);
return;
}
System.out.println(
new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
+ " [BeaconTask, Info]: Sending second-category CQ: "
+ beaconMessage.getMessageText()
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(
THREAD_NICKNAME + " 2",
true,
"on",
false
);
}
/**
* Resolves and validates one beacon before placing it in the regular outbound
* message queue.
*
* <p>The returned message contains only the public-chat payload and its chat
* category. {@link WriteThread} creates the final ON4KST frame through
* {@link On4KstProtocol#chatMessage(int, String)}. This prevents a configurable
* beacon text from bypassing the common protocol validation.</p>
*
* @param category target ON4KST chat category
* @param configuredText configured beacon template
* @param categoryDescription text used in diagnostic output
* @return prepared message, or {@code null} if the category or text is invalid
*/
private ChatMessage buildBeaconMessage(
ChatCategory category,
String configuredText,
String categoryDescription
) {
try {
if (category == null) {
throw new IllegalArgumentException(
"No chat category is configured."
);
}
On4KstProtocol.category(category.getCategoryNumber());
String resolvedText =
chatController.resolveAndValidateBeaconText(
configuredText
);
ChatMessage beaconMessage = new ChatMessage();
beaconMessage.setMessageText(resolvedText);
beaconMessage.setChatCategory(category);
beaconMessage.setMessageDirectedToServer(false);
return beaconMessage;
} catch (IllegalArgumentException exception) {
System.out.println(
"[BeaconTask, Warning]: Beacon for "
+ categoryDescription
+ " was not queued: "
+ exception.getMessage()
);
return null;
}
}
/**
* Forwards one state update to the existing thread-status display.
*/
private void reportStatus(
String threadName,
boolean running,
String information,
boolean criticalState
) {
ThreadStateMessage stateMessage = new ThreadStateMessage(
threadName,
running,
information,
criticalState
);
callbackToController.onThreadStatus(
THREAD_NICKNAME,
stateMessage
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,196 +0,0 @@
//package kst4contest.controller;
//
//import kst4contest.model.ChatMember;
//import kst4contest.model.ChatMessage;
//
//import java.io.*;
//import java.net.ServerSocket;
//import java.net.Socket;
//import java.nio.channels.ServerSocketChannel;
//import java.nio.channels.SocketChannel;
//import java.time.Instant;
//
///**
// * This thread is responsible for providing DXCluster messages for a connected log program.
// *
// *
// */
////public class DXClusterController extends Thread {
// PrintWriter outTelnet;
// BufferedReader inTelnet;
// private Socket socket;
// private ChatController client;
//// private OutputStream output;
//// private InputStream input;
//
// private ChatMessage messageTextRaw;
//
// private static final int PORT = 23;
// private static final String USERNAME = "user";
// private static final String PASSWORD = "pass";
// private Socket clientSocket;
//
//
//
//
//// public DXClusterController(Socket clientSocket, ChatController client) throws InterruptedException {
////
//// this.client = client;
////
//// try {
//// outTelnet = new PrintWriter(clientSocket.getOutputStream(), true);
//// inTelnet = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
//// } catch (IOException e) {
//// throw new RuntimeException(e);
//// }
//// System.out.println("defcons");
//// this.clientSocket = clientSocket;
////
//// }
//
// public DXClusterController(Socket clientSocket, ChatController chatController) {
//
// try {
// socket = clientSocket;
// } catch (Exception e) {
// throw new RuntimeException(e);
// }
// this.client = chatController;
//
// try {
// outTelnet = new PrintWriter(socket.getOutputStream(), true);
// inTelnet = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// } catch (IOException e) {
// throw new RuntimeException(e);
// }
// System.out.println("[DXCCtrl, info:] DXCluster Controller created!");
// this.clientSocket = socket;
// }
//
// public DXClusterController(Socket clientSocket, ObjectOutputStream objectout, ChatController chatController) {
//
// try {
// socket = clientSocket;
// } catch (Exception e) {
// throw new RuntimeException(e);
// }
// this.client = chatController;
//
// try {
// outTelnet = new PrintWriter(socket.getOutputStream(), true);
// inTelnet = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// } catch (IOException e) {
// throw new RuntimeException(e);
// }
// System.out.println("[DXCCtrl, info:] DXCluster Controller created!");
// this.clientSocket = socket;
// }
//
//// public DXClusterController(ServerSocket clientSocket, ChatController client) throws InterruptedException {
//// //TODO: GOT FROM https://stackoverflow.com/questions/15541804/creating-the-serversocket-in-a-separate-thread
//// try {
//// socket = clientSocket.accept(2);
//// } catch (IOException e) {
//// throw new RuntimeException(e);
//// }
//// this.client = client;
////
//// try {
//// outTelnet = new PrintWriter(socket.getOutputStream(), true);
//// inTelnet = new BufferedReader(new InputStreamReader(socket.getInputStream()));
//// } catch (IOException e) {
//// throw new RuntimeException(e);
//// }
//// System.out.println("defcons");
//// this.clientSocket = socket;
////
//// }
//
//// public DXClusterController(ServerSocketChannel serverSocketChannel, ChatController client) throws InterruptedException {
////
//// this.client = client;
////
////// clientSocketChannel.ac
////
//// try {
////
//// serverSocketChannel.accept();
//// serverSocketChannel.rea
////
//// outTelnet = new PrintWriter(clientSocket.getOutputStream(), true);
//// inTelnet = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
//// } catch (IOException e) {
//// throw new RuntimeException(e);
//// }
//// System.out.println("defcons");
//// this.clientSocket = clientSocket;
////
//// }
//
// public boolean terminateConnection() throws IOException {
//
//// this.output.close();
// this.socket.close();
//
// return true;
// }
//
// public void sendLocalClusterMessage() {
//
// }
//
// public void run() {
//// try (
//// PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
//// BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())))
//// {
//
//// out.println("Welcome to the Telnet Server");
// outTelnet.print("login: ");
// outTelnet.flush();
// try {
// String user = inTelnet.readLine();
// } catch (IOException ex) {
// throw new RuntimeException(ex);
// }
//// finally {
//// try {
//// clientSocket.close();
//// } catch (Exception e) {
//// System.out.println("Error closing client socket: " + e.getMessage());
//// }
//// }
//
//
//// for (int i = 0; i < 10; i++) {
////
//// outTelnet.println("DX de DM5M: 144222.0 DO5AMF JN49FL 2250Z\n");
//// }
//
//
//
// }
//
// /**
// * Sends a DX cluster message to the connected log programs via telnet, returns true if sent
// *
// * @param aChatMember
// * @return
// */
// public boolean propagateSingleDXClusterEntry(ChatMember aChatMember) {
//
// String singleDXClusterMessage = "DX de ";
//
// singleDXClusterMessage += client.getChatPreferences().getLoginCallSign() + " ";
// singleDXClusterMessage += aChatMember.getFrequency().getValue() + " ";
// singleDXClusterMessage += aChatMember.getCallSign().toUpperCase() + " ";
// singleDXClusterMessage += aChatMember.getQra().toUpperCase() + " ";
// singleDXClusterMessage += new Utils4KST().time_generateCurrenthhmmZTimeStringForClusterMessage() + "\n";
//
// outTelnet.println(singleDXClusterMessage);
// outTelnet.flush();
// return true;
// }
//}
@@ -1,165 +0,0 @@
package kst4contest.controller;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
/**
* Formats local DX Cluster spots using the fixed-column layout emitted by
* DXSpider and accepted by common logging programs.
*/
final class DXClusterSpotFormatter {
/** Length of the DX Cluster line before BEL and CRLF framing. */
/* package */
static final int LINE_LENGTH = 75;
/** One-based column in which the spotted callsign starts. */
/* package */
static final int DX_CALL_COLUMN = 27;
/** Width of the fixed comment field. */
/* package */
static final int COMMENT_LENGTH = 30;
/** One-based column in which the UTC time starts. */
/* package */
static final int TIME_COLUMN = 71;
/** Zero-based exclusive end position of the frequency field. */
private static final int FREQUENCY_END = 24;
/** Maximum width of the spotted callsign field. */
private static final int DX_CALL_LENGTH = 12;
/** Required width of the HHMMZ time field. */
private static final int TIME_LENGTH = 5;
/** Minimum separator width between spotter and frequency. */
private static final int MIN_FREQUENCY_GAP = 1;
/** Wire framing appended to every formatted line. */
private static final String PAYLOAD_SUFFIX = "\u0007\u0007\r\n";
private DXClusterSpotFormatter() {
}
/** Builds the fixed 75-character payload line without wire framing. */
/* package */
static String formatLine(
final String spotterCallSign,
final String frequency,
final String dxCallSign,
final String comment,
final String time
) {
final String spotter = requireValue(
spotterCallSign,
"spotter callsign"
)
.toUpperCase(Locale.ROOT);
final String frequencyValue = requireValue(frequency, "frequency");
final String dxCall = requireValue(dxCallSign, "DX callsign")
.toUpperCase(Locale.ROOT);
final String timeValue = requireValue(time, "time");
validateDxCall(dxCall);
validateTime(timeValue);
final String prefix = "DX de " + spotter + ":";
final int frequencyPadding = calculateFrequencyPadding(
prefix,
frequencyValue
);
final String normalizedComment = normalizeComment(comment);
final String line = prefix
+ " ".repeat(frequencyPadding)
+ frequencyValue
+ " "
+ padRight(dxCall, DX_CALL_LENGTH)
+ " "
+ padRight(normalizedComment, COMMENT_LENGTH)
+ " "
+ timeValue;
if (line.length() != LINE_LENGTH) {
throw new IllegalStateException(
"DX Cluster formatter produced "
+ line.length()
+ " characters instead of "
+ LINE_LENGTH
);
}
return line;
}
/** Builds one complete ASCII spot payload including BEL and CRLF framing. */
/* package */
static byte[] formatPayload(
final String spotterCallSign,
final String frequency,
final String dxCallSign,
final String comment,
final String time
) {
return (formatLine(
spotterCallSign,
frequency,
dxCallSign,
comment,
time
) + PAYLOAD_SUFFIX).getBytes(StandardCharsets.US_ASCII);
}
private static void validateDxCall(final String dxCall) {
if (dxCall.length() > DX_CALL_LENGTH) {
throw new IllegalArgumentException(
"DX callsign exceeds 12 characters: " + dxCall
);
}
}
private static void validateTime(final String time) {
if (time.length() != TIME_LENGTH) {
throw new IllegalArgumentException(
"DX Cluster time must contain exactly five characters"
);
}
}
private static int calculateFrequencyPadding(
final String prefix,
final String frequency
) {
final int padding = FREQUENCY_END
- prefix.length()
- frequency.length();
if (padding < MIN_FREQUENCY_GAP) {
throw new IllegalArgumentException(
"Spotter callsign and frequency do not fit the DX Cluster prefix"
);
}
return padding;
}
private static String normalizeComment(final String comment) {
final String normalized = comment == null ? "" : comment.trim();
return normalized.length() > COMMENT_LENGTH
? normalized.substring(0, COMMENT_LENGTH)
: normalized;
}
private static String requireValue(
final String value,
final String fieldName
) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(
"DX Cluster " + fieldName + " is missing"
);
}
return value.trim();
}
private static String padRight(final String value, final int length) {
return value + " ".repeat(length - value.length());
}
}
@@ -1,363 +0,0 @@
package kst4contest.controller;
import kst4contest.model.ChatMember;
import kst4contest.model.ThreadStateMessage;
import java.io.IOException;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
public class DXClusterThreadPooledServer implements Runnable {
private static final Logger LOGGER =
Logger.getLogger(DXClusterThreadPooledServer.class.getName());
private static final String THREAD_NICKNAME = "DXCluster-Server";
private final List<Socket> clientSockets =
Collections.synchronizedList(new ArrayList<>());
private final ChatController chatController;
private final ThreadStatusCallback callBackToController;
private final int serverPort;
private final ExecutorService threadPool =
Executors.newFixedThreadPool(10);
private final ScheduledExecutorService keepAliveExecutor =
Executors.newSingleThreadScheduledExecutor();
private volatile boolean stopped;
private ServerSocket serverSocket;
public DXClusterThreadPooledServer(
int port,
ChatController chatController,
ThreadStatusCallback callback
) {
this.serverPort = port;
this.chatController = chatController;
this.callBackToController = callback;
}
@Override
public void run() {
Thread.currentThread().setName("DXCluster-thread-pooled-server");
try {
serverSocket = new ServerSocket(serverPort);
if (stopped) {
return;
}
callBackToController.onThreadStatus(
THREAD_NICKNAME,
new ThreadStateMessage(
THREAD_NICKNAME,
true,
"Listening on TCP port " + serverPort,
false
)
);
keepAliveExecutor.scheduleAtFixedRate(
this::sendKeepAlive,
30,
30,
TimeUnit.SECONDS
);
while (!stopped) {
try {
Socket clientSocket = serverSocket.accept();
clientSockets.add(clientSocket);
threadPool.execute(
new DXClusterServerWorkerRunnable(
clientSocket,
clientSockets
)
);
} catch (IOException exception) {
if (!stopped) {
LOGGER.log(
Level.SEVERE,
"Error accepting DX Cluster client connection",
exception
);
}
}
}
} catch (IOException exception) {
if (!stopped) {
LOGGER.log(
Level.SEVERE,
"Cannot open DX Cluster TCP port " + serverPort,
exception
);
callBackToController.onThreadStatus(
THREAD_NICKNAME,
new ThreadStateMessage(
THREAD_NICKNAME,
false,
"Cannot open TCP port "
+ serverPort
+ ": "
+ exception.getMessage(),
true
)
);
}
} finally {
closeServerSocket();
closeClientSockets();
keepAliveExecutor.shutdownNow();
threadPool.shutdownNow();
}
}
public synchronized void stop() {
stopped = true;
closeServerSocket();
closeClientSockets();
keepAliveExecutor.shutdownNow();
threadPool.shutdownNow();
}
public boolean hasConnectedClients() {
synchronized (clientSockets) {
removeClosedClients();
return !clientSockets.isEmpty();
}
}
/**
* Sends one DX Cluster spot to all currently connected clients.
*
* @return true if the spot was delivered to at least one client
*/
public boolean broadcastSingleDXClusterEntryToLoggers(
ChatMember chatMember
) {
final byte[] clusterPayload;
final String clusterMessage;
try {
String frequency = Utils4KST.normalizeFrequencyString(
chatMember.getFrequency().getValue(),
chatController
.getChatPreferences()
.getNotify_optionalFrequencyPrefix()
);
clusterPayload = DXClusterSpotFormatter.formatPayload(
chatController
.getChatPreferences()
.getNotify_DXCSrv_SpottersCallSign()
.getValue(),
frequency,
chatMember.getCallSign(),
chatMember.getQra(),
new Utils4KST()
.time_generateCurrenthhmmZTimeStringForClusterMessage()
);
clusterMessage = new String(
clusterPayload,
StandardCharsets.US_ASCII
);
} catch (IllegalArgumentException exception) {
LOGGER.log(
Level.WARNING,
"DX Cluster spot rejected: " + exception.getMessage()
);
return false;
} catch (Exception exception) {
LOGGER.log(
Level.SEVERE,
"Cannot build DX Cluster message",
exception
);
return false;
}
int deliveredClients = 0;
synchronized (clientSockets) {
Iterator<Socket> iterator = clientSockets.iterator();
while (iterator.hasNext()) {
Socket socket = iterator.next();
if (socket == null || socket.isClosed()) {
iterator.remove();
continue;
}
try {
OutputStream output = socket.getOutputStream();
output.write(clusterPayload);
output.flush();
deliveredClients++;
} catch (IOException exception) {
LOGGER.log(
Level.WARNING,
"DX Cluster client disconnected while sending a spot",
exception
);
closeSocket(socket);
iterator.remove();
}
}
}
if (deliveredClients > 0) {
callBackToController.onThreadStatus(
THREAD_NICKNAME,
new ThreadStateMessage(
THREAD_NICKNAME,
true,
"Last spot sent to "
+ deliveredClients
+ " DX Cluster client(s):\n"
+ clusterMessage,
false
)
);
}
return deliveredClients > 0;
}
private void sendKeepAlive() {
synchronized (clientSockets) {
Iterator<Socket> iterator = clientSockets.iterator();
while (iterator.hasNext()) {
Socket socket = iterator.next();
if (socket == null || socket.isClosed()) {
iterator.remove();
continue;
}
try {
OutputStream output = socket.getOutputStream();
output.write(
"\r\n".getBytes(StandardCharsets.US_ASCII)
);
output.flush();
} catch (IOException exception) {
closeSocket(socket);
iterator.remove();
}
}
}
}
private void removeClosedClients() {
clientSockets.removeIf(
socket -> socket == null || socket.isClosed()
);
}
private synchronized void closeServerSocket() {
if (serverSocket == null || serverSocket.isClosed()) {
return;
}
try {
serverSocket.close();
} catch (IOException exception) {
LOGGER.log(
Level.WARNING,
"Error closing DX Cluster server socket",
exception
);
}
}
private void closeClientSockets() {
synchronized (clientSockets) {
for (Socket socket : clientSockets) {
closeSocket(socket);
}
clientSockets.clear();
}
}
private static void closeSocket(Socket socket) {
if (socket == null || socket.isClosed()) {
return;
}
try {
socket.close();
} catch (IOException ignored) {
// The connection is already unusable.
}
}
}
class DXClusterServerWorkerRunnable implements Runnable {
private static final Logger LOGGER =
Logger.getLogger(DXClusterServerWorkerRunnable.class.getName());
private final Socket clientSocket;
private final List<Socket> clientSockets;
DXClusterServerWorkerRunnable(
Socket clientSocket,
List<Socket> clientSockets
) {
this.clientSocket = clientSocket;
this.clientSockets = clientSockets;
}
@Override
public void run() {
try {
OutputStream output = clientSocket.getOutputStream();
output.write(
"login: ".getBytes(StandardCharsets.US_ASCII)
);
output.flush();
System.out.println(
"[DXClusterServer] New client connected: "
+ clientSocket.getInetAddress()
);
} catch (IOException exception) {
LOGGER.log(
Level.WARNING,
"Cannot initialise DX Cluster client connection",
exception
);
synchronized (clientSockets) {
clientSockets.remove(clientSocket);
}
try {
clientSocket.close();
} catch (IOException ignored) {
// The connection is already unusable.
}
}
}
}
@@ -1,116 +0,0 @@
package kst4contest.controller;
import java.util.Optional;
import kst4contest.model.ChatMember;
/**
* Validated QSO state shared by external logger listeners and the controller.
*/
public final class ExternalLoggedQso {
private final String callSign;
private final LoggedQsoBand band;
private final String locator;
private final String source;
private ExternalLoggedQso(
String callSign,
LoggedQsoBand band,
String locator,
String source
) {
this.callSign = callSign;
this.band = band;
this.locator = locator;
this.source = source;
}
/**
* Creates a QSO only when the logger supplied a usable callsign. Missing band
* and locator values remain unavailable and never receive guessed defaults.
*
* @param rawCallSign callsign received from the logger
* @param band normalized band or {@code null}
* @param rawLocator locator received from the logger or {@code null}
* @param source logger name used for persistence diagnostics
* @return validated QSO, or an empty value for a missing callsign
*/
public static Optional<ExternalLoggedQso> create(
String rawCallSign,
LoggedQsoBand band,
String rawLocator,
String source
) {
if (rawCallSign == null) {
return Optional.empty();
}
String normalizedCallSign = rawCallSign.trim();
if (normalizedCallSign.isEmpty()) {
return Optional.empty();
}
String normalizedLocator = WorkedGrossFieldCache.extractLocator6(rawLocator);
return Optional.of(new ExternalLoggedQso(
normalizedCallSign,
band,
normalizedLocator,
source
));
}
public String getCallSign() {
return callSign;
}
public LoggedQsoBand getBand() {
return band;
}
public String getLocator() {
return locator;
}
public String getSource() {
return source;
}
/**
* Builds the database representation of this QSO.
*
* @return chat-member state with global and optional band Worked flags
*/
public ChatMember toWorkedChatMember() {
ChatMember workedCall = new ChatMember();
workedCall.setCallSign(callSign);
workedCall.setWorked(true);
if (band != null) {
band.applyWorkedFlag(workedCall);
}
if (locator != null) {
workedCall.setQra(locator);
}
return workedCall;
}
/**
* Applies this QSO to one active callsign variant.
*
* @param member active variant with the same base callsign
*/
public void applyToActiveMember(ChatMember member) {
if (member == null) {
return;
}
member.setWorked(true);
if (band != null) {
band.applyWorkedFlag(member);
}
if (locator != null
&& (member.getQra() == null
|| member.getQra().isBlank()
|| "unknown".equalsIgnoreCase(member.getQra()))) {
member.setQra(locator);
}
}
}
@@ -1,65 +0,0 @@
package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.ChatMessage;
/**
* Consolereader, reads a full line and adds it to the tx-queue after formatting it like: <br> <br>
*
* MSG|2|0|/cq DO5AMF kst4contest.test|0| <br/><br/>
*
* No need for it as it´s not longer a console application
*/
public class InputReaderThread extends Thread {
private static final Logger LOGGER = Logger.getLogger(InputReaderThread.class.getName());
private PrintWriter writer;
private Socket socket;
private ChatController client;
public InputReaderThread(ChatController client) throws InterruptedException {
this.client = client;
}
public void run() {
while (true) {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
ChatMessage ownMSG = new ChatMessage();
// ownMSG.setDirectedToServer(true);
String sendThisMessage23001 = "";
try {
sendThisMessage23001 = reader.readLine();
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Error reading from stdin", e);
}
ownMSG.setMessageText("MSG|" + this.client.getChatCategoryMain().getCategoryNumber() + "|0|" + sendThisMessage23001 + "|0|");
// System.out.println("inreader " + ownMSG.getMessage() + client.getMessageTXBus().size());
// client.getMessageTXBus().add(ownMSG);
client.getMessageTXBus().add(ownMSG);
try {
this.sleep(500);
} catch (InterruptedException e) {
LOGGER.log(Level.SEVERE, "InputReaderThread interrupted", e);
Thread.currentThread().interrupt();
}
}
}
}
@@ -1,129 +0,0 @@
package kst4contest.controller;
import java.util.Locale;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
/**
* Normalized band of a QSO received from an external logger.
*
* <p>Logger protocols use different representations for the same amateur-radio
* band. UCXLog-compatible XML may contain a frequency-like number or a metre /
* centimetre label, while Win-Test uses its own numeric band IDs. Resolving the
* raw value once prevents the Worked flag and gross-field update from interpreting
* the same packet differently.</p>
*/
public enum LoggedQsoBand {
BAND_50(Band.B_50),
BAND_70(Band.B_70),
BAND_144(Band.B_144),
BAND_432(Band.B_432),
BAND_1296(Band.B_1296),
BAND_2320(Band.B_2320),
BAND_3400(Band.B_3400),
BAND_5760(Band.B_5760),
BAND_10G(Band.B_10G),
BAND_24G(Band.B_24G),
BAND_47G(null),
BAND_76G(null);
private final Band projectBand;
LoggedQsoBand(Band projectBand) {
this.projectBand = projectBand;
}
/**
* Resolves aliases used by UCXLog, N1MM+, DXLog.net, QARTest and compatible
* loggers. The input is trimmed and case-normalized exactly once here.
*
* @param rawBand band value received in the XML packet
* @return normalized band, or {@code null} for a missing or unknown value
*/
public static LoggedQsoBand fromLoggerValue(String rawBand) {
if (rawBand == null) {
return null;
}
String normalizedBand = rawBand.trim().toLowerCase(Locale.ROOT);
return switch (normalizedBand) {
case "50", "6m" -> BAND_50;
case "70", "4m" -> BAND_70;
case "144", "2m" -> BAND_144;
case "432", "70cm" -> BAND_432;
case "1240", "1296", "23cm" -> BAND_1296;
case "2300", "2320", "13cm" -> BAND_2320;
case "3400", "9cm" -> BAND_3400;
case "5600", "5760", "6cm" -> BAND_5760;
case "10g", "10368", "3cm" -> BAND_10G;
default -> null;
};
}
/**
* Resolves the established Win-Test ADDQSO band IDs. IDs 22 and 23 retain
* their existing Worked flags even though the project Band enum has no 47 GHz
* or 76 GHz value for gross-field storage.
*
* @param rawBandId Win-Test band ID
* @return normalized band, or {@code null} for a missing or unknown ID
*/
public static LoggedQsoBand fromWinTestBandId(String rawBandId) {
if (rawBandId == null) {
return null;
}
String normalizedBandId = rawBandId.trim();
return switch (normalizedBandId) {
case "10" -> BAND_50;
case "11" -> BAND_70;
case "12" -> BAND_144;
case "14" -> BAND_432;
case "16" -> BAND_1296;
case "17" -> BAND_2320;
case "18" -> BAND_3400;
case "19" -> BAND_5760;
case "20" -> BAND_10G;
case "21" -> BAND_24G;
case "22" -> BAND_47G;
case "23" -> BAND_76G;
default -> null;
};
}
/**
* Returns the common Band value used by worked-grid persistence.
*
* @return project band, or {@code null} where no matching Band value exists
*/
public Band getProjectBand() {
return projectBand;
}
/**
* Applies the established per-band Worked flag to a chat member.
*
* @param member worked station or active callsign variant
*/
public void applyWorkedFlag(ChatMember member) {
if (member == null) {
return;
}
switch (this) {
case BAND_50 -> member.setWorked50(true);
case BAND_70 -> member.setWorked70(true);
case BAND_144 -> member.setWorked144(true);
case BAND_432 -> member.setWorked432(true);
case BAND_1296 -> member.setWorked1240(true);
case BAND_2320 -> member.setWorked2300(true);
case BAND_3400 -> member.setWorked3400(true);
case BAND_5760 -> member.setWorked5600(true);
case BAND_10G -> member.setWorked10G(true);
case BAND_24G -> member.setWorked24G(true);
case BAND_47G -> member.setWorked47G(true);
case BAND_76G -> member.setWorked76G(true);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,81 +0,0 @@
//package kst4contest.controller;
//
//import kst4contest.model.Band;
//import org.junit.jupiter.api.Test;
//import org.junit.jupiter.params.ParameterizedTest;
//import org.junit.jupiter.params.provider.ValueSource;
//
//import java.util.regex.Matcher;
//import java.util.regex.Pattern;
//
//import static org.junit.jupiter.api.Assertions.assertEquals;
//import static org.junit.jupiter.api.Assertions.assertFalse;
//import static org.junit.jupiter.api.Assertions.assertNotNull;
//import static org.junit.jupiter.api.Assertions.assertNull;
//import static org.junit.jupiter.api.Assertions.assertTrue;
//
//class MessageBusManagementThreadFrequencyContextTest {
//
// private static final Pattern THREE_DIGIT_VALUE =
// Pattern.compile("\\b\\d{3}\\b");
//
// @ParameterizedTest
// @ValueSource(strings = {
// "qrg 210",
// "QRG: 210",
// "freq is 210",
// "frequency = 210",
// "on 210",
// "210 MHz",
// "210 qrg"
// })
// void acceptsBareThreeDigitValueWithFrequencyContext(String messageText) {
// Matcher matcher = findThreeDigitValue(messageText);
//
// assertTrue(
// MessageBusManagementThread
// .hasExplicitBareFrequencyContext(
// messageText,
// matcher.start(),
// matcher.end()
// )
// );
// }
//
// @ParameterizedTest
// @ValueSource(strings = {
// "599",
// "144",
// "serial 210",
// "score 210",
// "worked 210 stations"
// })
// void rejectsBareThreeDigitValueWithoutFrequencyContext(String messageText) {
// Matcher matcher = findThreeDigitValue(messageText);
//
// assertFalse(
// MessageBusManagementThread
// .hasExplicitBareFrequencyContext(
// messageText,
// matcher.start(),
// matcher.end()
// )
// );
// }
//
// @Test
// void resolvesOnlySupportedFallbackPrefixes() {
// assertEquals(Band.B_144, Band.fromPrefix("144"));
// assertEquals(Band.B_432, Band.fromPrefix(" 432 "));
// assertEquals(Band.B_10G, Band.fromPrefix("10368"));
// assertNull(Band.fromPrefix("999"));
// assertNull(Band.fromPrefix(null));
// }
//
// private Matcher findThreeDigitValue(String messageText) {
// Matcher matcher = THREE_DIGIT_VALUE.matcher(messageText);
// assertTrue(matcher.find(), "Test message must contain a three-digit value");
// assertNotNull(matcher.group());
// return matcher;
// }
//}
@@ -1,238 +0,0 @@
package kst4contest.controller;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Records the raw ON4KST transport frames of one application session.
*
* <p>The recorder is shared by the reader and writer threads. Every event is
* written under one lock so concurrent RX and TX traffic cannot interleave.
* File-system failures are contained here and never propagated to connection
* threads.</p>
*/
public final class MessageHistoryRecorder implements AutoCloseable {
/** File name of the active program-session history. */
/* package */ static final String CURRENT_FILE_NAME = "Messagehistory.raw";
/** Component logger. */
private static final Logger LOGGER =
Logger.getLogger(MessageHistoryRecorder.class.getName());
/** Timestamp format for individual history events. */
private static final DateTimeFormatter EVENT_TIMESTAMP =
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'")
.withZone(ZoneOffset.UTC);
/** Timestamp format used in archived history file names. */
private static final DateTimeFormatter ARCHIVE_TIMESTAMP =
DateTimeFormatter.ofPattern("uuuuMMdd-HHmmss'Z'")
.withZone(ZoneOffset.UTC);
/** Shared recorder instance that deliberately discards all frames. */
private static final MessageHistoryRecorder NO_OP_RECORDER =
new MessageHistoryRecorder();
/** Serializes state transitions and complete line writes. */
private final ReentrantLock lock = new ReentrantLock();
/** Directory containing the active and archived history files. */
private final Path appDirectory;
/** Path of the active program-session history. */
private final Path currentFile;
/** Time source for event and archive timestamps. */
private final Clock clock;
/** Whether this instance is the shared no-op recorder. */
private final boolean noOp;
/** Whether new frames should currently be recorded. */
private boolean enabled;
/** Whether this program session has completed its one-time rotation. */
private boolean sessionReady;
/** Whether opening the active file has already been attempted. */
private boolean openAttempted;
/** Writer for the active history file, or {@code null} while closed. */
private BufferedWriter writer;
private MessageHistoryRecorder() {
appDirectory = null;
currentFile = null;
clock = Clock.systemUTC();
noOp = true;
}
/**
* Creates a recorder for one application program session.
*
* @param appDirectory directory containing the current and archived history
* files
* @param enabled whether recording starts immediately
* @param clock time source used for event and archive timestamps
*/
public MessageHistoryRecorder(
final Path appDirectory,
final boolean enabled,
final Clock clock
) {
this.appDirectory = appDirectory;
this.currentFile = appDirectory.resolve(CURRENT_FILE_NAME);
this.clock = clock;
this.noOp = false;
setEnabled(enabled);
}
/** Returns a recorder that deliberately discards every frame. */
/* package */ static MessageHistoryRecorder disabled() {
return NO_OP_RECORDER;
}
/** Enables or disables recording immediately for the current program session. */
public void setEnabled(final boolean requestedEnabled) {
lock.lock();
try {
if (!noOp && enabled != requestedEnabled) {
enabled = requestedEnabled;
if (requestedEnabled) {
if (!sessionReady) {
openAttempted = false;
}
ensureWriter();
} else {
closeWriter();
openAttempted = false;
}
}
} finally {
lock.unlock();
}
}
/** Records one frame received from ON4KST without its transport CR/LF. */
public void recordRx(final String frame) {
record("RX", frame);
}
/** Records one frame successfully sent to ON4KST without its transport CR/LF. */
public void recordTx(final String frame) {
record("TX", frame);
}
private void record(final String direction, final String frame) {
lock.lock();
try {
if (enabled && !noOp) {
ensureWriter();
if (writer != null) {
final String safeFrame =
On4KstProtocol.redactSecretsForRecording(frame);
try {
writer.write(EVENT_TIMESTAMP.format(Instant.now(clock)));
writer.write('\t');
writer.write(direction);
writer.write('\t');
writer.write(safeFrame == null ? "" : safeFrame);
writer.newLine();
writer.flush();
} catch (IOException exception) {
if (LOGGER.isLoggable(Level.WARNING)) {
LOGGER.log(Level.WARNING,
"Could not write ON4KST message history; "
+ "recording is disabled until it "
+ "is enabled again",
exception);
}
closeWriter();
openAttempted = false;
enabled = false;
}
}
}
} finally {
lock.unlock();
}
}
private void ensureWriter() {
if (!enabled || writer != null || openAttempted) {
return;
}
openAttempted = true;
try {
Files.createDirectories(appDirectory);
if (!sessionReady) {
rotatePreviousSession();
sessionReady = true;
}
writer = Files.newBufferedWriter(
currentFile,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
} catch (IOException | RuntimeException exception) {
if (LOGGER.isLoggable(Level.WARNING)) {
LOGGER.log(Level.WARNING,
"Could not initialize ON4KST message history at "
+ currentFile,
exception);
}
closeWriter();
}
}
private void rotatePreviousSession() throws IOException {
if (!Files.isRegularFile(currentFile) || Files.size(currentFile) == 0L) {
return;
}
final String baseName = "Messagehistory-"
+ ARCHIVE_TIMESTAMP.format(Instant.now(clock));
Path archive = appDirectory.resolve(baseName + ".raw");
int suffix = 1;
while (Files.exists(archive)) {
archive = appDirectory.resolve(
baseName + "-" + suffix + ".raw");
suffix++;
}
try {
Files.move(currentFile, archive, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException unsupported) {
Files.move(currentFile, archive);
}
}
private void closeWriter() {
if (writer == null) {
return;
}
try {
writer.close();
} catch (IOException exception) {
LOGGER.log(Level.WARNING,
"Could not close ON4KST message history", exception);
} finally {
writer = null;
}
}
/** Stops recording and closes the current history file. */
@Override
public void close() {
lock.lock();
try {
enabled = false;
closeWriter();
} finally {
lock.unlock();
}
}
}
@@ -1,154 +0,0 @@
package kst4contest.controller;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
import kst4contest.model.AirPlane;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
/**
* Resolves variables used in operator messages, shortcuts, snippets and
* beacons.
*
* <p>Global variables only depend on the local station configuration and can
* therefore be used in every message context. Station variables additionally
* require a selected remote station. Keeping both groups in one resolver
* prevents the send field and the beacon tasks from implementing different
* replacement rules.</p>
*/
public final class MessageVariableResolver {
private static final int SHORT_VALUE_LENGTH = 7;
private final ChatPreferences chatPreferences;
/**
* Creates a resolver backed by the live application preferences.
*
* @param chatPreferences preferences that provide current station values
*/
public MessageVariableResolver(ChatPreferences chatPreferences) {
this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences");
}
/**
* Resolves variables which do not require a selected remote station.
*
* <p>The replacement is literal rather than regular-expression based.
* Callsigns, locators and frequencies are data, not regular expressions.</p>
*
* @param template text that may contain variables
* @return text with all available global variables resolved, or {@code null}
* when the supplied template is {@code null}
*/
public String resolveGlobalVariables(String template) {
if (template == null) {
return null;
}
String primaryFrequency = valueOrEmpty(chatPreferences.getMYQRGFirstCat().getValue());
String secondaryFrequency = valueOrEmpty(chatPreferences.getMYQRGSecondCat().getValue());
String ownLocator = valueOrEmpty(chatPreferences.getStn_loginLocatorMainCat());
String resolvedText = template;
resolvedText = resolvedText.replace("MYQRGSHORT", abbreviate(primaryFrequency));
resolvedText = resolvedText.replace("MYQRG", primaryFrequency);
resolvedText = resolvedText.replace("SECONDQRG", secondaryFrequency);
resolvedText = resolvedText.replace("MYLOCATORSHORT", abbreviateLocator(ownLocator));
resolvedText = resolvedText.replace("MYLOCATOR", ownLocator);
resolvedText = resolvedText.replace("MYCALL", valueOrEmpty(chatPreferences.getStn_loginCallSign()));
resolvedText = resolvedText.replace("MYQTF", formatHeading(chatPreferences.getActualQTF().getValue().doubleValue()));
return resolvedText;
}
/**
* Resolves global variables and variables derived from a selected station.
*
* <p>If no station is selected, station-specific placeholders remain visible.
* This is intentional: silently removing {@code QRZNAME}, {@code FIRSTAP} or
* {@code SECONDAP} could create a plausible-looking but incomplete message.</p>
*
* @param template text that may contain variables
* @param selectedStation currently selected remote station, may be {@code null}
* @return resolved message text
*/
public String resolveForSelectedStation(String template, ChatMember selectedStation) {
String resolvedText = resolveGlobalVariables(template);
if (resolvedText == null || selectedStation == null) {
return resolvedText;
}
resolvedText = resolvedText.replace("QRZNAME", resolveStationName(selectedStation));
resolvedText = resolvedText.replace("FIRSTAP", resolveFirstAirPlane(selectedStation));
resolvedText = resolvedText.replace("SECONDAP", resolveSecondAirPlane(selectedStation));
return resolvedText;
}
private String resolveStationName(ChatMember selectedStation) {
String stationName = valueOrEmpty(selectedStation.getName()).trim();
if (!stationName.isEmpty()) {
return stationName;
}
return valueOrEmpty(selectedStation.getCallSign());
}
private String resolveFirstAirPlane(ChatMember selectedStation) {
List<AirPlane> risingAirPlanes = getRisingAirPlanes(selectedStation);
if (risingAirPlanes.isEmpty()) {
return "no ap available";
}
AirPlane firstAirPlane = risingAirPlanes.get(0);
return "a " + firstAirPlane.getPotencialDescriptionAsWord()
+ " in " + firstAirPlane.getArrivingDurationMinutes() + " min";
}
private String resolveSecondAirPlane(ChatMember selectedStation) {
List<AirPlane> risingAirPlanes = getRisingAirPlanes(selectedStation);
if (risingAirPlanes.size() < 2) {
return "";
}
AirPlane secondAirPlane = risingAirPlanes.get(1);
return "Next " + secondAirPlane.getPotencialDescriptionAsWord()
+ " in " + secondAirPlane.getArrivingDurationMinutes() + " min";
}
private List<AirPlane> getRisingAirPlanes(ChatMember selectedStation) {
AirPlaneReflectionInfo reflectionInfo = selectedStation.getAirPlaneReflectInfo();
if (reflectionInfo == null || reflectionInfo.getRisingAirplanes() == null) {
return List.of();
}
return reflectionInfo.getRisingAirplanes();
}
private String abbreviate(String value) {
return value.substring(0, Math.min(value.length(), SHORT_VALUE_LENGTH));
}
private String abbreviateLocator(String locator) {
return locator.substring(0, Math.min(locator.length(), 4));
}
private String formatHeading(double headingDegrees) {
if (!Double.isFinite(headingDegrees)) {
return "";
}
return BigDecimal.valueOf(headingDegrees).stripTrailingZeros().toPlainString();
}
private String valueOrEmpty(String value) {
return value == null ? "" : value;
}
}
@@ -1,951 +0,0 @@
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 CLIENT_LIVENESS_PROBE_AFTER_MILLIS = 90_000L;
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 = On4KstProtocol.opcode(line);
long probeResponseMillis = session.clientLivenessProbe.acknowledge(now);
if (probeResponseMillis >= 0L) {
LOGGER.log(Level.INFO,
"ON4KST client liveness probe confirmed: session {0}, "
+ "received opcode {1}, response time {2} ms",
new Object[] {
sessionId,
opcode,
probeResponseMillis
});
}
if (On4KstProtocol.isServerLivenessProbe(line)) {
sendServerLivenessProbeResponse(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<>();
int mainCategory = controller.getChatPreferences()
.getLoginChatCategoryMain()
.getCategoryNumber();
Session session = new Session(
token,
socket,
receiveQueue,
transmitQueue);
MessageHistoryRecorder historyRecorder =
controller.getMessageHistoryRecorder();
ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession,
line -> onInboundActivity(token, line),
failure -> onConnectionFailure(token, failure),
historyRecorder);
WriteThread writeThread = new WriteThread(
token, socket, transmitQueue,
mainCategory,
this::isActiveSession,
failure -> onConnectionFailure(token, failure),
controller::onOn4KstOutboundFrameRejected,
historyRecorder);
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 (Throwable exception) {
// Errors must be caught as well: an Error escaping here would be
// swallowed by the scheduler and leave the state machine stuck in
// CONNECTING without any reconnect attempt or user visible failure.
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 sendServerLivenessProbeResponse(Session session) {
if (session == null || !isActiveSession(session.id)) {
return;
}
LOGGER.log(Level.FINE,
"Responding to ON4KST server liveness probe: session {0}, "
+ "opcode CK",
session.id);
sendControl(session, On4KstProtocol.serverLivenessProbeResponse());
}
private void sendClientLivenessProbe(
Session session,
long now,
long inboundIdle
) {
if (session == null || !isActiveSession(session.id)
|| !session.clientLivenessProbe.tryStart(now)) {
return;
}
LOGGER.log(Level.INFO,
"Sending ON4KST client liveness probe: session {0}, "
+ "opcode CK, inbound idle {1} seconds",
new Object[] {
session.id,
inboundIdle / 1_000L
});
sendControl(session, On4KstProtocol.clientLivenessProbe());
}
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 lastInboundMillis = session.lastInboundMillis.get();
long inboundIdle = now - lastInboundMillis;
IdleAction idleAction = determineIdleAction(
inboundIdle,
session.online,
session.clientLivenessProbe.isOutstanding());
if (session.lastInboundMillis.get() != lastInboundMillis) {
return;
}
switch (idleAction) {
case TIMEOUT -> {
if (session.lastInboundMillis.get() != lastInboundMillis) {
return;
}
long probeWaitMillis =
session.clientLivenessProbe.responseWaitMillis(now);
if (probeWaitMillis >= 0L) {
LOGGER.log(Level.WARNING,
"ON4KST client liveness probe timed out: "
+ "session {0}, opcode CK, no response "
+ "for {1} ms, inbound idle {2} seconds; "
+ "reconnecting",
new Object[] {
session.id,
probeWaitMillis,
inboundIdle / 1_000L
});
}
failSession(session.id,
new SocketException("No ON4KST data received for "
+ inboundIdle / 1_000L + " seconds"));
}
case CLIENT_LIVENESS_PROBE ->
sendClientLivenessProbe(session, now, inboundIdle);
case NONE -> {
// The session is active or already has the required idle action.
}
}
} catch (RuntimeException exception) {
LOGGER.log(Level.WARNING,
"ON4KST connection monitor failed", exception);
}
}
/**
* Selects at most one maintenance action for the current inbound idle phase.
*/
static IdleAction determineIdleAction(
long inboundIdleMillis,
boolean online,
boolean probeOutstanding
) {
if (!online) {
return IdleAction.NONE;
}
if (inboundIdleMillis > INBOUND_STALE_AFTER_MILLIS) {
return IdleAction.TIMEOUT;
}
if (inboundIdleMillis > CLIENT_LIVENESS_PROBE_AFTER_MILLIS
&& !probeOutstanding) {
return IdleAction.CLIENT_LIVENESS_PROBE;
}
return IdleAction.NONE;
}
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 | LinkageError exception) {
// LinkageError covers runtime images built without the jdk.net module;
// the connection stays usable, only kernel side keepalive is missing.
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 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 ClientLivenessProbeState clientLivenessProbe =
new ClientLivenessProbeState();
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;
}
}
/** Maintenance action selected by the session monitor. */
enum IdleAction {
NONE,
CLIENT_LIVENESS_PROBE,
TIMEOUT
}
/** Tracks one client-initiated liveness probe for the complete TCP session. */
static final class ClientLivenessProbeState {
private final AtomicLong sentMillis = new AtomicLong();
boolean tryStart(long now) {
return now > 0L && sentMillis.compareAndSet(0L, now);
}
long acknowledge(long now) {
long sent = sentMillis.getAndSet(0L);
return sent == 0L ? -1L : Math.max(0L, now - sent);
}
boolean isOutstanding() {
return sentMillis.get() > 0L;
}
long responseWaitMillis(long now) {
long sent = sentMillis.get();
return sent == 0L ? -1L : Math.max(0L, now - sent);
}
}
}
@@ -1,50 +0,0 @@
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;
};
}
}
@@ -1,259 +0,0 @@
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|";
}
/**
* Redacts credentials from a raw login frame while preserving its structure.
* All non-login frames are returned byte-for-character unchanged.
*/
static String redactSecretsForRecording(String frame) {
if (frame == null || !("LOGINC".equals(opcode(frame))
|| "LOGIN".equals(opcode(frame)))) {
return frame;
}
int opcodeSeparator = frame.indexOf('|');
if (opcodeSeparator < 0) {
return frame;
}
int callsignSeparator = frame.indexOf('|', opcodeSeparator + 1);
if (callsignSeparator < 0) {
return frame.substring(0, opcodeSeparator + 1) + "<REDACTED>";
}
int passwordSeparator = frame.indexOf('|', callsignSeparator + 1);
if (passwordSeparator < 0) {
return frame.substring(0, callsignSeparator + 1) + "<REDACTED>";
}
return frame.substring(0, callsignSeparator + 1)
+ "<REDACTED>"
+ frame.substring(passwordSeparator);
}
/** 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 the session-wide liveness probe initiated by this client. */
static String clientLivenessProbe() {
return "CK|";
}
/** Returns whether a server frame acknowledges a client-initiated probe. */
static boolean isClientLivenessProbeResponse(String frame) {
if (frame == null) {
return false;
}
String normalized = frame.trim().toUpperCase(Locale.ROOT);
return "OK".equals(normalized) || "OK|".equals(normalized);
}
/** Returns whether ON4KST initiated its own liveness check. */
static boolean isServerLivenessProbe(String frame) {
return "CK".equals(opcode(frame));
}
/** Builds the established empty response to a server-initiated {@code CK}. */
static String serverLivenessProbeResponse() {
return "";
}
/** Preserves the existing internal handling of DXQ server data. */
static boolean isInternalDxqResponse(String frame) {
return "DXQ".equals(opcode(frame));
}
/** Extracts and normalizes the opcode without exposing the remaining frame. */
static String opcode(String frame) {
if (frame == null) {
return "";
}
int separator = frame.indexOf('|');
return (separator < 0 ? frame : frame.substring(0, separator))
.trim()
.toUpperCase(Locale.ROOT);
}
/** 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;
}
}
@@ -1,142 +0,0 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
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;
import kst4contest.model.ChatPreferences;
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|"));
}
@Test
void resolvesBeaconVariablesBeforeApplyingProtocolValidation() {
ChatPreferences preferences = new ChatPreferences();
preferences.setMYQRGFirstCat("144.300");
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
controller.validateBeaconTemplate(
"calling cq at MYQRG"
);
assertEquals(
"calling cq at 144.300",
controller.resolveAndValidateBeaconText(
"calling cq at MYQRG"
)
);
}
@Test
void acceptsTemporarilyUnresolvedVariableOnlyBeaconTemplate() {
ChatPreferences preferences = new ChatPreferences();
preferences.setMYQRGFirstCat("");
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
assertDoesNotThrow(
() -> controller.validateBeaconTemplate("MYQRG")
);
assertThrows(
IllegalArgumentException.class,
() -> controller.resolveAndValidateBeaconText("MYQRG")
);
}
@Test
void rejectsEmptyOverlongAndProtocolBreakingBeaconText() {
ChatPreferences preferences = new ChatPreferences();
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate(" ")
);
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate(
"cq at 144.300|0|QUIT"
)
);
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate(
"x".repeat(
ChatController.MAX_BEACON_TEXT_LENGTH
+ 1
)
)
);
}
}
@@ -1,100 +0,0 @@
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,237 +0,0 @@
package kst4contest.controller;
import kst4contest.controller.interfaces.PstRotatorEventListener;
import kst4contest.model.ThreadStateMessage;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
import java.util.logging.Level;
import java.util.logging.Logger;
public class PstRotatorClient implements Runnable {
private ThreadStatusCallback callBackToController;
private String ThreadNickName = "PSTRotator";
private static final Logger LOGGER = Logger.getLogger(PstRotatorClient.class.getName());
private static final int BUFFER_SIZE = 1024;
// Konfiguration
private final String host;
private final int remotePort; // Port, auf dem PSTRotator hört (z.B. 12060)
private final int localPort; // Port, auf dem wir hören (z.B. 12061)
private DatagramSocket socket;
private volatile boolean running = false;
private PstRotatorEventListener listener;
// Executor für Polling (Status-Abfrage)
private ScheduledExecutorService poller;
/**
* Konstruktor
* @param host IP Adresse von PSTRotator (meist "127.0.0.1")
* @param remotePort Der Port, der in PSTRotator eingestellt ist (User-Wunsch: 12060)
* @param listener Callback für den Chatcontroller
*/
public PstRotatorClient(String host, int remotePort, PstRotatorEventListener listener, ThreadStatusCallback callBack) {
this.callBackToController = callBack;
this.host = host;
this.remotePort = remotePort;
// Laut Manual antwortet PSTRotator oft auf Port+1.
// Wir binden uns also standardmäßig auf remotePort + 1.
this.localPort = remotePort + 1;
this.listener = listener;
}
/**
* alternative constructor for seting the remote port explicitely
*/
public PstRotatorClient(String host, int remotePort, int localPort, PstRotatorEventListener listener) {
this.host = host;
this.remotePort = remotePort;
this.localPort = localPort;
this.listener = listener;
}
/**
* Startet den Empfangs-Thread und das Polling
*/
public void start() {
try {
// Socket binden
// socket = new DatagramSocket(null);
// socket.setReuseAddress(true);
// socket = new DatagramSocket(localPort);
//
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(localPort)); //bind to port
running = true;
// 1. Empfangs-Thread starten (dieses Runnable)
Thread thread = new Thread(this, "PSTRotator-Listener-" + remotePort);
thread.start();
// 2. Polling starten (z.B. alle 2 Sekunden Status abfragen)
poller = Executors.newSingleThreadScheduledExecutor();
poller.scheduleAtFixedRate(this::pollStatus, 1, 2, TimeUnit.SECONDS);
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, running, "initialized", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
LOGGER.info("PstRotatorClient started. Remote: " + remotePort + ", Local: " + localPort);
} catch (SocketException e) {
LOGGER.log(Level.SEVERE, "Fehler beim Öffnen des UDP Sockets", e);
}
}
/**
* Stopping threads and closing sockets of pstRotator communicator
*/
public void stop() {
running = false;
if (poller != null && !poller.isShutdown()) {
poller.shutdownNow();
}
if (socket != null && !socket.isClosed()) {
socket.close();
}
}
/**
* Main loop in thread which listens fpr PSTrotator packets
*/
@Override
public void run() {
byte[] buffer = new byte[BUFFER_SIZE];
while (running && !socket.isClosed()) {
try {
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
socket.receive(packet); // Blockiert bis Daten kommen
String received = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.US_ASCII).trim();
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received line\n" + received, false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
parseResponse(received);
} catch (IOException e) {
if (running) {
LOGGER.log(Level.WARNING, "Fehler beim Empfangen des Pakets", e);
}
}
}
}
/**
* parses a pst rotatpr message to fit the PST listener interface
* @param msg
*/
private void parseResponse(String msg) {
// Debug
if (listener != null) listener.onMessageReceived(msg);
// Example answer: "AZ:145.0<CR>", "EL:010.0<CR>", "MODE:1<CR>"
msg = msg.replace("<CR>", "").trim();
try {
if (msg.startsWith("AZ:")) {
String val = msg.substring(3);
if (listener != null) listener.onAzimuthUpdate(Double.parseDouble(val));
}
else if (msg.startsWith("EL:")) {
String val = msg.substring(3);
if (listener != null) listener.onElevationUpdate(Double.parseDouble(val));
}
else if (msg.startsWith("MODE:")) {
// MODE:1 = Tracking, MODE:0 = Manual
String val = msg.substring(5);
boolean tracking = "1".equals(val);
if (listener != null) listener.onModeUpdate(tracking);
}
else if (msg.startsWith("OK:")) {
// Bestätigung von Befehlen, z.B. OK:STOP:1
LOGGER.fine("Befehl bestätigt: " + msg);
}
} catch (NumberFormatException e) {
LOGGER.warning("Konnte Wert nicht parsen: " + msg);
}
}
// --- Sende Methoden (API für den Chatcontroller) ---
private void sendUdp(String message) {
if (socket == null || socket.isClosed()) return;
try {
byte[] data = message.getBytes(StandardCharsets.US_ASCII);
InetAddress address = InetAddress.getByName(host);
DatagramPacket packet = new DatagramPacket(data, data.length, address, remotePort);
socket.send(packet);
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Fehler beim Senden an PstRotator", e);
}
}
/**
* Sendet den generischen XML Befehl.
* Bsp: <PST><AZIMUTH>85</AZIMUTH></PST>
*/
private void sendCommand(String tag, String value) {
String xml = String.format("<PST><%s>%s</%s></PST>", tag, value, tag);
System.out.println("PSTRotatorClient: sent: " + xml);
sendUdp(xml);
}
// Öffentliche Steuermethoden
public void setAzimuth(double degrees) {
// Formatierung ohne unnötige Nachkommastellen, falls nötig
sendCommand("AZIMUTH", String.valueOf((int) degrees));
}
public void setElevation(double degrees) {
sendCommand("ELEVATION", String.valueOf(degrees));
}
public void stopRotor() {
sendCommand("STOP", "1");
}
public void park() {
sendCommand("PARK", "1");
}
public void setTrackingMode(boolean enable) {
sendCommand("TRACK", enable ? "1" : "0");
}
/**
* Method for polling rotators status via PSTRotator software. Asks only for AZ value!<br/>
* Scheduled in a fixed time by executor
*/
public void pollStatus() {
// PSTRotator Dokumentation:
// <PST>AZ?</PST>
// <PST>EL?</PST>
// <PST>MODE?</PST>
// Man kann mehrere Befehle in einem Paket senden
String query = "<PST><AZ?></AZ?><EL?></EL?><MODE?></MODE?></PST>";
// HINWEIS: Laut Doku ist die Syntax für Abfragen etwas anders: <PST>AZ?</PST>
// Daher bauen wir den String manuell, da sendCommand Tags schließt.
sendUdp("<PST>AZ?</PST>");
// sendUdp("<PST>EL?</PST>");
sendUdp("<PST>MODE?</PST>");
}
}
@@ -1,671 +0,0 @@
package kst4contest.controller;
import kst4contest.logic.BandOpportunityResolver;
import kst4contest.view.map.MapCallsignRawSnapshot;
import kst4contest.logic.PropagationFrequencyResolver;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
import javafx.application.Platform;
import kst4contest.locatorUtils.Location;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.view.map.GeometryOnlyPathAnalysisService;
import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
import kst4contest.view.map.PathAnalysisRequest;
import kst4contest.view.map.PathAnalysisResult;
import kst4contest.view.map.PathAnalysisService;
import kst4contest.view.map.PathGeometryUtils;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.function.Consumer;
/**
* Central service for tropo/path reachability calculations.
*
* <p>This service is now the single calculation path for:
* <ul>
* <li>the station table Tropo column</li>
* <li>the Tropo filter/sorter</li>
* <li>the station map path-analysis detail panel</li>
* </ul>
*
* <p>The service always calculates a full {@link PathAnalysisResult}. When the
* result contains a usable link budget, the bidirectional SSB margin is copied
* into all matching {@link ChatMember} objects. The UI can then sort/filter by
* a simple number while the map still receives the full path result.</p>
*/
public final class ReachabilityService {
private static final long FAILED_ANALYSIS_RETRY_DELAY_MS = 5L * 60L * 1000L;
private final ChatController chatController;
private final PathAnalysisService pathAnalysisService;
private final ExecutorService executor;
/**
* Prevents duplicate jobs for the same path while a calculation is already
* queued or running.
*/
private final Set<String> queuedCalculationKeys = ConcurrentHashMap.newKeySet();
/**
* Stores completed usable path-analysis results. Failed/no-budget results are
* not permanently cached so transient terrain/network problems can be retried.
*/
private final Map<String, PathAnalysisResult> pathAnalysisResultCache = new ConcurrentHashMap<>();
/**
* Temporary callback list for map requests waiting for an already running
* background calculation.
*/
private final Map<String, List<Consumer<PathAnalysisResult>>> pendingMapCallbacksByKey = new ConcurrentHashMap<>();
/**
* Prevents immediate retry loops for failed/no-budget analyses, especially
* when a TableView cell repeatedly asks for the same value.
*/
private final Map<String, Long> failedCalculationRetryAfterEpochMs = new ConcurrentHashMap<>();
public ReachabilityService(ChatController chatController) {
this.chatController = Objects.requireNonNull(chatController, "chatController");
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider());
this.executor = Executors.newSingleThreadExecutor(new ReachabilityThreadFactory());
}
/**
* Ensures that the automatically selected reachability band is calculated.
*
* @param member chatmember to evaluate
*/
public void ensureAutoTropoMarginCalculated(ChatMember member) {
ensureTropoMarginCalculated(member, resolveAutoBand(member));
}
/**
* Ensures that a station/band SSB-margin value exists if it is already cached.
*
* <p>This method deliberately does not start online terrain analysis anymore.
* It is safe to call from table cells and filters because it will not consume
* API quota. Full calculations are now started only by explicit map/manual
* requests.</p>
*
* @param member chatmember to evaluate
* @param band band to evaluate
*/
public void ensureTropoMarginCalculated(ChatMember member, Band band) {
if (member == null || band == null) {
return;
}
if (member.hasFiniteTropoSsbMarginDb(band)) {
return;
}
PathAnalysisRequest request = buildRequestForChatMember(member, band);
if (request == null) {
return;
}
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request));
if (cachedResult != null) {
storePathAnalysisResultInChatMembers(member, band, cachedResult);
}
}
/**
* Requests a full path analysis for the station map.
*
* <p>This is the only normal automatic entry point for online/full terrain
* analysis. The map needs the full PathAnalysisResult, and the station table
* needs the SSB margin. Both are produced here through the same calculation.</p>
*
* @param member best matching ChatMember, may be null when only a map snapshot exists
* @param selectedSnapshot selected map snapshot
* @param fxCallback callback executed on the JavaFX thread
* @param requestedBandOverride operator-selected band, or null for automatic resolution
*/
public void requestPathAnalysisForMap(ChatMember member,
MapCallsignRawSnapshot selectedSnapshot,
Band requestedBandOverride,
Consumer<PathAnalysisResult> fxCallback) {
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
if (selectedSnapshot == null) {
dispatchFxCallback(fxCallback, PathAnalysisResult.waitingForSelection(ownLocator6));
return;
}
String targetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
if (ownLocator6.length() != 6) {
dispatchFxCallback(fxCallback,
PathAnalysisResult.waitingForValidHomeLocator(ownLocator6, targetLocator6));
return;
}
if (!selectedSnapshot.hasUsablePosition()) {
dispatchFxCallback(fxCallback,
PathAnalysisResult.waitingForValidTarget(ownLocator6, targetLocator6));
return;
}
Band analysisBand;
double analysisFrequencyMHz;
if (requestedBandOverride != null) {
/*
* An explicit operator selection has priority over automatic propagation
* resolution. Exact recent QRG information on that band is still used
* when available; otherwise the band's default analysis frequency is used.
*/
analysisBand = requestedBandOverride;
analysisFrequencyMHz =
resolveAnalysisFrequencyForBand(member, analysisBand);
} else {
PropagationFrequencyResolver.Resolution frequencyResolution =
resolveAutomaticPropagationFrequency(member);
if (frequencyResolution == null) {
dispatchFxCallback(
fxCallback,
PathAnalysisResult.waitingForUsableBand(
ownLocator6,
targetLocator6,
selectedSnapshot.callSignRaw()
)
);
return;
}
analysisBand = frequencyResolution.getBand();
analysisFrequencyMHz =
frequencyResolution.getAnalysisFrequencyMHz();
}
PathAnalysisRequest request = buildRequest(
ownLocator6,
selectedSnapshot.callSignRaw(),
targetLocator6,
selectedSnapshot.latitudeDeg(),
selectedSnapshot.longitudeDeg(),
analysisFrequencyMHz
);
requestPathAnalysisAndStore(member, analysisBand, request, fxCallback);
}
/**
* Starts a full on-demand reachability calculation for one station row.
*
* <p>Use this for explicit operator actions only. It may call the online terrain
* API and therefore must not be used from TableView cell factories or automatic
* chat-join processing.</p>
*
* @param member station to calculate
* @param band selected reachability band
*/
public void calculateSelectedStationOnDemand(ChatMember member, Band band) {
if (member == null || band == null) {
return;
}
PathAnalysisRequest request = buildRequestForChatMember(member, band);
if (request == null) {
member.setTropoSsbMarginDb(band, Double.NaN);
chatController.fireUserListUpdate("Reachability unavailable");
return;
}
requestPathAnalysisAndStore(member, band, request, null);
}
/**
* Resolves the auto reachability band through the shared propagation
* frequency selection used by AirScout and path analysis.
*
* @param member member to inspect
* @return resolved band
*/
public Band resolveAutoBand(ChatMember member) {
PropagationFrequencyResolver.Resolution resolution =
resolveAutomaticPropagationFrequency(member);
return resolution == null ? null : resolution.getBand();
}
/**
* Resolves one automatic band and exact analysis frequency for a station.
*
* @param member any active category variant of the target station
* @return shared propagation resolution, or {@code null} for unsupported data
*/
public PropagationFrequencyResolver.Resolution resolveAutomaticPropagationFrequency(
ChatMember member
) {
return PropagationFrequencyResolver.resolve(
resolveCallsignVariants(member),
getEnabledStationBands(),
System.currentTimeMillis()
);
}
/**
* Returns the active own bands configured in the station preferences. High bands
* above 10 GHz are intentionally ignored for the first version.
*
* @return set of enabled bands
*/
public EnumSet<Band> getEnabledStationBands() {
return BandOpportunityResolver.getEnabledStationBands(
chatController.getChatPreferences()
);
}
private List<ChatMember> resolveCallsignVariants(ChatMember member) {
if (member == null) {
return List.of();
}
String rawCall = member.getCallSignRaw() != null
? member.getCallSignRaw()
: member.getCallSign();
List<ChatMember> variants = chatController.findActiveChatMembersByRawCall(rawCall);
return variants.isEmpty() ? List.of(member) : variants;
}
/**
* Stops the background executor.
*/
public void shutdown() {
executor.shutdownNow();
}
/**
* Starts or reuses one shared path-analysis calculation.
*
* @param member matching ChatMember, may be null for map-only snapshots
* @param band band under which the value is stored in ChatMember
* @param request full path-analysis request
* @param fxCallback optional map callback
*/
private void requestPathAnalysisAndStore(ChatMember member,
Band band,
PathAnalysisRequest request,
Consumer<PathAnalysisResult> fxCallback) {
if (request == null || band == null) {
return;
}
String calculationKey = buildCalculationKey(request);
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(calculationKey);
if (cachedResult != null) {
storePathAnalysisResultInChatMembers(member, band, cachedResult);
dispatchFxCallback(fxCallback, cachedResult);
return;
}
Long retryAfterEpochMs = failedCalculationRetryAfterEpochMs.get(calculationKey);
if (retryAfterEpochMs != null && System.currentTimeMillis() < retryAfterEpochMs) {
dispatchFxCallback(fxCallback, createNoProfileResult(
request,
"Previous path analysis failed or the terrain API limit was reached. Retry is delayed briefly."
));
return;
}
addPendingCallback(calculationKey, fxCallback);
if (!queuedCalculationKeys.add(calculationKey)) {
return;
}
executor.submit(() -> {
PathAnalysisResult result;
try {
result = pathAnalysisService.analyze(request);
if (result == null) {
result = createNoProfileResult(request, "Path analysis returned no result.");
}
} catch (Exception exception) {
result = createNoProfileResult(
request,
"Path analysis failed: " + exception.getMessage()
);
}
boolean usableBudget = hasUsableLinkBudget(result);
if (usableBudget) {
pathAnalysisResultCache.put(calculationKey, result);
failedCalculationRetryAfterEpochMs.remove(calculationKey);
} else {
failedCalculationRetryAfterEpochMs.put(
calculationKey,
System.currentTimeMillis() + FAILED_ANALYSIS_RETRY_DELAY_MS
);
}
queuedCalculationKeys.remove(calculationKey);
PathAnalysisResult finalResult = result;
Runnable updateTask = () -> {
storePathAnalysisResultInChatMembers(member, band, finalResult);
dispatchAndClearPendingCallbacks(calculationKey, finalResult);
chatController.fireUserListUpdate("Reachability calculated");
};
if (Platform.isFxApplicationThread()) {
updateTask.run();
} else {
Platform.runLater(updateTask);
}
});
}
/**
* Builds a path-analysis request from a ChatMember row.
*
* @param member station row
* @param band selected reachability band
* @return request or null if locators are not usable
*/
private PathAnalysisRequest buildRequestForChatMember(ChatMember member, Band band) {
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
String targetLocator6 = normalizeLocator6(member.getQra());
if (ownLocator6.length() != 6 || targetLocator6.length() != 6) {
return null;
}
Location targetLocation = new Location(targetLocator6);
double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band);
return buildRequest(
ownLocator6,
member.getCallSignRaw(),
targetLocator6,
targetLocation.getLatitude().toDegrees(),
targetLocation.getLongitude().toDegrees(),
analysisFrequencyMHz
);
}
/**
* Builds the shared PathAnalysisRequest used by map and table/manual requests.
*/
private PathAnalysisRequest buildRequest(String ownLocator6,
String targetCallsignRaw,
String targetLocator6,
double targetLatitudeDeg,
double targetLongitudeDeg,
double analysisFrequencyMHz) {
Location homeLocation = new Location(ownLocator6);
return new PathAnalysisRequest(
ownLocator6,
homeLocation.getLatitude().toDegrees(),
homeLocation.getLongitude().toDegrees(),
targetCallsignRaw,
targetLocator6,
targetLatitudeDeg,
targetLongitudeDeg,
analysisFrequencyMHz,
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
chatController.getChatPreferences().buildPathLinkBudgetSettings()
);
}
/**
* Stores the SSB margin from a completed analysis in all matching ChatMember
* objects. Matching uses raw callsign and locator.
*/
private void storePathAnalysisResultInChatMembers(ChatMember primaryMember,
Band band,
PathAnalysisResult result) {
if (band == null || result == null) {
return;
}
double marginDb = hasUsableLinkBudget(result)
? result.linkBudgetSummary().bidirectionalSsbMarginDb()
: Double.NaN;
if (primaryMember != null) {
primaryMember.setTropoSsbMarginDb(band, marginDb);
}
String resultCallSignRaw = normalizeCallsignRaw(result.toCallsignRaw());
String resultLocator6 = normalizeLocator6(result.toLocator6());
for (ChatMember member : chatController.snapshotChatMembers()) {
if (member == null) {
continue;
}
String memberCallSignRaw = normalizeCallsignRaw(member.getCallSignRaw());
if (!resultCallSignRaw.isBlank() && !resultCallSignRaw.equals(memberCallSignRaw)) {
continue;
}
String memberLocator6 = normalizeLocator6(member.getQra());
if (!resultLocator6.isBlank() && !resultLocator6.equals(memberLocator6)) {
continue;
}
member.setTropoSsbMarginDb(band, marginDb);
}
}
/**
* Resolves the analysis frequency for one member/band pair.
*
* <p>Preference order:
* <ol>
* <li>knownActiveBands frequency for the band</li>
* <li>current displayed QRG if it belongs to the same band</li>
* <li>band default frequency</li>
* </ol>
*/
private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) {
if (band == null) {
return Double.NaN;
}
ChatMember.ActiveFrequencyInfo latestFrequencyInfo = null;
for (ChatMember variant : resolveCallsignVariants(member)) {
ChatMember.ActiveFrequencyInfo activeFrequencyInfo =
variant.getKnownActiveBands().get(band);
if (activeFrequencyInfo != null
&& Double.isFinite(activeFrequencyInfo.frequency)
&& activeFrequencyInfo.frequency > 0.0
&& (latestFrequencyInfo == null
|| activeFrequencyInfo.timestampEpoch > latestFrequencyInfo.timestampEpoch)) {
latestFrequencyInfo = activeFrequencyInfo;
}
}
if (latestFrequencyInfo != null) {
return latestFrequencyInfo.frequency;
}
if (member != null && member.getFrequency() != null && member.getFrequency().getValue() != null) {
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().getValue());
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
Band parsedBand = Band.fromFrequency(parsedFrequencyMHz);
if (parsedBand == null || parsedBand == band) {
return parsedFrequencyMHz;
}
}
}
return band.getDefaultAnalysisFrequencyMHz();
}
/**
* Adds a map callback to the pending callback list for one calculation key.
*
* @param calculationKey path key
* @param fxCallback callback to add
*/
private void addPendingCallback(String calculationKey, Consumer<PathAnalysisResult> fxCallback) {
if (fxCallback == null) {
return;
}
pendingMapCallbacksByKey
.computeIfAbsent(
calculationKey,
ignored -> Collections.synchronizedList(new ArrayList<>())
)
.add(fxCallback);
}
/**
* Dispatches and removes all callbacks waiting for one completed calculation.
*
* @param calculationKey path key
* @param result completed result
*/
private void dispatchAndClearPendingCallbacks(String calculationKey, PathAnalysisResult result) {
List<Consumer<PathAnalysisResult>> callbacks = pendingMapCallbacksByKey.remove(calculationKey);
if (callbacks == null || callbacks.isEmpty()) {
return;
}
List<Consumer<PathAnalysisResult>> callbackSnapshot;
synchronized (callbacks) {
callbackSnapshot = new ArrayList<>(callbacks);
}
for (Consumer<PathAnalysisResult> callback : callbackSnapshot) {
dispatchFxCallback(callback, result);
}
}
/**
* Executes a map callback on the JavaFX application thread.
*
* @param fxCallback callback to execute
* @param result result to pass
*/
private void dispatchFxCallback(Consumer<PathAnalysisResult> fxCallback, PathAnalysisResult result) {
if (fxCallback == null) {
return;
}
if (Platform.isFxApplicationThread()) {
fxCallback.accept(result);
} else {
Platform.runLater(() -> fxCallback.accept(result));
}
}
/**
* Checks whether the result contains a usable link-budget summary.
*
* @param result path-analysis result
* @return true if SSB margin can be read
*/
private boolean hasUsableLinkBudget(PathAnalysisResult result) {
return result != null
&& result.linkBudgetSummary() != null
&& result.linkBudgetSummary().hasUsableBudget();
}
/**
* Creates a no-profile/no-budget result for failed service calculations.
*
* @param request original request
* @param statusText status shown in the map detail panel
* @return placeholder path result
*/
private PathAnalysisResult createNoProfileResult(PathAnalysisRequest request, String statusText) {
return PathAnalysisResult.noProfile(
"Reachability",
request.fromLocator6(),
request.toLocator6(),
request.toCallsignRaw(),
Double.NaN,
Double.NaN,
request.homeAntennaHeightMeters(),
request.targetAntennaHeightMeters(),
request.frequencyMHz(),
statusText
);
}
/**
* Builds a stable calculation key.
*
* <p>The key includes station, locators, frequency, antenna heights and link
* budget settings. If any relevant input changes, a new calculation is allowed.</p>
*/
private String buildCalculationKey(PathAnalysisRequest request) {
return normalizeLocator6(request.fromLocator6())
+ "|"
+ normalizeCallsignRaw(request.toCallsignRaw())
+ "|"
+ normalizeLocator6(request.toLocator6())
+ "|"
+ String.format(Locale.US, "%.5f", request.toLatitudeDeg())
+ "|"
+ String.format(Locale.US, "%.5f", request.toLongitudeDeg())
+ "|"
+ String.format(Locale.US, "%.3f", request.frequencyMHz())
+ "|"
+ String.format(Locale.US, "%.1f", request.homeAntennaHeightMeters())
+ "|"
+ String.format(Locale.US, "%.1f", request.targetAntennaHeightMeters())
+ "|"
+ request.linkBudgetSettings();
}
private String normalizeCallsignRaw(String callSignRaw) {
return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT);
}
private String normalizeLocator6(String locator) {
return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT);
}
private static final class ReachabilityThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable, "reachability-service");
thread.setDaemon(true);
return thread;
}
}
}
@@ -1,160 +0,0 @@
package kst4contest.controller;
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;
/**
* Reads exactly one immutable ON4KST connection session.
*
* <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 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 MessageHistoryRecorder historyRecorder;
private final BufferedReader reader;
/**
* 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 -> { },
client.getMessageHistoryRecorder());
}
/**
* 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, socket, receiveQueue, sessionIsActive,
inboundActivity, connectionFailure,
MessageHistoryRecorder.disabled());
}
/** Creates a reader that reports every received frame to the recorder. */
public ReadThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LongPredicate sessionIsActive,
Consumer<String> inboundActivity,
Consumer<Throwable> connectionFailure,
MessageHistoryRecorder historyRecorder
) throws IOException {
this.sessionId = sessionId;
this.socket = socket;
this.receiveQueue = receiveQueue;
this.sessionIsActive = sessionIsActive;
this.inboundActivity = inboundActivity;
this.connectionFailure = connectionFailure;
this.historyRecorder = historyRecorder == null
? MessageHistoryRecorder.disabled() : historyRecorder;
this.reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(), StandardCharsets.UTF_8));
}
@Override
public void run() {
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");
}
historyRecorder.recordRx(response);
if (!sessionIsActive.test(sessionId)) {
break;
}
inboundActivity.accept(response);
if (!sessionIsActive.test(sessionId)) {
break;
}
if (On4KstProtocol.isClientLivenessProbeResponse(response)) {
// OK and OK| acknowledge the client-side CK| probe.
continue;
}
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 {
interrupt();
reader.close();
socket.close();
return true;
}
}
@@ -1,835 +0,0 @@
package kst4contest.controller;
import javafx.application.Platform;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember;
import kst4contest.model.ThreadStateMessage;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class ReadUDPByWintestThread extends Thread {
private static final Pattern STATUS_TOKEN_PATTERN = Pattern.compile("\"([^\"]*)\"|(\\S+)");
private DatagramSocket socket;
private ChatController client;
private volatile boolean running = true;
private int PORT = 9871; //default
private static final int BUFFER_SIZE = 4096;
private long lastPacketTime = 0;
private String myStation = "DO5AMF";
private String targetStation = "";
private String stationID = "";
private ThreadStatusCallback callBackToController;
private String ThreadNickName = "Wintest-msg";
/**
* Number of fields of a complete ADDQSO packet, including message type,
* source and destination.
*/
private static final int ADDQSO_FIELD_COUNT = 24;
/** Field position of the Win-Test QSO number inside an ADDQSO packet. */
private static final int ADDQSO_QSO_NUMBER_INDEX = 11;
/** Field position of the logging station name inside an ADDQSO packet. */
private static final int ADDQSO_STATION_NAME_INDEX = 3;
private final WinTestLogSyncService logSyncService;
private WinTestLogSyncService.SyncState lastReportedSyncState;
/**
* Last IHAVE payload seen per station. Win-Test repeats the inventory
* periodically, so tracing only the changes keeps the output readable.
*/
private final Map<String, String> lastTracedIhaveByStation = new HashMap<>();
private final WinTestNetworkAddressResolver addressResolver;
public ReadUDPByWintestThread(ChatController client, ThreadStatusCallback callback) {
this.callBackToController = callback;
this.client = client;
this.myStation = client.getChatPreferences().getStn_loginCallSignRaw(); //callsign of the logging stn
this.PORT = client.getChatPreferences().getLogsynch_wintestNetworkPort();
WinTestNetworkAddressResolver sharedAddressResolver =
client.getWinTestAddressResolver();
this.addressResolver = sharedAddressResolver != null
? sharedAddressResolver
: new WinTestNetworkAddressResolver();
/*
* Preferences are read late on purpose: station name, port and broadcast
* address can be changed while the listener is running.
*/
this.logSyncService = new WinTestLogSyncService(
this::sendNeedQso,
this::resolveOwnWinTestStationName
);
}
@Override
public void interrupt() {
running = false;
if (socket != null && !socket.isClosed()) socket.close();
super.interrupt();
}
@Override
public void run() {
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
Thread.currentThread().setName("ReadUDPByWintestThread");
byte[] buffer = new byte[BUFFER_SIZE];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
try {
socket = new DatagramSocket(null); //first init with null, then make ready for reuse
socket.setReuseAddress(true);
// socket = new DatagramSocket(PORT);
int boundPort = client.getChatPreferences().getLogsynch_wintestNetworkPort();
socket.bind(new InetSocketAddress(boundPort));
socket.setSoTimeout(3000);
System.out.println("[WinTest UDP listener] started at port: " + boundPort);
} catch (SocketException e) {
e.printStackTrace();
return;
}
while (running) {
try {
/*
* DatagramPacket keeps the length of the previous datagram, so
* without resetting it a long packet would be truncated after a
* short one. A truncated packet loses its trailing fields and
* its checksum.
*/
packet.setLength(buffer.length);
socket.receive(packet);
processWinTestDatagram(
packet.getData(), packet.getLength(), packet.getAddress());
} catch (SocketTimeoutException e) {
logSyncService.tick();
reportSyncStateIfChanged();
} catch (IOException e) {
//TODO: here is something to catch
}
}
}
/**
* Resolves the Win-Test framing of a received datagram and processes it.
*
* <p>The checksum byte and the NUL terminator are removed on the raw bytes
* before any text parsing, so the trailing fields of the packet stay
* readable. Afterwards the log synchronization gets its chance to request
* QSOs that were logged before this listener was started.</p>
*
* @param datagram raw datagram buffer
* @param length number of valid bytes in the buffer
*/
void processWinTestDatagram(byte[] datagram, int length, InetAddress source) {
if (datagram == null || length <= 0) {
return;
}
WinTestPacket packet = WinTestPacket.fromDatagram(datagram, length);
if (packet != null && isWinTestStationMessage(packet.getMessageType())) {
/*
* Win-Test only answers broadcasts. Remembering where its packets
* come from keeps outgoing requests on the network the station
* actually lives in, even when the configured broadcast address
* belongs to a different or no longer existing network.
*/
addressResolver.rememberStationAddress(source);
}
if (packet == null) {
/*
* The datagram does not follow the Win-Test framing. It still
* reaches the established text handling, which also recognizes the
* poison pill that stops this listener.
*/
processWinTestPacket(
null,
new String(datagram, 0, length, StandardCharsets.US_ASCII).trim()
);
return;
}
processWinTestPacket(packet, packet.getMessageText());
logSyncService.tick();
reportSyncStateIfChanged();
}
void processWinTestMessage(String msg) {
processWinTestPacket(WinTestPacket.fromMessageText(msg), msg);
}
/**
* Processes one Win-Test message.
*
* @param packet parsed packet, or {@code null} when the message does not
* follow the Win-Test framing
* @param msg complete message text
*/
private void processWinTestPacket(WinTestPacket packet, String msg) {
// System.out.println("Wintest-Message received: " + msg);
if (msg == null) {
return;
}
lastPacketTime = System.currentTimeMillis();
if (msg.startsWith("HELLO:")) { //Client Signon of wintest
parseHello(msg);
if (packet != null) {
System.out.println("[WinTest RX] HELLO from " + packet.getSource());
logSyncService.onStationSeen(packet.getSource());
}
} else if (msg.startsWith("ADDQSO:")) { //adding qso to wintest log
try {
if (packet != null && !packet.getDestination().isEmpty()) {
/*
* A directed ADDQSO is the answer to one of our NEEDQSO
* requests. Tracing it separates a missing answer from a
* failing evaluation of the answer.
*/
System.out.println("[WinTest RX] ADDQSO answer from "
+ packet.getSource() + " to " + packet.getDestination());
}
parseAddQso(msg);
} catch (Exception e) {
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "Parsing ERROR: " + Arrays.toString(e.getStackTrace()), true);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
}
} else if (msg.startsWith("STATUS")) {
parseStatus(msg);
/*
* HELLO is only sent when a log is opened, so a listener that was
* started later learns about a station from its periodic STATUS.
* The configured station-name filter stays a QRG-sync setting: in a
* multi-station setup every band station keeps its own log, and all
* of them contribute Worked state.
*/
if (packet != null) {
logSyncService.onStationSeen(packet.getSource());
}
} else if (msg.startsWith("IHAVE:")) { //periodical message of wintest, which qsos are in the log
parseIHave(packet);
}
else if (msg.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
System.out.println("ReadUdpByWintest, Info: got poison, now dieing....");
socket.close();
running = false;
}
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "message received\n" + msg, false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
}
/**
* Hands the periodic Win-Test log inventory to the log synchronization.
*
* <p>A packet with a broken checksum is discarded here. The run-length
* inventory is the last field of an IHAVE packet, so a corrupted packet
* would announce QSO ranges that do not exist. The established handling of
* the other message types is deliberately left unchanged, because it never
* verified the checksum.</p>
*
* @param packet received IHAVE packet
*/
private void parseIHave(WinTestPacket packet) {
if (packet == null) {
return;
}
if (packet.isChecksumPresent() && !packet.isChecksumValid()) {
System.out.println("[WinTest] IHAVE with invalid checksum ignored");
return;
}
String tracedPayload = String.join(" ", packet.getDataTokens());
if (!tracedPayload.equals(lastTracedIhaveByStation.put(packet.getSource(), tracedPayload))) {
System.out.println("[WinTest RX] IHAVE from " + packet.getSource()
+ " to '" + packet.getDestination() + "': " + tracedPayload
+ (WinTestIhaveInventory.fromPacket(packet).isEmpty()
? " <-- not usable as inventory" : ""));
}
logSyncService.onIhaveReceived(packet);
}
/**
* Reports a change of the log-synchronization progress to the controller.
*/
private void reportSyncStateIfChanged() {
WinTestLogSyncService.SyncState currentSyncState = logSyncService.getState();
if (currentSyncState == lastReportedSyncState) {
return;
}
lastReportedSyncState = currentSyncState;
ThreadStateMessage threadStateMessage = new ThreadStateMessage(
this.ThreadNickName, true, "log sync: " + currentSyncState, false);
callBackToController.onThreadStatus(ThreadNickName, threadStateMessage);
}
/**
* Sends a NEEDQSO request as a UDP broadcast.
*
* <p>The framing follows the wtKST implementation exactly, including the
* leading blank of the data part:</p>
*
* <pre>
* NEEDQSO: "KST4Contest" "STN1" "STN1@44510" 1 50{checksum}\0
* </pre>
*
* @param targetStation Win-Test station the request is addressed to
* @param logId log identity in the form {@code StationName@LogUniqueID}
* @param countFrom first requested QSO number
* @param countTo last requested QSO number
*/
private void sendNeedQso(String targetStation, String logId, long countFrom, long countTo) {
String data = " \"" + logId + "\" " + countFrom + " " + countTo;
WinTestMessage needQsoMessage = new WinTestMessage(
WinTestMessage.MessageType.NEEDQSO,
resolveOwnWinTestStationName(),
targetStation,
data
);
try (DatagramSocket sendSocket = new DatagramSocket()) {
sendSocket.setBroadcast(true);
sendSocket.setReuseAddress(true);
byte[] messageBytes = needQsoMessage.toBytes();
InetAddress broadcastAddress = addressResolver.resolveBroadcastAddress(
client.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress());
int targetPort = client.getChatPreferences().getLogsynch_wintestNetworkPort();
sendSocket.send(new DatagramPacket(
messageBytes, messageBytes.length, broadcastAddress, targetPort));
System.out.println("[WinTest LogSync] NEEDQSO to " + targetStation
+ " for " + logId + " " + countFrom + "-" + countTo);
} catch (IOException | RuntimeException exception) {
System.out.println("[WinTest LogSync] NEEDQSO could not be sent: "
+ exception.getMessage());
}
}
/**
* @return own station name in the Win-Test network, never blank
*/
private String resolveOwnWinTestStationName() {
String configuredStationName =
client.getChatPreferences().getLogsynch_wintestNetworkStationNameOfKST();
if (configuredStationName == null || configuredStationName.isBlank()) {
return "KST4Contest";
}
return configuredStationName.trim();
}
/**
* Checks whether a message type identifies a genuine Win-Test station.
*
* <p>Internal control messages such as the poison pill must not influence
* the address of outgoing Win-Test packets.</p>
*
* @param messageType message type of a received packet
* @return {@code true} for a Win-Test station message
*/
private static boolean isWinTestStationMessage(String messageType) {
return "HELLO".equals(messageType)
|| "STATUS".equals(messageType)
|| "IHAVE".equals(messageType)
|| "ADDQSO".equals(messageType);
}
/**
* parsing of the hello message of wintest:
* "HELLO: "STN1" "" 6667 130 "SLAVE" 1 0 1762201985"
* @param msg
*/
private void parseHello(String msg) {
try {
String[] tokens = msg.split("\"");
if (tokens.length >= 2) {
targetStation = tokens[1];
System.out.println("[WinTest rcv: found logger instance: " + targetStation);
}
} catch (Exception e) {
System.out.println("[WinTest] ERROR on HELLO-Parsing: " + e.getMessage());
}
}
private byte util_calculateChecksum(byte[] bytes) {
int sum = 0;
for (byte b : bytes) sum += b;
return (byte) ((sum | 0x80) & 0xFF);
}
/**
* Formats a frequency from a Win-Test STATUS packet for use as MYQRG.
*
* <p>Win-Test transmits the frequency in units of 0.1 kHz. KST4Contest
* displays frequencies as {@code MHz.kHz.10Hz}, for example
* {@code 144.300.00} or {@code 10368.100.00}. The MHz part may contain
* between one and five digits. Deriving its length from the complete value
* avoids separate and incomplete handling for individual bands.</p>
*
* @param frequencyIn100Hz frequency received from Win-Test in units of
* 0.1 kHz, equivalent to 100 Hz
* @return frequency formatted for MYQRG
* @throws IllegalArgumentException if the supplied frequency is not positive
* or too small to be formatted
* @throws ArithmeticException if the supplied value exceeds the supported
* numeric range
*/
private String helper_formatWinTestFrequency(long frequencyIn100Hz) {
if (frequencyIn100Hz <= 0) {
throw new IllegalArgumentException(
"Win-Test frequency must be greater than zero"
);
}
/*
* Multiplication by ten creates a digit sequence whose final five
* digits represent kHz and 10-Hz groups:
*
* 1443210 -> 14432100 -> 144.321.00
* 103681000 -> 1036810000 -> 10368.100.00
*/
long frequencyIn10Hz = Math.multiplyExact(
frequencyIn100Hz,
10L
);
String frequencyDigits = Long.toString(frequencyIn10Hz);
if (frequencyDigits.length() < 6) {
throw new IllegalArgumentException(
"Win-Test frequency is too small: " + frequencyIn100Hz
);
}
int mhzEndIndex = frequencyDigits.length() - 5;
int khzEndIndex = frequencyDigits.length() - 2;
return frequencyDigits.substring(0, mhzEndIndex)
+ "."
+ frequencyDigits.substring(mhzEndIndex, khzEndIndex)
+ "."
+ frequencyDigits.substring(khzEndIndex);
}
/**
* Parses a Win-Test STATUS packet and optionally updates MYQRG.
*
* <p>The packet is tokenised while preserving quoted station names.
* The configured station-name filter is applied before any frequency is
* processed. An empty filter accepts STATUS packets from every Win-Test
* station.</p>
*
* <p>The main frequency is read from token 7. If pass-frequency use is
* enabled and token 11 contains a valid frequency, the pass frequency is
* used instead. A missing or invalid pass frequency deliberately falls
* back to the main frequency.</p>
*
* @param msg complete Win-Test STATUS packet
*/
private void parseStatus(String msg) {
try {
ArrayList<String> parts = new ArrayList<>();
Matcher matcher = STATUS_TOKEN_PATTERN.matcher(msg);
while (matcher.find()) {
if (matcher.group(1) != null) {
parts.add(matcher.group(1));
} else {
parts.add(matcher.group(2));
}
}
if (parts.size() < 8) {
System.out.println(
"[WinTest] STATUS too short: " + msg
);
return;
}
String stationName = parts.get(1);
String stationFilter = client
.getChatPreferences()
.getLogsynch_wintestNetworkStationNameOfWintestClient1();
if (stationFilter != null
&& !stationFilter.isBlank()
&& !stationName.equalsIgnoreCase(stationFilter)) {
return;
}
String modeValue = parts.get(5);
long mainFrequencyRaw = Long.parseLong(parts.get(7));
double mainFrequencyKHz = mainFrequencyRaw / 10.0;
String mode;
if ("1".equals(modeValue)) {
mode = mainFrequencyKHz > 10000.0 ? "usb" : "lsb";
} else {
mode = "cw";
}
String formattedMainQrg =
helper_formatWinTestFrequency(mainFrequencyRaw);
/*
* Token 11 may contain the pass frequency, depending on the
* Win-Test STATUS packet. Small numeric flag values must not be
* interpreted as frequencies.
*/
String formattedPassQrg = null;
if (parts.size() > 11) {
try {
long passFrequencyRaw =
Long.parseLong(parts.get(11));
double passFrequencyKHz =
passFrequencyRaw / 10.0;
if (passFrequencyKHz > 100.0) {
formattedPassQrg =
helper_formatWinTestFrequency(
passFrequencyRaw
);
}
} catch (IllegalArgumentException
| ArithmeticException ignored) {
/*
* Token 11 does not contain a usable frequency.
* The main frequency remains the safe fallback.
*/
}
}
boolean usePassQrg = client
.getChatPreferences()
.isLogsynch_wintestUsePassQrg();
final String qrgToSet =
usePassQrg && formattedPassQrg != null
? formattedPassQrg
: formattedMainQrg;
if (client
.getChatPreferences()
.isLogsynch_wintestQrgSyncEnabled()) {
Platform.runLater(
() -> client
.getChatPreferences()
.getMYQRGFirstCat()
.set(qrgToSet)
);
}
System.out.println(
"[WinTest STATUS] stn=" + stationName
+ ", mode=" + mode
+ ", qrg=" + formattedMainQrg
+ (formattedPassQrg != null
? ", passQrg=" + formattedPassQrg
: "")
+ ", selectedQrg=" + qrgToSet
+ ", syncActive="
+ client
.getChatPreferences()
.isLogsynch_wintestQrgSyncEnabled()
);
} catch (Exception exception) {
System.out.println(
"[WinTest] STATUS parsing error: "
+ exception.getMessage()
);
}
}
// private void send_hello() throws IOException {
// String payload = String.format("HELLO:\"%s\" \"%s\" \"%s\" %d %d?\0",
// "DO5AMF", "", stationID, "SLAVE", 1, 14);
// InetAddress broadcast = InetAddress.getByName("255.255.255.255");
// byte[] bytes = payload.getBytes(StandardCharsets.US_ASCII);
// bytes[bytes.length - 2] = util_calculateChecksum((bytes));
// socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871));
// }
/**
* Extracts the unchanged Win-Test band ID from the unquoted ADDQSO fields.
*
* @param message complete ADDQSO packet
* @return raw band ID or an empty value when the field is missing
*/
static String extractBandIdFromWinTestAddQso(String message) {
if (message == null) {
return "";
}
String[] quotedParts = message.split("\"");
if (quotedParts.length <= 6) {
return "";
}
String unquotedFields = quotedParts[6].trim();
if (unquotedFields.isEmpty()) {
return "";
}
String[] packetFields = unquotedFields.split("\\s+");
return packetFields.length > 3 ? packetFields[3] : "";
}
/**
* Builds the log identity of an ADDQSO packet.
*
* <p>Win-Test numbers the QSOs of every log continuously, so a QSO is only
* identified by the combination of the logging station, the unique log ID
* and the QSO number. The log ID is the last field of the packet.</p>
*
* @param packetFields fields of the ADDQSO packet
* @return identity in the form {@code StationName@LogUniqueID}, or
* {@code null} when the packet does not carry both values
*/
static String extractLogIdFromWinTestAddQso(List<String> packetFields) {
if (packetFields == null || packetFields.size() < ADDQSO_FIELD_COUNT) {
return null;
}
String stationName = packetFields.get(ADDQSO_STATION_NAME_INDEX);
String logUniqueId = packetFields.get(packetFields.size() - 1);
if (stationName == null || stationName.isBlank()
|| logUniqueId == null || logUniqueId.isBlank()) {
return null;
}
return stationName.trim() + "@" + logUniqueId.trim();
}
/**
* Extracts the Win-Test QSO number of an ADDQSO packet.
*
* <p>Win-Test sends {@code 0} instead of {@code 1} for the first QSO of a
* log in some situations. wtKST corrects that the same way.</p>
*
* @param packetFields fields of the ADDQSO packet
* @return QSO number, or {@code 0} when the packet carries no usable value
*/
static long extractQsoNumberFromWinTestAddQso(List<String> packetFields) {
if (packetFields == null || packetFields.size() < ADDQSO_FIELD_COUNT) {
return 0L;
}
String rawQsoNumber = packetFields.get(ADDQSO_QSO_NUMBER_INDEX);
if (rawQsoNumber == null) {
return 0L;
}
try {
long qsoNumber = Long.parseLong(rawQsoNumber.trim());
return qsoNumber <= 0L ? 1L : qsoNumber;
} catch (NumberFormatException exception) {
return 0L;
}
}
/**
* Extracts the locator from a Win-Test ADDQSO packet.
*
* <p>Current parser model based on the existing split-by-quotes code:
* <ul>
* <li>{@code split("\"")[7]} = callsign</li>
* <li>{@code split("\"")[11]} = received exchange, e.g. 599001</li>
* <li>{@code split("\"")[13]} = locator, e.g. JO51UM</li>
* </ul>
*
* <p>If the dedicated locator field is empty, the exchange is used as fallback.</p>
*
* @param msg raw ADDQSO message
* @return normalized six-character locator or null
*/
private String helper_resolveLocatorFromWinTestAddQso(String msg) {
if (msg == null) {
return null;
}
String[] quotedParts = msg.split("\"");
if (quotedParts.length > 13) {
String locator = WorkedGrossFieldCache.extractLocator6(quotedParts[13]);
if (locator != null) {
return locator;
}
}
if (quotedParts.length > 11) {
return WorkedGrossFieldCache.extractLocator6(quotedParts[11]);
}
return null;
}
/**
* Catches add-qso messages of wintest if a new qso gets into the log<br/>
*
* String is like this:<br/><br/>
*ADDQSO: "STN1" "" "STN1" 1762202297 1440000 0 12 0 0 0 2 2 "DM2RN" "599" "599001" "JO51UM" "" "" 0 "" "" "" 44510
*
* ^^^^sentby<br/>
* ^^^^^^^^^^time<br/>
* ^^^^^^qrg<br/>
* ^^band<br/>
* ^^^^^callsign logged<br/>
* stn-id ^^^^
* @param msg
*/
private void parseAddQso(String msg) {
try {
List<String> packetFields = WinTestPacket.tokenize(msg);
String logId = extractLogIdFromWinTestAddQso(packetFields);
long qsoNumber = extractQsoNumberFromWinTestAddQso(packetFields);
/*
* The QSO number is registered before any validation. Otherwise the
* log synchronization would request a QSO with unusable content
* over and over again.
*/
boolean isUnknownQso = logSyncService.registerReceivedQso(logId, qsoNumber);
String[] quotedParts = msg == null ? new String[0] : msg.split("\"");
String callSign = quotedParts.length > 7 ? quotedParts[7] : "";
String rawBandId = extractBandIdFromWinTestAddQso(msg);
String locatorFromLogger = helper_resolveLocatorFromWinTestAddQso(msg);
LoggedQsoBand loggedBand = LoggedQsoBand.fromWinTestBandId(rawBandId);
ExternalLoggedQso loggedQso = ExternalLoggedQso.create(
callSign, loggedBand, locatorFromLogger, "WINTEST").orElse(null);
if (loggedQso == null) {
System.out.println("[WinTestUDPRcvr: warning] ADDQSO without usable callsign ignored");
return;
}
if (!isUnknownQso) {
/*
* Win-Test resends known QSOs when a NEEDQSO request overlaps
* with QSOs that already arrived as a broadcast. Worked state
* and database entry exist in that case, so repeating the write
* would only cost time during the initial log recovery.
*/
return;
}
if (loggedBand == null && !rawBandId.isEmpty()) {
System.out.println("[WinTestUDPRcvr: warning] Unknown band ID: " + rawBandId);
}
ChatMember workedCall = loggedQso.toWorkedChatMember();
if (loggedBand != null
&& loggedBand.getProjectBand() != null
&& locatorFromLogger != null) {
this.client.registerWorkedGrossField(
loggedBand.getProjectBand(), locatorFromLogger, workedCall, loggedQso.getSource());
}
client.applyExternalLoggedQso(loggedQso);
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
File logUDPMessageToThisFile = new File(this.client.getChatPreferences()
.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup());
FileWriter fileWriterPersistUDPToFile = null;
BufferedWriter bufwrtrRawMSGOut;
try {
fileWriterPersistUDPToFile = new FileWriter(logUDPMessageToThisFile, true);
} catch (IOException e1) {
e1.printStackTrace();
}
bufwrtrRawMSGOut = new BufferedWriter(fileWriterPersistUDPToFile);
bufwrtrRawMSGOut.write("\n" + workedCall.toString());
bufwrtrRawMSGOut.flush();
bufwrtrRawMSGOut.close();
System.out.println("[WinTest, Info: Marking Chatmember as worked: " + workedCall.toString());
// markChatMemberAsWorked(call, band); //TODO
} catch (Exception e) {
System.out.println("[WinTest] Fehler beim ADDQSO-Parsing: " + e.getMessage());
}
}
}
@@ -1,18 +0,0 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
public class ReadUDPByWintestThreadTest {
public static void main(String[] args) {
ChatController ctrl1 = new ChatController();
ChatPreferences prefs = new ChatPreferences();
ctrl1.setChatPreferences(prefs);
// ReadUDPByWintestThread test = new ReadUDPByWintestThread(ctrl1);
// test.run();
}
}
@@ -1,375 +0,0 @@
package kst4contest.controller;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.util.Comparator;
import java.util.List;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import kst4contest.ApplicationConstants;
import kst4contest.model.AirPlane;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatMember;
import kst4contest.model.ThreadStateMessage;
/**
* This thread is responsible for reading server's input and printing it to the
* console. It runs in an infinite loop until the client disconnects from the
* server.
*
* @author www.codejava.net
*/
public class ReadUDPbyAirScoutMessageThread extends Thread {
private final ChatController client;
private final int localPort;
private final ThreadStatusCallback callBackToController;
private final String threadNickName = "AirScout msg";
private DatagramSocket socket;
public ReadUDPbyAirScoutMessageThread(
int localPort,
ChatController client,
ThreadStatusCallback callback
) {
this.localPort = localPort;
this.client = client;
this.callBackToController = callback;
}
@Override
public void interrupt() {
super.interrupt();
if (socket != null && !socket.isClosed()) {
socket.close();
}
}
/**
* Checks whether an AirScout response is addressed to the currently
* configured server and client identifiers.
*
* Outgoing message:
* ASSETPATH: "client" "server" ...
*
* Corresponding response:
* ASNEAREST: "server" "client" ...
*
* The comparison is deliberately case-sensitive. In a setup with several
* clients, KST-A and kst-a must not silently become the same destination.
*
* @param message received UDP message
* @return true if the response belongs to this KST4Contest instance
*/
private boolean isMessageForConfiguredClient(String message) {
if (message == null || !message.startsWith("ASNEAREST:")) {
return false;
}
String[] quotedParts = message.split("\"");
if (quotedParts.length < 4) {
return false;
}
String receivedServerIdentifier = quotedParts[1].trim();
String receivedClientIdentifier = quotedParts[3].trim();
String configuredServerIdentifier =
client.getChatPreferences()
.getAirScout_asServerNameString();
String configuredClientIdentifier =
client.getChatPreferences()
.getAirScout_asClientNameString();
if (configuredServerIdentifier == null
|| configuredClientIdentifier == null) {
return false;
}
return configuredServerIdentifier.equals(
receivedServerIdentifier
) && configuredClientIdentifier.equals(
receivedClientIdentifier
);
}
@Override
public void run() {
Thread.currentThread().setName(
"ReadUDPByAirScoutThread"
);
try {
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(localPort));
socket.setSoTimeout(3000);
while (!Thread.currentThread().isInterrupted()) {
if (client.isDisconnectionPerformedByUser()) {
break;
}
byte[] buffer = new byte[1777];
DatagramPacket packet = new DatagramPacket(
buffer,
buffer.length
);
try {
socket.receive(packet);
} catch (SocketTimeoutException timeoutException) {
/*
* AirScout may remain silent for some time. Continue with a
* new packet instead of processing the previous packet again.
*/
continue;
}
String received = new String(
packet.getData(),
packet.getOffset(),
packet.getLength()
).trim();
if (received.contains(
ApplicationConstants.DISCONNECT_RDR_POISONPILL
)) {
System.out.println(
"[AirScout UDP, info]: Received shutdown packet."
);
break;
}
/*
* The socket remains bound so that AirScout can be enabled
* without reconnecting. Disabled means that received data is
* discarded and no station state is changed.
*/
if (!client.getChatPreferences()
.isAirScout_asUDPListenerEnabled()) {
continue;
}
if (!isMessageForConfiguredClient(received)) {
continue;
}
processAirScoutResponse(received);
}
} catch (SocketException exception) {
if (!Thread.currentThread().isInterrupted()) {
System.out.println(
"[AirScout UDP, error]: Could not use UDP port "
+ localPort
+ ": "
+ exception.getMessage()
);
}
} catch (IOException exception) {
if (!Thread.currentThread().isInterrupted()) {
System.out.println(
"[AirScout UDP, error]: Communication failed: "
+ exception.getMessage()
);
}
} finally {
if (socket != null && !socket.isClosed()) {
socket.close();
}
socket = null;
}
}
/**
* Parses an AirScout response and applies it to every active category
* instance of the reported station.
*
* @param received received ASNEAREST message
*/
private void processAirScoutResponse(String received) {
try {
AirPlaneReflectionInfo reflectionInfo =
processASUDPMessage(received);
if (reflectionInfo == null
|| reflectionInfo.getReceiver() == null) {
return;
}
String receiverCallSign =
reflectionInfo.getReceiver().getCallSignRaw();
if (receiverCallSign == null
|| receiverCallSign.isBlank()) {
receiverCallSign =
reflectionInfo.getReceiver().getCallSign();
}
if (receiverCallSign == null
|| receiverCallSign.isBlank()) {
return;
}
List<ChatMember> matchingMembers =
client.findActiveChatMembersByRawCall(
receiverCallSign
);
for (ChatMember matchingMember : matchingMembers) {
matchingMember.setAirPlaneReflectInfo(
reflectionInfo
);
}
if (!matchingMembers.isEmpty()) {
client.getScoreService().requestRecompute(
"airscout-update"
);
}
if (callBackToController != null) {
ThreadStateMessage threadStateMessage =
new ThreadStateMessage(
threadNickName,
true,
"Received AirScout response\n"
+ reflectionInfo,
false
);
callBackToController.onThreadStatus(
threadNickName,
threadStateMessage
);
}
} catch (RuntimeException exception) {
System.out.println(
"[AirScout UDP, warning]: Could not process response: "
+ exception.getMessage()
);
}
}
public AirPlaneReflectionInfo processASUDPMessage(String udpStringToProcess) {
// System.out.println("RDUDPAS RECV: " + udpStringToProcess);
/*
* Example mesage: ASNEAREST: "AS" "KST"
* "2023-04-01 21:33:42Z,DO5AMF,JN49GL,G4CLA,IO92JL,9,VLG2PT,M,190,75,14,BAW809,M,250,50,18,BEL6CM,M,143,50,12,WZZ6719,M,148,50,11,KLM1678,M,313,75,22,TRA1B,M,271,75,20,SAS4728,M,125,75,9,RYR6TL,M,90,75,6,UAE10,S,96,50,6"
* Syntax: ASNEAREST: "AS" "KST" "2023-03-09
* 23:21:50Z,DO5AMF,JN49GL,DM5M,JO51IJ,3, SWT8TB,M,121,75,16, ^kleines Ding
* ^^^Distanz km ^^Potenzial 0-100% ^^Dauer bis ankunft minutes
*/
String[] fullStringSplitter;
String[] apStringSplitter;
AirPlaneReflectionInfo apInfo = new AirPlaneReflectionInfo();
ObservableList<AirPlane> airplaneList = FXCollections.observableArrayList();
if (udpStringToProcess.contains("ASNEAREST: ")) {
udpStringToProcess = udpStringToProcess.replace("ASNEAREST: ", "");
udpStringToProcess = udpStringToProcess.replace(" ", "");
fullStringSplitter = udpStringToProcess.split("\"");
// for (int i = 0; i < fullStringSplitter.length; i++) {
// System.out.println(i + " " + fullStringSplitter[i]);
// }
String APInfoString = fullStringSplitter[5];
apStringSplitter = APInfoString.split(",");
String[] allAPInfos = new String[apStringSplitter.length - 6]; // new String shold only provide aps, nothing
// other
for (int i = 0; i < apStringSplitter.length; i++) {
if (i >= 6) {
allAPInfos[i - 6] = apStringSplitter[i];
}
// System.out.println(i + ": " + apStringSplitter[i]);
// if (i>=6) {
// allAPInfos[i-6] = apStringSplitter[i];
// System.out.println(i-5 + " > " + apStringSplitter[i]);
//
//
//
// }
}
AirPlane airPlane = new AirPlane();
for (int i = 0; i < allAPInfos.length; i++) {
if (((i) % 5) == 0) {
airPlane = new AirPlane();
// airPlane = new AirPlane();
airPlane.setApCallSign(allAPInfos[i]);
// System.out.println(i + " AP: " + allAPInfos[i]);
} else if (((i) % 5) == 1) {
airPlane.setApSizeCategory(allAPInfos[i]);
// System.out.println(i + " cat: " + allAPInfos[i]);
} else if (((i) % 5) == 2) {
airPlane.setDistanceKm(Integer.parseInt(allAPInfos[i]));
// System.out.println(i + " dist: " + allAPInfos[i]);
} else if (((i) % 5) == 3) {
airPlane.setPotential(Integer.parseInt(allAPInfos[i]));
// System.out.println(i + " potential: " + allAPInfos[i]);
}
if (((i) % 5) == 4) {
// System.out.println(i + " duration: " + allAPInfos[i]);
airPlane.setArrivingDurationMinutes(Integer.parseInt(allAPInfos[i]));
airplaneList.add(airPlane);
}
}
apInfo.setDate(apStringSplitter[0]);
ChatMember apStartCallSign = new ChatMember();
apStartCallSign.setCallSign(apStringSplitter[1]);
apStartCallSign.setQra(apStringSplitter[2]);
apInfo.setSender(apStartCallSign); // usally its the callsign of own chatmember object, may check this
ChatMember apDestCallSign = new ChatMember();
apDestCallSign.setCallSign(apStringSplitter[3]);
apDestCallSign.setQra(apStringSplitter[4]);
apInfo.setReceiver(apDestCallSign);
apInfo.setAirPlanesReachableCntr(Integer.parseInt(apStringSplitter[5]));
apInfo.setRisingAirplanes(airplaneList);
// System.out.println("total airplanes for rx stn " + apInfo.getReceiver().getCallSign() + ": " + airplaneList.size() + " " + apInfo.toString());
airplaneList.sort(Comparator.comparing(AirPlane::getPotential).reversed()
.thenComparing(AirPlane::getArrivingDurationMinutes));
}
return apInfo;
}
public boolean terminateConnection() {
if (socket != null && !socket.isClosed()) {
socket.close();
}
return true;
}
}
@@ -1,594 +0,0 @@
package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.sql.SQLException;
import java.util.Arrays;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import kst4contest.ApplicationConstants;
import kst4contest.model.ThreadStateMessage;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import kst4contest.model.ChatMember;
import kst4contest.model.Band;
import javafx.application.Platform;
/**
* This thread is responsible for reading server's input and printing it to the
* console. It runs in an infinite loop until the client disconnects from the
* server.
*
* @author www.codejava.net
*/
public class ReadUDPbyUCXMessageThread extends Thread {
private BufferedReader reader;
private Socket socket;
private ChatController client;
private int udpPortNr = 12060;
private ThreadStatusCallback callBackToController;
private String ThreadNickName = "UDP-Log msg";
// public ReadUDPbyUCXMessageThread(int localPort , ThreadStatusCallback callback) {
//
//// this.callBackToController = callback;
// }
public ReadUDPbyUCXMessageThread(int localPort, ChatController client, ThreadStatusCallback callback) {
this.udpPortNr = localPort;
this.client = client;
this.callBackToController = callback;
}
@Override
public void interrupt() {
super.interrupt();
try {
if (this.socket != null) {
System.out.println(">>>>>>>>>>>>>>ReadUdpbyUCS: closing socket");
terminateConnection();
// callBackToController.onThreadStatus("UDPReceiver", new String[]);
}
} catch (Exception e) {
// TODO Auto-generated catch block
System.out.println("UCXUDPRDR: catched error " + e.getMessage());
}
}
/**
* Strips binary logger framing bytes before the XML payload. Some UCXLog packets
* contain transport bytes before the XML declaration.
*
* @param rawPacket raw UDP payload
* @return cleaned XML string or trimmed original text
*/
private String helper_extractXmlPayload(String rawPacket) {
if (rawPacket == null) {
return "";
}
int xmlStart = rawPacket.indexOf("<?xml");
if (xmlStart < 0) {
xmlStart = rawPacket.indexOf("<contactinfo");
}
if (xmlStart < 0) {
xmlStart = rawPacket.indexOf("<contactreplace");
}
if (xmlStart < 0) {
xmlStart = rawPacket.indexOf("<RadioInfo");
}
return xmlStart >= 0
? rawPacket.substring(xmlStart).trim()
: rawPacket.trim();
}
/**
* Reads an optional XML child node.
*
* @param element parent element
* @param tagName tag to read
* @return trimmed value or empty string
*/
private String helper_getOptionalElementText(Element element, String tagName) {
if (element == null || tagName == null) {
return "";
}
NodeList nodeList = element.getElementsByTagName(tagName);
if (nodeList == null || nodeList.getLength() == 0 || nodeList.item(0) == null) {
return "";
}
String textContent = nodeList.item(0).getTextContent();
return textContent == null ? "" : textContent.trim();
}
/**
* Resolves the QSO locator from UCXLog contactinfo. gridsquare is preferred,
* rcvnr is used as fallback for exchanges such as 001JO41HK.
*
* @param element contactinfo XML element
* @return normalized six-character locator or null
*/
private String helper_resolveLocatorFromContactInfo(Element element) {
String gridSquare = WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "gridsquare"));
if (gridSquare != null) {
return gridSquare;
}
return WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "rcvnr"));
}
public void run() {
System.out.println("ReadUDPByUCXLogThread: started Thread for UCXLog getUDP");
Thread.currentThread().setName("ReadUDPByUCXLogThread");
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
DatagramSocket socket = null;
boolean running;
byte[] buf = new byte[1777];
DatagramPacket packet = new DatagramPacket(buf, buf.length);
try {
// socket = new DatagramSocket(12060);
socket = new DatagramSocket(udpPortNr);
socket.setSoTimeout(2000); //TODO try for end properly
}
catch (SocketException e) {
//this will catch the repeating Sockettimeoutexception...nothing to do
// e.printStackTrace();
}
while (true) {
boolean timeOutIndicator = false;
// packet = new DatagramPacket(buf, buf.length); //TODO: Changed that due to memory leak, check if all works (seems like that)
// DatagramPacket packet = new DatagramPacket(SRPDefinitions.BYTE_BUFFER_MAX_LENGTH); //TODO: Changed that due to memory leak, check if all works (seems like that)
try {
socket.receive(packet);
} catch (SocketTimeoutException e2) {
timeOutIndicator = true;
// this will catch the repeating Sockettimeoutexception...nothing to do
// e2.printStackTrace();
}
catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (NullPointerException nE) {
// TODO Auto-generated catch block
nE.printStackTrace();
System.out.println("ReadUdpByUCXTH: Socket not ready");
try {
socket = new DatagramSocket(client.getChatPreferences().getLogsynch_ucxUDPWkdCallListenerPort());
socket.setSoTimeout(2000);
} catch (SocketException e) {
System.out.println("[ReadUDPByUCSMsgTH, Error]: socket in use or something:");
e.printStackTrace();
try {
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(client.getChatPreferences().getLogsynch_ucxUDPWkdCallListenerPort()));
socket.receive(packet);
socket.setSoTimeout(3000);
} catch (Exception ex) {
System.out.println("ReadUDPByUCXMsgTh: Could not solve that. Program Restart needed.");
throw new RuntimeException(ex);
}
}
}
InetAddress address = packet.getAddress();
int port = packet.getPort();
// packet = new DatagramPacket(buf, buf.length, address, port);
String received = new String(packet.getData(), packet.getOffset(), packet.getLength());
received = received.trim();
// System.out.println("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<recv " + received);
if (received.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
System.out.println("ReadUdpByUCX, Info: got poison, now dieing....");
socket.close();
timeOutIndicator = true;
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "stopped";
// threadStatusMessage[1] = "by poisonpill message (disconnect on purpose)";
threadStateMessage = new ThreadStateMessage(this.ThreadNickName, false, "stopped by Poisonpill", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
break;
}
if (this.client.isDisconnectionPerformedByUser()) {
break;//TODO: what if it´s not the finally closage but a band channel change?
}
if (!timeOutIndicator) {
processUCXUDPMessage(received);
} else {
//dont process the empty message
}
buf = new byte[1777]; // reset buffer for future smaller packets
}
}
public String processUCXUDPMessage(String udpPacketToProcess) {
File logUDPMessageToThisFile;
String udpMsg = helper_extractXmlPayload(udpPacketToProcess);
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received Message\n" + udpMsg, false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
try {
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
} catch (ParserConfigurationException e1) {
e1.printStackTrace();
}
try {
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new InputSource(new StringReader(udpMsg)));
/**
* case Log-QSO-Packet in ucxlog / DXLog and compatible
*
*/
NodeList list = doc.getElementsByTagName("contactinfo");
if (list.getLength() == 0) {
list = doc.getElementsByTagName("contactreplace");
//DXlog will send contactreplace instead of contactinfo on clicking "broadcast whole logbook"
}
if (list.getLength() != 0) {
/*
* QSO and TRX information share the same UDP receiver. The log-sync
* preference therefore controls processing, not ownership of the socket.
*/
if (!client.getChatPreferences().isLogsynch_ucxUDPWkdCallListenerEnabled()) {
return "";
}
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
String call = helper_getOptionalElementText(element, "call");
String rawBand = helper_getOptionalElementText(element, "band");
String gridSquare = helper_resolveLocatorFromContactInfo(element);
String points = helper_getOptionalElementText(element, "points");
LoggedQsoBand loggedBand = LoggedQsoBand.fromLoggerValue(rawBand);
ExternalLoggedQso loggedQso = ExternalLoggedQso.create(
call, loggedBand, gridSquare, "UCXLOG").orElse(null);
if (loggedQso == null) {
System.out.println("[ReadUDPFromUCX, warning]: QSO packet without usable callsign ignored");
continue;
}
System.out.println("[Readudp, info ]: received Current Element :" + node.getNodeName()
+ "call: " + call + " / " + rawBand + " ----> " + points + " POINTS");
// client.getChatPreferences().setBcn_contestScoreSum(Long.parseLong(points));
ChatMember workedCall = loggedQso.toWorkedChatMember();
Band workedBand = loggedBand == null ? null : loggedBand.getProjectBand();
if (loggedBand == null && !rawBand.isEmpty()) {
System.out.println("[ReadUDPFromUCX, warning]: unexpected band value: \"" + rawBand + "\"");
}
{
/**
* That means, the station is worked already but maybe at another band. So we
* have to get the worked ChatMember out of the list and to modify the worked
* options.
*/
// modifyThat = (ChatMember) client.getMap_ucxLogInfoWorkedCalls().get(call);
// asd //TODO: Check if callsign and callsignraw is similar, then mark first and further via new checklistforchatmembermultiplemethod with array of indize
client.applyExternalLoggedQso(loggedQso);
/**
* old mechanic to markup worked stations in the chatmember table
*/
// int indexOfChatMemberInTable = -1; //chatmember not in table
// indexOfChatMemberInTable = client.checkListForChatMemberIndexByCallSign(workedCall);
//
// if (indexOfChatMemberInTable == -1) {
// // do nothing
// } else {
// modifyThat = client.getLst_chatMemberList().get(indexOfChatMemberInTable);
//
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)).setWorked(true);
//
// if (workedCall.isWorked144()) {
// modifyThat.setWorked144(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked144(true);
//
// } else if (workedCall.isWorked432()) {
// modifyThat.setWorked432(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked432(true);
//
// } else if (workedCall.isWorked1240()) {
// modifyThat.setWorked1240(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked1240(true);
//
// } else if (workedCall.isWorked2300()) {
// modifyThat.setWorked2300(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked2300(true);
//
// } else if (workedCall.isWorked3400()) {
// modifyThat.setWorked3400(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked3400(true);
//
// } else if (workedCall.isWorked5600()) {
// modifyThat.setWorked5600(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked5600(true);
//
// } else if (workedCall.isWorked10G()) {
// modifyThat.setWorked10G(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked10G(true);
// }
/**
* //TODO: following line is a quick fix to making disappear worked chatmembers of the list
* Thats uncomfortable due to this also causes selection changes,
* Better way is to change all worked and qrv values to observables and then trigger the underlying
* list to fire an invalidationevent. Really Todo!
*/
// try{
//
// GuiUtils.triggerGUIFilteredChatMemberListChange(client); //not clean at all
// } catch (Exception IllegalStateException) {
// //do nothing, as it works...
// }
// }
/**
* end -> old mechanic to markup worked stations in the chatmember table
*/
}
if (workedBand != null && gridSquare != null) {
this.client.registerWorkedGrossField(
workedBand, gridSquare, workedCall, loggedQso.getSource());
}
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
logUDPMessageToThisFile = new File(this.client.getChatPreferences()
.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup());
FileWriter fileWriterPersistUDPToFile = null;
BufferedWriter bufwrtrRawMSGOut;
try {
fileWriterPersistUDPToFile = new FileWriter(logUDPMessageToThisFile, true);
} catch (IOException e1) {
e1.printStackTrace();
}
bufwrtrRawMSGOut = new BufferedWriter(fileWriterPersistUDPToFile);
bufwrtrRawMSGOut.write("\n" + workedCall.toString());
bufwrtrRawMSGOut.flush();
bufwrtrRawMSGOut.close();
}
}
} else {
list = doc.getElementsByTagName("RadioInfo");
/*
* RadioInfo packets may arrive on the shared UDP port even when automatic
* QRG synchronization is disabled. Ignore them unless TRX sync is enabled.
*/
if (list.getLength() != 0
&& !client.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled()) {
return "";
}
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
if (node.getNodeType() == Node.ELEMENT_NODE) {
String formattedQRG;
Element element = (Element) node;
String qrg = element.getElementsByTagName("Freq").item(0).getTextContent();
String mode = element.getElementsByTagName("Mode").item(0).getTextContent();
// System.out.println("QRG Length: " + qrg.length() + " // " + qrg);
/**
* The following if statement is only for formatting the frequency input for
* good readability to avoid values like 129601000 and set it to something
* readable like 1296.010.00
*
*/
if (qrg.length() == 6) {
// 701000 KHz
formattedQRG = qrg.format("%s.%s.%s", qrg.substring(0, 1), qrg.substring(2, 5),
qrg.substring(5, 6));
} else if (qrg.length() == 7) {
// 700000 KHz
formattedQRG = qrg.format("%s.%s.%s", qrg.substring(0, 2), qrg.substring(2, 5),
qrg.substring(5, 7));
} else if (qrg.length() == 8) {
// 144.123.22 KHz
formattedQRG = qrg.format("%s.%s.%s", qrg.substring(0, 3), qrg.substring(3, 6),
qrg.substring(6, 8));
} else if (qrg.length() == 9) {
// 1296.010.00
formattedQRG = qrg.format("%s.%s.%s", qrg.substring(0, 4), qrg.substring(4, 7),
qrg.substring(7, 9));
} else if (qrg.length() == 10) {
// 10000.010.00
formattedQRG = qrg.format("%s.%s.%s", qrg.substring(0, 5), qrg.substring(5, 8),
qrg.substring(8, 10));
}
else {
formattedQRG = qrg;
}
// System.out.println("Current Element :" + node.getNodeName());
// System.out.println("Radio QRG : " + qrg);
// System.out.println("Radio Mode: " + mode);
// System.out.println("[ReadUDPFromUCX, Info:] Setted QRG pref to: \"" + qrg + "\"" );
// this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
final String finalFormattedQRG = formattedQRG;
helper_runOnFxThread(() ->
this.client.getChatPreferences().getMYQRGFirstCat().set(finalFormattedQRG)
);
// System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG);
}
}
}
} catch (ParserConfigurationException | SAXException | IOException e) {
e.printStackTrace();
System.out.println(e.getCause());
System.out.println(e.getMessage());
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "STOPPED";
// threadStatusMessage[1] = Arrays.toString(e.getStackTrace());
threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true);
threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace()));
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
} catch (SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true);
threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace()));
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "STOPPED";
// threadStatusMessage[1] = Arrays.toString(e.getStackTrace());
// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage);
}
// System.out.println("[ReadUDPbyUCXTh: ] worked size = " + this.client.getMap_ucxLogInfoWorkedCalls().size());
// System.out.println("[ReadUDPbyUCXTh: ] worked size = removeThisActions" );
return "";
}
public boolean terminateConnection() throws IOException {
// String[] threadStatusMessage = new String[2];
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "STOPPED";
// threadStatusMessage[1] = "Connection terminated for purpose.";
// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage);
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, false, "terminated", false);
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
this.socket.close();
return true;
}
/**
* Runs UI-bound changes on the JavaFX application thread.
*
* <p>UCXLog UDP packets are processed in a background thread. Some preference
* properties are bound to JavaFX controls, so setting them directly from this
* thread can crash JavaFX with "Not on FX application thread".</p>
*
* @param runnable UI-bound update
*/
private void helper_runOnFxThread(Runnable runnable) {
if (runnable == null) {
return;
}
if (Platform.isFxApplicationThread()) {
runnable.run();
} else {
Platform.runLater(runnable);
}
}
}
@@ -1,86 +0,0 @@
package kst4contest.controller;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import kst4contest.model.ChatMember;
public class RegexTester {
public static void main(String[] args) {
String temp;
temp = "DL1KDA JO30EP Alex\r\n"
+ "DL3LST JO61FI Ray(Rainer)\r\n"
+ "DL4ZAQ JN49MP Peter 2m&70cm&6m\r\n"
+ "DL9LBH JN59ID Hans 2/70/23\r\n"
+ "DO5AMF JN49FK Marc\r\n"
+ "F5ICN JN03BF Alex QRV 2/70/23\r\n"
+ "F6GRA JN04DB Carol 2m\r\n"
+ "F6HTJ JN12KQ Michel\r\n"
+ "G0BIX JO01GI Terry\r\n"
+ "G0HOF IO92UL Ken 11el + 400 W\r\n"
+ "G0LBK JO03BD David\r\n"
+ "G0MBL JO01QH Andrew 2m\r\n"
+ "G1SDX IO80FL Grant\r\n"
+ "G3MXH JO02LF Terry\r\n"
+ "G3OVH IO92JP Tony\r\n"
+ "G3VCA IO93MG bob\r\n"
+ "G4AEP IO91MB bill\r\n"
+ "G4DHF IO92UU David\r\n"
+ "G4FUF JO01GN Keith\r\n"
+ "G4TRA IO81WN Steve\r\n"
+ "G6HKS IO92OB Richard\r\n"
+ "G8SEI IO92FO Jeff 6/4/2/70\r\n"
+ "GM0EWX IO67UL Calum\r\n"
+ "GW8IZR IO73TI Paul\r\n"
+ "IV3GTH JN65RU Gigi\r\n"
+ "IZ2XZM JN45KH Nick\r\n"
+ "LY1BWB KO24PR VU Club 2/70\r\n"
+ "LY2EN KO24PQ QRT\r\n"
+ "OK1FPR JO80CE Milos\r\n"
+ "OV3T JO46CM Thomas\r\n"
+ "OY4TN IP62NB Trygvi 11EL/350W\r\n"
+ "OZ2M JO65FR Bo PNGonVHF\r\n"
+ "OZ3Z JO45UN Anders @432.228\r\n"
+ "OZ7UV JO65DH Svend\r\n"
+ "PA3DOL JO22MT Sjoerd\r\n"
+ "PA3PCV JO20VV Marcel 2m\r\n"
+ "PA9R JO22JK Rob\r\n"
+ "S52FO JN76EF Janez\r\n"
+ "S53RM JN76HD Sine\r\n"
+ "SM2CEW KP15CR Peter\r\n"
+ "SP6VGJ JO81HU Jacek";
Pattern pattern = Pattern.compile("([a-zA-Z0-9]{2}/{1})?([a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z]{0,3})(/p)? [a-zA-Z]{2}[0-9]{2}[a-zA-Z]{2} [ -~]{1,20}");
Matcher matcher = pattern.matcher(temp);
/**
* "([a-zA-Z0-9]{1,2}\/)?[a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z](\/(p|m))?( )[a-zA-Z]{2}[0-9]{2}[a-zA-Z]{2}[ -~]{0,30}"gm
* Thats a line of the show users list
*/
while (matcher.find()) {
// System.out.println("Chatmember detected: "+ matcher.group() + " " + matcher.start());
ChatMember member = new ChatMember();
String matchedString = matcher.group();
String[] splittedUserString;
splittedUserString = matchedString.split(" ");
member.setCallSign(splittedUserString[0]);
member.setQra(splittedUserString[1]);
String stringAggregation = "";
for (int i = 2; i < splittedUserString.length; i++) {
stringAggregation += splittedUserString[i] + " ";
}
member.setName(stringAggregation);
System.out.println("Call: " + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName());
}
}
}
@@ -1,359 +0,0 @@
package kst4contest.controller;
import javafx.application.Platform;
import javafx.beans.property.LongProperty;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.SimpleLongProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import kst4contest.logic.PriorityCalculator;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ContestSked;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
/**
* Calculates priority scores off the JavaFX thread and publishes a small UI model.
*
* Design goals:
* - No per-member Platform.runLater flooding.
* - Score is computed once per callsignRaw (e.g. "SM6VTZ"), even if it exists in multiple chat categories.
* - A routing hint (preferred ChatCategory) is kept using "last inbound category" if available.
*/
public final class ScoreService {
public static final int DEFAULT_TOP_N = 15; //how many top places we have?
/** Force a refresh at least every X ms (some scoring inputs are time dependent). */
private static final long MAX_SNAPSHOT_AGE_MS = 10_000L;
private final ChatController controller;
private final PriorityCalculator priorityCalculator;
private final AtomicBoolean recomputeRequested = new AtomicBoolean(true);
private final AtomicReference<ScoreSnapshot> latestSnapshot = new AtomicReference<>(ScoreSnapshot.empty());
// UI outputs
private final ObservableList<TopCandidate> topCandidatesFx = FXCollections.observableArrayList();
private final ReadOnlyDoubleWrapper selectedCallPriorityScore = new ReadOnlyDoubleWrapper(Double.NaN);
private final LongProperty uiPulse = new SimpleLongProperty(0);
private volatile String selectedCallSignRaw;
private volatile long lastComputedEpochMs = 0L;
private final int topN;
private final ObjectProperty<ChatMember> selectedChatMember = new SimpleObjectProperty<>(null);
public ScoreService(ChatController controller, PriorityCalculator priorityCalculator, int topN) {
this.controller = Objects.requireNonNull(controller, "controller");
this.priorityCalculator = Objects.requireNonNull(priorityCalculator, "priorityCalculator");
this.topN = topN > 0 ? topN : DEFAULT_TOP_N;
}
public ObservableList<TopCandidate> getTopCandidatesFx() {
return topCandidatesFx;
}
public ReadOnlyDoubleProperty selectedCallPriorityScoreProperty() {
return selectedCallPriorityScore.getReadOnlyProperty();
}
/**
* A lightweight UI invalidation signal that increments after every published snapshot.
* Consumers can refresh small panels (timeline/toplist), but should avoid refreshing huge tables.
*/
public LongProperty uiPulseProperty() {
return uiPulse;
}
public ScoreSnapshot getLatestSnapshot() {
return latestSnapshot.get();
}
/** Coalesced recompute request (safe to call frequently from other threads). */
public void requestRecompute(String reason) {
recomputeRequested.set(true);
}
/** Called by UI when selection changes. */
public void setSelectedChatMember(ChatMember member) {
// keep a central selection for UI actions (FurtherInfo buttons, timeline clicks, etc.)
if (Platform.isFxApplicationThread()) {
selectedChatMember.set(member);
} else {
Platform.runLater(() -> selectedChatMember.set(member));
}
selectedCallSignRaw = member == null ? null : normalizeCallRaw(member.getCallSignRaw());
// Update score immediately from the latest snapshot
if (Platform.isFxApplicationThread()) {
updateSelectedScoreFromSnapshot(latestSnapshot.get());
} else {
Platform.runLater(() -> updateSelectedScoreFromSnapshot(latestSnapshot.get()));
}
}
/**
* Called periodically by the scheduler thread.
* Recomputes only if explicitly requested or if the snapshot is too old.
*/
public void tick() {
long now = System.currentTimeMillis();
boolean shouldRecompute = recomputeRequested.getAndSet(false) || (now - lastComputedEpochMs) > MAX_SNAPSHOT_AGE_MS;
if (!shouldRecompute) return;
try {
// Apply "no reply" strikes (operator pinged via /cq but no inbound line arrived)
controller.getStationMetricsService().evaluateNoReplyTimeouts(now, controller.getChatPreferences());
recompute(now);
} catch (Exception e) {
System.err.println("[ScoreService] CRITICAL error while recomputing scores");
e.printStackTrace();
}
}
private void recompute(long nowEpochMs) {
// Keep sked list clean (must happen on FX thread)
controller.requestRemoveExpiredSkeds(nowEpochMs);
final List<ChatMember> members = controller.snapshotChatMembers();
final List<ContestSked> activeSkeds = controller.snapshotActiveSkeds();
final ChatPreferences prefs = controller.getChatPreferences();
final Map<String, ChatCategory> lastInbound = controller.snapshotLastInboundCategoryMap();
StationMetricsService.Snapshot metricsSnapshot =
controller.getStationMetricsService().snapshot(nowEpochMs, prefs);
// 1) Choose one representative per callsignRaw
Map<String, List<ChatMember>> variantsByCallRaw = groupMembersByCallRaw(members);
Map<String, ChatMember> representativeByCallRaw =
chooseRepresentativeMembers(variantsByCallRaw, lastInbound);
// 2) Compute score once per callsignRaw
Map<String, Double> scoreByCallRaw = new HashMap<>(representativeByCallRaw.size());
Map<String, ChatCategory> preferredCategoryByCallRaw = new HashMap<>(representativeByCallRaw.size());
List<TopCandidate> topAll = new ArrayList<>(representativeByCallRaw.size());
for (Map.Entry<String, ChatMember> e : representativeByCallRaw.entrySet()) {
String callRaw = e.getKey();
ChatMember representative = e.getValue();
if (representative == null) continue;
double score = priorityCalculator.calculatePriority(
representative,
variantsByCallRaw.getOrDefault(callRaw, List.of(representative)),
prefs,
activeSkeds,
metricsSnapshot,
nowEpochMs
);
scoreByCallRaw.put(callRaw, score);
preferredCategoryByCallRaw.put(callRaw, representative.getChatCategory());
if (Double.isFinite(score) && score > 0.0) {
topAll.add(new TopCandidate(
callRaw,
representative.getCallSign(),
representative.getChatCategory(),
score
));
}
}
// 3) Build Top-N
topAll.sort(Comparator.comparingDouble(TopCandidate::getScore).reversed());
List<TopCandidate> topNList = topAll.size() <= topN ? topAll : new ArrayList<>(topAll.subList(0, topN));
ScoreSnapshot snap = new ScoreSnapshot(
nowEpochMs,
Collections.unmodifiableMap(scoreByCallRaw),
Collections.unmodifiableMap(preferredCategoryByCallRaw),
Collections.unmodifiableList(topNList)
);
latestSnapshot.set(snap);
lastComputedEpochMs = nowEpochMs;
// 4) Publish to UI in ONE batched runLater
Platform.runLater(() -> {
applyScoreSnapshotToChatMembers(snap);
topCandidatesFx.setAll(snap.getTopCandidates());
updateSelectedScoreFromSnapshot(snap);
uiPulse.set(uiPulse.get() + 1);
});
}
private Map<String, List<ChatMember>> groupMembersByCallRaw(List<ChatMember> members) {
Map<String, List<ChatMember>> byCallRaw = new HashMap<>();
for (ChatMember member : members) {
if (member == null) continue;
String callRaw = normalizeCallRaw(member.getCallSignRaw());
if (callRaw == null || callRaw.isEmpty()) continue;
byCallRaw.computeIfAbsent(callRaw, ignored -> new ArrayList<>()).add(member);
}
return byCallRaw;
}
/**
* Picks one ChatMember object per callsignRaw.
* Preference order:
* 1) Variant in last inbound chat category (stable reply routing)
* 2) Most recently active variant (fallback)
*/
private Map<String, ChatMember> chooseRepresentativeMembers(
Map<String, List<ChatMember>> byCallRaw,
Map<String, ChatCategory> lastInboundCategoryByCallRaw
) {
Map<String, ChatMember> representative = new HashMap<>(byCallRaw.size());
for (Map.Entry<String, List<ChatMember>> entry : byCallRaw.entrySet()) {
String callRaw = entry.getKey();
List<ChatMember> variants = entry.getValue();
ChatCategory preferredCat = lastInboundCategoryByCallRaw.get(callRaw);
ChatMember chosen = null;
if (preferredCat != null) {
chosen = variants.stream()
.filter(Objects::nonNull)
.filter(v -> isSameChatCategory(v.getChatCategory(), preferredCat))
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
.orElse(null);
}
if (chosen == null) {
chosen = variants.stream()
.filter(Objects::nonNull)
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
.orElse(null);
}
if (chosen != null) representative.put(callRaw, chosen);
}
return representative;
}
private static boolean isSameChatCategory(ChatCategory left, ChatCategory right) {
return left != null
&& right != null
&& left.getCategoryNumber() == right.getCategoryNumber();
}
/**
* Projects the immutable score snapshot back into ChatMember display fields so
* the normal station table can sort/filter by score without knowing the score
* calculation internals.
*
* @param snap latest score snapshot
*/
private void applyScoreSnapshotToChatMembers(ScoreSnapshot snap) {
if (snap == null) {
return;
}
Map<String, Double> scoreByCallSignRaw = snap.getScoreByCallSignRaw();
for (ChatMember member : controller.snapshotChatMembers()) {
if (member == null || member.getCallSignRaw() == null) {
continue;
}
Double score = scoreByCallSignRaw.get(normalizeCallRaw(member.getCallSignRaw()));
member.setCurrentPriorityScore(score == null ? 0.0 : score);
}
controller.fireUserListUpdate();
}
private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) {
if (snap == null || selectedCallSignRaw == null) {
selectedCallPriorityScore.set(Double.NaN);
return;
}
Double v = snap.getScoreByCallSignRaw().get(selectedCallSignRaw);
selectedCallPriorityScore.set(v == null ? Double.NaN : v);
}
private static String normalizeCallRaw(String callRaw) {
if (callRaw == null) return null;
return callRaw.trim().toUpperCase();
}
// ------------------------- DTOs -------------------------
public static final class TopCandidate {
private final String callSignRaw;
private final String displayCallSign;
private final ChatCategory preferredChatCategory;
private final double score;
public TopCandidate(String callSignRaw, String displayCallSign, ChatCategory preferredChatCategory, double score) {
this.callSignRaw = callSignRaw;
this.displayCallSign = displayCallSign;
this.preferredChatCategory = preferredChatCategory;
this.score = score;
}
public String getCallSignRaw() { return callSignRaw; }
public String getDisplayCallSign() { return displayCallSign; }
public ChatCategory getPreferredChatCategory() { return preferredChatCategory; }
public double getScore() { return score; }
}
public static final class ScoreSnapshot {
private final long computedAtEpochMs;
private final Map<String, Double> scoreByCallSignRaw;
private final Map<String, ChatCategory> preferredCategoryByCallSignRaw;
private final List<TopCandidate> topCandidates;
public ScoreSnapshot(long computedAtEpochMs,
Map<String, Double> scoreByCallSignRaw,
Map<String, ChatCategory> preferredCategoryByCallSignRaw,
List<TopCandidate> topCandidates) {
this.computedAtEpochMs = computedAtEpochMs;
this.scoreByCallSignRaw = scoreByCallSignRaw;
this.preferredCategoryByCallSignRaw = preferredCategoryByCallSignRaw;
this.topCandidates = topCandidates;
}
public static ScoreSnapshot empty() {
return new ScoreSnapshot(System.currentTimeMillis(), Collections.emptyMap(), Collections.emptyMap(), Collections.emptyList());
}
public long getComputedAtEpochMs() { return computedAtEpochMs; }
public Map<String, Double> getScoreByCallSignRaw() { return scoreByCallSignRaw; }
public Map<String, ChatCategory> getPreferredCategoryByCallSignRaw() { return preferredCategoryByCallSignRaw; }
public List<TopCandidate> getTopCandidates() { return topCandidates; }
}
public ReadOnlyObjectProperty<ChatMember> selectedChatMemberProperty() {
return selectedChatMember;
}
public ChatMember getSelectedChatMember() {
return selectedChatMember.get();
}
}
@@ -1,63 +0,0 @@
package kst4contest.controller;
import kst4contest.model.ChatMessage;
import java.util.TimerTask;
/**
* This class is updateing the scoreboard at https://slovhf.net/claimed/. Gets scores of all bands out of the
* preferences which is updated via ReadUdpByUCXLog Thread.
*
* api description: https://slovhf.net/claimed-score-api/
*
* <br/><br/>
* The task will be runned out of the singleton ChatController instance in an
* intervall as specified by the Chatpreferences-instance (typically as
* configured in the xml file.
*
*
* @author prakt
*
*/
public class ScoreboardUpdateTask extends TimerTask {
private ChatController chatController;
public ScoreboardUpdateTask(ChatController client) {
this.chatController = client;
}
@Override
public void run() {
Thread.currentThread().setName("BeaconTask");
ChatMessage beaconMSG = new ChatMessage();
MessageVariableResolver variableResolver =
new MessageVariableResolver(this.chatController.getChatPreferences());
String replaceVariables = variableResolver.resolveGlobalVariables(
this.chatController.getChatPreferences().getBcn_beaconTextMainCat()
);
beaconMSG.setMessageText(
"MSG|" + this.chatController.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber() + "|0|" + replaceVariables + "|0|");
beaconMSG.setMessageDirectedToServer(true);
// System.out.println("########### " + replaceVariables);
if (this.chatController.getChatPreferences().isBcn_beaconsEnabledMainCat() ) {
System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
+ " [BeaconTask, Info]: Sending CQ: " + beaconMSG.getMessageText());
this.chatController.getMessageTXBus().add(beaconMSG);
} else {
//do nothing, CQ is disabled
}
}
}
@@ -1,124 +0,0 @@
package kst4contest.controller;
import javafx.application.Platform;
import kst4contest.model.ChatCategory;
import kst4contest.model.ThreadStateMessage;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
/**
* Schedules PM reminders for a specific sked time.
*
* Requirements:
* - Reminder goes out as PM to the station (via "/cq CALL ...").
* - Reminders are armed manually from FurtherInfo.
*/
public final class SkedReminderService {
private final ChatController controller;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r);
t.setDaemon(true);
t.setName("SkedReminderService");
return t;
});
private final ConcurrentHashMap<String, List<ScheduledFuture<?>>> scheduledByCallRaw = new ConcurrentHashMap<>();
public SkedReminderService(ChatController controller) {
this.controller = controller;
}
/**
* Arms reminders for one sked. Existing reminders for this call are cancelled.
*
* @param callSignRaw target call
* @param preferredCategory where to send (if null, controller resolves via lastInbound category)
* @param skedTimeEpochMs sked time
* @param offsetsMinutes e.g. [5,2,1] => reminders 5,2,1 minutes before
*/
public void armReminders(String callSignRaw,
ChatCategory preferredCategory,
long skedTimeEpochMs,
List<Integer> offsetsMinutes) {
String callRaw = normalize(callSignRaw);
if (callRaw == null || callRaw.isBlank()) return;
cancelReminders(callRaw);
long now = System.currentTimeMillis();
List<Integer> offsets = (offsetsMinutes == null) ? List.of() : offsetsMinutes;
List<ScheduledFuture<?>> futures = new ArrayList<>();
for (Integer offMin : offsets) {
if (offMin == null) continue;
long fireAt = skedTimeEpochMs - (offMin * 60_000L);
long delayMs = fireAt - now;
if (delayMs <= 0) continue;
ScheduledFuture<?> f = scheduler.schedule(
() -> fireReminder(callRaw, preferredCategory, offMin),
delayMs,
TimeUnit.MILLISECONDS
);
futures.add(f);
}
scheduledByCallRaw.put(callRaw, futures);
controller.onThreadStatus("SkedReminderService",
new ThreadStateMessage("SkedReminder", true,
"Armed for " + callRaw + " (" + offsets + " min before)", false));
}
public void cancelReminders(String callSignRaw) {
String callRaw = normalize(callSignRaw);
if (callRaw == null || callRaw.isBlank()) return;
List<ScheduledFuture<?>> futures = scheduledByCallRaw.remove(callRaw);
if (futures != null) {
for (ScheduledFuture<?> f : futures) {
if (f != null) f.cancel(false);
}
}
}
private void fireReminder(String callRaw, ChatCategory preferredCategory, int minutesBefore) {
try {
controller.queuePrivateCqMessage(callRaw, preferredCategory, "[KST4C Autoreminder] sked in " + minutesBefore + " min");
controller.fireUiReminderEvent(callRaw, minutesBefore); //triggers some blingbling in the UI
///Local acoustic hint (reuse existing project audio utilities, no AWT, no extra JavaFX modules)
try {
if (controller.getChatPreferences().isNotify_playSimpleSounds()) {
controller.getPlayAudioUtils().playNoiseLauncher('!'); // choose a suitable char you already use
}
// Optional: voice/cw hint (short, not too intrusive)
// controller.getPlayAudioUtils().playCWLauncher(" SKED " + minutesBefore);
} catch (Exception ignore) {
// never block reminder sending because of audio issues
}
controller.onThreadStatus("SkedReminderService",
new ThreadStateMessage("SkedReminder", true,
"PM reminder sent to " + callRaw + " (" + minutesBefore + " min)", false));
} catch (Exception e) {
controller.onThreadStatus("SkedReminderService",
new ThreadStateMessage("SkedReminder", false,
"ERROR sending reminder to " + callRaw + ": " + e.getMessage(), true));
e.printStackTrace();
}
}
private static String normalize(String s) {
if (s == null) return null;
return s.trim().toUpperCase();
}
}
@@ -1,33 +0,0 @@
/**
*
*/
package kst4contest.controller;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ObservableStringValue;
import java.io.IOException;
/**
* @author mywire
*
*/
public class StartChat {
/**
* @param args
* @throws InterruptedException
* @throws IOException
*/
public static void main(String[] args) throws InterruptedException, IOException {
System.out.println("[Startchat:] Starting new Chat instance");
// ObservableStringValue messageBus = new SimpleStringProperty("");
ChatController client = new ChatController();
client.execute();
}
}
@@ -1,269 +0,0 @@
package kst4contest.controller;
import kst4contest.logic.SignalDetector;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ChatMember;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Thread-safe metrics store keyed by normalized callsignRaw (e.g. "SM6VTZ").
*
* Purpose:
* - Provide inputs for scoring (momentum, reply time, no-reply strikes, manual sked-fail, positive signals).
* - Decouple MessageBus / TX from ScoreService (only data flows, no UI calls here).
*/
public final class StationMetricsService {
/** /cq <CALL> ... */
private static final Pattern OUTBOUND_CQ_PATTERN = Pattern.compile("(?i)^\\s*/cq\\s+([A-Z0-9/-]+)\\b.*");
/** Rolling window timestamps for momentum scoring. */
private static final int MAX_STORED_INBOUND_TIMESTAMPS = 32;
private final ConcurrentHashMap<String, StationMetrics> byCallRaw = new ConcurrentHashMap<>();
/**
* Called when the operator sends a message.
* If it is a "/cq CALL ..." message, this arms a pending ping for response-time / no-reply tracking.
*/
public Optional<String> tryRecordOutboundCq(String messageText, long nowEpochMs) {
if (messageText == null) return Optional.empty();
Matcher m = OUTBOUND_CQ_PATTERN.matcher(messageText.trim());
if (!m.matches()) return Optional.empty();
String callRaw = normalizeCallRaw(m.group(1));
if (callRaw == null || callRaw.isBlank()) return Optional.empty();
StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics());
synchronized (metrics) {
metrics.pendingCqSentAtEpochMs = nowEpochMs;
metrics.lastOutboundCqEpochMs = nowEpochMs;
}
return Optional.of(callRaw);
}
/**
* Called for EVERY inbound line from a station (CH or PM).
* "Any line counts as activity"
*/
public void onInboundMessage(String senderCallSignRaw,
long nowEpochMs,
String messageText,
ChatPreferences prefs,
String ownCallSignRaw) {
String callRaw = normalizeCallRaw(senderCallSignRaw);
if (callRaw == null || callRaw.isBlank()) return;
// ignore own echoed messages
if (ownCallSignRaw != null && callRaw.equalsIgnoreCase(normalizeCallRaw(ownCallSignRaw))) return;
StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics());
synchronized (metrics) {
metrics.lastInboundEpochMs = nowEpochMs;
// rolling timestamps (momentum)
metrics.recentInboundEpochMs.addLast(nowEpochMs);
while (metrics.recentInboundEpochMs.size() > MAX_STORED_INBOUND_TIMESTAMPS) {
metrics.recentInboundEpochMs.removeFirst();
}
// positive signal detection (extendable by prefs)
if (messageText != null && prefs != null) {
if (SignalDetector.containsPositiveSignal(messageText, prefs.getNotify_positiveSignalsPatterns())) {
metrics.lastPositiveSignalEpochMs = nowEpochMs;
}
}
// response time measurement: any inbound line ends a pending ping
if (metrics.pendingCqSentAtEpochMs > 0) {
long rttMs = Math.max(0, nowEpochMs - metrics.pendingCqSentAtEpochMs);
metrics.pendingCqSentAtEpochMs = 0;
// EWMA for response time (stable, no spikes)
final double alpha = 0.25;
if (metrics.avgResponseTimeMs <= 0) {
metrics.avgResponseTimeMs = rttMs;
} else {
metrics.avgResponseTimeMs = alpha * rttMs + (1.0 - alpha) * metrics.avgResponseTimeMs;
}
}
}
}
/**
* Called periodically (e.g. from ScoreService.tick()).
* Applies a "no reply" strike if the pending ping is older than prefs timeout.
*/
public void evaluateNoReplyTimeouts(long nowEpochMs, ChatPreferences prefs) {
if (prefs == null) return;
long timeoutMs = Math.max(1, prefs.getNotify_noReplyPenaltyMinutes()) * 60_000L;
for (Map.Entry<String, StationMetrics> e : byCallRaw.entrySet()) {
StationMetrics metrics = e.getValue();
if (metrics == null) continue;
synchronized (metrics) {
if (metrics.pendingCqSentAtEpochMs <= 0) continue;
long age = nowEpochMs - metrics.pendingCqSentAtEpochMs;
if (age >= timeoutMs) {
metrics.pendingCqSentAtEpochMs = 0;
metrics.noReplyStrikes++;
metrics.lastNoReplyStrikeEpochMs = nowEpochMs;
}
}
}
}
/** Manual sked fail: permanent until reset. */
public void markManualSkedFail(String callSignRaw) {
String callRaw = normalizeCallRaw(callSignRaw);
if (callRaw == null || callRaw.isBlank()) return;
StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics());
synchronized (metrics) {
metrics.manualSkedFailed = true;
metrics.manualSkedFailCount++;
}
}
public void resetManualSkedFail(String callSignRaw) {
String callRaw = normalizeCallRaw(callSignRaw);
if (callRaw == null || callRaw.isBlank()) return;
StationMetrics metrics = byCallRaw.computeIfAbsent(callRaw, k -> new StationMetrics());
synchronized (metrics) {
metrics.manualSkedFailed = false;
metrics.manualSkedFailCount = 0;
}
}
public boolean isManualSkedFailed(String callSignRaw) {
String callRaw = normalizeCallRaw(callSignRaw);
if (callRaw == null || callRaw.isBlank()) return false;
StationMetrics metrics = byCallRaw.get(callRaw);
if (metrics == null) return false;
synchronized (metrics) {
return metrics.manualSkedFailed;
}
}
/** Immutable snapshot for scoring */
public Snapshot snapshot(long nowEpochMs, ChatPreferences prefs) {
long momentumWindowMs = (prefs != null ? prefs.getNotify_momentumWindowSeconds() : 180) * 1000L;
Snapshot snap = new Snapshot(nowEpochMs, momentumWindowMs);
for (Map.Entry<String, StationMetrics> e : byCallRaw.entrySet()) {
String callRaw = e.getKey();
StationMetrics m = e.getValue();
if (m == null) continue;
synchronized (m) {
snap.byCallRaw.put(callRaw, new Snapshot.Metrics(
m.lastInboundEpochMs,
countRecent(m.recentInboundEpochMs, nowEpochMs, momentumWindowMs),
m.avgResponseTimeMs,
m.noReplyStrikes,
m.manualSkedFailed,
m.manualSkedFailCount,
m.lastPositiveSignalEpochMs
));
}
}
return snap;
}
private static int countRecent(Deque<Long> timestamps, long nowEpochMs, long windowMs) {
if (timestamps == null || timestamps.isEmpty()) return 0;
int cnt = 0;
for (Long t : timestamps) {
if (t == null) continue;
if (nowEpochMs - t <= windowMs) cnt++;
}
return cnt;
}
private static String normalizeCallRaw(String s) {
if (s == null) return null;
return ChatMember.normalizeCallSignToBaseCallSign(s);
}
private static final class StationMetrics {
long lastInboundEpochMs;
long lastOutboundCqEpochMs;
long pendingCqSentAtEpochMs; // 0 = none
int noReplyStrikes;
long lastNoReplyStrikeEpochMs;
double avgResponseTimeMs; // EWMA
final Deque<Long> recentInboundEpochMs = new ArrayDeque<>();
long lastPositiveSignalEpochMs;
boolean manualSkedFailed;
int manualSkedFailCount;
}
public static final class Snapshot {
private final long snapshotEpochMs;
private final long momentumWindowMs;
private final ConcurrentHashMap<String, Metrics> byCallRaw = new ConcurrentHashMap<>();
private Snapshot(long snapshotEpochMs, long momentumWindowMs) {
this.snapshotEpochMs = snapshotEpochMs;
this.momentumWindowMs = momentumWindowMs;
}
public Metrics get(String callSignRaw) {
if (callSignRaw == null) return null;
return byCallRaw.get(normalizeCallRaw(callSignRaw));
}
public long getSnapshotEpochMs() {
return snapshotEpochMs;
}
public long getMomentumWindowMs() {
return momentumWindowMs;
}
public static final class Metrics {
public final long lastInboundEpochMs;
public final int inboundCountInWindow;
public final double avgResponseTimeMs;
public final int noReplyStrikes;
public final boolean manualSkedFailed;
public final int manualSkedFailCount;
public final long lastPositiveSignalEpochMs;
public Metrics(long lastInboundEpochMs,
int inboundCountInWindow,
double avgResponseTimeMs,
int noReplyStrikes,
boolean manualSkedFailed,
int manualSkedFailCount,
long lastPositiveSignalEpochMs) {
this.lastInboundEpochMs = lastInboundEpochMs;
this.inboundCountInWindow = inboundCountInWindow;
this.avgResponseTimeMs = avgResponseTimeMs;
this.noReplyStrikes = noReplyStrikes;
this.manualSkedFailed = manualSkedFailed;
this.manualSkedFailCount = manualSkedFailCount;
this.lastPositiveSignalEpochMs = lastPositiveSignalEpochMs;
}
}
}
}
@@ -1,55 +0,0 @@
package kst4contest.controller;
import java.nio.file.Path;
import kst4contest.model.ThreadStateMessage;
public interface StatusUpdateListener {
/**
* Thread (key) will send update status (value) to the view via this interface.
*
*/
void onThreadStatusChanged(String key, ThreadStateMessage threadStateMessage);
/**
* Called when the user list needs a UI refresh without a diagnostic reason.
*/
void onUserListUpdated();
/**
* Called on change if the userlist needs a UI update and the change has a
* useful diagnostic reason.
*
* @param reason short diagnostic reason for the refresh
*/
void onUserListUpdated(final 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.
}
/**
* Called after KST4Contest successfully creates a missing Simplelogfile.
*
* @param filePath absolute path of the newly created file
*/
default void onSimpleLogFileCreated(Path filePath) {
// Optional for non-UI listeners.
}
}
@@ -1,8 +0,0 @@
package kst4contest.controller;
import kst4contest.model.ThreadStateMessage;
public interface ThreadStatusCallback {
void onThreadStatus(String threadName, ThreadStateMessage threadStateMessage);
}
@@ -1,64 +0,0 @@
package kst4contest.controller;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import kst4contest.model.ChatMember;
public class UCXLogFileToHashsetParser {
private static final Pattern CALL_SIGN_PATTERN = Pattern.compile(
"(([a-zA-Z]{1,2}[\\d]{1}?\\/)?(\\d{1}[a-zA-Z][\\d]{1}[a-zA-Z]{1,3})((\\/p)|(\\/\\d))?)"
+ "|(([a-zA-Z0-9]{1,2}[\\d]{1}?\\/)?(([a-zA-Z]{1,2}(\\d{1}[a-zA-Z]{1,4})))((\\/p)|(\\/\\d))?)"
+ "|([A-Z]\\d{2}[A-Z]{1,3})");
private final Path logFile;
public UCXLogFileToHashsetParser(String filePathAndName) {
this.logFile = Path.of(filePathAndName);
}
private String findLastCallSign(String line) {
Matcher matcher = CALL_SIGN_PATTERN.matcher(line);
String matchedCallSign = "";
while (matcher.find()) {
matchedCallSign = matcher.group();
}
return matchedCallSign.toUpperCase(Locale.ROOT);
}
/**
* Parses the selected log file and returns every detected station as a
* normalized base callsign. The reader is closed after each pass so the logging
* application can continue replacing or rotating the file.
*
* @return unique normalized base callsigns found in the file
* @throws IOException if the file cannot be read
*/
public Set<String> parse() throws IOException {
Set<String> workedBaseCalls = new HashSet<>();
try (BufferedReader fileReader = Files.newBufferedReader(logFile, Charset.defaultCharset())) {
String line;
while ((line = fileReader.readLine()) != null) {
String matchedCallSign = findLastCallSign(line);
String baseCallSign = ChatMember.normalizeCallSignToBaseCallSign(matchedCallSign);
if (baseCallSign != null && !baseCallSign.isBlank()) {
workedBaseCalls.add(baseCallSign.toUpperCase(Locale.ROOT));
}
}
}
return workedBaseCalls;
}
}
@@ -1,247 +0,0 @@
package kst4contest.controller;
import java.io.InputStream;
import kst4contest.ApplicationConstants;
import kst4contest.model.UpdateInformation;
import kst4contest.utils.ApplicationFileUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.File;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
public class UpdateChecker {
public static void main(String[] args) {
// new UpdateChecker(null).parseUpdateXMLFile();
if (new UpdateChecker(null).downloadLatestVersionInfoXML()) {
// ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME,ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE,ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE);
}
new UpdateChecker(null).parseUpdateXMLFile();
}
public UpdateChecker(ChatController chatController) {
System.out.println("[Updatechecker: checking for updates...]");
// double currentVersionNumber = ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER;
}
String versionInfoDownloadedFromServerFileName = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE);
String versionInfoXMLURLAtServer = ApplicationConstants.VERSIONINFOURLFORUPDATES_KST4CONTEST;
// double currentVersion = ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER;
//DOWNLOAD from URL, then parse, then do anything with it...
/**
* Downloads the versioninfo-xml-file from a webserver to local. Returns true if download was successful, else false
*
* @return true if successful
*/
public boolean downloadLatestVersionInfoXML() {
try {
InputStream in = new URL(versionInfoXMLURLAtServer).openStream();
Files.copy(in, Paths.get(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/"+ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE)), StandardCopyOption.REPLACE_EXISTING);
in.close();
// System.out.println(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/"+ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE));
// ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME,ApplicationFileUtils.get,ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE);
} catch (Exception e) {
System.out.println("ERROR DOWNLOADING!" + e);
return false;
}
return true;
}
public UpdateInformation parseUpdateXMLFile() {
UpdateInformation updateInfos = new UpdateInformation();
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME,"/"+ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE,ApplicationConstants.VERSIONINFDOWNLOADEDLOCALFILE);
// System.out.println("[Updatecker, Info]: restoring prefs from file " + versionInfoDownloadedFromServerFileName);
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
try {
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
} catch (ParserConfigurationException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}
try {
File xmlConfigFile = new File(versionInfoDownloadedFromServerFileName);
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(xmlConfigFile);
/**
* latestVersion on server
*/
NodeList list = doc.getElementsByTagName("latestVersion");
if (list.getLength() != 0) {
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
updateInfos.setLatestVersionNumberOnServer(Double.parseDouble(element.getElementsByTagName("versionNumber").item(0).getTextContent()));
NodeList semanticVersionNodes =
element.getElementsByTagName("semanticVersion");
if (semanticVersionNodes.getLength() > 0) {
updateInfos.setLatestSemanticVersionOnServer(
semanticVersionNodes.item(0).getTextContent()
);
}
updateInfos.setAdminMessage(element.getElementsByTagName("adminMessage").item(0).getTextContent());
updateInfos.setMajorChanges(element.getElementsByTagName("majorChanges").item(0)
.getTextContent());
updateInfos.setLatestVersionPathOnWebserver(element.getElementsByTagName("latestVersionPathOnWebserver").item(0).getTextContent());
// System.out.println(updateInfos.toString());
}
}
}
/**
* Section changeLog
*/
list = doc.getElementsByTagName("changeLog");
ArrayList<String[]> changeLogArrayList = new ArrayList<String[]>();
if (list.getLength() != 0) {
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
Element element = (Element) node;
int childNodeCounter = 0; //need an extra counter due to childnodes are counted...no idea, how
String[] aChangeLogEntry = new String[7];
aChangeLogEntry[0] = "";
aChangeLogEntry[1] = "Date: ";
aChangeLogEntry[2] = "Desc: ";
aChangeLogEntry[3] = "Added: ";
aChangeLogEntry[4] = "Changed: ";
aChangeLogEntry[5] = "Fixed: ";
aChangeLogEntry[6] = "Removed: ";
for (int i = 0; i < element.getChildNodes().getLength(); i++) {
if (element.getChildNodes().item(i).getNodeType() == Node.ELEMENT_NODE) {
// System.out.println(element.getChildNodes().item(i).getTextContent() + " <<<<<<<<<<<<<<<<<< " + i + " / " + childNodeCounter);
// System.out.println(element.getChildNodes().item(i).getNodeName());
aChangeLogEntry[childNodeCounter] = aChangeLogEntry[childNodeCounter] + element.getChildNodes().item(i).getTextContent();
childNodeCounter++;
}
}
changeLogArrayList.add(aChangeLogEntry);
}
updateInfos.setChangeLog(changeLogArrayList);
}
/**
* Section Buglist
*/
list = doc.getElementsByTagName("bug");
ArrayList<String[]> bugFixArrayList = new ArrayList<String[]>();
if (list.getLength() != 0) {
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
Element element = (Element) node;
int childNodeCounter = 0; //need an extra counter due to childnodes are counted...no idea, how
String[] aChangeLogEntry = new String[3];
aChangeLogEntry[0] = "";
aChangeLogEntry[1] = "State: ";
for (int i = 0; i < element.getChildNodes().getLength(); i++) {
if (element.getChildNodes().item(i).getNodeType() == Node.ELEMENT_NODE) {
// System.out.println(element.getChildNodes().item(i).getTextContent() + " <<<<<<<<<<<<<<<<<< " + i + " / " + childNodeCounter);
// System.out.println(element.getChildNodes().item(i).getNodeName());
aChangeLogEntry[childNodeCounter] = aChangeLogEntry[childNodeCounter] + element.getChildNodes().item(i).getTextContent();
childNodeCounter++;
}
}
bugFixArrayList.add(aChangeLogEntry);
}
updateInfos.setBugList(bugFixArrayList);
}
} catch (Exception e) {
System.out.println(e.getMessage());
e.printStackTrace();
}
String[] testEntry = new String[7];
testEntry[0] = "0.99";
testEntry[1] = "2022-09";
testEntry[2] = "researched the Chatprotocol";
testEntry[3] = "addednothing";
testEntry[4] = "changedsome";
testEntry[5] = "fixedxed";
testEntry[6] = "removedYourMom";
String[] testEntry2 = new String[7];
testEntry2[0] = "0.29";
testEntry2[1] = "2033-09";
testEntry2[2] = "tested";
testEntry2[3] = "addednotashing";
testEntry2[4] = "changeasdsome";
testEntry2[5] = "fixedxeds";
testEntry2[6] = "removedYosssurMom";
// changeLogArrayList.add(testEntry);
// changeLogArrayList.add(testEntry2);
return updateInfos;
}
@Override
public String toString() {
String toString = "";
// toString += this.currentVersion;
return toString;
}
}
@@ -1,100 +0,0 @@
package kst4contest.controller;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Set;
import java.util.TimerTask;
import java.util.logging.Level;
import java.util.logging.Logger;
public class UserActualizationTask extends TimerTask {
private static final Logger LOGGER = Logger.getLogger(UserActualizationTask.class.getName());
private final ChatController client;
public UserActualizationTask(ChatController client) {
this.client = client;
}
@Override
public void run() {
Thread.currentThread().setName("UserActualizationTask");
try {
updateWorkedCallSignsFromFile();
} catch (RuntimeException exception) {
LOGGER.log(Level.WARNING,
"Unexpected failure while updating Worked callsigns from the Simplelogfile; "
+ "the periodic task will continue.",
exception);
}
}
private void updateWorkedCallSignsFromFile() {
if (!client.getChatPreferences().isLogsynch_fileBasedWkdCallInterpreterEnabled()) {
return;
}
String configuredFileName = client.getChatPreferences()
.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly();
if (configuredFileName == null || configuredFileName.isBlank()) {
LOGGER.warning("Cannot read the Simplelogfile because no file is selected.");
return;
}
final Path logFile;
try {
logFile = Path.of(configuredFileName).toAbsolutePath().normalize();
} catch (InvalidPathException exception) {
LOGGER.log(Level.WARNING,
"Cannot use the configured Simplelogfile path: " + configuredFileName,
exception);
return;
}
boolean created = createMissingLogFile(logFile);
if (!Files.isRegularFile(logFile)) {
return;
}
try {
Set<String> workedBaseCalls = new UCXLogFileToHashsetParser(logFile.toString()).parse();
client.applySimpleLogWorkedBaseCalls(workedBaseCalls);
LOGGER.log(Level.FINE,
"Read {0} unique base callsigns from Simplelogfile {1}.",
new Object[] { workedBaseCalls.size(), logFile });
} catch (IOException exception) {
LOGGER.log(Level.WARNING, "Cannot read Simplelogfile " + logFile, exception);
}
if (created) {
client.notifySimpleLogFileCreated(logFile);
}
}
private boolean createMissingLogFile(Path logFile) {
if (Files.exists(logFile)) {
if (!Files.isRegularFile(logFile)) {
LOGGER.log(Level.WARNING,
"The selected Simplelogfile path is not a regular file: {0}",
logFile);
}
return false;
}
try {
Files.createFile(logFile);
LOGGER.log(Level.INFO, "Created missing Simplelogfile {0}.", logFile);
return true;
} catch (FileAlreadyExistsException exception) {
return false;
} catch (IOException | SecurityException exception) {
LOGGER.log(Level.WARNING, "Cannot create Simplelogfile " + logFile, exception);
return false;
}
}
}
@@ -1,242 +0,0 @@
package kst4contest.controller;
import javafx.beans.property.SimpleStringProperty;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Utils4KST {
public long time_generateCurrentEpochTime() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
// System.out.println(currentTimeInUtc.format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm X")));
long millisecondsSinceEpoch = currentTimeInUtc.toInstant().toEpochMilli() / 1000;
// System.out.println(millisecondsSinceEpoch);
return millisecondsSinceEpoch;
}
public String time_generateCurrenthhmmZTimeStringForClusterMessage() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
return currentTimeInUtc.format(DateTimeFormatter.ofPattern("HHmm"))+"Z";
}
public String time_generateCurrentMMDDhhmmTimeString() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
return currentTimeInUtc.format(DateTimeFormatter.ofPattern("MM-dd hh:mm"));
}
public String time_generateCurrentMMddString() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
return currentTimeInUtc.format(DateTimeFormatter.ofPattern("MM-dd"));
}
public String time_convertEpochToReadable(String epochFromServer) {
long epoch = Long.parseLong(epochFromServer);
// Instant instant = Instant.ofEpochSecond(epoch);
Date date = new Date(epoch * 1000L);
// DateFormat format = new SimpleDateFormat("dd.MM HH:mm:ss"); //old value which is too long
DateFormat format = new SimpleDateFormat("H:mm:ss");
format.setTimeZone(TimeZone.getTimeZone("Etc/UTC"));
String formatted = format.format(date);
// System.out.println("UTIL " + formatted);
return formatted;
}
public static long time_getSecondsBetweenEpochAndNow(String epoch1) {
long epoch1Long = Long.parseLong(epoch1);
long epoch2Long = new Utils4KST().time_generateCurrentEpochTime();
// Instant instant = Instant.ofEpochSecond(epoch);
Date date = new Date(epoch1Long * 1000L);
Date date2 = new Date(epoch2Long * 1000L);
long seconds = Math.abs(date.getTime()-date2.getTime())/1000;
return seconds;
}
public Date time_generateActualTimeInDateFormat() {
Date date = new Date(time_generateCurrentEpochTime() * 1000L);
return date;
}
/**
* This method tests a regexp-pattern against a given string
*
* @param testString: check if this string matches a given pattern
* @param regExPattern: pattern which should be checked
* @return true if match, else false
*/
private static boolean testPattern(String testString, String regExPattern) {
Pattern pattern = Pattern.compile(regExPattern);
Matcher matcher = pattern.matcher(testString);
return matcher.find();
}
/**
* Converts a frequency detected in a chat message into the kHz representation
* used by the DX Cluster protocol.
*
* <p>Complete frequencies with two to five MHz digits are supported, for
* example 50.200, 144.205, 1296.338, 10368.100 and 24048.100. An optional
* second fractional group is retained as sub-kHz precision, for example
* 144.205.2 becomes 144205.2 kHz.</p>
*
* <p>Relative values such as .205 or 205 use the configured fallback-band
* prefix. The method only performs this fallback conversion after the general
* message parser has already accepted the value as a frequency.</p>
*
* @param qrgString frequency as detected by the chat parser
* @param optionalPrefix fallback MHz prefix for relative frequencies
* @return frequency in kHz for a DX Cluster spot, or an empty string if the
* value cannot be converted safely
*/
public static String normalizeFrequencyString(
String qrgString,
SimpleStringProperty optionalPrefix
) {
if (qrgString == null || qrgString.isBlank()) {
return "";
}
/*
* A comma is accepted as a decimal separator. Spaces are removed so older
* stored formats such as "432 088" remain usable.
*/
String normalizedValue = qrgString
.trim()
.replace(" ", "")
.replace(',', '.');
/*
* Complete frequency with an explicit separator:
*
* 50.200
* 144.205
* 1296.338
* 10368.100
* 144.205.2
*/
Matcher completeFrequencyMatcher = Pattern.compile(
"^(\\d{2,5})\\.(\\d{1,3})(?:\\.(\\d{1,2}))?$"
).matcher(normalizedValue);
if (completeFrequencyMatcher.matches()) {
return formatDxClusterFrequency(
completeFrequencyMatcher.group(1),
completeFrequencyMatcher.group(2),
completeFrequencyMatcher.group(3)
);
}
/*
* Compact complete frequency retained for compatibility:
*
* 144205
* 1296338
* 10368100
* 432088.2
*/
Matcher compactFrequencyMatcher = Pattern.compile(
"^(\\d{2,5})(\\d{3})(?:\\.(\\d{1,2}))?$"
).matcher(normalizedValue);
if (compactFrequencyMatcher.matches()) {
return formatDxClusterFrequency(
compactFrequencyMatcher.group(1),
compactFrequencyMatcher.group(2),
compactFrequencyMatcher.group(3)
);
}
/*
* Relative frequency. Values above 499 are deliberately rejected, matching
* the previous behaviour and preventing a report such as 599 from becoming
* a plausible-looking cluster frequency.
*/
Matcher relativeFrequencyMatcher = Pattern.compile(
"^\\.?([0-4]\\d{2})(?:\\.(\\d{1,2}))?$"
).matcher(normalizedValue);
if (!relativeFrequencyMatcher.matches()) {
return "";
}
String fallbackPrefix = optionalPrefix == null
? null
: optionalPrefix.getValue();
if (fallbackPrefix == null) {
return "";
}
fallbackPrefix = fallbackPrefix.trim();
if (!fallbackPrefix.matches("\\d{2,5}")) {
return "";
}
return formatDxClusterFrequency(
fallbackPrefix,
relativeFrequencyMatcher.group(1),
relativeFrequencyMatcher.group(2)
);
}
/**
* Formats an MHz part, a fractional MHz part and optional sub-kHz digits as a
* DX Cluster frequency in kHz.
*
* <p>The fractional MHz part is padded on the right because 144.2 means
* 144.200 MHz, while 144.21 means 144.210 MHz.</p>
*
* @param mhzPart complete MHz part
* @param fractionalMhzPart one to three digits following the first separator
* @param subKhzPart optional digits following a second separator
* @return DX Cluster frequency in kHz
*/
private static String formatDxClusterFrequency(
String mhzPart,
String fractionalMhzPart,
String subKhzPart
) {
String paddedFraction =
(fractionalMhzPart + "000").substring(0, 3);
String subKhz = subKhzPart == null
? "0"
: subKhzPart;
return mhzPart
+ paddedFraction
+ "."
+ subKhz;
}
}
@@ -1,217 +0,0 @@
package kst4contest.controller;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
/**
* Inventory of one Win-Test log, transported in an {@code IHAVE} packet.
*
* <p>Win-Test announces which QSO numbers of a log a station currently holds.
* To keep the packet short the inventory is run-length encoded:</p>
*
* <pre>
* IHAVE: "Shack" "" "Shack@9" E 1 1 911-1-117
* ^logId ^ ^ ^ ^run lengths
* origin | initial state
* first row
* </pre>
*
* <p>The run lengths alternate between present and missing QSOs, starting with
* the state given by {@code InitialState} at QSO number {@code FirstRow}. The
* example above therefore means: QSOs 1 to 911 are present, QSO 912 is missing
* and QSOs 913 to 1029 are present again.</p>
*
* <p>Unlike the wtKST implementation this parser honours {@code FirstRow}
* instead of assuming that every inventory starts at QSO number one. Win-Test
* splits long inventories into several packets, and a split inventory starts at
* a higher first row.</p>
*/
public final class WinTestIhaveInventory {
/** Where the sending station got the log from. */
public enum Origin {
/** The station owns the log or the operator is logged on there. */
OWNER,
/** The station only mirrors a log owned by somebody else. */
LOGGED_ELSE
}
/** Protects against endless loops caused by a corrupted run-length chain. */
private static final int MAX_SEGMENTS = 512;
private static final int EXPECTED_FIELD_COUNT = 5;
private final String logId;
private final Origin origin;
private final List<WinTestLogSegment> segments;
private WinTestIhaveInventory(String logId, Origin origin, List<WinTestLogSegment> segments) {
this.logId = logId;
this.origin = origin;
this.segments = segments;
}
/**
* Parses an {@code IHAVE} packet.
*
* @param packet received packet
* @return inventory, or an empty value when the packet is not a usable
* {@code IHAVE} announcement
*/
public static Optional<WinTestIhaveInventory> fromPacket(WinTestPacket packet) {
if (packet == null || !"IHAVE".equals(packet.getMessageType())) {
return Optional.empty();
}
List<String> fields = packet.getDataTokens();
if (fields.size() != EXPECTED_FIELD_COUNT) {
/*
* Win-Test versions before 1.29 use a shorter IHAVE format without
* run-length encoding. It carries no usable range information, so
* the blind fallback of the sync service has to take over.
*/
return Optional.empty();
}
String parsedLogId = fields.get(0) == null ? "" : fields.get(0).trim();
if (parsedLogId.isEmpty()) {
return Optional.empty();
}
Origin parsedOrigin = parseOrigin(fields.get(1));
long firstRow = parseUnsignedValue(fields.get(2));
long initialState = parseUnsignedValue(fields.get(3));
if (firstRow < 1L || initialState < 0L || initialState > 1L) {
return Optional.empty();
}
List<WinTestLogSegment> parsedSegments =
parseRunLengths(fields.get(4), firstRow, initialState == 1L);
if (parsedSegments == null) {
return Optional.empty();
}
return Optional.of(new WinTestIhaveInventory(parsedLogId, parsedOrigin, parsedSegments));
}
private static Origin parseOrigin(String rawOrigin) {
if (rawOrigin == null) {
return Origin.OWNER;
}
String normalizedOrigin = rawOrigin.trim().toUpperCase(java.util.Locale.ROOT);
if ("E".equals(normalizedOrigin) || "LOGGEDELSE".equals(normalizedOrigin)) {
return Origin.LOGGED_ELSE;
}
return Origin.OWNER;
}
/**
* Expands the hyphen-separated run lengths into ranges.
*
* @param rawRunLengths run-length chain such as {@code 911-1-117}
* @param firstRow QSO number the first run starts at
* @param startsPresent {@code true} when the first run describes present QSOs
* @return ranges of present QSOs, or {@code null} for an unusable chain
*/
private static List<WinTestLogSegment> parseRunLengths(
String rawRunLengths,
long firstRow,
boolean startsPresent
) {
if (rawRunLengths == null || rawRunLengths.isBlank()) {
return null;
}
String[] runLengths = rawRunLengths.trim().split("-");
/*
* A chain that starts with present QSOs has to end with a present run,
* so its length is odd. A chain that starts with missing QSOs needs an
* even length for the same reason.
*/
if (startsPresent) {
if (runLengths.length % 2 == 0) {
return null;
}
} else if (runLengths.length % 2 == 1 || runLengths.length < 2) {
return null;
}
List<WinTestLogSegment> parsedSegments = new ArrayList<>();
long cursor = firstRow;
boolean present = startsPresent;
for (String runLength : runLengths) {
long count = parseUnsignedValue(runLength);
if (count < 0L) {
return null;
}
if (present && count > 0L) {
if (parsedSegments.size() >= MAX_SEGMENTS) {
return null;
}
parsedSegments.add(new WinTestLogSegment(cursor, cursor + count - 1L));
}
cursor += count;
present = !present;
}
return parsedSegments;
}
private static long parseUnsignedValue(String rawValue) {
if (rawValue == null) {
return -1L;
}
try {
return Long.parseLong(rawValue.trim());
} catch (NumberFormatException exception) {
return -1L;
}
}
/**
* @return log identity in the form {@code StationName@LogUniqueID}
*/
public String getLogId() {
return logId;
}
public Origin getOrigin() {
return origin;
}
/**
* @return ranges of QSO numbers the announcing station holds
*/
public List<WinTestLogSegment> getSegments() {
return Collections.unmodifiableList(segments);
}
/**
* @return highest announced QSO number, or {@code 0} for an empty inventory
*/
public long getHighestQsoNumber() {
long highestQsoNumber = 0L;
for (WinTestLogSegment segment : segments) {
if (segment.getCountTo() > highestQsoNumber) {
highestQsoNumber = segment.getCountTo();
}
}
return highestQsoNumber;
}
@Override
public String toString() {
return logId + " " + origin + " " + segments;
}
}
@@ -1,60 +0,0 @@
package kst4contest.controller;
/**
* Consecutive range of Win-Test QSO numbers inside one log.
*
* <p>Win-Test numbers the QSOs of every log continuously. The {@code IHAVE}
* inventory of a log is therefore expressed as a list of ranges that are
* present in that log. A range is inclusive on both ends.</p>
*/
public final class WinTestLogSegment {
private final long countFrom;
private final long countTo;
/**
* @param countFrom first QSO number of the range
* @param countTo last QSO number of the range
*/
public WinTestLogSegment(long countFrom, long countTo) {
this.countFrom = countFrom;
this.countTo = countTo;
}
public long getCountFrom() {
return countFrom;
}
public long getCountTo() {
return countTo;
}
/**
* @return number of QSOs covered by this range, never negative
*/
public long getCount() {
return countTo < countFrom ? 0L : countTo - countFrom + 1L;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof WinTestLogSegment)) {
return false;
}
WinTestLogSegment otherSegment = (WinTestLogSegment) other;
return countFrom == otherSegment.countFrom && countTo == otherSegment.countTo;
}
@Override
public int hashCode() {
return Long.hashCode(countFrom) * 31 + Long.hashCode(countTo);
}
@Override
public String toString() {
return countFrom + "-" + countTo;
}
}
@@ -1,611 +0,0 @@
package kst4contest.controller;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableSet;
import java.util.Optional;
import java.util.TreeSet;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/**
* Recovers the part of a Win-Test log that was written before KST4Contest was
* started.
*
* <p>Win-Test broadcasts every new QSO as an {@code ADDQSO} packet. A client
* that joins the network later never sees the QSOs logged before it started, so
* stations already worked would still be shown as not worked. Win-Test also
* offers a pull mechanism for exactly this situation, and this service is the
* port of the wtKST {@code WtLogSync} implementation of it:</p>
*
* <ol>
* <li>a Win-Test station announces itself with {@code HELLO} or, if its log
* was opened before we started listening, with its periodic
* {@code STATUS};</li>
* <li>its periodic {@code IHAVE} packets announce which QSO numbers of
* which log it holds;</li>
* <li>missing ranges are requested with {@code NEEDQSO}, at most
* {@value #MAX_QSOS_PER_REQUEST} QSOs per request;</li>
* <li>Win-Test answers with ordinary {@code ADDQSO} packets that are
* addressed to us instead of being broadcast.</li>
* </ol>
*
* <p>Because the answers are ordinary {@code ADDQSO} packets, the recovered
* QSOs run through the same Worked handling as live QSOs. This service only
* decides what still has to be requested; it neither touches the database nor
* the user interface.</p>
*
* <p>If a station is known but no usable {@code IHAVE} inventory arrives within
* {@value #INVENTORY_GRACE_PERIOD_MS} ms, a blind fallback requests fixed
* blocks starting at QSO number one until a block stays unanswered. That covers
* Win-Test versions whose {@code IHAVE} format carries no run lengths.</p>
*
* <p>Deviation from wtKST: wtKST discards its whole QSO table whenever a
* {@code HELLO} arrives, because it displays that table. KST4Contest only
* accumulates Worked state, where a stale entry is harmless while a discarded
* one would cause the complete log to be requested and written again. The log
* identity {@code StationName@LogUniqueID} already changes when Win-Test opens
* a different log, so nothing is cleared here.</p>
*/
public class WinTestLogSyncService {
/** Win-Test answers at most this many QSOs for one NEEDQSO request. */
static final int MAX_QSOS_PER_REQUEST = 50;
/** Time after which an unanswered request is retried elsewhere. */
static final long REQUEST_TIMEOUT_MS = 5000L;
/** Shortest distance between two evaluations without a pending trigger. */
static final long TICK_INTERVAL_MS = 2000L;
/** Waiting time for a usable IHAVE before the blind fallback starts. */
static final long INVENTORY_GRACE_PERIOD_MS = 15000L;
/** Upper bound for the blind fallback, equals 10000 QSOs. */
static final int MAX_BLIND_BLOCKS = 200;
/** Guards the gap search against a corrupted inventory. */
private static final long MAX_SCANNED_QSO_NUMBERS = 200000L;
/**
* Sends a NEEDQSO request to a Win-Test station.
*/
@FunctionalInterface
public interface NeedQsoSender {
/**
* @param targetStation Win-Test station name the request is sent to
* @param logId log identity in the form {@code StationName@LogUniqueID}
* @param countFrom first requested QSO number
* @param countTo last requested QSO number
*/
void sendNeedQso(String targetStation, String logId, long countFrom, long countTo);
}
/** Progress of the log recovery, used for status reporting. */
public enum SyncState {
/** No Win-Test station seen yet. */
IDLE,
/** A station is known, but nothing has been requested yet. */
STATION_DETECTED,
/** QSOs are being requested. */
SYNCING,
/** Everything announced by the known stations has been received. */
IN_SYNC
}
private final NeedQsoSender needQsoSender;
private final Supplier<String> ownStationNameSupplier;
private final LongSupplier clock;
/** Inventories per Win-Test station, keyed by log identity. */
private final Map<String, Map<String, WinTestIhaveInventory>> inventoriesByStation =
new LinkedHashMap<>();
/** QSO numbers already received, keyed by log identity. */
private final Map<String, NavigableSet<Long>> receivedQsoNumbersByLogId = new HashMap<>();
/** State of the blind fallback, keyed by log identity. */
private final Map<String, BlindScan> blindScansByLogId = new LinkedHashMap<>();
private PendingRequest pendingRequest;
private long firstStationSeenAtMs;
private boolean usableInventorySeen;
private long lastTickMs;
private boolean tickDueImmediately;
private SyncState state = SyncState.IDLE;
/**
* @param needQsoSender transport used for NEEDQSO requests
* @param ownStationNameSupplier own Win-Test station name, read late because
* it can be changed in the settings at runtime
*/
public WinTestLogSyncService(
NeedQsoSender needQsoSender,
Supplier<String> ownStationNameSupplier
) {
this(needQsoSender, ownStationNameSupplier, System::currentTimeMillis);
}
/**
* @param needQsoSender transport used for NEEDQSO requests
* @param ownStationNameSupplier own Win-Test station name
* @param clock time source in milliseconds
*/
WinTestLogSyncService(
NeedQsoSender needQsoSender,
Supplier<String> ownStationNameSupplier,
LongSupplier clock
) {
this.needQsoSender = needQsoSender;
this.ownStationNameSupplier = ownStationNameSupplier;
this.clock = clock;
}
/**
* Registers a Win-Test station seen in a HELLO or STATUS packet.
*
* @param stationName Win-Test station name
*/
public synchronized void onStationSeen(String stationName) {
if (stationName == null || stationName.isBlank()) {
return;
}
String normalizedStationName = stationName.trim();
if (normalizedStationName.equalsIgnoreCase(resolveOwnStationName())) {
// our own packets, nothing to synchronize from
return;
}
if (inventoriesByStation.putIfAbsent(normalizedStationName, new LinkedHashMap<>()) == null) {
tickDueImmediately = true;
}
if (firstStationSeenAtMs == 0L) {
firstStationSeenAtMs = clock.getAsLong();
}
if (state == SyncState.IDLE) {
state = SyncState.STATION_DETECTED;
}
}
/**
* Takes over the inventory of an IHAVE packet.
*
* @param packet received IHAVE packet
*/
public synchronized void onIhaveReceived(WinTestPacket packet) {
if (packet == null || !packet.isAddressedTo(resolveOwnStationName())) {
return;
}
Optional<WinTestIhaveInventory> parsedInventory = WinTestIhaveInventory.fromPacket(packet);
if (parsedInventory.isEmpty()) {
return;
}
WinTestIhaveInventory inventory = parsedInventory.get();
onStationSeen(packet.getSource());
Map<String, WinTestIhaveInventory> stationInventories =
inventoriesByStation.get(packet.getSource() == null ? "" : packet.getSource().trim());
if (stationInventories == null) {
return;
}
WinTestIhaveInventory previousInventory =
stationInventories.put(inventory.getLogId(), inventory);
if (previousInventory == null
|| !previousInventory.getSegments().equals(inventory.getSegments())) {
System.out.println("[WinTest LogSync] inventory of " + inventory.getLogId()
+ " from " + packet.getSource()
+ " (" + inventory.getOrigin() + "): " + inventory.getSegments());
}
usableInventorySeen = true;
blindScansByLogId.remove(inventory.getLogId());
tickDueImmediately = true;
}
/**
* Registers a QSO received in an ADDQSO packet.
*
* @param logId log identity in the form {@code StationName@LogUniqueID}
* @param qsoNumber Win-Test QSO number inside that log
* @return {@code true} when this QSO was not known before, and therefore
* still has to be applied to Worked state and database
*/
public synchronized boolean registerReceivedQso(String logId, long qsoNumber) {
if (logId == null || logId.isBlank() || qsoNumber <= 0L) {
// without a usable identity the QSO cannot be deduplicated
return true;
}
NavigableSet<Long> receivedQsoNumbers =
receivedQsoNumbersByLogId.computeIfAbsent(logId.trim(), key -> new TreeSet<>());
boolean isNewQso = receivedQsoNumbers.add(qsoNumber);
if (pendingRequest != null
&& pendingRequest.logId.equals(logId.trim())
&& qsoNumber >= pendingRequest.countFrom
&& qsoNumber <= pendingRequest.countTo) {
pendingRequest.answeredQsoCount++;
if (qsoNumber == pendingRequest.countTo
|| pendingRequest.answeredQsoCount >= pendingRequest.getRequestedQsoCount()) {
PendingRequest completedRequest = pendingRequest;
pendingRequest = null;
if (completedRequest.blind) {
finishBlindBlock(completedRequest);
}
tickDueImmediately = true;
}
}
return isNewQso;
}
/**
* Advances the recovery. Called after every received packet and on every
* receive timeout of the listener; an internal interval keeps the actual
* work rare while a satisfied request triggers the next one immediately.
*/
public synchronized void tick() {
long now = clock.getAsLong();
if (!tickDueImmediately && now - lastTickMs < TICK_INTERVAL_MS) {
return;
}
lastTickMs = now;
tickDueImmediately = false;
if (pendingRequest != null) {
if (now - pendingRequest.sentAtMs < REQUEST_TIMEOUT_MS) {
return;
}
handlePendingTimeout();
return;
}
if (requestNextMissingRange(now)) {
return;
}
if (requestNextBlindBlock(now)) {
return;
}
if (state == SyncState.SYNCING) {
state = SyncState.IN_SYNC;
}
}
/**
* @return current progress of the recovery
*/
public synchronized SyncState getState() {
return state;
}
/**
* @return number of QSO numbers known for the given log
*/
synchronized int getKnownQsoCount(String logId) {
NavigableSet<Long> receivedQsoNumbers = receivedQsoNumbersByLogId.get(logId);
return receivedQsoNumbers == null ? 0 : receivedQsoNumbers.size();
}
private void handlePendingTimeout() {
PendingRequest timedOutRequest = pendingRequest;
pendingRequest = null;
if (timedOutRequest.blind) {
finishBlindBlock(timedOutRequest);
return;
}
String alternativeStation =
findAlternativeStation(timedOutRequest.logId, timedOutRequest.targetStation);
System.out.println("[WinTest LogSync] no answer from " + timedOutRequest.targetStation
+ " for " + timedOutRequest.logId + " "
+ timedOutRequest.countFrom + "-" + timedOutRequest.countTo);
if (alternativeStation != null) {
sendRequest(
alternativeStation,
timedOutRequest.logId,
timedOutRequest.countFrom,
timedOutRequest.countTo,
false,
clock.getAsLong()
);
return;
}
/*
* Nobody else holds this log. The silent station is dropped and returns
* with its next STATUS or IHAVE packet.
*/
System.out.println("[WinTest LogSync] dropping silent station "
+ timedOutRequest.targetStation + ", waiting for its next STATUS or IHAVE");
inventoriesByStation.remove(timedOutRequest.targetStation);
tickDueImmediately = true;
}
private String findAlternativeStation(String logId, String excludedStation) {
for (Map.Entry<String, Map<String, WinTestIhaveInventory>> station
: inventoriesByStation.entrySet()) {
if (station.getKey().equals(excludedStation)) {
continue;
}
if (station.getValue().containsKey(logId)) {
return station.getKey();
}
}
return null;
}
/**
* Looks for the first announced QSO range that is still missing and
* requests it. Stations that own a log are preferred over stations that
* only mirror it.
*
* @param now current time in milliseconds
* @return {@code true} when a request was sent
*/
private boolean requestNextMissingRange(long now) {
for (int pass = 0; pass < 2; pass++) {
boolean preferOwner = pass == 0;
for (Map.Entry<String, Map<String, WinTestIhaveInventory>> station
: new ArrayList<>(inventoriesByStation.entrySet())) {
for (WinTestIhaveInventory inventory : new ArrayList<>(station.getValue().values())) {
boolean isOwner = inventory.getOrigin() == WinTestIhaveInventory.Origin.OWNER;
if (preferOwner != isOwner) {
continue;
}
long[] missingRange = findMissingRange(
inventory.getSegments(),
receivedQsoNumbersByLogId.get(inventory.getLogId())
);
if (missingRange == null) {
continue;
}
sendRequest(
station.getKey(),
inventory.getLogId(),
missingRange[0],
missingRange[1],
false,
now
);
return true;
}
}
}
return false;
}
/**
* Determines the next missing QSO range of one log.
*
* <p>wtKST compares its own segment list against the announced one and
* derives the request bounds from the segment indices. Searching the gap
* directly produces the same ranges for the ordinary cases, cannot run past
* the end of either list, and never asks for QSO numbers that are already
* known.</p>
*
* @param segments ranges announced by the station
* @param receivedQsoNumbers QSO numbers already received for this log
* @return first missing range as {@code {countFrom, countTo}}, or
* {@code null} when nothing is missing
*/
static long[] findMissingRange(
List<WinTestLogSegment> segments,
NavigableSet<Long> receivedQsoNumbers
) {
if (segments == null) {
return null;
}
long remainingScanBudget = MAX_SCANNED_QSO_NUMBERS;
for (WinTestLogSegment segment : segments) {
for (long qsoNumber = segment.getCountFrom();
qsoNumber <= segment.getCountTo();
qsoNumber++) {
remainingScanBudget--;
if (remainingScanBudget < 0L) {
return null;
}
if (receivedQsoNumbers != null && receivedQsoNumbers.contains(qsoNumber)) {
continue;
}
long countFrom = qsoNumber;
long countTo = countFrom;
while (countTo < segment.getCountTo()
&& countTo - countFrom + 1L < MAX_QSOS_PER_REQUEST
&& (receivedQsoNumbers == null || !receivedQsoNumbers.contains(countTo + 1L))) {
countTo++;
}
return new long[] { countFrom, countTo };
}
}
return null;
}
/**
* Requests the next fixed block of a log whose station never sent a usable
* inventory.
*
* @param now current time in milliseconds
* @return {@code true} when a request was sent
*/
private boolean requestNextBlindBlock(long now) {
if (usableInventorySeen || firstStationSeenAtMs == 0L) {
return false;
}
if (now - firstStationSeenAtMs < INVENTORY_GRACE_PERIOD_MS) {
return false;
}
for (String logId : new ArrayList<>(receivedQsoNumbersByLogId.keySet())) {
BlindScan blindScan = blindScansByLogId.computeIfAbsent(logId, key -> new BlindScan());
if (blindScan.completed || blindScan.requestedBlockCount >= MAX_BLIND_BLOCKS) {
continue;
}
String targetStation = resolveStationForLogId(logId);
if (targetStation == null) {
continue;
}
blindScan.requestedBlockCount++;
sendRequest(
targetStation,
logId,
blindScan.nextCountFrom,
blindScan.nextCountFrom + MAX_QSOS_PER_REQUEST - 1L,
true,
now
);
return true;
}
return false;
}
private void finishBlindBlock(PendingRequest finishedRequest) {
BlindScan blindScan = blindScansByLogId.get(finishedRequest.logId);
if (blindScan == null) {
return;
}
System.out.println("[WinTest LogSync] blind block " + finishedRequest.countFrom
+ "-" + finishedRequest.countTo + " of " + finishedRequest.logId
+ " answered with " + finishedRequest.answeredQsoCount + " QSOs");
if (finishedRequest.answeredQsoCount == 0) {
// the log ends before this block, nothing left to fetch
blindScan.completed = true;
} else {
blindScan.nextCountFrom = finishedRequest.countTo + 1L;
}
tickDueImmediately = true;
}
/**
* Resolves the Win-Test station a log belongs to. The log identity carries
* the owning station name in front of the {@code @} separator.
*
* @param logId log identity
* @return station name to ask, or {@code null} when none is known
*/
private String resolveStationForLogId(String logId) {
int separatorIndex = logId.indexOf('@');
String ownerStationName = separatorIndex > 0 ? logId.substring(0, separatorIndex) : logId;
for (String stationName : inventoriesByStation.keySet()) {
if (stationName.equalsIgnoreCase(ownerStationName)) {
return stationName;
}
}
return ownerStationName.isBlank() ? null : ownerStationName;
}
private void sendRequest(
String targetStation,
String logId,
long countFrom,
long countTo,
boolean blind,
long now
) {
pendingRequest = new PendingRequest(targetStation, logId, countFrom, countTo, blind, now);
state = SyncState.SYNCING;
try {
needQsoSender.sendNeedQso(targetStation, logId, countFrom, countTo);
} catch (RuntimeException exception) {
/*
* A failed transmission must not stop the receive loop. The pending
* request runs into its timeout and is retried from there.
*/
System.out.println(
"[WinTest LogSync] NEEDQSO could not be sent: " + exception.getMessage()
);
}
}
private String resolveOwnStationName() {
if (ownStationNameSupplier == null) {
return "";
}
String ownStationName = ownStationNameSupplier.get();
return ownStationName == null ? "" : ownStationName.trim();
}
/** Request that is waiting for its answer. */
private static final class PendingRequest {
private final String targetStation;
private final String logId;
private final long countFrom;
private final long countTo;
private final boolean blind;
private final long sentAtMs;
private int answeredQsoCount;
private PendingRequest(
String targetStation,
String logId,
long countFrom,
long countTo,
boolean blind,
long sentAtMs
) {
this.targetStation = targetStation;
this.logId = logId;
this.countFrom = countFrom;
this.countTo = countTo;
this.blind = blind;
this.sentAtMs = sentAtMs;
}
private long getRequestedQsoCount() {
return countTo - countFrom + 1L;
}
}
/** Progress of the blind fallback for one log. */
private static final class BlindScan {
private long nextCountFrom = 1L;
private int requestedBlockCount;
private boolean completed;
}
}
@@ -1,82 +0,0 @@
package kst4contest.controller;
import java.nio.charset.StandardCharsets;
/**
* Represents a Win-Test network protocol message.
* <p>
* Ported from the C# wtMessage class in wtKST.
* <p>
* Win-Test uses a simple ASCII-based UDP protocol with a checksum byte.
* Message format (for sending):
* <pre>
* MESSAGETYPE: "src" "dst" data{checksum}\0
* </pre>
* The checksum is calculated over all bytes before the checksum position,
* then OR'd with 0x80.
*/
public class WinTestMessage {
/** Win-Test message types sent by KST4Contest. */
public enum MessageType {
/** Requests a range of QSOs of one Win-Test log for log synchronization. */
NEEDQSO,
LOCKSKED,
UNLOCKSKED,
ADDSKED,
DELETESKED,
UPDATESKED
}
private final MessageType type;
private final String src;
private final String dst;
private final String data;
public WinTestMessage(MessageType type, String src, String dst, String data) {
this.type = type;
this.src = src;
this.dst = dst;
this.data = data;
}
/**
* Serializes this message to bytes for UDP transmission.
* <p>
* Format: {@code MESSAGETYPE: "src" "dst" data{checksum}\0}
* <p>
* The '?' placeholder is replaced by the calculated checksum,
* followed by a NUL terminator.
* Degree signs (°) are escaped as \260 per Win-Test convention.
*/
public byte[] toBytes() {
String escapedData = data.replace("°", "\\260");
// Format: MESSAGETYPE: "src" "dst" data?\0
// The '?' is a placeholder for the checksum byte
String raw = type.name() + ": \"" + src + "\" \"" + dst + "\" " + escapedData + "?\0";
byte[] bytes = raw.getBytes(StandardCharsets.US_ASCII);
// Calculate checksum over everything before the checksum position (length - 2)
int sum = 0;
for (int i = 0; i < bytes.length - 2; i++) {
sum += (bytes[i] & 0xFF);
}
byte checksum = (byte) ((sum | 0x80) & 0xFF);
bytes[bytes.length - 2] = checksum;
return bytes;
}
// Getters for debugging/logging
public MessageType getType() { return type; }
public String getSrc() { return src; }
public String getDst() { return dst; }
public String getData() { return data; }
@Override
public String toString() {
return type.name() + ": src=" + src + " dst=" + dst + " data=" + data;
}
}
@@ -1,227 +0,0 @@
package kst4contest.controller;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.Enumeration;
/**
* Determines the broadcast address used to talk to the Win-Test network.
*
* <p>Win-Test only reacts to broadcast packets; a unicast request to the same
* station stays unanswered. The configured broadcast address is therefore the
* one setting that silently disables every outgoing Win-Test feature when it is
* wrong: sending to an address outside the local networks succeeds without an
* error and the packet is routed away.</p>
*
* <p>Incoming Win-Test packets carry the information that is actually needed.
* The source address of a received packet identifies the network the station
* lives in, so the broadcast address of the matching local interface reaches it
* reliably. The configured address remains the fallback and keeps working for a
* station behind a router, where no local interface matches.</p>
*/
public class WinTestNetworkAddressResolver {
/** Last resort when neither a station nor a usable setting is available. */
private static final String LIMITED_BROADCAST_ADDRESS = "255.255.255.255";
/**
* Resolves the local broadcast address for a remote address.
*/
@FunctionalInterface
public interface LocalBroadcastLookup {
/**
* @param remoteAddress address a Win-Test packet was received from
* @return broadcast address of the matching local interface, or
* {@code null} when no local interface serves that network
*/
InetAddress findBroadcastFor(InetAddress remoteAddress);
}
private final LocalBroadcastLookup localBroadcastLookup;
private volatile InetAddress lastStationAddress;
private volatile String lastReportedBroadcastAddress;
public WinTestNetworkAddressResolver() {
this(WinTestNetworkAddressResolver::findLocalBroadcastAddress);
}
/**
* @param localBroadcastLookup interface lookup, replaceable for tests
*/
WinTestNetworkAddressResolver(LocalBroadcastLookup localBroadcastLookup) {
this.localBroadcastLookup = localBroadcastLookup;
}
/**
* Remembers where Win-Test packets come from.
*
* <p>Only addresses of real Win-Test stations may be passed in. Loopback and
* wildcard addresses are ignored, so an internal control packet cannot
* redirect outgoing Win-Test traffic.</p>
*
* @param stationAddress source address of a received Win-Test packet
*/
public void rememberStationAddress(InetAddress stationAddress) {
if (stationAddress == null
|| stationAddress.isLoopbackAddress()
|| stationAddress.isAnyLocalAddress()
|| !(stationAddress instanceof Inet4Address)) {
return;
}
this.lastStationAddress = stationAddress;
}
/**
* Determines the broadcast address for outgoing Win-Test packets.
*
* <p>Order of preference: the broadcast address of the local interface that
* serves the last seen Win-Test station, then the configured address, then
* the limited broadcast address.</p>
*
* @param configuredBroadcastAddress address from the settings, may be blank
* @return address to send Win-Test packets to
* @throws UnknownHostException if the configured address cannot be resolved
* and the limited broadcast address fails too
*/
public InetAddress resolveBroadcastAddress(String configuredBroadcastAddress)
throws UnknownHostException {
InetAddress stationAddress = this.lastStationAddress;
if (stationAddress != null) {
InetAddress derivedBroadcastAddress =
localBroadcastLookup.findBroadcastFor(stationAddress);
if (derivedBroadcastAddress != null) {
reportDerivedAddress(derivedBroadcastAddress, configuredBroadcastAddress);
return derivedBroadcastAddress;
}
}
if (configuredBroadcastAddress != null && !configuredBroadcastAddress.isBlank()) {
return InetAddress.getByName(configuredBroadcastAddress.trim());
}
return InetAddress.getByName(LIMITED_BROADCAST_ADDRESS);
}
/**
* Logs a derived address once as long as it stays the same, and points out
* a configured address that does not match the Win-Test network.
*/
private void reportDerivedAddress(
InetAddress derivedBroadcastAddress,
String configuredBroadcastAddress
) {
String derivedHostAddress = derivedBroadcastAddress.getHostAddress();
if (derivedHostAddress.equals(lastReportedBroadcastAddress)) {
return;
}
lastReportedBroadcastAddress = derivedHostAddress;
String configuredHostAddress = configuredBroadcastAddress == null
? "" : configuredBroadcastAddress.trim();
if (derivedHostAddress.equals(configuredHostAddress)) {
return;
}
System.out.println("[WinTest] using broadcast address " + derivedHostAddress
+ " of the network Win-Test was heard on, configured is '"
+ configuredHostAddress + "'");
}
/**
* Searches the local interfaces for the network a remote address belongs to.
*
* @param remoteAddress address of a Win-Test station
* @return broadcast address of the matching interface, or {@code null}
*/
static InetAddress findLocalBroadcastAddress(InetAddress remoteAddress) {
if (!(remoteAddress instanceof Inet4Address)) {
return null;
}
try {
Enumeration<NetworkInterface> networkInterfaces =
NetworkInterface.getNetworkInterfaces();
while (networkInterfaces != null && networkInterfaces.hasMoreElements()) {
NetworkInterface networkInterface = networkInterfaces.nextElement();
for (InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) {
InetAddress broadcastAddress = interfaceAddress.getBroadcast();
if (broadcastAddress == null
|| !(interfaceAddress.getAddress() instanceof Inet4Address)) {
continue;
}
if (isInSameSubnet(
interfaceAddress.getAddress(),
remoteAddress,
interfaceAddress.getNetworkPrefixLength())) {
return broadcastAddress;
}
}
}
} catch (SocketException exception) {
System.out.println("[WinTest] could not inspect local interfaces: "
+ exception.getMessage());
}
return null;
}
/**
* Compares two IPv4 addresses up to the given network prefix length.
*
* @param localAddress address of a local interface
* @param remoteAddress address of the Win-Test station
* @param networkPrefixLength prefix length of the local interface
* @return {@code true} when both addresses share the same network
*/
static boolean isInSameSubnet(
InetAddress localAddress,
InetAddress remoteAddress,
int networkPrefixLength
) {
if (localAddress == null || remoteAddress == null) {
return false;
}
byte[] localBytes = localAddress.getAddress();
byte[] remoteBytes = remoteAddress.getAddress();
if (localBytes.length != remoteBytes.length
|| networkPrefixLength < 0
|| networkPrefixLength > localBytes.length * 8) {
return false;
}
int remainingPrefixBits = networkPrefixLength;
for (int byteIndex = 0; byteIndex < localBytes.length && remainingPrefixBits > 0; byteIndex++) {
int comparedBits = Math.min(8, remainingPrefixBits);
int mask = (0xFF << (8 - comparedBits)) & 0xFF;
if ((localBytes[byteIndex] & mask) != (remoteBytes[byteIndex] & mask)) {
return false;
}
remainingPrefixBits -= comparedBits;
}
return true;
}
}
@@ -1,238 +0,0 @@
package kst4contest.controller;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Win-Test network packet received over UDP.
*
* <p>This is the receiving counterpart of {@link WinTestMessage} and follows the
* same framing:</p>
*
* <pre>
* MESSAGETYPE: "src" "dst" data{checksum}\0
* </pre>
*
* <p>The checksum byte always has bit 7 set and is therefore not valid ASCII.
* Decoding the datagram as text before removing it turns the byte into a
* replacement character that sticks to the last data field. That is harmless
* for fields KST4Contest never reads, but the log synchronization needs exactly
* those trailing fields: the log ID of an {@code ADDQSO} packet and the
* run-length inventory of an {@code IHAVE} packet. The framing is therefore
* resolved on the raw bytes here, once, before any text parsing.</p>
*/
public final class WinTestPacket {
/** Quoted values stay one token, unquoted values are split at whitespace. */
private static final Pattern TOKEN_PATTERN = Pattern.compile("\"([^\"]*)\"|(\\S+)");
private final String messageType;
private final String source;
private final String destination;
private final String messageText;
private final List<String> dataTokens;
private final boolean checksumPresent;
private final boolean checksumValid;
private WinTestPacket(
String messageType,
String source,
String destination,
String messageText,
List<String> dataTokens,
boolean checksumPresent,
boolean checksumValid
) {
this.messageType = messageType;
this.source = source;
this.destination = destination;
this.messageText = messageText;
this.dataTokens = dataTokens;
this.checksumPresent = checksumPresent;
this.checksumValid = checksumValid;
}
/**
* Builds a packet from a received datagram.
*
* <p>Trailing NUL bytes are removed first. If the resulting last byte has
* bit 7 set it is the Win-Test checksum: it is verified against the sum of
* all preceding bytes and removed before the message text is decoded.</p>
*
* @param datagram raw datagram buffer
* @param length number of valid bytes in the buffer
* @return parsed packet, or {@code null} when the datagram carries no message
*/
public static WinTestPacket fromDatagram(byte[] datagram, int length) {
if (datagram == null || length <= 0 || length > datagram.length) {
return null;
}
int endIndex = length;
while (endIndex > 0 && datagram[endIndex - 1] == 0) {
endIndex--;
}
if (endIndex == 0) {
return null;
}
boolean hasChecksum = (datagram[endIndex - 1] & 0x80) != 0;
boolean isChecksumValid = false;
int textEndIndex = endIndex;
if (hasChecksum) {
int sum = 0;
for (int index = 0; index < endIndex - 1; index++) {
sum += datagram[index] & 0xFF;
}
byte expectedChecksum = (byte) ((sum | 0x80) & 0xFF);
isChecksumValid = expectedChecksum == datagram[endIndex - 1];
textEndIndex = endIndex - 1;
}
String text = new String(datagram, 0, textEndIndex, StandardCharsets.US_ASCII);
return fromMessageText(text, hasChecksum, isChecksumValid);
}
/**
* Builds a packet from an already decoded message text without checksum
* information. Used for messages that reach the listener as text.
*
* @param messageText complete message text
* @return parsed packet, or {@code null} for an unusable message
*/
public static WinTestPacket fromMessageText(String messageText) {
return fromMessageText(messageText, false, false);
}
private static WinTestPacket fromMessageText(
String rawMessageText,
boolean checksumPresent,
boolean checksumValid
) {
if (rawMessageText == null) {
return null;
}
String text = rawMessageText.trim();
int typeEndIndex = text.indexOf(": ");
if (typeEndIndex <= 0) {
return null;
}
String type = text.substring(0, typeEndIndex);
List<String> tokens = tokenize(text.substring(typeEndIndex + 2));
String packetSource = tokens.isEmpty() ? "" : tokens.get(0);
String packetDestination = tokens.size() > 1 ? tokens.get(1) : "";
List<String> data = tokens.size() > 2
? new ArrayList<>(tokens.subList(2, tokens.size()))
: new ArrayList<>();
return new WinTestPacket(
type,
packetSource,
packetDestination,
text,
data,
checksumPresent,
checksumValid
);
}
/**
* Splits Win-Test payload text into fields. Quoted values are kept together
* and empty quoted values are preserved, so field positions stay stable.
*
* @param text payload text
* @return field values without their surrounding quotes
*/
static List<String> tokenize(String text) {
List<String> tokens = new ArrayList<>();
if (text == null) {
return tokens;
}
Matcher matcher = TOKEN_PATTERN.matcher(text);
while (matcher.find()) {
tokens.add(matcher.group(1) != null ? matcher.group(1) : matcher.group(2));
}
return tokens;
}
/**
* @return message type such as {@code ADDQSO}, never {@code null}
*/
public String getMessageType() {
return messageType;
}
/**
* @return Win-Test station that sent the packet
*/
public String getSource() {
return source;
}
/**
* @return addressed Win-Test station, empty for a broadcast
*/
public String getDestination() {
return destination;
}
/**
* @return complete message text without checksum byte and NUL terminator
*/
public String getMessageText() {
return messageText;
}
/**
* @return payload fields following source and destination
*/
public List<String> getDataTokens() {
return Collections.unmodifiableList(dataTokens);
}
/**
* @param index payload field position
* @return field value, or {@code null} when the field is missing
*/
public String getDataToken(int index) {
return index >= 0 && index < dataTokens.size() ? dataTokens.get(index) : null;
}
public boolean isChecksumPresent() {
return checksumPresent;
}
public boolean isChecksumValid() {
return checksumValid;
}
/**
* Checks whether this packet is meant for us.
*
* @param ownStationName own Win-Test station name
* @return {@code true} for a broadcast or for a packet addressed to us
*/
public boolean isAddressedTo(String ownStationName) {
if (destination == null || destination.isEmpty()) {
return true;
}
return ownStationName != null && destination.equalsIgnoreCase(ownStationName.trim());
}
@Override
public String toString() {
return messageType + ": src=" + source + " dst=" + destination
+ " fields=" + dataTokens.size()
+ (checksumPresent ? (checksumValid ? " checksum=ok" : " checksum=bad") : " checksum=none");
}
}
@@ -1,221 +0,0 @@
package kst4contest.controller;
import kst4contest.model.Band;
import kst4contest.model.ContestSked;
import kst4contest.model.ThreadStateMessage;
import java.net.*;
import java.nio.charset.StandardCharsets;
/**
* Sends SKED entries to Win-Test via UDP broadcast.
* <p>
* Ported from the C# wtSked class in wtKST.
* <p>
* Win-Test expects a LOCKSKED / ADDSKED / UNLOCKSKED sequence
* to safely insert a new sked into its schedule window.
*/
public class WinTestSkedSender {
private final String stationName;
private final InetAddress broadcastAddress;
private final int port;
private final ThreadStatusCallback callback;
private static final String THREAD_NICKNAME = "WT-SkedSend";
/**
* @param stationName our station name in the Win-Test network (e.g. "KST4Contest")
* @param broadcastAddress UDP broadcast address (e.g. 255.255.255.255 or subnet broadcast)
* @param port Win-Test network port (default 9871)
* @param callback optional callback for status reporting (may be null)
*/
public WinTestSkedSender(String stationName, InetAddress broadcastAddress, int port,
ThreadStatusCallback callback) {
this.stationName = stationName;
this.broadcastAddress = broadcastAddress;
this.port = port;
this.callback = callback;
}
/**
* Pushes a ContestSked into Win-Test by sending the
* LOCKSKED / ADDSKED / UNLOCKSKED sequence.
*
* @param sked sked to push
* @param targetCallsign callsign prepared for Win-Test
* @param frequencyKHz operating frequency in kHz
* @param notes optional notes
* @param mode Win-Test mode ID: 0 for CW, 1 for SSB
*/
public void pushSkedToWinTest(ContestSked sked,
String targetCallsign,
double frequencyKHz,
String notes,
int mode) {
try {
sendLockSked();
sendAddSked(
sked,
targetCallsign,
frequencyKHz,
notes,
mode
);
sendUnlockSked();
reportStatus(
"Sked pushed to WT: " + targetCallsign,
false
);
System.out.println(
"[WinTestSkedSender] Sked pushed: "
+ targetCallsign
+ " at "
+ frequencyKHz
+ " kHz, band="
+ sked.getBand()
+ ", mode="
+ mode
);
} catch (Exception exception) {
reportStatus(
"ERROR pushing sked: "
+ exception.getMessage(),
true
);
System.out.println(
"[WinTestSkedSender] Error pushing sked: "
+ exception.getMessage()
);
exception.printStackTrace();
}
}
/**
* Sends a LOCKSKED message to lock the Win-Test sked window.
*/
private void sendLockSked() throws Exception {
WinTestMessage msg = new WinTestMessage(
WinTestMessage.MessageType.LOCKSKED,
stationName, "",
"\"" + stationName + "\"");
sendUdp(msg);
}
/**
* Sends an UNLOCKSKED message to unlock the Win-Test sked window.
*/
private void sendUnlockSked() throws Exception {
WinTestMessage msg = new WinTestMessage(
WinTestMessage.MessageType.UNLOCKSKED,
stationName, "",
"\"" + stationName + "\"");
sendUdp(msg);
}
/**
* Sends an ADDSKED message with the sked details.
*
* <p>The wtKST implementation subtracts a reference time of
* 1970-01-01 00:01:00 UTC and subsequently adds 60 seconds. Both
* operations cancel each other out. The transmitted value is therefore
* an ordinary Unix timestamp and must not receive another offset here.</p>
*/
private void sendAddSked(ContestSked sked,
String targetCallsign,
double frequencyKHz,
String notes,
int mode) throws Exception {
long wtTimestamp =
sked.getSkedTimeEpoch() / 1000L;
// Frequency in 0.1 kHz units.
long frequencyTenthKHz =
Math.round(frequencyKHz * 10.0);
int bandId =
toWinTestBandId(sked.getBand());
/*
* Accept only the mode IDs supported by this UI.
* Any unexpected value falls back to SSB.
*/
int winTestMode =
mode == 0
? 0
: 1;
String data =
wtTimestamp
+ " " + frequencyTenthKHz
+ " " + bandId
+ " " + winTestMode
+ " \"" + targetCallsign + "\""
+ " \"" + (notes != null ? notes : "") + "\"";
WinTestMessage message = new WinTestMessage(
WinTestMessage.MessageType.ADDSKED,
stationName,
"",
data
);
sendUdp(message);
}
/**
* Sends a WinTestMessage via UDP broadcast.
*/
private void sendUdp(WinTestMessage msg) throws Exception {
try (DatagramSocket socket = new DatagramSocket()) {
socket.setBroadcast(true);
socket.setReuseAddress(true);
byte[] bytes = msg.toBytes();
DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcastAddress, port);
socket.send(packet);
System.out.println("[WinTestSkedSender] sent: " + msg);
}
}
/**
* Maps the kst4contest Band enum to Win-Test band IDs.
* <p>
* Win-Test band IDs (reverse-engineered from wtKST):
* 10=50MHz, 11=70MHz, 12=144MHz, 14=432MHz, 16=1.2GHz,
* 17=2.3GHz, 18=3.4GHz, 19=5.7GHz, 20=10GHz, 21=24GHz,
* 22=47GHz, 23=76GHz
*/
public static int toWinTestBandId(Band band) {
if (band == null) return 12; // default to 144 MHz
return switch (band) {
case B_50 -> 10;
case B_70 -> 11;
case B_144 -> 12;
case B_432 -> 14;
case B_1296 -> 16;
case B_2320 -> 17;
case B_3400 -> 18;
case B_5760 -> 19;
case B_10G -> 20;
case B_24G -> 21;
};
}
private void reportStatus(String text, boolean isError) {
if (callback != null) {
callback.onThreadStatus(THREAD_NICKNAME,
new ThreadStateMessage(THREAD_NICKNAME, !isError, text, isError));
}
}
}
@@ -1,202 +0,0 @@
package kst4contest.controller;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import java.util.Collection;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Runtime cache for worked Maidenhead gross fields per band.
*
* <p>The cache is used by the "new locator" station filter. It is kept in memory
* for fast UI predicates and rebuilt from SQLite on startup/refresh. New QSO log
* packets update both SQLite and this cache immediately.</p>
*/
public final class WorkedGrossFieldCache {
private static final Pattern MAIDENHEAD_6_PATTERN =
Pattern.compile("(?i)([A-R]{2}[0-9]{2}[A-X]{2})");
private final Map<Band, Set<String>> workedGrossFieldsByBand = new EnumMap<>(Band.class);
/**
* Replaces the full cache content with data read from the database.
*
* @param databaseSnapshot map of band to worked gross fields
*/
public synchronized void rebuildFromDatabaseSnapshot(Map<Band, Set<String>> databaseSnapshot) {
workedGrossFieldsByBand.clear();
if (databaseSnapshot == null) {
return;
}
for (Map.Entry<Band, Set<String>> entry : databaseSnapshot.entrySet()) {
Band band = entry.getKey();
Set<String> grossFields = entry.getValue();
if (band == null || grossFields == null) {
continue;
}
Set<String> normalizedGrossFields = workedGrossFieldsByBand.computeIfAbsent(band, ignored -> new HashSet<>());
for (String grossField : grossFields) {
String normalizedGrossField = normalizeGrossField(grossField);
if (normalizedGrossField != null) {
normalizedGrossFields.add(normalizedGrossField);
}
}
}
}
/**
* Adds one worked locator to the cache.
*
* @param band worked band
* @param locatorOrGrossField six-character locator or four-character gross field
*/
public synchronized void addWorked(Band band, String locatorOrGrossField) {
String grossField = extractGrossField(locatorOrGrossField);
if (band == null || grossField == null) {
return;
}
workedGrossFieldsByBand
.computeIfAbsent(band, ignored -> new HashSet<>())
.add(grossField);
}
/**
* Adds all worked-band flags from stored ChatMember rows. This is only a fallback
* for legacy data before WorkedGrossField existed or when logger packets did not
* provide a locator.
*
* @param storedMembers stored ChatMember rows
*/
public synchronized void addWorkedBandsFromStoredChatMembers(Collection<ChatMember> storedMembers) {
if (storedMembers == null) {
return;
}
for (ChatMember member : storedMembers) {
if (member == null) {
continue;
}
String locator = member.getQra();
if (member.isWorked144()) addWorked(Band.B_144, locator);
if (member.isWorked432()) addWorked(Band.B_432, locator);
if (member.isWorked1240()) addWorked(Band.B_1296, locator);
if (member.isWorked2300()) addWorked(Band.B_2320, locator);
if (member.isWorked3400()) addWorked(Band.B_3400, locator);
if (member.isWorked5600()) addWorked(Band.B_5760, locator);
if (member.isWorked10G()) addWorked(Band.B_10G, locator);
if (member.isWorked50()) addWorked(Band.B_50, locator);
if (member.isWorked70()) addWorked(Band.B_70, locator);
}
}
/**
* Checks whether a locator gross field is already worked on a band.
*
* @param band band to check
* @param locatorOrGrossField six-character locator or four-character gross field
* @return true if the gross field is already worked on that band
*/
public synchronized boolean isGrossFieldWorked(Band band, String locatorOrGrossField) {
String grossField = extractGrossField(locatorOrGrossField);
if (band == null || grossField == null) {
return false;
}
return workedGrossFieldsByBand
.getOrDefault(band, Set.of())
.contains(grossField);
}
/**
* Checks whether a locator gross field has already been worked on any band.
*
* <p>The UI grid status and the "Only new grids" filter use this any-band logic.
* This avoids user errors caused by wrong active-band settings and fits single
* band contests such as NAC better.</p>
*
* @param locatorOrGrossField six-character locator or four-character gross field
* @return true if the gross field exists in the cache on any band
*/
public synchronized boolean isGrossFieldWorkedAny(String locatorOrGrossField) {
String grossField = extractGrossField(locatorOrGrossField);
if (grossField == null) {
return false;
}
for (Set<String> workedGrossFields : workedGrossFieldsByBand.values()) {
if (workedGrossFields != null && workedGrossFields.contains(grossField)) {
return true;
}
}
return false;
}
/**
* Tries to normalize a locator. Accepts a plain six-character locator or extracts
* one from exchange strings such as "001JO41HK".
*
* @param rawLocatorOrExchange raw locator/exchange text
* @return normalized six-character locator, or null if none was found
*/
public static String extractLocator6(String rawLocatorOrExchange) {
if (rawLocatorOrExchange == null || rawLocatorOrExchange.isBlank()) {
return null;
}
Matcher matcher = MAIDENHEAD_6_PATTERN.matcher(rawLocatorOrExchange.trim());
if (!matcher.find()) {
return null;
}
return matcher.group(1).toUpperCase(Locale.ROOT);
}
/**
* Extracts and normalizes the four-character gross field.
*
* @param rawLocatorOrGrossField six-character locator, four-character gross field or exchange text
* @return normalized gross field, or null if no valid value is available
*/
public static String extractGrossField(String rawLocatorOrGrossField) {
if (rawLocatorOrGrossField == null || rawLocatorOrGrossField.isBlank()) {
return null;
}
String trimmed = rawLocatorOrGrossField.trim().toUpperCase(Locale.ROOT);
if (trimmed.matches("[A-R]{2}[0-9]{2}")) {
return trimmed;
}
String locator6 = extractLocator6(trimmed);
if (locator6 == null || locator6.length() < 4) {
return null;
}
return locator6.substring(0, 4);
}
private static String normalizeGrossField(String grossField) {
if (grossField == null) {
return null;
}
String normalized = grossField.trim().toUpperCase(Locale.ROOT);
return normalized.matches("[A-R]{2}[0-9]{2}") ? normalized : null;
}
}
@@ -1,195 +0,0 @@
package kst4contest.controller;
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;
/**
* 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 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 MessageHistoryRecorder historyRecorder;
private final BufferedWriter writer;
private final int defaultCategory;
/**
* Compatibility constructor for the pre-session controller path.
*
* @deprecated new connections should be created by
* {@link On4KstConnectionManager}
*/
@Deprecated
public WriteThread(Socket socket, ChatController client) throws IOException {
this(0L, socket, client.getMessageTXBus(),
client.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber(),
ignored -> true,
ignored -> { },
message -> LOGGER.log(Level.WARNING, message),
client.getMessageHistoryRecorder());
}
/**
* 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 WriteThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> transmitQueue,
int defaultCategory,
LongPredicate sessionIsActive,
Consumer<Throwable> connectionFailure,
Consumer<String> rejectedFrame
) throws IOException {
this(sessionId, socket, transmitQueue, defaultCategory,
sessionIsActive, connectionFailure, rejectedFrame,
MessageHistoryRecorder.disabled());
}
/** Creates a writer that records each frame after a successful flush. */
public WriteThread(
long sessionId,
Socket socket,
LinkedBlockingQueue<ChatMessage> transmitQueue,
int defaultCategory,
LongPredicate sessionIsActive,
Consumer<Throwable> connectionFailure,
Consumer<String> rejectedFrame,
MessageHistoryRecorder historyRecorder
) throws IOException {
this.sessionId = sessionId;
this.socket = socket;
this.transmitQueue = transmitQueue;
this.defaultCategory = defaultCategory;
this.sessionIsActive = sessionIsActive;
this.connectionFailure = connectionFailure;
this.rejectedFrame = rejectedFrame;
this.historyRecorder = historyRecorder == null
? MessageHistoryRecorder.disabled() : historyRecorder;
this.writer = new BufferedWriter(new OutputStreamWriter(
socket.getOutputStream(), StandardCharsets.UTF_8));
}
@Override
public void run() {
Thread.currentThread().setName("WriteToOn4Kst-" + sessionId);
LOGGER.log(Level.FINE,
"ON4KST writer started for session {0}", sessionId);
try {
while (!isInterrupted() && sessionIsActive.test(sessionId)) {
ChatMessage message = transmitQueue.take();
if (isPoisonPill(message)) {
break;
}
if (!sessionIsActive.test(sessionId)) {
break;
}
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());
}
}
} 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);
}
}
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();
historyRecorder.recordTx(frame);
}
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;
}
}
@@ -1,13 +0,0 @@
package kst4contest.controller.interfaces;
public interface PstRotatorEventListener {
void onAzimuthUpdate(double azimuth);
void onElevationUpdate(double elevation);
void onModeUpdate(boolean isTracking); // true = Tracking, false = Manual
void onMessageReceived(String rawMessage); // Debugging usage
// void setRotorPosition(double azimuth);
}
@@ -1,44 +0,0 @@
package kst4contest.controller;
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;
public keepAliveMessageSenderTask(ChatController client) {
this.client = client;
}
@Override
public void run() {
Thread.currentThread().setName("KeepAliveMessageSenderTask");
// System.out.println("[keepalive: ] Thread runned now");
ChatMessage keepAliveMSG = new ChatMessage();
// 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: "
// + keepAliveMSG.getMessageText());
/**
* Sending keepalive
*/
this.client.getMessageTXBus().add(keepAliveMSG);
}
}
@@ -1,140 +0,0 @@
package kst4contest.locatorUtils;
import java.math.BigDecimal;
import java.math.MathContext;
/**
* Allow for easy use of angles without messy conversions to and from degrees
* and radians.
*
* @author Paul Picazo <ppicazo@gmail.com>
*
*/
public class Angle {
/**
* pi.
*/
private static final BigDecimal BIG_PI = new BigDecimal(Math.PI);
/**
* representation of the angle in radians.
*/
protected BigDecimal radians;
/**
* no argument constructor.
*
*/
public Angle() {
}
/**
* @param radiansIn
* angle in radians
*/
public Angle(final double radiansIn) {
this.radians = new BigDecimal(radiansIn);
}
@Override
public int hashCode() {
final int prime = 7;
if (radians == null) {
return prime;
}
return prime + radians.hashCode();
}
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (!(obj instanceof Angle)) {
return false;
}
return ((Angle) obj).hashCode() == this.hashCode();
}
/**
* @param degrees
* angle in degrees
* @return angle
*/
public static Angle fromDegrees(final double degrees) {
Angle angle = new Angle();
angle.radians = degreesToRadians(new BigDecimal(degrees),
MathContext.DECIMAL128);
return angle;
}
/**
* @param radiansIn
* the radians to set
*/
public void setRadians(final double radiansIn) {
this.radians = new BigDecimal(radiansIn);
}
/**
* @return the radians
*/
public double getRadians() {
return radians.doubleValue();
}
/**
* @return degrees
*/
public double toDegrees() {
return radiansToDegrees(this.radians, MathContext.DECIMAL128).doubleValue();
}
/**
* @param radians
* input
* @return degrees
*/
public static double radiansToDegrees(final double radians) {
return radiansToDegrees(new BigDecimal(radians), MathContext.DECIMAL128)
.doubleValue();
}
/**
* @param degrees
* input
* @return radians
*/
public static double degreesToRadians(final double degrees) {
return degreesToRadians(new BigDecimal(degrees), MathContext.DECIMAL128)
.doubleValue();
}
/**
* @param radians
* input
* @param mc
* MathContext for division
* @return degrees
*/
protected static BigDecimal radiansToDegrees(final BigDecimal radians,
final MathContext mc) {
return radians.multiply(new BigDecimal(180)).divide(BIG_PI, mc);
}
/**
* @param degrees
* input
* @param mc
* MathContext for division
* @return radians
*/
protected static BigDecimal degreesToRadians(final BigDecimal degrees,
final MathContext mc) {
return degrees.divide(new BigDecimal(180), mc).multiply(BIG_PI);
}
}
@@ -1,109 +0,0 @@
package kst4contest.locatorUtils;
public class DirectionUtils {
/**
* Checks wheter a sked-sender writes to a sked-receiver and is in my direction due he beams to this receiver
*
* @param myLocator
* @param locatorOfSkedSender
* @param locatorOfSekdReceiver
* @param maxRangeKm
* @param hisAntennaBeamWidth
* @return
*/
public static boolean isInAngleAndRange(String myLocator, String locatorOfSkedSender, String locatorOfSekdReceiver, double maxRangeKm, double hisAntennaBeamWidth) {
Location myLocation = new Location(myLocator);
Location skedSenderLocation = new Location(locatorOfSkedSender);
Location skedReceiverLocation = new Location(locatorOfSekdReceiver);
double distanceFromMeToLocSender = new Location(myLocator).getDistanceKm(new Location(locatorOfSkedSender));
// Check if distance exceeds my setted maximum range
if (distanceFromMeToLocSender > maxRangeKm) {
return false;
}
//check bearing of sender to receiver
double bearingOfSekdSenderToSkedReceiver = skedSenderLocation.getBearing(skedReceiverLocation);
// System.out.println("skedTX -> skedTX deg: " + bearingOfSekdSenderToSkedReceiver);
double bearingOfSekdSenderToMe = skedSenderLocation.getBearing(myLocation);
// System.out.println("skedTX -> me deg: " + bearingOfSekdSenderToMe);
/**
* simple mech works
*/
// if (bearingOfSekdSenderToMe >= bearingOfSekdSenderToSkedReceiver) {
// if (bearingOfSekdSenderToMe-bearingOfSekdSenderToSkedReceiver <= hisAntennaBeamWidth/2){
// System.out.println(bearingOfSekdSenderToMe-bearingOfSekdSenderToSkedReceiver + " <= " + hisAntennaBeamWidth);
// return true;
// }
// } else if ((bearingOfSekdSenderToMe <= bearingOfSekdSenderToSkedReceiver)) {
// if (bearingOfSekdSenderToSkedReceiver-bearingOfSekdSenderToMe <= hisAntennaBeamWidth/2){
// return true;
// }
// } else return false;
/**
* simple mech end
*/
if (DirectionUtils.isAngleInRange(bearingOfSekdSenderToSkedReceiver, bearingOfSekdSenderToMe, hisAntennaBeamWidth)) {
//I may should get "/2" because of 50% of the 3dB opening angle if txer is directed to sender exactly
// System.out.println("------------> isinangleandrange!");
return true;
} else {
// System.out.println("not in angle and reach");
return false;
}
}
/**
* Tests, if the angle (from me to) other station is in the range of the
* angle (qtf) in degrees where my antenna points to.
*
* @param toForeignAngle [degrees]
* @param mySelectedQTFAngle [degrees]
* @param antennaBeamwidth [degrees]
* @return
*/
public static boolean isAngleInRange(double toForeignAngle,
double mySelectedQTFAngle, double antennaBeamwidth) {
double beamwidth = antennaBeamwidth / 2; // half left, half right
double startAngle = mySelectedQTFAngle - beamwidth;
double endAngle = mySelectedQTFAngle + beamwidth;
// Normalize angles to be between 0 and 360 degrees
toForeignAngle = normalizeAngle(toForeignAngle);
startAngle = normalizeAngle(startAngle);
endAngle = normalizeAngle(endAngle);
// Check if the range wraps around 360 degrees
if (startAngle <= endAngle) {
return toForeignAngle >= startAngle && toForeignAngle <= endAngle;
} else {
// Range wraps around 360 degrees, so check if angle is within the
// range or outside the range
return toForeignAngle >= startAngle || toForeignAngle <= endAngle;
}
}
private static double normalizeAngle(double angle) {
if (angle < 0) {
angle += 360;
}
if (angle >= 360) {
angle -= 360;
}
return angle;
}
}
@@ -1,48 +0,0 @@
package kst4contest.locatorUtils;
import java.math.BigDecimal;
import java.math.MathContext;
/**
* Latitude.
*
* @author Paul Picazo <ppicazo@gmail.com>
*
*/
public class Latitude extends Angle {
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (!(obj.getClass() == this.getClass())) {
return false;
}
return ((Latitude) obj).hashCode() == this.hashCode();
}
@Override
public int hashCode() {
final int prime = 37;
if (radians == null) {
return prime;
}
return prime + radians.hashCode();
}
/**
* @param degrees input
* @return latitude object
*/
public static Latitude fromDegrees(final double degrees) {
Latitude latitude = new Latitude();
latitude.radians = Angle.degreesToRadians(new BigDecimal(degrees),
MathContext.DECIMAL128);
return latitude;
}
}
@@ -1,359 +0,0 @@
package kst4contest.locatorUtils;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Locale;
/**
* Location class with methods allowing conversion to and from Maidenhead
* locator (grid squares) based off of
* "Conversion Between Geodetic and Grid Locator Systems" by Edmund T. Tyson,
* N5JTY in QST January 1989, pp. 29-30, 43
*
* @author Paul Picazo <ppicazo@gmail.com>
*
*/
public class Location {
/**
* Average earth radius in kilometers, IUGG definition.
*/
private static final double AVG_EARTH_RADIUS_KM = 6371.009;
/**
* Average earth radius in statute miles, IUGG definition.
*/
private static final double AVG_EARTH_RADIUS_SM = 3958.761;
/**
* Average earth radius in nautical miles, IUGG definition.
*/
private static final double AVG_EARTH_RADIUS_NM = 3440.069;
/**
* latitude of location.
*/
private Latitude latitude;
/**
* longitude of location.
*/
private Longitude longitude;
/**
* No argument constructor.
*
*/
public Location() {
this.latitude = new Latitude();
this.longitude = new Longitude();
}
/**
* @param latitudeIn
* initial latitude
* @param longitudeIn
* initial longitude
*/
public Location(final Latitude latitudeIn, final Longitude longitudeIn) {
this.latitude = latitudeIn;
this.longitude = longitudeIn;
}
/**
* @param latitudeIn
* initial latitude
* @param longitudeIn
* initial longitude
*/
public Location(final double latitudeIn, final double longitudeIn) {
this.latitude = Latitude.fromDegrees(latitudeIn);
this.longitude = Longitude.fromDegrees(longitudeIn);
}
/**
* @param maidenhead
* used construct location from maidenhead locator string
*/
public Location(final String maidenhead) {
this.latitude = extractLat(maidenhead);
this.longitude = extractLon(maidenhead);
}
@Override
public boolean equals(final Object obj) {
if (obj instanceof Location) {
return ((Location) obj).hashCode() == this.hashCode();
}
return false;
}
@Override
public int hashCode() {
int hash = 1;
hash = hash * 17 + this.latitude.hashCode();
hash = hash * 31 + this.longitude.hashCode();
return hash;
}
/**
* @return maidenhead locator string
*/
public String toMaidenhead() {
return toMaidenhead(this.latitude.toDegrees(),
this.longitude.toDegrees());
}
/**
* @param latitudeIn
* latitude component of locator string
* @param longitudeIn
* longitude component of locator string
* @return maidenhead locator string
*/
public static String toMaidenhead(final double latitudeIn,
final double longitudeIn) {
double longitude = longitudeIn + 180;
longitude /= 2;
char lonFirst = (char) ('A' + (longitude / 10));
char lonSecond = (char) ('0' + longitude % 10);
char lonThird = (char) ('A' + (longitude % 1) * 24);
double latitude = latitudeIn + 90;
char latFirst = (char) ('A' + (latitude / 10));
char latSecond = (char) ('0' + latitude % 10);
char latThird = (char) ('A' + (latitude % 1) * 24);
StringBuilder sb = new StringBuilder();
sb.append(lonFirst);
sb.append(latFirst);
sb.append(lonSecond);
sb.append(latSecond);
sb.append(("" + lonThird).toLowerCase());
sb.append(("" + latThird).toLowerCase());
return sb.toString();
}
/**
* @param maidenheadIn
* locator string to be converted
* @return latitude
*/
public static Latitude extractLat(final String maidenheadIn) {
String maidenhead = maidenheadIn.toUpperCase();
double latitude = -90 + 10 * (maidenhead.charAt(1) - 'A')
+ (maidenhead.charAt(3) - '0') + 2.5 / 60
* (maidenhead.charAt(5) - 'A') + 2.5 / 60 / 2;
return Latitude.fromDegrees(latitude);
}
/**
* @param maidenheadIn
* locator string to be converted
* @return longitude
*/
public static Longitude extractLon(final String maidenheadIn) {
String maidenhead = maidenheadIn.toUpperCase();
double longitude = -180 + 20 * (maidenhead.charAt(0) - 'A') + 2
* (maidenhead.charAt(2) - '0') + 5.0 / 60
* (maidenhead.charAt(4) - 'A') + 5.0 / 60 / 2;
return Longitude.fromDegrees(longitude);
}
/**
* @return latitude
*/
public Latitude getLatitude() {
return latitude;
}
/**
* @return longitude
*/
public Longitude getLongitude() {
return longitude;
}
/**
* @param latitudeIn
* north/south component of location
*/
public void setLatitude(final Latitude latitudeIn) {
this.latitude = latitudeIn;
}
/**
* @param longitudeIn
* east/west component of location
*/
public void setLongitude(final Longitude longitudeIn) {
this.longitude = longitudeIn;
}
/**
* @param loc2
* second location
* @return great circle distance in miles
*/
public double getDistanceMi(final Location loc2) {
return getDistanceMi(this, loc2);
}
/**
* @param loc2
* second location
* @return great circle distance in kilometers
*/
public double getDistanceKm(final Location loc2) {
return getDistanceKm(this, loc2);
}
/**
* @param locator1 6 letter location string
* @param locator2 6 letter location string
* @return great circle distance in kilometers
*/
public double getDistanceKmByTwoLocatorStrings(String locator1,String locator2 ) {
Location loc1 = new Location(locator1);
Location loc2 = new Location(locator2);
Locale locale = new Locale("en", "UK");
String pattern = "###.##";
DecimalFormat decimalFormat = (DecimalFormat)
NumberFormat.getNumberInstance(locale);
decimalFormat.applyPattern(pattern);
String format = decimalFormat.format(loc1.getDistanceKm(loc2));
return Double.parseDouble(format);
}
/**
* @param loc2
* second location
* @return great circle distance in nautical miles
*/
public double getDistanceNm(final Location loc2) {
return getDistanceNm(this, loc2);
}
/**
* @param loc1
* first location
* @param loc2
* second location
* @return great circle distance in miles
*/
public static double getDistanceMi(final Location loc1, final Location loc2) {
return getDistance(loc1, loc2, AVG_EARTH_RADIUS_SM);
}
/**
* @param loc1
* first location
* @param loc2
* second location
* @return great circle distance in kilometers
*/
private static double getDistanceKm(final Location loc1, final Location loc2) {
return getDistance(loc1, loc2, AVG_EARTH_RADIUS_KM);
}
/**
* @param loc1
* first location
* @param loc2
* second location
* @return great circle distance in nautical miles
*/
private static double getDistanceNm(final Location loc1, final Location loc2) {
return getDistance(loc1, loc2, AVG_EARTH_RADIUS_NM);
}
/**
* @param loc1
* first location
* @param loc2
* second location
* @param radius
* radius of the earth in the units desired for result
* @return great circle distance between the two locations, result units same
* of the radius units
*/
private static double getDistance(final Location loc1, final Location loc2,
final double radius) {
if (loc1.equals(loc2)) {
return 0;
}
return Math.acos(Math.sin(loc1.getLatitude().getRadians())
* Math.sin(loc2.getLatitude().getRadians())
+ Math.cos(loc1.getLatitude().getRadians())
* Math.cos(loc2.getLatitude().getRadians())
* Math.cos(loc2.getLongitude().getRadians()
- loc1.getLongitude().getRadians()))
* radius;
}
/**
* @param loc2
* destination location
* @return bearing in degrees
*/
public double getBearing(final Location loc2) {
return getBearing(this, loc2);
}
/**
*
* @return bearing in degrees
*/
public double getBearingOfTwoLocatorStrings(String locator1, String locator2) {
Location loc1 = new Location(locator1);
Location loc2 = new Location(locator2);
return getBearing(loc1, loc2);
}
/**
* @param loc1
* source location
* @param loc2
* destination location
* @return bearing in degrees
*/
public static double getBearing(final Location loc1, final Location loc2) {
if (loc1.equals(loc2)) {
return Double.NaN;
}
double dLon = loc2.getLongitude().getRadians()
- loc1.getLongitude().getRadians();
double y = Math.sin(dLon)
* Math.cos(loc2.getLatitude().getRadians());
double x = Math.cos(loc1.getLatitude().getRadians())
* Math.sin(loc2.getLatitude().getRadians())
- Math.sin(loc1.getLatitude().getRadians())
* Math.cos(loc2.getLatitude().getRadians())
* Math.cos(dLon);
double bearing = (Angle.radiansToDegrees(Math.atan2(y, x)) + 360) % 360;
// return bearing;
Locale locale = new Locale("en", "UK");
String pattern = "###.##";
DecimalFormat decimalFormat = (DecimalFormat)
NumberFormat.getNumberInstance(locale);
decimalFormat.applyPattern(pattern);
String format = decimalFormat.format(bearing);
return Double.parseDouble(format);
}
}
@@ -1,49 +0,0 @@
package kst4contest.locatorUtils;
import java.math.BigDecimal;
import java.math.MathContext;
/**
* Longitude.
*
* @author Paul Picazo <ppicazo@gmail.com>
*
*/
public class Longitude extends Angle {
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (!(obj.getClass() == this.getClass())) {
return false;
}
return ((Longitude) obj).hashCode() == this.hashCode();
}
@Override
public int hashCode() {
final int prime = 29;
if (radians == null) {
return prime;
}
return prime + radians.hashCode();
}
/**
* @param degrees input
* @return longitude object
*/
public static Longitude fromDegrees(final double degrees) {
Longitude longitude = new Longitude();
longitude.radians = Angle.degreesToRadians(new BigDecimal(degrees),
MathContext.DECIMAL128);
return longitude;
}
}
@@ -1,43 +0,0 @@
package kst4contest.locatorUtils;
public class TestLocatorUtils {
public static void main(String[] args) {
// isInAngle(myLocation, location1, location2);
System.out.println(isInAngleAndRange("JN49FL", "jo43xm", "jo30sa", 900, 50));
System.out.println(isInAngleAndRange("JN49FL", "jo51ij", "jn39oc", 900, 50));
System.out.println(isInAngleAndRange("JN49FL", "jn39oc", "jo51ij", 1100, 50));
}
public static boolean isInAngleAndRange(String myLocator, String locatorOfSkedSender, String locatorOfSekdReceiver, double maxRangeKm, double hisAntennaBeamWidth) {
Location myLocation = new Location(myLocator);
Location skedSenderLocation = new Location(locatorOfSkedSender);
Location skedReceiverLocation = new Location(locatorOfSekdReceiver);
double distanceFromMeToLocSender = new Location(myLocator).getDistanceKm(new Location(locatorOfSkedSender));
// Check if distance exceeds my setted maximum range
if (distanceFromMeToLocSender > maxRangeKm) {
System.out.println("too far, " + distanceFromMeToLocSender + " km");
return false;
}
//check bearing of sender to receiver
double bearingOfSekdSenderToSkedReceiver = skedSenderLocation.getBearing(skedReceiverLocation);
System.out.println("skedTX -> skedRX deg: " + bearingOfSekdSenderToSkedReceiver);
double bearingOfSekdSenderToMe = skedSenderLocation.getBearing(myLocation);
System.out.println("skedTX -> me deg: " + bearingOfSekdSenderToMe);
if (DirectionUtils.isAngleInRange(bearingOfSekdSenderToSkedReceiver, bearingOfSekdSenderToMe, hisAntennaBeamWidth)) {
//may I should get "/2" because of 50% of the 3dB opening angle if txer is directed to sender exactly
return true;
} else return false;
}
}
@@ -1,284 +0,0 @@
package kst4contest.logic;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Resolves band availability and band-upgrade opportunities from one or more
* active {@link ChatMember} variants of the same base callsign.
*
* <p>The resolver deliberately separates a band hint from an exact frequency:
* {@code knownActiveBands} remains the source for detected QRGs with timestamps,
* while the station name may add a band without inventing a frequency.</p>
*
* <p>A manual NOT-QRV flag always overrides automatic evidence. Worked flags are
* evaluated separately because an offered band may still be useful for display,
* even when it is no longer a band-upgrade opportunity.</p>
*/
public final class BandOpportunityResolver {
public static final long RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS = 30L * 60L * 1000L;
private static final Map<Band, Pattern> STATION_NAME_BAND_PATTERNS = createStationNameBandPatterns();
private BandOpportunityResolver() {
}
/**
* Resolves the common band state using the application-wide 30-minute window
* for frequency evidence. Name-derived band hints remain valid while the
* ChatMember is present in the active chat model.
*/
public static Resolution resolve(Collection<ChatMember> variants, long nowEpochMs) {
return resolve(variants, nowEpochMs, RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS);
}
/**
* Resolves offered, worked and manually excluded bands across all supplied
* category/callsign variants.
*/
public static Resolution resolve(Collection<ChatMember> variants,
long nowEpochMs,
long dynamicEvidenceMaxAgeMs) {
EnumSet<Band> offeredBands = EnumSet.noneOf(Band.class);
EnumSet<Band> workedBands = EnumSet.noneOf(Band.class);
EnumSet<Band> notQrvBands = EnumSet.noneOf(Band.class);
if (variants == null) {
return new Resolution(offeredBands, workedBands, notQrvBands);
}
for (ChatMember member : variants) {
if (member == null) {
continue;
}
collectRecentFrequencyBands(
member,
offeredBands,
nowEpochMs,
dynamicEvidenceMaxAgeMs
);
offeredBands.addAll(detectBandsFromStationName(member.getName()));
collectWorkedBands(member, workedBands);
collectNotQrvBands(member, notQrvBands);
}
return new Resolution(offeredBands, workedBands, notQrvBands);
}
/**
* Returns the bands enabled in the local station setup. Bands above 10 GHz
* remain excluded because the current preferences do not provide active-band
* flags for them.
*/
public static EnumSet<Band> getEnabledStationBands(ChatPreferences preferences) {
EnumSet<Band> enabledBands = EnumSet.noneOf(Band.class);
if (preferences == null) {
return enabledBands;
}
if (preferences.isStn_bandActive50()) enabledBands.add(Band.B_50);
if (preferences.isStn_bandActive70()) enabledBands.add(Band.B_70);
if (preferences.isStn_bandActive144()) enabledBands.add(Band.B_144);
if (preferences.isStn_bandActive432()) enabledBands.add(Band.B_432);
if (preferences.isStn_bandActive1240()) enabledBands.add(Band.B_1296);
if (preferences.isStn_bandActive2300()) enabledBands.add(Band.B_2320);
if (preferences.isStn_bandActive3400()) enabledBands.add(Band.B_3400);
if (preferences.isStn_bandActive5600()) enabledBands.add(Band.B_5760);
if (preferences.isStn_bandActive10G()) enabledBands.add(Band.B_10G);
return enabledBands;
}
/**
* Detects explicit amateur-band designators in a station name.
*
* <p>The boundary rules are intentionally stricter than simple token splitting.
* In particular, the trailing {@code 2} in {@code 1.2 cm} must not be mistaken
* for the 2 m band.</p>
*/
public static EnumSet<Band> detectBandsFromStationName(String stationName) {
EnumSet<Band> detectedBands = EnumSet.noneOf(Band.class);
if (stationName == null || stationName.isBlank()) {
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;
}
private static void collectRecentFrequencyBands(ChatMember member,
EnumSet<Band> target,
long nowEpochMs,
long dynamicEvidenceMaxAgeMs) {
if (member.getKnownActiveBands() == null || member.getKnownActiveBands().isEmpty()) {
return;
}
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> entry
: member.getKnownActiveBands().entrySet()) {
Band band = entry.getKey();
ChatMember.ActiveFrequencyInfo info = entry.getValue();
if (band == null || info == null) {
continue;
}
long ageMs = nowEpochMs - info.timestampEpoch;
boolean ageAccepted = dynamicEvidenceMaxAgeMs <= 0L
? ageMs >= 0L
: ageMs >= 0L && ageMs <= dynamicEvidenceMaxAgeMs;
if (ageAccepted) {
target.add(band);
}
}
}
private static void collectWorkedBands(ChatMember member, EnumSet<Band> target) {
if (member.isWorked50()) target.add(Band.B_50);
if (member.isWorked70()) target.add(Band.B_70);
if (member.isWorked144()) target.add(Band.B_144);
if (member.isWorked432()) target.add(Band.B_432);
if (member.isWorked1240()) target.add(Band.B_1296);
if (member.isWorked2300()) target.add(Band.B_2320);
if (member.isWorked3400()) target.add(Band.B_3400);
if (member.isWorked5600()) target.add(Band.B_5760);
if (member.isWorked10G()) target.add(Band.B_10G);
if (member.isWorked24G()) target.add(Band.B_24G);
}
private static void collectNotQrvBands(ChatMember member, EnumSet<Band> target) {
if (!member.isQrv50()) target.add(Band.B_50);
if (!member.isQrv70()) target.add(Band.B_70);
if (!member.isQrv144()) target.add(Band.B_144);
if (!member.isQrv432()) target.add(Band.B_432);
if (!member.isQrv1240()) target.add(Band.B_1296);
if (!member.isQrv2300()) target.add(Band.B_2320);
if (!member.isQrv3400()) target.add(Band.B_3400);
if (!member.isQrv5600()) target.add(Band.B_5760);
if (!member.isQrv10G()) target.add(Band.B_10G);
// There is currently no persisted NOT-QRV flag for 24 GHz.
}
private static Map<Band, Pattern> createStationNameBandPatterns() {
Map<Band, Pattern> patterns = new EnumMap<>(Band.class);
// Bare "70" and bare "6" are already claimed by the 70cm/6cm shorthand below
// (their "CM" suffix is optional), so 4m/6m must require an explicit MHz/"M"
// suffix here to avoid misreading a cm-band shorthand as 70/50 MHz.
patterns.put(Band.B_50, bandPattern("50(?:\\s*MHZ)?|6\\s*M"));
patterns.put(Band.B_70, bandPattern("70\\s*MHZ|4\\s*M"));
patterns.put(Band.B_144, bandPattern("144(?:\\s*MHZ)?|2(?:\\s*M)?"));
patterns.put(Band.B_432, bandPattern("432(?:\\s*MHZ)?|70(?:\\s*CM)?"));
patterns.put(Band.B_1296, bandPattern("1296(?:\\s*MHZ)?|23(?:\\s*CM)?"));
patterns.put(Band.B_2320, bandPattern("(?:2300|2320)(?:\\s*MHZ)?|13(?:\\s*CM)?"));
patterns.put(Band.B_3400, bandPattern("3400(?:\\s*MHZ)?|9(?:\\s*CM)?"));
patterns.put(Band.B_5760, bandPattern("(?:5600|5760)(?:\\s*MHZ)?|6(?:\\s*CM)?"));
patterns.put(Band.B_10G, bandPattern("10368(?:\\s*MHZ)?|10\\s*G(?:HZ)?|3(?:\\s*CM)?"));
patterns.put(Band.B_24G, bandPattern("24048(?:\\s*MHZ)?|24\\s*G(?:HZ)?|1[.,]2(?:\\s*CM)?"));
return Collections.unmodifiableMap(patterns);
}
private static Pattern bandPattern(String alternatives) {
return Pattern.compile(
"(?<![A-Z0-9.,])(?:" + alternatives + ")(?![A-Z0-9.,])",
Pattern.CASE_INSENSITIVE
);
}
/** Immutable result of one callsign-wide band resolution. */
public static final class Resolution {
private final EnumSet<Band> offeredBands;
private final EnumSet<Band> workedBands;
private final EnumSet<Band> notQrvBands;
private Resolution(EnumSet<Band> offeredBands,
EnumSet<Band> workedBands,
EnumSet<Band> notQrvBands) {
this.offeredBands = copyOf(offeredBands);
this.workedBands = copyOf(workedBands);
this.notQrvBands = copyOf(notQrvBands);
}
/** Returns all recent/name-derived bands before NOT-QRV is applied. */
public EnumSet<Band> getOfferedBands() {
return copyOf(offeredBands);
}
public EnumSet<Band> getWorkedBands() {
return copyOf(workedBands);
}
public EnumSet<Band> getNotQrvBands() {
return copyOf(notQrvBands);
}
/** Returns offered bands after manual NOT-QRV exclusions. */
public EnumSet<Band> getAvailableBands() {
EnumSet<Band> availableBands = copyOf(offeredBands);
availableBands.removeAll(notQrvBands);
return availableBands;
}
/** Returns offered, QRV, enabled and not-yet-worked bands. */
public EnumSet<Band> getUnworkedEnabledBands(EnumSet<Band> enabledBands) {
EnumSet<Band> opportunities = getAvailableBands();
if (enabledBands == null || enabledBands.isEmpty()) {
opportunities.clear();
return opportunities;
}
opportunities.retainAll(enabledBands);
opportunities.removeAll(workedBands);
return opportunities;
}
public boolean hasBandEvidence() {
return !offeredBands.isEmpty();
}
private static EnumSet<Band> copyOf(EnumSet<Band> source) {
return source == null || source.isEmpty()
? EnumSet.noneOf(Band.class)
: EnumSet.copyOf(source);
}
}
}
@@ -1,196 +0,0 @@
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, 10368.100 or their compact digit-only forms. 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
* 144300
* 10368100
*
* At least two digits are required before a decimal separator. Compact
* values need at least five digits because their final three digits form
* the kHz part. This intentionally prevents "1.2" and bare values such as
* "210" from being interpreted as complete frequencies.
*/
private static final Pattern EXPLICIT_FREQUENCY_PATTERN = Pattern.compile(
"(?<![A-Z0-9])"
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?|\\d{5,8})"
+ "(?![A-Z0-9])",
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;
}
final String trimmedFrequency = rawFrequency.trim();
final String normalized = trimmedFrequency.matches("\\d{5,8}")
? trimmedFrequency.substring(0, trimmedFrequency.length() - 3)
+ "."
+ trimmedFrequency.substring(trimmedFrequency.length() - 3)
: normalizeFrequencyString(trimmedFrequency.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;
}
}
}
@@ -1,278 +0,0 @@
package kst4contest.logic;
import kst4contest.controller.StationMetricsService;
import kst4contest.model.*;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
/**
* Priority score calculation (off FX-thread).
*
* Notes:
* - Score is computed once per callsignRaw by ScoreService.
* - This calculator MUST be pure (no UI calls) and fast.
*/
public class PriorityCalculator {
public double calculatePriority(ChatMember member,
Collection<ChatMember> callsignVariants,
ChatPreferences prefs,
List<ContestSked> activeSkeds,
StationMetricsService.Snapshot metricsSnapshot,
long nowEpochMs) {
if (member == null || prefs == null) return 0.0;
final String callRaw = normalize(member.getCallSignRaw());
if (callRaw == null || callRaw.isBlank()) return 0.0;
// --------------------------------------------------------------------
// 1) HARD FILTER: reachable hardware + "already worked on all possible bands"
// --------------------------------------------------------------------
Collection<ChatMember> variants = callsignVariants == null || callsignVariants.isEmpty()
? List.of(member)
: callsignVariants;
BandOpportunityResolver.Resolution bandResolution =
BandOpportunityResolver.resolve(variants, nowEpochMs);
EnumSet<Band> myEnabledBands =
BandOpportunityResolver.getEnabledStationBands(prefs);
// ChatMember.worked remains UI-only. Scoring uses only per-band worked flags.
EnumSet<Band> workedBandsForScoring = bandResolution.getWorkedBands();
EnumSet<Band> unworkedPossible = EnumSet.noneOf(Band.class);
EnumSet<Band> stationOfferedBands = bandResolution.getOfferedBands();
EnumSet<Band> stationAvailableBands = bandResolution.getAvailableBands();
EnumSet<Band> possibleBands = stationOfferedBands.isEmpty()
? EnumSet.noneOf(Band.class)
: EnumSet.copyOf(stationAvailableBands);
if (!stationOfferedBands.isEmpty()) {
possibleBands.retainAll(myEnabledBands);
if (possibleBands.isEmpty()) {
// Known bands are disabled locally or manually marked NOT QRV.
return 0.0;
}
unworkedPossible = EnumSet.copyOf(possibleBands);
unworkedPossible.removeAll(workedBandsForScoring);
if (unworkedPossible.isEmpty()) {
return 0.0;
}
} else {
/*
* Missing band evidence is not automatically negative. A complete manual
* NOT-QRV exclusion is different: if every enabled own band is excluded,
* this station cannot be a current contest candidate.
*/
EnumSet<Band> notExplicitlyExcluded = EnumSet.copyOf(myEnabledBands);
notExplicitlyExcluded.removeAll(bandResolution.getNotQrvBands());
if (!myEnabledBands.isEmpty() && notExplicitlyExcluded.isEmpty()) {
return 0.0;
}
}
// --------------------------------------------------------------------
// 2) BASE SCORE
// --------------------------------------------------------------------
double score = 100.0;
/*
* A manual Sked-fail mark represents an explicit operator decision. The
* penalty is therefore applied after all positive contributions, including
* an imminent sked. Applying it earlier would allow the later sked boost to
* almost completely cancel the failure mark.
*/
boolean manualSkedFailed = false;
if (workedBandsForScoring.isEmpty()) {
score += 200.0; // never worked on any supported band -> higher priority
} else {
score -= 150.0; // already worked on at least one band -> lower base priority
}
// Multi-band bonus: if they offer >1 possible band and we worked at least one, prefer them
if (!possibleBands.isEmpty()) {
int bandCount = possibleBands.size();
score += (bandCount - 1) * 80.0;
}
// Optional: band-upgrade visibility boost
// If the station is already worked on at least one band, but is still QRV on other unworked enabled band(s),
// we can optionally add a boost so it remains visible in the list.
if (prefs.isNotify_bandUpgradePriorityBoostEnabled()
&& !workedBandsForScoring.isEmpty()
&& !unworkedPossible.isEmpty()) {
score += 180.0; // tuned visibility boost
}
// --------------------------------------------------------------------
// 3) DISTANCE ("Goldilocks Zone")
// --------------------------------------------------------------------
double distKm = member.getQrb() == null ? 0.0 : member.getQrb();
if (distKm > 0) {
if (distKm < 200) {
score *= 0.7;
} else if (distKm > prefs.getStn_maxQRBDefault()) {
score *= 0.3;
} else {
score *= 1.15;
}
}
// --------------------------------------------------------------------
// 4) AIRSCOUT BOOST
// --------------------------------------------------------------------
AirPlaneReflectionInfo apInfo = member.getAirPlaneReflectInfo();
if (apInfo != null && apInfo.getAirPlanesReachableCntr() > 0) {
score += 200;
int nextMinutes = findNextAirplaneArrivingMinutes(apInfo);
if (nextMinutes == 0) score += 120;
else if (nextMinutes == 1) score += 60;
else if (nextMinutes == 2) score += 30;
}
// --------------------------------------------------------------------
// 5) BOOST IDEA #1: Beam direction match (within beamwidth)
// --------------------------------------------------------------------
if (member.getQTFdirection() != null) {
double myAz = prefs.getActualQTF().getValue();
double targetAz = member.getQTFdirection();
double diff = minimalAngleDiffDeg(myAz, targetAz);
double halfBeam = Math.max(1.0, prefs.getStn_antennaBeamWidthDeg()) / 2.0;
if (diff <= halfBeam) {
double centerFactor = 1.0 - (diff / halfBeam); // 1.0 center -> 0.0 edge
score += 80.0 + (120.0 * centerFactor);
}
}
// --------------------------------------------------------------------
// 6) BOOST IDEA #3: Conversation momentum (recent inbound burst)
// --------------------------------------------------------------------
if (metricsSnapshot != null) {
StationMetricsService.Snapshot.Metrics mx = metricsSnapshot.get(callRaw);
if (mx != null) {
long ageMs = mx.lastInboundEpochMs > 0 ? (nowEpochMs - mx.lastInboundEpochMs) : Long.MAX_VALUE;
// "Active now" bonus
if (ageMs < 60_000) score += 120;
else if (ageMs < 3 * 60_000) score += 60;
// Momentum bonus: multiple lines in the configured window
int cnt = mx.inboundCountInWindow;
if (cnt >= 6) score += 160;
else if (cnt >= 4) score += 110;
else if (cnt >= 2) score += 60;
// Positive signal (configurable)
if (mx.lastPositiveSignalEpochMs > 0 && (nowEpochMs - mx.lastPositiveSignalEpochMs) < 5 * 60_000) {
score += 120;
}
// Reply time: prefer fast responders
if (mx.avgResponseTimeMs > 0) {
if (mx.avgResponseTimeMs < 60_000) score += 80;
else if (mx.avgResponseTimeMs < 3 * 60_000) score += 40;
}
// No-reply penalty (automatic failed attempt)
if (mx.noReplyStrikes > 0) {
score /= (1.0 + (mx.noReplyStrikes * 0.6));
}
// Manual sked fail (path likely bad) => strong, permanent penalty until reset
/*
* Remember the explicit failure state here. The actual penalty is applied
* after the sked contribution has been calculated.
*/
manualSkedFailed = mx.manualSkedFailed;
}
}
// --------------------------------------------------------------------
// 7) BOOST IDEA #4: Sked commitment ramp-up
// --------------------------------------------------------------------
if (activeSkeds != null && !activeSkeds.isEmpty()) {
for (ContestSked sked : activeSkeds) {
if (sked == null) continue;
if (!callRaw.equals(normalize(sked.getTargetCallsign()))) continue;
long seconds = sked.getTimeUntilSkedSeconds();
// Imminent sked: absolute priority (T-3min..T+1min)
if (seconds < 180 && seconds > -60) {
score += 5000;
continue;
}
// Ramp: 0..15 minutes before => up to +1200
if (seconds >= 0 && seconds <= 15 * 60) {
double t = (15 * 60 - seconds) / (15.0 * 60.0); // 0.0..1.0
score += 300 + (900 * t);
} else if (seconds > 15 * 60) {
score += 40;
}
}
}
// --------------------------------------------------------------------
// 8) Legacy penalty: failed attempts in ChatMember
// --------------------------------------------------------------------
// --------------------------------------------------------------------
// 8) Legacy penalty: failed attempts in ChatMember
// --------------------------------------------------------------------
if (member.getFailedQSOAttempts() > 0) {
score = score / (member.getFailedQSOAttempts() + 1);
}
// --------------------------------------------------------------------
// 9) Explicit operator override: manual Sked fail
// --------------------------------------------------------------------
if (manualSkedFailed) {
/*
* Apply the penalty to the complete result. This includes an imminent
* sked and prevents its strong time-dependent boost from cancelling the
* operator's explicit failure assessment.
*/
score *= 0.15;
}
return Math.max(0.0, score);
}
private static int findNextAirplaneArrivingMinutes(AirPlaneReflectionInfo apInfo) {
try {
if (apInfo.getRisingAirplanes() == null || apInfo.getRisingAirplanes().isEmpty()) return -1;
int min = Integer.MAX_VALUE;
for (AirPlane ap : apInfo.getRisingAirplanes()) {
if (ap == null) continue;
min = Math.min(min, ap.getArrivingDurationMinutes());
}
return min == Integer.MAX_VALUE ? -1 : min;
} catch (Exception ignore) {
return -1;
}
}
private static double minimalAngleDiffDeg(double a, double b) {
double diff = Math.abs((a - b) % 360.0);
return diff > 180.0 ? 360.0 - diff : diff;
}
private static String normalize(String s) {
if (s == null) return null;
return s.trim().toUpperCase();
}
}
@@ -1,381 +0,0 @@
package kst4contest.logic;
import kst4contest.model.Band;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
/**
* Selects one realistic propagation frequency for a station.
*
* <p>The same resolution is used by AirScout and by the internal path analysis.
* Only the chat categories supported by these features participate. This keeps
* unrelated KST categories from silently falling back to 144 MHz.</p>
*/
public final class PropagationFrequencyResolver {
// private static final double DUAL_VUHF_MICROWAVE_FALLBACK_MHZ = 430.0;
private PropagationFrequencyResolver() {
}
/** Explains why a frequency was selected. */
public enum Source {
CURRENT_QRG,
STATION_NAME_QRG,
STATION_NAME,
DUAL_CATEGORY_FALLBACK,
CHAT_CATEGORY
}
/**
* Resolves the frequency from all active category variants of one base
* callsign.
*
* <ol>
* <li>Most recently detected QRG</li>
* <li>Lowest band explicitly named by the station</li>
* <li>432 MHz if the station is present in VUHF and Microwave</li>
* <li>Lowest usable fallback band of the supported chat category</li>
* </ol>
*
* <p>Locally disabled and manually excluded bands are never selected.</p>
*
* @param variants active category variants of one callsign
* @param enabledBands bands enabled for the local station
* @param nowEpochMs current time used for the QRG age check
* @return one resolution, or {@code null} if no safe choice exists
*/
public static Resolution resolve(Collection<ChatMember> variants,
EnumSet<Band> enabledBands,
long nowEpochMs) {
if (variants == null || variants.isEmpty()
|| enabledBands == null || enabledBands.isEmpty()) {
return null;
}
List<ChatMember> supportedVariants = variants.stream()
.filter(PropagationFrequencyResolver::isSupportedVariant)
.toList();
if (supportedVariants.isEmpty()) {
return null;
}
BandOpportunityResolver.Resolution opportunityResolution =
BandOpportunityResolver.resolve(supportedVariants, nowEpochMs);
EnumSet<Band> usableBands = EnumSet.copyOf(enabledBands);
usableBands.removeAll(opportunityResolution.getNotQrvBands());
if (usableBands.isEmpty()) {
return null;
}
FrequencyCandidate latestQrg = findLatestQrg(
supportedVariants,
usableBands,
nowEpochMs
);
if (latestQrg != null) {
return new Resolution(
latestQrg.band,
latestQrg.frequencyMHz,
Source.CURRENT_QRG
);
}
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(
BandOpportunityResolver.detectBandsFromStationName(variant.getName())
);
}
nameBands.retainAll(usableBands);
Band nameBand = lowestBand(nameBands);
if (nameBand != null) {
return new Resolution(
nameBand,
nameBand.getDefaultAnalysisFrequencyMHz(),
Source.STATION_NAME
);
}
EnumSet<SupportedCategory> categories = collectSupportedCategories(supportedVariants);
if (categories.contains(SupportedCategory.VUHF)
&& categories.contains(SupportedCategory.MICROWAVE)
&& usableBands.contains(Band.B_432)) {
return new Resolution(
Band.B_432,
Band.B_432.getDefaultAnalysisFrequencyMHz(),
Source.DUAL_CATEGORY_FALLBACK
);
}
EnumSet<Band> categoryBands = EnumSet.noneOf(Band.class);
for (SupportedCategory category : categories) {
categoryBands.addAll(category.fallbackBands);
}
categoryBands.retainAll(usableBands);
Band categoryBand = lowestBand(categoryBands);
if (categoryBand == null) {
return null;
}
return new Resolution(
categoryBand,
categoryBand.getDefaultAnalysisFrequencyMHz(),
Source.CHAT_CATEGORY
);
}
private static FrequencyCandidate findLatestQrg(List<ChatMember> variants,
EnumSet<Band> usableBands,
long nowEpochMs) {
FrequencyCandidate latest = null;
for (ChatMember variant : variants) {
for (var entry : variant.getKnownActiveBands().entrySet()) {
Band band = entry.getKey();
ChatMember.ActiveFrequencyInfo info = entry.getValue();
if (band == null || info == null || !usableBands.contains(band)) {
continue;
}
long ageMs = nowEpochMs - info.timestampEpoch;
if (ageMs < 0L
|| ageMs > BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS
|| !Double.isFinite(info.frequency)
|| !band.isPlausible(info.frequency)) {
continue;
}
if (latest == null || info.timestampEpoch > latest.timestampEpochMs) {
latest = new FrequencyCandidate(
band,
info.frequency,
info.timestampEpoch
);
}
}
}
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;
}
int categoryNumber = member.getChatCategory().getCategoryNumber();
return categoryNumber == ChatCategory.FIFTYSEVENTYMHz
|| categoryNumber == ChatCategory.VUHF
|| categoryNumber == ChatCategory.MICROWAVE
|| categoryNumber == ChatCategory.EMEJT65;
}
private static EnumSet<SupportedCategory> collectSupportedCategories(
List<ChatMember> variants
) {
EnumSet<SupportedCategory> categories = EnumSet.noneOf(SupportedCategory.class);
for (ChatMember variant : variants) {
int categoryNumber = variant.getChatCategory().getCategoryNumber();
if (categoryNumber == ChatCategory.FIFTYSEVENTYMHz) {
categories.add(SupportedCategory.FIFTY_SEVENTY);
} else if (categoryNumber == ChatCategory.VUHF) {
categories.add(SupportedCategory.VUHF);
} else if (categoryNumber == ChatCategory.MICROWAVE) {
categories.add(SupportedCategory.MICROWAVE);
} else if (categoryNumber == ChatCategory.EMEJT65) {
categories.add(SupportedCategory.EME);
}
}
return categories;
}
private static Band lowestBand(Collection<Band> bands) {
if (bands == null || bands.isEmpty()) {
return null;
}
return bands.stream()
.min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz))
.orElse(null);
}
private enum SupportedCategory {
FIFTY_SEVENTY(EnumSet.of(Band.B_50, Band.B_70)),
VUHF(EnumSet.of(Band.B_144, Band.B_432)),
MICROWAVE(EnumSet.of(
Band.B_1296,
Band.B_2320,
Band.B_3400,
Band.B_5760,
Band.B_10G,
Band.B_24G
)),
EME(EnumSet.of(
Band.B_144,
Band.B_432,
Band.B_1296,
Band.B_2320,
Band.B_3400,
Band.B_5760,
Band.B_10G,
Band.B_24G
));
private final EnumSet<Band> fallbackBands;
SupportedCategory(EnumSet<Band> fallbackBands) {
this.fallbackBands = fallbackBands;
}
}
private static final class FrequencyCandidate {
private final Band band;
private final double frequencyMHz;
private final long timestampEpochMs;
private FrequencyCandidate(Band band,
double frequencyMHz,
long timestampEpochMs) {
this.band = band;
this.frequencyMHz = frequencyMHz;
this.timestampEpochMs = timestampEpochMs;
}
}
/** Immutable selected band/frequency pair. */
public static final class Resolution {
private final Band band;
private final double analysisFrequencyMHz;
private final Source source;
private Resolution(Band band,
double analysisFrequencyMHz,
Source source) {
this.band = band;
this.analysisFrequencyMHz = analysisFrequencyMHz;
this.source = source;
}
public Band getBand() {
return band;
}
public double getAnalysisFrequencyMHz() {
return analysisFrequencyMHz;
}
public Source getSource() {
return source;
}
/**
* Converts MHz to AirScout's 100-Hz protocol unit.
*
* @return integer protocol value, for example 1442100 for 144.210 MHz
*/
public String getAirScoutBandValue() {
return Long.toString(Math.round(analysisFrequencyMHz * 10_000.0));
}
}
}
@@ -1,51 +0,0 @@
package kst4contest.logic;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
/**
* Lightweight positive-signal detector.
*
* Patterns are configured via a single preference string, delimited by ';' or newlines.
* Examples: "QRV;READY;RX OK;RGR;TNX;TU;HRD"
*/
public final class SignalDetector {
private static final AtomicReference<String> lastPatterns = new AtomicReference<>("");
private static final AtomicReference<List<Pattern>> cached = new AtomicReference<>(List.of());
private SignalDetector() {}
public static boolean containsPositiveSignal(String messageText, String patternsDelimited) {
if (messageText == null || messageText.isBlank()) return false;
List<Pattern> patterns = compileIfChanged(patternsDelimited);
String txt = messageText.toUpperCase();
for (Pattern p : patterns) {
if (p.matcher(txt).find()) return true;
}
return false;
}
private static List<Pattern> compileIfChanged(String patternsDelimited) {
String p = patternsDelimited == null ? "" : patternsDelimited.trim();
String prev = lastPatterns.get();
if (p.equals(prev)) return cached.get();
List<Pattern> out = new ArrayList<>();
for (String token : p.split("[;\\n\\r]+")) {
String t = token.trim();
if (t.isEmpty()) continue;
// plain substring match, but regex-safe
String regex = Pattern.quote(t.toUpperCase());
out.add(Pattern.compile(regex));
}
lastPatterns.set(p);
cached.set(List.copyOf(out));
return out;
}
}
@@ -1,67 +0,0 @@
package kst4contest.model;
public class AirPlane {
String apCallSign, apSizeCategory;
String potencialDescriptionAsWord;
public String getPotencialDescriptionAsWord() {
if (this.getPotential() <=50) {
return "small AP";
} else if (this.getPotential() <=75 && this.getPotential() > 50) {
return "big AP";
} else if (this.getPotential() > 75) {
return "very big AP";
}
return potencialDescriptionAsWord;
}
public void setPotencialDescriptionAsWord(String potencialDescriptionAsWord) {
this.potencialDescriptionAsWord = potencialDescriptionAsWord;
}
int distanceKm, potential, arrivingDurationMinutes;
public String getApCallSign() {
return apCallSign;
}
public void setApCallSign(String apCallSign) {
this.apCallSign = apCallSign;
}
public String getApSizeCategory() {
return apSizeCategory;
}
public void setApSizeCategory(String apSizeCategory) {
this.apSizeCategory = apSizeCategory;
}
public int getDistanceKm() {
return distanceKm;
}
public void setDistanceKm(int distanceKm) {
this.distanceKm = distanceKm;
}
public int getPotential() {
return potential;
}
public void setPotential(int potential) {
this.potential = potential;
}
public int getArrivingDurationMinutes() {
return arrivingDurationMinutes;
}
public void setArrivingDurationMinutes(int arrivingDurationMinutes) {
this.arrivingDurationMinutes = arrivingDurationMinutes;
}
@Override
public String toString() {
String toStringString = "\n";
toStringString += this.apCallSign + ", category: " + this.apSizeCategory + ", distance: " + this.getDistanceKm() + ", potential: " + this.potential + ", distance: " + this.getDistanceKm() + ", duration " + this.arrivingDurationMinutes + "" ;
return toStringString;
}
}
@@ -1,62 +0,0 @@
package kst4contest.model;
import javafx.collections.ObservableList;
public class AirPlaneReflectionInfo {
String date;
ChatMember sender, receiver;
int airPlanesReachableCntr;
ObservableList<AirPlane> risingAirplanes;
public String getDate() {
return date;
}
public void setDate(String date) {
this.date = date;
}
public ChatMember getSender() {
return sender;
}
public void setSender(ChatMember sender) {
this.sender = sender;
}
public ChatMember getReceiver() {
return receiver;
}
public void setReceiver(ChatMember receiver) {
this.receiver = receiver;
}
public int getAirPlanesReachableCntr() {
return airPlanesReachableCntr;
}
public void setAirPlanesReachableCntr(int airPlanesReachableCntr) {
this.airPlanesReachableCntr = airPlanesReachableCntr;
}
public ObservableList<AirPlane> getRisingAirplanes() {
return risingAirplanes;
}
public void setRisingAirplanes(ObservableList<AirPlane> risingAirplanes) {
this.risingAirplanes = risingAirplanes;
}
@Override
public String toString() {
String toStringString = "";
toStringString += this.sender.getCallSign() + " > " + this.getReceiver().getCallSign() + " at " + this.getDate() + " " + this.airPlanesReachableCntr + " planes: " + this.getRisingAirplanes().toString();
return toStringString;
}
}
-111
View File
@@ -1,111 +0,0 @@
package kst4contest.model;
/**
* Represents Amateur Radio Bands and their physical limits.
* Used for plausibility checks in the Smart Parser.
*/
public enum Band {
B_50(50.000, 54.000, "50"),
B_70(70.000, 70.500, "70"),
B_144(144.000, 146.000, "144"),
B_432(432.000, 434.000, "432"),
B_1296(1296.000, 1298.000, "1296"),
B_2320(2320.000, 2322.000, "2320"),
B_3400(3400.000, 3410.000, "3400"),
B_5760(5760.000, 5762.000, "5760"),
B_10G(10368.000, 10370.000, "10368"),
B_24G(24048.000, 24050.000, "24048");
// more space for future usage
private final double minFreq;
private final double maxFreq;
private final String prefix; // Default prefix for "short value" parsing (e.g., .210)
Band(double min, double max, String prefix) {
this.minFreq = min;
this.maxFreq = max;
this.prefix = prefix;
}
public String getPrefix() {
return prefix;
}
/**
* Resolves a configured MHz prefix to one of the bands supported by the
* frequency parser.
*
* <p>The former free-text preference accepted arbitrary numeric values even
* though only prefixes represented by this enum can be used for a plausible
* frequency. Keeping the lookup here gives the UI and the parser one common
* definition of a valid fallback band.</p>
*
* @param prefix configured MHz prefix, for example {@code 144} or {@code 10368}
* @return matching band, or {@code null} if the prefix is not supported
*/
public static Band fromPrefix(String prefix) {
if (prefix == null) {
return null;
}
String normalizedPrefix = prefix.trim();
for (Band band : values()) {
if (band.prefix.equals(normalizedPrefix)) {
return band;
}
}
return null;
}
/**
* Returns the lower edge used as practical analysis frequency when only the band
* is known. This keeps the batch reachability calculation deterministic.
*
* @return frequency in MHz
*/
public double getDefaultAnalysisFrequencyMHz() {
return minFreq;
}
/**
* Returns a compact label for table display and filter controls.
*
* @return human readable band label
*/
public String getDisplayLabel() {
switch (this) {
case B_50: return "50";
case B_70: return "70";
case B_144: return "144";
case B_432: return "432";
case B_1296: return "1296";
case B_2320: return "2320";
case B_3400: return "3400";
case B_5760: return "5760";
case B_10G: return "10G";
case B_24G: return "24G";
default: return prefix;
}
}
/**
* Checks if a specific frequency falls within this band's limits.
*/
public boolean isPlausible(double freq) {
return freq >= minFreq && freq <= maxFreq;
}
/**
* Helper to find the matching Band enum for a given frequency.
* Returns null if no band matches.
*/
public static Band fromFrequency(double freq) {
for (Band b : values()) {
if (b.isPlausible(freq)) return b;
}
return null;
}
}
@@ -1,279 +0,0 @@
package kst4contest.model;
/* 50/70 MHz..............1 <br/> 144/432 MHz............2<br/> Microwave..............3<br/>
* EME/JT65...............4 <br/> Low Band...............5 <br/> 50 MHz IARU Region 3...6 <br/> 50
* MHz IARU Region 2...7 <br/> 144/432 MHz IARU R 2...8 <br/> 144/432 MHz IARU R 3...9 <br/> kHz
* (2000-630m).......10 <br/> Warc (30,17,12m)......11 <br/> 28 MHz................12
* <br/>
* TODO: LowBand and khz need to be continued later...
*/
public class ChatCategory {
@Override
public String toString() {
String toStringString ="";
toStringString = this.getCategoryNumber()+": " + this.getChatCategoryName(categoryNumber);
return toStringString;
}
public static final int FIFTYSEVENTYMHz = 1;
public static final int VUHF = 2;
public static final int MICROWAVE = 3;
public static final int EMEJT65 = 4;
public static final int LOWBAND = 5;
public static final int FIFTYR3 = 6;
public static final int FIFTYR2 = 7;
public static final int VUHFR2 = 8;
public static final int VUHFR3 = 9;
public static final int KHZ2KM630M = 10;
public static final int WARC301712 = 11;
public static final int TENMeter = 12;
private int categoryNumber;
public int[] getPossibleCategoryNumbers() {
int[] possibleVals = new int[12];
possibleVals[0] = 1;
possibleVals[1] = 2;
possibleVals[2] = 3;
possibleVals[3] = 4;
possibleVals[4] = 5;
possibleVals[5] = 6;
possibleVals[6] = 7;
possibleVals[7] = 8;
possibleVals[8] = 9;
possibleVals[9] = 10;
possibleVals[10] = 11;
possibleVals[11] = 12;
return possibleVals;
}
public ChatCategory(int setThiscategoryNumber) {
this.categoryNumber = setThiscategoryNumber;
setCategoryNumber(setThiscategoryNumber);
}
public int getCategoryNumber() {
return categoryNumber;
}
public void setCategoryNumber(int categoryNumber) {
this.categoryNumber = categoryNumber;
}
/**
* Returns an Array of int with possible frequency prefixes, due to in the chat
* normally the following format is used (not ever): <br/>
* "pse listen at .325"<br/>
* <br/>
*
* 50/70 MHz..............1 <br/>
* 144/432 MHz............2 <br/>
* Microwave..............3 <br/>
* EME/JT65...............4 <br/>
* Low Band...............5 <br/>
* 50 MHz IARU Region 3...6 <br/>
* 50 MHz IARU Region 2...7 <br/>
* 144/432 MHz IARU R 2...8 <br/>
* 144/432 MHz IARU R 3...9 <br/>
* kHz (2000-630m).......10 <br/>
* Warc (30,17,12m)......11 <br/>
* 28 MHz................12 <br/>
* TODO: LowBand and khz need to be continued later...
*
* @param int chatCategory (KST ChatCategory)
* @return Array of int with frequency prefixes, beginning with the lowest band
* (e.G. int[2] = [144, 430] for VUHF)
*/
public int[] getChatCategoryFrequencyPrefix(int chatCategory) {
int[] answer = new int[1];
answer[0] = 0;
switch (chatCategory) {
case ChatCategory.FIFTYSEVENTYMHz: {
answer = new int[2];
answer[0] = 50;
answer[1] = 70;
return answer;
}
case ChatCategory.VUHF: {
answer = new int[2];
answer[0] = 144;
answer[1] = 432;
return answer;
}
case ChatCategory.VUHFR2: {
answer = new int[2];
answer[0] = 144;
answer[1] = 432;
return answer;
}
case ChatCategory.VUHFR3: {
answer = new int[2];
answer[0] = 144;
answer[1] = 432;
return answer;
}
case ChatCategory.EMEJT65: { // 1296.2
answer = new int[13];
answer[0] = 144;
answer[1] = 432;
answer[2] = 1296;
answer[3] = 3400;
answer[4] = 5760;
answer[5] = 10368;
answer[6] = 24048;
answer[7] = 47000;
answer[8] = 77500;
answer[9] = 122250;
answer[10] = 134928;
answer[11] = 241920;
answer[12] = 453000;
return answer;
}
case ChatCategory.MICROWAVE: { // 1296.2
answer = new int[13];
answer[0] = 144;
answer[1] = 432;
answer[2] = 1296;
answer[3] = 3400;
answer[4] = 5760;
answer[5] = 10368;
answer[6] = 24048;
answer[7] = 47000;
answer[8] = 77500;
answer[9] = 122250;
answer[10] = 134928;
answer[11] = 241920;
answer[12] = 453000;
return answer;
}
case ChatCategory.TENMeter: {
answer = new int[1];
answer[0] = 28;
return answer;
}
case ChatCategory.WARC301712: {
answer = new int[3];
answer[0] = 10;
answer[1] = 18;
answer[2] = 24;
return answer;
}
default:
return answer;
}
};
/**
* Returns an Array of int with possible frequency prefixes, due to in the chat
* normally the following format is used (not ever): <br/>
* "pse listen at .325"<br/>
* <br/>
*
* 50/70 MHz..............1 <br/>
* 144/432 MHz............2 <br/>
* Microwave..............3 <br/>
* EME/JT65...............4 <br/>
* Low Band...............5 <br/>
* 50 MHz IARU Region 3...6 <br/>
* 50 MHz IARU Region 2...7 <br/>
* 144/432 MHz IARU R 2...8 <br/>
* 144/432 MHz IARU R 3...9 <br/>
* kHz (2000-630m).......10 <br/>
* Warc (30,17,12m)......11 <br/>
* 28 MHz................12 <br/>
* TODO: LowBand and khz need to be continued later...
*
* @param int chatCategory (KST ChatCategory)
* @return Array of int with frequency prefixes, beginning with the lowest band
* (e.G. int[2] = [144, 430] for VUHF)
*/
public String getChatCategoryName(int chatCategory) {
switch (chatCategory) {
case ChatCategory.FIFTYSEVENTYMHz: {
return "50/70 MHz";
}
case ChatCategory.FIFTYR2: {
return "50 MHz IARU Region 2";
}
case ChatCategory.FIFTYR3: {
return "50 MHz IARU Region 3";
}
case ChatCategory.LOWBAND: {
return "Low Band";
}
case ChatCategory.KHZ2KM630M: {
return "kHz (2000-630m)";
}
case ChatCategory.VUHF: {
return "144/432 MHz";
}
case ChatCategory.VUHFR2: {
return "144/432 MHz IARU R 2";
}
case ChatCategory.VUHFR3: {
return "144/432 MHz IARU R 3";
}
case ChatCategory.EMEJT65: { // 1296.2
return "EME/JT65";
}
case ChatCategory.MICROWAVE: { // 1296.2
return "Microwave";
}
case ChatCategory.TENMeter: {
return "28 MHz";
}
case ChatCategory.WARC301712: {
return "Warc (30,17,12m)";
}
default:
return "ERRROR: unknown";
}
};
}
@@ -1,853 +0,0 @@
package kst4contest.model;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import java.util.OptionalDouble;
public class ChatMember {
long lastFlagsChangeEpochMs; // timestamp of the last worked/not-QRV flag change in the internal DB
// private final BooleanProperty workedInfoChangeFireListEventTrigger = new SimpleBooleanProperty();
AirPlaneReflectionInfo airPlaneReflectInfo;
String callSign;
String qra;
String name;
String callSignRaw; //without -2 or -70 etc.
/**
* A directional opportunity inferred from a directed chat message remains
* relevant for five minutes. The timestamp is used instead of a permanent
* boolean so an old antenna-direction assumption cannot remain visible
* indefinitely.
*/
static final long DIRECTION_OPPORTUNITY_VALIDITY_MILLIS = 5L * 60L * 1000L;
private volatile long directionOpportunityValidUntilEpochMs;
// String frequency; // last known qrg of the station
StringProperty frequency = new SimpleStringProperty();
String password; // only used by own instance of the chatmember instance to login to the chat
ChatCategory chatCategory; //Source category
// ChatCategory chatCategory;//only used by own instance of the chatmember instance to login to the chat
long activityTimeLastInEpoch; // time of last activity in epochtimesec
Date lastActivity; // time of last activity in epochtimesec
Date lastActualizationTimeOfThisMember; // time of last state change if that member
Double qrb;
int state;
Double QTFdirection; // antenna direction in deg
int[] workedCategories; // Chatcategory where the station is in the log, see kst4contest.model.ChatCategory
boolean worked; // true if the callsign is logged already - for temporary worked processing
boolean worked144;
boolean worked432;
boolean worked1240;
boolean worked2300;
boolean worked3400;
boolean worked5600;
boolean worked10G;
boolean Worked50;
boolean Worked70;
boolean Worked24G;
boolean Worked47G;
boolean Worked76G;
/**
* Chatmember is qrv at all band except we initialize anything other, depending to user entry
*/
boolean qrv50 = true;
boolean qrv70 = true;
boolean qrv144 = true;
boolean qrv432 = true;
boolean qrv1240 = true;
boolean qrv2300 = true;
boolean qrv3400 = true;
boolean qrv5600 = true;
boolean qrv10G = true;
boolean qrvAny = true;
// Stores the last known frequency per band (Context History)
private final Map<Band, ActiveFrequencyInfo> knownActiveBands = new ConcurrentHashMap<>();
// Stores the calculated bidirectional SSB tropo margin per band.
// Values are calculated by the reachability backend and used only for UI sorting/filtering.
private final Map<Band, Double> tropoSsbMarginDbByBand = new ConcurrentHashMap<>();
// --- INNER CLASS FOR QRG HISTORY ---
public class ActiveFrequencyInfo {
public double frequency;
public long timestampEpoch;
public ActiveFrequencyInfo(double freq) {
this.frequency = freq;
this.timestampEpoch = System.currentTimeMillis();
}
}
// Counter for failed calls (Penalty Logic)
private int failedQSOAttempts = 0;
// Calculated Score for sorting the user list
private double currentPriorityScore = 0.0;
public long getLastFlagsChangeEpochMs() {
return lastFlagsChangeEpochMs;
}
public void setLastFlagsChangeEpochMs(long lastFlagsChangeEpochMs) {
this.lastFlagsChangeEpochMs = lastFlagsChangeEpochMs;
}
/**
* Returns whether the most recently inferred directional opportunity is
* still valid.
*
* @return {@code true} until the five-minute validity period has expired
*/
public boolean isInAngleAndRange() {
return isInAngleAndRangeAt(System.currentTimeMillis());
}
/**
* Time-aware variant used by the public getter and by unit tests.
*
* @param nowEpochMs time against which the validity is checked
* @return {@code true} while the stored validity timestamp is still in the future
*/
boolean isInAngleAndRangeAt(long nowEpochMs) {
return directionOpportunityValidUntilEpochMs > nowEpochMs;
}
/**
* Starts a new five-minute validity period or removes the current
* directional opportunity immediately.
*
* @param inAngleAndRange {@code true} for a newly detected opportunity;
* {@code false} to clear it
*/
public void setInAngleAndRange(boolean inAngleAndRange) {
directionOpportunityValidUntilEpochMs = inAngleAndRange
? System.currentTimeMillis() + DIRECTION_OPPORTUNITY_VALIDITY_MILLIS
: 0L;
}
public AirPlaneReflectionInfo getAirPlaneReflectInfo() {
return airPlaneReflectInfo;
}
public void setAirPlaneReflectInfo(AirPlaneReflectionInfo airPlaneReflectInfo) {
this.airPlaneReflectInfo = airPlaneReflectInfo;
}
public ChatCategory getChatCategory() {
return chatCategory;
}
public void setChatCategory(ChatCategory chatCategory) {
this.chatCategory = chatCategory;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public boolean isWorked144() {
return worked144;
}
public void setWorked144(boolean worked144) {
this.worked144 = worked144;
}
public boolean isWorked432() {
return worked432;
}
public void setWorked432(boolean worked432) {
this.worked432 = worked432;
}
public boolean isWorked1240() {
return worked1240;
}
public void setWorked1240(boolean worked1240) {
this.worked1240 = worked1240;
}
public boolean isWorked2300() {
return worked2300;
}
public void setWorked2300(boolean worked2300) {
this.worked2300 = worked2300;
}
public boolean isWorked3400() {
return worked3400;
}
public void setWorked3400(boolean worked3400) {
this.worked3400 = worked3400;
}
public boolean isWorked5600() {
return worked5600;
}
public void setWorked5600(boolean worked5600) {
this.worked5600 = worked5600;
}
public boolean isWorked10G() {
return worked10G;
}
public void setWorked10G(boolean worked10g) {
worked10G = worked10g;
}
public boolean isQrv50() {
return qrv50;
}
public void setQrv50(boolean qrv50) {
this.qrv50 = qrv50;
}
public boolean isQrv70() {
return qrv70;
}
public void setQrv70(boolean qrv70) {
this.qrv70 = qrv70;
}
public boolean isQrv144() {
return qrv144;
}
public void setQrv144(boolean qrv144) {
this.qrv144 = qrv144;
}
public boolean isQrv432() {
return qrv432;
}
public void setQrv432(boolean qrv432) {
this.qrv432 = qrv432;
}
public boolean isQrv1240() {
return qrv1240;
}
public void setQrv1240(boolean qrv1240) {
this.qrv1240 = qrv1240;
}
public boolean isQrv2300() {
return qrv2300;
}
public void setQrv2300(boolean qrv2300) {
this.qrv2300 = qrv2300;
}
public boolean isQrv3400() {
return qrv3400;
}
public void setQrv3400(boolean qrv3400) {
this.qrv3400 = qrv3400;
}
public boolean isQrv5600() {
return qrv5600;
}
public void setQrv5600(boolean qrv5600) {
this.qrv5600 = qrv5600;
}
public boolean isQrv10G() {
return qrv10G;
}
public void setQrv10G(boolean qrv10G) {
this.qrv10G = qrv10G;
}
public boolean isQrvAny() {
return qrvAny;
}
public void setQrvAny(boolean qrvAny) {
this.qrvAny = qrvAny;
}
public int[] getWorkedCategories() {
return workedCategories;
}
public void setWorkedCategories(int[] workedCategories) {
this.workedCategories = workedCategories;
}
public void setActivityTimeLastInEpoch(long activityTimeLastInEpoch) {
this.activityTimeLastInEpoch = activityTimeLastInEpoch;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
public Date getLastActivity() {
return lastActivity;
}
public void setLastActivity(Date lastActivity) {
this.lastActivity = lastActivity;
}
public Date getLastActualizationTimeOfThisMember() {
return lastActualizationTimeOfThisMember;
}
public void setLastActualizationTimeOfThisMember(Date lastActualizationTimeOfThisMember) {
this.lastActualizationTimeOfThisMember = lastActualizationTimeOfThisMember;
}
public Double getQrb() {
return qrb;
}
public void setQrb(Double qrb) {
this.qrb = qrb;
}
public Double getQTFdirection() {
return QTFdirection;
}
public void setQTFdirection(Double qTFdirection) {
QTFdirection = qTFdirection;
}
public String getCallSign() {
return callSign;
}
/**
* Sets the original callsign and derives the normalized base callsign which is
* used as the database key. Prefixes like EA5/ and suffixes like /P or -70 are
* ignored for the raw-key handling.
*
* @param callSign callsign as received from chat or database
*/
public void setCallSign(String callSign) {
if (callSign == null) {
this.callSign = null;
this.callSignRaw = null;
return;
}
this.callSign = callSign.trim().toUpperCase(Locale.ROOT);
this.callSignRaw = normalizeCallSignToBaseCallSign(this.callSign);
}
/**
* Normalizes a callsign to the base callsign which is used as the unique key in
* the internal database. The method removes KST suffixes like "-2", portable
* suffixes like "/P" and prefix additions like "EA5/".
*
* @param callSign callsign to normalize
* @return normalized base callsign in upper case
*/
public static String normalizeCallSignToBaseCallSign(String callSign) {
if (callSign == null) {
return null;
}
String normalizedCallSign = callSign.trim().toUpperCase(Locale.ROOT);
if (normalizedCallSign.isBlank()) {
return normalizedCallSign;
}
String callSignWithoutDashSuffix = normalizedCallSign.split("-", 2)[0].trim();
if (!callSignWithoutDashSuffix.contains("/")) {
return callSignWithoutDashSuffix;
}
String[] callSignParts = callSignWithoutDashSuffix.split("/");
String bestMatchingCallsignPart = helper_selectBestCallsignPart(callSignParts);
if (bestMatchingCallsignPart == null || bestMatchingCallsignPart.isBlank()) {
return callSignWithoutDashSuffix;
}
return bestMatchingCallsignPart;
}
/**
* Selects the most plausible base callsign segment from a slash-separated
* callsign. In strings like "EA5/G8MBI/P" the segment "G8MBI" is preferred over
* prefix or portable markers.
*
* @param callSignParts slash-separated callsign parts
* @return best matching base callsign segment
*/
private static String helper_selectBestCallsignPart(String[] callSignParts) {
String bestLikelyBaseCallsignPart = null;
int bestLikelyBaseCallsignLength = -1;
String bestFallbackCallsignPart = null;
int bestFallbackCallsignLength = -1;
for (String rawCallsignPart : callSignParts) {
String currentCallsignPart = rawCallsignPart == null ? "" : rawCallsignPart.trim().toUpperCase(Locale.ROOT);
if (currentCallsignPart.isBlank()) {
continue;
}
if (currentCallsignPart.length() > bestFallbackCallsignLength) {
bestFallbackCallsignPart = currentCallsignPart;
bestFallbackCallsignLength = currentCallsignPart.length();
}
if (helper_isLikelyBaseCallsignSegment(currentCallsignPart)
&& currentCallsignPart.length() > bestLikelyBaseCallsignLength) {
bestLikelyBaseCallsignPart = currentCallsignPart;
bestLikelyBaseCallsignLength = currentCallsignPart.length();
}
}
if (bestLikelyBaseCallsignPart != null) {
return bestLikelyBaseCallsignPart;
}
return bestFallbackCallsignPart;
}
/**
* Checks whether a slash-separated segment looks like a real base callsign. A
* normal amateur-radio callsign typically contains letters and digits and is
* longer than one-character postfix markers.
*
* @param callsignSegment segment to inspect
* @return true if the segment looks like a base callsign
*/
private static boolean helper_isLikelyBaseCallsignSegment(String callsignSegment) {
boolean containsLetter = false;
boolean containsDigit = false;
for (int currentIndex = 0; currentIndex < callsignSegment.length(); currentIndex++) {
char currentCharacter = callsignSegment.charAt(currentIndex);
if (Character.isLetter(currentCharacter)) {
containsLetter = true;
}
if (Character.isDigit(currentCharacter)) {
containsDigit = true;
}
}
return containsLetter && containsDigit && callsignSegment.length() >= 3;
}
public String getQra() {
return qra;
}
public void setQra(String qra) {
this.qra = qra;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public StringProperty getFrequency() {
return frequency;
// return frequency;
}
public void setFrequency(StringProperty frequency) {
this.frequency = frequency;
}
public long getActivityTimeLastInEpoch() {
return activityTimeLastInEpoch;
}
public void setActivityCounter(int activityCounter) {
this.activityTimeLastInEpoch = activityCounter;
}
public boolean isWorked() {
return worked;
}
public boolean isWorked50() {
return Worked50;
}
public void setWorked50(boolean worked50) {
Worked50 = worked50;
}
public boolean isWorked70() {
return Worked70;
}
public void setWorked70(boolean worked70) {
Worked70 = worked70;
}
public boolean isWorked24G() {
return Worked24G;
}
public void setWorked24G(boolean worked24G) {
Worked24G = worked24G;
}
public boolean isWorked47G() {
return Worked47G;
}
public void setWorked47G(boolean worked47G) {
Worked47G = worked47G;
}
public boolean isWorked76G() {
return Worked76G;
}
public void setWorked76G(boolean worked76G) {
Worked76G = worked76G;
}
public void setWorked(boolean worked) {
this.worked = worked;
}
/**
*
* @return String (callsign) without -2 or -70 etc.
*/
public String getCallSignRaw() {
return callSignRaw;
// String raw = "";
//
// try {
// return this.getCallSign().split("-")[0]; //e.g. OK2M-70, returns only ok2m
// } catch (Exception e) {
// return getCallSign();
// }
}
/**
* 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
*/
public void resetWorkedInformationAtAllBands() {
this.setWorked(false);
this.setWorked144(false);
this.setWorked50(false);
this.setWorked70(false);
this.setWorked432(false);
this.setWorked1240(false);
this.setWorked2300(false);
this.setWorked3400(false);
this.setWorked5600(false);
this.setWorked10G(false);
this.setWorked24G(false);
this.setWorked47G(false);
this.setWorked76G(false);
}
/**
* Sets all worked information of this object to false. Scope: GUI, Reset Button
* for worked info, called by appcontroller
*/
public void resetQRVInformationAtAllBands() {
this.setQrvAny(true);
this.setQrv50(true);
this.setQrv70(true);
this.setQrv144(true);
this.setQrv432(true);
this.setQrv1240(true);
this.setQrv2300(true);
this.setQrv3400(true);
this.setQrv5600(true);
this.setQrv10G(true);
}
@Override
public String toString() {
String chatMemberSerialization = "";
chatMemberSerialization += callSign + ";" + name + ";" + qra + ";" + frequency + "; wkd " + worked + "; wkd144 " + worked144
+ "; wkd432" + worked432 + "; wkd1240" + worked1240 + "; wkd2300" + worked2300 + "; wkd3400" + worked3400 + "; wkd5600" + worked5600 + "; wkd10G"
+ worked10G + " ; " + chatCategory;
return chatMemberSerialization;
}
/**
* Finds out if a given Chatmember-instance has the same callsign as this
* instance. Callsign is used as a key.
*
* @param anotherChatMember
* @return
*/
public boolean equals(ChatMember anotherChatMember) {
if (this.getCallSign().toUpperCase().equals(anotherChatMember.getCallSign().toUpperCase())) {
return true;
} else
return false;
}
/**
* Adds a new recognized frequency by band to the internal band/qrg map
* @param band
* @param freq
*/
public void addKnownFrequency(Band band, double freq) {
this.knownActiveBands.put(band, new ActiveFrequencyInfo(freq));
}
/**
* represents a map of bands which are known of this chatmember
*
* @return Band
*/
public Map<Band, ActiveFrequencyInfo> getKnownActiveBands() {
return knownActiveBands;
}
/**
* Stores the calculated bidirectional SSB tropo margin for one band.
*
* <p>The value is deliberately stored in ChatMember because the station table
* can then sort and filter directly without triggering a new RF calculation.
* The actual calculation stays outside ChatMember.</p>
*
* @param band band for which the margin was calculated
* @param marginDb bidirectional SSB margin in dB; NaN marks an attempted but failed analysis
*/
public void setTropoSsbMarginDb(Band band, double marginDb) {
if (band == null) {
return;
}
this.tropoSsbMarginDbByBand.put(band, marginDb);
}
/**
* Returns true if a tropo analysis result already exists for the given band.
*
* <p>A stored NaN also counts as an existing result because it means the analysis
* was attempted and failed. This prevents endless retry loops when a terrain
* provider is temporarily unavailable.</p>
*
* @param band band to check
* @return true if a value or NaN marker is stored
*/
public boolean hasTropoSsbMarginDb(Band band) {
return band != null && this.tropoSsbMarginDbByBand.containsKey(band);
}
/**
* Returns true only when a finite, usable tropo SSB margin is stored for the band.
*
* <p>This is intentionally different from {@link #hasTropoSsbMarginDb(Band)}:
* a stored NaN means that an analysis was attempted but did not produce a usable
* link budget. Such failed values should not permanently block later retries.</p>
*
* @param band band to check
* @return true if a finite SSB margin exists
*/
public boolean hasFiniteTropoSsbMarginDb(Band band) {
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
return false;
}
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
return storedValue != null && Double.isFinite(storedValue);
}
/**
* Reads the calculated tropo SSB margin for one band.
*
* @param band band to read
* @return OptionalDouble with the stored value, or empty if no calculation exists yet
*/
public OptionalDouble getTropoSsbMarginDb(Band band) {
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
return OptionalDouble.empty();
}
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
if (storedValue == null) {
return OptionalDouble.empty();
}
return OptionalDouble.of(storedValue);
}
/**
* Formats the calculated tropo margin for direct table display.
*
* <p>The method distinguishes three states:
* <ul>
* <li>{@code ... @144}: analysis has not run yet</li>
* <li>{@code ? @144}: analysis ran, but no usable link budget was produced</li>
* <li>{@code +8.4 dB @144}: finite SSB margin exists</li>
* </ul>
*
* @param band selected/auto-resolved reachability band
* @return display text such as "+8.4 dB @144", "? @144" or "... @144"
*/
public String formatTropoSsbMarginForBand(Band band) {
String bandLabel = band == null ? "?" : band.getDisplayLabel();
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
return "... @" + bandLabel;
}
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
if (storedValue == null || !Double.isFinite(storedValue)) {
return "? @" + bandLabel;
}
return String.format(Locale.US, "%+.1f dB @%s", storedValue, bandLabel);
}
/**
* If a sked fails and the user tells this to the client, this counter will be increased to give the station a
* lower score
*/
public void incrementFailedAttempts() {
this.failedQSOAttempts++;
}
public void resetFailedAttempts() {
this.failedQSOAttempts = 0;
}
public int getFailedQSOAttempts() {
return failedQSOAttempts;
}
/**
* Sets the working-priority score of a chatmember for the "Todo-List"
* @param score
*/
public void setCurrentPriorityScore(double score) {
this.currentPriorityScore = score;
}
/**
* Gets the working-priority score of a chatmember for the "Todo-List"
*
*/
public double getCurrentPriorityScore() {
return currentPriorityScore;
}
}
@@ -1,34 +0,0 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatMemberDirectionOpportunityTest {
@Test
void directionalOpportunityExpiresAfterConfiguredValidityPeriod() {
ChatMember member = new ChatMember();
member.setInAngleAndRange(true);
long afterActivationEpochMs = System.currentTimeMillis();
assertTrue(member.isInAngleAndRangeAt(afterActivationEpochMs));
assertFalse(member.isInAngleAndRangeAt(
afterActivationEpochMs
+ ChatMember.DIRECTION_OPPORTUNITY_VALIDITY_MILLIS
+ 1L));
}
@Test
void directionalOpportunityCanBeClearedImmediately() {
ChatMember member = new ChatMember();
member.setInAngleAndRange(true);
assertTrue(member.isInAngleAndRange());
member.setInAngleAndRange(false);
assertFalse(member.isInAngleAndRange());
}
}
@@ -1,90 +0,0 @@
package kst4contest.model;
//import java.time.OffsetDateTime;
/**
* boolean directedToServer is important
* @author mywire
*
*/
public class ChatMessage {
ChatMember sender;
String messageText;
ChatMember receiver; //the callsign to whom the message is directed
String messageGeneratedTime; //generated time in epochtimemillis/1000, as used by the chatserver
ChatCategory chatCategory;
String messageSenderName;
boolean messageByServer; //whether the chat message is directed do the server
boolean messageDirectedToCommunity; //wheter the message is directed to anyone or to all users
boolean messageDirectedToMe; //wheter message is directed to the praktiKST User
boolean messageDirectedToServer;
public boolean isMessageDirectedToServer() {
return messageDirectedToServer;
}
public void setMessageDirectedToServer(boolean messageDirectedToServer) {
this.messageDirectedToServer = messageDirectedToServer;
}
public boolean isMessageDirectedToCommunity() {
return messageDirectedToCommunity;
}
public void setMessageDirectedToCommunity(boolean messageDirectedToCommunity) {
this.messageDirectedToCommunity = messageDirectedToCommunity;
}
public boolean isMessageDirectedToMe() {
return messageDirectedToMe;
}
public void setMessageDirectedToMe(boolean messageDirectedToMe) {
this.messageDirectedToMe = messageDirectedToMe;
}
public String getMessageSenderName() {
return messageSenderName;
}
public void setMessageSenderName(String messageSenderName) {
this.messageSenderName = messageSenderName;
}
public ChatCategory getChatCategory() {
return chatCategory;
}
public void setChatCategory(ChatCategory chatCategory) {
this.chatCategory = chatCategory;
}
public boolean isMessageByServer() {
return messageByServer;
}
public void setMessageByServer(boolean isMessageByServer) {
this.messageByServer = isMessageByServer;
}
public void setMessageText(String messageText) {
this.messageText = messageText;
}
public String getMessageGeneratedTime() {
return messageGeneratedTime;
}
public void setMessageGeneratedTime(String messageGeneratedTime) {
this.messageGeneratedTime = messageGeneratedTime;
}
public ChatMember getSender() {
return sender;
}
public void setSender(ChatMember sender) {
this.sender = sender;
}
public String getMessageText() {
return messageText;
}
public ChatMember getReceiver() {
return receiver;
}
public void setReceiver(ChatMember receiver) {
this.receiver = receiver;
}
}
File diff suppressed because it is too large. Load diff
@@ -1,67 +0,0 @@
package kst4contest.model;
public class ClusterMessage {
String timeGenerated;
ChatMember sender;
ChatMember receiver;
String qrgSpotted;
String qraOfSender;
String qraOfReceiver;
String messageInhibited;
boolean receiverWkd;
public boolean isReceiverWkd() {
return receiverWkd;
}
public void setReceiverWkd(boolean receiverWkd) {
this.receiverWkd = receiverWkd;
}
public String getTimeGenerated() {
return timeGenerated;
}
public void setTimeGenerated(String timeGenerated) {
this.timeGenerated = timeGenerated;
}
public ChatMember getSender() {
return sender;
}
public void setSender(ChatMember sender) {
this.sender = sender;
}
public ChatMember getReceiver() {
return receiver;
}
public void setReceiver(ChatMember receiver) {
this.receiver = receiver;
}
public String getQrgSpotted() {
return qrgSpotted;
}
public void setQrgSpotted(String qrgSpotted) {
this.qrgSpotted = qrgSpotted;
}
public String getQraOfSender() {
return qraOfSender;
}
public void setQraOfSender(String qraOfSender) {
this.qraOfSender = qraOfSender;
}
public String getQraOfReceiver() {
return qraOfReceiver;
}
public void setQraOfReceiver(String qraOfReceiver) {
this.qraOfReceiver = qraOfReceiver;
}
public String getMessageInhibited() {
return messageInhibited;
}
public void setMessageInhibited(String messageInhibited) {
this.messageInhibited = messageInhibited;
}
}
@@ -1,124 +0,0 @@
package kst4contest.model;
/**
* Represents a scheduled event or an AirScout opportunity in the future.
* Used for the Timeline View and Priority Calculation.
*
* <p>The base callsign remains the grouping key for scoring and worked-state
* handling. The exact KST login and its chat category are stored separately
* because reminders and external logger handover refer to the selected
* ChatMember entity.</p>
*/
public class ContestSked {
private String targetCallsign;
private String targetChatCallsign;
private ChatCategory targetChatCategory;
private double targetAzimuth;
private long skedTimeEpoch;
private Band band;
// Opportunity potential (0..100). -1 means "unknown".
int opportunityPotentialPercent = -1;
// Status flags to prevent spamming alarms.
private boolean warning3MinSent = false;
private boolean warningNowSent = false;
/**
* Backward-compatible constructor.
*/
public ContestSked(String call, double azimuth, long time, Band band) {
this(call, call, null, azimuth, time, band);
}
/**
* Creates a sked for one exact KST login.
*
* @param callRaw base callsign used for scoring and worked states
* @param chatCallsign exact KST login, including an optional dash suffix
* @param chatCategory category in which the selected login is active
* @param azimuth target azimuth
* @param time sked time in epoch milliseconds
* @param band selected amateur-radio band
*/
public ContestSked(String callRaw,
String chatCallsign,
ChatCategory chatCategory,
double azimuth,
long time,
Band band) {
this.targetCallsign = callRaw;
this.targetChatCallsign = chatCallsign;
this.targetChatCategory = chatCategory;
this.targetAzimuth = azimuth;
this.skedTimeEpoch = time;
this.band = band;
}
/**
* Returns the seconds remaining until the event.
* Negative values mean the event is in the past.
*/
public long getTimeUntilSkedSeconds() {
return (skedTimeEpoch - System.currentTimeMillis()) / 1000;
}
/**
* Returns the base callsign used for scoring and worked-state grouping.
*/
public String getTargetCallsign() {
return targetCallsign;
}
/**
* Returns the exact KST login selected when the sked was created.
*/
public String getTargetChatCallsign() {
if (targetChatCallsign == null || targetChatCallsign.isBlank()) {
return targetCallsign;
}
return targetChatCallsign;
}
public ChatCategory getTargetChatCategory() {
return targetChatCategory;
}
public double getTargetAzimuth() {
return targetAzimuth;
}
public long getSkedTimeEpoch() {
return skedTimeEpoch;
}
public Band getBand() {
return band;
}
public boolean isWarning3MinSent() {
return warning3MinSent;
}
public void setWarning3MinSent(boolean warning3MinSent) {
this.warning3MinSent = warning3MinSent;
}
public boolean isWarningNowSent() {
return warningNowSent;
}
public void setWarningNowSent(boolean warningNowSent) {
this.warningNowSent = warningNowSent;
}
public int getOpportunityPotentialPercent() {
return opportunityPotentialPercent;
}
public void setOpportunityPotentialPercent(int opportunityPotentialPercent) {
this.opportunityPotentialPercent = opportunityPotentialPercent;
}
}
@@ -1,103 +0,0 @@
package kst4contest.model;
/**
* Object for the description of the activity of a Thread to show these information in a View.
* <br/><br/>
* If state is critical, there could be used a further information field for the stacktrace
*/
public class ThreadStateMessage {
String threadNickName;
String threadDescription;
boolean running;
String runningInformationTextDescription;
String runningInformation;
boolean criticalState;
String criticalStateFurtherInfo;
public ThreadStateMessage(String threadNickName, boolean running, String runningInformation, boolean criticalState) {
this.threadNickName = threadNickName;
this.running = running;
this.criticalState = criticalState;
this.runningInformation = runningInformation;
}
/**
* This triggers the message for "Sked armed"
*
* @return
*/
public String getRunningInformationTextDescription() {
// If a custom description was set (e.g. for UI indicator buttons), prefer it.
if (runningInformationTextDescription != null && !runningInformationTextDescription.isBlank()) {
return runningInformationTextDescription;
}
// Fallback (legacy behavior)
if (isRunning()) {
return "on";
} else if (!isRunning() && isCriticalState()) {
return "FAILED";
} else {
return "off";
}
}
public void setRunningInformationTextDescription(String runningInformationTextDescription) {
this.runningInformationTextDescription = runningInformationTextDescription;
}
public String getThreadNickName() {
return threadNickName;
}
public void setThreadNickName(String threadNickName) {
this.threadNickName = threadNickName;
}
public String getThreadDescription() {
return threadDescription;
}
public void setThreadDescription(String threadDescription) {
this.threadDescription = threadDescription;
}
public boolean isRunning() {
return running;
}
public void setRunning(boolean running) {
this.running = running;
}
public String getRunningInformation() {
return runningInformation;
}
public void setRunningInformation(String runningInformation) {
this.runningInformation = runningInformation;
}
public boolean isCriticalState() {
return criticalState;
}
public void setCriticalState(boolean criticalState) {
this.criticalState = criticalState;
}
public String getCriticalStateFurtherInfo() {
return criticalStateFurtherInfo;
}
public void setCriticalStateFurtherInfo(String criticalStateFurtherInfo) {
this.criticalStateFurtherInfo = criticalStateFurtherInfo;
}
}
@@ -1,111 +0,0 @@
package kst4contest.model;
import java.util.ArrayList;
public class UpdateInformation {
// double latestVersionNumberOnServer = 1.26; //dummy value to prevent nullpointerexc
double latestVersionNumberOnServer = 0.0;
String latestSemanticVersionOnServer = "";
String adminMessage ="";
String majorChanges ="";
String latestVersionPathOnWebserver="";
ArrayList<String> needUpdateResourcesSinceLastVersion = new ArrayList<String>();
ArrayList<String[]> featureRequest = new ArrayList<String[]>();
ArrayList<String[]> bugRequests = new ArrayList<String[]>();
ArrayList<String[]> changeLog = new ArrayList<String[]>();
ArrayList<String[]> bugList = new ArrayList<String[]>();
public ArrayList<String[]> getBugList() {
return bugList;
}
public void setBugList(ArrayList<String[]> bugList) {
this.bugList = bugList;
}
public ArrayList<String[]> getChangeLog() {
return changeLog;
}
public void setChangeLog(ArrayList<String[]> changeLog) {
this.changeLog = changeLog;
}
public double getLatestVersionNumberOnServer() {
return latestVersionNumberOnServer;
}
public void setLatestVersionNumberOnServer(double latestVersionNumberOnServer) {
this.latestVersionNumberOnServer = latestVersionNumberOnServer;
}
public String getLatestSemanticVersionOnServer() {
return latestSemanticVersionOnServer;
}
public void setLatestSemanticVersionOnServer(String latestSemanticVersionOnServer) {
this.latestSemanticVersionOnServer =
latestSemanticVersionOnServer == null ? "" : latestSemanticVersionOnServer.trim();
}
public boolean hasSemanticVersion() {
return !latestSemanticVersionOnServer.isBlank();
}
public String getLatestVersionForDisplay() {
if (hasSemanticVersion()) {
return latestSemanticVersionOnServer;
}
return Double.toString(latestVersionNumberOnServer);
}
public String getAdminMessage() {
return adminMessage;
}
public void setAdminMessage(String adminMessage) {
this.adminMessage = adminMessage;
}
public String getMajorChanges() {
return majorChanges;
}
public void setMajorChanges(String majorChanges) {
this.majorChanges = majorChanges;
}
public String getLatestVersionPathOnWebserver() {
return latestVersionPathOnWebserver;
}
public void setLatestVersionPathOnWebserver(String latestVersionPathOnWebserver) {
this.latestVersionPathOnWebserver = latestVersionPathOnWebserver;
}
public ArrayList<String> getNeedUpdateResourcesSinceLastVersion() {
return needUpdateResourcesSinceLastVersion;
}
public void setNeedUpdateResourcesSinceLastVersion(ArrayList<String> needUpdateResourcesSinceLastVersion) {
this.needUpdateResourcesSinceLastVersion = needUpdateResourcesSinceLastVersion;
}
public ArrayList<String[]> getFeatureRequest() {
return featureRequest;
}
public void setFeatureRequest(ArrayList<String[]> featureRequest) {
this.featureRequest = featureRequest;
}
public ArrayList<String[]> getBugRequests() {
return bugRequests;
}
public void setBugRequests(ArrayList<String[]> bugRequests) {
this.bugRequests = bugRequests;
}
}
@@ -1,74 +0,0 @@
package kst4contest.service.path;
/**
* Utility methods for Fresnel zone calculations.
*
* <p>This helper intentionally contains only pure mathematical functions.
* It has no dependency on UI code or terrain providers.</p>
*/
public final class FresnelMathUtils {
/**
* Speed of light in vacuum in meters per second.
*/
public static final double SPEED_OF_LIGHT_METERS_PER_SECOND = 299_792_458.0;
private FresnelMathUtils() {
// Utility class
}
/**
* Computes the wavelength in meters for the given frequency.
*
* @param frequencyHz signal frequency in Hz
* @return wavelength in meters, or 0 if the input is invalid
*/
public static double computeWavelengthMeters(final double frequencyHz) {
if (frequencyHz <= 0.0) {
return 0.0;
}
return SPEED_OF_LIGHT_METERS_PER_SECOND / frequencyHz;
}
/**
* Computes the radius of the first Fresnel zone at a specific point
* along the path.
*
* <p>Formula:
* r = sqrt(lambda * d1 * d2 / (d1 + d2))</p>
*
* @param frequencyHz signal frequency in Hz
* @param distanceFromTxMeters distance from TX to the current point in meters
* @param totalPathDistanceMeters full TX-to-RX path length in meters
* @return Fresnel radius in meters, or 0 at invalid inputs / path ends
*/
public static double computeFirstFresnelRadiusMeters(
final double frequencyHz,
final double distanceFromTxMeters,
final double totalPathDistanceMeters) {
if (frequencyHz <= 0.0 || totalPathDistanceMeters <= 0.0) {
return 0.0;
}
final double clampedDistanceFromTxMeters = Math.max(
0.0,
Math.min(distanceFromTxMeters, totalPathDistanceMeters)
);
final double distanceFromPointToRxMeters = totalPathDistanceMeters - clampedDistanceFromTxMeters;
if (clampedDistanceFromTxMeters <= 0.0 || distanceFromPointToRxMeters <= 0.0) {
return 0.0;
}
final double wavelengthMeters = computeWavelengthMeters(frequencyHz);
return Math.sqrt(
wavelengthMeters
* clampedDistanceFromTxMeters
* distanceFromPointToRxMeters
/ totalPathDistanceMeters
);
}
}
@@ -1,73 +0,0 @@
package kst4contest.test;
import kst4contest.model.ChatPreferences;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatPreferencesStationMapVisibilityTest {
@TempDir
Path temporaryDirectory;
@Test
void pathAnalysisIsVisibleByDefault() {
ChatPreferences preferences = new ChatPreferences();
assertTrue(preferences.isGUIstationMapPathAnalysisVisible());
}
@Test
void hiddenPathAnalysisStateSurvivesXmlRoundTrip() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
ChatPreferences writtenPreferences = new ChatPreferences();
writtenPreferences.setStoreAndRestorePreferencesFileName(
preferencesFile.toString());
writtenPreferences.setGUIstationMapPathAnalysisVisible(false);
writtenPreferences.writePreferencesToXmlFile();
String writtenXml = Files.readString(preferencesFile);
assertTrue(writtenXml.contains(
"<GUIstationMapPathAnalysisVisible>false"
+ "</GUIstationMapPathAnalysisVisible>"));
ChatPreferences restoredPreferences = new ChatPreferences();
restoredPreferences.setStoreAndRestorePreferencesFileName(
preferencesFile.toString());
restoredPreferences.readPreferencesFromXmlFile();
assertFalse(restoredPreferences.isGUIstationMapPathAnalysisVisible());
}
@Test
void legacyXmlWithoutVisibilitySettingKeepsAnalysisDiscoverable()
throws IOException {
Path legacyPreferencesFile =
temporaryDirectory.resolve("legacy-preferences.xml");
Files.writeString(legacyPreferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>4</configVersion>
<guiOptions>
<GUIstationMapStageSceneSizeHW>1000.0;800.0</GUIstationMapStageSceneSizeHW>
</guiOptions>
</praktiKST>
""");
ChatPreferences restoredPreferences = new ChatPreferences();
restoredPreferences.setStoreAndRestorePreferencesFileName(
legacyPreferencesFile.toString());
restoredPreferences.readPreferencesFromXmlFile();
assertTrue(restoredPreferences.isGUIstationMapPathAnalysisVisible());
}
}
@@ -1,123 +0,0 @@
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,112 +0,0 @@
package kst4contest.test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import kst4contest.controller.MessageVariableResolver;
import kst4contest.model.AirPlane;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
class MessageVariableResolverTest {
private ChatPreferences chatPreferences;
private MessageVariableResolver resolver;
@BeforeEach
void setUp() {
chatPreferences = mock(ChatPreferences.class);
when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144.388.03"));
when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleStringProperty("432.088.00"));
when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO51IJ");
when(chatPreferences.getStn_loginCallSign()).thenReturn("DO5AMF");
when(chatPreferences.getActualQTF()).thenReturn(new SimpleDoubleProperty(135.0));
resolver = new MessageVariableResolver(chatPreferences);
}
@Test
void resolvesAllGlobalVariablesInEveryMessageContext() {
String template = "MYCALL MYQRGSHORT MYQRG SECONDQRG MYLOCATORSHORT MYLOCATOR MYQTF";
assertEquals(
"DO5AMF 144.388 144.388.03 432.088.00 JO51 JO51IJ 135",
resolver.resolveGlobalVariables(template)
);
}
@Test
void shortValuesRemainSafeWhenTheConfiguredValueIsShorterThanExpected() {
when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144"));
when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO5");
assertEquals("144 JO5", resolver.resolveGlobalVariables("MYQRGSHORT MYLOCATORSHORT"));
}
@Test
void resolvesSelectedStationNameAndTheFirstTwoAirPlanes() {
ChatMember selectedStation = new ChatMember();
selectedStation.setCallSign("DL0TEST");
selectedStation.setName("Test Operator");
AirPlane firstAirPlane = new AirPlane();
firstAirPlane.setPotential(100);
firstAirPlane.setArrivingDurationMinutes(1);
AirPlane secondAirPlane = new AirPlane();
secondAirPlane.setPotential(75);
secondAirPlane.setArrivingDurationMinutes(9);
AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo();
reflectionInfo.setRisingAirplanes(FXCollections.observableArrayList(firstAirPlane, secondAirPlane));
selectedStation.setAirPlaneReflectInfo(reflectionInfo);
assertEquals(
"Hi Test Operator, a very big AP in 1 min; Next big AP in 9 min",
resolver.resolveForSelectedStation(
"Hi QRZNAME, FIRSTAP; SECONDAP",
selectedStation
)
);
}
@Test
void usesCallsignWhenTheSelectedStationHasNoName() {
ChatMember selectedStation = new ChatMember();
selectedStation.setCallSign("DL0TEST");
selectedStation.setName(" ");
assertEquals(
"Hi DL0TEST",
resolver.resolveForSelectedStation("Hi QRZNAME", selectedStation)
);
}
@Test
void keepsStationVariablesVisibleWhenNoStationIsSelected() {
assertEquals(
"QRZNAME FIRSTAP SECONDAP",
resolver.resolveForSelectedStation("QRZNAME FIRSTAP SECONDAP", null)
);
}
@Test
void returnsUsefulFallbacksWhenNoAirPlaneIsAvailable() {
ChatMember selectedStation = new ChatMember();
selectedStation.setCallSign("DL0TEST");
assertEquals(
"no ap available ",
resolver.resolveForSelectedStation("FIRSTAP SECONDAP", selectedStation)
);
}
}
@@ -1,256 +0,0 @@
package kst4contest.test;
import java.io.*;
import java.net.*;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
public class MockKstServer {
private static final int PORT = 23001;
private static final String CHAT_ID = "2"; // 2 = 144/432 MHz
// Thread-sichere Liste aller verbundenen Clients (OutputStreams)
private final List<PrintWriter> clients = new CopyOnWriteArrayList<>();
// Permanente User (Ihre Test-Callsigns)
private final Map<String, User> onlineUsers = new HashMap<>();
// Historien müssen synchronisiert werden
private final List<String> historyChat = Collections.synchronizedList(new ArrayList<>());
private final List<String> historyDx = Collections.synchronizedList(new ArrayList<>());
private boolean running = false;
private ServerSocket serverSocket;
public MockKstServer() {
// Initiale Permanente User
addUser("DK5EW", "Erwin", "JN47NX");
addUser("DL1TEST", "TestOp", "JO50XX");
addUser("ON4KST", "Alain", "JO20HI");
addUser("PA9R-2", "2", "JO20HI");
addUser("PA9R-70", "70", "JO20HI");
addUser("PA9R", "general", "JO20HI");
}
// Startet den Server im Hintergrund (Non-Blocking)
public void start() {
if (running) return;
running = true;
new Thread(() -> {
try {
serverSocket = new ServerSocket(PORT);
System.out.println("[Server] ON4KST Simulation gestartet auf Port " + PORT);
// Startet den Simulator für Zufallstraffic
new Thread(this::simulationLoop).start();
while (running) {
Socket clientSocket = serverSocket.accept();
System.out.println("[Server] Neuer Client verbunden: " + clientSocket.getInetAddress());
new Thread(new ClientHandler(clientSocket)).start();
}
} catch (IOException e) {
if (running) e.printStackTrace();
}
}).start();
}
public void stop() {
running = false;
try {
if (serverSocket != null) serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
private void addUser(String call, String name, String loc) {
onlineUsers.put(call, new User(call, name, loc));
}
private void removeUser(String call) {
onlineUsers.remove(call);
}
// Sendet Nachricht an ALLE verbundenen Clients (inkl. Sender)
private void broadcast(String message) {
if (!message.endsWith("\r\n")) message += "\r\n";
String finalMsg = message;
for (PrintWriter writer : clients) {
try {
writer.print(finalMsg);
writer.flush(); // WICHTIG: Sofort senden!
} catch (Exception e) {
// Client wohl weg, wird beim nächsten Schreibversuch oder im Handler entfernt
}
}
}
// --- Innere Logik: Client Handler ---
private class ClientHandler implements Runnable {
private Socket socket;
private PrintWriter out;
private BufferedReader in;
private String myCall = "MYCLIENT"; // Default, wird bei LOGIN überschrieben
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
// ISO-8859-1 ist Standard für KST/Telnet Cluster
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "ISO-8859-1"));
out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "ISO-8859-1"), true);
clients.add(out);
String line;
boolean loginComplete = false;
while ((line = in.readLine()) != null) {
// System.out.println("[RECV] " + line); // Debugging aktivieren falls nötig
String[] parts = line.split("\\|");
String cmd = parts[0];
if (cmd.equals("LOGIN") || cmd.equals("LOGINC")) {
// Protokoll: LOGIN|callsign|password|... [cite: 21]
if (parts.length > 1) myCall = parts[1];
// 1. Login Bestätigung
// Format: LOGSTAT|100|chat id|client software version|session key|config|dx option|
send("LOGSTAT|100|" + CHAT_ID + "|JavaSim|KEY123|Config|3|");
// Bei LOGIN senden wir die Daten sofort
// Bei LOGINC warten wir eigentlich auf SDONE, senden hier aber vereinfacht direkt
if (cmd.equals("LOGIN")) {
sendInitialData();
loginComplete = true;
}
}
else if (cmd.equals("SDONE")) {
// Abschluss der Settings (bei LOGINC) [cite: 34]
sendInitialData();
loginComplete = true;
}
else if (cmd.equals("MSG")) {
// MSG|chat id|destination|command|0| [cite: 42]
if (parts.length >= 4) {
String text = parts[3];
// Nachricht sofort als CH Frame an alle verteilen (Echo)
handleChatMessage(myCall, "Me", text);
}
}
else if (cmd.equals("CK")) {
// Keepalive [cite: 20]
// Server muss nicht zwingend antworten, aber Connection bleibt offen
}
}
} catch (IOException e) {
// System.out.println("Client getrennt");
} finally {
clients.remove(out);
try { socket.close(); } catch (IOException e) {}
}
}
private void send(String msg) {
if (!msg.endsWith("\r\n")) msg += "\r\n";
out.print(msg);
out.flush();
}
private void sendInitialData() {
// 1. User Liste UA0 [cite: 14]
for (User u : onlineUsers.values()) {
send("UA0|" + CHAT_ID + "|" + u.call + "|" + u.name + "|" + u.loc + "|0|");
}
// 2. Chat History CR [cite: 7]
synchronized(historyChat) {
for (String h : historyChat) send(h);
}
// 3. DX History DL [cite: 10]
synchronized(historyDx) {
for (String d : historyDx) send(d);
}
// 4. Ende User Liste UE [cite: 15]
send("UE|" + CHAT_ID + "|" + onlineUsers.size() + "|");
}
}
// --- Hilfsmethoden für Traffic ---
private void handleChatMessage(String call, String name, String text) {
// CH|chat id|date|callsign|firstname|destination|msg|highlight|
String date = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String frame = String.format("CH|%s|%s|%s|%s|0|%s|0|", CHAT_ID, date, call, name, text);
synchronized(historyChat) {
historyChat.add(frame);
if (historyChat.size() > 50) historyChat.remove(0);
}
broadcast(frame);
}
private void handleDxSpot(String spotter, String dx, String freq) {
// DL|Unix time|dx utc|spotter|qrg|dx|info|spotter locator|dx locator| [cite: 10]
long unixTime = System.currentTimeMillis() / 1000;
String utc = new SimpleDateFormat("HHmm").format(new Date());
// Simple Dummy Locators
String frame = String.format("DL|%d|%s|%s|%s|%s|Simulated|JO00|JO99|",
unixTime, utc, spotter, freq, dx);
synchronized(historyDx) {
historyDx.add(frame);
if (historyDx.size() > 20) historyDx.remove(0);
}
broadcast(frame);
}
private void simulationLoop() {
String[] randomCalls = {"PA0GUS", "F6APE", "OH8K", "OZ2M", "G4CBW"};
String[] msgs = {"CQ 144.300", "Tnx for QSO", "Any sked?", "QRV 432.200"};
Random rand = new Random();
while (running) {
try {
Thread.sleep(3000 + rand.nextInt(5000)); // 3-8 Sek Pause
int action = rand.nextInt(10);
String call = randomCalls[rand.nextInt(randomCalls.length)];
if (action < 4) { // 40% Chat
handleChatMessage(call, "SimOp", msgs[rand.nextInt(msgs.length)]);
} else if (action < 7) { // 30% DX Spot
handleDxSpot(call, randomCalls[rand.nextInt(randomCalls.length)], "144." + rand.nextInt(400));
} else if (action == 8) { // Login Simulation UA5
if (!onlineUsers.containsKey(call)) {
addUser(call, "SimOp", "JO11");
broadcast("UA5|" + CHAT_ID + "|" + call + "|SimOp|JO11|2|");
}
} else if (action == 9) { // Logout Simulation UR6
if (onlineUsers.containsKey(call) && !call.equals("DK5EW")) { // DK5EW nicht kicken
removeUser(call);
broadcast("UR6|" + CHAT_ID + "|" + call + "|");
}
}
// Ping ab und zu
if (rand.nextInt(5) == 0) broadcast("CK|");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// Kleine Datenklasse
private static class User {
String call, name, loc;
User(String c, String n, String l) { this.call=c; this.name=n; this.loc=l; }
}
}
@@ -1,131 +0,0 @@
package kst4contest.test;
import javafx.beans.property.SimpleStringProperty;
import kst4contest.controller.Utils4KST;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PatternMatcherTest {
/**
* Tests if pattern matches with the given String.
*
* @param testString
* @param regExPattern
* @return true if match, else false
*/
private static boolean testPattern(String testString, String regExPattern) {
Pattern pattern = Pattern.compile(regExPattern);
Matcher matcher = pattern.matcher(testString);
return matcher.find();
}
/**
* Normalizes a chatmembers frequency-string for cluster usage<br/>
* <b>returns a frequency String in KHz like = "144300" or "144300.0" to match DXC protocol needs</b>
*
* @param optionalPrefix: if there is a value like ".300", it have to be decided, wich ".300": 144.300, 432.300, 1296.300 .... prefix means for example "144."
*/
private static String normalizeFrequencyString(String qrgString, String optionalPrefix) {
// final String PTRN_QRG_CAT2 = "(([0-9]{3,4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)";
final String PTRN_QRG_CAT2_wholeQRGMHz4Digits = "(([0-9]{4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)"; //1296.300.3 etc
final String PTRN_QRG_CAT2_wholeQRGMHz3Digits = "(([0-9]{3}[\\.|,| ]?[0-9]{3})([\\.][\\d]{1,2})?)"; //144.300.3 etc
final String PTRN_QRG_CAT2_QRGwithoutPrefix = "((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)"; //144.300.3 etc
String predefinedPrefixInMHz = optionalPrefix;
String stringAggregation = "";
if (testPattern(qrgString, PTRN_QRG_CAT2_wholeQRGMHz4Digits)) {
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".","");
stringAggregation = stringAggregation.replace(",","");
if (stringAggregation.length() == 8) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-1) + "." + stringAggregation.substring(stringAggregation.length()-1, stringAggregation.length());
stringAggregation = stringAggregationNew;
} else if (stringAggregation.length() == 9) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-2) + "." + stringAggregation.substring(stringAggregation.length()-2, stringAggregation.length());
stringAggregation = stringAggregationNew;
}
} else
if (testPattern(qrgString, PTRN_QRG_CAT2_wholeQRGMHz3Digits)) {
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".","");
stringAggregation = stringAggregation.replace(",","");
if (stringAggregation.length() == 7) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-1) + "." + stringAggregation.substring(stringAggregation.length()-1, stringAggregation.length());
stringAggregation = stringAggregationNew;
} else if (stringAggregation.length() == 8) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-2) + "." + stringAggregation.substring(stringAggregation.length()-2, stringAggregation.length());
stringAggregation = stringAggregationNew;
}
}
else
if (testPattern(qrgString, PTRN_QRG_CAT2_QRGwithoutPrefix)) { //case ".050 or .300 or something like that"
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".", "");
stringAggregation = stringAggregation.replace(",", "");
if (stringAggregation.length() == 3) { // like 050 or 300
String stringAggregationNew = optionalPrefix + stringAggregation;
stringAggregation = stringAggregationNew;
return stringAggregation;
} else if (stringAggregation.length() == 4) { //like 050.2 --> 0502
stringAggregation = optionalPrefix + stringAggregation;
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length() - 1) + "." + stringAggregation.substring(stringAggregation.length() - 1, stringAggregation.length());
stringAggregation = stringAggregationNew;
return stringAggregation;
} else if (stringAggregation.length() == 5) { //like 050.20 --> 05020
stringAggregation = optionalPrefix + stringAggregation;
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length() - 2) + "." + stringAggregation.substring(stringAggregation.length() - 2, stringAggregation.length());
stringAggregation = stringAggregationNew;
return stringAggregation;
}
}
return qrgString;
}
public static void main(String[] args) {
int i = 0;
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleStringProperty("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleStringProperty("144")));
}
}
@@ -1,63 +0,0 @@
package kst4contest.test;
import javafx.beans.property.SimpleStringProperty;
import kst4contest.controller.Utils4KST;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class Utils4KSTFrequencyTest {
@Test
void convertsCompleteFrequenciesAcrossAllSupportedPrefixLengths() {
assertEquals("50200.0", convert("50.200", "144"));
assertEquals("70250.0", convert("70,250", "144"));
assertEquals("144205.0", convert("144.205", "432"));
assertEquals("432088.0", convert("432.088", "144"));
assertEquals("1296338.0", convert("1296.338", "144"));
assertEquals("10368100.0", convert("10368.100", "144"));
assertEquals("24048100.0", convert("24048.100", "144"));
}
@Test
void retainsSubKhzPrecision() {
assertEquals("144205.2", convert("144.205.2", "432"));
assertEquals("1296338.25", convert("1296.338.25", "144"));
}
@Test
void convertsCompactLegacyFormats() {
assertEquals("432088.0", convert("432088", "144"));
assertEquals("10368100.0", convert("10368100", "144"));
assertEquals("432088.2", convert("432088.2", "144"));
assertEquals("432088.0", convert("432 088", "144"));
}
@Test
void appliesFallbackOnlyToRelativeFrequencies() {
assertEquals("432205.0", convert(".205", "432"));
assertEquals("144205.0", convert("205", "144"));
assertEquals("10368300.0", convert(".300", "10368"));
}
@Test
void rejectsMissingOrUnsupportedValues() {
assertEquals("", convert(null, "144"));
assertEquals("", convert("", "144"));
assertEquals("", convert("not-a-frequency", "144"));
assertEquals("", convert("599", "144"));
assertEquals("", convert(".205", null));
assertEquals("", convert(".205", "invalid"));
}
private String convert(String frequency, String fallbackPrefix) {
SimpleStringProperty fallback = fallbackPrefix == null
? null
: new SimpleStringProperty(fallbackPrefix);
return Utils4KST.normalizeFrequencyString(
frequency,
fallback
);
}
}
@@ -1,120 +0,0 @@
package kst4contest.utils;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.FileHandler;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
/** Controls the persistent application log without redirecting stdout/stderr. */
public final class ApplicationFileLogging implements AutoCloseable {
/** Logger namespace owned by the application. */
private static final String APP_LOGGER_NAME = "kst4contest";
/** Runtime switch for additional application diagnostics. */
private final AtomicBoolean debugEnabled = new AtomicBoolean();
/** Strong reference to the root logger that owns the file handler. */
private final Logger rootLogger = Logger.getLogger("");
/** Strong reference to the application logger whose level is managed. */
private final Logger appLogger = Logger.getLogger(APP_LOGGER_NAME);
/** Application logger level that must be restored when this handler closes. */
private final Level previousAppLevel;
/** File handler installed on the root logger. */
private final Handler handler;
/**
* Installs one append-only file handler.
*
* @param logFile target log file
* @param debugEnabled whether INFO/FINE application diagnostics are included
* @throws IOException if the handler cannot be created
*/
public ApplicationFileLogging(
final Path logFile,
final boolean debugEnabled
)
throws IOException {
final Path parent = logFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
this.debugEnabled.set(debugEnabled);
previousAppLevel = appLogger.getLevel();
appLogger.setLevel(Level.FINE);
final FileHandler fileHandler =
new FileHandler(logFile.toString(), true);
fileHandler.setLevel(Level.ALL);
fileHandler.setFilter(this::isLoggable);
fileHandler.setFormatter(new UtcLogFormatter());
handler = fileHandler;
rootLogger.addHandler(handler);
}
/** Applies the runtime debug preference to subsequent file log records. */
public void setDebugEnabled(final boolean enabled) {
debugEnabled.set(enabled);
}
private boolean isLoggable(final LogRecord record) {
boolean loggable = record.getThrown() != null
|| record.getLevel().intValue() >= Level.WARNING.intValue();
if (!loggable) {
final String loggerName = record.getLoggerName();
final boolean appRecord = loggerName != null
&& (APP_LOGGER_NAME.equals(loggerName)
|| loggerName.startsWith(APP_LOGGER_NAME + "."));
loggable = debugEnabled.get()
&& appRecord
&& record.getLevel().intValue() >= Level.FINE.intValue();
}
return loggable;
}
/** Removes and closes this handler. */
@Override
public void close() {
rootLogger.removeHandler(handler);
handler.close();
appLogger.setLevel(previousAppLevel);
}
/** Formats file-log records with comparable UTC millisecond timestamps. */
private static final class UtcLogFormatter extends Formatter {
/** Timestamp format for every file-log record. */
private static final DateTimeFormatter TIMESTAMP =
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'")
.withZone(ZoneOffset.UTC);
@Override
public String format(final LogRecord record) {
final StringBuilder line = new StringBuilder()
.append(TIMESTAMP.format(Instant.ofEpochMilli(
record.getMillis())))
.append('\t')
.append(record.getLevel().getName())
.append('\t')
.append(record.getLoggerName())
.append('\t')
.append(formatMessage(record))
.append(System.lineSeparator());
if (record.getThrown() != null) {
final StringWriter stackTrace = new StringWriter();
record.getThrown().printStackTrace(new PrintWriter(stackTrace));
line.append(stackTrace);
}
return line.toString();
}
}
}
@@ -1,72 +0,0 @@
package kst4contest.utils;
import kst4contest.controller.DBController;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* This class has utility methods to handle application files inside the home directory.
*/
public class ApplicationFileUtils {
private static final Logger LOGGER = Logger.getLogger(ApplicationFileUtils.class.getName());
/**
* Gets the path of a file inside the home directory of the user.
* @param applicationName Name off the application which is used for the hidden directory
* @param fileName Name of the file.
* @return The full path inside the user home directory.
*/
public static String getFilePath(final String applicationName, final String fileName) {
return Path.of(System.getProperty("user.home"),
"." + applicationName,
fileName).toString();
}
/**
* Copies a resource to the application folder if required.
* @param applicationName Name of the application.
* @param resourcePath Path of the resource.
* @param fileName Name of the target file.
*/
public static void copyResourceIfRequired(final String applicationName, final String resourcePath, final String fileName) {
File file = new File(getFilePath(applicationName, fileName));
if (file.exists()) {
return;
}
File parentDir = file.getParentFile();
if (!parentDir.exists()) {
parentDir.mkdirs();
}
copyResourceToFile(resourcePath, file.getPath());
}
/**
* Copies a given resource at a resourcePath to the file path.
* @param resourcePath
* @param filePath
*/
public static void copyResourceToFile(String resourcePath, String filePath) {
try (InputStream resourceStream = ApplicationFileUtils.class.getResourceAsStream(resourcePath);
FileOutputStream fileOutputStream = new FileOutputStream(filePath)) {
if (resourceStream == null) {
throw new IllegalArgumentException("Resource not found: " + resourcePath);
}
resourceStream.transferTo(fileOutputStream);
} catch (IOException ex) {
LOGGER.log(Level.SEVERE, "Exception when copying Application file: " + ex.getMessage(), ex);
}
}
}
@@ -1,168 +0,0 @@
package kst4contest.utils;
import javafx.collections.ObservableListBase;
import java.util.Arrays;
/**
* A bounded ObservableList backed by a circular buffer (ring buffer).
* <p>
* Provides O(1) {@link #addFirst} and {@link #addLast} as well as O(1)
* random access via {@link #get}. When the list reaches {@code maxCapacity},
* adding a new element at the front automatically evicts the oldest element
* at the back — and vice versa.
* <p>
* This is a drop-in replacement for {@code FXCollections.observableArrayList()}
* wherever elements are prepended frequently, e.g. chat message lists.
*/
public class BoundedDequeObservableList<E> extends ObservableListBase<E> {
private final int maxCapacity;
private final Object[] elements;
private int head = 0;
private int size = 0;
public BoundedDequeObservableList(int maxCapacity) {
if (maxCapacity <= 0) throw new IllegalArgumentException("maxCapacity must be > 0");
this.maxCapacity = maxCapacity;
this.elements = new Object[maxCapacity];
}
// ── read access ──────────────────────────────────────────────────────────
@Override
public int size() {
return size;
}
@Override
@SuppressWarnings("unchecked")
public E get(int index) {
checkIndex(index);
return (E) elements[physicalIndex(index)];
}
// ── O(1) deque operations ─────────────────────────────────────────────────
/**
* Inserts {@code element} at index 0 (newest-first order).
* If the list is already at capacity the oldest element (last index) is
* removed first — both changes are reported as a single compound change.
*/
public void addFirst(E element) {
beginChange();
if (size == maxCapacity) {
// evict last element
int lastPhysical = physicalIndex(size - 1);
@SuppressWarnings("unchecked")
E evicted = (E) elements[lastPhysical];
elements[lastPhysical] = null;
size--;
nextRemove(size, evicted); // index after decrement == old last index
}
head = (head - 1 + maxCapacity) % maxCapacity;
elements[head] = element;
size++;
nextAdd(0, 1);
endChange();
}
/**
* Appends {@code element} at the last index (oldest-first order).
* If the list is already at capacity the newest element (index 0) is
* removed first.
*/
public void addLast(E element) {
beginChange();
if (size == maxCapacity) {
// evict first element
@SuppressWarnings("unchecked")
E evicted = (E) elements[head];
elements[head] = null;
head = (head + 1) % maxCapacity;
size--;
nextRemove(0, evicted);
}
elements[physicalIndex(size)] = element;
size++;
nextAdd(size - 1, size);
endChange();
}
// ── standard List mutation (O(n) — use addFirst/addLast for hot path) ─────
@Override
public void add(int index, E element) {
if (index == 0) {
addFirst(element);
return;
}
if (index == size) {
addLast(element);
return;
}
checkIndexForAdd(index);
beginChange();
if (size == maxCapacity) {
int lastPhysical = physicalIndex(size - 1);
@SuppressWarnings("unchecked")
E evicted = (E) elements[lastPhysical];
elements[lastPhysical] = null;
size--;
nextRemove(size, evicted);
}
// shift elements [index .. size-1] one position towards the end
for (int i = size; i > index; i--) {
elements[physicalIndex(i)] = elements[physicalIndex(i - 1)];
}
elements[physicalIndex(index)] = element;
size++;
nextAdd(index, index + 1);
endChange();
}
@Override
public E remove(int index) {
checkIndex(index);
beginChange();
@SuppressWarnings("unchecked")
E removed = (E) elements[physicalIndex(index)];
// shift elements [index+1 .. size-1] one position towards the front
for (int i = index; i < size - 1; i++) {
elements[physicalIndex(i)] = elements[physicalIndex(i + 1)];
}
elements[physicalIndex(size - 1)] = null;
size--;
nextRemove(index, removed);
endChange();
return removed;
}
@Override
public E set(int index, E element) {
checkIndex(index);
beginChange();
@SuppressWarnings("unchecked")
E old = (E) elements[physicalIndex(index)];
elements[physicalIndex(index)] = element;
nextSet(index, old);
endChange();
return old;
}
// ── helpers ───────────────────────────────────────────────────────────────
private int physicalIndex(int virtualIndex) {
return (head + virtualIndex) % maxCapacity;
}
private void checkIndex(int index) {
if (index < 0 || index >= size)
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}
private void checkIndexForAdd(int index) {
if (index < 0 || index > size)
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}
}
@@ -1,491 +0,0 @@
package kst4contest.utils;
import javazoom.jl.decoder.JavaLayerException;
import javazoom.jl.player.Player;
import kst4contest.ApplicationConstants;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* This part of the client drives the sounds. Its a singleton instance. All audio outs are directed to this instance.<br>
* <br>
* */
public class PlayAudioUtils {
/**
* Default constructor initializes the sound files and copies it to the project home folder
*/
public PlayAudioUtils() {
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3", "NOISESTARTUP.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/NOISECQWINDOW.mp3", "NOISECQWINDOW.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/NOISEPMWINDOW.mp3", "NOISEPMWINDOW.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/NOISEERROR.mp3", "NOISEERROR.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/NOISENOTIFY.mp3", "NOISENOTIFY.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/tick.mp3", "tick.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRA.mp3", "LTTRA.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRB.mp3", "LTTRB.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRC.mp3", "LTTRC.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRD.mp3", "LTTRD.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRE.mp3", "LTTRE.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRF.mp3", "LTTRF.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRG.mp3", "LTTRG.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRH.mp3", "LTTRH.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRI.mp3", "LTTRI.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRJ.mp3", "LTTRJ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRK.mp3", "LTTRK.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRL.mp3", "LTTRL.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRM.mp3", "LTTRM.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRN.mp3", "LTTRN.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRO.mp3", "LTTRO.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRP.mp3", "LTTRP.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRQ.mp3", "LTTRQ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRR.mp3", "LTTRR.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRS.mp3", "LTTRS.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRT.mp3", "LTTRT.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRU.mp3", "LTTRU.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRV.mp3", "LTTRV.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRW.mp3", "LTTRW.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRX.mp3", "LTTRX.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRY.mp3", "LTTRY.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRZ.mp3", "LTTRZ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR0.mp3", "LTTR0.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR1.mp3", "LTTR1.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR2.mp3", "LTTR2.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR3.mp3", "LTTR3.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR4.mp3", "LTTR4.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR5.mp3", "LTTR5.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR6.mp3", "LTTR6.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR7.mp3", "LTTR7.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR8.mp3", "LTTR8.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTR9.mp3", "LTTR9.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRSTROKE.mp3", "LTTRSTROKE.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/LTTRSPACE.mp3", "LTTRSPACE.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEA.mp3", "VOICEA.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEB.mp3", "VOICEB.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEC.mp3", "VOICEC.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICED.mp3", "VOICED.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEE.mp3", "VOICEE.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEF.mp3", "VOICEF.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEG.mp3", "VOICEG.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEH.mp3", "VOICEH.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEI.mp3", "VOICEI.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEJ.mp3", "VOICEJ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEK.mp3", "VOICEK.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEL.mp3", "VOICEL.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEM.mp3", "VOICEM.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEN.mp3", "VOICEN.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEO.mp3", "VOICEO.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEP.mp3", "VOICEP.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEQ.mp3", "VOICEQ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICER.mp3", "VOICER.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICES.mp3", "VOICES.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICET.mp3", "VOICET.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEU.mp3", "VOICEU.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEV.mp3", "VOICEV.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEW.mp3", "VOICEW.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEX.mp3", "VOICEX.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEY.mp3", "VOICEY.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEZ.mp3", "VOICEZ.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE0.mp3", "VOICE0.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE1.mp3", "VOICE1.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE2.mp3", "VOICE2.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE3.mp3", "VOICE3.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE4.mp3", "VOICE4.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE5.mp3", "VOICE5.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE6.mp3", "VOICE6.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE7.mp3", "VOICE7.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE8.mp3", "VOICE8.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE9.mp3", "VOICE9.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKE.mp3", "VOICESTROKE.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEBELL.mp3", "VOICEBELL.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEYOUGOTMAIL.mp3", "VOICEYOUGOTMAIL.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICEHELLO.mp3", "VOICEHELLO.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICE73.mp3", "VOICE73.mp3");
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKEPORTABLE.mp3", "VOICESTROKEPORTABLE.mp3");
}
private final Queue<String> musicList = new ConcurrentLinkedQueue<>();
/** True once audio fails; prevents repeated error logs and further play attempts. */
private final AtomicBoolean audioUnavailable = new AtomicBoolean(false);
/** True while the drain loop is running; prevents duplicate concurrent drains. */
private final AtomicBoolean playing = new AtomicBoolean(false);
private final ExecutorService audioThread = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "audio-player");
t.setDaemon(true);
return t;
});
/**
* Plays notification sounds out of the windws 95 box by given action character<br/>
*<br/>
*
* case '!': Startup<br/>
* case '-': tick<br/>
* case 'C': CQ Window new entry<br/>
* case 'P': PM Window new entry<br/>
* case 'E': Error occured<br/>
* case 'N': other notification sounds<br/>
*
* @param actionChar
*/
public void playNoiseLauncher(char actionChar) {
switch (actionChar){
case '-':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/tick.mp3"));
break;
case '!':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3"));
break;
case 'C':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISECQWINDOW.mp3"));
break;
case 'P':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEPMWINDOW.mp3"));
break;
case 'E':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEERROR.mp3"));
break;
case 'N':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISENOTIFY.mp3"));
break;
default:
System.out.println("[KST4ContestApp, warning, letter not defined!]");
}
playMusic();
}
/**
* Plays all chars of a given String-parameter as CW Sound out of the speaker.
* As a workaround for delay problems at the beginning of playing, there are added 2x pause chars to the string.
*
* @param playThisChars
*/
public void playCWLauncher(String playThisChars) {
char[] playThisInCW = playThisChars.toUpperCase().toCharArray();
for (char letterToPlay: playThisInCW){
switch (letterToPlay){
case 'A':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRA.mp3"));
break;
case 'B':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRB.mp3"));
break;
case 'C':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRC.mp3"));
break;
case 'D':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRD.mp3"));
break;
case 'E':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRE.mp3"));
break;
case 'F':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRF.mp3"));
break;
case 'G':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRG.mp3"));
break;
case 'H':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRH.mp3"));
break;
case 'I':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRI.mp3"));
break;
case 'J':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRJ.mp3"));
break;
case 'K':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRK.mp3"));
break;
case 'L':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRL.mp3"));
break;
case 'M':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRM.mp3"));
break;
case 'N':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRN.mp3"));
break;
case 'O':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRO.mp3"));
break;
case 'P':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRP.mp3"));
break;
case 'Q':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRQ.mp3"));
break;
case 'R':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRR.mp3"));
break;
case 'S':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRS.mp3"));
break;
case 'T':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRT.mp3"));
break;
case 'U':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRU.mp3"));
break;
case 'V':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRV.mp3"));
break;
case 'W':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRW.mp3"));
break;
case 'X':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRX.mp3"));
break;
case 'Y':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRY.mp3"));
break;
case 'Z':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRZ.mp3"));
break;
case '1':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR1.mp3"));
break;
case '2':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR2.mp3"));
break;
case '3':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR3.mp3"));
break;
case '4':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR4.mp3"));
break;
case '5':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR5.mp3"));
break;
case '6':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR6.mp3"));
break;
case '7':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR7.mp3"));
break;
case '8':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR8.mp3"));
break;
case '9':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR9.mp3"));
break;
case '0':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR0.mp3"));
break;
case '/':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSTROKE.mp3"));
break;
case ' ':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSPACE.mp3"));
break;
default:
System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(playThisInCW));
}
}
playMusic();
}
/**
*
* Plays a voice file for each char in the string (only EN alphabetic and numbers) except some specials: <br/><br/>
* <b>Note that the audio settings (ChatPreferences) must be switched on in order to make the sounds playing.</b><br/><br/>
* case '!': BELL<br/>
* case '?': YOUGOTMAIL<br/>
* case '#': HELLO<br/>
* case '*': 73 bye<br/>
* case '$': STROKEPORTABLE<br/>
* @param playThisChars
*/
public void playVoiceLauncher(String playThisChars) {
char[] spellThisWithVoice = playThisChars.toUpperCase().toCharArray();
for (char letterToPlay: spellThisWithVoice){
switch (letterToPlay){
case '!':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEBELL.mp3"));
break;
case '?':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEYOUGOTMAIL.mp3"));
break;
case '#':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEHELLO.mp3"));
break;
case '*':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE73.mp3"));
break;
case '$':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKEPORTABLE.mp3"));
break;
case 'A':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEA.mp3"));
break;
case 'B':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEB.mp3"));
break;
case 'C':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEC.mp3"));
break;
case 'D':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICED.mp3"));
break;
case 'E':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEE.mp3"));
break;
case 'F':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEF.mp3"));
break;
case 'G':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEG.mp3"));
break;
case 'H':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEH.mp3"));
break;
case 'I':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEI.mp3"));
break;
case 'J':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEJ.mp3"));
break;
case 'K':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEK.mp3"));
break;
case 'L':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEL.mp3"));
break;
case 'M':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEM.mp3"));
break;
case 'N':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEN.mp3"));
break;
case 'O':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEO.mp3"));
break;
case 'P':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEP.mp3"));
break;
case 'Q':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEQ.mp3"));
break;
case 'R':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICER.mp3"));
break;
case 'S':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICES.mp3"));
break;
case 'T':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICET.mp3"));
break;
case 'U':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEU.mp3"));
break;
case 'V':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEV.mp3"));
break;
case 'W':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEW.mp3"));
break;
case 'X':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEX.mp3"));
break;
case 'Y':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEY.mp3"));
break;
case 'Z':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEZ.mp3"));
break;
case '1':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE1.mp3"));
break;
case '2':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE2.mp3"));
break;
case '3':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE3.mp3"));
break;
case '4':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE4.mp3"));
break;
case '5':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE5.mp3"));
break;
case '6':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE6.mp3"));
break;
case '7':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE7.mp3"));
break;
case '8':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE8.mp3"));
break;
case '9':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE9.mp3"));
break;
case '0':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE0.mp3"));
break;
case '/':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKE.mp3"));
break;
default:
System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(spellThisWithVoice));
}
}
playMusic();
}
private void playMusic() {
if (audioUnavailable.get()) {
musicList.clear();
return;
}
// Only start a drain loop if none is running; the running loop will pick up new items itself.
if (!playing.compareAndSet(false, true)) return;
audioThread.submit(() -> {
String path;
while ((path = musicList.poll()) != null) {
if (audioUnavailable.get()) break;
try (FileInputStream fis = new FileInputStream(path);
BufferedInputStream bis = new BufferedInputStream(fis)) {
new Player(bis).play();
} catch (IOException | JavaLayerException e) {
audioUnavailable.set(true);
musicList.clear();
System.out.println("[KST4ContestApp, warning, audio playback disabled (could not create media player): " + e + "]");
break;
}
}
playing.set(false);
// Items may have been enqueued between the last poll() and playing.set(false);
// restart the drain loop if so.
if (!musicList.isEmpty() && !audioUnavailable.get()) {
playMusic();
}
});
}
}
@@ -1,19 +0,0 @@
package kst4contest.utils;
import javafx.application.Application;
import javafx.stage.Stage;
import kst4contest.utils.PlayAudioUtils;
public class TestAudioPlayerUtils extends Application {
public static void main(String[] args) {
}
@Override
public void start(Stage stage) throws Exception {
PlayAudioUtils testAudio = new PlayAudioUtils();
testAudio.playCWLauncher("DO5AMF");
}
}
@@ -1,68 +0,0 @@
package kst4contest.utils;
public final class VersionUtils {
private VersionUtils() {
}
/**
* Compares numeric release versions such as 1.41, 1.41.1 and 1.41.10.
*
* Pre-release and build suffixes are ignored because the update feed
* currently publishes stable releases only.
*/
public static int compareStableVersions(String left, String right) {
int[] leftParts = parseVersion(left);
int[] rightParts = parseVersion(right);
int partCount = Math.max(leftParts.length, rightParts.length);
for (int index = 0; index < partCount; index++) {
int leftPart = index < leftParts.length ? leftParts[index] : 0;
int rightPart = index < rightParts.length ? rightParts[index] : 0;
int comparison = Integer.compare(leftPart, rightPart);
if (comparison != 0) {
return comparison;
}
}
return 0;
}
private static int[] parseVersion(String version) {
if (version == null || version.isBlank()) {
throw new IllegalArgumentException("Version must not be empty");
}
String normalized = version.trim();
if (normalized.startsWith("v") || normalized.startsWith("V")) {
normalized = normalized.substring(1);
}
int hyphenIndex = normalized.indexOf('-');
int plusIndex = normalized.indexOf('+');
int suffixIndex;
if (hyphenIndex < 0) {
suffixIndex = plusIndex;
} else if (plusIndex < 0) {
suffixIndex = hyphenIndex;
} else {
suffixIndex = Math.min(hyphenIndex, plusIndex);
}
if (suffixIndex >= 0) {
normalized = normalized.substring(0, suffixIndex);
}
String[] textParts = normalized.split("\\.");
int[] numericParts = new int[textParts.length];
for (int index = 0; index < textParts.length; index++) {
numericParts[index] = Integer.parseInt(textParts[index]);
}
return numericParts;
}
}
@@ -1,123 +0,0 @@
package kst4contest.view;
import kst4contest.controller.ChatController;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javafx.scene.image.Image;
import javafx.stage.Stage;
import java.net.URL;
public class GuiUtils {
private static final String APPLICATION_ICON_RESOURCE = "/icons/kst4contest.png";
private static Image applicationIcon;
/**
* Applies the common KST4Contest application icon to a JavaFX stage.
*
* <p>The icon is loaded only once and reused for all application windows.
* A missing icon resource must never prevent a window from opening.</p>
*
* @param stage stage that should receive the application icon
*/
public static void applyApplicationIcon(Stage stage) {
if (stage == null) {
return;
}
Image icon = getApplicationIcon();
if (icon != null && !stage.getIcons().contains(icon)) {
stage.getIcons().add(icon);
}
}
/**
* Loads and caches the common KST4Contest application icon.
*
* @return application icon or null if the resource is unavailable
*/
private static Image getApplicationIcon() {
if (applicationIcon != null) {
return applicationIcon;
}
URL iconUrl = GuiUtils.class.getResource(APPLICATION_ICON_RESOURCE);
if (iconUrl == null) {
System.err.println(
"Application icon resource not found: "
+ APPLICATION_ICON_RESOURCE
);
return null;
}
applicationIcon = new Image(iconUrl.toExternalForm());
return applicationIcon;
}
private static final String PTRN_CALLSIGNSYNTAX = "^(?:[A-Z]{1,2}[0-9]|[0-9][A-Z])[0-9A-Z]{1,3}$";
/**
* Checks wheter the input value of the String is numeric or not, true if yes
* TODO: Move to a utils class for checking input values by user...
* @param str
* @return
*/
static boolean isNumeric(String str){
return str != null && str.matches("[0-9.]+");
}
/**
* Checks wheter the given String has a HAM radio callsign syntax or not
* @param maybeCallSignValue
* @return true if yes
*/
static boolean isCallSignSyntax(String maybeCallSignValue) {
Pattern pattern = Pattern.compile(PTRN_CALLSIGNSYNTAX, Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(maybeCallSignValue);
try {
if (matcher.find()) {
return true;
}
else return false;
} catch (Exception exc) {
return false;
}
}
public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) {
if (javafx.application.Platform.isFxApplicationThread()) {
triggerUpdate(chatController);
} else{
javafx.application.Platform.runLater(() -> triggerUpdate(chatController));
}
}
/**
* Requests a safe UI refresh of the filtered ChatMember list.
*
* <p>Older versions used the trick of adding/removing a dummy predicate. That can
* break JavaFX SortedList internals when the table is sorted and a FilteredList
* refilter happens at the same time. The controller-level refresh path is safer
* because Kst4ContestApplication now re-applies the existing predicates directly.</p>
*
* @param chatController central controller
*/
private static void triggerUpdate(ChatController chatController) {
if (chatController == null) {
return;
}
chatController.fireUserListUpdate("Forced filtered ChatMember refresh");
}
}
File diff suppressed because it is too large. Load diff
@@ -1,50 +0,0 @@
package kst4contest.view;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.util.Duration;
import kst4contest.model.ChatPreferences;
import java.util.Objects;
/**
* Coalesces JavaFX layout changes into selective preferences writes.
*/
public final class LayoutAutosave {
private static final Duration SAVE_DELAY = Duration.millis(750);
private final ChatPreferences preferences;
private final PauseTransition saveDelay = new PauseTransition(SAVE_DELAY);
private boolean pending;
public LayoutAutosave(ChatPreferences preferences) {
this.preferences = Objects.requireNonNull(preferences, "preferences");
saveDelay.setOnFinished(event -> flushPending());
}
public void requestSave() {
if (!Platform.isFxApplicationThread()) {
Platform.runLater(this::requestSave);
return;
}
pending = true;
saveDelay.playFromStart();
}
public void flushPending() {
if (!pending) {
return;
}
saveDelay.stop();
pending = false;
preferences.writeLayoutPreferencesToXmlFile();
}
public void cancelPending() {
saveDelay.stop();
pending = false;
}
}
-15
View File
@@ -1,15 +0,0 @@
package kst4contest.view;
/**
* Main class with simple main method.
* Workaround to avoid Error that JavaFX is missing.
*/
public class Main {
/**
* Main method that simply calls the main method inside our application class.
* @param args Commandline Arguments.
*/
public static void main(String[] args) {
Kst4ContestApplication.main(args);
}
}
@@ -1,327 +0,0 @@
package kst4contest.view;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.TableCell;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import java.net.URI;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Displays a single-line message text in a TableView.
*
* <p>The cell provides two functions that JavaFX's default text cell cannot
* combine:
* <ul>
* <li>a tooltip containing the complete text, but only while the visible
* cell is too narrow for that text;</li>
* <li>clickable HTTP, HTTPS and www links inside otherwise normal text.</li>
* </ul>
*
* <p>The row type is generic because the same implementation is used by the
* ChatMessage tables and the DX-cluster table.</p>
*
* @param <S> row type of the surrounding TableView
*/
public final class MessageTextTableCell<S> extends TableCell<S, String> {
private static final Pattern WEB_LINK_PATTERN = Pattern.compile(
"(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+"
);
private static final String TRAILING_LINK_PUNCTUATION = ".,;:!?)]}";
private final Consumer<String> linkOpener;
private final Predicate<String> highlightedTextPredicate;
private final String normalStyleClass;
private final String highlightedStyleClass;
private final HBox contentBox = new HBox(0);
private final Rectangle contentClip = new Rectangle();
private final Tooltip fullTextTooltip = new Tooltip();
private String displayedText = "";
private boolean fullTextTooltipInstalled = false;
/**
* Creates a normal message cell without additional text highlighting.
*
* @param linkOpener callback which opens a validated HTTP or HTTPS URL
*/
public MessageTextTableCell(Consumer<String> linkOpener) {
this(linkOpener, text -> false, null, null);
}
/**
* Creates a message cell with optional CSS highlighting.
*
* <p>KST4Contest uses this variant for public messages which contain the
* operator's own callsign. Only the supplied CSS classes are added or
* removed. The standard {@code table-cell} class and all other JavaFX
* state remain untouched.</p>
*
* @param linkOpener callback which opens a validated URL
* @param highlightedTextPredicate identifies highlighted messages
* @param normalStyleClass CSS class for normal messages, may be null
* @param highlightedStyleClass CSS class for highlighted messages, may be null
*/
public MessageTextTableCell(
Consumer<String> linkOpener,
Predicate<String> highlightedTextPredicate,
String normalStyleClass,
String highlightedStyleClass
) {
this.linkOpener = Objects.requireNonNull(linkOpener, "linkOpener");
this.highlightedTextPredicate = highlightedTextPredicate == null
? text -> false
: highlightedTextPredicate;
this.normalStyleClass = normalStyleClass;
this.highlightedStyleClass = highlightedStyleClass;
contentBox.setAlignment(Pos.CENTER_LEFT);
contentBox.setFillHeight(false);
contentBox.setClip(contentClip);
fullTextTooltip.setWrapText(true);
fullTextTooltip.setMaxWidth(800);
fullTextTooltip.setShowDelay(Duration.millis(250));
fullTextTooltip.setShowDuration(Duration.seconds(30));
setContentDisplay(javafx.scene.control.ContentDisplay.GRAPHIC_ONLY);
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
removeOptionalStyleClasses();
contentBox.getChildren().clear();
if (empty || item == null || item.isEmpty()) {
displayedText = "";
fullTextTooltip.setText("");
setText(null);
setGraphic(null);
setClippedTooltipActive(false);
return;
}
displayedText = item;
fullTextTooltip.setText(item);
applyOptionalStyleClass(item);
buildContent(item);
setText(null);
setGraphic(contentBox);
updateClipAndTooltip();
}
@Override
protected void layoutChildren() {
super.layoutChildren();
updateClipAndTooltip();
}
/**
* Splits the displayed text into ordinary labels and clickable links.
*/
private void buildContent(String text) {
Matcher matcher = WEB_LINK_PATTERN.matcher(text);
int nextPlainTextStart = 0;
while (matcher.find()) {
String rawMatch = matcher.group();
String linkText = removeTrailingPunctuation(rawMatch);
if (linkText.isEmpty()) {
continue;
}
appendPlainText(text.substring(nextPlainTextStart, matcher.start()));
appendLink(linkText);
/*
* Punctuation removed from the URL remains ordinary message text.
* Starting here ensures it is included by the next substring.
*/
nextPlainTextStart = matcher.start() + linkText.length();
}
appendPlainText(text.substring(nextPlainTextStart));
}
private void appendPlainText(String text) {
if (text == null || text.isEmpty()) {
return;
}
Label textFragment = new Label(text);
textFragment.setPadding(Insets.EMPTY);
textFragment.setMinWidth(Region.USE_PREF_SIZE);
textFragment.setMaxWidth(Region.USE_PREF_SIZE);
textFragment.setMouseTransparent(true);
textFragment.textFillProperty().bind(textFillProperty());
textFragment.getStyleClass().add("message-text-fragment");
contentBox.getChildren().add(textFragment);
}
private void appendLink(String linkText) {
Hyperlink hyperlink = new Hyperlink(linkText);
hyperlink.setPadding(Insets.EMPTY);
hyperlink.setMinWidth(Region.USE_PREF_SIZE);
hyperlink.setMaxWidth(Region.USE_PREF_SIZE);
hyperlink.setFocusTraversable(false);
hyperlink.getStyleClass().add("message-table-link");
hyperlink.setOnAction(event -> {
openLink(linkText);
event.consume();
});
contentBox.getChildren().add(hyperlink);
}
/**
* Opens only HTTP and HTTPS targets. A visible www address receives an
* HTTPS scheme before it is passed to the application.
*/
private void openLink(String linkText) {
try {
String normalizedLink = linkText.toLowerCase(Locale.ROOT).startsWith("www.")
? "https://" + linkText
: linkText;
URI uri = URI.create(normalizedLink);
String scheme = uri.getScheme();
if (scheme == null
|| (!scheme.equalsIgnoreCase("http")
&& !scheme.equalsIgnoreCase("https"))) {
return;
}
linkOpener.accept(uri.toASCIIString());
} catch (RuntimeException exception) {
System.out.println(
"[MessageTextTableCell] Cannot open malformed link: "
+ linkText
+ " / "
+ exception.getMessage()
);
}
}
/**
* Removes punctuation which commonly follows a link in normal prose.
*/
private String removeTrailingPunctuation(String rawLink) {
String result = rawLink;
while (!result.isEmpty()
&& TRAILING_LINK_PUNCTUATION.indexOf(
result.charAt(result.length() - 1)
) >= 0) {
result = result.substring(0, result.length() - 1);
}
return result;
}
/**
* Clips the one-line content and installs the full-text tooltip only if
* the rendered nodes are wider than the usable cell area.
*/
private void updateClipAndTooltip() {
if (displayedText.isEmpty() || getGraphic() == null) {
setClippedTooltipActive(false);
return;
}
double availableWidth = Math.max(
0,
getWidth() - snappedLeftInset() - snappedRightInset()
);
double availableHeight = Math.max(
0,
getHeight() - snappedTopInset() - snappedBottomInset()
);
contentClip.setWidth(availableWidth);
contentClip.setHeight(availableHeight);
boolean textIsClipped = contentBox.prefWidth(-1) > availableWidth + 1;
setClippedTooltipActive(textIsClipped);
}
/**
* Installs the tooltip on the actual graphic node below the mouse pointer.
*
* <p>Installing it on the TableCell itself is not reliable when the cell
* displays an HBox containing labels and hyperlinks. The graphic node is
* the effective mouse target. Installation is tracked explicitly so
* repeated layout passes neither add duplicate handlers nor leave a
* tooltip attached to a reused empty cell.</p>
*/
private void setClippedTooltipActive(boolean active) {
/*
* The tooltip is handled exclusively by the graphic node. Keeping a
* second tooltip on the TableCell would allow two competing tooltip
* targets for the same visible content.
*/
setTooltip(null);
if (active == fullTextTooltipInstalled) {
return;
}
if (active) {
Tooltip.install(contentBox, fullTextTooltip);
} else {
fullTextTooltip.hide();
Tooltip.uninstall(contentBox, fullTextTooltip);
}
fullTextTooltipInstalled = active;
}
private void applyOptionalStyleClass(String item) {
boolean highlighted;
try {
highlighted = highlightedTextPredicate.test(item);
} catch (RuntimeException exception) {
highlighted = false;
}
String styleClass = highlighted
? highlightedStyleClass
: normalStyleClass;
if (styleClass != null
&& !styleClass.isBlank()
&& !getStyleClass().contains(styleClass)) {
getStyleClass().add(styleClass);
}
}
private void removeOptionalStyleClasses() {
if (normalStyleClass != null) {
getStyleClass().remove(normalStyleClass);
}
if (highlightedStyleClass != null) {
getStyleClass().remove(highlightedStyleClass);
}
}
}
@@ -1,83 +0,0 @@
package kst4contest.view;
import java.util.List;
/**
* Selects the CSS style class used for a private-message table row.
*/
public final class PrivateMessageRowStyleResolver {
/** Style used for messages sent by the local station. */
public static final String OWN_STYLE_CLASS =
"messageHighlightOwn-column";
/** Upper inclusive age bounds for the private-message color levels. */
private static final List<Long> AGE_LIMITS = List.of(
30L,
60L,
90L,
120L,
180L,
300L
);
/** Style classes corresponding to the configured age bounds. */
private static final List<String> AGE_STYLES = List.of(
"messageHighlight30-column",
"messageHighlight60-column",
"messageHighlight90-column",
"messageHighlight120-column",
"messageHighlight180-column",
"messageHighlight300-column"
);
/** All private-message row classes managed by the row factory. */
private static final List<String> MANAGED_STYLES = List.of(
OWN_STYLE_CLASS,
AGE_STYLES.get(0),
AGE_STYLES.get(1),
AGE_STYLES.get(2),
AGE_STYLES.get(3),
AGE_STYLES.get(4),
AGE_STYLES.get(5)
);
private PrivateMessageRowStyleResolver() {
}
/**
* Returns the complete set of private-message row classes managed by the
* row factory.
*
* @return immutable list of managed style classes
*/
public static List<String> knownStyleClasses() {
return MANAGED_STYLES;
}
/**
* Selects the private-message row class for the supplied message age.
*
* @param ownMessage whether the message was sent by the local station
* @param ageSeconds message age in seconds
* @return managed CSS class, or {@code null} after the five-minute window
*/
public static String resolveStyleClass(
final boolean ownMessage,
final long ageSeconds
) {
String styleClass = null;
if (ownMessage) {
styleClass = OWN_STYLE_CLASS;
} else {
for (int index = 0; index < AGE_LIMITS.size(); index++) {
if (ageSeconds <= AGE_LIMITS.get(index)) {
styleClass = AGE_STYLES.get(index);
break;
}
}
}
return styleClass;
}
}
@@ -1,280 +0,0 @@
package kst4contest.view;
import javafx.application.Platform;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.scene.Node;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.input.MouseEvent;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import kst4contest.model.ChatPreferences;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalDouble;
/**
* Applies persisted leaf-column widths and performs one content-based initial
* sizing pass when no width has been stored yet.
*/
public final class TableLayoutManager {
private static final double CELL_HORIZONTAL_PADDING = 16.0;
private static final double DEFAULT_MINIMUM_WIDTH = 24.0;
private TableLayoutManager() {
}
public static ColumnSpec column(String id, TableColumn<?, String> column) {
return new ColumnSpec(id, column);
}
public static <S> void install(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
ColumnSpec... columnSpecs
) {
Objects.requireNonNull(table, "table");
Objects.requireNonNull(tableId, "tableId");
Objects.requireNonNull(preferences, "preferences");
Objects.requireNonNull(autosave, "autosave");
Map<TableColumn<?, String>, ColumnState> states = new IdentityHashMap<>();
for (ColumnSpec spec : columnSpecs) {
if (spec == null || !spec.column.getColumns().isEmpty()) {
continue;
}
if (spec.column.prefWidthProperty().isBound()) {
spec.column.prefWidthProperty().unbind();
}
spec.column.setId(tableId + "." + spec.id);
OptionalDouble storedWidth = preferences.getTableColumnWidth(tableId, spec.id);
ColumnState state = new ColumnState(spec, storedWidth.isEmpty());
states.put(spec.column, state);
if (storedWidth.isPresent()) {
setWidth(state, storedWidth.getAsDouble());
state.initialized = true;
}
spec.column.widthProperty().addListener((observable, oldWidth, newWidth) -> {
if (state.adjusting || !state.initialized || newWidth == null) {
return;
}
storeWidth(preferences, autosave, tableId, state.spec.id, newWidth.doubleValue());
});
}
installEarlyManualResizeDetection(table, tableId, preferences, autosave, states);
scheduleInitialSizingWhenUsable(table, tableId, preferences, autosave, states);
}
private static <S> void scheduleInitialSizingWhenUsable(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
if (states.values().stream().noneMatch(state -> !state.initialized)) {
return;
}
if (table.getItems() != null && !table.getItems().isEmpty()) {
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
return;
}
@SuppressWarnings("unchecked")
final ListChangeListener<S>[] holder = new ListChangeListener[1];
holder[0] = change -> {
if (table.getItems() == null || table.getItems().isEmpty()) {
return;
}
table.getItems().removeListener(holder[0]);
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
};
table.getItems().addListener(holder[0]);
}
private static <S> void sizePendingColumns(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
for (ColumnState state : states.values()) {
if (state.initialized) {
continue;
}
double width = state.spec.flexible
? flexibleInitialWidth(table, state.spec)
: contentInitialWidth(table, state.spec);
setWidth(state, width);
state.initialized = true;
storeWidth(preferences, autosave, tableId, state.spec.id, width);
}
}
private static <S> double contentInitialWidth(TableView<S> table, ColumnSpec spec) {
Text measurement = new Text();
measurement.setFont(Font.getDefault());
double requiredWidth = measure(spec.column.getText(), measurement);
for (int rowIndex = 0; rowIndex < table.getItems().size(); rowIndex++) {
ObservableValue<?> value = spec.column.getCellObservableValue(rowIndex);
if (value == null || value.getValue() == null) {
continue;
}
requiredWidth = Math.max(
requiredWidth,
measure(String.valueOf(value.getValue()), measurement)
);
}
return calculateInitialContentWidth(
requiredWidth,
spec.minimumWidth,
spec.maximumInitialWidth
);
}
private static <S> double flexibleInitialWidth(TableView<S> table, ColumnSpec spec) {
double tableShare = table.getWidth() > 1.0 ? table.getWidth() * 0.42 : spec.flexibleFallbackWidth;
Text measurement = new Text();
measurement.setFont(Font.getDefault());
double headerWidth = measure(spec.column.getText(), measurement) + CELL_HORIZONTAL_PADDING;
return clamp(Math.max(headerWidth, tableShare), spec.minimumWidth, spec.maximumInitialWidth);
}
private static <S> void installEarlyManualResizeDetection(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
Map<TableColumn<?, String>, Double> widthsAtHeaderPress = new IdentityHashMap<>();
table.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
if (!isColumnHeaderEvent(event)) {
return;
}
widthsAtHeaderPress.clear();
states.forEach((column, state) -> widthsAtHeaderPress.put(column, column.getWidth()));
});
table.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> {
if (widthsAtHeaderPress.isEmpty()) {
return;
}
states.forEach((column, state) -> {
Double oldWidth = widthsAtHeaderPress.get(column);
if (oldWidth == null || Math.abs(oldWidth - column.getWidth()) <= 0.5) {
return;
}
state.initialized = true;
storeWidth(preferences, autosave, tableId, state.spec.id, column.getWidth());
});
widthsAtHeaderPress.clear();
});
}
private static boolean isColumnHeaderEvent(MouseEvent event) {
Object target = event.getTarget();
Node node = target instanceof Node ? (Node) target : null;
while (node != null && node.getParent() != null) {
if (node.getStyleClass().contains("column-header")
|| node.getStyleClass().contains("nested-column-header")) {
return true;
}
node = node.getParent();
}
return false;
}
private static void setWidth(ColumnState state, double width) {
state.adjusting = true;
try {
state.spec.column.setPrefWidth(width);
} finally {
state.adjusting = false;
}
}
private static void storeWidth(
ChatPreferences preferences,
LayoutAutosave autosave,
String tableId,
String columnId,
double width
) {
preferences.setTableColumnWidth(tableId, columnId, width);
autosave.requestSave();
}
private static double measure(String value, Text measurement) {
measurement.setText(value == null ? "" : value);
return measurement.getLayoutBounds().getWidth();
}
private static double clamp(double value, double minimum, double maximum) {
return Math.max(minimum, Math.min(value, maximum));
}
/**
* Calculates a compact content width. Package-private for focused sizing tests.
*/
@SuppressWarnings("PMD.CommentDefaultAccessModifier")
static double calculateInitialContentWidth(
final double measuredWidth,
final double minimum,
final double maximum
) {
return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, maximum);
}
public static final class ColumnSpec {
private final String id;
private final TableColumn<?, String> column;
private double minimumWidth = DEFAULT_MINIMUM_WIDTH;
private double maximumInitialWidth = Double.MAX_VALUE;
private double flexibleFallbackWidth = 320.0;
private boolean flexible;
private ColumnSpec(String id, TableColumn<?, String> column) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Column id must not be blank");
}
this.id = id;
this.column = Objects.requireNonNull(column, "column");
}
public ColumnSpec maximumInitialWidth(double maximumInitialWidth) {
this.maximumInitialWidth = maximumInitialWidth;
return this;
}
public ColumnSpec flexible(double fallbackWidth) {
flexible = true;
flexibleFallbackWidth = fallbackWidth;
return this;
}
}
private static final class ColumnState {
private final ColumnSpec spec;
private boolean adjusting;
private boolean initialized;
private ColumnState(ColumnSpec spec, boolean awaitingInitialSizing) {
this.spec = spec;
initialized = !awaitingInitialSizing;
}
}
}
@@ -1,376 +0,0 @@
package kst4contest.view;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Line;
import javafx.scene.shape.Polygon;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.paint.Color;
import javafx.scene.effect.DropShadow;
import javafx.scene.Node;
import javafx.scene.Group;
import javafx.scene.text.Font;
import kst4contest.model.ContestSked;
import kst4contest.model.ChatCategory;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* A custom UI Component that visualizes future events (Skeds/AP).
* It changes opacity based on the current antenna direction.
*
* Extended:
* - Can also render "priority candidates" (ScoreService top list) with time base = next AirScout airplane minute.
* - Clicking a candidate triggers a callback (selection + /cq preparation happens in Kst4ContestApplication).
*/
public class TimelineView extends Pane {
private double currentAntennaAzimuth = 0;
private double beamWidth = 50.0; // TODO: from Prefs (later)
private final long PREVIEW_TIME_MS = 30 * 60 * 1000; // 30 Minutes Preview
double margin = 30; // enough space for the callsign label
private Function<ContestSked, String> skedTooltipExtraTextProvider; //used for further info in sked tooltip
private Consumer<CandidateEvent> onCandidateClicked;
public TimelineView() {
this.setPrefHeight(40);
this.setStyle("-fx-background-color: #2b2b2b;");
// weitere init defaults, falls du welche hattest
}
/**
* Backward compatibility: if some code still calls the old ctor.
* Potential/tooltip are not view-wide properties; they belong to markers/events.
*/
@Deprecated
public TimelineView(int opportunityPotentialPercent, String tooltipText) {
this(); // ignore args on purpose
}
//
// public int getOpportunityPotentialPercent() {
// return opportunityPotentialPercent;
// }
public void setCurrentAntennaAzimuth(double az) {
this.currentAntennaAzimuth = az;
}
public long getPreviewTimeMs() {
return PREVIEW_TIME_MS;
}
public void setOnCandidateClicked(Consumer<CandidateEvent> handler) {
this.onCandidateClicked = handler;
}
/** Backward compatible call (Skeds only) */
public void updateVisuals(List<ContestSked> skeds) {
updateVisuals(skeds, Collections.emptyList());
}
/**
* Redraws the timeline based on the list of active skeds AND priority candidates.
*/
public void updateVisuals(List<ContestSked> skeds, List<CandidateEvent> candidates) {
this.getChildren().clear();
double width = this.getWidth();
if (width <= 5) {
// Layout not ready yet; will be updated by caller later (uiPulse/list change)
return;
}
// Draw Axis
double axisY = 30;
Line axis = new Line(0, axisY, width, axisY);
axis.setStroke(Color.GRAY);
this.getChildren().add(axis);
long now = System.currentTimeMillis();
// 1) Draw Priority Candidates (upper lanes)
for (CandidateEvent ev : candidates) {
long timeDiff = ev.getTimeUntilMs();
if (timeDiff < 0 || timeDiff > PREVIEW_TIME_MS) continue;
double percent = (double) timeDiff / PREVIEW_TIME_MS;
double xPos = percent * width;
xPos = Math.max(margin, Math.min(this.getWidth() - margin, xPos)); //starting point of the diagram
Node marker = createCandidateMarker(ev);
applyAntennaEffect(marker, ev.getTargetAzimuth());
// Upper lanes so they don't overlap skeds
double laneBaseY = 2;
double laneOffsetY = 14.0 * ev.getLaneIndex();
marker.setLayoutX(xPos);
marker.setLayoutY(laneBaseY + laneOffsetY);
this.getChildren().add(marker);
}
// 2) Draw Skeds (lower lane)
for (ContestSked sked : skeds) {
long timeDiff = sked.getSkedTimeEpoch() - now;
// Only draw if within the next 30 mins
if (timeDiff >= 0 && timeDiff <= PREVIEW_TIME_MS) {
double percent = (double) timeDiff / PREVIEW_TIME_MS;
double xPos = percent * width;
xPos = clamp(xPos, 10, width - 10);
Node marker = createSkedMarker(sked);
applyAntennaEffect(marker, sked.getTargetAzimuth());
marker.setLayoutX(xPos);
marker.setLayoutY(axisY - 18); // below candidate lanes, near axis
this.getChildren().add(marker);
}
}
}
private double clamp(double v, double min, double max) {
return Math.max(min, Math.min(max, v));
}
/**
* Logic:
* If Antenna is ON TARGET -> Bright & Glowing.
* If Antenna is OFF TARGET -> Transparent (Ghost).
*/
private void applyAntennaEffect(Node marker, double targetAz) {
// invalid azimuth -> keep readable
if (!Double.isFinite(targetAz) || targetAz < 0) {
return;
}
double delta = Math.abs(currentAntennaAzimuth - targetAz);
if (delta > 180) delta = 360 - delta;
final boolean onTarget = delta <= (beamWidth / 2.0);
final boolean inBeam = delta <= beamWidth;
// Rule: only fade when we are clearly NOT pointing there
final double iconOpacity = inBeam ? 1.0 : 0.30;
if (marker instanceof Group g) {
// Never fade the whole group -> text stays readable
g.setOpacity(1.0);
for (Node child : g.getChildren()) {
if (child instanceof Label) {
child.setOpacity(1.0);
} else {
child.setOpacity(iconOpacity);
}
}
// Add glow only if well centered (optional)
if (onTarget) {
g.setEffect(new DropShadow(10, Color.LIMEGREEN));
g.setScaleX(1.10);
g.setScaleY(1.10);
} else {
g.setEffect(null);
g.setScaleX(1.0);
g.setScaleY(1.0);
}
return;
}
// fallback
marker.setOpacity(iconOpacity);
marker.setEffect(onTarget ? new DropShadow(10, Color.LIMEGREEN) : null);
}
public void setSkedTooltipExtraTextProvider(Function<ContestSked, String> provider) {
this.skedTooltipExtraTextProvider = provider;
}
/** Existing marker for Skeds (diamond + label) */
private Node createSkedMarker(ContestSked sked) {
Polygon diamond = new Polygon(0.0, 0.0, 6.0, 6.0, 0.0, 12.0, -6.0, 6.0);
diamond.setFill(colorForPotential(sked.getOpportunityPotentialPercent()));
String baseToolTipFallBack =
sked.getTargetChatCallsign()
+ " ("
+ sked.getBand()
+ ")\nAz: "
+ sked.getTargetAzimuth();
if (skedTooltipExtraTextProvider != null) {
String extra = skedTooltipExtraTextProvider.apply(sked);
if (extra != null && !extra.isBlank()) {
baseToolTipFallBack += "\n" + extra;
}
}
Tooltip t = new Tooltip(baseToolTipFallBack);
Tooltip.install(diamond, t);
Label lbl = new Label(
"SKED: " + sked.getTargetChatCallsign()
);
// lbl.setFont(new Font(9));
// lbl.setTextFill(Color.WHITE);
lbl.setLayoutY(14);
lbl.setLayoutX(-10);
lbl.setStyle(
"-fx-text-fill: white;" +
"-fx-font-weight: bold;" +
"-fx-background-color: rgba(0,0,0,0.65);" +
"-fx-background-radius: 6;" +
"-fx-padding: 1 4 1 4;"
);
lbl.setEffect(new DropShadow(2, Color.BLACK));
return new Group(diamond, lbl);
}
/**
* Give me a color for a given potencial of a AP reflection
*
* @param p AS potencial
* @return
*/
private Color colorForPotential(int p) {
if (p >= 95) return Color.MAGENTA; // ~100%
if (p >= 75) return Color.RED;
if (p >= 50) return Color.YELLOW;
return Color.DEEPSKYBLUE; // low potential
}
/** New marker for Priority Candidates (triangle + label) */
private Node createCandidateMarker(CandidateEvent ev) {
// Color derived from airplane potential (not urgency)
Color markerColor = colorForPotential(ev.getOpportunityPotentialPercent());
// small triangle marker (points downwards)
Polygon tri = new Polygon(-6.0, 0.0, 6.0, 0.0, 0.0, 10.0);
tri.setFill(markerColor);
// Optional: small dot behind triangle (makes it easier to see)
Circle dot = new Circle(4, markerColor);
dot.setLayoutY(5); // center behind triangle
Label lbl = new Label(ev.getDisplayCallSign());
lbl.setFont(new Font(9));
lbl.setTextFill(Color.WHITE);
lbl.setLayoutY(10);
lbl.setLayoutX(-12);
lbl.setStyle(
"-fx-text-fill: white;" +
"-fx-font-weight: bold;" +
"-fx-background-color: rgba(0,0,0,0.65);" +
"-fx-background-radius: 6;" +
"-fx-padding: 1 4 1 4;"
);
lbl.setEffect(new DropShadow(8, Color.BLACK));
// IMPORTANT: include dot + triangle + label in the Group
Group g = new Group(dot, tri, lbl);
if (ev.getTooltipText() != null && !ev.getTooltipText().isBlank()) {
Tooltip.install(g, new Tooltip(ev.getTooltipText()));
}
g.setOnMouseClicked(e -> {
if (onCandidateClicked != null) {
onCandidateClicked.accept(ev);
}
});
return g;
}
/**
* Data object rendered by the Timeline ("priority candidate").
* Created in Kst4ContestApplication from ScoreService TopCandidates + AirScout next-AP minute.
*/
public static class CandidateEvent {
private final String callSignRaw;
private final String displayCallSign;
private final ChatCategory preferredChatCategory;
private final long timeUntilMs;
private final int minuteBucket;
private final int laneIndex;
private final double targetAzimuth;
private final double score;
private final String tooltipText;
private final int opportunityPotentialPercent;
public CandidateEvent(
String callSignRaw,
String displayCallSign,
ChatCategory preferredChatCategory,
long timeUntilMs,
int minuteBucket,
int laneIndex,
double targetAzimuth,
double score,
int opportunityPotentialPercent,
String tooltipText
) {
this.callSignRaw = callSignRaw;
this.displayCallSign = displayCallSign;
this.preferredChatCategory = preferredChatCategory;
this.timeUntilMs = timeUntilMs;
this.minuteBucket = minuteBucket;
this.laneIndex = laneIndex;
this.targetAzimuth = targetAzimuth;
this.score = score;
this.opportunityPotentialPercent = opportunityPotentialPercent;
this.tooltipText = tooltipText;
}
public String getCallSignRaw() { return callSignRaw; }
public String getDisplayCallSign() { return displayCallSign; }
public ChatCategory getPreferredChatCategory() { return preferredChatCategory; }
public long getTimeUntilMs() { return timeUntilMs; }
public int getMinuteBucket() { return minuteBucket; }
public int getLaneIndex() { return laneIndex; }
public double getTargetAzimuth() { return targetAzimuth; }
public double getScore() { return score; }
public String getTooltipText() { return tooltipText; }
public int getOpportunityPotentialPercent() { return opportunityPotentialPercent; }
}
public void setBeamWidthDeg(double beamWidthDeg) {
if (beamWidthDeg > 0 && Double.isFinite(beamWidthDeg)) {
this.beamWidth = beamWidthDeg;
}
}
}
@@ -1,109 +0,0 @@
package kst4contest.view;
import javafx.scene.control.TableCell;
import javafx.scene.control.Tooltip;
import javafx.scene.text.Text;
import javafx.util.Duration;
import java.util.function.Function;
import java.util.function.BiFunction;
/**
* Displays text normally and exposes the full value only when it is clipped.
* An optional functional explanation remains available and is combined with
* the full value when both are needed.
*/
public class TruncatedTextTableCell<S> extends TableCell<S, String> {
private final Function<String, String> formatter;
private final BiFunction<S, String, String> functionalTooltipProvider;
private final Tooltip tooltip = new Tooltip();
private final Text textMeasurement = new Text();
private String fullText = "";
public TruncatedTextTableCell() {
this(Function.identity(), null);
}
public TruncatedTextTableCell(Function<String, String> formatter) {
this(formatter, null);
}
public TruncatedTextTableCell(
Function<String, String> formatter,
BiFunction<S, String, String> functionalTooltipProvider
) {
this.formatter = formatter == null ? Function.identity() : formatter;
this.functionalTooltipProvider = functionalTooltipProvider;
tooltip.setWrapText(true);
tooltip.setMaxWidth(800);
tooltip.setShowDelay(Duration.millis(250));
tooltip.setShowDuration(Duration.seconds(30));
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
fullText = "";
setText(null);
setGraphic(null);
setTooltip(null);
return;
}
String formatted = formatter.apply(item);
fullText = formatted == null ? "" : formatted;
setText(fullText);
setGraphic(null);
updateTooltip();
}
@Override
protected void layoutChildren() {
super.layoutChildren();
updateTooltip();
}
private void updateTooltip() {
if (isEmpty()) {
setTooltip(null);
return;
}
String functionalText = resolveFunctionalTooltip();
boolean clipped = isTextClipped();
String tooltipText = TruncatedTextTooltipSupport.buildTooltipText(
fullText,
clipped,
functionalText
);
if (tooltipText == null) {
setTooltip(null);
return;
}
tooltip.setText(tooltipText);
setTooltip(tooltip);
}
private String resolveFunctionalTooltip() {
if (functionalTooltipProvider == null || getTableRow() == null) {
return null;
}
return functionalTooltipProvider.apply(getTableRow().getItem(), fullText);
}
private boolean isTextClipped() {
if (fullText.isEmpty()) {
return false;
}
textMeasurement.setText(fullText);
textMeasurement.setFont(getFont());
double requiredWidth = textMeasurement.getLayoutBounds().getWidth();
double availableWidth = Math.max(0.0,
getWidth() - snappedLeftInset() - snappedRightInset() - 2.0);
return TruncatedTextTooltipSupport.isTextClipped(requiredWidth, availableWidth);
}
}
@@ -1,28 +0,0 @@
package kst4contest.view;
/**
* Pure tooltip decisions kept separate from JavaFX controls for unit testing.
*/
final class TruncatedTextTooltipSupport {
private TruncatedTextTooltipSupport() {
}
static boolean isTextClipped(double requiredWidth, double availableWidth) {
return requiredWidth > availableWidth + 1.0;
}
static String buildTooltipText(String fullText, boolean clipped, String functionalText) {
boolean hasFunctionalText = functionalText != null && !functionalText.isBlank();
if (!clipped && !hasFunctionalText) {
return null;
}
if (!clipped) {
return functionalText;
}
if (!hasFunctionalText) {
return fullText;
}
return fullText + "\n\n" + functionalText;
}
}
@@ -1,36 +0,0 @@
package kst4contest.view.map;
import java.util.List;
/**
* Tries terrain providers in order and returns the first usable profile.
*/
public final class ChainedTerrainProfileProvider implements TerrainProfileProvider {
private final List<TerrainProfileProvider> providers;
public ChainedTerrainProfileProvider(List<TerrainProfileProvider> providers) {
this.providers = providers == null ? List.of() : List.copyOf(providers);
}
@Override
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
TerrainProfileData lastResult = TerrainProfileData.empty("No terrain provider");
for (TerrainProfileProvider provider : providers) {
if (provider == null) {
continue;
}
TerrainProfileData currentResult = provider.loadProfile(request);
if (currentResult != null) {
lastResult = currentResult;
if (currentResult.hasUsableProfile()) {
return currentResult;
}
}
}
return lastResult;
}
}
@@ -1,293 +0,0 @@
package kst4contest.view.map;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.image.Raster;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Offline terrain provider for locally extracted Copernicus GLO-30 DGED DEM tiles.
*
* <p>Assumptions of this reader:
* <ul>
* <li>local tiles already exist on disk</li>
* <li>official DGED GeoTIFF filenames are used</li>
* <li>tiles represent 1° x 1° geocells</li>
* <li>the raster uses RasterPixelIsPoint semantics</li>
* </ul>
*
* <p>The active improvement step uses great-circle interpolation for the
* sampled path points. This avoids the path distortion of simple linear
* latitude/longitude interpolation on longer Europe-wide paths.</p>
*/
public final class CopernicusGlo30TerrainProfileProvider implements TerrainProfileProvider {
private static final String SOURCE_NAME = "Copernicus GLO-30 offline DEM";
private static final double NODATA_VALUE = -32767.0;
private static final int MAX_LOADED_TILES = 8;
private final Supplier<String> demRootDirectorySupplier;
private final OfflineDemManager offlineDemManager;
private final Map<Path, LoadedTile> loadedTileCache =
new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Path, LoadedTile> eldest) {
return size() > MAX_LOADED_TILES;
}
};
public CopernicusGlo30TerrainProfileProvider(Supplier<String> demRootDirectorySupplier,
OfflineDemManager offlineDemManager) {
this.demRootDirectorySupplier = Objects.requireNonNull(demRootDirectorySupplier, "demRootDirectorySupplier");
this.offlineDemManager = Objects.requireNonNull(offlineDemManager, "offlineDemManager");
}
@Override
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) {
return TerrainProfileData.empty(SOURCE_NAME);
}
OfflineDemManager.OfflineDemIndex demIndex =
offlineDemManager.inspectAndIndex(demRootDirectorySupplier.get(), DemDataset.COPERNICUS_GLO_30);
if (!demIndex.usable()) {
return TerrainProfileData.empty(SOURCE_NAME + " unavailable");
}
int sampleCount = Math.max(2, request.requestedSampleCount());
List<PathProfilePoint> points = new ArrayList<>(sampleCount);
for (int i = 0; i < sampleCount; i++) {
double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1);
PathGeometryUtils.GeoPoint interpolatedPoint =
PathGeometryUtils.interpolateGreatCirclePoint(
request.fromLatitudeDeg(),
request.fromLongitudeDeg(),
request.toLatitudeDeg(),
request.toLongitudeDeg(),
t
);
double latitudeDeg = interpolatedPoint.latitudeDeg();
double longitudeDeg = interpolatedPoint.longitudeDeg();
double distanceKm = request.totalDistanceKm() * t;
if (!Double.isFinite(latitudeDeg) || !Double.isFinite(longitudeDeg)) {
return TerrainProfileData.empty(SOURCE_NAME + " path interpolation failed");
}
Path tilePath = demIndex.findTilePath(latitudeDeg, longitudeDeg);
if (tilePath == null) {
return TerrainProfileData.empty(String.format(
Locale.US,
"%s missing required tile(s) near %.5f / %.5f",
SOURCE_NAME,
latitudeDeg,
longitudeDeg
));
}
LoadedTile loadedTile = getOrLoadTile(tilePath);
if (loadedTile == null) {
return TerrainProfileData.empty(SOURCE_NAME + " tile read failed");
}
double elevationMeters = sampleElevationMeters(loadedTile, latitudeDeg, longitudeDeg);
if (!Double.isFinite(elevationMeters)) {
return TerrainProfileData.empty(String.format(
Locale.US,
"%s contains no-data sample(s) near %.5f / %.5f",
SOURCE_NAME,
latitudeDeg,
longitudeDeg
));
}
points.add(new PathProfilePoint(
distanceKm,
latitudeDeg,
longitudeDeg,
elevationMeters
));
}
return new TerrainProfileData(points, SOURCE_NAME, false);
}
private synchronized LoadedTile getOrLoadTile(Path tilePath) {
LoadedTile cachedTile = loadedTileCache.get(tilePath);
if (cachedTile != null) {
return cachedTile;
}
LoadedTile loadedTile = loadTile(tilePath);
if (loadedTile != null) {
loadedTileCache.put(tilePath, loadedTile);
}
return loadedTile;
}
private LoadedTile loadTile(Path tilePath) {
if (tilePath == null || !Files.isRegularFile(tilePath)) {
return null;
}
try (ImageInputStream imageInputStream = ImageIO.createImageInputStream(tilePath.toFile())) {
if (imageInputStream == null) {
return null;
}
Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInputStream);
if (!readers.hasNext()) {
return null;
}
ImageReader imageReader = readers.next();
try {
imageReader.setInput(imageInputStream, true, true);
Raster raster = imageReader.readRaster(0, null);
if (raster == null || raster.getWidth() < 2 || raster.getHeight() < 2) {
return null;
}
return new LoadedTile(
tilePath,
raster,
raster.getWidth(),
raster.getHeight(),
parseSouthDeg(tilePath.getFileName().toString()),
parseWestDeg(tilePath.getFileName().toString())
);
} finally {
imageReader.dispose();
}
} catch (IOException exception) {
System.err.println("[StationMap] Could not read DEM tile " + tilePath + ": " + exception.getMessage());
return null;
}
}
private int parseSouthDeg(String filename) {
ParsedTileKey key = ParsedTileKey.fromFilename(filename);
return key == null ? 0 : key.southDeg();
}
private int parseWestDeg(String filename) {
ParsedTileKey key = ParsedTileKey.fromFilename(filename);
return key == null ? 0 : key.westDeg();
}
/**
* Samples one DEM tile using bilinear interpolation.
*
* <p>The current reader assumes 1° x 1° geocells and derives raster
* coordinates directly from the sample latitude/longitude.</p>
*
* @param tile loaded DEM tile
* @param latitudeDeg sample latitude in degrees
* @param longitudeDeg sample longitude in degrees
* @return interpolated elevation in meters or NaN
*/
private double sampleElevationMeters(LoadedTile tile, double latitudeDeg, double longitudeDeg) {
if (tile == null) {
return Double.NaN;
}
double x = (longitudeDeg - tile.westDeg()) * (tile.width() - 1);
double y = ((tile.southDeg() + 1.0) - latitudeDeg) * (tile.height() - 1);
x = clamp(x, 0.0, tile.width() - 1.0);
y = clamp(y, 0.0, tile.height() - 1.0);
int x0 = (int) Math.floor(x);
int y0 = (int) Math.floor(y);
int x1 = Math.min(x0 + 1, tile.width() - 1);
int y1 = Math.min(y0 + 1, tile.height() - 1);
double q11 = readSample(tile.raster(), x0, y0);
double q21 = readSample(tile.raster(), x1, y0);
double q12 = readSample(tile.raster(), x0, y1);
double q22 = readSample(tile.raster(), x1, y1);
if (!Double.isFinite(q11) || !Double.isFinite(q21) || !Double.isFinite(q12) || !Double.isFinite(q22)) {
double nearest = readSample(tile.raster(), (int) Math.round(x), (int) Math.round(y));
return Double.isFinite(nearest) ? nearest : Double.NaN;
}
double dx = x - x0;
double dy = y - y0;
double top = q11 + (q21 - q11) * dx;
double bottom = q12 + (q22 - q12) * dx;
return top + (bottom - top) * dy;
}
private double readSample(Raster raster, int x, int y) {
double value = raster.getSampleDouble(x, y, 0);
if (!Double.isFinite(value) || value <= NODATA_VALUE) {
return Double.NaN;
}
return value;
}
private double clamp(double value, double minValue, double maxValue) {
return Math.max(minValue, Math.min(maxValue, value));
}
private record LoadedTile(
Path path,
Raster raster,
int width,
int height,
int southDeg,
int westDeg
) {
}
private record ParsedTileKey(int southDeg, int westDeg) {
private static final java.util.regex.Pattern TILE_PATTERN =
java.util.regex.Pattern.compile("(?i)^Copernicus_[A-Z]{3}_10_([NS])(\\d{2})_(\\d{2})_([EW])(\\d{3})_(\\d{2})_DEM\\.tif$");
static ParsedTileKey fromFilename(String filename) {
if (filename == null) {
return null;
}
var matcher = TILE_PATTERN.matcher(filename);
if (!matcher.matches()) {
return null;
}
int south = signed(matcher.group(1), matcher.group(2));
int west = signed(matcher.group(4), matcher.group(5));
return new ParsedTileKey(south, west);
}
private static int signed(String direction, String degrees) {
int value = Integer.parseInt(degrees);
if ("S".equalsIgnoreCase(direction) || "W".equalsIgnoreCase(direction)) {
return -value;
}
return value;
}
}
}
@@ -1,45 +0,0 @@
package kst4contest.view.map;
import java.util.Locale;
/**
* Supported offline DEM datasets.
*
* First real offline target:
* Copernicus DEM GLO-30 DGED GeoTIFF.
*/
public enum DemDataset {
COPERNICUS_GLO_30("copernicus_glo_30", "Copernicus DEM GLO-30");
private final String id;
private final String displayName;
DemDataset(String id, String displayName) {
this.id = id;
this.displayName = displayName;
}
public String id() {
return id;
}
public String displayName() {
return displayName;
}
public static DemDataset fromId(String id) {
if (id == null || id.isBlank()) {
return COPERNICUS_GLO_30;
}
String normalized = id.trim().toLowerCase(Locale.ROOT);
for (DemDataset dataset : values()) {
if (dataset.id.equals(normalized)) {
return dataset;
}
}
return COPERNICUS_GLO_30;
}
}
Loaded 100 of 600 files, more files were not shown because too many files have changed in this diff. Show more