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()