mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
fix: revert AMSAT live data route; hardcode FM/Linear satellite lists
User decision: drop the AMSAT live-page fetch pipeline entirely (page
parsing, amateur whitelist, ISS module disambiguation, timeout fallbacks,
amSatListsVersion re-resolution) and hardcode the FM/Linear lists instead.
- Sources: remove amSatLiveUrls + amSatActiveUrls; add
amSatFmCatnums = {SO-50 27607, ISS ZARYA 25544, AO-123 43137} and
amSatLinearCatnums = {RS-44 44909, FO-29 24278, AO-7 7530,
AO-73 39444, JO-97 43803}
- SelectionRepo: virtual types resolve FM/Linear from hardcoded sets;
SSTV stays (amateur-only mode query + R/B debris exclusion)
- MutualViewModel: FM/Linear chips filter by the hardcoded sets
- DatabaseRepo/SettingsRepo/ISettingsRepo: AMSAT list fetch/persist
removed; DataParser AMSAT parse helpers removed
- Tests: AMSAT pipeline tests dropped; selection/mutual tests re-targeted
at the hardcoded lists
This commit is contained in:
1 parent
317f6d877f
commit
70ada0d608
14 files changed
+93
-658
No files matched your search
@@ -116,107 +116,12 @@ class DatabaseRepo(
|
||||
localSource.insertRadios(importedRadios)
|
||||
}
|
||||
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
||||
updateAmSatLiveLists()
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the AMSAT Live FM/Linear satellite lists (transponders currently
|
||||
* on the air). Best effort: any failure keeps the previous lists and never
|
||||
* blocks the rest of the data update, so the mutual-match filter simply
|
||||
* falls back to the last successful snapshot.
|
||||
*/
|
||||
private suspend fun updateAmSatLiveLists() = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val entries = localSource.getEntriesList() // catnum -> name
|
||||
val jobs = Sources.amSatLiveUrls.map { (type, url) ->
|
||||
async { type to remoteSource.getNetworkStream(url) }
|
||||
} + async { "Active" to fetchAmSatActiveStream() }
|
||||
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()
|
||||
// Amateur whitelist: a failed fetch yields an empty result, which
|
||||
// must NOT wipe out the last good snapshot. Fall back to the
|
||||
// persisted whitelist, and only persist when this fetch actually
|
||||
// returned a stream.
|
||||
val activeStream = results.firstOrNull { it.first == "Active" }?.second?.stream
|
||||
val activeCatnums = activeStream
|
||||
?.let { dataParser.parseAmSatActiveCatnums(it) }
|
||||
.orEmpty()
|
||||
.ifEmpty { settingsRepo.getAmSatActiveCatnums() }
|
||||
if (activeStream != null) settingsRepo.setAmSatActiveCatnums(activeCatnums)
|
||||
val nameToCatnum = entries.associate { it.name.uppercase() to it.catnum }
|
||||
// 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 all = nameToCatnum.filter { (localName, _) ->
|
||||
dataParser.matchesAmSatName(localName, keys)
|
||||
}.values.toSet()
|
||||
// No local entry matches this AMSAT name (e.g. a brand-new
|
||||
// satellite not yet in the local TLE, like TEVEL2/RS95S):
|
||||
// skip it. Returning empty is correct — crashing here (via
|
||||
// all.first()) aborted the WHOLE list update and kept the
|
||||
// stale pre-whitelist FM list with all five ISS modules.
|
||||
if (all.isEmpty()) return emptySet()
|
||||
if (all.size <= 1) return all
|
||||
val preferred = all.intersect(activeCatnums)
|
||||
if (preferred.isNotEmpty()) return preferred
|
||||
// Whitelist unavailable: keep only the primary entry (smallest
|
||||
// catnum — ISS ZARYA=25544 is the smallest of its five module
|
||||
// entries) instead of admitting every alias (DESTINY etc.).
|
||||
return setOf(all.minOrNull() ?: all.first())
|
||||
}
|
||||
// A failed FM/Linear page fetch yields an empty list here; do NOT
|
||||
// keep the stale list verbatim (it may predate the whitelist and
|
||||
// still contain ISS module aliases like DESTINY). Instead re-run
|
||||
// the multi-match disambiguation on the previous catnums via their
|
||||
// local names, so the whitelist / smallest-catnum rules clean it.
|
||||
fun cleanStaleList(previous: Set<Int>): Set<Int> =
|
||||
previous.mapNotNull { catnum ->
|
||||
nameToCatnum.entries.firstOrNull { it.value == catnum }?.key
|
||||
}.flatMap { resolvePerName(it) }.toSet()
|
||||
// Last-resort fallback when the AMSAT page is down AND there is no
|
||||
// previous list to clean (e.g. right after clearing data): derive
|
||||
// the FM/Linear sets from the local transceivers intersected with
|
||||
// the amateur whitelist, so a network timeout never wipes the
|
||||
// filters to empty.
|
||||
val fmFallback = localSource.getIdsWithModes(listOf("FM")).toSet().intersect(activeCatnums)
|
||||
val linearFallback = localSource
|
||||
.getIdsWithModes(listOf("SSB", "CW", "USB", "LSB")).toSet().intersect(activeCatnums)
|
||||
val fmCatnums = fmNames.flatMap { resolvePerName(it) }.toSet()
|
||||
.ifEmpty { cleanStaleList(settingsRepo.getAmSatFmCatnums()) }
|
||||
.ifEmpty { fmFallback }
|
||||
val linearCatnums = linearNames.flatMap { resolvePerName(it) }.toSet()
|
||||
.ifEmpty { cleanStaleList(settingsRepo.getAmSatLinearCatnums()) }
|
||||
.ifEmpty { linearFallback }
|
||||
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/** Try the amateur-whitelist sources in order and return the first
|
||||
* NetworkResult that carried a stream (or null if all failed). */
|
||||
private suspend fun fetchAmSatActiveStream(): NetworkResult? {
|
||||
for (url in Sources.amSatActiveUrls) {
|
||||
val result = remoteSource.getNetworkStream(url)
|
||||
if (result.stream != null) return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||
localSource.deleteEntries()
|
||||
localSource.deleteRadios()
|
||||
settingsRepo.setAmSatCatnums(emptySet(), emptySet())
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
|
||||
+18
-20
@@ -25,7 +25,6 @@ import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -45,22 +44,19 @@ class SelectionRepo(
|
||||
// Resolve type IDs once when types change, then filter items reactively.
|
||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||
// are resolved from live transponder data instead of SharedPreferences.
|
||||
// The AMSAT list version counter re-triggers resolution when a background
|
||||
// data sync rewrites the FM/Linear lists, so the UI reflects the new list
|
||||
// without a restart or type toggle.
|
||||
private val itemsWithTypes = combine(currentTypes, settingsRepo.amSatListsVersion) { types, _ -> types }
|
||||
.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = resolveTypeIds(types)
|
||||
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||
}
|
||||
// are resolved from hardcoded FM/Linear catnum sets (Sources) or live
|
||||
// SSTV transponder data instead of SharedPreferences.
|
||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = resolveTypeIds(types)
|
||||
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||
}
|
||||
}
|
||||
|
||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||
@@ -70,15 +66,17 @@ class SelectionRepo(
|
||||
|
||||
/**
|
||||
* Resolves a list of type names to satellite catnums. The three virtual
|
||||
* transponder/activity types are resolved from live data; all other types
|
||||
* come from the per-type ID lists persisted by [ISettingsRepo].
|
||||
* transponder/activity types are resolved from hardcoded FM/Linear lists
|
||||
* (Sources.amSatFmCatnums / amSatLinearCatnums) or local SSTV transponder
|
||||
* data; all other types come from the per-type ID lists persisted by
|
||||
* [ISettingsRepo].
|
||||
*/
|
||||
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
|
||||
val idsSet = mutableSetOf<Int>()
|
||||
types.forEach { type ->
|
||||
when (type) {
|
||||
Sources.virtualTypeNames[0] -> idsSet.addAll(settingsRepo.getAmSatFmCatnums())
|
||||
Sources.virtualTypeNames[1] -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums())
|
||||
Sources.virtualTypeNames[0] -> idsSet.addAll(Sources.amSatFmCatnums)
|
||||
Sources.virtualTypeNames[1] -> idsSet.addAll(Sources.amSatLinearCatnums)
|
||||
// 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
|
||||
|
||||
@@ -107,9 +107,6 @@ class SettingsRepo(
|
||||
private val keySatelliteEnabled = "satelliteEnabled"
|
||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||
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 =
|
||||
@@ -750,40 +747,6 @@ class SettingsRepo(
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # AMSAT live-transponder lists
|
||||
private val _amSatListsVersion = MutableStateFlow(0)
|
||||
override val amSatListsVersion: StateFlow<Int> = _amSatListsVersion
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> {
|
||||
val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet()
|
||||
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
|
||||
}
|
||||
|
||||
override fun getAmSatLinearCatnums(): Set<Int> {
|
||||
val raw = preferences.getString(keyAmSatLinearCatnums, null) ?: return emptySet()
|
||||
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
|
||||
}
|
||||
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
|
||||
preferences.edit {
|
||||
putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma))
|
||||
putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma))
|
||||
}
|
||||
_amSatListsVersion.update { it + 1 }
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
//region # Radio control settings
|
||||
private val keyRadioControlEnabled = "radioControlEnabled"
|
||||
private val keyRadioModel = "radioModel"
|
||||
|
||||
+2
-181
@@ -166,168 +166,6 @@ class DatabaseRepoTest {
|
||||
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
|
||||
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
@Test
|
||||
fun `amsat fm list disambiguates ISS modules via persisted whitelist when active csv fetch fails`() =
|
||||
runTest(dispatcher) {
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { issModulesCsvStream() }
|
||||
networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageStream() }
|
||||
// Amateur-whitelist URLs intentionally NOT registered -> fetch fails.
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
).apply { amSatActive = setOf(25544) } // whitelist persisted by an earlier good sync
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
// Whitelist fetch fails on both sources, but the persisted whitelist
|
||||
// still disambiguates: only primary ISS (ZARYA) survives,
|
||||
// ISS (DESTINY) = 26700 must NOT enter the FM list.
|
||||
assertTrue(25544 in settingsRepo.amSatFm)
|
||||
assertTrue(26700 !in settingsRepo.amSatFm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat fm list keeps only smallest catnum when whitelist never available`() = runTest(dispatcher) {
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { issModulesCsvStream() }
|
||||
networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
) // no persisted whitelist at all
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
// Without any whitelist, the multi-match collapse keeps the primary
|
||||
// entry (smallest catnum = ISS ZARYA 25544), never every module alias.
|
||||
assertEquals(setOf(25544), settingsRepo.amSatFm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat fm list cleans stale ISS module aliases when fm page fetch fails`() = runTest(dispatcher) {
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { issModulesCsvStream() }
|
||||
// FM page NOT registered -> 404 -> empty parse.
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
).apply {
|
||||
// Pre-whitelist era stale list: ALL five ISS module entries.
|
||||
amSatFm = setOf(25544, 25575, 26400, 26700, 49044)
|
||||
}
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
// The stale list is not kept verbatim: with the FM page down, the
|
||||
// previous catnums are re-disambiguated via local names, collapsing
|
||||
// the five ISS modules to the primary ZARYA (25544).
|
||||
assertEquals(setOf(25544), settingsRepo.amSatFm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat fm list survives unknown satellite names and still disambiguates ISS`() = runTest(dispatcher) {
|
||||
// Regression: AMSAT pages list satellites absent from the local TLE
|
||||
// (TEVEL2-1..9, RS95S). resolvePerName used to crash on the empty
|
||||
// match set (all.first()), aborting the WHOLE list update and keeping
|
||||
// the stale pre-whitelist FM list with all five ISS modules.
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { issModulesCsvStream() }
|
||||
networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageWithUnknownStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
).apply { amSatActive = setOf(25544) }
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
// No crash; ISS still collapses to the primary ZARYA (25544).
|
||||
assertEquals(setOf(25544), settingsRepo.amSatFm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat fm list falls back to local FM transceivers when page fails and no previous list`() =
|
||||
runTest(dispatcher) {
|
||||
// After clearing data, the previous FM list is empty AND the AMSAT
|
||||
// page is down (timeout) -> the filter must fall back to local FM
|
||||
// transceivers intersected with the whitelist, never stay empty.
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource().apply {
|
||||
idsWithModes = listOf(25544, 26700, 39444) // local FM-transceiver sats
|
||||
}
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { issModulesCsvStream() }
|
||||
// FM/Linear pages NOT registered -> 404 -> fetch fails.
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
).apply { amSatActive = setOf(25544) } // whitelist has only ZARYA
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
// Fallback = local FM ids ∩ whitelist = {25544}; ISS module aliases
|
||||
// (26700) are excluded by the whitelist.
|
||||
assertEquals(setOf(25544), settingsRepo.amSatFm)
|
||||
}
|
||||
|
||||
private fun amsatFmPageWithUnknownStream(): InputStream = """
|
||||
<table>
|
||||
<thead><tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ISS</td><td>145.990 MHz</td><td>437.800 MHz</td><td></td></tr>
|
||||
<tr><td>TEVEL2-1 thru TEVEL2-9</td><td>145.970 MHz</td><td>436.400 MHz</td><td></td></tr>
|
||||
<tr><td>RS95S(QMR-KWT-2)</td><td>145.920 MHz</td><td>436.950 MHz</td><td></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun issModulesCsvStream(): InputStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (UNITY),1998-067B,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25575,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (ZVEZDA),1998-067C,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,26400,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (DESTINY),1998-067D,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,26700,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (NAUKA),1998-067E,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,49044,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun amsatFmPageStream(): InputStream = """
|
||||
<table class="has-background">
|
||||
<thead><tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ISS</td><td>145.990 MHz</td><td>437.800 MHz</td><td></td></tr>
|
||||
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250 MHz</td><td>145.960 MHz</td><td></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
""".trimIndent().byteInputStream()
|
||||
}
|
||||
|
||||
private class FakeRemoteSource : IRemoteSource {
|
||||
@@ -349,7 +187,6 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
val insertedEntries = mutableListOf<OrbitalData>()
|
||||
private val insertedRadios = mutableListOf<SatRadio>()
|
||||
var idsWithModes: List<Int> = emptyList()
|
||||
|
||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||
|
||||
@@ -366,8 +203,8 @@ private class FakeLocalSource : ILocalSource {
|
||||
insertedEntries.clear()
|
||||
}
|
||||
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = idsWithModes
|
||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = idsWithModes
|
||||
override suspend fun getIdsWithModes(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
|
||||
@@ -458,22 +295,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
||||
}
|
||||
|
||||
var amSatFm: Set<Int> = emptySet()
|
||||
var amSatLinear: Set<Int> = emptySet()
|
||||
var amSatActive: Set<Int> = emptySet()
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
|
||||
amSatFm = fmCatnums
|
||||
amSatLinear = linearCatnums
|
||||
}
|
||||
override fun getAmSatActiveCatnums(): Set<Int> = amSatActive
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) {
|
||||
amSatActive = catnums
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
+31
-65
@@ -113,14 +113,23 @@ class SelectionRepoSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FM virtual type shows only AMSAT FM list satellites`() = runTest {
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
amSatFm = setOf(25544, 39444)
|
||||
)
|
||||
fun `FM virtual type shows only hardcoded FM satellites`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(25544, 39444), results.map { it.catnum })
|
||||
// Hardcoded FM list: SO-50 (27607), ISS ZARYA (25544), AO-123 (43137).
|
||||
// Only ISS ZARYA is present in the sample DB.
|
||||
assertEquals(listOf(25544), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Linear virtual type shows only hardcoded linear satellites`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live Linear"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
// Hardcoded linear list: RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
||||
// AO-73 (39444), JO-97 (43803). Present in the sample DB: 7530, 39444, 43803.
|
||||
assertEquals(setOf(7530, 39444, 43803), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -140,8 +149,7 @@ class SelectionRepoSearchTest {
|
||||
// only catnums the DAO would have filtered to Amateur service (43803).
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
sstvIds = listOf(43803),
|
||||
amSatActive = setOf(43803)
|
||||
sstvIds = listOf(43803)
|
||||
)
|
||||
repo.setTypes(listOf("Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
@@ -155,8 +163,7 @@ class SelectionRepoSearchTest {
|
||||
val items = sampleItems + SatItem(catnum = 60239, name = "ARIANE 6 R/B")
|
||||
val repo = createRepo(
|
||||
items = items,
|
||||
sstvIds = listOf(43803, 60239),
|
||||
amSatActive = setOf(43803, 60239)
|
||||
sstvIds = listOf(43803, 60239)
|
||||
)
|
||||
repo.setTypes(listOf("Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
@@ -167,8 +174,6 @@ class SelectionRepoSearchTest {
|
||||
fun `multiple virtual types union their satellites`() = runTest {
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
amSatFm = setOf(25544),
|
||||
amSatLinear = setOf(7530),
|
||||
sstvIds = listOf(43803)
|
||||
)
|
||||
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
|
||||
@@ -178,7 +183,7 @@ class SelectionRepoSearchTest {
|
||||
|
||||
@Test
|
||||
fun `clearing types restores full list`() = runTest {
|
||||
val repo = createRepo(items = sampleItems, amSatFm = setOf(25544))
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
|
||||
repo.setTypes(emptyList())
|
||||
@@ -186,23 +191,24 @@ class SelectionRepoSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `virtual type with empty AMSAT list shows empty not everything`() = runTest {
|
||||
// 根因2回归: AMSAT 清单未同步(空)时, 选虚拟类型应显示空列表, 而非全部卫星.
|
||||
val repo = createRepo(items = sampleItems, amSatFm = emptySet())
|
||||
fun `virtual type with no matching satellites shows empty not everything`() = runTest {
|
||||
// 硬编码 FM 列表 (27607/25544/43137) 里没有样本库中的卫星时, 选虚拟
|
||||
// 类型应显示空列表, 而非全部卫星.
|
||||
val items = listOf(SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"))
|
||||
val repo = createRepo(items)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `virtual type combined with regular type unions both lists`() = runTest {
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
amSatFm = setOf(25544),
|
||||
amSatLinear = setOf(7530)
|
||||
)
|
||||
val repo = createRepo(items = sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live FM", "AMSAT Live Linear"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(setOf(25544, 7530), results.map { it.catnum }.toSet())
|
||||
assertEquals(
|
||||
setOf(25544, 7530, 39444, 43803),
|
||||
results.map { it.catnum }.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -256,40 +262,14 @@ class SelectionRepoSearchTest {
|
||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat list version bump re-resolves FM filter after data sync`() = runTest {
|
||||
// ISS module aliases: dirty pre-sync FM list contains both ZARYA and DESTINY.
|
||||
val items = listOf(
|
||||
SatItem(25544, "ISS (ZARYA)"),
|
||||
SatItem(26700, "ISS (DESTINY)")
|
||||
)
|
||||
val fake = FakeSettingsRepoForSearch(amSatFm = setOf(25544, 26700))
|
||||
val repo = SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(items),
|
||||
settingsRepo = fake
|
||||
)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertEquals(setOf(25544, 26700), repo.getEntriesFlow().first().map { it.catnum }.toSet())
|
||||
|
||||
// Simulate a background data sync: lists rewritten + version bumped.
|
||||
fake.setAmSatCatnums(fmCatnums = setOf(25544), linearCatnums = emptySet())
|
||||
|
||||
// The FM filter must reflect the new list WITHOUT a type toggle or restart.
|
||||
assertEquals(listOf(25544), repo.getEntriesFlow().first().map { it.catnum })
|
||||
}
|
||||
|
||||
private fun createRepo(
|
||||
items: List<SatItem>,
|
||||
amSatFm: Set<Int> = emptySet(),
|
||||
amSatLinear: Set<Int> = emptySet(),
|
||||
sstvIds: List<Int> = emptyList(),
|
||||
amSatActive: Set<Int> = emptySet()
|
||||
sstvIds: List<Int> = emptyList()
|
||||
): ISelectionRepo {
|
||||
return SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(items, sstvIds),
|
||||
settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear, amSatActive)
|
||||
settingsRepo = FakeSettingsRepoForSearch()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -313,11 +293,7 @@ private class FakeLocalSourceForSearch(
|
||||
override suspend fun deleteRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepoForSearch(
|
||||
var amSatFm: Set<Int> = emptySet(),
|
||||
var amSatLinear: Set<Int> = emptySet(),
|
||||
private var amSatActive: Set<Int> = emptySet()
|
||||
) : ISettingsRepo {
|
||||
private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
private val typeIds = mutableMapOf<String, List<Int>>()
|
||||
override val appVersionName: String = "test"
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
@@ -364,16 +340,6 @@ private class FakeSettingsRepoForSearch(
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
|
||||
amSatFm = fmCatnums
|
||||
amSatLinear = linearCatnums
|
||||
(amSatListsVersion as MutableStateFlow<Int>).value++
|
||||
}
|
||||
override fun getAmSatActiveCatnums(): Set<Int> = amSatActive
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) { amSatActive = catnums }
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
-17
@@ -79,23 +79,6 @@ interface ISettingsRepo {
|
||||
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||
//endregion
|
||||
|
||||
//region # AMSAT live-transponder lists
|
||||
/** Catalog numbers of satellites listed on AMSAT's "Live FM Satellites" page. */
|
||||
fun getAmSatFmCatnums(): Set<Int>
|
||||
/** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */
|
||||
fun getAmSatLinearCatnums(): Set<Int>
|
||||
fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>)
|
||||
/** 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>)
|
||||
/** Monotonic version counter, incremented every time the AMSAT live
|
||||
* FM/Linear lists are rewritten. Consumers that resolve the virtual
|
||||
* types (SelectionRepo) should re-resolve on change so a background
|
||||
* data sync is reflected in the UI without a restart. */
|
||||
val amSatListsVersion: StateFlow<Int>
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
|
||||
@@ -55,31 +55,25 @@ object Sources {
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||
)
|
||||
/** AMSAT "Live FM/Linear Satellites" pages: human-maintained lists of
|
||||
* transponders currently on the air. Parsed into the mutual-match filter
|
||||
* (satellites that actually carry a working FM-voice / linear repeater). */
|
||||
val amSatLiveUrls = mapOf(
|
||||
"FM" to "https://www.amsat.org/live-fm-satellites/",
|
||||
"Linear" to "https://www.amsat.org/live-linear-satellites/"
|
||||
)
|
||||
/**
|
||||
* Hardcoded AMSAT Live FM satellites (NORAD catnums):
|
||||
* SO-50 (27607), ISS ZARYA (25544), AO-123 Fox-1D (43137).
|
||||
* Replaces the AMSAT live-page fetch: stable on any network, no
|
||||
* sync-time dependency, no stale-list or timeout failure modes.
|
||||
*/
|
||||
val amSatFmCatnums = setOf(27607, 25544, 43137)
|
||||
|
||||
/** 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.). Primary source plus
|
||||
* a jsDelivr CDN mirror: raw.githubusercontent.com is unreachable on
|
||||
* some networks (e.g. mainland China mobile), and a failed whitelist
|
||||
* fetch would silently disable the module disambiguation. */
|
||||
val amSatActiveUrls = listOf(
|
||||
"https://raw.githubusercontent.com/palewire/amateur-satellite-database/main/data/amsat-active-frequencies.csv",
|
||||
"https://cdn.jsdelivr.net/gh/palewire/amateur-satellite-database@main/data/amsat-active-frequencies.csv"
|
||||
)
|
||||
/**
|
||||
* Hardcoded AMSAT Live linear (SSB/CW) satellites:
|
||||
* RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
||||
* AO-73 FUNcube-1 (39444), JO-97 JY1SAT (43803).
|
||||
*/
|
||||
val amSatLinearCatnums = setOf(44909, 24278, 7530, 39444, 43803)
|
||||
|
||||
/** 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
|
||||
* mutually exclusive with the regular TLE-source types in the picker. */
|
||||
* at the top of the type picker. They resolve to live lists (hardcoded
|
||||
* FM/Linear catnum sets or mode=SSTV radios) instead of persisted
|
||||
* per-type IDs, and are mutually exclusive with the regular TLE-source
|
||||
* types in the picker. */
|
||||
val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||
}
|
||||
@@ -40,21 +40,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse AMSAT's active-transponder CSV (palewire mirror) and return the
|
||||
* set of NORAD catnums of active *amateur* satellites. Header row has a
|
||||
* norad_id column; some rows carry alphanumeric IDs (Bluebird A0241 etc.)
|
||||
* which are skipped. */
|
||||
suspend fun parseAmSatActiveCatnums(stream: InputStream): Set<Int> = withContext(dispatcher) {
|
||||
stream.bufferedReader().useLines { lines ->
|
||||
val header = lines.firstOrNull() ?: return@useLines emptySet()
|
||||
val noradIdx = header.split(",").indexOfFirst { it.trim().equals("norad_id", true) }
|
||||
if (noradIdx < 0) return@useLines emptySet()
|
||||
lines.mapNotNull { line ->
|
||||
line.split(",").getOrNull(noradIdx)?.trim()?.toIntOrNull()
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
stream.bufferedReader().readLines()
|
||||
.chunked(3)
|
||||
@@ -73,101 +58,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an AMSAT "Live FM/Linear Satellites" page into satellite names.
|
||||
* The page is an HTML table whose first column is the satellite name,
|
||||
* sometimes written as an alias list ("AO-91(RadFxSat / Fox-1B)") or a
|
||||
* range ("TEVEL2-1 thru TEVEL2-9"). Returns the distinct expanded names.
|
||||
*/
|
||||
suspend fun parseAmSatLivePage(stream: InputStream): List<String> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val html = stream.bufferedReader().readText()
|
||||
// Grab table rows, take the first cell of each row, keep rows that
|
||||
// look like satellite names (start with letters/digits, not a header).
|
||||
val rows = Regex("<tr[^>]*>(.*?)</tr>", RegexOption.IGNORE_CASE)
|
||||
.findAll(html)
|
||||
.mapNotNull { match ->
|
||||
val cells = Regex("<t[dh][^>]*>(.*?)</t[dh]>", RegexOption.IGNORE_CASE)
|
||||
.findAll(match.groupValues[1])
|
||||
.map { cell -> stripHtml(cell.groupValues[1]).trim() }
|
||||
.toList()
|
||||
cells.firstOrNull()?.takeIf { it.isNotBlank() && !it.equals("Satellite", ignoreCase = true) }
|
||||
}
|
||||
.toList()
|
||||
rows.flatMap { expandNameRange(it) }.distinct()
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun stripHtml(raw: String): String =
|
||||
raw.replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
/** Expand "NAME1 thru NAME9" ranges (e.g. TEVEL2-1 thru TEVEL2-9). */
|
||||
private fun expandNameRange(name: String): List<String> {
|
||||
val m = Regex("^(.*?)(\\d+)\\s+thru\\s+(.*?)(\\d+)$", RegexOption.IGNORE_CASE).find(name)
|
||||
if (m == null) return listOf(name)
|
||||
val prefix1 = m.groupValues[1].trim()
|
||||
val startNum = m.groupValues[2].toIntOrNull() ?: return listOf(name)
|
||||
val endNum = m.groupValues[4].toIntOrNull() ?: return listOf(name)
|
||||
val prefix2 = m.groupValues[3].trim()
|
||||
if (startNum > endNum) return listOf(name)
|
||||
// Suffix after the end number, if any (e.g. "TEVEL2-1 thru TEVEL2-9 ").
|
||||
val suffix = name.substringAfterLast(m.groupValues[4]).trim()
|
||||
return (startNum..endNum).map { "$prefix1$it$suffix" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a satellite name from the AMSAT live pages into lookup keys
|
||||
* used to match against the local entries table:
|
||||
* - primary key: the leading designator ("AO-91" from "AO-91 (RadFxSat / Fox-1B)")
|
||||
* - alias keys: every bracketed alias, uppercased, digits preserved
|
||||
* The local entry names are normalized the same way in [matchesAmSatName].
|
||||
*/
|
||||
fun normalizeAmSatName(name: String): List<String> {
|
||||
val keys = mutableListOf<String>()
|
||||
// Primary: everything before the first '(' (or '['), then the first token.
|
||||
val primary = name.substringBefore('(').substringBefore('[').trim()
|
||||
primary.split(Regex("\\s+")).firstOrNull()?.takeIf { it.isNotBlank() }?.let {
|
||||
keys += it.uppercase()
|
||||
}
|
||||
// Aliases inside parentheses / brackets: "RadFxSat / Fox-1B" -> two keys.
|
||||
Regex("\\(([^)]*)\\)").findAll(name).forEach { m ->
|
||||
m.groupValues[1].split('/', '|').forEach { alias ->
|
||||
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
|
||||
}
|
||||
}
|
||||
Regex("\\[([^]]*)]").findAll(name).forEach { m ->
|
||||
m.groupValues[1].split('/', '|').forEach { alias ->
|
||||
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
|
||||
}
|
||||
}
|
||||
return keys.distinct()
|
||||
}
|
||||
|
||||
/** True if a local entry name matches any of the AMSAT normalized keys.
|
||||
* 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). OSCAR designators are expanded so
|
||||
* that "AO-7" also matches local names spelled "OSCAR 7" / "AMSAT-OSCAR 7"
|
||||
* (the form used by CelesTrak/SatNOGS TLE sources). */
|
||||
fun matchesAmSatName(localName: String, amSatKeys: List<String>): Boolean {
|
||||
val localUpper = localName.uppercase()
|
||||
val localTokens = localUpper
|
||||
.split(Regex("[\\s()\\[\\]/]+"))
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet()
|
||||
return amSatKeys.any { key ->
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||
val name = values[0]
|
||||
val timestamp = values[2]
|
||||
|
||||
@@ -239,60 +239,4 @@ class DataParserTest {
|
||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT FM page parses satellite names and expands thru ranges`() = runTest(testDispatcher) {
|
||||
val html = """
|
||||
<html><body><table>
|
||||
<tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr>
|
||||
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250</td><td>145.960</td></tr>
|
||||
<tr><td>SO-50(SaudiSat-1C)</td><td>145.850</td><td>436.795</td></tr>
|
||||
<tr><td>TEVEL2-1 thru TEVEL2-9</td><td>145.970</td><td>436.400</td></tr>
|
||||
</table></body></html>
|
||||
""".trimIndent().byteInputStream()
|
||||
val names = dataParser.parseAmSatLivePage(html)
|
||||
assert(names.contains("AO-91(RadFxSat / Fox-1B)"))
|
||||
assert(names.contains("SO-50(SaudiSat-1C)"))
|
||||
assert(names.contains("TEVEL2-1"))
|
||||
assert(names.contains("TEVEL2-9"))
|
||||
assert(names.size == 11) // 2 singles + 9 expanded TEVEL2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT linear page keeps mode-suffixed names`() = runTest(testDispatcher) {
|
||||
val html = """
|
||||
<table>
|
||||
<tr><th>Satellite</th><th>Frequencies</th><th>Comment</th></tr>
|
||||
<tr><td>AO-7 Mode B U/v Inverting Analog</td><td>Uplink LSB</td></tr>
|
||||
<tr><td>FO-29 (JAS-2)V/u Inverting Analog</td><td>Uplink LSB</td></tr>
|
||||
</table>
|
||||
""".trimIndent().byteInputStream()
|
||||
val names = dataParser.parseAmSatLivePage(html)
|
||||
assert(names.contains("AO-7 Mode B U/v Inverting Analog"))
|
||||
assert(names.contains("FO-29 (JAS-2)V/u Inverting Analog"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT name normalization produces primary and alias keys`() {
|
||||
assert(dataParser.normalizeAmSatName("AO-91(RadFxSat / Fox-1B)") == listOf("AO-91", "RADFXSAT", "FOX-1B"))
|
||||
assert(dataParser.normalizeAmSatName("QO-100 (Es'hail-2/P4A) S/x") == listOf("QO-100", "ES'HAIL-2", "P4A"))
|
||||
assert(dataParser.normalizeAmSatName("ISS") == listOf("ISS"))
|
||||
assert(dataParser.matchesAmSatName("AO-91 (RADFXSAT)", listOf("AO-91", "RADFXSAT")))
|
||||
assert(!dataParser.matchesAmSatName("SO-50", listOf("AO-91")))
|
||||
// Regression: "ISS" must not substring-match "AISSAT-1" (token match only).
|
||||
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
|
||||
fun `AMSAT live page with no table returns empty`() = runTest(testDispatcher) {
|
||||
val html = "<html><body>No data</body></html>".byteInputStream()
|
||||
assert(dataParser.parseAmSatLivePage(html).isEmpty())
|
||||
}
|
||||
}
|
||||
-6
@@ -283,12 +283,6 @@ class LoTWGridSyncTest {
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
|
||||
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) = Unit
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
|
||||
+9
-20
@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -303,27 +304,15 @@ class MutualViewModel(
|
||||
|
||||
viewModelScope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
// 转发器类型过滤(协程上下文): AMSAT Live 清单优先, 为空时回退数据库.
|
||||
val amSatFm = settingsRepo.getAmSatFmCatnums()
|
||||
val amSatLinear = settingsRepo.getAmSatLinearCatnums()
|
||||
val filteredSatellites = if (amSatFm.isEmpty() && amSatLinear.isEmpty()) {
|
||||
// 回退到数据库模式过滤: 只用带上行频率的转发器记录, 排除纯下行
|
||||
// 信标/遥测(如 CW 信标、FM 语音合成信标), 避免 ISS/IO-86/FO-29 等
|
||||
// 因共享模式标签(CW/FM)被误判进错误类别.
|
||||
val filterModes = buildList {
|
||||
if (state.filterFM) add("FM")
|
||||
if (state.filterLinear) addAll(listOf("USB", "LSB", "CW", "SSB"))
|
||||
}
|
||||
val idsWithModes = satelliteRepo.getSatelliteIdsWithModesAndUplink(filterModes)
|
||||
if (idsWithModes.isEmpty()) satellites
|
||||
else satellites.filter { it.data.catnum in idsWithModes }
|
||||
} else {
|
||||
val allowed = buildSet {
|
||||
if (state.filterFM) addAll(amSatFm)
|
||||
if (state.filterLinear) addAll(amSatLinear)
|
||||
}
|
||||
satellites.filter { it.data.catnum in allowed }
|
||||
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
|
||||
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
|
||||
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
|
||||
// 稳定, 没有旧脏列表/超时/同步失败清空等问题.
|
||||
val allowed = buildSet {
|
||||
if (state.filterFM) addAll(Sources.amSatFmCatnums)
|
||||
if (state.filterLinear) addAll(Sources.amSatLinearCatnums)
|
||||
}
|
||||
val filteredSatellites = satellites.filter { it.data.catnum in allowed }
|
||||
// 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置),
|
||||
// 终点 = now + hoursAhead 不变.
|
||||
val time = now - state.hoursBefore * 60L * 60L * 1000L
|
||||
|
||||
@@ -54,9 +54,7 @@ class FakeSatelliteRepo(
|
||||
* state; everything else the ViewModel never touches fails loudly.
|
||||
*/
|
||||
class FakeSettingsRepo(
|
||||
initialPosition: GeoPos = GeoPos(23.13, 113.26),
|
||||
val amSatFm: Set<Int> = emptySet(),
|
||||
val amSatLinear: Set<Int> = emptySet()
|
||||
initialPosition: GeoPos = GeoPos(23.13, 113.26)
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val stationPosition = MutableStateFlow(initialPosition)
|
||||
@@ -110,13 +108,6 @@ class FakeSettingsRepo(
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
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 getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
|
||||
+13
-16
@@ -31,12 +31,10 @@ class MutualViewModelTest {
|
||||
private fun TestScope.createVm(
|
||||
satellites: List<OrbitalObject> = emptyList(),
|
||||
passes: List<OrbitalPass> = emptyList(),
|
||||
position: GeoPos = TestOrbits.GUANGZHOU,
|
||||
amSatFm: Set<Int> = emptySet(),
|
||||
amSatLinear: Set<Int> = emptySet()
|
||||
position: GeoPos = TestOrbits.GUANGZHOU
|
||||
): MutualViewModel = MutualViewModel(
|
||||
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
||||
settingsRepo = FakeSettingsRepo(position, amSatFm, amSatLinear),
|
||||
settingsRepo = FakeSettingsRepo(position),
|
||||
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
|
||||
)
|
||||
|
||||
@@ -343,15 +341,15 @@ class MutualViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
fun `hardcoded AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
assertTrue("fixture must produce windows", windows.isNotEmpty())
|
||||
// Only ISS (25544) is on the AMSAT live FM list; ISS_VARIANT (25545) is not.
|
||||
// ISS (25544) is on the hardcoded FM list (Sources.amSatFmCatnums);
|
||||
// ISS_VARIANT (25545) is not.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = windows,
|
||||
amSatFm = setOf(25544)
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
@@ -361,19 +359,18 @@ class MutualViewModelTest {
|
||||
val state = vm.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue(state.mutualPasses.isNotEmpty())
|
||||
assertTrue("only AMSAT-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
|
||||
assertTrue("only hardcoded FM-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT filter off for one type excludes its satellites`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
fun `turning FM off excludes FM-listed satellites under Linear filter`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
// FM filter off, Linear on: neither sat is linear-listed -> nothing matches.
|
||||
// ISS (25544) is on the hardcoded FM list but NOT on the hardcoded
|
||||
// Linear list; with FM off and Linear on, no satellite passes.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = windows,
|
||||
amSatFm = setOf(25544),
|
||||
amSatLinear = setOf(25545)
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
@@ -382,7 +379,7 @@ class MutualViewModelTest {
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue("only linear-listed satellites should appear", state.mutualPasses.all { it.catNum == 25545 })
|
||||
// 空结果会设置 "No mutual passes found" 提示, 这是正常行为而非筛选错误.
|
||||
assertTrue(state.mutualPasses.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "505"
|
||||
appVersionCode = "506"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.7-ba7opf.15.8"
|
||||
appVersionName = "4.4.7-ba7opf.15.9"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user