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")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
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>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM radios")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
|
|||||||
@@ -115,12 +115,48 @@ class DatabaseRepo(
|
|||||||
localSource.insertRadios(importedRadios)
|
localSource.insertRadios(importedRadios)
|
||||||
}
|
}
|
||||||
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
||||||
|
updateAmSatLiveLists()
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
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) {
|
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||||
localSource.deleteEntries()
|
localSource.deleteEntries()
|
||||||
localSource.deleteRadios()
|
localSource.deleteRadios()
|
||||||
|
settingsRepo.setAmSatCatnums(emptySet(), emptySet())
|
||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ class SatelliteRepo(
|
|||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
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) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
combine(
|
combine(
|
||||||
settingsRepo.selectedIds,
|
settingsRepo.selectedIds,
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ class SettingsRepo(
|
|||||||
private val keySatelliteEnabled = "satelliteEnabled"
|
private val keySatelliteEnabled = "satelliteEnabled"
|
||||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||||
|
private val keyAmSatFmCatnums = "amSatFmCatnums"
|
||||||
|
private val keyAmSatLinearCatnums = "amSatLinearCatnums"
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
private val separatorUrl = "\n"
|
private val separatorUrl = "\n"
|
||||||
private val legacyCelestrakSatnogsUrl =
|
private val legacyCelestrakSatnogsUrl =
|
||||||
@@ -745,6 +747,25 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
//endregion
|
//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
|
//region # Radio control settings
|
||||||
private val keyRadioControlEnabled = "radioControlEnabled"
|
private val keyRadioControlEnabled = "radioControlEnabled"
|
||||||
private val keyRadioModel = "radioModel"
|
private val keyRadioModel = "radioModel"
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
(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 updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
|||||||
+5
@@ -65,4 +65,9 @@ interface ISatelliteRepo {
|
|||||||
|
|
||||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
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>)
|
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||||
//endregion
|
//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
|
//region # Radio control settings
|
||||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
|
|||||||
@@ -55,4 +55,11 @@ object Sources {
|
|||||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
"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())
|
}.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 {
|
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||||
val name = values[0]
|
val name = values[0]
|
||||||
val timestamp = values[2]
|
val timestamp = values[2]
|
||||||
|
|||||||
@@ -239,4 +239,51 @@ class DataParserTest {
|
|||||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 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 updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = 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 updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
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_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
|
||||||
<string name="mutual_min_elevation">最小仰角:%d°</string>
|
<string name="mutual_min_elevation">最小仰角:%d°</string>
|
||||||
<string name="mutual_time_range">时间范围</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_calculating">计算中…</string>
|
||||||
<string name="mutual_query">查询匹配</string>
|
<string name="mutual_query">查询匹配</string>
|
||||||
<string name="mutual_elevation_you">你:%d°</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_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_min_elevation">Min elevation: %d°</string>
|
||||||
<string name="mutual_time_range">Time range</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_calculating">Calculating…</string>
|
||||||
<string name="mutual_query">Query Match</string>
|
<string name="mutual_query">Query Match</string>
|
||||||
<string name="mutual_elevation_you">You: %d°</string>
|
<string name="mutual_elevation_you">You: %d°</string>
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ fun MutualScreen(
|
|||||||
onStationBMinElev = viewModel::onStationBMinElev,
|
onStationBMinElev = viewModel::onStationBMinElev,
|
||||||
onUseCurrentPosition = viewModel::onUseCurrentPosition,
|
onUseCurrentPosition = viewModel::onUseCurrentPosition,
|
||||||
onHoursAhead = viewModel::onHoursAhead,
|
onHoursAhead = viewModel::onHoursAhead,
|
||||||
|
onFilterFM = viewModel::onFilterFM,
|
||||||
|
onFilterLinear = viewModel::onFilterLinear,
|
||||||
onClearError = viewModel::clearError
|
onClearError = viewModel::clearError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -197,6 +199,8 @@ private fun MutualContent(
|
|||||||
onStationBMinElev: (Double) -> Unit,
|
onStationBMinElev: (Double) -> Unit,
|
||||||
onUseCurrentPosition: () -> Unit,
|
onUseCurrentPosition: () -> Unit,
|
||||||
onHoursAhead: (Int) -> Unit,
|
onHoursAhead: (Int) -> Unit,
|
||||||
|
onFilterFM: (Boolean) -> Unit,
|
||||||
|
onFilterLinear: (Boolean) -> Unit,
|
||||||
onClearError: () -> Unit
|
onClearError: () -> Unit
|
||||||
) {
|
) {
|
||||||
val timeFormat = remember(state.isUtc) {
|
val timeFormat = remember(state.isUtc) {
|
||||||
@@ -353,6 +357,10 @@ private fun MutualContent(
|
|||||||
MatchSearchCard(
|
MatchSearchCard(
|
||||||
hoursAhead = state.hoursAhead,
|
hoursAhead = state.hoursAhead,
|
||||||
isCalculating = state.isCalculating,
|
isCalculating = state.isCalculating,
|
||||||
|
filterFM = state.filterFM,
|
||||||
|
filterLinear = state.filterLinear,
|
||||||
|
onFilterFM = onFilterFM,
|
||||||
|
onFilterLinear = onFilterLinear,
|
||||||
onHoursAhead = onHoursAhead,
|
onHoursAhead = onHoursAhead,
|
||||||
onQuery = onQuery
|
onQuery = onQuery
|
||||||
)
|
)
|
||||||
@@ -570,6 +578,10 @@ private fun StationInputCard(
|
|||||||
private fun MatchSearchCard(
|
private fun MatchSearchCard(
|
||||||
hoursAhead: Int,
|
hoursAhead: Int,
|
||||||
isCalculating: Boolean,
|
isCalculating: Boolean,
|
||||||
|
filterFM: Boolean,
|
||||||
|
filterLinear: Boolean,
|
||||||
|
onFilterFM: (Boolean) -> Unit,
|
||||||
|
onFilterLinear: (Boolean) -> Unit,
|
||||||
onHoursAhead: (Int) -> Unit,
|
onHoursAhead: (Int) -> Unit,
|
||||||
onQuery: () -> Unit,
|
onQuery: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
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(
|
Button(
|
||||||
onClick = onQuery,
|
onClick = onQuery,
|
||||||
enabled = !isCalculating,
|
enabled = !isCalculating,
|
||||||
|
|||||||
+40
-2
@@ -55,6 +55,10 @@ data class MutualUiState(
|
|||||||
/** 历史过境回看窗口(小时), 跟随主页面 Passes 设置; 匹配查询从
|
/** 历史过境回看窗口(小时), 跟随主页面 Passes 设置; 匹配查询从
|
||||||
* now-hoursBefore 开始, 使设置过历史回看时匹配页也包含历史过境. */
|
* now-hoursBefore 开始, 使设置过历史回看时匹配页也包含历史过境. */
|
||||||
val hoursBefore: Int = 0,
|
val hoursBefore: Int = 0,
|
||||||
|
/** 转发器类型筛选: 只匹配搭载 FM 话音转发器的卫星. 默认开启. */
|
||||||
|
val filterFM: Boolean = true,
|
||||||
|
/** 转发器类型筛选: 只匹配搭载线性(USB/LSB/CW/SSB)转发器的卫星. 默认开启. */
|
||||||
|
val filterLinear: Boolean = true,
|
||||||
val mutualPasses: List<MutualPass> = emptyList(),
|
val mutualPasses: List<MutualPass> = emptyList(),
|
||||||
val isCalculating: Boolean = false,
|
val isCalculating: Boolean = false,
|
||||||
val hasSearched: Boolean = false,
|
val hasSearched: Boolean = false,
|
||||||
@@ -201,6 +205,8 @@ class MutualViewModel(
|
|||||||
) }
|
) }
|
||||||
}
|
}
|
||||||
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
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) }
|
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -264,6 +270,17 @@ class MutualViewModel(
|
|||||||
return
|
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 {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isCalculating = true,
|
isCalculating = true,
|
||||||
@@ -286,6 +303,24 @@ class MutualViewModel(
|
|||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val now = System.currentTimeMillis()
|
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 - hoursBefore (跟随主页面 Passes 设置),
|
||||||
// 终点 = now + hoursAhead 不变.
|
// 终点 = now + hoursAhead 不变.
|
||||||
val time = now - state.hoursBefore * 60L * 60L * 1000L
|
val time = now - state.hoursBefore * 60L * 60L * 1000L
|
||||||
@@ -295,7 +330,7 @@ class MutualViewModel(
|
|||||||
|
|
||||||
val results = try {
|
val results = try {
|
||||||
withContext(computeDispatcher) {
|
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) {
|
} catch (t: Throwable) {
|
||||||
// Never leave the query stuck in "calculating" (which would also
|
// 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
|
// 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
|
// using the actual positions. The passes list is already computed by getLeoPass
|
||||||
// and its AOS/LOS times match the Passes page exactly.
|
// 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,
|
val results = findMutualPassesFromList(existingPasses, satellites, posA, posB,
|
||||||
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
|
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
|
||||||
if (results.isNotEmpty()) return results
|
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 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 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 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
|
* Test fake for ISettingsRepo. Only [stationPosition] is backed by mutable
|
||||||
* state; everything else the ViewModel never touches fails loudly.
|
* 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)
|
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 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 updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||||
|
|||||||
+67
-2
@@ -31,10 +31,12 @@ class MutualViewModelTest {
|
|||||||
private fun TestScope.createVm(
|
private fun TestScope.createVm(
|
||||||
satellites: List<OrbitalObject> = emptyList(),
|
satellites: List<OrbitalObject> = emptyList(),
|
||||||
passes: List<OrbitalPass> = emptyList(),
|
passes: List<OrbitalPass> = emptyList(),
|
||||||
position: GeoPos = TestOrbits.GUANGZHOU
|
position: GeoPos = TestOrbits.GUANGZHOU,
|
||||||
|
amSatFm: Set<Int> = emptySet(),
|
||||||
|
amSatLinear: Set<Int> = emptySet()
|
||||||
): MutualViewModel = MutualViewModel(
|
): MutualViewModel = MutualViewModel(
|
||||||
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
||||||
settingsRepo = FakeSettingsRepo(position),
|
settingsRepo = FakeSettingsRepo(position, amSatFm, amSatLinear),
|
||||||
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
|
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -320,4 +322,67 @@ class MutualViewModelTest {
|
|||||||
assertEquals(4, vm.listScrollIndex)
|
assertEquals(4, vm.listScrollIndex)
|
||||||
assertEquals(137, vm.listScrollOffset)
|
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