mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-19 05:42:58 +02:00
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:
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.lookupQrz=Auf qrz.com nachschlagen
|
||||||
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
|
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
|
||||||
selected.showPathInAs=Strecke in AS zeigen
|
selected.showPathInAs=Strecke in AS zeigen
|
||||||
|
selected.turnAntenna=Antenne 1 Richtung {0} drehen
|
||||||
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
|
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
|
||||||
candidates.title=Stationen mit höchster Priorität
|
candidates.title=Stationen mit höchster Priorität
|
||||||
candidates.none=Noch keine Kandidaten.
|
candidates.none=Noch keine Kandidaten.
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ selected.lookUpAndShow=Look up and show
|
|||||||
selected.lookupQrz=Lookup on qrz.com
|
selected.lookupQrz=Lookup on qrz.com
|
||||||
selected.lookupQrzcq=Lookup on qrzcq.com
|
selected.lookupQrzcq=Lookup on qrzcq.com
|
||||||
selected.showPathInAs=Show path in AS
|
selected.showPathInAs=Show path in AS
|
||||||
|
selected.turnAntenna=Turn ant1 to {0}
|
||||||
selected.lastActivity=Last activity: {0} ({1} min ago)
|
selected.lastActivity=Last activity: {0} ({1} min ago)
|
||||||
candidates.title=Top priority candidates
|
candidates.title=Top priority candidates
|
||||||
candidates.none=No candidates yet.
|
candidates.none=No candidates yet.
|
||||||
|
|||||||
@@ -3135,6 +3135,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
if (member == null) return null;
|
if (member == null) return null;
|
||||||
java.util.EnumSet<Band> enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
|
java.util.EnumSet<Band> enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
|
||||||
return resolveDefaultSkedBand(member, enabledBands);
|
return resolveDefaultSkedBand(member, enabledBands);
|
||||||
|
},
|
||||||
|
azimuth -> {
|
||||||
|
chatcontroller.rotateTo(azimuth);
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
|
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
|
||||||
|
|||||||
@@ -184,6 +184,10 @@ fun SelectedStationPanel(
|
|||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||||
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
|
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
|
||||||
Form.button("🗺 Show on map", onClick = onShowOnMap)
|
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) {
|
Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) {
|
||||||
openInBrowser.accept(state.qrzComUrl())
|
openInBrowser.accept(state.qrzComUrl())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ enum class SelectedMessageFilter(val label: String) {
|
|||||||
* `propagateNotQrvStateToActiveMembers` does
|
* `propagateNotQrvStateToActiveMembers` does
|
||||||
* @param createSked takes the station, the minutes, the band and the mode; returns
|
* @param createSked takes the station, the minutes, the band and the mode; returns
|
||||||
* whether it was accepted
|
* 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(
|
class SelectedStationState(
|
||||||
private val propagateNotQrv: (ChatMember) -> Unit,
|
private val propagateNotQrv: (ChatMember) -> Unit,
|
||||||
@@ -63,6 +65,7 @@ class SelectedStationState(
|
|||||||
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
|
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
|
||||||
private val detectBands: (ChatMember) -> String = { "" },
|
private val detectBands: (ChatMember) -> String = { "" },
|
||||||
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
|
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
|
||||||
|
private val turnAntennaTo: ((Double) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var selected: ChatMember? by mutableStateOf(null)
|
var selected: ChatMember? by mutableStateOf(null)
|
||||||
@@ -103,6 +106,16 @@ class SelectedStationState(
|
|||||||
val canLookUp: Boolean
|
val canLookUp: Boolean
|
||||||
get() = !selected?.callSign.isNullOrBlank()
|
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?) {
|
fun select(member: ChatMember?) {
|
||||||
if (selected?.callSignRaw != member?.callSignRaw) {
|
if (selected?.callSignRaw != member?.callSignRaw) {
|
||||||
skedBand = member?.let { resolveDefaultSkedBand(it) }
|
skedBand = member?.let { resolveDefaultSkedBand(it) }
|
||||||
|
|||||||
@@ -39,6 +39,42 @@ class SelectedStationStateTest {
|
|||||||
|
|
||||||
private fun member(call: String) = ChatMember().apply { callSign = call }
|
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
|
@Test
|
||||||
fun `without a selection there is nothing to act on`() {
|
fun `without a selection there is nothing to act on`() {
|
||||||
val state = Session().state()
|
val state = Session().state()
|
||||||
|
|||||||
@@ -321,6 +321,13 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
rotatorClient.start();
|
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.
|
* Sends a new azimuth to PSTRotator without blocking the JavaFX thread.
|
||||||
*
|
*
|
||||||
@@ -399,7 +406,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
double currentAzimuth = lastReportedRotatorAzimuth;
|
double currentAzimuth = lastReportedRotatorAzimuth;
|
||||||
|
|
||||||
if (Double.isFinite(currentAzimuth)
|
if (Double.isFinite(currentAzimuth)
|
||||||
&& angularDistance(currentAzimuth, targetAzimuth) < 0.5) {
|
&& angularDistance(currentAzimuth, targetAzimuth) < ROTATOR_REACHED_TOLERANCE_DEGREES) {
|
||||||
|
|
||||||
LOGGER.log(
|
LOGGER.log(
|
||||||
Level.FINE,
|
Level.FINE,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kst4contest.model.ThreadStateMessage;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.*;
|
import java.net.*;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
@@ -192,8 +193,24 @@ public class PstRotatorClient implements Runnable {
|
|||||||
// Öffentliche Steuermethoden
|
// Öffentliche Steuermethoden
|
||||||
|
|
||||||
public void setAzimuth(double degrees) {
|
public void setAzimuth(double degrees) {
|
||||||
// Formatierung ohne unnötige Nachkommastellen, falls nötig
|
sendCommand("AZIMUTH", formatAzimuth(degrees));
|
||||||
sendCommand("AZIMUTH", String.valueOf((int) 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) {
|
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(true);
|
||||||
client.setTrackingMode(false);
|
client.setTrackingMode(false);
|
||||||
|
|
||||||
// The azimuth is truncated to whole degrees; the elevation is not.
|
// The azimuth keeps one decimal; it used to be truncated to whole degrees.
|
||||||
assertEquals("<PST><AZIMUTH>145</AZIMUTH></PST>", receiveCommand());
|
assertEquals("<PST><AZIMUTH>145.7</AZIMUTH></PST>", receiveCommand());
|
||||||
assertEquals("<PST><ELEVATION>10.5</ELEVATION></PST>", receiveCommand());
|
assertEquals("<PST><ELEVATION>10.5</ELEVATION></PST>", receiveCommand());
|
||||||
assertEquals("<PST><STOP>1</STOP></PST>", receiveCommand());
|
assertEquals("<PST><STOP>1</STOP></PST>", receiveCommand());
|
||||||
assertEquals("<PST><PARK>1</PARK></PST>", receiveCommand());
|
assertEquals("<PST><PARK>1</PARK></PST>", receiveCommand());
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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
|
### Behoben
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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
|
### Fixed
|
||||||
|
|||||||
Reference in new issue
Block a user