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

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