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
|
||||
|
||||
Reference in new issue
Block a user