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 <noreply@anthropic.com>

* Bring back the turn-antenna button lost in the Compose migration

The JavaFX station panel had "Turn ant1 to <call>", 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 <noreply@anthropic.com>

* Keep rotateTo's Javadoc on the method and word the German label as a bearing

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-16 10:47:53 +02:00
1 parent e1e6fe7297
commit 2898c530d9
12 files changed
+150 -5

No files matched your search

@@ -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.
@@ -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.
@@ -3135,6 +3135,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
if (member == null) return null;
java.util.EnumSet<Band> enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
return resolveDefaultSkedBand(member, enabledBands);
},
azimuth -> {
chatcontroller.rotateTo(azimuth);
return kotlin.Unit.INSTANCE;
});
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
@@ -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())
}
@@ -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) }
@@ -39,6 +39,42 @@ class SelectedStationStateTest {
private fun member(call: String) = ChatMember().apply { callSign = call }
@Test
fun turnAntennaSendsTheSelectedStationsHeading() {
val headings = mutableListOf<Double>()
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<Double>()
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()
@@ -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,
@@ -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) {
@@ -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);
}
}
}
@@ -108,8 +108,8 @@ class PstRotatorClientLoopbackTest {
client.setTrackingMode(true);
client.setTrackingMode(false);
// The azimuth is truncated to whole degrees; the elevation is not.
assertEquals("<PST><AZIMUTH>145</AZIMUTH></PST>", receiveCommand());
// The azimuth keeps one decimal; it used to be truncated to whole degrees.
assertEquals("<PST><AZIMUTH>145.7</AZIMUTH></PST>", receiveCommand());
assertEquals("<PST><ELEVATION>10.5</ELEVATION></PST>", receiveCommand());
assertEquals("<PST><STOP>1</STOP></PST>", receiveCommand());
assertEquals("<PST><PARK>1</PARK></PST>", receiveCommand());
+2
View File
@@ -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
+2
View File
@@ -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