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

@@ -54,6 +54,13 @@ interface Look4SatDao {
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND uplinkLow IS NOT NULL AND isAlive = 1") @Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND uplinkLow IS NOT NULL AND isAlive = 1")
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
/** Like [getIdsWithModes] but only matches records whose service class is
* "Amateur", so the SSTV filter never matches weather birds (TIROS),
* launcher debris or other non-amateur transmitters that happen to carry
* an SSTV-labelled downlink. */
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND service = 'Amateur' AND isAlive = 1")
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios") @Query("SELECT COUNT(*) FROM radios")
suspend fun getRadiosTotal(): Int suspend fun getRadiosTotal(): Int
@@ -24,7 +24,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.SatEntry import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio import com.rtbishop.look4sat.core.data.database.entity.SatRadio
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false) @Database(entities = [SatEntry::class, SatRadio::class], version = 3, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() { abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao abstract fun look4SatDao(): Look4SatDao
} }
@@ -37,3 +37,11 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
} }
} }
/** Adds the transceiver service class ("Amateur" etc.), used by the SSTV virtual
* filter to tell amateur satellites apart from debris/weather/launcher stages. */
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE radios ADD COLUMN service TEXT")
}
}
@@ -33,6 +33,10 @@ data class SatRadio(
val uplinkMode: String?, val uplinkMode: String?,
val isInverted: Boolean, val isInverted: Boolean,
val catnum: Int?, val catnum: Int?,
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
* Used to tell amateur satellites apart from debris / weather birds
* (TIROS) / launcher stages (Ariane 6 R/B) in the SSTV virtual filter. */
val service: String? = null,
/** Set for manually imported transceivers, which remote updates must not replace. */ /** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false val isCustom: Boolean = false
) )
@@ -25,6 +25,7 @@ import android.location.LocationManager
import androidx.room.Room import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2 import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller import com.rtbishop.look4sat.core.data.framework.Ic705Controller
@@ -169,7 +170,7 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideLocalSource(): ILocalSource { private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400") val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
val database = builder val database = builder
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.fallbackToDestructiveMigration(false) .fallbackToDestructiveMigration(false)
.build() .build()
return LocalSource(database.look4SatDao()) return LocalSource(database.look4SatDao())
@@ -130,23 +130,34 @@ class DatabaseRepo(
val entries = localSource.getEntriesList() // catnum -> name val entries = localSource.getEntriesList() // catnum -> name
val jobs = Sources.amSatLiveUrls.map { (type, url) -> val jobs = Sources.amSatLiveUrls.map { (type, url) ->
async { type to remoteSource.getNetworkStream(url) } async { type to remoteSource.getNetworkStream(url) }
} } + async { "Active" to remoteSource.getNetworkStream(Sources.amSatActiveUrl) }
val results = jobs.awaitAll() val results = jobs.awaitAll()
val fmNames = results.firstOrNull { it.first == "FM" }?.second?.stream val fmNames = results.firstOrNull { it.first == "FM" }?.second?.stream
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty() ?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
val linearNames = results.firstOrNull { it.first == "Linear" }?.second?.stream val linearNames = results.firstOrNull { it.first == "Linear" }?.second?.stream
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty() ?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
val activeCatnums = results.firstOrNull { it.first == "Active" }?.second?.stream
?.let { dataParser.parseAmSatActiveCatnums(it) }.orEmpty()
settingsRepo.setAmSatActiveCatnums(activeCatnums)
val nameToCatnum = entries.associate { it.name.uppercase() to it.catnum } val nameToCatnum = entries.associate { it.name.uppercase() to it.catnum }
fun resolve(names: List<String>): Set<Int> = names.mapNotNull { name -> // Resolve every matching local entry per AMSAT name. A single match
// is kept as-is (so satellites absent from the amateur whitelist,
// e.g. JO-97/TO-108, are never dropped). Only when several local
// entries share the name (ISS station modules ZARYA/UNITY/ZVEZDA/
// DESTINY/NAUKA) is the whitelist used to pick the primary one.
fun resolvePerName(name: String): Set<Int> {
val keys = dataParser.normalizeAmSatName(name) val keys = dataParser.normalizeAmSatName(name)
nameToCatnum.entries.firstOrNull { (localName, _) -> val all = nameToCatnum.filter { (localName, _) ->
dataParser.matchesAmSatName(localName, keys) dataParser.matchesAmSatName(localName, keys)
}?.value }.values.toSet()
}.toSet() if (all.size <= 1) return all
val fmCatnums = resolve(fmNames) val preferred = all.intersect(activeCatnums)
val linearCatnums = resolve(linearNames) return if (preferred.isNotEmpty()) preferred else all
}
val fmCatnums = fmNames.flatMap { resolvePerName(it) }.toSet()
val linearCatnums = linearNames.flatMap { resolvePerName(it) }.toSet()
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums) settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}") println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}, Active=${activeCatnums.size}")
}.onFailure { }.onFailure {
// Keep the previous lists; the mutual filter stays on the last good snapshot. // Keep the previous lists; the mutual filter stays on the last good snapshot.
println("AMSAT live lists update failed: $it") println("AMSAT live lists update failed: $it")
@@ -69,6 +69,8 @@ class SatelliteRepo(
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>) = localStorage.getIdsWithModesAndUplink(modes) override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>) = localStorage.getIdsWithModesAndUplink(modes)
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>) = localStorage.getIdsWithModesAndAmateur(modes)
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
combine( combine(
settingsRepo.selectedIds, settingsRepo.selectedIds,
@@ -74,16 +74,19 @@ class SelectionRepo(
when (type) { when (type) {
Sources.virtualTypeNames[0] -> idsSet.addAll(settingsRepo.getAmSatFmCatnums()) Sources.virtualTypeNames[0] -> idsSet.addAll(settingsRepo.getAmSatFmCatnums())
Sources.virtualTypeNames[1] -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums()) Sources.virtualTypeNames[1] -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums())
// Live SSTV: mode=SSTV transponder records intersected with the // Live SSTV: mode=SSTV transponder records whose service class
// currently-tracked (in-orbit, from the selected TLE sources) // is "Amateur", excluding launcher debris / rocket bodies
// satellites. The raw mode query alone would include retired // (names ending in "R/B" or "DEB" — e.g. Ariane 6 R/B) that
// weather/experimental birds (e.g. TIROS catnum 1430 whose old // carry an amateur payload transponder but are not satellites.
// TV downlink is labelled SSTV but is long gone from orbit).
Sources.virtualTypeNames[2] -> { Sources.virtualTypeNames[2] -> {
val inOrbitIds = currentItems.value.map { it.catnum }.toHashSet() val sstvIds = localSource.getIdsWithModesAndAmateur(listOf("SSTV")).toSet()
idsSet.addAll( val inOrbitNames = currentItems.value.associate { it.catnum to it.name }
localSource.getIdsWithModes(listOf("SSTV")).filter { it in inOrbitIds } val filtered = sstvIds.filter { catnum ->
) val name = inOrbitNames[catnum]?.uppercase().orEmpty()
!name.endsWith(" R/B") && !name.endsWith(" DEB") &&
!name.endsWith("R/B") && !name.endsWith("DEB")
}
idsSet.addAll(filtered)
} }
else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type))) else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type)))
} }
@@ -109,6 +109,7 @@ class SettingsRepo(
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration" private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
private val keyAmSatFmCatnums = "amSatFmCatnums" private val keyAmSatFmCatnums = "amSatFmCatnums"
private val keyAmSatLinearCatnums = "amSatLinearCatnums" private val keyAmSatLinearCatnums = "amSatLinearCatnums"
private val keyAmSatActiveCatnums = "amSatActiveCatnums"
private val separatorComma = "," private val separatorComma = ","
private val separatorUrl = "\n" private val separatorUrl = "\n"
private val legacyCelestrakSatnogsUrl = private val legacyCelestrakSatnogsUrl =
@@ -764,6 +765,17 @@ class SettingsRepo(
putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma)) putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma))
} }
} }
override fun getAmSatActiveCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatActiveCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun setAmSatActiveCatnums(catnums: Set<Int>) {
preferences.edit {
putString(keyAmSatActiveCatnums, catnums.sorted().joinToString(separatorComma))
}
}
//endregion //endregion
//region # Radio control settings //region # Radio control settings
@@ -49,6 +49,7 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes) override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
override suspend fun getIdsWithModesAndUplink(modes: List<String>) = look4SatDao.getIdsWithModesAndUplink(modes) override suspend fun getIdsWithModesAndUplink(modes: List<String>) = look4SatDao.getIdsWithModesAndUplink(modes)
override suspend fun getIdsWithModesAndAmateur(modes: List<String>) = look4SatDao.getIdsWithModesAndAmateur(modes)
private fun FrameworkEntry.toDomain() = OrbitalData( private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, this.name, this.epoch, this.meanmo, this.eccn, this.incl,
@@ -84,12 +85,14 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun DomainRadio.toFramework() = FrameworkRadio( private fun DomainRadio.toFramework() = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode, this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
this.service, this.isCustom
) )
private fun FrameworkRadio.toDomain() = DomainRadio( private fun FrameworkRadio.toDomain() = DomainRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode, this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
this.service, this.isCustom
) )
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() } private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
@@ -181,6 +181,7 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList() override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList() override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = insertedRadios.size override suspend fun getRadiosTotal(): Int = insertedRadios.size
@@ -273,6 +274,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun getAmSatFmCatnums(): Set<Int> = emptySet() override fun getAmSatFmCatnums(): Set<Int> = emptySet()
override fun getAmSatLinearCatnums(): Set<Int> = emptySet() override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit 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) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
@@ -134,6 +134,35 @@ class SelectionRepoSearchTest {
assertEquals(listOf(43803), results.map { it.catnum }) assertEquals(listOf(43803), results.map { it.catnum })
} }
@Test
fun `SSTV virtual type uses the amateur-only radio query`() = runTest {
// The repo delegates to getIdsWithModesAndAmateur: the fake returns
// only catnums the DAO would have filtered to Amateur service (43803).
val repo = createRepo(
items = sampleItems,
sstvIds = listOf(43803),
amSatActive = setOf(43803)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(43803), results.map { it.catnum })
}
@Test
fun `SSTV virtual type excludes rocket-body debris by name`() = runTest {
// 60239 is "ARIANE 6 R/B" (launcher debris carrying an amateur SSTV
// payload); it must be dropped even though its radio is Amateur.
val items = sampleItems + SatItem(catnum = 60239, name = "ARIANE 6 R/B")
val repo = createRepo(
items = items,
sstvIds = listOf(43803, 60239),
amSatActive = setOf(43803, 60239)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(43803), results.map { it.catnum })
}
@Test @Test
fun `multiple virtual types union their satellites`() = runTest { fun `multiple virtual types union their satellites`() = runTest {
val repo = createRepo( val repo = createRepo(
@@ -192,12 +221,13 @@ class SelectionRepoSearchTest {
items: List<SatItem>, items: List<SatItem>,
amSatFm: Set<Int> = emptySet(), amSatFm: Set<Int> = emptySet(),
amSatLinear: Set<Int> = emptySet(), amSatLinear: Set<Int> = emptySet(),
sstvIds: List<Int> = emptyList() sstvIds: List<Int> = emptyList(),
amSatActive: Set<Int> = emptySet()
): ISelectionRepo { ): ISelectionRepo {
return SelectionRepo( return SelectionRepo(
dispatcher = Dispatchers.Unconfined, dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(items, sstvIds), localSource = FakeLocalSourceForSearch(items, sstvIds),
settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear) settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear, amSatActive)
) )
} }
} }
@@ -213,6 +243,7 @@ private class FakeLocalSourceForSearch(
override suspend fun deleteEntries() = Unit override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = sstvIds override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = sstvIds
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = sstvIds
override suspend fun getRadiosTotal(): Int = 0 override suspend fun getRadiosTotal(): Int = 0
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList() override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
@@ -222,7 +253,8 @@ private class FakeLocalSourceForSearch(
private class FakeSettingsRepoForSearch( private class FakeSettingsRepoForSearch(
private val amSatFm: Set<Int> = emptySet(), private val amSatFm: Set<Int> = emptySet(),
private val amSatLinear: Set<Int> = emptySet() private val amSatLinear: Set<Int> = emptySet(),
private var amSatActive: Set<Int> = emptySet()
) : ISettingsRepo { ) : ISettingsRepo {
override val appVersionName: String = "test" override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList()) override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
@@ -268,6 +300,8 @@ private class FakeSettingsRepoForSearch(
override fun getAmSatFmCatnums(): Set<Int> = amSatFm override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun getAmSatActiveCatnums(): Set<Int> = amSatActive
override fun setAmSatActiveCatnums(catnums: Set<Int>) { amSatActive = catnums }
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
@@ -33,6 +33,10 @@ data class SatRadio(
@SerialName("uplink_mode") val uplinkMode: String?, @SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean, @SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int?, @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. */ /** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false val isCustom: Boolean = false
) )
@@ -75,4 +75,9 @@ interface ISatelliteRepo {
* (real transponders), excluding downlink-only beacons/telemetry that share * (real transponders), excluding downlink-only beacons/telemetry that share
* the same mode label. Used by the FM/Linear fallback filter. */ * the same mode label. Used by the FM/Linear fallback filter. */
suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int> 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. */ /** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */
fun getAmSatLinearCatnums(): Set<Int> fun getAmSatLinearCatnums(): Set<Int>
fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: 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 //endregion
//region # Radio control settings //region # Radio control settings
@@ -30,6 +30,7 @@ interface ILocalSource {
suspend fun deleteEntries() suspend fun deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int> suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getIdsWithModesAndUplink(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 getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio> suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false) suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
@@ -63,6 +63,15 @@ object Sources {
"Linear" to "https://www.amsat.org/live-linear-satellites/" "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 /** Virtual satellite-selection types: transponder/activity filters shown
* at the top of the type picker. They resolve to live lists (AMSAT pages * 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 * 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) { suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().readLines() stream.bufferedReader().readLines()
.chunked(3) .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. /** 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 * 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 * 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 { fun matchesAmSatName(localName: String, amSatKeys: List<String>): Boolean {
val localTokens = localName.uppercase() val localUpper = localName.uppercase()
val localTokens = localUpper
.split(Regex("[\\s()\\[\\]/]+")) .split(Regex("[\\s()\\[\\]/]+"))
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.toSet() .toSet()
return amSatKeys.any { key -> 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("ISS (ZARYA)", listOf("ISS")))
assert(!dataParser.matchesAmSatName("AISSAT-1", listOf("ISS"))) assert(!dataParser.matchesAmSatName("AISSAT-1", listOf("ISS")))
assert(!dataParser.matchesAmSatName("TESS", 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 @Test
@@ -286,6 +286,8 @@ class LoTWGridSyncTest {
override fun getAmSatFmCatnums(): Set<Int> = emptySet() override fun getAmSatFmCatnums(): Set<Int> = emptySet()
override fun getAmSatLinearCatnums(): Set<Int> = emptySet() override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit 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 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()
@@ -46,6 +46,7 @@ class FakeSatelliteRepo(
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() override suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList() override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
} }
/** /**
@@ -112,6 +113,8 @@ class FakeSettingsRepo(
override fun getAmSatFmCatnums(): Set<Int> = amSatFm override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = TODO() override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = TODO()
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
override fun setAmSatActiveCatnums(catnums: 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 = ""