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