fix: correct AMSAT live filter satellite lists

- Resolve ISS station modules (ZARYA/UNITY/ZVEZDA/DESTINY/NAUKA) to the
  primary 25544 instead of whichever module appears first in the TLE source
- Match OSCAR designators to local names spelled 'OSCAR N'/'AMSAT-OSCAR N'
  (AO-7 -> OSCAR 7) so Linear lists keep AO-7 on SatNOGS-sourced data
- SSTV virtual type now uses the transceiver service class (Amateur only)
  plus launcher-debris name exclusion, dropping retired weather birds
  (TIROS) and rocket bodies (ARIANE 6 R/B) while keeping ISS/CSS/TEIKYOSAT
- Persist the AMSAT active-frequency whitelist (palewire mirror) on sync;
  used only to disambiguate multi-match names, never as a hard filter
This commit is contained in:
atsunatsu committed 2026-09-24 08:49:53 +08:00
1 parent 732412deb2
commit 522cac25e8
20 files changed
+174 -27

No files matched your search

@@ -33,6 +33,10 @@ data class SatRadio(
@SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int?,
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
* Used by the SSTV virtual filter to tell amateur satellites apart from
* debris / weather birds (TIROS) / launcher stages (Ariane 6 R/B). */
@SerialName("service") val service: String? = null,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
)
@@ -75,4 +75,9 @@ interface ISatelliteRepo {
* (real transponders), excluding downlink-only beacons/telemetry that share
* the same mode label. Used by the FM/Linear fallback filter. */
suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int>
/** Like [getSatelliteIdsWithModes] but only matches records whose service
* class is "Amateur", so SSTV never matches weather birds (TIROS),
* launcher debris or other non-amateur transmitters. */
suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int>
}
@@ -85,6 +85,10 @@ interface ISettingsRepo {
/** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */
fun getAmSatLinearCatnums(): Set<Int>
fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>)
/** Catalog numbers of active *amateur* satellites from AMSAT's
* active-transponder table (whitelist for the virtual type filters). */
fun getAmSatActiveCatnums(): Set<Int>
fun setAmSatActiveCatnums(catnums: Set<Int>)
//endregion
//region # Radio control settings
@@ -30,6 +30,7 @@ interface ILocalSource {
suspend fun deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
@@ -63,6 +63,15 @@ object Sources {
"Linear" to "https://www.amsat.org/live-linear-satellites/"
)
/** Machine-readable AMSAT active-transponder table (palewire mirror of
* amsat.org's active-frequency pages): one CSV row per active amateur
* transponder with its NORAD catnum. Used as the authoritative whitelist
* of *amateur* satellites, so the virtual filters never match
* non-amateur debris (Ariane 6 R/B), retired weather sats (TIROS), or
* ISS station-module aliases (ISS (DESTINY) etc.). */
const val amSatActiveUrl =
"https://raw.githubusercontent.com/palewire/amateur-satellite-database/main/data/amsat-active-frequencies.csv"
/** Virtual satellite-selection types: transponder/activity filters shown
* at the top of the type picker. They resolve to live lists (AMSAT pages
* or mode=SSTV radios) instead of persisted per-type IDs, and are
@@ -40,6 +40,21 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}
}
/** Parse AMSAT's active-transponder CSV (palewire mirror) and return the
* set of NORAD catnums of active *amateur* satellites. Header row has a
* norad_id column; some rows carry alphanumeric IDs (Bluebird A0241 etc.)
* which are skipped. */
suspend fun parseAmSatActiveCatnums(stream: InputStream): Set<Int> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines ->
val header = lines.firstOrNull() ?: return@useLines emptySet()
val noradIdx = header.split(",").indexOfFirst { it.trim().equals("norad_id", true) }
if (noradIdx < 0) return@useLines emptySet()
lines.mapNotNull { line ->
line.split(",").getOrNull(noradIdx)?.trim()?.toIntOrNull()
}.toSet()
}
}
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().readLines()
.chunked(3)
@@ -131,14 +146,25 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
/** True if a local entry name matches any of the AMSAT normalized keys.
* Token-based exact match (split on spaces/brackets/slashes), so a key
* like "ISS" does not substring-match "AISSAT-1" — the key must equal a
* whole name token (case-insensitive). */
* whole name token (case-insensitive). OSCAR designators are expanded so
* that "AO-7" also matches local names spelled "OSCAR 7" / "AMSAT-OSCAR 7"
* (the form used by CelesTrak/SatNOGS TLE sources). */
fun matchesAmSatName(localName: String, amSatKeys: List<String>): Boolean {
val localTokens = localName.uppercase()
val localUpper = localName.uppercase()
val localTokens = localUpper
.split(Regex("[\\s()\\[\\]/]+"))
.filter { it.isNotBlank() }
.toSet()
return amSatKeys.any { key ->
key.uppercase() in localTokens
val k = key.uppercase()
if (k in localTokens) return@any true
// "AO-7" -> also match local "OSCAR 7" or "AMSAT-OSCAR 7".
val oscar = Regex("^([A-Z]{1,3})-(\\d+)$").find(k)
if (oscar != null) {
val num = oscar.groupValues[2]
if ("OSCAR $num" in localUpper || "AMSAT-OSCAR $num" in localUpper) return@any true
}
false
}
}
@@ -283,6 +283,11 @@ class DataParserTest {
assert(dataParser.matchesAmSatName("ISS (ZARYA)", listOf("ISS")))
assert(!dataParser.matchesAmSatName("AISSAT-1", listOf("ISS")))
assert(!dataParser.matchesAmSatName("TESS", listOf("ISS")))
// OSCAR designators match local names spelled "OSCAR N"/"AMSAT-OSCAR N".
assert(dataParser.matchesAmSatName("OSCAR 7", listOf("AO-7")))
assert(dataParser.matchesAmSatName("AMSAT-OSCAR 7", listOf("AO-7")))
assert(dataParser.matchesAmSatName("CATSAT", listOf("CATSAT")))
assert(dataParser.matchesAmSatName("RS-44 & BREEZE-KM R/B", listOf("RS-44")))
}
@Test
@@ -286,6 +286,8 @@ class LoTWGridSyncTest {
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
override fun setAmSatActiveCatnums(catnums: Set<Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()