mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +02:00
* Spec: SO2R with two radios in one instance (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Plan: SO2R implementation (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Move the rotator command rules into RotatorControl (#46) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Store the SO2R switch, the second Win-Test station and rotator 2 (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Route frequency reports to radio 1 or 2 (#46) Add the RadioSlot enum (FIRST, SECOND) with its QRG and QTF targets, and the pure RadioSlotRouter that decides per RadioInfo packet and per Win-Test station which radio a report belongs to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Send RadioInfo of radio 2 to SECONDQRG when SO2R is on (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Send Win-Test STATUS of the second station to SECONDQRG when SO2R is on (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Run a second PSTRotator for radio 2 with SO2R (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start rotator 2 only while an ON4KST session runs (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Settings for SO2R, the second Win-Test station and rotator 2 (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Turn antenna 2 and show QTF2 with SO2R (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Make the SO2R switches observable so Apply shows them (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document SO2R (#46) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refuse clashing PSTRotator ports in both directions (#46) Writing either port now checks same-or-adjacent against the drafted value of the other; rotor 1 is only checked while SO2R and rotor 2 are on. Also fixes a misplaced Javadoc, stale WinTest parseStatus Javadoc, docs wording and removes an unused import and dead comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remove the executed SO2R plan (#46) The plan's content lives on in the commits, the spec and PROJECT_CONTEXT. 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
feb4b4e2c9
commit
5b7c774f5f
37 files changed
+1587
-266
No files matched your search
@@ -125,6 +125,11 @@ station.rotatorEnabled=Rotorabgleich aktiv
|
||||
station.rotatorHost=Rotor-Host
|
||||
station.rotatorPort=Rotor-Port
|
||||
station.rotatorSection=PSTRotator
|
||||
station.rotator2Enabled=Rotor-2-Abgleich aktiv
|
||||
station.rotator2Host=Rotor-2-Host
|
||||
station.rotator2Port=Rotor-2-Port (antwortet auf Port + 1)
|
||||
station.rotator2PortClash=Port {0} ist derselbe oder ein benachbarter Port wie beim anderen PSTRotator
|
||||
station.rotator2Section=PSTRotator 2 (SO2R)
|
||||
station.secondCategory=Zweite Kategorie
|
||||
station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden
|
||||
station.serverHost=Servername
|
||||
@@ -135,12 +140,16 @@ station.targetHighestPoint=Gegenstation auf dem höchsten Punkt ihres Locatorfel
|
||||
station.targetTxPower=Sendeleistung der Gegenstation, Watt
|
||||
trxSynch.hint=Eine aktivierte Quelle erzeugt von sich aus keine Frequenz. Die eigene QRG wird erst aktualisiert, wenn tatsächlich gültige RadioInfo- oder Win-Test-STATUS-Daten eintreffen; bis dahin bleibt sie so, wie sie von Hand eingetragen wurde.
|
||||
trxSynch.hintSection=Was eine aktivierte Quelle bewirkt
|
||||
trxSynch.so2rEnabled=SO2R: zwei Geräte getrennt führen (Gerät 2 schreibt SECONDQRG)
|
||||
trxSynch.so2rHint=N1MM, DXLog und UCXLog werden über RadioNr zugeordnet; dafür ist nichts einzustellen. Win-Test braucht je Gerät einen Stationsnamen.
|
||||
trxSynch.so2rSection=SO2R
|
||||
trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren
|
||||
trxSynch.ucxSection=UCXLog-TRX-Infos empfangen
|
||||
trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz)
|
||||
trxSynch.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG)
|
||||
trxSynch.winTestSection=Win-Test-TRX-Abgleich
|
||||
trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen)
|
||||
trxSynch.winTestStationRadio2=Win-Test-Stationsname von Gerät 2 (z. B. STN2)
|
||||
worked.empty=Die Datenbank enthält keine Rufzeichen.
|
||||
worked.expiryHint=Worked-, NOT-QRV- und Feldangaben verfallen nach drei Tagen von selbst. Ein Zurücksetzen vor jedem Contest ist normalerweise nicht nötig.
|
||||
worked.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen.
|
||||
@@ -266,6 +275,7 @@ 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.turnAntenna2=Antenne 2 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.
|
||||
@@ -380,6 +390,8 @@ messages.qsoOfTheOther=Fremd-QSOs
|
||||
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
|
||||
chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen
|
||||
chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird
|
||||
chat.qtf2Tooltip=Richtung von Antenne 2 (Rotor 2, SO2R); nur Anzeige
|
||||
chat.secondQrgTooltipSo2r=QRG von Gerät 2 (aus RadioNr 2 bzw. Win-Test-Station 2)
|
||||
list.moveDown=Auswahl nach unten
|
||||
list.moveUp=Auswahl nach oben
|
||||
list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen.
|
||||
|
||||
@@ -134,6 +134,11 @@ station.rotatorEnabled=Rotator sync enabled
|
||||
station.rotatorHost=Rotator host
|
||||
station.rotatorPort=Rotator port
|
||||
station.rotatorSection=PSTRotator
|
||||
station.rotator2Enabled=Rotator 2 sync enabled
|
||||
station.rotator2Host=Rotator 2 host
|
||||
station.rotator2Port=Rotator 2 port (answers on port + 1)
|
||||
station.rotator2PortClash=Port {0} is the same as or next to the port of the other PSTRotator
|
||||
station.rotator2Section=PSTRotator 2 (SO2R)
|
||||
station.secondCategory=Second category
|
||||
station.secondChatEnabled=2nd chat: log in to a second category
|
||||
station.serverHost=Server host name
|
||||
@@ -144,12 +149,16 @@ station.targetHighestPoint=Target station on the highest point of its locator sq
|
||||
station.targetTxPower=Target TX power, watts
|
||||
trxSynch.hint=An enabled source does not produce a frequency by itself. The own QRG is only updated once valid RadioInfo or Win-Test STATUS data actually arrives; until then it stays as entered by hand.
|
||||
trxSynch.hintSection=What an enabled source does
|
||||
trxSynch.so2rEnabled=SO2R: keep two radios apart (radio 2 writes SECONDQRG)
|
||||
trxSynch.so2rHint=N1MM, DXLog and UCXLog are assigned by RadioNr; nothing to set. Win-Test needs one station name per radio.
|
||||
trxSynch.so2rSection=SO2R
|
||||
trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port
|
||||
trxSynch.ucxSection=Receive UCXLog TRX info
|
||||
trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)
|
||||
trxSynch.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG)
|
||||
trxSynch.winTestSection=Win-Test TRX sync
|
||||
trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all)
|
||||
trxSynch.winTestStationRadio2=Win-Test station name of radio 2 (e.g. STN2)
|
||||
worked.empty=The worked database holds no callsigns.
|
||||
worked.expiryHint=Worked, NOT-QRV and grid data expire after three days on their own. A reset before every contest is normally not required.
|
||||
worked.lastResetAffected=Last reset affected {0} rows.
|
||||
@@ -275,6 +284,7 @@ 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.turnAntenna2=Turn ant2 to {0}
|
||||
selected.lastActivity=Last activity: {0} ({1} min ago)
|
||||
candidates.title=Top priority candidates
|
||||
candidates.none=No candidates yet.
|
||||
@@ -389,6 +399,8 @@ messages.qsoOfTheOther=QSO of the other
|
||||
chat.ownQrgTooltip=Your own frequency in the main category
|
||||
chat.secondQrgTooltip=Enter the frequency for the second chat category by hand
|
||||
chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced
|
||||
chat.qtf2Tooltip=Heading of antenna 2 (rotator 2, SO2R); display only
|
||||
chat.secondQrgTooltipSo2r=QRG of radio 2 (from RadioNr 2 or Win-Test station 2)
|
||||
list.moveDown=Move selected down
|
||||
list.moveUp=Move selected up
|
||||
list.clearToRemove=Clear the text of an entry to remove it.
|
||||
|
||||
@@ -3099,6 +3099,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
|
||||
MainMenuState menu = new MainMenuState();
|
||||
MainWindowSurroundings surroundings = new MainWindowSurroundings(menu);
|
||||
surroundings.setSo2rEnabled(chatcontroller.getChatPreferences().isTrxSynch_so2rEnabled());
|
||||
surroundings.setSecondAntennaOffered(chatcontroller.getChatPreferences().isTrxSynch_so2rEnabled()
|
||||
&& chatcontroller.getChatPreferences().isStn_pstRotator2Enabled());
|
||||
|
||||
SelectedStationState selectedStation = new SelectedStationState(
|
||||
member -> {
|
||||
@@ -3164,9 +3167,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
return resolveDefaultSkedBand(member, enabledBands);
|
||||
},
|
||||
azimuth -> {
|
||||
chatcontroller.rotateTo(azimuth);
|
||||
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.FIRST, azimuth);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
},
|
||||
azimuth -> {
|
||||
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.SECOND, azimuth);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
},
|
||||
surroundings::getSecondAntennaOffered);
|
||||
|
||||
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
|
||||
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_ME);
|
||||
@@ -3993,6 +4001,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
}
|
||||
}
|
||||
|
||||
/** Pushes the stored SO2R switches to the main window so it shows or hides the radio 2 controls. */
|
||||
@Override
|
||||
public void refreshSo2rState() {
|
||||
runOnUi(() -> {
|
||||
if (composeMainWindowState == null) {
|
||||
return;
|
||||
}
|
||||
kst4contest.model.ChatPreferences preferences = chatcontroller.getChatPreferences();
|
||||
MainWindowSurroundings target = composeMainWindowState.getSurroundings();
|
||||
target.setSo2rEnabled(preferences.isTrxSynch_so2rEnabled());
|
||||
target.setSecondAntennaOffered(preferences.isTrxSynch_so2rEnabled() && preferences.isStn_pstRotator2Enabled());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op since the JavaFX own-QRG text field was removed with the never-shown window.
|
||||
*
|
||||
|
||||
@@ -158,6 +158,8 @@ fun ChatInputRow(
|
||||
ownQrgMain: ObservedValue<String>,
|
||||
ownQrgSecond: ObservedValue<String>,
|
||||
antennaQtf: ObservedValue<Double>,
|
||||
antennaQtf2: ObservedValue<Double>,
|
||||
so2rEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
@@ -195,13 +197,26 @@ fun ChatInputRow(
|
||||
InlineCommittedField(
|
||||
stored = ownQrgSecond.value.orEmpty(),
|
||||
onCommit = { prefs.getMYQRGSecondCat().set(it) },
|
||||
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip,
|
||||
tooltip = if (so2rEnabled) {
|
||||
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltipSo2r
|
||||
} else {
|
||||
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip
|
||||
},
|
||||
)
|
||||
InlineCommittedField(
|
||||
stored = antennaQtf.value?.toString().orEmpty(),
|
||||
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
|
||||
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip,
|
||||
)
|
||||
if (so2rEnabled) {
|
||||
// Display only: rotator 2 writes it, the operator does not.
|
||||
InlineCommittedField(
|
||||
stored = antennaQtf2.value?.takeIf { it.isFinite() }?.toString().orEmpty(),
|
||||
onCommit = { },
|
||||
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip,
|
||||
editable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +233,7 @@ private fun InlineCommittedField(
|
||||
stored: String,
|
||||
onCommit: (String) -> Unit,
|
||||
tooltip: String,
|
||||
editable: Boolean = true,
|
||||
) {
|
||||
var draft by remember(stored) { mutableStateOf(stored) }
|
||||
|
||||
@@ -225,6 +241,7 @@ private fun InlineCommittedField(
|
||||
CompactTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
enabled = editable,
|
||||
modifier = Modifier
|
||||
.width(QRG_FIELD_WIDTH)
|
||||
.onFocusChanged { focus ->
|
||||
|
||||
@@ -205,6 +205,8 @@ private fun MessageColumn(
|
||||
ownQrgMain = state.ownQrgMain,
|
||||
ownQrgSecond = state.ownQrgSecond,
|
||||
antennaQtf = state.antennaQtf,
|
||||
antennaQtf2 = state.antennaQtf2,
|
||||
so2rEnabled = state.surroundings.so2rEnabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -75,13 +75,14 @@ class MainWindowState(
|
||||
val snippets = RosterMirror(prefs.getLst_txtSnipList())
|
||||
|
||||
/**
|
||||
* The three small fields beside the send controls. Mirrored rather than read straight
|
||||
* from the preferences, because all three move without the operator touching them: the
|
||||
* The four small fields beside the send controls. Mirrored rather than read straight
|
||||
* from the preferences, because all of them move without the operator touching them: the
|
||||
* rotator writes the heading, and the frequency follower writes the own QRG.
|
||||
*/
|
||||
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
|
||||
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
|
||||
val antennaQtf = ObservedValue(prefs.getActualQTF())
|
||||
val antennaQtf2 = ObservedValue(prefs.getActualQTF2())
|
||||
|
||||
/**
|
||||
* Stops following the preferences. Called when this state is dropped — a profile switch
|
||||
@@ -92,6 +93,7 @@ class MainWindowState(
|
||||
ownQrgMain.release()
|
||||
ownQrgSecond.release()
|
||||
antennaQtf.release()
|
||||
antennaQtf2.release()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -161,4 +163,13 @@ class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState())
|
||||
var settingsWindowOpen: Boolean by mutableStateOf(false)
|
||||
|
||||
var monitorWindowOpen: Boolean by mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* Whether SO2R is switched on. Compose state, because the preference is not: the
|
||||
* application pushes the stored value in at start-up and after every settings Apply.
|
||||
*/
|
||||
var so2rEnabled: Boolean by mutableStateOf(false)
|
||||
|
||||
/** SO2R with rotator 2 enabled, i.e. antenna 2 can be turned; pushed in like [so2rEnabled]. */
|
||||
var secondAntennaOffered: Boolean by mutableStateOf(false)
|
||||
}
|
||||
@@ -188,6 +188,12 @@ fun SelectedStationPanel(
|
||||
strings.selectedTurnAntenna(state.selected?.callSignRaw.orEmpty()),
|
||||
enabled = state.canTurnAntenna,
|
||||
) { state.turnAntenna() }
|
||||
if (state.showsSecondAntenna) {
|
||||
Form.button(
|
||||
strings.selectedTurnAntenna2(state.selected?.callSignRaw.orEmpty()),
|
||||
enabled = state.canTurnSecondAntenna,
|
||||
) { state.turnSecondAntenna() }
|
||||
}
|
||||
Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) {
|
||||
openInBrowser.accept(state.qrzComUrl())
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ enum class SelectedMessageFilter(val label: String) {
|
||||
* 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
|
||||
* @param turnSecondAntennaTo the same for antenna 2 (rotator 2); null where it cannot be driven
|
||||
* @param secondAntennaOffered whether SO2R with rotator 2 is configured right now; called on
|
||||
* every draw, so the supplied lambda has to read Compose state for the button to
|
||||
* follow a settings Apply
|
||||
*/
|
||||
class SelectedStationState(
|
||||
private val propagateNotQrv: (ChatMember) -> Unit,
|
||||
@@ -66,6 +70,8 @@ class SelectedStationState(
|
||||
private val detectBands: (ChatMember) -> String = { "" },
|
||||
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
|
||||
private val turnAntennaTo: ((Double) -> Unit)? = null,
|
||||
private val turnSecondAntennaTo: ((Double) -> Unit)? = null,
|
||||
private val secondAntennaOffered: () -> Boolean = { false },
|
||||
) {
|
||||
|
||||
var selected: ChatMember? by mutableStateOf(null)
|
||||
@@ -116,6 +122,20 @@ class SelectedStationState(
|
||||
if (heading.isFinite()) turnAntennaTo?.invoke(heading)
|
||||
}
|
||||
|
||||
/** SO2R with rotator 2 is configured, so the second turn button is offered at all. */
|
||||
val showsSecondAntenna: Boolean
|
||||
get() = turnSecondAntennaTo != null && secondAntennaOffered()
|
||||
|
||||
/** Needs the button to be offered and a finite heading to the selected station. */
|
||||
val canTurnSecondAntenna: Boolean
|
||||
get() = showsSecondAntenna && selected?.getQTFdirection()?.isFinite() == true
|
||||
|
||||
/** Turns antenna 2 to the selected station's heading; does nothing when it cannot. */
|
||||
fun turnSecondAntenna() {
|
||||
val heading = selected?.getQTFdirection() ?: return
|
||||
if (heading.isFinite() && showsSecondAntenna) turnSecondAntennaTo?.invoke(heading)
|
||||
}
|
||||
|
||||
fun select(member: ChatMember?) {
|
||||
if (selected?.callSignRaw != member?.callSignRaw) {
|
||||
skedBand = member?.let { resolveDefaultSkedBand(it) }
|
||||
|
||||
@@ -58,6 +58,13 @@ interface SettingsHost {
|
||||
*/
|
||||
fun refreshShortcutButtonsFromSettings()
|
||||
|
||||
/**
|
||||
* Pushes the stored SO2R switch and the rotator 2 switch into the main window's Compose
|
||||
* state, so the QTF2 field and the "Turn ant2" button follow an Apply. The
|
||||
* implementation has to hop onto the UI thread; the settings window runs on its own.
|
||||
*/
|
||||
fun refreshSo2rState()
|
||||
|
||||
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
|
||||
fun refreshTextSnippetContextMenusFromSettings()
|
||||
|
||||
@@ -143,6 +150,7 @@ fun buildSettingsTabs(
|
||||
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
|
||||
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
|
||||
connectionState = connectionState,
|
||||
applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() },
|
||||
)
|
||||
|
||||
val notificationState = NotificationTabState(
|
||||
@@ -225,7 +233,7 @@ fun buildSettingsTabs(
|
||||
return listOf(
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState, reportRefusal) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, draft, host::applyOwnQrgFollower)) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, draft, applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() }, applyOwnQrgFollower = host::applyOwnQrgFollower)) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs, draft)) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) },
|
||||
|
||||
@@ -96,6 +96,19 @@ fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
|
||||
Form.int(strings.stationRotatorPort, state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it }
|
||||
}
|
||||
|
||||
/*
|
||||
* Only with SO2R, as stored: Form.check and Form.int have no disabled state, and a
|
||||
* rotator 2 switch without SO2R would do nothing. Switching SO2R on shows the block
|
||||
* after Apply.
|
||||
*/
|
||||
if (state.so2rEnabled) {
|
||||
Form.section(strings.stationRotator2Section) {
|
||||
Form.check(strings.stationRotator2Enabled, state.stn_pstRotator2Enabled) { state.stn_pstRotator2Enabled = it }
|
||||
Form.text(strings.stationRotator2Host, state.stn_pstRotator2Host) { state.stn_pstRotator2Host = it }
|
||||
Form.int(strings.stationRotator2Port, state.stn_pstRotator2Port) { state.stn_pstRotator2Port = it }
|
||||
}
|
||||
}
|
||||
|
||||
Form.section(strings.stationPathSection) {
|
||||
Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
|
||||
Form.decimal(strings.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
|
||||
|
||||
@@ -39,6 +39,11 @@ class StationTabState(
|
||||
* follow it while the tab is open; the default suits a state built without a session.
|
||||
*/
|
||||
private val connectionState: () -> On4KstConnectionState = { On4KstConnectionState.DISCONNECTED },
|
||||
/**
|
||||
* Restarts or stops rotator 2 to match the stored settings; shared by this tab and the
|
||||
* TRX synch tab through [SECOND_ROTATOR], so it runs once per apply.
|
||||
*/
|
||||
private val applySecondRotator: () -> Unit = {},
|
||||
) {
|
||||
|
||||
var loginChatCategoryMain: ChatCategory?
|
||||
@@ -164,7 +169,47 @@ class StationTabState(
|
||||
|
||||
var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) }
|
||||
|
||||
var stn_pstRotatorPort: Int by draft.setting({ prefs.getStn_pstRotatorPort() }) { prefs.setStn_pstRotatorPort(it) }
|
||||
/**
|
||||
* Refused on apply when it clashes with rotor 2's port as it will be stored, but only
|
||||
* while rotor 2 is in use (SO2R on and rotor 2 enabled), so a station without SO2R is
|
||||
* never held back by rotor 2's default port. See [portsClash].
|
||||
*/
|
||||
var stn_pstRotatorPort: Int by draft.setting({ prefs.getStn_pstRotatorPort() }) { port ->
|
||||
if (prefs.isTrxSynch_so2rEnabled() && stn_pstRotator2Enabled) {
|
||||
require(!portsClash(port, stn_pstRotator2Port)) { CurrentStrings.get().stationRotator2PortClash(port.toString()) }
|
||||
}
|
||||
prefs.setStn_pstRotatorPort(port)
|
||||
}
|
||||
|
||||
var stn_pstRotator2Enabled: Boolean by draft.setting(
|
||||
{ prefs.isStn_pstRotator2Enabled() },
|
||||
effectKey = SECOND_ROTATOR,
|
||||
effect = { applySecondRotator() },
|
||||
) { prefs.setStn_pstRotator2Enabled(it) }
|
||||
|
||||
var stn_pstRotator2Host: String by draft.setting(
|
||||
{ prefs.getStn_pstRotator2Host() },
|
||||
effectKey = SECOND_ROTATOR,
|
||||
effect = { applySecondRotator() },
|
||||
) { prefs.setStn_pstRotator2Host(it) }
|
||||
|
||||
/**
|
||||
* Refused on apply when it is the same as or adjacent to rotator 1's port as it will be
|
||||
* stored (see [portsClash]). Compared with the drafted value of rotator 1, so changing
|
||||
* both in one apply is judged by the result, not by the order of the edits.
|
||||
*/
|
||||
var stn_pstRotator2Port: Int by draft.setting(
|
||||
{ prefs.getStn_pstRotator2Port() },
|
||||
effectKey = SECOND_ROTATOR,
|
||||
effect = { applySecondRotator() },
|
||||
) { port ->
|
||||
require(!portsClash(port, stn_pstRotatorPort)) { CurrentStrings.get().stationRotator2PortClash(port.toString()) }
|
||||
prefs.setStn_pstRotator2Port(port)
|
||||
}
|
||||
|
||||
/** Whether the rotator 2 block is active; follows the stored SO2R switch. */
|
||||
val so2rEnabled: Boolean
|
||||
get() = prefs.isTrxSynch_so2rEnabled()
|
||||
|
||||
var stn_qtfDefault: Double by draft.setting({ prefs.getStn_qtfDefault() }) { prefs.setStn_qtfDefault(it) }
|
||||
|
||||
@@ -235,6 +280,15 @@ class StationTabState(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The effect key of everything that restarts rotator 2; shared with the TRX synch tab. */
|
||||
const val SECOND_ROTATOR: String = "secondRotator"
|
||||
|
||||
/**
|
||||
* Whether two PSTRotator ports collide: the same port, or one apart because
|
||||
* PSTRotator answers on port + 1, which would be the other client's local port.
|
||||
*/
|
||||
fun portsClash(a: Int, b: Int): Boolean = kotlin.math.abs(a - b) <= 1
|
||||
|
||||
/**
|
||||
* Reads an own ground elevation entry: empty means "elevation model" (NaN), a
|
||||
* number in the accepted range is taken, anything else is refused with null.
|
||||
|
||||
@@ -62,6 +62,24 @@ fun TrxSynchTab(state: TrxSynchTabState) {
|
||||
)
|
||||
}
|
||||
|
||||
Form.section(strings.trxSynchSo2rSection) {
|
||||
Form.check(
|
||||
strings.trxSynchSo2rEnabled,
|
||||
state.trxSynch_so2rEnabled,
|
||||
) { state.trxSynch_so2rEnabled = it; revision++ }
|
||||
|
||||
GuardedTextField(
|
||||
label = strings.trxSynchWinTestStationRadio2,
|
||||
value = state.logsynch_wintestNetworkStationNameOfWintestClient2,
|
||||
enabled = state.trxSynch_so2rEnabled,
|
||||
onAccepted = {
|
||||
state.logsynch_wintestNetworkStationNameOfWintestClient2 = it
|
||||
revision++
|
||||
},
|
||||
)
|
||||
Text(strings.trxSynchSo2rHint)
|
||||
}
|
||||
|
||||
Form.section(strings.trxSynchHintSection) {
|
||||
Text(strings.trxSynchHint)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import kst4contest.view.compose.setting
|
||||
* Win-Test STATUS data. [QRG_SOURCE_HINT_KEY] names the text the tab shows so an operator
|
||||
* cannot read a checked box as "my QRG is being kept current".
|
||||
*
|
||||
* @param applySecondRotator restarts or stops rotator 2 to match the stored SO2R and rotator 2
|
||||
* switches, once per apply. Declared before [applyOwnQrgFollower] so callers passing the
|
||||
* follower as a trailing lambda keep binding to it.
|
||||
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
|
||||
* and detaches it otherwise, once per apply however many sources changed; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
|
||||
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
|
||||
@@ -28,6 +31,7 @@ import kst4contest.view.compose.setting
|
||||
class TrxSynchTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val draft: SettingsDraft,
|
||||
private val applySecondRotator: () -> Unit = {},
|
||||
private val applyOwnQrgFollower: (Boolean) -> Unit,
|
||||
) {
|
||||
|
||||
@@ -62,6 +66,22 @@ class TrxSynchTabState(
|
||||
get() = stationFilter
|
||||
set(value) { stationFilter = value.trim() }
|
||||
|
||||
/** SO2R: radio 2 gets SECONDQRG and, with its own switch, rotator 2. */
|
||||
var trxSynch_so2rEnabled: Boolean by draft.setting(
|
||||
{ prefs.isTrxSynch_so2rEnabled() },
|
||||
effectKey = StationTabState.SECOND_ROTATOR,
|
||||
effect = { applySecondRotator() },
|
||||
) { prefs.setTrxSynch_so2rEnabled(it) }
|
||||
|
||||
private var stationRadio2: String by draft.setting(
|
||||
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2() },
|
||||
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient2(it) }
|
||||
|
||||
/** Win-Test station of radio 2; empty sends nothing to radio 2. Staged trimmed. */
|
||||
var logsynch_wintestNetworkStationNameOfWintestClient2: String
|
||||
get() = stationRadio2
|
||||
set(value) { stationRadio2 = value.trim() }
|
||||
|
||||
/* Read from the stored values: it runs on apply, after both switches are written. */
|
||||
private fun applyFollower() {
|
||||
applyOwnQrgFollower(prefs.isTrxSynch_ucxLogUDPListenerEnabled() || prefs.isLogsynch_wintestQrgSyncEnabled())
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
/** The send line with SO2R: the display-only QTF2 field appears only while SO2R is on. */
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class ChatInputRowSo2rScreenTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
private fun prefs() = ChatPreferences(tempDir.resolve("preferences.xml")).apply {
|
||||
getActualQTF().set(145.0)
|
||||
getActualQTF2().set(270.0)
|
||||
}
|
||||
|
||||
private fun chatInputStateFor(prefs: ChatPreferences) = ChatInputState(object : ChatInputActions {
|
||||
override fun panelSelection(): ChatMember? = null
|
||||
override fun tableSelection(): ChatMember? = null
|
||||
override fun scoreSelection(): ChatMember? = null
|
||||
override fun resolveVariables(template: String, member: ChatMember?): String? = template
|
||||
override fun isAddressedToOwnCallsign(text: String): Boolean = false
|
||||
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
|
||||
override fun mainCategory(): ChatCategory? = null
|
||||
override fun recordOutboundCq(text: String) {}
|
||||
override fun requestRecompute(reason: String) {}
|
||||
override fun queue(message: ChatMessage) {}
|
||||
override fun ownQrgMain(): String = prefs.getMYQRGFirstCat().get().orEmpty()
|
||||
override fun ownQrgSecond(): String = prefs.getMYQRGSecondCat().get().orEmpty()
|
||||
})
|
||||
|
||||
@Test
|
||||
fun `QTF2 is shown only with SO2R and cannot be edited`() = runComposeUiTest {
|
||||
val prefs = prefs()
|
||||
var so2r by mutableStateOf(false)
|
||||
setContent {
|
||||
ChatInputRow(
|
||||
state = chatInputStateFor(prefs),
|
||||
prefs = prefs,
|
||||
ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()),
|
||||
ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()),
|
||||
antennaQtf = ObservedValue(prefs.getActualQTF()),
|
||||
antennaQtf2 = ObservedValue(prefs.getActualQTF2()),
|
||||
so2rEnabled = so2r,
|
||||
)
|
||||
}
|
||||
|
||||
onAllNodesWithText("270.0").assertCountEquals(0)
|
||||
so2r = true
|
||||
waitForIdle()
|
||||
onNodeWithText("270.0").assertExists()
|
||||
onNodeWithText("270.0").assertIsNotEnabled()
|
||||
}
|
||||
}
|
||||
@@ -259,4 +259,57 @@ class SelectedStationStateTest {
|
||||
|
||||
assertTrue(session.failMarked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `antenna 2 is offered only with SO2R and turns rotator 2`() {
|
||||
val headings2 = mutableListOf<Double>()
|
||||
var offered = false
|
||||
val state = SelectedStationState(
|
||||
propagateNotQrv = { },
|
||||
createSked = { _, _, _, _ -> true },
|
||||
turnAntennaTo = { },
|
||||
turnSecondAntennaTo = { headings2.add(it) },
|
||||
secondAntennaOffered = { offered },
|
||||
)
|
||||
state.select(member("DL1ABC").apply { setQTFdirection(145.7) })
|
||||
|
||||
assertFalse(state.showsSecondAntenna)
|
||||
offered = true
|
||||
assertTrue(state.showsSecondAntenna)
|
||||
assertTrue(state.canTurnSecondAntenna)
|
||||
|
||||
state.turnSecondAntenna()
|
||||
|
||||
assertEquals(listOf(145.7), headings2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `antenna 2 is greyed out without a heading`() {
|
||||
val state = SelectedStationState(
|
||||
propagateNotQrv = { },
|
||||
createSked = { _, _, _, _ -> true },
|
||||
turnSecondAntennaTo = { },
|
||||
secondAntennaOffered = { true },
|
||||
)
|
||||
state.select(member("DL1ABC"))
|
||||
|
||||
assertTrue(state.showsSecondAntenna)
|
||||
assertFalse(state.canTurnSecondAntenna)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `antenna 2 offer follows the surroundings flag without a new selection`() {
|
||||
val surroundings = MainWindowSurroundings()
|
||||
val state = SelectedStationState(
|
||||
propagateNotQrv = { },
|
||||
createSked = { _, _, _, _ -> true },
|
||||
turnSecondAntennaTo = { },
|
||||
secondAntennaOffered = { surroundings.secondAntennaOffered },
|
||||
)
|
||||
state.select(member("DL1ABC"))
|
||||
|
||||
assertFalse(state.showsSecondAntenna)
|
||||
surroundings.secondAntennaOffered = true
|
||||
assertTrue(state.showsSecondAntenna)
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,106 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
var mainApplied: ChatCategory? = null
|
||||
var secondApplied: ChatCategory? = null
|
||||
var secondWasApplied = false
|
||||
val secondRotatorCalls = mutableListOf<Unit>()
|
||||
val draft = SettingsDraft()
|
||||
val state = StationTabState(
|
||||
prefs,
|
||||
draft,
|
||||
{ mainApplied = it },
|
||||
{ secondApplied = it; secondWasApplied = true },
|
||||
applySecondRotator = { secondRotatorCalls.add(Unit) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotator 2 settings restart it once per apply however many changed`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_pstRotator2Enabled = true
|
||||
s.state.stn_pstRotator2Host = "10.0.0.7"
|
||||
s.state.stn_pstRotator2Port = 12020
|
||||
val outcome = s.draft.apply()
|
||||
|
||||
assertTrue(outcome.isClean, outcome.failures.toString())
|
||||
assertEquals(12020, s.prefs.getStn_pstRotator2Port())
|
||||
assertEquals(1, s.secondRotatorCalls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotator 2 on the port of rotator 1 is refused and stays in the draft`() {
|
||||
val s = Session()
|
||||
s.state.stn_pstRotatorPort = 12040
|
||||
|
||||
s.state.stn_pstRotator2Port = 12040
|
||||
val outcome = s.draft.apply()
|
||||
|
||||
assertEquals(1, outcome.failedWrites.size)
|
||||
assertTrue(outcome.failedWrites.single().contains("12040"), outcome.failedWrites.toString())
|
||||
assertEquals(12010, s.prefs.getStn_pstRotator2Port())
|
||||
assertEquals(12040, s.state.stn_pstRotator2Port, "the refused value is still shown")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotator 1 onto the port of rotator 2 is refused while SO2R and rotator 2 are on`() {
|
||||
val s = Session()
|
||||
s.prefs.setTrxSynch_so2rEnabled(true)
|
||||
s.prefs.setStn_pstRotator2Enabled(true)
|
||||
|
||||
s.state.stn_pstRotatorPort = 12010
|
||||
val outcome = s.draft.apply()
|
||||
|
||||
assertEquals(1, outcome.failedWrites.size, outcome.failedWrites.toString())
|
||||
assertTrue(outcome.failedWrites.single().contains("12010"), outcome.failedWrites.toString())
|
||||
assertNotEquals(12010, s.prefs.getStn_pstRotatorPort())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotator 1 onto the port of rotator 2 is accepted without SO2R`() {
|
||||
val s = Session()
|
||||
s.prefs.setStn_pstRotator2Enabled(true)
|
||||
|
||||
s.state.stn_pstRotatorPort = 12010
|
||||
val outcome = s.draft.apply()
|
||||
|
||||
assertTrue(outcome.isClean, outcome.failures.toString())
|
||||
assertEquals(12010, s.prefs.getStn_pstRotatorPort())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotator 1 onto the port of rotator 2 is accepted while rotator 2 is off`() {
|
||||
val s = Session()
|
||||
s.prefs.setTrxSynch_so2rEnabled(true)
|
||||
|
||||
s.state.stn_pstRotatorPort = 12010
|
||||
val outcome = s.draft.apply()
|
||||
|
||||
assertTrue(outcome.isClean, outcome.failures.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adjacent rotator ports are refused in both directions and distant ones accepted`() {
|
||||
for (p2 in intArrayOf(12041, 12039)) {
|
||||
val s = Session()
|
||||
s.state.stn_pstRotatorPort = 12040
|
||||
s.state.stn_pstRotator2Port = p2
|
||||
val outcome = s.draft.apply()
|
||||
assertEquals(1, outcome.failedWrites.size, "rotor 2 on $p2: ${outcome.failedWrites}")
|
||||
}
|
||||
for (p1 in intArrayOf(12011, 12009)) {
|
||||
val s = Session()
|
||||
s.prefs.setTrxSynch_so2rEnabled(true)
|
||||
s.prefs.setStn_pstRotator2Enabled(true)
|
||||
s.state.stn_pstRotatorPort = p1
|
||||
val outcome = s.draft.apply()
|
||||
assertEquals(1, outcome.failedWrites.size, "rotor 1 on $p1: ${outcome.failedWrites}")
|
||||
}
|
||||
val s = Session()
|
||||
s.prefs.setTrxSynch_so2rEnabled(true)
|
||||
s.prefs.setStn_pstRotator2Enabled(true)
|
||||
s.state.stn_pstRotatorPort = 12030
|
||||
assertTrue(s.draft.apply().isClean)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading comes straight from the preferences`() {
|
||||
val s = Session()
|
||||
|
||||
@@ -15,7 +15,25 @@ class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val followerCalls = mutableListOf<Boolean>()
|
||||
val draft = SettingsDraft()
|
||||
val state by lazy { TrxSynchTabState(prefs, draft) { followerCalls.add(it) } }
|
||||
val secondRotatorCalls = mutableListOf<Unit>()
|
||||
val state by lazy {
|
||||
TrxSynchTabState(prefs, draft, applySecondRotator = { secondRotatorCalls.add(Unit) }) { followerCalls.add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the SO2R switch and the second Win-Test station wait for apply and restart rotator 2 once`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.trxSynch_so2rEnabled = true
|
||||
s.state.logsynch_wintestNetworkStationNameOfWintestClient2 = " STN2 "
|
||||
|
||||
assertFalse(s.prefs.isTrxSynch_so2rEnabled())
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isTrxSynch_so2rEnabled())
|
||||
assertEquals("STN2", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2())
|
||||
assertEquals(1, s.secondRotatorCalls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -35,7 +35,6 @@ import java.nio.charset.StandardCharsets;
|
||||
import kst4contest.logic.FrequencyTextParser;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import kst4contest.observe.SimpleValue;
|
||||
|
||||
|
||||
@@ -48,7 +47,7 @@ import kst4contest.observe.SimpleValue;
|
||||
* <b>SINGLETON</b>
|
||||
*
|
||||
*/
|
||||
public class ChatController implements ThreadStatusCallback, PstRotatorEventListener {
|
||||
public class ChatController implements ThreadStatusCallback {
|
||||
|
||||
/**
|
||||
* Chat selection ? 50/70 MHz..............1 144/432 MHz............2
|
||||
@@ -69,7 +68,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
public static final int MAX_BEACON_TEXT_LENGTH = 120;
|
||||
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
|
||||
|
||||
private volatile PstRotatorClient rotatorClient;
|
||||
private Consumer<Double> viewRotorCallback;
|
||||
|
||||
/*
|
||||
@@ -86,14 +84,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return thread;
|
||||
});
|
||||
|
||||
private ScheduledFuture<?> pendingRotatorRetry;
|
||||
|
||||
/*
|
||||
* Updated directly by the PSTRotator receiver thread. This avoids reading
|
||||
* a JavaFX property from the command scheduler.
|
||||
*/
|
||||
private volatile double lastReportedRotatorAzimuth = Double.NaN;
|
||||
|
||||
private StatusUpdateListener statusListener; //update info interface for the threads
|
||||
|
||||
|
||||
@@ -311,158 +301,108 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
|
||||
public void initRotor() {
|
||||
rotatorClient = new PstRotatorClient(
|
||||
chatPreferences.getStn_pstRotatorHost(),
|
||||
chatPreferences.getStn_pstRotatorPort(),
|
||||
this,
|
||||
this
|
||||
);
|
||||
rotator1.start();
|
||||
}
|
||||
|
||||
rotatorClient.start();
|
||||
/** Kept for existing references; the value lives in RotatorControl. */
|
||||
static final double ROTATOR_REACHED_TOLERANCE_DEGREES = RotatorControl.REACHED_TOLERANCE_DEGREES;
|
||||
|
||||
/** Rotator of radio 1; also the only one without SO2R. */
|
||||
private final RotatorControl rotator1 = new RotatorControl(
|
||||
"PSTRotator",
|
||||
control -> startPstRotatorClient(control, chatPreferences.getStn_pstRotatorHost(),
|
||||
chatPreferences.getStn_pstRotatorPort(), "PSTRotator"),
|
||||
rotatorCommandScheduler,
|
||||
azimuth -> runOnUi(() -> chatPreferences.getActualQTF().set(azimuth)));
|
||||
|
||||
/** Rotator of radio 2; only with SO2R and its own switch. */
|
||||
private final RotatorControl rotator2 = new RotatorControl(
|
||||
"PSTRotator2",
|
||||
control -> startPstRotatorClient(control, chatPreferences.getStn_pstRotator2Host(),
|
||||
chatPreferences.getStn_pstRotator2Port(), "PSTRotator2"),
|
||||
rotatorCommandScheduler,
|
||||
azimuth -> runOnUi(() -> chatPreferences.getActualQTF2().set(azimuth)));
|
||||
|
||||
/** Test seam: the rotator of radio 2. */
|
||||
RotatorControl secondRotator() {
|
||||
return rotator2;
|
||||
}
|
||||
|
||||
static boolean secondRotatorWanted(ChatPreferences prefs) {
|
||||
return prefs.isTrxSynch_so2rEnabled() && prefs.isStn_pstRotator2Enabled();
|
||||
}
|
||||
|
||||
/** Turns the antenna of [slot]; radio 2 only does something while its rotator runs. */
|
||||
public void rotateTo(RadioSlot slot, double azimuth) {
|
||||
(slot == RadioSlot.SECOND ? rotator2 : rotator1).rotateTo(azimuth);
|
||||
}
|
||||
|
||||
/*
|
||||
* Not isDisconnected(): that flag defaults to false before the first connect,
|
||||
* so it would claim a session where none exists.
|
||||
*/
|
||||
private boolean isSessionRunning() {
|
||||
return on4KstConnectionManager != null && sessionRunning(on4KstConnectionManager.getState());
|
||||
}
|
||||
|
||||
static boolean sessionRunning(On4KstConnectionState state) {
|
||||
return state != null
|
||||
&& state != On4KstConnectionState.DISCONNECTED
|
||||
&& state != On4KstConnectionState.STOPPING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Follows the settings for rotator 2 after Apply: always stopped first, so a changed
|
||||
* host or port is bound anew, and started again only while a session runs. Without it,
|
||||
* QTF2 shows nothing rather than a stale heading.
|
||||
*/
|
||||
static final double ROTATOR_REACHED_TOLERANCE_DEGREES = 1.0;
|
||||
public void reconfigureSecondRotator() {
|
||||
rotator2.stop();
|
||||
if (secondRotatorWanted(chatPreferences)) {
|
||||
if (isSessionRunning()) {
|
||||
rotator2.start();
|
||||
}
|
||||
} else {
|
||||
runOnUi(() -> chatPreferences.getActualQTF2().set(Double.NaN));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new azimuth to PSTRotator without blocking the JavaFX thread.
|
||||
*
|
||||
* <p>Some SPID configurations occasionally ignore the first azimuth
|
||||
* command. KST4Contest therefore checks the latest reported position after
|
||||
* two seconds. If no movement was reported and the target has not already
|
||||
* been reached, the original compatibility sequence is sent again.</p>
|
||||
* Opens one PSTRotator connection whose position reports go to [control]. The
|
||||
* returned link is what RotatorControl drives; stopping it also stops the polling.
|
||||
*/
|
||||
private RotatorControl.Link startPstRotatorClient(RotatorControl control, String host, int port, String threadName) {
|
||||
PstRotatorClient client = new PstRotatorClient(host, port, threadName, new PstRotatorEventListener() {
|
||||
@Override public void onAzimuthUpdate(double azimuth) { control.onAzimuthUpdate(azimuth); }
|
||||
@Override public void onElevationUpdate(double elevation) { /* not used */ }
|
||||
@Override public void onModeUpdate(boolean isTracking) { /* not used */ }
|
||||
@Override public void onMessageReceived(String raw) { /* not used */ }
|
||||
}, this);
|
||||
client.start();
|
||||
return new RotatorControl.Link() {
|
||||
@Override public void setTrackingMode(boolean on) { client.setTrackingMode(on); }
|
||||
@Override public void setAzimuth(double azimuth) { client.setAzimuth(azimuth); }
|
||||
@Override public void stopRotation() { client.stopRotor(); }
|
||||
@Override public void stop() { client.stop(); }
|
||||
};
|
||||
}
|
||||
|
||||
/* Runs on the UI thread, directly when already there. */
|
||||
private void runOnUi(Runnable update) {
|
||||
if (uiDispatcher.isUiThread()) {
|
||||
update.run();
|
||||
} else {
|
||||
uiDispatcher.runOnUi(update);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new azimuth to PSTRotator without blocking the UI thread; see RotatorControl.
|
||||
*
|
||||
* @param azimuth required antenna azimuth in degrees
|
||||
*/
|
||||
public void rotateTo(double azimuth) {
|
||||
if (!Double.isFinite(azimuth)) {
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"Ignoring invalid PSTRotator azimuth: {0}",
|
||||
azimuth
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
PstRotatorClient activeClient = rotatorClient;
|
||||
if (activeClient == null) {
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"Cannot rotate antenna to {0} degrees: "
|
||||
+ "PSTRotator integration is not active.",
|
||||
azimuth
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
double targetAzimuth = normalizeAzimuth(azimuth);
|
||||
double positionBeforeCommand = lastReportedRotatorAzimuth;
|
||||
|
||||
activeClient.setTrackingMode(false);
|
||||
|
||||
LOGGER.log(
|
||||
Level.INFO,
|
||||
"Sending PSTRotator azimuth requested by the operator: {0}",
|
||||
targetAzimuth
|
||||
);
|
||||
|
||||
activeClient.setAzimuth(targetAzimuth);
|
||||
|
||||
ScheduledFuture<?> previousRetry = pendingRotatorRetry;
|
||||
if (previousRetry != null) {
|
||||
previousRetry.cancel(false);
|
||||
}
|
||||
|
||||
pendingRotatorRetry = rotatorCommandScheduler.schedule(
|
||||
() -> retryRotatorCommandIfRequired(
|
||||
positionBeforeCommand,
|
||||
targetAzimuth
|
||||
),
|
||||
2,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the delayed SPID compatibility check.
|
||||
*
|
||||
* <p>No retry is required when the requested position has already been
|
||||
* reached or when PSTRotator reported movement after the original command.
|
||||
* Missing feedback is treated like an unchanged position.</p>
|
||||
*/
|
||||
private void retryRotatorCommandIfRequired(
|
||||
double positionBeforeCommand,
|
||||
double targetAzimuth
|
||||
) {
|
||||
PstRotatorClient activeClient = rotatorClient;
|
||||
if (activeClient == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
double currentAzimuth = lastReportedRotatorAzimuth;
|
||||
|
||||
if (Double.isFinite(currentAzimuth)
|
||||
&& angularDistance(currentAzimuth, targetAzimuth) < ROTATOR_REACHED_TOLERANCE_DEGREES) {
|
||||
|
||||
LOGGER.log(
|
||||
Level.FINE,
|
||||
"PSTRotator reached the requested azimuth without retry: {0}",
|
||||
targetAzimuth
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean noPositionFeedback =
|
||||
!Double.isFinite(currentAzimuth);
|
||||
|
||||
boolean positionUnchanged =
|
||||
Double.isFinite(positionBeforeCommand)
|
||||
&& Double.isFinite(currentAzimuth)
|
||||
&& angularDistance(
|
||||
positionBeforeCommand,
|
||||
currentAzimuth
|
||||
) < 0.5;
|
||||
|
||||
if (!noPositionFeedback && !positionUnchanged) {
|
||||
LOGGER.log(
|
||||
Level.FINE,
|
||||
"PSTRotator reported movement towards {0}; no retry required.",
|
||||
targetAzimuth
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"PSTRotator reported no movement after the command for {0} degrees; "
|
||||
+ "sending the SPID compatibility retry.",
|
||||
targetAzimuth
|
||||
);
|
||||
|
||||
activeClient.setAzimuth(0.0);
|
||||
activeClient.setAzimuth(targetAzimuth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the smallest angular distance between two azimuth values.
|
||||
*/
|
||||
private static double angularDistance(double first, double second) {
|
||||
double difference = Math.abs(
|
||||
normalizeAzimuth(first) - normalizeAzimuth(second)
|
||||
);
|
||||
return Math.min(difference, 360.0 - difference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises an azimuth to the range from 0 inclusive to 360 exclusive.
|
||||
*/
|
||||
private static double normalizeAzimuth(double azimuth) {
|
||||
double normalized = azimuth % 360.0;
|
||||
return normalized < 0.0 ? normalized + 360.0 : normalized;
|
||||
rotator1.rotateTo(azimuth);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -568,69 +508,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
|
||||
public void stopRotator() {
|
||||
ScheduledFuture<?> pendingRetry = pendingRotatorRetry;
|
||||
if (pendingRetry != null) {
|
||||
pendingRetry.cancel(false);
|
||||
pendingRotatorRetry = null;
|
||||
rotator1.stop();
|
||||
}
|
||||
|
||||
PstRotatorClient activeClient = rotatorClient;
|
||||
rotatorClient = null;
|
||||
lastReportedRotatorAzimuth = Double.NaN;
|
||||
|
||||
if (activeClient != null) {
|
||||
activeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAzimuthUpdate(double azimuth) {
|
||||
if (!Double.isFinite(azimuth)) {
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"Ignoring invalid azimuth reported by PSTRotator: {0}",
|
||||
azimuth
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
double normalizedAzimuth = normalizeAzimuth(azimuth);
|
||||
lastReportedRotatorAzimuth = normalizedAzimuth;
|
||||
|
||||
/*
|
||||
* The callback runs in the PSTRotator receiver thread. JavaFX properties
|
||||
* must be updated on the JavaFX Application Thread.
|
||||
*/
|
||||
Runnable fxUpdate = () ->
|
||||
chatPreferences.getActualQTF().set(normalizedAzimuth);
|
||||
|
||||
if (uiDispatcher.isUiThread()) {
|
||||
fxUpdate.run();
|
||||
} else {
|
||||
uiDispatcher.runOnUi(fxUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onElevationUpdate(double elevation) {
|
||||
// System.out.println("Neue Elevation: " + elevation);
|
||||
//not used in first version
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onModeUpdate(boolean isTracking) {
|
||||
// System.out.println("Modus: " + (isTracking ? "Tracking" : "Manuell"));
|
||||
//not used in first version
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(String raw) {
|
||||
// Logging
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Helping
|
||||
* @param targetCallSignRaw
|
||||
@@ -921,8 +801,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE)) {
|
||||
|
||||
// rotatorClient.
|
||||
|
||||
// this.lst_chatMemberList.clear();;
|
||||
this.clearActiveChatMembers();
|
||||
this.lst_clusterMemberList.clear();
|
||||
@@ -971,8 +849,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
dbHandler.closeDBConnection();
|
||||
|
||||
rotatorClient.stopRotor();
|
||||
rotatorClient.stop();
|
||||
rotator1.stopRotation();
|
||||
rotator1.stop();
|
||||
rotator2.stopRotation();
|
||||
rotator2.stop();
|
||||
|
||||
try {
|
||||
|
||||
@@ -1079,11 +959,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
if (dbHandler != null) {
|
||||
dbHandler.closeDBConnection();
|
||||
}
|
||||
if (rotatorClient != null) {
|
||||
rotatorClient.stopRotor();
|
||||
rotatorClient.stop();
|
||||
rotatorClient = null;
|
||||
}
|
||||
rotator1.stopRotation();
|
||||
rotator1.stop();
|
||||
rotator2.stopRotation();
|
||||
rotator2.stop();
|
||||
|
||||
releaseBackgroundExecutors();
|
||||
}
|
||||
@@ -1105,11 +984,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
reachabilityService.shutdown();
|
||||
}
|
||||
|
||||
if (pendingRotatorRetry != null) {
|
||||
pendingRotatorRetry.cancel(false);
|
||||
pendingRotatorRetry = null;
|
||||
}
|
||||
|
||||
rotatorCommandScheduler.shutdownNow();
|
||||
}
|
||||
|
||||
@@ -3612,6 +3486,9 @@ private SimpleRoster<String>
|
||||
} else {
|
||||
System.out.println("[ChatController, info]: PSTRotator disabled by user preference -> not starting rotator client.");
|
||||
}
|
||||
if (secondRotatorWanted(chatPreferences) && !rotator2.isRunning()) {
|
||||
rotator2.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* DX cluster service running config
|
||||
@@ -3791,9 +3668,12 @@ private SimpleRoster<String>
|
||||
new AirScoutPeriodicalAPReflectionInquirerTask(this),
|
||||
10_000L, 60_000L);
|
||||
|
||||
if (chatPreferences.isStn_pstRotatorEnabled() && rotatorClient == null) {
|
||||
if (chatPreferences.isStn_pstRotatorEnabled() && !rotator1.isRunning()) {
|
||||
initRotor();
|
||||
}
|
||||
if (secondRotatorWanted(chatPreferences) && !rotator2.isRunning()) {
|
||||
rotator2.start();
|
||||
}
|
||||
startDxClusterServerIfEnabled();
|
||||
on4KstConnectionManager.start();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import java.util.logging.Logger;
|
||||
public class PstRotatorClient implements Runnable {
|
||||
|
||||
private ThreadStatusCallback callBackToController;
|
||||
private String ThreadNickName = "PSTRotator";
|
||||
private final String ThreadNickName;
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(PstRotatorClient.class.getName());
|
||||
private static final int BUFFER_SIZE = 1024;
|
||||
@@ -38,20 +38,34 @@ public class PstRotatorClient implements Runnable {
|
||||
* @param listener Callback für den Chatcontroller
|
||||
*/
|
||||
public PstRotatorClient(String host, int remotePort, PstRotatorEventListener listener, ThreadStatusCallback callBack) {
|
||||
// Laut Manual antwortet PSTRotator oft auf Port+1.
|
||||
// Wir binden uns also standardmäßig auf remotePort + 1.
|
||||
this(host, remotePort, remotePort + 1, listener, callBack);
|
||||
this(host, remotePort, "PSTRotator", listener, callBack);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param threadNickName name of the status button and the listener thread, so two
|
||||
* rotators can be told apart in the status bar
|
||||
*/
|
||||
public PstRotatorClient(String host, int remotePort, String threadNickName,
|
||||
PstRotatorEventListener listener, ThreadStatusCallback callBack) {
|
||||
// PSTRotator answers on the configured port + 1.
|
||||
this(host, remotePort, remotePort + 1, threadNickName, listener, callBack);
|
||||
}
|
||||
|
||||
/**
|
||||
* alternative constructor for seting the remote port explicitely
|
||||
*/
|
||||
public PstRotatorClient(String host, int remotePort, int localPort, PstRotatorEventListener listener, ThreadStatusCallback callBack) {
|
||||
this(host, remotePort, localPort, "PSTRotator", listener, callBack);
|
||||
}
|
||||
|
||||
private PstRotatorClient(String host, int remotePort, int localPort, String threadNickName,
|
||||
PstRotatorEventListener listener, ThreadStatusCallback callBack) {
|
||||
this.callBackToController = callBack;
|
||||
this.host = host;
|
||||
this.remotePort = remotePort;
|
||||
this.localPort = localPort;
|
||||
this.listener = listener;
|
||||
this.ThreadNickName = threadNickName;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.observe.MutableValue;
|
||||
|
||||
/**
|
||||
* One of the two radios of an SO2R station: the place where its frequency and its
|
||||
* antenna heading are shown. Without SO2R only {@link #FIRST} is used.
|
||||
*/
|
||||
public enum RadioSlot {
|
||||
FIRST,
|
||||
SECOND;
|
||||
|
||||
/** The frequency field of this radio: MYQRG for radio 1, SECONDQRG for radio 2. */
|
||||
public MutableValue<String> qrgIn(ChatPreferences prefs) {
|
||||
return this == FIRST ? prefs.getMYQRGFirstCat() : prefs.getMYQRGSecondCat();
|
||||
}
|
||||
|
||||
/** The heading field of this radio: QTF for radio 1, QTF2 for radio 2. */
|
||||
public MutableValue<Double> qtfIn(ChatPreferences prefs) {
|
||||
return this == FIRST ? prefs.getActualQTF() : prefs.getActualQTF2();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Decides which radio a received report belongs to. Pure, so the rule is tested without
|
||||
* a network connection; the receivers ask it and write to the returned slot.
|
||||
*
|
||||
* <p>Without SO2R every report goes to radio 1, as before SO2R existed. An empty result
|
||||
* means the report is dropped.</p>
|
||||
*/
|
||||
public final class RadioSlotRouter {
|
||||
|
||||
private RadioSlotRouter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param radioNr the RadioInfo {@code RadioNr} text, null when the packet has none
|
||||
*/
|
||||
public static Optional<RadioSlot> forRadioInfo(boolean so2rEnabled, String radioNr) {
|
||||
if (!so2rEnabled || radioNr == null || radioNr.isBlank()) {
|
||||
return Optional.of(RadioSlot.FIRST);
|
||||
}
|
||||
return switch (radioNr.trim()) {
|
||||
case "1" -> Optional.of(RadioSlot.FIRST);
|
||||
case "2" -> Optional.of(RadioSlot.SECOND);
|
||||
default -> Optional.empty();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param nameRadio1 the existing station filter; empty accepts any station for radio 1
|
||||
* @param nameRadio2 the station of radio 2; empty means radio 2 receives nothing
|
||||
*/
|
||||
public static Optional<RadioSlot> forWinTestStation(
|
||||
boolean so2rEnabled, String stationName, String nameRadio1, String nameRadio2) {
|
||||
String station = stationName == null ? "" : stationName;
|
||||
boolean radio1Accepts = nameRadio1 == null || nameRadio1.isBlank()
|
||||
|| station.equalsIgnoreCase(nameRadio1);
|
||||
|
||||
if (so2rEnabled && nameRadio2 != null && !nameRadio2.isBlank()
|
||||
&& station.equalsIgnoreCase(nameRadio2)) {
|
||||
return Optional.of(RadioSlot.SECOND);
|
||||
}
|
||||
return radio1Accepts ? Optional.of(RadioSlot.FIRST) : Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package kst4contest.controller;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
@@ -466,12 +467,13 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Win-Test STATUS packet and optionally updates MYQRG.
|
||||
* Parses a Win-Test STATUS packet and routes it to a radio.
|
||||
*
|
||||
* <p>The packet is tokenised while preserving quoted station names.
|
||||
* The configured station-name filter is applied before any frequency is
|
||||
* processed. An empty filter accepts STATUS packets from every Win-Test
|
||||
* station.</p>
|
||||
* {@code RadioSlotRouter} decides before any frequency is processed: radio 1
|
||||
* updates MYQRG, radio 2 (only with SO2R) updates SECONDQRG, and a packet
|
||||
* from any other station is ignored. An empty radio 1 station name accepts
|
||||
* STATUS packets from every Win-Test station.</p>
|
||||
*
|
||||
* <p>The main frequency is read from token 7. If pass-frequency use is
|
||||
* enabled and token 11 contains a valid frequency, the pass frequency is
|
||||
@@ -480,7 +482,8 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
*
|
||||
* @param msg complete Win-Test STATUS packet
|
||||
*/
|
||||
private void parseStatus(String msg) {
|
||||
/* package-private for tests */
|
||||
void parseStatus(String msg) {
|
||||
try {
|
||||
ArrayList<String> parts = new ArrayList<>();
|
||||
Matcher matcher = STATUS_TOKEN_PATTERN.matcher(msg);
|
||||
@@ -501,13 +504,13 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
}
|
||||
|
||||
String stationName = parts.get(1);
|
||||
String stationFilter = client
|
||||
.getChatPreferences()
|
||||
.getLogsynch_wintestNetworkStationNameOfWintestClient1();
|
||||
|
||||
if (stationFilter != null
|
||||
&& !stationFilter.isBlank()
|
||||
&& !stationName.equalsIgnoreCase(stationFilter)) {
|
||||
ChatPreferences prefs = client.getChatPreferences();
|
||||
java.util.Optional<RadioSlot> slot = RadioSlotRouter.forWinTestStation(
|
||||
prefs.isTrxSynch_so2rEnabled(),
|
||||
stationName,
|
||||
prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1(),
|
||||
prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2());
|
||||
if (slot.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -564,15 +567,9 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
? formattedPassQrg
|
||||
: formattedMainQrg;
|
||||
|
||||
if (client
|
||||
.getChatPreferences()
|
||||
.isLogsynch_wintestQrgSyncEnabled()) {
|
||||
client.uiDispatcher().runOnUi(
|
||||
() -> client
|
||||
.getChatPreferences()
|
||||
.getMYQRGFirstCat()
|
||||
.set(qrgToSet)
|
||||
);
|
||||
if (prefs.isLogsynch_wintestQrgSyncEnabled()) {
|
||||
RadioSlot target = slot.get();
|
||||
client.uiDispatcher().runOnUi(() -> target.qrgIn(prefs).set(qrgToSet));
|
||||
}
|
||||
|
||||
System.out.println(
|
||||
|
||||
@@ -515,6 +515,15 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
continue;
|
||||
}
|
||||
|
||||
Node radioNrNode = element.getElementsByTagName("RadioNr").item(0);
|
||||
String radioNr = radioNrNode == null ? null : radioNrNode.getTextContent();
|
||||
java.util.Optional<RadioSlot> slot = RadioSlotRouter.forRadioInfo(
|
||||
client.getChatPreferences().isTrxSynch_so2rEnabled(), radioNr);
|
||||
if (slot.isEmpty()) {
|
||||
System.out.println("[ReadUDPFromUCX, warning]: RadioInfo for unknown RadioNr \"" + radioNr + "\" ignored");
|
||||
continue;
|
||||
}
|
||||
|
||||
String qrg = freqNode.getTextContent();
|
||||
String mode = modeNode.getTextContent();
|
||||
|
||||
@@ -561,8 +570,9 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
// this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
|
||||
|
||||
final String finalFormattedQRG = formattedQRG;
|
||||
final RadioSlot target = slot.get();
|
||||
helper_runOnFxThread(() ->
|
||||
this.client.getChatPreferences().getMYQRGFirstCat().set(finalFormattedQRG)
|
||||
target.qrgIn(this.client.getChatPreferences()).set(finalFormattedQRG)
|
||||
);
|
||||
|
||||
// System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* One PSTRotator as the operator drives it: start and stop, a heading command with its
|
||||
* delayed compatibility check, and the last reported position.
|
||||
*
|
||||
* <p>Moved out of ChatController unchanged so SO2R can run one per radio. The command
|
||||
* scheduler is shared and passed in; nothing here blocks the UI thread.</p>
|
||||
*/
|
||||
public final class RotatorControl {
|
||||
|
||||
/** What the control needs from a rotator connection; PstRotatorClient in production. */
|
||||
public interface Link {
|
||||
void setTrackingMode(boolean on);
|
||||
void setAzimuth(double azimuth);
|
||||
/** Tells the rotor to stop turning. */
|
||||
void stopRotation();
|
||||
/** Closes the connection and its polling. */
|
||||
void stop();
|
||||
}
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(RotatorControl.class.getName());
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public static final double REACHED_TOLERANCE_DEGREES = 1.0;
|
||||
|
||||
private final String threadName;
|
||||
private final Function<RotatorControl, Link> linkFactory;
|
||||
private final ScheduledExecutorService retryScheduler;
|
||||
private final Consumer<Double> reportedAzimuthSink;
|
||||
|
||||
private volatile Link link;
|
||||
private ScheduledFuture<?> pendingRetry;
|
||||
|
||||
/* Updated by the receiver thread; read by the retry check, never from UI state. */
|
||||
private volatile double lastReportedAzimuth = Double.NaN;
|
||||
|
||||
/**
|
||||
* @param threadName names the rotator in logs and in the status bar
|
||||
* @param linkFactory opens the connection; receives this control as the event target
|
||||
* @param retryScheduler runs the delayed compatibility check
|
||||
* @param reportedAzimuthSink receives every valid, normalised reported azimuth on the
|
||||
* receiver thread; the caller moves it to the UI thread
|
||||
*/
|
||||
public RotatorControl(String threadName, Function<RotatorControl, Link> linkFactory,
|
||||
ScheduledExecutorService retryScheduler, Consumer<Double> reportedAzimuthSink) {
|
||||
this.threadName = threadName;
|
||||
this.linkFactory = linkFactory;
|
||||
this.retryScheduler = retryScheduler;
|
||||
this.reportedAzimuthSink = reportedAzimuthSink;
|
||||
}
|
||||
|
||||
/** Opens the connection unless one is open. */
|
||||
public synchronized void start() {
|
||||
if (link == null) {
|
||||
link = linkFactory.apply(this);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isRunning() {
|
||||
return link != null;
|
||||
}
|
||||
|
||||
/** Cancels a pending check, closes the connection and forgets the position. */
|
||||
public synchronized void stop() {
|
||||
ScheduledFuture<?> retry = pendingRetry;
|
||||
if (retry != null) {
|
||||
retry.cancel(false);
|
||||
pendingRetry = null;
|
||||
}
|
||||
Link active = link;
|
||||
link = null;
|
||||
lastReportedAzimuth = Double.NaN;
|
||||
if (active != null) {
|
||||
active.stop();
|
||||
}
|
||||
}
|
||||
|
||||
public double lastReportedAzimuth() {
|
||||
return lastReportedAzimuth;
|
||||
}
|
||||
|
||||
/** Tells the rotor to stop turning; used when the session ends. */
|
||||
public void stopRotation() {
|
||||
Link active = link;
|
||||
if (active != null) {
|
||||
active.stopRotation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new azimuth without blocking the caller.
|
||||
*
|
||||
* <p>Some SPID configurations occasionally ignore the first azimuth command. The latest
|
||||
* reported position is therefore checked after two seconds. If no movement was reported
|
||||
* and the target has not already been reached, the original compatibility sequence is
|
||||
* sent again.</p>
|
||||
*/
|
||||
public synchronized void rotateTo(double azimuth) {
|
||||
if (!Double.isFinite(azimuth)) {
|
||||
LOGGER.log(Level.WARNING, "Ignoring invalid {0} azimuth: {1}", new Object[] { threadName, azimuth });
|
||||
return;
|
||||
}
|
||||
Link active = link;
|
||||
if (active == null) {
|
||||
LOGGER.log(Level.WARNING, "Cannot rotate antenna to {0} degrees: {1} integration is not active.",
|
||||
new Object[] { azimuth, threadName });
|
||||
return;
|
||||
}
|
||||
|
||||
double target = normalizeAzimuth(azimuth);
|
||||
double positionBeforeCommand = lastReportedAzimuth;
|
||||
|
||||
active.setTrackingMode(false);
|
||||
LOGGER.log(Level.INFO, "Sending {0} azimuth requested by the operator: {1}", new Object[] { threadName, target });
|
||||
active.setAzimuth(target);
|
||||
|
||||
ScheduledFuture<?> previous = pendingRetry;
|
||||
if (previous != null) {
|
||||
previous.cancel(false);
|
||||
}
|
||||
pendingRetry = retryScheduler.schedule(
|
||||
() -> retryIfRequired(positionBeforeCommand, target), 2, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/** Called by the receiver thread for every reported position. */
|
||||
public void onAzimuthUpdate(double azimuth) {
|
||||
if (!Double.isFinite(azimuth)) {
|
||||
LOGGER.log(Level.WARNING, "Ignoring invalid azimuth reported by {0}: {1}", new Object[] { threadName, azimuth });
|
||||
return;
|
||||
}
|
||||
double normalized = normalizeAzimuth(azimuth);
|
||||
lastReportedAzimuth = normalized;
|
||||
reportedAzimuthSink.accept(normalized);
|
||||
}
|
||||
|
||||
private void retryIfRequired(double positionBeforeCommand, double target) {
|
||||
Link active = link;
|
||||
if (active == null) {
|
||||
return;
|
||||
}
|
||||
double current = lastReportedAzimuth;
|
||||
|
||||
if (Double.isFinite(current) && angularDistance(current, target) < REACHED_TOLERANCE_DEGREES) {
|
||||
LOGGER.log(Level.FINE, "{0} reached the requested azimuth without retry: {1}", new Object[] { threadName, target });
|
||||
return;
|
||||
}
|
||||
|
||||
boolean noPositionFeedback = !Double.isFinite(current);
|
||||
boolean positionUnchanged = Double.isFinite(positionBeforeCommand)
|
||||
&& Double.isFinite(current)
|
||||
&& angularDistance(positionBeforeCommand, current) < 0.5;
|
||||
|
||||
if (!noPositionFeedback && !positionUnchanged) {
|
||||
LOGGER.log(Level.FINE, "{0} reported movement towards {1}; no retry required.", new Object[] { threadName, target });
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.log(Level.WARNING, "{0} reported no movement after the command for {1} degrees; "
|
||||
+ "sending the SPID compatibility retry.", new Object[] { threadName, target });
|
||||
active.setAzimuth(0.0);
|
||||
active.setAzimuth(target);
|
||||
}
|
||||
|
||||
static double angularDistance(double first, double second) {
|
||||
double difference = Math.abs(normalizeAzimuth(first) - normalizeAzimuth(second));
|
||||
return Math.min(difference, 360.0 - difference);
|
||||
}
|
||||
|
||||
static double normalizeAzimuth(double azimuth) {
|
||||
double normalized = azimuth % 360.0;
|
||||
return normalized < 0.0 ? normalized + 360.0 : normalized;
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,9 @@ public class ChatPreferences {
|
||||
boolean stn_pstRotatorEnabled = false;
|
||||
String stn_pstRotatorHost = "127.0.0.1";
|
||||
int stn_pstRotatorPort = 12000;
|
||||
boolean stn_pstRotator2Enabled = false;
|
||||
String stn_pstRotator2Host = "127.0.0.1";
|
||||
int stn_pstRotator2Port = 12010;
|
||||
|
||||
boolean stn_loginAFKState = false; //always start as here
|
||||
/*
|
||||
@@ -255,6 +258,7 @@ public class ChatPreferences {
|
||||
ChatCategory loginChatCategorySecond = new ChatCategory(3);
|
||||
boolean loginToSecondChatEnabled;
|
||||
MutableValue<Double> actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime!
|
||||
MutableValue<Double> actualQTF2 = new SimpleValue<>(Double.NaN); // heading of rotator 2 (SO2R), NaN when unknown
|
||||
|
||||
boolean stn_bandActive50;
|
||||
boolean stn_bandActive70;
|
||||
@@ -277,6 +281,7 @@ public class ChatPreferences {
|
||||
|
||||
String logsynch_wintestNetworkStationNameOfKST = "KST4Contest";
|
||||
String logsynch_wintestNetworkStationNameOfWintestClient1 = "STN1";
|
||||
String logsynch_wintestNetworkStationNameOfWintestClient2 = "";
|
||||
boolean logsynch_wintestNetworkSimulationEnabled = false;
|
||||
int logsynch_wintestNetworkStationIDOfKST = 55555;
|
||||
int logsynch_wintestNetworkPort = 9871;
|
||||
@@ -297,6 +302,7 @@ public class ChatPreferences {
|
||||
MutableValue<String> MYQRGSecondCat = new SimpleValue<>(); // own qrg will be set by user entry or ucxlog if trx Synch is activated
|
||||
|
||||
boolean trxSynch_ucxLogUDPListenerEnabled = false;
|
||||
boolean trxSynch_so2rEnabled = false;
|
||||
|
||||
/**
|
||||
* AirScout prefs
|
||||
@@ -645,6 +651,14 @@ public class ChatPreferences {
|
||||
this.logsynch_wintestNetworkStationNameOfWintestClient1 = logsynch_wintestNetworkStationNameOfWintestClient1;
|
||||
}
|
||||
|
||||
public String getLogsynch_wintestNetworkStationNameOfWintestClient2() {
|
||||
return logsynch_wintestNetworkStationNameOfWintestClient2;
|
||||
}
|
||||
|
||||
public void setLogsynch_wintestNetworkStationNameOfWintestClient2(String name) {
|
||||
this.logsynch_wintestNetworkStationNameOfWintestClient2 = name == null ? "" : name.trim();
|
||||
}
|
||||
|
||||
public boolean isLogsynch_wintestNetworkSimulationEnabled() {
|
||||
return logsynch_wintestNetworkSimulationEnabled;
|
||||
}
|
||||
@@ -1057,6 +1071,31 @@ public class ChatPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isStn_pstRotator2Enabled() {
|
||||
return stn_pstRotator2Enabled;
|
||||
}
|
||||
|
||||
public void setStn_pstRotator2Enabled(boolean enabled) {
|
||||
this.stn_pstRotator2Enabled = enabled;
|
||||
}
|
||||
|
||||
public String getStn_pstRotator2Host() {
|
||||
return stn_pstRotator2Host;
|
||||
}
|
||||
|
||||
public void setStn_pstRotator2Host(String host) {
|
||||
this.stn_pstRotator2Host = host == null || host.isBlank() ? "127.0.0.1" : host.trim();
|
||||
}
|
||||
|
||||
public int getStn_pstRotator2Port() {
|
||||
return stn_pstRotator2Port;
|
||||
}
|
||||
|
||||
/** PSTRotator answers on port + 1, so 65535 is not usable. */
|
||||
public void setStn_pstRotator2Port(int port) {
|
||||
this.stn_pstRotator2Port = port < 1 || port > 65534 ? 12010 : port;
|
||||
}
|
||||
|
||||
public MutableValue<String> getNotify_optionalFrequencyPrefix() {
|
||||
return notify_optionalFrequencyPrefix;
|
||||
}
|
||||
@@ -1375,6 +1414,11 @@ public class ChatPreferences {
|
||||
return actualQTF;
|
||||
}
|
||||
|
||||
/** Heading reported by rotator 2; NaN while there is none. Display only. */
|
||||
public MutableValue<Double> getActualQTF2() {
|
||||
return actualQTF2;
|
||||
}
|
||||
|
||||
public void setActualQTF(MutableValue<Double> actualQTF) {
|
||||
this.actualQTF = actualQTF;
|
||||
}
|
||||
@@ -1473,6 +1517,14 @@ public class ChatPreferences {
|
||||
this.trxSynch_ucxLogUDPListenerEnabled = trxSynch_ucxLogUDPListenerEnabled;
|
||||
}
|
||||
|
||||
public boolean isTrxSynch_so2rEnabled() {
|
||||
return trxSynch_so2rEnabled;
|
||||
}
|
||||
|
||||
public void setTrxSynch_so2rEnabled(boolean enabled) {
|
||||
this.trxSynch_so2rEnabled = enabled;
|
||||
}
|
||||
|
||||
public void setNotify_optionalFrequencyPrefix(String notify_optionalFrequencyPrefix) {
|
||||
this.notify_optionalFrequencyPrefix.set(notify_optionalFrequencyPrefix);
|
||||
}
|
||||
@@ -1809,6 +1861,18 @@ public class ChatPreferences {
|
||||
stn_pstRotatorPort.setTextContent(Integer.toString(this.stn_pstRotatorPort));
|
||||
station.appendChild(stn_pstRotatorPort);
|
||||
|
||||
Element stn_pstRotator2Enabled = doc.createElement("stn_pstRotator2Enabled");
|
||||
stn_pstRotator2Enabled.setTextContent(this.stn_pstRotator2Enabled + "");
|
||||
station.appendChild(stn_pstRotator2Enabled);
|
||||
|
||||
Element stn_pstRotator2Host = doc.createElement("stn_pstRotator2Host");
|
||||
stn_pstRotator2Host.setTextContent(this.stn_pstRotator2Host);
|
||||
station.appendChild(stn_pstRotator2Host);
|
||||
|
||||
Element stn_pstRotator2Port = doc.createElement("stn_pstRotator2Port");
|
||||
stn_pstRotator2Port.setTextContent(Integer.toString(this.stn_pstRotator2Port));
|
||||
station.appendChild(stn_pstRotator2Port);
|
||||
|
||||
|
||||
/**
|
||||
* LOGSYNCH
|
||||
@@ -1847,6 +1911,10 @@ public class ChatPreferences {
|
||||
logsynch_wintestNetworkStationNameOfWintestClient1.setTextContent(this.logsynch_wintestNetworkStationNameOfWintestClient1);
|
||||
logsynch.appendChild(logsynch_wintestNetworkStationNameOfWintestClient1);
|
||||
|
||||
Element logsynch_wintestNetworkStationNameOfWintestClient2 = doc.createElement("logsynch_wintestNetworkStationNameOfWintestClient2");
|
||||
logsynch_wintestNetworkStationNameOfWintestClient2.setTextContent(this.logsynch_wintestNetworkStationNameOfWintestClient2);
|
||||
logsynch.appendChild(logsynch_wintestNetworkStationNameOfWintestClient2);
|
||||
|
||||
Element logsynch_wintestNetworkSimulationEnabled = doc.createElement("logsynch_wintestNetworkSimulationEnabled");
|
||||
logsynch_wintestNetworkSimulationEnabled.setTextContent(this.logsynch_wintestNetworkSimulationEnabled + "");
|
||||
logsynch.appendChild(logsynch_wintestNetworkSimulationEnabled);
|
||||
@@ -1895,6 +1963,10 @@ public class ChatPreferences {
|
||||
trxSynch_ucxLogUDPListenerEnabled.setTextContent(this.isTrxSynch_ucxLogUDPListenerEnabled()+"");
|
||||
trxSynchUCX.appendChild(trxSynch_ucxLogUDPListenerEnabled);
|
||||
|
||||
Element trxSynch_so2rEnabled = doc.createElement("trxSynch_so2rEnabled");
|
||||
trxSynch_so2rEnabled.setTextContent(this.trxSynch_so2rEnabled + "");
|
||||
trxSynchUCX.appendChild(trxSynch_so2rEnabled);
|
||||
|
||||
Element trxSynch_defaultMYQRGValue = doc.createElement("trxSynch_defaultMYQRGValue");
|
||||
trxSynch_defaultMYQRGValue.setTextContent(this.getMYQRGFirstCat().get());
|
||||
trxSynchUCX.appendChild(trxSynch_defaultMYQRGValue);
|
||||
@@ -2666,6 +2738,11 @@ public class ChatPreferences {
|
||||
)
|
||||
);
|
||||
|
||||
// Added in release 1.50 for SO2R; absent in older files.
|
||||
stn_pstRotator2Enabled = getBoolean(stationEl, stn_pstRotator2Enabled, "stn_pstRotator2Enabled");
|
||||
setStn_pstRotator2Host(getText(stationEl, stn_pstRotator2Host, "stn_pstRotator2Host"));
|
||||
setStn_pstRotator2Port(getInt(stationEl, stn_pstRotator2Port, "stn_pstRotator2Port"));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2704,6 +2781,10 @@ public class ChatPreferences {
|
||||
logsynchEl,
|
||||
logsynch_wintestNetworkStationNameOfWintestClient1,
|
||||
"logsynch_wintestNetworkStationNameOfWintestClient1");
|
||||
setLogsynch_wintestNetworkStationNameOfWintestClient2(getText(
|
||||
logsynchEl,
|
||||
logsynch_wintestNetworkStationNameOfWintestClient2,
|
||||
"logsynch_wintestNetworkStationNameOfWintestClient2"));
|
||||
logsynch_wintestNetworkSimulationEnabled = getBoolean(
|
||||
logsynchEl,
|
||||
logsynch_wintestNetworkSimulationEnabled,
|
||||
@@ -2763,6 +2844,7 @@ public class ChatPreferences {
|
||||
trxSynchEl,
|
||||
trxSynch_ucxLogUDPListenerEnabled,
|
||||
"trxSynch_ucxLogUDPListenerEnabled");
|
||||
trxSynch_so2rEnabled = getBoolean(trxSynchEl, trxSynch_so2rEnabled, "trxSynch_so2rEnabled");
|
||||
|
||||
String qrg1 = getText(trxSynchEl, null, "trxSynch_defaultMYQRGValue");
|
||||
if (qrg1 != null) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static kst4contest.controller.RadioSlot.FIRST;
|
||||
import static kst4contest.controller.RadioSlot.SECOND;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/** Every row of the spec's two routing tables. */
|
||||
class RadioSlotRouterTest {
|
||||
|
||||
@Test
|
||||
void withoutSo2rEveryRadioInfoGoesToRadioOne() {
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(false, "2"));
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(false, null));
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(false, "x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withSo2rTheRadioNumberDecides() {
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(true, "1"));
|
||||
assertEquals(Optional.of(SECOND), RadioSlotRouter.forRadioInfo(true, "2"));
|
||||
assertEquals(Optional.of(SECOND), RadioSlotRouter.forRadioInfo(true, " 2 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withSo2rAMissingRadioNumberIsRadioOne() {
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(true, null));
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forRadioInfo(true, " "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withSo2rOtherRadioNumbersAreDropped() {
|
||||
assertEquals(Optional.empty(), RadioSlotRouter.forRadioInfo(true, "3"));
|
||||
assertEquals(Optional.empty(), RadioSlotRouter.forRadioInfo(true, "0"));
|
||||
assertEquals(Optional.empty(), RadioSlotRouter.forRadioInfo(true, "x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutSo2rWinTestKeepsTheOldFilter() {
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(false, "STN1", "stn1", "STN2"));
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(false, "ANY", "", "STN2"));
|
||||
assertEquals(Optional.empty(), RadioSlotRouter.forWinTestStation(false, "STN2", "STN1", "STN2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withSo2rTheSecondNameWinsThenTheFirst() {
|
||||
assertEquals(Optional.of(SECOND), RadioSlotRouter.forWinTestStation(true, "stn2", "STN1", "STN2"));
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(true, "STN1", "STN1", "STN2"));
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(true, "ANY", "", "STN2"));
|
||||
assertEquals(Optional.empty(), RadioSlotRouter.forWinTestStation(true, "STN3", "STN1", "STN2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withSo2rAnEmptySecondNameSendsNothingToRadioTwo() {
|
||||
assertEquals(Optional.of(FIRST), RadioSlotRouter.forWinTestStation(true, "STN2", "", ""));
|
||||
assertEquals(Optional.empty(), RadioSlotRouter.forWinTestStation(true, "STN2", "STN1", ""));
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -304,6 +306,65 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
|
||||
.anyMatch(message -> message.getRunningInformation().startsWith("packet dropped after error")));
|
||||
}
|
||||
|
||||
/** Records what radio 2 receives. */
|
||||
private final List<String> secondQrgWrites = new CopyOnWriteArrayList<>();
|
||||
private final SimpleValue<String> secondQrg = new SimpleValue<>("");
|
||||
|
||||
@Test
|
||||
void withSo2rRadioTwoGoesToTheSecondQrgAndLeavesMyQrgAlone(@TempDir Path tempDir) throws Exception {
|
||||
givenRadioInfoMarkerEnabled();
|
||||
givenSo2r(true);
|
||||
List<String> firstWrites = new CopyOnWriteArrayList<>();
|
||||
markerQrg.addListener(firstWrites::add);
|
||||
|
||||
sendThenAwaitMarker("<RadioInfo><RadioNr>2</RadioNr><Freq>43220000</Freq><Mode>SSB</Mode></RadioInfo>");
|
||||
|
||||
assertEquals(List.of("432.200.00"), secondQrgWrites);
|
||||
assertEquals(List.of("1296.200.00"), firstWrites, "only the marker may write MYQRG");
|
||||
}
|
||||
|
||||
@Test
|
||||
void withSo2rARadioInfoWithoutRadioNumberIsRadioOne(@TempDir Path tempDir) throws Exception {
|
||||
givenRadioInfoMarkerEnabled();
|
||||
givenSo2r(true);
|
||||
List<String> firstWrites = new CopyOnWriteArrayList<>();
|
||||
markerQrg.addListener(firstWrites::add);
|
||||
|
||||
sendThenAwaitMarker("<RadioInfo><Freq>14420000</Freq><Mode>CW</Mode></RadioInfo>");
|
||||
|
||||
assertEquals(List.of("144.200.00", "1296.200.00"), firstWrites);
|
||||
assertTrue(secondQrgWrites.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withSo2rAnUnknownRadioNumberIsDroppedAndTheListenerKeepsRunning(@TempDir Path tempDir) throws Exception {
|
||||
givenRadioInfoMarkerEnabled();
|
||||
givenSo2r(true);
|
||||
|
||||
sendThenAwaitMarker("<RadioInfo><RadioNr>x</RadioNr><Freq>43220000</Freq><Mode>SSB</Mode></RadioInfo>");
|
||||
|
||||
assertTrue(secondQrgWrites.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutSo2rRadioTwoStillWritesMyQrg(@TempDir Path tempDir) throws Exception {
|
||||
givenRadioInfoMarkerEnabled();
|
||||
givenSo2r(false);
|
||||
List<String> firstWrites = new CopyOnWriteArrayList<>();
|
||||
markerQrg.addListener(firstWrites::add);
|
||||
|
||||
sendThenAwaitMarker("<RadioInfo><RadioNr>2</RadioNr><Freq>43220000</Freq><Mode>SSB</Mode></RadioInfo>");
|
||||
|
||||
assertEquals(List.of("432.200.00", "1296.200.00"), firstWrites);
|
||||
assertTrue(secondQrgWrites.isEmpty());
|
||||
}
|
||||
|
||||
private void givenSo2r(boolean enabled) {
|
||||
when(preferences.isTrxSynch_so2rEnabled()).thenReturn(enabled);
|
||||
secondQrg.addListener(secondQrgWrites::add);
|
||||
when(preferences.getMYQRGSecondCat()).thenReturn(secondQrg);
|
||||
}
|
||||
|
||||
private CountDownLatch markerLatch() {
|
||||
CountDownLatch marker = new CountDownLatch(1);
|
||||
markerQrg.addListener(value -> {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Delayed;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* The rotator command rules moved out of ChatController unchanged: the 1 degree
|
||||
* "reached" tolerance, no retry after reported movement, and the compatibility retry
|
||||
* when nothing moved. The scheduler is captured, so the delayed check runs on demand.
|
||||
*/
|
||||
class RotatorControlTest {
|
||||
|
||||
private final List<String> commands = new ArrayList<>();
|
||||
private final List<Double> reported = new ArrayList<>();
|
||||
private final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
|
||||
private Runnable pendingRetry;
|
||||
|
||||
private RotatorControl control() {
|
||||
when(scheduler.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))).thenAnswer(call -> {
|
||||
pendingRetry = call.getArgument(0);
|
||||
return mock(ScheduledFuture.class);
|
||||
});
|
||||
RotatorControl control = new RotatorControl(
|
||||
"PSTRotator",
|
||||
ignored -> new RotatorControl.Link() {
|
||||
@Override public void setTrackingMode(boolean on) { commands.add("tracking " + on); }
|
||||
@Override public void setAzimuth(double azimuth) { commands.add("az " + azimuth); }
|
||||
@Override public void stopRotation() { commands.add("halt"); }
|
||||
@Override public void stop() { commands.add("stop"); }
|
||||
},
|
||||
scheduler,
|
||||
reported::add);
|
||||
control.start();
|
||||
return control;
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotateSendsTheNormalisedTargetAndSchedulesTheCheckAfterTwoSeconds() {
|
||||
RotatorControl control = control();
|
||||
|
||||
control.rotateTo(-10.0);
|
||||
|
||||
assertEquals(List.of("tracking false", "az 350.0"), commands);
|
||||
verify(scheduler).schedule(any(Runnable.class), eq(2L), eq(TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReportWithinOneDegreeCountsAsReachedAndSendsNoRetry() {
|
||||
RotatorControl control = control();
|
||||
control.onAzimuthUpdate(100.0);
|
||||
control.rotateTo(145.7);
|
||||
control.onAzimuthUpdate(145.0);
|
||||
commands.clear();
|
||||
|
||||
pendingRetry.run();
|
||||
|
||||
assertTrue(commands.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportedMovementSendsNoRetry() {
|
||||
RotatorControl control = control();
|
||||
control.onAzimuthUpdate(100.0);
|
||||
control.rotateTo(200.0);
|
||||
control.onAzimuthUpdate(120.0);
|
||||
commands.clear();
|
||||
|
||||
pendingRetry.run();
|
||||
|
||||
assertTrue(commands.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noMovementSendsTheCompatibilityRetry() {
|
||||
RotatorControl control = control();
|
||||
control.onAzimuthUpdate(100.0);
|
||||
control.rotateTo(200.0);
|
||||
commands.clear();
|
||||
|
||||
pendingRetry.run();
|
||||
|
||||
assertEquals(List.of("az 0.0", "az 200.0"), commands);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsAreNormalisedAndPassedOnAndInvalidOnesIgnored() {
|
||||
RotatorControl control = control();
|
||||
|
||||
control.onAzimuthUpdate(370.0);
|
||||
control.onAzimuthUpdate(Double.NaN);
|
||||
|
||||
assertEquals(List.of(10.0), reported);
|
||||
assertEquals(10.0, control.lastReportedAzimuth());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopClosesTheLinkForgetsThePositionAndIgnoresLaterCommands() {
|
||||
RotatorControl control = control();
|
||||
control.onAzimuthUpdate(100.0);
|
||||
|
||||
control.stop();
|
||||
control.rotateTo(50.0);
|
||||
|
||||
assertEquals(List.of("stop"), commands);
|
||||
assertFalse(control.isRunning());
|
||||
assertTrue(Double.isNaN(control.lastReportedAzimuth()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SecondRotatorLifecycleTest {
|
||||
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void rotatorTwoIsWantedOnlyWithSo2rAndItsOwnSwitch() {
|
||||
ChatPreferences prefs = new ChatPreferences(directory.resolve("p.xml"));
|
||||
assertFalse(ChatController.secondRotatorWanted(prefs));
|
||||
|
||||
prefs.setStn_pstRotator2Enabled(true);
|
||||
assertFalse(ChatController.secondRotatorWanted(prefs), "rotator 2 without SO2R");
|
||||
|
||||
prefs.setTrxSynch_so2rEnabled(true);
|
||||
assertTrue(ChatController.secondRotatorWanted(prefs));
|
||||
|
||||
prefs.setStn_pstRotator2Enabled(false);
|
||||
assertFalse(ChatController.secondRotatorWanted(prefs));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionRunsOnlyInStatesBetweenStartAndStop() {
|
||||
assertFalse(ChatController.sessionRunning(null));
|
||||
assertFalse(ChatController.sessionRunning(On4KstConnectionState.DISCONNECTED));
|
||||
assertFalse(ChatController.sessionRunning(On4KstConnectionState.STOPPING));
|
||||
assertTrue(ChatController.sessionRunning(On4KstConnectionState.CONNECTING));
|
||||
assertTrue(ChatController.sessionRunning(On4KstConnectionState.ONLINE));
|
||||
assertTrue(ChatController.sessionRunning(On4KstConnectionState.RECONNECT_WAIT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.observe.DirectUiDispatcher;
|
||||
import kst4contest.observe.SimpleValue;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/** A Win-Test STATUS of the second station writes SECONDQRG when SO2R is on. */
|
||||
class WinTestStatusSlotTest {
|
||||
|
||||
/* STATUS layout as parseStatus reads it: token 1 station, 5 mode, 7 frequency in 100 Hz. */
|
||||
private static final String STATUS_STN2 = "STATUS: \"STN2\" \"\" 0 12 0 0 4322000 0";
|
||||
|
||||
private final ChatController controller = mock(ChatController.class);
|
||||
private final ChatPreferences prefs = mock(ChatPreferences.class);
|
||||
private final List<String> first = new ArrayList<>();
|
||||
private final List<String> second = new ArrayList<>();
|
||||
|
||||
private static SimpleValue<String> recording(List<String> writes) {
|
||||
SimpleValue<String> value = new SimpleValue<>("");
|
||||
value.addListener(writes::add);
|
||||
return value;
|
||||
}
|
||||
|
||||
private ReadUDPByWintestThread thread(boolean so2r) {
|
||||
when(controller.getChatPreferences()).thenReturn(prefs);
|
||||
when(controller.uiDispatcher()).thenReturn(new DirectUiDispatcher());
|
||||
when(prefs.isLogsynch_wintestQrgSyncEnabled()).thenReturn(true);
|
||||
when(prefs.isTrxSynch_so2rEnabled()).thenReturn(so2r);
|
||||
when(prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()).thenReturn("STN1");
|
||||
when(prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2()).thenReturn("STN2");
|
||||
when(prefs.getMYQRGFirstCat()).thenReturn(recording(first));
|
||||
when(prefs.getMYQRGSecondCat()).thenReturn(recording(second));
|
||||
return new ReadUDPByWintestThread(controller, mock(ThreadStatusCallback.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withSo2rTheSecondStationWritesTheSecondQrg() {
|
||||
thread(true).parseStatus(STATUS_STN2);
|
||||
|
||||
assertEquals(1, second.size());
|
||||
assertTrue(first.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutSo2rTheSecondStationIsFilteredOutAsBefore() {
|
||||
thread(false).parseStatus(STATUS_STN2);
|
||||
|
||||
assertTrue(first.isEmpty());
|
||||
assertTrue(second.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ChatPreferencesSo2rPersistenceTest {
|
||||
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void so2rSettingsSurviveAFullRoundTrip() {
|
||||
Path file = directory.resolve("preferences.xml");
|
||||
ChatPreferences written = new ChatPreferences(file);
|
||||
written.setTrxSynch_so2rEnabled(true);
|
||||
written.setLogsynch_wintestNetworkStationNameOfWintestClient2("STN2");
|
||||
written.setStn_pstRotator2Enabled(true);
|
||||
written.setStn_pstRotator2Host("10.0.0.7");
|
||||
written.setStn_pstRotator2Port(12020);
|
||||
assertTrue(written.writePreferencesToXmlFile());
|
||||
|
||||
ChatPreferences restored = new ChatPreferences(file);
|
||||
assertTrue(restored.readPreferencesFromXmlFile());
|
||||
|
||||
assertTrue(restored.isTrxSynch_so2rEnabled());
|
||||
assertEquals("STN2", restored.getLogsynch_wintestNetworkStationNameOfWintestClient2());
|
||||
assertTrue(restored.isStn_pstRotator2Enabled());
|
||||
assertEquals("10.0.0.7", restored.getStn_pstRotator2Host());
|
||||
assertEquals(12020, restored.getStn_pstRotator2Port());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOlderFileWithoutThemYieldsTheDefaults() throws IOException {
|
||||
Path file = directory.resolve("old.xml");
|
||||
Files.writeString(file, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<praktiKST>
|
||||
<configVersion>8</configVersion>
|
||||
<station><stn_pstRotatorPort>12000</stn_pstRotatorPort></station>
|
||||
<trxSynchUCX><trxSynch_ucxLogUDPListenerEnabled>true</trxSynch_ucxLogUDPListenerEnabled></trxSynchUCX>
|
||||
</praktiKST>
|
||||
""");
|
||||
|
||||
ChatPreferences restored = new ChatPreferences(file);
|
||||
assertTrue(restored.readPreferencesFromXmlFile());
|
||||
|
||||
assertFalse(restored.isTrxSynch_so2rEnabled());
|
||||
assertEquals("", restored.getLogsynch_wintestNetworkStationNameOfWintestClient2());
|
||||
assertFalse(restored.isStn_pstRotator2Enabled());
|
||||
assertEquals("127.0.0.1", restored.getStn_pstRotator2Host());
|
||||
assertEquals(12010, restored.getStn_pstRotator2Port());
|
||||
assertTrue(Double.isNaN(restored.getActualQTF2().get()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidRotator2ValuesFallBackToTheDefaults() {
|
||||
ChatPreferences prefs = new ChatPreferences(directory.resolve("p.xml"));
|
||||
|
||||
prefs.setStn_pstRotator2Host(" ");
|
||||
prefs.setStn_pstRotator2Port(70000);
|
||||
|
||||
assertEquals("127.0.0.1", prefs.getStn_pstRotator2Host());
|
||||
assertEquals(12010, prefs.getStn_pstRotator2Port());
|
||||
}
|
||||
}
|
||||
@@ -278,6 +278,7 @@ section records only the durable architecture and operational boundaries.
|
||||
|
||||
## Important Decisions and Workarounds
|
||||
|
||||
- SO2R (issue #46): `RadioSlot` FIRST/SECOND; `RadioSlotRouter` assigns RadioInfo by `RadioNr` and Win-Test STATUS by station name, everything to FIRST while `trxSynch_so2rEnabled` is off. Radio 2 writes SECONDQRG and `actualQTF2`. Rotator logic lives in `RotatorControl`, one per radio; the compatibility retry is unchanged. MO2R and multi-PC are separate projects and need their own design.
|
||||
- **Settings take effect with Apply, not while they are edited** (release 1.50,
|
||||
`docs/superpowers/specs/2026-10-16-settings-apply-drafts-design.md`): the settings window
|
||||
stages every edit in a `SettingsDraft` that the tab states read through; Apply/Close, Save
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Entwurf — SO2R: zwei Funkgeräte, eine Instanz (#46)
|
||||
|
||||
Freigegeben vom Maintainer am 2026-10-16 (Gespräch). Erster Schritt zu #46; MO2R und
|
||||
Multi-Operator über mehrere Rechner sind eigene Vorhaben mit eigenem Entwurf.
|
||||
|
||||
## Warum
|
||||
|
||||
KST4Contest kennt heute genau ein Funkgerät:
|
||||
|
||||
- Jede RadioInfo-Meldung aus dem Logprogramm (N1MM, DXLog, UCXLog) überschreibt MYQRG. Das
|
||||
Feld `RadioNr` wird nicht gelesen. Wer mit zwei Geräten arbeitet, sieht MYQRG bei jedem
|
||||
Gerätewechsel springen, und die Makros tragen die QRG des zuletzt gemeldeten Geräts.
|
||||
- Win-Test kennt einen einzigen Stationsnamen-Filter, also ebenfalls ein Gerät.
|
||||
- Es gibt einen PSTRotator-Client, also nur „Antenne 1 drehen".
|
||||
|
||||
Ein SO2R-Platz (ein Operator, ein Rechner, eine ON4KST-Anmeldung, zwei Geräte mit je eigener
|
||||
Antenne) braucht pro Gerät eine eigene QRG und einen eigenen Rotor.
|
||||
|
||||
## Umfang
|
||||
|
||||
Drin:
|
||||
|
||||
- Pro Gerät eine eigene QRG: Gerät 1 schreibt MYQRG, Gerät 2 schreibt SECONDQRG.
|
||||
- Zuordnung über `RadioNr` (N1MM, DXLog, UCXLog) und über je einen Win-Test-Stationsnamen.
|
||||
- Ein zweiter PSTRotator mit Button „Antenne 2 drehen" und eigenem QTF-Feld.
|
||||
- Ein Schalter „SO2R", standardmäßig aus. Aus heißt: alles wie heute.
|
||||
|
||||
Bewusst nicht drin:
|
||||
|
||||
- Ein eigenes Rechenband pro Gerät (Pfadanalyse, Tropo-Spalte, Karte bleiben wie heute).
|
||||
- Ein „aktiver Platz", auf den sich Gearbeitet, Skeds und Makros beziehen.
|
||||
- Strahlrichtung beider Antennen auf der Karte.
|
||||
- Mehr als zwei Geräte.
|
||||
- MO2R und Multi-Operator. Das Modell schließt sie nicht aus (siehe unten), wird aber nicht
|
||||
dafür ausgebaut.
|
||||
|
||||
## Modell
|
||||
|
||||
### `RadioSlot` (core)
|
||||
|
||||
Genau zwei Instanzen, Platz 1 und Platz 2. Ein Platz bündelt:
|
||||
|
||||
| | Platz 1 | Platz 2 |
|
||||
|---|---|---|
|
||||
| QRG-Ziel | `getMYQRGFirstCat()` (MYQRG) | `getMYQRGSecondCat()` (SECONDQRG) |
|
||||
| Rotor | `RotatorControl` über `stn_pstRotator*` | `RotatorControl` über `stn_pstRotator2*` |
|
||||
| gemeldete Richtung | `getActualQTF()` | neu `getActualQTF2()` |
|
||||
| Win-Test-Stationsname | vorhandener Filter `logsynch_wintestNetworkStationNameOfWintestClient1` | neu `logsynch_wintestNetworkStationNameOfWintestClient2` |
|
||||
|
||||
Platz 2 existiert nur bei eingeschaltetem SO2R. Ohne SO2R gibt es keinen Rotor 2 und kein
|
||||
QTF2.
|
||||
|
||||
`RadioSlot` ist die Einheit, an die ein späteres MO2R einen Operator oder eine Arbeitsfläche
|
||||
hängen kann. Für SO2R wird nichts davon gebaut.
|
||||
|
||||
### `RotatorControl` (core, herausgezogen)
|
||||
|
||||
Drehbefehl, Wiederholung und die Toleranz „erreicht" (1°, `ROTATOR_REACHED_TOLERANCE_DEGREES`)
|
||||
liegen heute im `ChatController`. Sie ziehen unverändert in eine eigene Klasse, die einmal pro
|
||||
Platz instanziert wird: eigener `PstRotatorClient`, eigener Wiederholungs-Scheduler, eigene
|
||||
zuletzt gemeldete Richtung. Das Verhalten bleibt bitgenau gleich, einschließlich der
|
||||
vorhandenen Kompatibilitäts-Wiederholung; der `ChatController` delegiert nur noch.
|
||||
|
||||
PSTRotator antwortet auf Port+1. Eine zweite PSTRotator-Instanz braucht also eigene Ports; die
|
||||
Vorgabe für Rotor 2 ist 12010 (Antwort auf 12011).
|
||||
|
||||
### `RadioSlotRouter` (core, rein)
|
||||
|
||||
Entscheidet, zu welchem Platz eine Meldung gehört. Rein und ohne Abhängigkeiten, damit die
|
||||
Regel ohne Netz testbar ist.
|
||||
|
||||
RadioInfo:
|
||||
|
||||
| SO2R | `RadioNr` | Platz |
|
||||
|---|---|---|
|
||||
| aus | beliebig, auch fehlend | 1 |
|
||||
| an | 1 | 1 |
|
||||
| an | 2 | 2 |
|
||||
| an | fehlend | 1 |
|
||||
| an | anderer Wert oder nicht lesbar | verworfen, geloggt |
|
||||
|
||||
Win-Test STATUS:
|
||||
|
||||
| SO2R | Stationsname | Platz |
|
||||
|---|---|---|
|
||||
| aus | wie heute: Filter leer oder passt | 1, sonst verworfen |
|
||||
| an | passt auf Name von Platz 2 | 2 |
|
||||
| an | Name von Platz 1 leer oder passt | 1 |
|
||||
| an | sonst | verworfen |
|
||||
|
||||
Namensvergleich wie heute beim Filter (`equalsIgnoreCase`). Die Filterung der ADDQSO-Pakete nach Stationsname bleibt unberührt.
|
||||
|
||||
## Datenfluss und Fäden
|
||||
|
||||
- `ReadUDPbyUCXMessageThread` liest zusätzlich `RadioNr`, fragt den Router und schreibt die QRG
|
||||
über `uiDispatcher.runOnUi` in das QRG-Ziel des Platzes. Der Weg für eine RadioInfo ohne
|
||||
Freq oder Mode bleibt unverändert (Paket verwerfen, weiter empfangen).
|
||||
- `ReadUDPByWintestThread` fragt den Router mit dem Stationsnamen; Passfrequenz-Option und
|
||||
Formatierung bleiben, nur das Ziel hängt vom Platz ab.
|
||||
- Rotor-Rückmeldungen laufen im Empfangsfaden des jeweiligen Rotors und schreiben die Richtung
|
||||
des Platzes, wie heute bei Rotor 1. Nichts wartet auf dem AWT-Ereignisfaden.
|
||||
|
||||
## Einstellungen
|
||||
|
||||
### Reiter TRX-Abgleich
|
||||
|
||||
- Neuer Schalter „SO2R: zwei Geräte getrennt führen" (`trxSynch_so2rEnabled`, Vorgabe aus).
|
||||
- Bei SO2R an wird der vorhandene Win-Test-Stationsnamen-Filter zum Namen für Platz 1; ein neues
|
||||
Feld nimmt den Namen für Platz 2 auf. Bei SO2R aus ist das neue Feld ausgegraut.
|
||||
- Hinweistext: N1MM, DXLog und UCXLog werden über `RadioNr` zugeordnet, dafür ist nichts
|
||||
einzustellen.
|
||||
|
||||
### Reiter Station, Abschnitt PSTRotator
|
||||
|
||||
- Zweiter Block „PSTRotator 2 (SO2R)": Schalter, Host, Port (Vorgaben aus, 127.0.0.1, 12010).
|
||||
- Nur aktiv bei SO2R an. Der Hinweis „antwortet auf Port+1" gilt auch hier.
|
||||
- Gleicher Port wie Rotor 1 wird beim Übernehmen abgelehnt, mit Meldung, wie andere ungültige
|
||||
Eingaben.
|
||||
|
||||
### Übernehmen
|
||||
|
||||
Wie seit dem Entwurf vom 2026-10-16 (Einstellungen mit „Übernehmen"): alles wirkt erst mit
|
||||
Übernehmen/Schließen oder Speichern.
|
||||
|
||||
- Ändern sich SO2R oder ein Wert von Rotor 2, wird Rotor 2 einmal neu gestartet oder gestoppt —
|
||||
ein eigener Effekt-Schlüssel neben dem von Rotor 1.
|
||||
- SO2R aus stoppt Rotor 2 und leert QTF2.
|
||||
|
||||
### preferences.xml
|
||||
|
||||
Neue Elemente: `trxSynch_so2rEnabled`, `logsynch_wintestNetworkStationNameOfWintestClient2`,
|
||||
`stn_pstRotator2Enabled`, `stn_pstRotator2Host`, `stn_pstRotator2Port`. Fehlen sie in einer
|
||||
älteren Datei, gelten die Vorgaben; keine Migration. Alles gilt pro Profil.
|
||||
|
||||
## Oberfläche
|
||||
|
||||
### Stationspanel
|
||||
|
||||
- Neben „Antenne 1 Richtung {0} drehen" steht „Antenne 2 Richtung {0} drehen"
|
||||
(`selected.turnAntenna2`, de/en).
|
||||
- Sichtbar nur bei SO2R an und aktiviertem Rotor 2. Ausgegraut nach derselben Regel wie
|
||||
`canTurnAntenna` bei Rotor 1 (Rotor nicht verbunden, Station ohne Locator).
|
||||
- Überall, wo heute „Antenne 1 drehen" angeboten wird, kommt „Antenne 2 drehen" dazu.
|
||||
|
||||
### Sendezeile
|
||||
|
||||
- Reihenfolge MYQRG | SECONDQRG | QTF | QTF2. QTF2 zeigt die Richtung von Rotor 2, nur zur
|
||||
Anzeige, und ist nur bei SO2R sichtbar.
|
||||
- Bei SO2R an lautet der Tooltip von SECONDQRG „QRG Gerät 2 (aus RadioNr 2 bzw.
|
||||
Win-Test-Station 2)".
|
||||
- Bei SO2R aus sieht die Zeile aus wie heute.
|
||||
|
||||
### Statusleiste
|
||||
|
||||
Rotor 2 meldet seinen Zustand wie Rotor 1 über `ThreadStatusCallback`, aber unter eigenem Thread-Namen, und bekommt so einen eigenen Knopf in der Statusleiste. Ein
|
||||
Ausfall von Rotor 2 berührt Rotor 1 nicht.
|
||||
|
||||
### Unverändert
|
||||
|
||||
QTF-Filter der Stationsliste (Rotor 1), Karte, Pfadanalyse, Gearbeitet-Logik. Die Makros
|
||||
MYQRG und SECONDQRG tragen bei SO2R die Frequenzen der beiden Geräte.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- Changelog-Eintrag de/en.
|
||||
- Handbuch: kurzer Abschnitt „SO2R" — N1MM/DXLog/UCXLog brauchen nichts, Win-Test braucht zwei
|
||||
Stationsnamen, die zweite PSTRotator-Instanz braucht einen anderen Port.
|
||||
|
||||
## Tests
|
||||
|
||||
Core:
|
||||
|
||||
- `RadioSlotRouterTest`: alle Zeilen der beiden Tabellen oben.
|
||||
- `RotatorControlTest`: Toleranz „erreicht", keine Wiederholung bei Bewegung, die vorhandene
|
||||
Wiederholung bei Stillstand — mit Fake-Client und Fake-Scheduler. Die bisherigen Rotor-Tests
|
||||
bleiben unverändert grün; sie sichern das Herausziehen ab.
|
||||
- Empfänger: ein echtes RadioInfo-Paket mit `RadioNr` 2 bzw. ein Win-Test-STATUS mit
|
||||
Stationsname 2 setzt SECONDQRG, MYQRG bleibt unverändert. Bei SO2R aus setzt dasselbe Paket
|
||||
MYQRG.
|
||||
- `ChatPreferences`: Roundtrip der fünf neuen Elemente; eine alte Datei ohne sie ergibt die
|
||||
Vorgaben (SO2R aus, Port 12010).
|
||||
|
||||
App:
|
||||
|
||||
- Einstellungen (`DraftedSettingsTableTest`-Gerüst): Übernehmen startet bzw. stoppt Rotor 2
|
||||
genau einmal; gleicher Port wie Rotor 1 wird abgelehnt; SO2R aus stoppt Rotor 2 und leert
|
||||
QTF2.
|
||||
- Oberfläche (Compose-UI-Tests wie beim Stationspanel): Button Antenne 2 und QTF2 nur bei SO2R
|
||||
sichtbar; Button ohne Rotor 2 ausgegraut; ohne SO2R ist die Sendezeile wie heute.
|
||||
- i18n: neue Schlüssel de/en, vom Erzeuger beim Bau geprüft.
|
||||
|
||||
Live, durch den Maintainer:
|
||||
|
||||
- N1MM oder DXLog mit zwei Geräten: Gerätewechsel bewegt MYQRG und SECONDQRG getrennt.
|
||||
- Zwei PSTRotator-Instanzen auf verschiedenen Ports: beide Buttons drehen die richtige Antenne,
|
||||
QTF und QTF2 folgen.
|
||||
- Win-Test mit zwei Stationen, falls verfügbar.
|
||||
@@ -30,6 +30,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
|
||||
|
||||
- **Eigene Standorthöhe und höchster Punkt des Gegenstationsfelds in der Streckenanalyse:** Der Reiter **Station** hat im Abschnitt der Streckenanalyse zwei neue Einstellungen. **Eigene Standorthöhe über NN, Meter** ersetzt den Wert des Höhenmodells am eigenen Locatormittelpunkt durch die Höhe des tatsächlichen Standorts; bleibt das Feld leer, entscheidet wie bisher das Höhenmodell. **Gegenstation auf dem höchsten Punkt ihres Locatorfelds**, standardmäßig eingeschaltet, lässt die Strecke am höchsten von 25 abgetasteten Punkten des Locatorfelds der Gegenstation enden statt in dessen Mitte, weil Conteststationen meist vom höchsten Punkt ihres Feldes aus arbeiten. Die Suche kostet pro Gegenstationsfeld und Sitzung eine zusätzliche Anfrage an den Höhendienst; schlägt sie fehl, rechnet die Analyse wie bisher mit der Feldmitte.
|
||||
|
||||
- **SO2R mit zwei Funkgeräten:** Die neue Option **SO2R** im Reiter **TRX-Abgleich** (standardmäßig aus) führt zwei Geräte getrennt. N1MM, DXLog und UCXLog werden über `RadioNr` zugeordnet: Gerät 1 setzt MYQRG, Gerät 2 setzt SECONDQRG. Für Win-Test wird je Gerät ein Stationsname eingetragen. Ein zweiter PSTRotator (eigener Host und Port, im Reiter **Station**) bringt **Antenne 2 drehen** neben **Antenne 1 drehen** und ein Feld QTF2 neben QTF. Ohne SO2R bleibt alles wie bisher. Das setzt den ersten Teil von [Issue #46](https://github.com/praktimarc/kst4contest/issues/46) um.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Zwischenspeicher der Geländeprofile getrennt:** Berechnete Geländeprofile liegen jetzt in der eigenen, gemeinsam genutzten Datei `terrainprofilecache.db` und werden nach Besitzer getrennt gespeichert. Bisher wurde der gesamte Zwischenspeicher gelöscht, sobald sich Rufzeichen oder Locator änderten; bei einem Profilwechsel wäre damit jedes berechnete Profil verloren gegangen.
|
||||
|
||||
@@ -184,7 +184,7 @@ Weitere Informationen zu den Textvariablen: [Makros und Variablen](de-Makros-und
|
||||
|
||||
### Mehrere Logger oder Funkgeräte
|
||||
|
||||
Alle aktivierten QRG-Quellen schreiben in denselben Wert `MYQRG`. KST4Contest ordnet eingehende `RadioInfo`- oder `STATUS`-Pakete derzeit weder einem bestimmten Funkgerät noch einer Chat-Kategorie zu.
|
||||
Ohne SO2R schreiben alle aktivierten QRG-Quellen in denselben Wert `MYQRG`. KST4Contest ordnet eingehende `RadioInfo`- oder `STATUS`-Pakete weder einem bestimmten Funkgerät noch einer Chat-Kategorie zu.
|
||||
|
||||
Sind der allgemeine RadioInfo-Listener und die Win-Test-Synchronisation gleichzeitig aktiviert, bestimmt deshalb das zuletzt verarbeitete Paket die angezeigte QRG. Dasselbe gilt, wenn mehrere Logger ihre Frequenzpakete an dieselbe KST4Contest-Instanz senden.
|
||||
|
||||
@@ -197,6 +197,16 @@ Für ein Setup mit mehreren Funkgeräten gilt daher:
|
||||
|
||||
Anders ausgedrückt: Mehrere Worked-Quellen lassen sich sinnvoll zusammenführen. Mehrere gleichzeitig sendende Frequenzquellen erzeugen dagegen keine zusätzliche Information, sondern lediglich einen Wettbewerb darum, welches Paket zuletzt angekommen ist.
|
||||
|
||||
### SO2R: zwei Funkgeräte getrennt führen
|
||||
|
||||
Mit der Option **SO2R: zwei Geräte getrennt führen (Gerät 2 schreibt SECONDQRG)** ordnet KST4Contest die Frequenzpakete dem jeweiligen Funkgerät zu. Die Option ist standardmäßig aus; ohne SO2R verhält sich alles wie in den Abschnitten oben beschrieben.
|
||||
|
||||
- **N1MM+, DXLog.net, UCXLog:** Die Zuordnung erfolgt über das Feld `RadioNr` im `RadioInfo`-Paket. Dafür ist nichts einzustellen. Gerät 1 setzt `MYQRG`, Gerät 2 setzt `SECONDQRG`.
|
||||
- **Win-Test:** Hier gibt es kein `RadioNr`, deshalb wird je Gerät ein Stationsname eingetragen, zum Beispiel `STN1` für Gerät 1 im bisherigen Stationsfilter und `STN2` im Feld **Win-Test-Stationsname von Gerät 2**. Das Feld ist ohne SO2R ausgegraut.
|
||||
- **Zweiter PSTRotator:** Nach **Apply** erscheint im Reiter **Station** der Abschnitt **PSTRotator 2 (SO2R)** mit Schalter, Host und Port. Die zweite Instanz braucht einen eigenen Port, zum Beispiel `12010`; sie antwortet dann auf `12011`. Derselbe Port wie bei PSTRotator 1 oder ein direkt benachbarter wird beim Übernehmen abgelehnt. Der zweite Rotor startet mit der ON4KST-Sitzung.
|
||||
- **Bedienung:** Im Bereich der gewählten Station steht neben **Antenne 1 drehen** nun **Antenne 2 drehen …**, und in der Sendezeile zeigt ein reines Anzeigefeld QTF2 die Richtung der zweiten Antenne. Der Tooltip von `SECONDQRG` nennt Gerät 2.
|
||||
- **QTF-Filter:** Die QTF-Filter der Stationsliste folgen weiterhin Rotor 1.
|
||||
|
||||
Nach Abschluss der Konfiguration **Save Settings** verwenden.
|
||||
|
||||
---
|
||||
@@ -715,7 +725,7 @@ Eine mögliche Antwort lautet:
|
||||
[KST4C Automsg] QRG is: 144.300.00
|
||||
```
|
||||
|
||||
Die Werte stammen aus denselben QRG-Feldern, die auch von `MYQRG` und `SECONDQRG` verwendet werden. Die Haupt-QRG kann manuell eingetragen oder durch die [TRX-Synchronisation](#trx-sync-einstellungen) aktualisiert werden. Für die zweite Kategorie wird der dort konfigurierte beziehungsweise manuell eingetragene Wert verwendet.
|
||||
Die Werte stammen aus denselben QRG-Feldern, die auch von `MYQRG` und `SECONDQRG` verwendet werden. Die Haupt-QRG kann manuell eingetragen oder durch die [TRX-Synchronisation](#trx-sync-einstellungen) aktualisiert werden. Für die zweite Kategorie wird der dort konfigurierte beziehungsweise manuell eingetragene Wert verwendet; mit SO2R schreibt Funkgerät 2 dieses Feld (`SECONDQRG`).
|
||||
|
||||
Ist für die betreffende Kategorie keine QRG vorhanden, sendet KST4Contest keine unvollständige Antwort. Eine Nachricht wie `QRG is:` ohne Frequenz würde zwar auf die Frage reagieren, dem Anfragenden aber keine Information liefern. Sie wird deshalb bereits vor der Übergabe an die Sendequeue verworfen.
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ Several operators sharing one computer can now use their own callsigns, locators
|
||||
|
||||
- **Own site height and highest point of the remote square in the path analysis:** the **Station** tab has two new settings in its path-analysis section. **Own ground height ASL, metres** replaces the elevation-model value at the own locator centre by the height of the actual site; left empty, the elevation model decides as before. **Target station on the highest point of its locator square**, on by default, ends the path at the highest of 25 sampled points of the remote locator square instead of at its centre, because contest stations usually operate from the high ground of their square. The lookup costs one additional request to the elevation service per remote square and session; if it fails, the analysis uses the square centre as before.
|
||||
|
||||
- **SO2R with two radios:** the new option **SO2R** on the **TRX sync** tab (off by default) keeps two radios apart. N1MM, DXLog and UCXLog are assigned by `RadioNr`: radio 1 updates MYQRG, radio 2 updates SECONDQRG. For Win-Test, enter one station name per radio. A second PSTRotator (own host and port, on the **Station** tab) adds **Turn ant2** next to **Turn ant1** and a QTF2 field next to QTF. Without SO2R everything stays as before. This implements the first part of [Issue #46](https://github.com/praktimarc/kst4contest/issues/46).
|
||||
|
||||
### Changed
|
||||
|
||||
- **The terrain profile cache is separate:** computed terrain profiles now live in their own shared file `terrainprofilecache.db` and are stored per owner. Previously the whole cache was dropped whenever the callsign or locator changed, which would have discarded every computed profile on each profile switch.
|
||||
|
||||
@@ -238,7 +238,7 @@ Further information about text variables: [Macros and Variables](en-Macros-and-V
|
||||
|
||||
### Multiple Loggers or Radios
|
||||
|
||||
Every enabled QRG source writes to the same `MYQRG` value. KST4Contest does not currently assign incoming `RadioInfo` or `STATUS` packets to a particular radio or chat category.
|
||||
Without SO2R, every enabled QRG source writes to the same `MYQRG` value. KST4Contest does not assign incoming `RadioInfo` or `STATUS` packets to a particular radio or chat category.
|
||||
|
||||
If the general RadioInfo listener and Win-Test synchronisation are enabled at the same time, the most recently processed packet therefore determines the displayed QRG. The same applies when several logging applications send frequency packets to one KST4Contest instance.
|
||||
|
||||
@@ -251,6 +251,16 @@ For a setup containing several radios:
|
||||
|
||||
In other words, combining several Worked sources is useful. Combining several simultaneously transmitting frequency sources merely creates a contest over which packet arrived last.
|
||||
|
||||
### SO2R: Keeping Two Radios Apart
|
||||
|
||||
The option **SO2R: keep two radios apart (radio 2 writes SECONDQRG)** makes KST4Contest assign frequency packets to the respective radio. It is off by default; without SO2R everything behaves as described in the sections above.
|
||||
|
||||
- **N1MM+, DXLog.net, UCXLog:** assignment uses the `RadioNr` field of the `RadioInfo` packet. Nothing needs to be configured. Radio 1 updates `MYQRG`, radio 2 updates `SECONDQRG`.
|
||||
- **Win-Test:** there is no `RadioNr` here, so one station name per radio is entered, for example `STN1` for radio 1 in the existing station filter and `STN2` in the field **Win-Test station name of radio 2**. The field is greyed out without SO2R.
|
||||
- **Second PSTRotator:** after **Apply**, the **Station** tab shows the section **PSTRotator 2 (SO2R)** with a switch, host and port. The second instance needs its own port, for example `12010`; it answers on `12011`. The same port as PSTRotator 1, or one next to it, is refused on Apply. The second rotator starts with the ON4KST session.
|
||||
- **Operation:** the selected-station panel gets **Turn ant2 to ...** next to **Turn ant1**, and the send line shows a display-only QTF2 field for the second antenna. The tooltip of `SECONDQRG` names radio 2.
|
||||
- **QTF filters:** the QTF filters of the station list still follow rotator 1.
|
||||
|
||||
Click **Save Settings** after completing the configuration.
|
||||
|
||||
---
|
||||
@@ -715,7 +725,7 @@ A possible reply is:
|
||||
[KST4C Automsg] QRG is: 144.300.00
|
||||
```
|
||||
|
||||
The values come from the same QRG fields used by `MYQRG` and `SECONDQRG`. The primary QRG may be entered manually or updated through [TRX synchronisation](#trx-sync-settings). The second category uses the value configured or entered for that category.
|
||||
The values come from the same QRG fields used by `MYQRG` and `SECONDQRG`. The primary QRG may be entered manually or updated through [TRX synchronisation](#trx-sync-settings). The second category uses the value configured or entered for that category; with SO2R, radio 2 writes this field (`SECONDQRG`).
|
||||
|
||||
If no QRG is available for the incoming category, KST4Contest does not send an incomplete reply. A message containing `QRG is:` without a frequency would technically answer the request while providing no useful information. It is therefore rejected before reaching the transmit queue.
|
||||
|
||||
|
||||
Reference in new issue
Block a user