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>
|
||||
|
||||
Reference in new issue
Block a user