mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
feat: mutual pass FM/Linear transponder filters (AMSAT Live source)
Mutual match page gains FM/Linear transponder-type filters. The authoritative source is AMSAT's human-maintained Live FM/Linear Satellites pages, fetched during data sync and stored as catnum sets; filters resolve to those sets (falling back to DB mode filter when empty), and unchecking both shows a prompt instead of querying. - DataParser.parseAmSatLivePage: table rows to satellite names, TEVEL2-1 thru TEVEL2-9 range expansion, bracket-alias matching - SettingsRepo: AMSAT FM/Linear catnum sets persisted in prefs - DatabaseRepo: fetch AMSAT pages in updateFromRemote, best-effort (failure keeps previous list, never blocks the update) - getIdsWithModes: AND isAlive = 1 - MutualScreen: FilterChip row inside MatchSearchCard - Tests: parser (4) + viewmodel (3) + repo fakes
This commit is contained in:
1 parent
c4c4dd682d
commit
85a4b9cfa3
17 files changed
+367
-6
No files matched your search
@@ -45,7 +45,7 @@ interface Look4SatDao {
|
||||
@Query("DELETE FROM entries")
|
||||
suspend fun deleteEntries()
|
||||
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND isAlive = 1")
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM radios")
|
||||
|
||||
@@ -115,12 +115,48 @@ class DatabaseRepo(
|
||||
localSource.insertRadios(importedRadios)
|
||||
}
|
||||
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
||||
updateAmSatLiveLists()
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the AMSAT Live FM/Linear satellite lists (transponders currently
|
||||
* on the air). Best effort: any failure keeps the previous lists and never
|
||||
* blocks the rest of the data update, so the mutual-match filter simply
|
||||
* falls back to the last successful snapshot.
|
||||
*/
|
||||
private suspend fun updateAmSatLiveLists() = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val entries = localSource.getEntriesList() // catnum -> name
|
||||
val jobs = Sources.amSatLiveUrls.map { (type, url) ->
|
||||
async { type to remoteSource.getNetworkStream(url) }
|
||||
}
|
||||
val results = jobs.awaitAll()
|
||||
val fmNames = results.firstOrNull { it.first == "FM" }?.second?.stream
|
||||
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
|
||||
val linearNames = results.firstOrNull { it.first == "Linear" }?.second?.stream
|
||||
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
|
||||
val nameToCatnum = entries.associate { it.name.uppercase() to it.catnum }
|
||||
fun resolve(names: List<String>): Set<Int> = names.mapNotNull { name ->
|
||||
val keys = dataParser.normalizeAmSatName(name)
|
||||
nameToCatnum.entries.firstOrNull { (localName, _) ->
|
||||
keys.any { key -> localName.contains(key) }
|
||||
}?.value
|
||||
}.toSet()
|
||||
val fmCatnums = resolve(fmNames)
|
||||
val linearCatnums = resolve(linearNames)
|
||||
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
|
||||
println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}")
|
||||
}.onFailure {
|
||||
// Keep the previous lists; the mutual filter stays on the last good snapshot.
|
||||
println("AMSAT live lists update failed: $it")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||
localSource.deleteEntries()
|
||||
localSource.deleteRadios()
|
||||
settingsRepo.setAmSatCatnums(emptySet(), emptySet())
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ class SatelliteRepo(
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>) = localStorage.getIdsWithModes(modes)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
|
||||
@@ -107,6 +107,8 @@ class SettingsRepo(
|
||||
private val keySatelliteEnabled = "satelliteEnabled"
|
||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||
private val keyAmSatFmCatnums = "amSatFmCatnums"
|
||||
private val keyAmSatLinearCatnums = "amSatLinearCatnums"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
private val legacyCelestrakSatnogsUrl =
|
||||
@@ -745,6 +747,25 @@ class SettingsRepo(
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # AMSAT live-transponder lists
|
||||
override fun getAmSatFmCatnums(): Set<Int> {
|
||||
val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet()
|
||||
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
|
||||
}
|
||||
|
||||
override fun getAmSatLinearCatnums(): Set<Int> {
|
||||
val raw = preferences.getString(keyAmSatLinearCatnums, null) ?: return emptySet()
|
||||
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
|
||||
}
|
||||
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
|
||||
preferences.edit {
|
||||
putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma))
|
||||
putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma))
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
private val keyRadioControlEnabled = "radioControlEnabled"
|
||||
private val keyRadioModel = "radioModel"
|
||||
|
||||
@@ -269,6 +269,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
||||
}
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
+5
@@ -65,4 +65,9 @@ interface ISatelliteRepo {
|
||||
|
||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
|
||||
/** Satellite catnums whose radios carry any of the given downlink modes.
|
||||
* Empty modes -> empty result; used to filter the mutual-match satellite
|
||||
* set to transponder satellites (FM voice / linear). */
|
||||
suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int>
|
||||
}
|
||||
+8
@@ -79,6 +79,14 @@ interface ISettingsRepo {
|
||||
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||
//endregion
|
||||
|
||||
//region # AMSAT live-transponder lists
|
||||
/** Catalog numbers of satellites listed on AMSAT's "Live FM Satellites" page. */
|
||||
fun getAmSatFmCatnums(): Set<Int>
|
||||
/** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */
|
||||
fun getAmSatLinearCatnums(): Set<Int>
|
||||
fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>)
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
|
||||
@@ -55,4 +55,11 @@ object Sources {
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||
)
|
||||
/** AMSAT "Live FM/Linear Satellites" pages: human-maintained lists of
|
||||
* transponders currently on the air. Parsed into the mutual-match filter
|
||||
* (satellites that actually carry a working FM-voice / linear repeater). */
|
||||
val amSatLiveUrls = mapOf(
|
||||
"FM" to "https://www.amsat.org/live-fm-satellites/",
|
||||
"Linear" to "https://www.amsat.org/live-linear-satellites/"
|
||||
)
|
||||
}
|
||||
@@ -58,6 +58,82 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an AMSAT "Live FM/Linear Satellites" page into satellite names.
|
||||
* The page is an HTML table whose first column is the satellite name,
|
||||
* sometimes written as an alias list ("AO-91(RadFxSat / Fox-1B)") or a
|
||||
* range ("TEVEL2-1 thru TEVEL2-9"). Returns the distinct expanded names.
|
||||
*/
|
||||
suspend fun parseAmSatLivePage(stream: InputStream): List<String> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val html = stream.bufferedReader().readText()
|
||||
// Grab table rows, take the first cell of each row, keep rows that
|
||||
// look like satellite names (start with letters/digits, not a header).
|
||||
val rows = Regex("<tr[^>]*>(.*?)</tr>", RegexOption.IGNORE_CASE)
|
||||
.findAll(html)
|
||||
.mapNotNull { match ->
|
||||
val cells = Regex("<t[dh][^>]*>(.*?)</t[dh]>", RegexOption.IGNORE_CASE)
|
||||
.findAll(match.groupValues[1])
|
||||
.map { cell -> stripHtml(cell.groupValues[1]).trim() }
|
||||
.toList()
|
||||
cells.firstOrNull()?.takeIf { it.isNotBlank() && !it.equals("Satellite", ignoreCase = true) }
|
||||
}
|
||||
.toList()
|
||||
rows.flatMap { expandNameRange(it) }.distinct()
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun stripHtml(raw: String): String =
|
||||
raw.replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
/** Expand "NAME1 thru NAME9" ranges (e.g. TEVEL2-1 thru TEVEL2-9). */
|
||||
private fun expandNameRange(name: String): List<String> {
|
||||
val m = Regex("^(.*?)(\\d+)\\s+thru\\s+(.*?)(\\d+)$", RegexOption.IGNORE_CASE).find(name)
|
||||
if (m == null) return listOf(name)
|
||||
val prefix1 = m.groupValues[1].trim()
|
||||
val startNum = m.groupValues[2].toIntOrNull() ?: return listOf(name)
|
||||
val endNum = m.groupValues[4].toIntOrNull() ?: return listOf(name)
|
||||
val prefix2 = m.groupValues[3].trim()
|
||||
if (startNum > endNum) return listOf(name)
|
||||
// Suffix after the end number, if any (e.g. "TEVEL2-1 thru TEVEL2-9 ").
|
||||
val suffix = name.substringAfterLast(m.groupValues[4]).trim()
|
||||
return (startNum..endNum).map { "$prefix1$it$suffix" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a satellite name from the AMSAT live pages into lookup keys
|
||||
* used to match against the local entries table:
|
||||
* - primary key: the leading designator ("AO-91" from "AO-91 (RadFxSat / Fox-1B)")
|
||||
* - alias keys: every bracketed alias, uppercased, digits preserved
|
||||
* The local entry names are normalized the same way in [matchesAmSatName].
|
||||
*/
|
||||
fun normalizeAmSatName(name: String): List<String> {
|
||||
val keys = mutableListOf<String>()
|
||||
// Primary: everything before the first '(' (or '['), then the first token.
|
||||
val primary = name.substringBefore('(').substringBefore('[').trim()
|
||||
primary.split(Regex("\\s+")).firstOrNull()?.takeIf { it.isNotBlank() }?.let {
|
||||
keys += it.uppercase()
|
||||
}
|
||||
// Aliases inside parentheses / brackets: "RadFxSat / Fox-1B" -> two keys.
|
||||
Regex("\\(([^)]*)\\)").findAll(name).forEach { m ->
|
||||
m.groupValues[1].split('/', '|').forEach { alias ->
|
||||
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
|
||||
}
|
||||
}
|
||||
Regex("\\[([^]]*)]").findAll(name).forEach { m ->
|
||||
m.groupValues[1].split('/', '|').forEach { alias ->
|
||||
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
|
||||
}
|
||||
}
|
||||
return keys.distinct()
|
||||
}
|
||||
|
||||
/** True if a local entry name matches any of the AMSAT normalized keys. */
|
||||
fun matchesAmSatName(localName: String, amSatKeys: List<String>): Boolean {
|
||||
val localUpper = localName.uppercase()
|
||||
return amSatKeys.any { key -> localUpper.contains(key) }
|
||||
}
|
||||
|
||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||
val name = values[0]
|
||||
val timestamp = values[2]
|
||||
|
||||
@@ -239,4 +239,51 @@ class DataParserTest {
|
||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT FM page parses satellite names and expands thru ranges`() = runTest(testDispatcher) {
|
||||
val html = """
|
||||
<html><body><table>
|
||||
<tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr>
|
||||
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250</td><td>145.960</td></tr>
|
||||
<tr><td>SO-50(SaudiSat-1C)</td><td>145.850</td><td>436.795</td></tr>
|
||||
<tr><td>TEVEL2-1 thru TEVEL2-9</td><td>145.970</td><td>436.400</td></tr>
|
||||
</table></body></html>
|
||||
""".trimIndent().byteInputStream()
|
||||
val names = dataParser.parseAmSatLivePage(html)
|
||||
assert(names.contains("AO-91(RadFxSat / Fox-1B)"))
|
||||
assert(names.contains("SO-50(SaudiSat-1C)"))
|
||||
assert(names.contains("TEVEL2-1"))
|
||||
assert(names.contains("TEVEL2-9"))
|
||||
assert(names.size == 11) // 2 singles + 9 expanded TEVEL2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT linear page keeps mode-suffixed names`() = runTest(testDispatcher) {
|
||||
val html = """
|
||||
<table>
|
||||
<tr><th>Satellite</th><th>Frequencies</th><th>Comment</th></tr>
|
||||
<tr><td>AO-7 Mode B U/v Inverting Analog</td><td>Uplink LSB</td></tr>
|
||||
<tr><td>FO-29 (JAS-2)V/u Inverting Analog</td><td>Uplink LSB</td></tr>
|
||||
</table>
|
||||
""".trimIndent().byteInputStream()
|
||||
val names = dataParser.parseAmSatLivePage(html)
|
||||
assert(names.contains("AO-7 Mode B U/v Inverting Analog"))
|
||||
assert(names.contains("FO-29 (JAS-2)V/u Inverting Analog"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT name normalization produces primary and alias keys`() {
|
||||
assert(dataParser.normalizeAmSatName("AO-91(RadFxSat / Fox-1B)") == listOf("AO-91", "RADFXSAT", "FOX-1B"))
|
||||
assert(dataParser.normalizeAmSatName("QO-100 (Es'hail-2/P4A) S/x") == listOf("QO-100", "ES'HAIL-2", "P4A"))
|
||||
assert(dataParser.normalizeAmSatName("ISS") == listOf("ISS"))
|
||||
assert(dataParser.matchesAmSatName("AO-91 (RADFXSAT)", listOf("AO-91", "RADFXSAT")))
|
||||
assert(!dataParser.matchesAmSatName("SO-50", listOf("AO-91")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT live page with no table returns empty`() = runTest(testDispatcher) {
|
||||
val html = "<html><body>No data</body></html>".byteInputStream()
|
||||
assert(dataParser.parseAmSatLivePage(html).isEmpty())
|
||||
}
|
||||
}
|
||||
+3
@@ -283,6 +283,9 @@ class LoTWGridSyncTest {
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
|
||||
@@ -308,6 +308,9 @@
|
||||
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
|
||||
<string name="mutual_min_elevation">最小仰角:%d°</string>
|
||||
<string name="mutual_time_range">时间范围</string>
|
||||
<string name="mutual_filter_transponder">转发器</string>
|
||||
<string name="mutual_filter_fm">FM</string>
|
||||
<string name="mutual_filter_linear">线性</string>
|
||||
<string name="mutual_calculating">计算中…</string>
|
||||
<string name="mutual_query">查询匹配</string>
|
||||
<string name="mutual_elevation_you">你:%d°</string>
|
||||
|
||||
@@ -338,6 +338,9 @@
|
||||
<string name="mutual_grid_hint">Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly</string>
|
||||
<string name="mutual_min_elevation">Min elevation: %d°</string>
|
||||
<string name="mutual_time_range">Time range</string>
|
||||
<string name="mutual_filter_transponder">Transponders</string>
|
||||
<string name="mutual_filter_fm">FM</string>
|
||||
<string name="mutual_filter_linear">Linear</string>
|
||||
<string name="mutual_calculating">Calculating…</string>
|
||||
<string name="mutual_query">Query Match</string>
|
||||
<string name="mutual_elevation_you">You: %d°</string>
|
||||
|
||||
@@ -174,6 +174,8 @@ fun MutualScreen(
|
||||
onStationBMinElev = viewModel::onStationBMinElev,
|
||||
onUseCurrentPosition = viewModel::onUseCurrentPosition,
|
||||
onHoursAhead = viewModel::onHoursAhead,
|
||||
onFilterFM = viewModel::onFilterFM,
|
||||
onFilterLinear = viewModel::onFilterLinear,
|
||||
onClearError = viewModel::clearError
|
||||
)
|
||||
}
|
||||
@@ -197,6 +199,8 @@ private fun MutualContent(
|
||||
onStationBMinElev: (Double) -> Unit,
|
||||
onUseCurrentPosition: () -> Unit,
|
||||
onHoursAhead: (Int) -> Unit,
|
||||
onFilterFM: (Boolean) -> Unit,
|
||||
onFilterLinear: (Boolean) -> Unit,
|
||||
onClearError: () -> Unit
|
||||
) {
|
||||
val timeFormat = remember(state.isUtc) {
|
||||
@@ -353,6 +357,10 @@ private fun MutualContent(
|
||||
MatchSearchCard(
|
||||
hoursAhead = state.hoursAhead,
|
||||
isCalculating = state.isCalculating,
|
||||
filterFM = state.filterFM,
|
||||
filterLinear = state.filterLinear,
|
||||
onFilterFM = onFilterFM,
|
||||
onFilterLinear = onFilterLinear,
|
||||
onHoursAhead = onHoursAhead,
|
||||
onQuery = onQuery
|
||||
)
|
||||
@@ -570,6 +578,10 @@ private fun StationInputCard(
|
||||
private fun MatchSearchCard(
|
||||
hoursAhead: Int,
|
||||
isCalculating: Boolean,
|
||||
filterFM: Boolean,
|
||||
filterLinear: Boolean,
|
||||
onFilterFM: (Boolean) -> Unit,
|
||||
onFilterLinear: (Boolean) -> Unit,
|
||||
onHoursAhead: (Int) -> Unit,
|
||||
onQuery: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -596,6 +608,28 @@ private fun MatchSearchCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
// 转发器类型筛选: 只匹配 AMSAT Live 清单里的 FM 话音 / 线性转发器卫星,
|
||||
// 防止选入大量无转发器卫星占用匹配页.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_filter_transponder),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
FilterChip(
|
||||
selected = filterFM,
|
||||
onClick = { onFilterFM(!filterFM) },
|
||||
label = { Text(stringResource(R.string.mutual_filter_fm)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = filterLinear,
|
||||
onClick = { onFilterLinear(!filterLinear) },
|
||||
label = { Text(stringResource(R.string.mutual_filter_linear)) }
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onQuery,
|
||||
enabled = !isCalculating,
|
||||
|
||||
+40
-2
@@ -55,6 +55,10 @@ data class MutualUiState(
|
||||
/** 历史过境回看窗口(小时), 跟随主页面 Passes 设置; 匹配查询从
|
||||
* now-hoursBefore 开始, 使设置过历史回看时匹配页也包含历史过境. */
|
||||
val hoursBefore: Int = 0,
|
||||
/** 转发器类型筛选: 只匹配搭载 FM 话音转发器的卫星. 默认开启. */
|
||||
val filterFM: Boolean = true,
|
||||
/** 转发器类型筛选: 只匹配搭载线性(USB/LSB/CW/SSB)转发器的卫星. 默认开启. */
|
||||
val filterLinear: Boolean = true,
|
||||
val mutualPasses: List<MutualPass> = emptyList(),
|
||||
val isCalculating: Boolean = false,
|
||||
val hasSearched: Boolean = false,
|
||||
@@ -201,6 +205,8 @@ class MutualViewModel(
|
||||
) }
|
||||
}
|
||||
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||
fun onFilterFM(value: Boolean) = _uiState.update { it.copy(filterFM = value) }
|
||||
fun onFilterLinear(value: Boolean) = _uiState.update { it.copy(filterLinear = value) }
|
||||
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
|
||||
|
||||
/**
|
||||
@@ -264,6 +270,17 @@ class MutualViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
// 转发器类型筛选: 至少勾选一种(默认 FM + Linear 都开), 否则提示不查询.
|
||||
// 防止用户选入大量无转发器卫星占用匹配页.
|
||||
if (!state.filterFM && !state.filterLinear) {
|
||||
_uiState.update { it.copy(errorMessage = "Select at least one transponder type (FM / Linear) to run the match.") }
|
||||
return
|
||||
}
|
||||
// AMSAT Live 清单(同步自 AMSAT "Live FM/Linear Satellites" 页面)是本过滤的
|
||||
// 权威来源: 只匹配当前在轨且话音/线性转发器实际工作的卫星. 清单为空
|
||||
// (从未成功同步过)时回退到数据库 downlinkMode 过滤, 避免首启匹配页空结果.
|
||||
// 过滤本身在下面的协程里执行 (getSatelliteIdsWithModes 是 suspend).
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCalculating = true,
|
||||
@@ -286,6 +303,24 @@ class MutualViewModel(
|
||||
|
||||
viewModelScope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
// 转发器类型过滤(协程上下文): AMSAT Live 清单优先, 为空时回退数据库.
|
||||
val amSatFm = settingsRepo.getAmSatFmCatnums()
|
||||
val amSatLinear = settingsRepo.getAmSatLinearCatnums()
|
||||
val filteredSatellites = if (amSatFm.isEmpty() && amSatLinear.isEmpty()) {
|
||||
val filterModes = buildList {
|
||||
if (state.filterFM) add("FM")
|
||||
if (state.filterLinear) addAll(listOf("USB", "LSB", "CW", "SSB"))
|
||||
}
|
||||
val idsWithModes = satelliteRepo.getSatelliteIdsWithModes(filterModes)
|
||||
if (idsWithModes.isEmpty()) satellites
|
||||
else satellites.filter { it.data.catnum in idsWithModes }
|
||||
} else {
|
||||
val allowed = buildSet {
|
||||
if (state.filterFM) addAll(amSatFm)
|
||||
if (state.filterLinear) addAll(amSatLinear)
|
||||
}
|
||||
satellites.filter { it.data.catnum in allowed }
|
||||
}
|
||||
// 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置),
|
||||
// 终点 = now + hoursAhead 不变.
|
||||
val time = now - state.hoursBefore * 60L * 60L * 1000L
|
||||
@@ -295,7 +330,7 @@ class MutualViewModel(
|
||||
|
||||
val results = try {
|
||||
withContext(computeDispatcher) {
|
||||
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
|
||||
findMutualPasses(filteredSatellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
// Never leave the query stuck in "calculating" (which would also
|
||||
@@ -350,7 +385,10 @@ class MutualViewModel(
|
||||
// Use the main page's pass list for AOS/LOS times, then sample the curves
|
||||
// using the actual positions. The passes list is already computed by getLeoPass
|
||||
// and its AOS/LOS times match the Passes page exactly.
|
||||
val existingPasses = satelliteRepo.passes.value
|
||||
// The satellites argument may be a subset (transponder-type filter), so
|
||||
// only passes belonging to the filtered satellites are reused.
|
||||
val allowedCatnums = satellites.map { it.data.catnum }.toSet()
|
||||
val existingPasses = satelliteRepo.passes.value.filter { it.catNum in allowedCatnums }
|
||||
val results = findMutualPassesFromList(existingPasses, satellites, posA, posB,
|
||||
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
|
||||
if (results.isNotEmpty()) return results
|
||||
|
||||
@@ -44,13 +44,18 @@ class FakeSatelliteRepo(
|
||||
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> = TODO()
|
||||
override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> = TODO()
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = TODO()
|
||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test fake for ISettingsRepo. Only [stationPosition] is backed by mutable
|
||||
* state; everything else the ViewModel never touches fails loudly.
|
||||
*/
|
||||
class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISettingsRepo {
|
||||
class FakeSettingsRepo(
|
||||
initialPosition: GeoPos = GeoPos(23.13, 113.26),
|
||||
val amSatFm: Set<Int> = emptySet(),
|
||||
val amSatLinear: Set<Int> = emptySet()
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val stationPosition = MutableStateFlow(initialPosition)
|
||||
|
||||
@@ -103,6 +108,10 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = TODO()
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
|
||||
+67
-2
@@ -31,10 +31,12 @@ class MutualViewModelTest {
|
||||
private fun TestScope.createVm(
|
||||
satellites: List<OrbitalObject> = emptyList(),
|
||||
passes: List<OrbitalPass> = emptyList(),
|
||||
position: GeoPos = TestOrbits.GUANGZHOU
|
||||
position: GeoPos = TestOrbits.GUANGZHOU,
|
||||
amSatFm: Set<Int> = emptySet(),
|
||||
amSatLinear: Set<Int> = emptySet()
|
||||
): MutualViewModel = MutualViewModel(
|
||||
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
||||
settingsRepo = FakeSettingsRepo(position),
|
||||
settingsRepo = FakeSettingsRepo(position, amSatFm, amSatLinear),
|
||||
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
|
||||
)
|
||||
|
||||
@@ -320,4 +322,67 @@ class MutualViewModelTest {
|
||||
assertEquals(4, vm.listScrollIndex)
|
||||
assertEquals(137, vm.listScrollOffset)
|
||||
}
|
||||
|
||||
// --------------------------------------------- transponder filters (AMSAT)
|
||||
|
||||
@Test
|
||||
fun `both filters off sets an error and does not search`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows()
|
||||
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = windows)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onFilterFM(false)
|
||||
vm.onFilterLinear(false)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
assertEquals(
|
||||
"Select at least one transponder type (FM / Linear) to run the match.",
|
||||
vm.uiState.value.errorMessage
|
||||
)
|
||||
assertFalse(vm.uiState.value.hasSearched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
assertTrue("fixture must produce windows", windows.isNotEmpty())
|
||||
// Only ISS (25544) is on the AMSAT live FM list; ISS_VARIANT (25545) is not.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = windows,
|
||||
amSatFm = setOf(25544)
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue(state.mutualPasses.isNotEmpty())
|
||||
assertTrue("only AMSAT-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT filter off for one type excludes its satellites`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
// FM filter off, Linear on: neither sat is linear-listed -> nothing matches.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = windows,
|
||||
amSatFm = setOf(25544),
|
||||
amSatLinear = setOf(25545)
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
vm.onFilterFM(false)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue("only linear-listed satellites should appear", state.mutualPasses.all { it.catNum == 25545 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user