mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-26 16:15:51 +01: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
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user