From 522cac25e8f8ba67750894f3a52e156fe9354d46 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Thu, 24 Sep 2026 08:48:38 +0800 Subject: [PATCH] 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 --- .../core/data/database/Look4SatDao.kt | 7 ++++ .../look4sat/core/data/database/Look4SatDb.kt | 10 ++++- .../core/data/database/entity/SatRadio.kt | 4 ++ .../core/data/injection/MainContainer.kt | 3 +- .../core/data/repository/DatabaseRepo.kt | 27 +++++++++---- .../core/data/repository/SatelliteRepo.kt | 2 + .../core/data/repository/SelectionRepo.kt | 21 +++++----- .../core/data/repository/SettingsRepo.kt | 12 ++++++ .../look4sat/core/data/source/LocalSource.kt | 7 +++- .../core/data/repository/DatabaseRepoTest.kt | 3 ++ .../repository/SelectionRepoSearchTest.kt | 40 +++++++++++++++++-- .../look4sat/core/domain/model/SatRadio.kt | 4 ++ .../core/domain/repository/ISatelliteRepo.kt | 5 +++ .../core/domain/repository/ISettingsRepo.kt | 4 ++ .../core/domain/source/ILocalSource.kt | 1 + .../look4sat/core/domain/source/Sources.kt | 9 +++++ .../core/domain/utility/DataParser.kt | 32 +++++++++++++-- .../look4sat/core/domain/DataParserTest.kt | 5 +++ .../domain/repository/LoTWGridSyncTest.kt | 2 + .../rtbishop/look4sat/feature/mutual/Fakes.kt | 3 ++ 20 files changed, 174 insertions(+), 27 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt index f982982c..2e7a0219 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt @@ -54,6 +54,13 @@ interface Look4SatDao { @Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND uplinkLow IS NOT NULL AND isAlive = 1") suspend fun getIdsWithModesAndUplink(modes: List): List + /** 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): List + @Query("SELECT COUNT(*) FROM radios") suspend fun getRadiosTotal(): Int diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDb.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDb.kt index 688de323..63995022 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDb.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDb.kt @@ -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.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 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") } } + +/** 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") + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatRadio.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatRadio.kt index 6e3a3476..10056aef 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatRadio.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatRadio.kt @@ -33,6 +33,10 @@ data class SatRadio( val uplinkMode: String?, val isInverted: Boolean, 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. */ val isCustom: Boolean = false ) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 6948e52a..03114576 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -25,6 +25,7 @@ import android.location.LocationManager import androidx.room.Room 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_2_3 import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ic705Controller @@ -169,7 +170,7 @@ class MainContainer(private val context: Context) : IMainContainer { private fun provideLocalSource(): ILocalSource { val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400") val database = builder - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .fallbackToDestructiveMigration(false) .build() return LocalSource(database.look4SatDao()) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt index 5ed07958..8f6543b6 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt @@ -130,23 +130,34 @@ class DatabaseRepo( val entries = localSource.getEntriesList() // catnum -> name val jobs = Sources.amSatLiveUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } - } + } + async { "Active" to remoteSource.getNetworkStream(Sources.amSatActiveUrl) } 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 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 } - fun resolve(names: List): Set = 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 { val keys = dataParser.normalizeAmSatName(name) - nameToCatnum.entries.firstOrNull { (localName, _) -> + val all = nameToCatnum.filter { (localName, _) -> dataParser.matchesAmSatName(localName, keys) - }?.value - }.toSet() - val fmCatnums = resolve(fmNames) - val linearCatnums = resolve(linearNames) + }.values.toSet() + if (all.size <= 1) return all + val preferred = all.intersect(activeCatnums) + 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) - 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 { // Keep the previous lists; the mutual filter stays on the last good snapshot. println("AMSAT live lists update failed: $it") diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt index d1da96ee..c12f60f4 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt @@ -69,6 +69,8 @@ class SatelliteRepo( override suspend fun getSatelliteIdsWithModesAndUplink(modes: List) = localStorage.getIdsWithModesAndUplink(modes) + override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List) = localStorage.getIdsWithModesAndAmateur(modes) + override suspend fun initRepository() = withContext(dispatcher) { combine( settingsRepo.selectedIds, diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt index d9e183b1..f343d03f 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt @@ -74,16 +74,19 @@ class SelectionRepo( when (type) { Sources.virtualTypeNames[0] -> idsSet.addAll(settingsRepo.getAmSatFmCatnums()) Sources.virtualTypeNames[1] -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums()) - // Live SSTV: mode=SSTV transponder records intersected with the - // currently-tracked (in-orbit, from the selected TLE sources) - // satellites. The raw mode query alone would include retired - // weather/experimental birds (e.g. TIROS catnum 1430 whose old - // TV downlink is labelled SSTV but is long gone from orbit). + // Live SSTV: mode=SSTV transponder records whose service class + // is "Amateur", excluding launcher debris / rocket bodies + // (names ending in "R/B" or "DEB" — e.g. Ariane 6 R/B) that + // carry an amateur payload transponder but are not satellites. Sources.virtualTypeNames[2] -> { - val inOrbitIds = currentItems.value.map { it.catnum }.toHashSet() - idsSet.addAll( - localSource.getIdsWithModes(listOf("SSTV")).filter { it in inOrbitIds } - ) + val sstvIds = localSource.getIdsWithModesAndAmateur(listOf("SSTV")).toSet() + val inOrbitNames = currentItems.value.associate { it.catnum to it.name } + 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))) } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 7b042ef1..bac25c18 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -109,6 +109,7 @@ class SettingsRepo( private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration" private val keyAmSatFmCatnums = "amSatFmCatnums" private val keyAmSatLinearCatnums = "amSatLinearCatnums" + private val keyAmSatActiveCatnums = "amSatActiveCatnums" private val separatorComma = "," private val separatorUrl = "\n" private val legacyCelestrakSatnogsUrl = @@ -764,6 +765,17 @@ class SettingsRepo( putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma)) } } + + override fun getAmSatActiveCatnums(): Set { + val raw = preferences.getString(keyAmSatActiveCatnums, null) ?: return emptySet() + return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet() + } + + override fun setAmSatActiveCatnums(catnums: Set) { + preferences.edit { + putString(keyAmSatActiveCatnums, catnums.sorted().joinToString(separatorComma)) + } + } //endregion //region # Radio control settings diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt index ab37366a..baba33e1 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt @@ -49,6 +49,7 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource { override suspend fun getIdsWithModes(modes: List) = look4SatDao.getIdsWithModes(modes) override suspend fun getIdsWithModesAndUplink(modes: List) = look4SatDao.getIdsWithModesAndUplink(modes) + override suspend fun getIdsWithModesAndAmateur(modes: List) = look4SatDao.getIdsWithModesAndAmateur(modes) private fun FrameworkEntry.toDomain() = OrbitalData( 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( 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( 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.toFrameworkRadios() = this.map { radio -> radio.toFramework() } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index 3c6de2d1..a44e7494 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -181,6 +181,7 @@ private class FakeLocalSource : ILocalSource { override suspend fun getIdsWithModes(modes: List): List = emptyList() override suspend fun getIdsWithModesAndUplink(modes: List): List = emptyList() + override suspend fun getIdsWithModesAndAmateur(modes: List): List = emptyList() override suspend fun getRadiosTotal(): Int = insertedRadios.size @@ -273,6 +274,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou override fun getAmSatFmCatnums(): Set = emptySet() override fun getAmSatLinearCatnums(): Set = emptySet() override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) = Unit + override fun getAmSatActiveCatnums(): Set = emptySet() + override fun setAmSatActiveCatnums(catnums: Set) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt index bd4e6696..01b4d1a9 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt @@ -134,6 +134,35 @@ class SelectionRepoSearchTest { 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 fun `multiple virtual types union their satellites`() = runTest { val repo = createRepo( @@ -192,12 +221,13 @@ class SelectionRepoSearchTest { items: List, amSatFm: Set = emptySet(), amSatLinear: Set = emptySet(), - sstvIds: List = emptyList() + sstvIds: List = emptyList(), + amSatActive: Set = emptySet() ): ISelectionRepo { return SelectionRepo( dispatcher = Dispatchers.Unconfined, 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 getIdsWithModes(modes: List): List = sstvIds override suspend fun getIdsWithModesAndUplink(modes: List): List = sstvIds + override suspend fun getIdsWithModesAndAmateur(modes: List): List = sstvIds override suspend fun getRadiosTotal(): Int = 0 override suspend fun getRadiosWithId(id: Int): List = emptyList() override suspend fun insertRadios(radios: List, isCustom: Boolean) = Unit @@ -222,7 +253,8 @@ private class FakeLocalSourceForSearch( private class FakeSettingsRepoForSearch( private val amSatFm: Set = emptySet(), - private val amSatLinear: Set = emptySet() + private val amSatLinear: Set = emptySet(), + private var amSatActive: Set = emptySet() ) : ISettingsRepo { override val appVersionName: String = "test" override val selectedIds: StateFlow> = MutableStateFlow(emptyList()) @@ -268,6 +300,8 @@ private class FakeSettingsRepoForSearch( override fun getAmSatFmCatnums(): Set = amSatFm override fun getAmSatLinearCatnums(): Set = amSatLinear override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) = Unit + override fun getAmSatActiveCatnums(): Set = amSatActive + override fun setAmSatActiveCatnums(catnums: Set) { amSatActive = catnums } override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun getSatelliteOffset(catnum: Int): String = "" override fun setSatelliteOffset(catnum: Int, offset: String) = Unit diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatRadio.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatRadio.kt index 38d114d3..40f7a1cb 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatRadio.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatRadio.kt @@ -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 ) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt index f8aba7a5..ba446991 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt @@ -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): List + + /** 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): List } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index 8f2c0997..0e2f726e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -85,6 +85,10 @@ interface ISettingsRepo { /** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */ fun getAmSatLinearCatnums(): Set fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) + /** Catalog numbers of active *amateur* satellites from AMSAT's + * active-transponder table (whitelist for the virtual type filters). */ + fun getAmSatActiveCatnums(): Set + fun setAmSatActiveCatnums(catnums: Set) //endregion //region # Radio control settings diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt index 2ee4fbc5..b23ae86e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt @@ -30,6 +30,7 @@ interface ILocalSource { suspend fun deleteEntries() suspend fun getIdsWithModes(modes: List): List suspend fun getIdsWithModesAndUplink(modes: List): List + suspend fun getIdsWithModesAndAmateur(modes: List): List suspend fun getRadiosTotal(): Int suspend fun getRadiosWithId(id: Int): List suspend fun insertRadios(radios: List, isCustom: Boolean = false) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt index e17e876d..f34cfcce 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt @@ -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 diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt index 4e162a21..44b547f7 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt @@ -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 = 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 = 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): 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 } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt index eb30be83..131e4975 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt @@ -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 diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt index ffb7bdcc..b1e95490 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt @@ -286,6 +286,8 @@ class LoTWGridSyncTest { override fun getAmSatFmCatnums(): Set = emptySet() override fun getAmSatLinearCatnums(): Set = emptySet() override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) = Unit + override fun getAmSatActiveCatnums(): Set = emptySet() + override fun setAmSatActiveCatnums(catnums: Set) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO() override fun getSatelliteOffset(catnum: Int): String = "" override fun setSatelliteOffset(catnum: Int, offset: String) = TODO() diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt index 066867f2..84ffc185 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt @@ -46,6 +46,7 @@ class FakeSatelliteRepo( override suspend fun getRadiosWithId(id: Int): List = TODO() override suspend fun getSatelliteIdsWithModes(modes: List): List = emptyList() override suspend fun getSatelliteIdsWithModesAndUplink(modes: List): List = emptyList() + override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List): List = emptyList() } /** @@ -112,6 +113,8 @@ class FakeSettingsRepo( override fun getAmSatFmCatnums(): Set = amSatFm override fun getAmSatLinearCatnums(): Set = amSatLinear override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) = TODO() + override fun getAmSatActiveCatnums(): Set = emptySet() + override fun setAmSatActiveCatnums(catnums: Set) = TODO() override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO() override fun getSatelliteOffset(catnum: Int): String = ""