Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ public static Context getMainContext() {
public static String qrzApiKey = ""; //QRZ API key
public static String qrzXmlUsername = ""; //QRZ XML API username (for callsign lookups)
public static String qrzXmlPassword = ""; //QRZ XML API password
public static boolean pskOverlayEnabled = false; //PSK Reporter map overlay (issue #33)
public static boolean pskOverlayEnabled = true; //PSK Reporter map overlay (issue #33)
public static boolean synFrequency = false;//Same-frequency transmit
public static int transmitDelay = 500;//Transmit delay; also allows decoding time for the previous cycle
public static int pttDelay = 100;//PTT response time; radios typically need some response time after PTT command, default 100ms
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ object PskReporterClient {
@Volatile
private var rateLimitedUntilEpochMs: Long = 0L

suspend fun fetchSpotsForMe(call: String, secondsBack: Int): List<PskReporterSpot>? = withContext(Dispatchers.IO) {
suspend fun fetchSpotsForMe(
call: String,
secondsBack: Int,
modeFilter: String? = "FT8",
): List<PskReporterSpot>? = withContext(Dispatchers.IO) {
val now = clock()
if (now < rateLimitedUntilEpochMs) {
log("skipped (rate-limit back-off ${(rateLimitedUntilEpochMs - now) / 1000}s remaining)")
Expand All @@ -84,12 +88,13 @@ object PskReporterClient {
lastFetchEpochMs = now

val callUpper = call.uppercase()
val modeParam = modeFilter?.trim()?.takeIf { it.isNotEmpty() }?.uppercase()
val url = "$baseUrl?senderCallsign=${urlEncode(callUpper)}" +
"&flowStartSeconds=-$secondsBack" +
"&rronly=1" +
"&mode=FT8" +
(modeParam?.let { "&mode=${urlEncode(it)}" } ?: "") +
"&appcontact=${urlEncode(APP_CONTACT)}"
log("fetch start call=$callUpper secondsBack=$secondsBack")
log("fetch start call=$callUpper secondsBack=$secondsBack mode=${modeParam ?: "ALL"}")

val body = fetch(url) ?: return@withContext null
val spots = parseSpots(body)
Expand Down
98 changes: 94 additions & 4 deletions ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/map/MapScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ import com.bg7yoz.ft8cn.Ft8Message
import com.bg7yoz.ft8cn.GeneralVariables
import com.bg7yoz.ft8cn.MainViewModel
import com.bg7yoz.ft8cn.maidenhead.MaidenheadGrid
import com.bg7yoz.ft8cn.rigs.BaseRigOperation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import radio.ks3ckc.ft8us.pskreporter.PskReporterClient
import radio.ks3ckc.ft8us.pskreporter.PskReporterSpot
import radio.ks3ckc.ft8us.theme.*
import radio.ks3ckc.ft8us.ui.components.GlassCard
import radio.ks3ckc.ft8us.ui.components.TopBar
Expand Down Expand Up @@ -97,9 +99,39 @@ private data class PskSpotMarker(
val frequencyHz: Long,
)

private const val PSK_OVERLAY_SECONDS_BACK = 3600
internal enum class PskBandFilter(val label: String) { ALL("Band: ALL"), CURRENT("Band: CUR") }
internal enum class PskModeFilter(val label: String, val apiValue: String?) {
ALL("Mode: ALL", null),
FT8("Mode: FT8", "FT8"),
FT4("Mode: FT4", "FT4"),
}
internal enum class PskTimeFilter(val label: String, val secondsBack: Int) {
M15("Time: 15m", 15 * 60),
H1("Time: 1h", 60 * 60),
H6("Time: 6h", 6 * 60 * 60),
H24("Time: 24h", 24 * 60 * 60),
}
private const val PSK_POLL_INTERVAL_MS = 5L * 60L * 1000L

internal fun filterPskSpots(
spots: List<PskReporterSpot>,
bandFilter: PskBandFilter,
modeFilter: PskModeFilter,
currentBandHz: Long,
maxAgeSeconds: Int,
nowEpochSeconds: Long,
): List<PskReporterSpot> {
val currentBand = BaseRigOperation.getMeterFromFreq(currentBandHz)
return spots.filter { spot ->
val ageSeconds = nowEpochSeconds - spot.flowStartSeconds
val withinTime = ageSeconds in 0..maxAgeSeconds.toLong()
val modeMatches = modeFilter.apiValue == null || spot.mode.equals(modeFilter.apiValue, ignoreCase = true)
val bandMatches = bandFilter != PskBandFilter.CURRENT ||
BaseRigOperation.getMeterFromFreq(spot.frequencyHz) == currentBand
withinTime && modeMatches && bandMatches
}
}

private data class ProjectedPoint(
val x: Float,
val y: Float,
Expand Down Expand Up @@ -179,6 +211,9 @@ fun MapScreen(mainViewModel: MainViewModel) {
var selectedCallsign by remember { mutableStateOf<String?>(null) }
var viewMode by rememberSaveable { mutableStateOf(MapViewMode.STANDARD) }
var pskOverlayEnabled by rememberSaveable { mutableStateOf(GeneralVariables.pskOverlayEnabled) }
var pskBandFilter by rememberSaveable { mutableStateOf(PskBandFilter.ALL) }
var pskModeFilter by rememberSaveable { mutableStateOf(PskModeFilter.FT8) }
var pskTimeFilter by rememberSaveable { mutableStateOf(PskTimeFilter.H1) }
var pskSpots by remember { mutableStateOf<List<PskSpotMarker>>(emptyList()) }

// Zoom + pan (issue #51). Scale is clamped to [1, MAX_ZOOM]; pan is clamped so the
Expand All @@ -197,7 +232,7 @@ fun MapScreen(mainViewModel: MainViewModel) {
// PSK Reporter polling — fires immediately on enter and every 5 min while enabled.
// Re-reads myCallsign each cycle so a mid-session change is picked up on the next tick.
// Structured concurrency cancels the loop when MapScreen leaves composition.
LaunchedEffect(pskOverlayEnabled) {
LaunchedEffect(pskOverlayEnabled, pskBandFilter, pskModeFilter, pskTimeFilter, GeneralVariables.band) {
if (!pskOverlayEnabled) {
pskSpots = emptyList()
return@LaunchedEffect
Expand All @@ -207,9 +242,21 @@ fun MapScreen(mainViewModel: MainViewModel) {
if (call.isEmpty()) {
pskSpots = emptyList()
} else {
val spots = PskReporterClient.fetchSpotsForMe(call, PSK_OVERLAY_SECONDS_BACK)
val spots = PskReporterClient.fetchSpotsForMe(
call = call,
secondsBack = pskTimeFilter.secondsBack,
modeFilter = pskModeFilter.apiValue,
)
if (spots != null) {
pskSpots = spots.map {
val filtered = filterPskSpots(
spots = spots,
bandFilter = pskBandFilter,
modeFilter = pskModeFilter,
currentBandHz = GeneralVariables.band,
maxAgeSeconds = pskTimeFilter.secondsBack,
nowEpochSeconds = System.currentTimeMillis() / 1000L,
)
pskSpots = filtered.map {
PskSpotMarker(
receiverCallsign = it.receiverCallsign,
grid = it.receiverGrid,
Expand Down Expand Up @@ -317,6 +364,49 @@ fun MapScreen(mainViewModel: MainViewModel) {
)
},
)
if (pskOverlayEnabled) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TogglePill(
label = pskBandFilter.label,
active = true,
onClick = {
pskBandFilter = if (pskBandFilter == PskBandFilter.ALL) {
PskBandFilter.CURRENT
} else {
PskBandFilter.ALL
}
},
)
TogglePill(
label = pskModeFilter.label,
active = true,
onClick = {
pskModeFilter = when (pskModeFilter) {
PskModeFilter.ALL -> PskModeFilter.FT8
PskModeFilter.FT8 -> PskModeFilter.FT4
PskModeFilter.FT4 -> PskModeFilter.ALL
}
},
)
TogglePill(
label = pskTimeFilter.label,
active = true,
onClick = {
pskTimeFilter = when (pskTimeFilter) {
PskTimeFilter.M15 -> PskTimeFilter.H1
PskTimeFilter.H1 -> PskTimeFilter.H6
PskTimeFilter.H6 -> PskTimeFilter.H24
PskTimeFilter.H24 -> PskTimeFilter.M15
}
},
)
}
}

// Map canvas — pinch to zoom, drag to pan, double-tap to toggle 2× zoom in/out.
// The STD/AZ mode toggle moved to the TopBar pill since drag now means pan.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ fun SettingsScreen(
var autoUpdateGridFromGPS by remember { mutableStateOf(GeneralVariables.autoUpdateGridFromGPS) }
var enableCloudlog by remember { mutableStateOf(GeneralVariables.enableCloudlog) }
var enableQRZ by remember { mutableStateOf(GeneralVariables.enableQRZ) }
var pskReporterEnabled by remember { mutableStateOf(GeneralVariables.pskOverlayEnabled) }
var saveSWLMessage by remember { mutableStateOf(GeneralVariables.saveSWLMessage) }
var saveSWL_QSO by remember { mutableStateOf(GeneralVariables.saveSWL_QSO) }

Expand Down Expand Up @@ -984,6 +985,19 @@ fun SettingsScreen(
},
)
SectionDivider()
SettingsRow(
label = "PSK Reporter Spots",
description = "Show stations hearing your signal on the map",
toggle = pskReporterEnabled,
onToggleChange = { checked ->
pskReporterEnabled = checked
GeneralVariables.pskOverlayEnabled = checked
mainViewModel.databaseOpr.writeConfig(
"pskOverlayEnabled", if (checked) "1" else "0", null,
)
},
)
SectionDivider()
SettingsRow(
label = "QRZ.com",
description = "Auto-upload QSOs to QRZ Logbook",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,26 @@ class PskReporterClientTest {
assertThat(req.path).contains("senderCallsign=W1AW")
}

@Test
fun fetchSpots_includesModeFilterWhenSpecified() = runBlocking<Unit> {
server.enqueue(MockResponse().setBody(fixture("pskreporter/reception-report.xml")))

PskReporterClient.fetchSpotsForMe("W1AW", 900, modeFilter = "ft4")

val req = server.takeRequest()
assertThat(req.path).contains("mode=FT4")
}

@Test
fun fetchSpots_omitsModeFilterWhenAllModesRequested() = runBlocking<Unit> {
server.enqueue(MockResponse().setBody(fixture("pskreporter/reception-report.xml")))

PskReporterClient.fetchSpotsForMe("W1AW", 900, modeFilter = null)

val req = server.takeRequest()
assertThat(req.path).doesNotContain("mode=")
}

private fun fixture(path: String): String =
checkNotNull(javaClass.classLoader?.getResourceAsStream(path)) {
"missing test resource: $path"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package radio.ks3ckc.ft8us.ui.map

import com.google.common.truth.Truth.assertThat
import org.junit.Test
import radio.ks3ckc.ft8us.pskreporter.PskReporterSpot

class MapScreenPskFilterTest {

@Test
fun filterPskSpots_filtersByCurrentBandModeAndTime() {
val now = 1_700_000_000L
val spots = listOf(
spot(freq = 14_076_000L, mode = "FT8", flow = now - 120),
spot(freq = 7_074_000L, mode = "FT8", flow = now - 120),
spot(freq = 14_076_000L, mode = "FT4", flow = now - 120),
spot(freq = 14_076_000L, mode = "FT8", flow = now - 8_000),
)

val filtered = filterPskSpots(
spots = spots,
bandFilter = PskBandFilter.CURRENT,
modeFilter = PskModeFilter.FT8,
currentBandHz = 14_074_000L,
maxAgeSeconds = 3600,
nowEpochSeconds = now,
)

assertThat(filtered).hasSize(1)
assertThat(filtered.single().frequencyHz).isEqualTo(14_076_000L)
assertThat(filtered.single().mode).isEqualTo("FT8")
}

@Test
fun filterPskSpots_allBandAndAllMode_keepsMultipleBands() {
val now = 1_700_000_000L
val spots = listOf(
spot(freq = 14_076_000L, mode = "FT8", flow = now - 10),
spot(freq = 7_074_000L, mode = "FT4", flow = now - 10),
)

val filtered = filterPskSpots(
spots = spots,
bandFilter = PskBandFilter.ALL,
modeFilter = PskModeFilter.ALL,
currentBandHz = 14_074_000L,
maxAgeSeconds = 3600,
nowEpochSeconds = now,
)

assertThat(filtered).hasSize(2)
}

private fun spot(freq: Long, mode: String, flow: Long): PskReporterSpot = PskReporterSpot(
senderCallsign = "W1AW",
receiverCallsign = "RX",
receiverGrid = "FN42",
receiverLat = 42.0,
receiverLon = -71.0,
frequencyHz = freq,
snr = -10,
mode = mode,
flowStartSeconds = flow,
)
}