SO2R: two radios in one instance (#46) (#137)

* 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:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-16 19:14:27 +02:00
1 parent feb4b4e2c9
commit 5b7c774f5f
37 files changed
+1587 -266

No files matched your search

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