From 2898c530d95178251647390831a3a9ef2b243b41 Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Fri, 16 Oct 2026 10:47:53 +0200 Subject: [PATCH] Restore the turn-antenna button and send the PSTRotator azimuth with one decimal (#126) * Send the PSTRotator azimuth with one decimal instead of truncating it setAzimuth cast the azimuth to int, so 145.7 degrees turned the antenna to 145. It is now rounded to one decimal and formatted with Locale.ROOT, so a German default locale cannot turn the dot into a comma; a value that rounds up to 360.0 is sent as 0.0. The loopback test pinned the old truncation and now expects the decimal. Co-Authored-By: Claude Opus 5.5 * Bring back the turn-antenna button lost in the Compose migration The JavaFX station panel had "Turn ant1 to ", the only caller of ChatController.rotateTo; removing JavaFX (fe4215bc) dropped it, so the antenna could no longer be turned from KST4Contest at all. The Compose panel gets the button again, enabled when a rotator callback is wired and the selected station has a finite heading. Review follow-ups for the decimal azimuth: formatAzimuth refuses NaN and infinite values and wraps any finite value into [0, 360). The "reached" check before the SPID retry uses a 1.0 degree tolerance, because the target now carries a decimal while many controllers report whole degrees. Co-Authored-By: Claude Opus 5.5 * Keep rotateTo's Javadoc on the method and word the German label as a bearing Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../src/main/i18n/strings_de.properties | 1 + .../src/main/i18n/strings_en.properties | 1 + .../view/Kst4ContestApplication.java | 4 ++ .../view/compose/SelectedStationPanel.kt | 4 ++ .../view/compose/SelectedStationState.kt | 13 +++++ .../view/compose/SelectedStationStateTest.kt | 36 ++++++++++++ .../controller/ChatController.java | 9 ++- .../controller/PstRotatorClient.java | 21 ++++++- .../PstRotatorClientAzimuthFormatTest.java | 58 +++++++++++++++++++ .../PstRotatorClientLoopbackTest.java | 4 +- github_docs/de-Changelog.md | 2 + github_docs/en-Changelog.md | 2 + 12 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 core/src/test/java/kst4contest/controller/PstRotatorClientAzimuthFormatTest.java diff --git a/app-desktop/src/main/i18n/strings_de.properties b/app-desktop/src/main/i18n/strings_de.properties index c994505a..c1d72074 100644 --- a/app-desktop/src/main/i18n/strings_de.properties +++ b/app-desktop/src/main/i18n/strings_de.properties @@ -253,6 +253,7 @@ selected.lookUpAndShow=Nachschlagen und zeigen selected.lookupQrz=Auf qrz.com nachschlagen selected.lookupQrzcq=Auf qrzcq.com nachschlagen selected.showPathInAs=Strecke in AS zeigen +selected.turnAntenna=Antenne 1 Richtung {0} drehen selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.) candidates.title=Stationen mit höchster Priorität candidates.none=Noch keine Kandidaten. diff --git a/app-desktop/src/main/i18n/strings_en.properties b/app-desktop/src/main/i18n/strings_en.properties index 79d6016e..d70d456b 100644 --- a/app-desktop/src/main/i18n/strings_en.properties +++ b/app-desktop/src/main/i18n/strings_en.properties @@ -262,6 +262,7 @@ selected.lookUpAndShow=Look up and show selected.lookupQrz=Lookup on qrz.com selected.lookupQrzcq=Lookup on qrzcq.com selected.showPathInAs=Show path in AS +selected.turnAntenna=Turn ant1 to {0} selected.lastActivity=Last activity: {0} ({1} min ago) candidates.title=Top priority candidates candidates.none=No candidates yet. diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index d7d961c2..1a978850 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -3135,6 +3135,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos if (member == null) return null; java.util.EnumSet enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); return resolveDefaultSkedBand(member, enabledBands); + }, + azimuth -> { + chatcontroller.rotateTo(azimuth); + return kotlin.Unit.INSTANCE; }); if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) { diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt index f8fc203a..aa7e91ca 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt @@ -184,6 +184,10 @@ fun SelectedStationPanel( FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout) Form.button("🗺 Show on map", onClick = onShowOnMap) + Form.button( + strings.selectedTurnAntenna(state.selected?.callSignRaw.orEmpty()), + enabled = state.canTurnAntenna, + ) { state.turnAntenna() } Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) { openInBrowser.accept(state.qrzComUrl()) } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt index eb912bdc..508daaac 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt @@ -54,6 +54,8 @@ enum class SelectedMessageFilter(val label: String) { * `propagateNotQrvStateToActiveMembers` does * @param createSked takes the station, the minutes, the band and the mode; returns * whether it was accepted + * @param turnAntennaTo turns the antenna to an azimuth in degrees, which is what + * `ChatController.rotateTo` does; null where no rotator can be driven */ class SelectedStationState( private val propagateNotQrv: (ChatMember) -> Unit, @@ -63,6 +65,7 @@ class SelectedStationState( private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { }, private val detectBands: (ChatMember) -> String = { "" }, private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null }, + private val turnAntennaTo: ((Double) -> Unit)? = null, ) { var selected: ChatMember? by mutableStateOf(null) @@ -103,6 +106,16 @@ class SelectedStationState( val canLookUp: Boolean get() = !selected?.callSign.isNullOrBlank() + /** Needs a rotator to drive and a finite heading to the selected station. */ + val canTurnAntenna: Boolean + get() = turnAntennaTo != null && selected?.getQTFdirection()?.isFinite() == true + + /** Turns the antenna to the selected station's heading; does nothing when it cannot. */ + fun turnAntenna() { + val heading = selected?.getQTFdirection() ?: return + if (heading.isFinite()) turnAntennaTo?.invoke(heading) + } + fun select(member: ChatMember?) { if (selected?.callSignRaw != member?.callSignRaw) { skedBand = member?.let { resolveDefaultSkedBand(it) } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt index 41a83daa..e3288dc9 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt @@ -39,6 +39,42 @@ class SelectedStationStateTest { private fun member(call: String) = ChatMember().apply { callSign = call } + @Test + fun turnAntennaSendsTheSelectedStationsHeading() { + val headings = mutableListOf() + val state = SelectedStationState( + propagateNotQrv = { }, + createSked = { _, _, _, _ -> true }, + turnAntennaTo = { headings.add(it) }, + ) + val station = member("DL1ABC") + station.setQTFdirection(145.7) + state.select(station) + + assertTrue(state.canTurnAntenna) + state.turnAntenna() + + assertEquals(listOf(145.7), headings) + } + + @Test + fun turnAntennaIsUnavailableWithoutARotatorOrAHeading() { + val withoutRotator = SelectedStationState(propagateNotQrv = { }, createSked = { _, _, _, _ -> true }) + withoutRotator.select(member("DL1ABC").apply { setQTFdirection(145.7) }) + assertFalse(withoutRotator.canTurnAntenna) + + val headings = mutableListOf() + val withoutHeading = SelectedStationState( + propagateNotQrv = { }, + createSked = { _, _, _, _ -> true }, + turnAntennaTo = { headings.add(it) }, + ) + withoutHeading.select(member("DL1ABC")) + assertFalse(withoutHeading.canTurnAntenna) + withoutHeading.turnAntenna() + assertTrue(headings.isEmpty()) + } + @Test fun `without a selection there is nothing to act on`() { val state = Session().state() diff --git a/core/src/main/java/kst4contest/controller/ChatController.java b/core/src/main/java/kst4contest/controller/ChatController.java index bc8d4ab1..e08f9f89 100644 --- a/core/src/main/java/kst4contest/controller/ChatController.java +++ b/core/src/main/java/kst4contest/controller/ChatController.java @@ -321,6 +321,13 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList rotatorClient.start(); } + /** + * How close the reported position must be to count as "reached". The target carries a + * decimal, while many rotor controllers report whole degrees: with 0.5 a target of 145.7 + * and a report of 145 would count as missed and could trigger the retry. + */ + static final double ROTATOR_REACHED_TOLERANCE_DEGREES = 1.0; + /** * Sends a new azimuth to PSTRotator without blocking the JavaFX thread. * @@ -399,7 +406,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList double currentAzimuth = lastReportedRotatorAzimuth; if (Double.isFinite(currentAzimuth) - && angularDistance(currentAzimuth, targetAzimuth) < 0.5) { + && angularDistance(currentAzimuth, targetAzimuth) < ROTATOR_REACHED_TOLERANCE_DEGREES) { LOGGER.log( Level.FINE, diff --git a/core/src/main/java/kst4contest/controller/PstRotatorClient.java b/core/src/main/java/kst4contest/controller/PstRotatorClient.java index ed7a832f..043e1687 100644 --- a/core/src/main/java/kst4contest/controller/PstRotatorClient.java +++ b/core/src/main/java/kst4contest/controller/PstRotatorClient.java @@ -6,6 +6,7 @@ import kst4contest.model.ThreadStateMessage; import java.io.IOException; import java.net.*; import java.nio.charset.StandardCharsets; +import java.util.Locale; import java.util.concurrent.*; import java.util.logging.Level; import java.util.logging.Logger; @@ -192,8 +193,24 @@ public class PstRotatorClient implements Runnable { // Öffentliche Steuermethoden public void setAzimuth(double degrees) { - // Formatierung ohne unnötige Nachkommastellen, falls nötig - sendCommand("AZIMUTH", String.valueOf((int) degrees)); + sendCommand("AZIMUTH", formatAzimuth(degrees)); + } + + /** + * Formats an azimuth with one decimal and a dot as separator, independent of the + * default locale, wrapped into [0, 360): 360.0 is sent as 0.0, -10 as 350.0. The + * azimuth used to be truncated to whole degrees, so 145.7 turned the antenna to 145. + * + * @throws IllegalArgumentException for NaN or an infinite value, which has no heading + */ + static String formatAzimuth(double degrees) { + if (!Double.isFinite(degrees)) { + throw new IllegalArgumentException("Azimuth is not a finite number: " + degrees); + } + double rounded = Math.round(degrees * 10.0) / 10.0; + double wrapped = ((rounded % 360.0) + 360.0) % 360.0; + // Rounding can leave a tiny negative remainder that would print as -0.0. + return String.format(Locale.ROOT, "%.1f", wrapped == 0.0 ? 0.0 : wrapped); } public void setElevation(double degrees) { diff --git a/core/src/test/java/kst4contest/controller/PstRotatorClientAzimuthFormatTest.java b/core/src/test/java/kst4contest/controller/PstRotatorClientAzimuthFormatTest.java new file mode 100644 index 00000000..7b32cefa --- /dev/null +++ b/core/src/test/java/kst4contest/controller/PstRotatorClientAzimuthFormatTest.java @@ -0,0 +1,58 @@ +package kst4contest.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +/** The azimuth sent to PSTRotator: one decimal, dot separator, within [0, 360). */ +class PstRotatorClientAzimuthFormatTest { + + @Test + void keepsOneDecimalInsteadOfTruncating() { + assertEquals("145.7", PstRotatorClient.formatAzimuth(145.7)); + assertEquals("145.0", PstRotatorClient.formatAzimuth(145.0)); + assertEquals("0.0", PstRotatorClient.formatAzimuth(0.0)); + } + + @Test + void roundsToTheNearestTenth() { + assertEquals("145.7", PstRotatorClient.formatAzimuth(145.66)); + assertEquals("145.7", PstRotatorClient.formatAzimuth(145.70000000000002)); + assertEquals("12.3", PstRotatorClient.formatAzimuth(12.34)); + } + + @Test + void aValueRoundingUpTo360IsSentAsZero() { + assertEquals("0.0", PstRotatorClient.formatAzimuth(359.96)); + assertEquals("359.9", PstRotatorClient.formatAzimuth(359.94)); + } + + @Test + void wrapsAnyFiniteValueIntoOneTurn() { + assertEquals("0.0", PstRotatorClient.formatAzimuth(720.0)); + assertEquals("10.0", PstRotatorClient.formatAzimuth(370.0)); + assertEquals("350.0", PstRotatorClient.formatAzimuth(-10.0)); + assertEquals("0.0", PstRotatorClient.formatAzimuth(-0.0)); + } + + @Test + void refusesAValueWithoutAHeading() { + assertThrows(IllegalArgumentException.class, () -> PstRotatorClient.formatAzimuth(Double.NaN)); + assertThrows(IllegalArgumentException.class, + () -> PstRotatorClient.formatAzimuth(Double.POSITIVE_INFINITY)); + } + + @Test + void usesADotEvenUnderAGermanDefaultLocale() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.GERMANY); + assertEquals("145.7", PstRotatorClient.formatAzimuth(145.7)); + } finally { + Locale.setDefault(previous); + } + } +} diff --git a/core/src/test/java/kst4contest/controller/PstRotatorClientLoopbackTest.java b/core/src/test/java/kst4contest/controller/PstRotatorClientLoopbackTest.java index 9f3981e8..ff2acba1 100644 --- a/core/src/test/java/kst4contest/controller/PstRotatorClientLoopbackTest.java +++ b/core/src/test/java/kst4contest/controller/PstRotatorClientLoopbackTest.java @@ -108,8 +108,8 @@ class PstRotatorClientLoopbackTest { client.setTrackingMode(true); client.setTrackingMode(false); - // The azimuth is truncated to whole degrees; the elevation is not. - assertEquals("145", receiveCommand()); + // The azimuth keeps one decimal; it used to be truncated to whole degrees. + assertEquals("145.7", receiveCommand()); assertEquals("10.5", receiveCommand()); assertEquals("1", receiveCommand()); assertEquals("1", receiveCommand()); diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md index 2ae3dc49..ec09e212 100644 --- a/github_docs/de-Changelog.md +++ b/github_docs/de-Changelog.md @@ -32,6 +32,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un - **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten. +- **PSTRotator-Azimut mit einer Nachkommastelle:** Der an PSTRotator gesendete Azimut wurde bisher auf ganze Grad abgeschnitten, sodass eine Station bei 145,7° die Antenne auf 145° drehte. Jetzt wird er auf eine Nachkommastelle gerundet, immer mit Punkt als Trennzeichen. + - **Dark Mode pro Profil:** Im Reiter **GUI** legt jedes Operator-Profil unter **Design** fest, ob KST4Contest hell oder dunkel startet. Die Menüeinträge unter **Windows** schalten weiterhin schnell um, gelten aber nur für die laufende Sitzung. Das Farbschema erfasst jetzt auch das Update-Fenster und die Liste der Top-Kandidaten. ### Behoben diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md index b18fb590..1fdd9d03 100644 --- a/github_docs/en-Changelog.md +++ b/github_docs/en-Changelog.md @@ -32,6 +32,8 @@ Several operators sharing one computer can now use their own callsigns, locators - **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials. +- **PSTRotator azimuth with one decimal:** the azimuth sent to PSTRotator used to be cut to whole degrees, so a station at 145.7° turned the antenna to 145°. It is now rounded to one decimal, always with a dot as separator. + - **Dark mode per profile:** on the **GUI** tab each operator profile sets under **Design** whether KST4Contest starts light or dark. The entries in the **Windows** menu still switch quickly, but only for the running session. The colour scheme now also covers the update window and the top candidates list. ### Fixed