mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
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:
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,
|
||||||
|
|||||||
+12
-9
@@ -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
|
||||||
|
|
||||||
|
|||||||
+37
-3
@@ -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
|
||||||
)
|
)
|
||||||
+5
@@ -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>
|
||||||
}
|
}
|
||||||
+4
@@ -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
|
||||||
|
|||||||
+2
@@ -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 = ""
|
||||||
|
|||||||
Reference in new issue
Block a user