diff --git a/README.md b/README.md index 521f6c2..98cba3d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,26 @@ Installation sur un telephone connecte en USB (debogage USB active) : adb install -r app/build/outputs/apk/debug/app-debug.apk ``` +## Tests + +Les calculs (visee geostationnaire, analyse du ciel GNSS, encodage NTP, priorite des sources de +temps, mesure directionnelle 360°, azimuts de l'interface) sont couverts par des tests unitaires +JVM, sans emulateur : + +```bash +./gradlew test +./gradlew lint +``` + +Les essais terrain (plusieurs telephones, plusieurs regions, modems differents) restent a faire sur +appareils reels. + +## Langues + +Tous les textes affiches sont dans `app/src/main/res/values/strings.xml` (francais, langue par +defaut) et `app/src/main/res/values-en/strings.xml` (anglais). Ajouter une langue revient a copier +le fichier dans un dossier `values-xx`. + ## Permissions | Permission | Usage | diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ae80720..9fc2f3a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,44 @@ # Notes de version +## v1.1.0 — 30 aout 2026 + +Version de fiabilite : aucune fonction retiree, mais des erreurs enfin expliquees a l'ecran, des +textes traduisibles, une consommation reduite et des calculs couverts par des tests. + +APK : `dist/SatTemps-v1.1.0-debug.apk` (ou `./gradlew assembleDebug`). + +### Ameliorations + +**Messages d'erreur et etats** +- Localisation : service absent, permission refusee (avec acces direct aux reglages en cas de refus + definitif), GPS desactive, recherche de fix en cours, signal perdu avec l'age du dernier fix. +- Reseau mobile : telephonie indisponible, permission refusee, aucune cellule remontee par le modem, + echec de lecture. +- Heure : serveur NTP injoignable ou reponse invalide, indicateur de synchronisation en cours, + reessais espaces progressivement hors couverture. +- Boussole : capteur absent, echec d'enregistrement, besoin de calibrage. +- Mesure 360° : la couverture du tour est affichee et signalee comme insuffisante sous 70 %. + +**Langues** +- Tous les textes sont dans `res/values/strings.xml` (francais) et `res/values-en/strings.xml` + (anglais) ; nombres, dates et heures suivent la langue du telephone. + +**Batterie** +- Horloge a 40 ms ecran allume, 200 ms en economie d'energie, 1 s en arriere plan. +- Modem interroge chaque seconde pendant la mesure 360°, toutes les 5 s au repos ; calculs GNSS et + de visee ralentis hors premier plan. + +**Qualite du code** +- 46 tests unitaires JVM : visee geostationnaire, analyse du ciel GNSS, encodage NTP, priorite des + sources de temps et espacement des reessais, mesure directionnelle 360°, conversions d'azimut. +- Logique de calcul separee des API Android (`SkyAnalysis`, `NtpTimestamp`, `Azimuth`) et KDoc sur + les classes principales. + +### Limites inchangees + +Les limites de la v1.0.0 restent valables. Les essais terrain sur plusieurs telephones, plusieurs +regions et differents modems restent a faire sur materiel reel. + ## v1.0.0 — 30 aout 2026 Premiere version de **SatTemps** : heure exacte et meilleure orientation d'antenne diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bb9b688..98c6240 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,10 @@ android { packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" } + + testOptions { + unitTests.isReturnDefaultValues = true + } } dependencies { @@ -56,4 +60,8 @@ dependencies { implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlin:kotlin-test:1.9.24") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/app/src/main/java/com/mikael/sattemps/MainActivity.kt b/app/src/main/java/com/mikael/sattemps/MainActivity.kt index afddb56..7695952 100644 --- a/app/src/main/java/com/mikael/sattemps/MainActivity.kt +++ b/app/src/main/java/com/mikael/sattemps/MainActivity.kt @@ -1,8 +1,11 @@ package com.mikael.sattemps import android.Manifest +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Bundle +import android.provider.Settings import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts @@ -19,6 +22,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel @@ -31,6 +36,11 @@ private val REQUIRED_PERMISSIONS = arrayOf( Manifest.permission.READ_PHONE_STATE ) +/** + * Ecran unique de l'application : demande les permissions necessaires puis affiche + * [MainScreen]. Tant que la localisation precise est refusee, seule l'explication + * est visible car aucune mesure n'est possible. + */ class MainActivity : ComponentActivity() { private var viewModel: MainViewModel? = null @@ -42,9 +52,12 @@ class MainActivity : ComponentActivity() { viewModel?.onPermissionsGranted() } granted = hasLocationPermission() + permanentlyDenied = !granted && + !shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) } private var granted by mutableStateOf(false) + private var permanentlyDenied by mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -59,7 +72,11 @@ class MainActivity : ComponentActivity() { model.onPermissionsGranted() MainScreen(model) } else { - PermissionRequest { permissionLauncher.launch(REQUIRED_PERMISSIONS) } + PermissionRequest( + permanentlyDenied = permanentlyDenied, + onRequest = { permissionLauncher.launch(REQUIRED_PERMISSIONS) }, + onOpenSettings = ::openAppSettings + ) } } } @@ -78,22 +95,39 @@ class MainActivity : ComponentActivity() { super.onPause() } + private fun openAppSettings() { + startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null) + ) + ) + } + private fun hasLocationPermission(): Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED } @androidx.compose.runtime.Composable -private fun PermissionRequest(onRequest: () -> Unit) { +private fun PermissionRequest( + permanentlyDenied: Boolean, + onRequest: () -> Unit, + onOpenSettings: () -> Unit +) { Column( Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - "SatTemps a besoin de la localisation precise (GPS) et de l'etat du telephone " + - "pour lire les satellites, l'heure GNSS et les cellules 5G / LTE." - ) - Button(onClick = onRequest) { Text("Autoriser") } + Text(stringResource(R.string.permission_rationale), textAlign = TextAlign.Center) + if (permanentlyDenied) { + Text(stringResource(R.string.permission_denied_twice), textAlign = TextAlign.Center) + Button(onClick = onOpenSettings) { + Text(stringResource(R.string.permission_open_settings)) + } + } else { + Button(onClick = onRequest) { Text(stringResource(R.string.permission_allow)) } + } } } diff --git a/app/src/main/java/com/mikael/sattemps/MainViewModel.kt b/app/src/main/java/com/mikael/sattemps/MainViewModel.kt index 1416046..ecd5b50 100644 --- a/app/src/main/java/com/mikael/sattemps/MainViewModel.kt +++ b/app/src/main/java/com/mikael/sattemps/MainViewModel.kt @@ -1,6 +1,8 @@ package com.mikael.sattemps import android.app.Application +import android.content.Context +import android.os.PowerManager import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.mikael.sattemps.geo.DishPointing @@ -22,6 +24,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +/** + * Rassemble les sources de mesure (GNSS, boussole, modem, temps de reference) et + * cadence leur rafraichissement selon l'usage : plein regime quand l'ecran est + * visible, ralenti en arriere plan ou en economie de batterie. + */ class MainViewModel(application: Application) : AndroidViewModel(application) { private val gnss = GnssRepository(application) @@ -29,6 +36,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val cells = CellRepository(application, viewModelScope) private val timeSync = TimeSyncManager(viewModelScope) private val scanner = DirectionScanner() + private val powerManager = + application.getSystemService(Context.POWER_SERVICE) as? PowerManager val gnssState: StateFlow = gnss.state val compassState: StateFlow = compass.state @@ -46,39 +55,45 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val dishPointings: StateFlow> = _dishPointings.asStateFlow() private var permissionsGranted = false + private var foreground = false init { gnss.onGnssTimeOffset = timeSync::onGnssTimeOffset + gnss.onGnssTimeLost = timeSync::onGnssTimeLost viewModelScope.launch { while (isActive) { _nowMillis.value = timeSync.state.value.nowMillis() - delay(40L) + delay(clockIntervalMillis()) } } viewModelScope.launch { while (isActive) { + gnss.refreshStatus() val location = gnss.state.value.location if (location != null) { compass.updateDeclination( - location.latitude, location.longitude, location.altitude, System.currentTimeMillis() + location.latitude, location.longitude, location.altitude, + System.currentTimeMillis() ) _dishPointings.value = GeoSatellites.all(location.latitude, location.longitude, location.altitude) } - delay(5000L) + delay(if (foreground) STATUS_INTERVAL_MILLIS else STATUS_IDLE_INTERVAL_MILLIS) } } viewModelScope.launch { while (isActive) { - if (scanner.snapshot().running) { - val dbm = cells.state.value.serving?.dbm - if (dbm != null) scanner.addSample(compass.state.value.trueHeadingDeg, dbm) + val running = scanner.snapshot().running + if (running) { + cells.state.value.serving?.dbm?.let { + scanner.addSample(compass.state.value.trueHeadingDeg, it) + } _scanState.value = scanner.snapshot() } - delay(400L) + delay(if (running) SCAN_SAMPLE_INTERVAL_MILLIS else SCAN_IDLE_INTERVAL_MILLIS) } } } @@ -91,24 +106,58 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { start() } + /** Ecran visible : capteurs actifs et cadence nominale. */ fun start() { + foreground = true compass.start() } + /** Ecran masque : on relache la boussole, principale consommatrice de batterie. */ fun stop() { + foreground = false compass.stop() } fun forceTimeSync() = timeSync.forceNtpSync() + /** + * Demarre ou arrete la mesure directionnelle 360°, en accelerant la lecture du + * modem pendant la mesure uniquement. + */ fun toggleScan() { - if (scanner.snapshot().running) scanner.stop() else scanner.start() + if (scanner.snapshot().running) { + scanner.stop() + cells.refreshIntervalMillis = CellRepository.IDLE_INTERVAL_MILLIS + } else { + scanner.start() + cells.refreshIntervalMillis = CellRepository.SCAN_INTERVAL_MILLIS + } _scanState.value = scanner.snapshot() } + /** + * Cadence de l'horloge : 40 ms pour une lecture fluide a la milliseconde, ralentie + * en arriere plan ou lorsque le telephone est en economie d'energie. + */ + private fun clockIntervalMillis(): Long = when { + !foreground -> CLOCK_BACKGROUND_INTERVAL_MILLIS + powerManager?.isPowerSaveMode == true -> CLOCK_SAVER_INTERVAL_MILLIS + else -> CLOCK_INTERVAL_MILLIS + } + override fun onCleared() { gnss.stop() compass.stop() super.onCleared() } + + private companion object { + const val CLOCK_INTERVAL_MILLIS = 40L + const val CLOCK_SAVER_INTERVAL_MILLIS = 200L + const val CLOCK_BACKGROUND_INTERVAL_MILLIS = 1000L + const val STATUS_INTERVAL_MILLIS = 5000L + const val STATUS_IDLE_INTERVAL_MILLIS = 30_000L + const val SCAN_SAMPLE_INTERVAL_MILLIS = 400L + const val SCAN_IDLE_INTERVAL_MILLIS = 1000L + } } diff --git a/app/src/main/java/com/mikael/sattemps/geo/GeoSatellites.kt b/app/src/main/java/com/mikael/sattemps/geo/GeoSatellites.kt index d03d538..d34fee6 100644 --- a/app/src/main/java/com/mikael/sattemps/geo/GeoSatellites.kt +++ b/app/src/main/java/com/mikael/sattemps/geo/GeoSatellites.kt @@ -50,6 +50,10 @@ object GeoSatellites { GeoSatellite("Hispasat 30W (30.0W)", -30.0) ) + /** + * Azimut, elevation, inclinaison LNB et distance pour viser [satellite] depuis la + * position donnee. L'elevation est negative quand le satellite est sous l'horizon. + */ fun pointing(satellite: GeoSatellite, latDeg: Double, lonDeg: Double, altitudeM: Double = 0.0): DishPointing { val lat = Math.toRadians(latDeg) val lon = Math.toRadians(lonDeg) @@ -82,6 +86,7 @@ object GeoSatellites { .filter { it.visible } .maxByOrNull { it.elevationDeg } + /** Tous les satellites connus, du plus haut au plus bas sur l'horizon. */ fun all(latDeg: Double, lonDeg: Double, altitudeM: Double = 0.0): List = common.map { pointing(it, latDeg, lonDeg, altitudeM) } .sortedByDescending { it.elevationDeg } diff --git a/app/src/main/java/com/mikael/sattemps/gnss/GnssRepository.kt b/app/src/main/java/com/mikael/sattemps/gnss/GnssRepository.kt index c250d82..4f0e7bb 100644 --- a/app/src/main/java/com/mikael/sattemps/gnss/GnssRepository.kt +++ b/app/src/main/java/com/mikael/sattemps/gnss/GnssRepository.kt @@ -26,47 +26,51 @@ data class Satellite( val hasEphemeris: Boolean ) +/** Cause d'indisponibilite du recepteur, traduite dans l'interface. */ +enum class GnssIssue { + NO_SERVICE, + PERMISSION_DENIED, + RECEIVER_UNAVAILABLE, + LOCATION_DISABLED, + SEARCHING, + SIGNAL_LOST +} + data class GnssState( val location: Location? = null, val satellites: List = emptyList(), val satellitesUsed: Int = 0, - val hasFix: Boolean = false + val hasFix: Boolean = false, + /** Le service de localisation du telephone est actif. */ + val gpsEnabled: Boolean = true, + /** Le recepteur est demarre et l'application ecoute les mises a jour. */ + val listening: Boolean = false, + /** Age du dernier fix en secondes, `null` si aucun fix. */ + val fixAgeSeconds: Long? = null, + /** Probleme courant empechant une mesure fiable, `null` si tout va bien. */ + val issue: GnssIssue? = null ) { /** Satellite offrant le meilleur signal parmi ceux au dessus de l'horizon. */ - val bestSatellite: Satellite? - get() = satellites.filter { it.elevationDeg > 5f }.maxByOrNull { it.cn0DbHz } + val bestSatellite: Satellite? get() = SkyAnalysis.bestSatellite(satellites) - /** - * Direction ou le ciel est le mieux degage : moyenne vectorielle des azimuts - * des satellites recus, ponderee par la qualite du signal. - */ - val bestSkyAzimuthDeg: Float? - get() { - val visible = satellites.filter { it.cn0DbHz > 0f && it.elevationDeg > 5f } - if (visible.isEmpty()) return null - var x = 0.0 - var y = 0.0 - visible.forEach { sat -> - val weight = sat.cn0DbHz.toDouble() * Math.cos(Math.toRadians(sat.elevationDeg.toDouble())) - val rad = Math.toRadians(sat.azimuthDeg.toDouble()) - x += weight * Math.cos(rad) - y += weight * Math.sin(rad) - } - if (x == 0.0 && y == 0.0) return null - return ((Math.toDegrees(Math.atan2(y, x)) + 360.0) % 360.0).toFloat() - } + /** Direction ou le ciel est le mieux degage, ponderee par la qualite du signal. */ + val bestSkyAzimuthDeg: Float? get() = SkyAnalysis.bestSkyAzimuthDeg(satellites) - val meanElevationDeg: Float? - get() = satellites.filter { it.cn0DbHz > 0f }.map { it.elevationDeg }.average() - .takeIf { !it.isNaN() }?.toFloat() + val meanElevationDeg: Float? get() = SkyAnalysis.meanElevationDeg(satellites) } -/** Ecoute le GPS : position, temps satellite et etat de la constellation. */ +/** + * Ecoute le GPS : position, temps satellite et etat de la constellation. + * + * Toutes les entrees systeme sont protegees : un service de localisation desactive, + * une permission revoquee ou un fabricant qui refuse l'acces GNSS se traduisent par + * un message dans [state] plutot que par un plantage. + */ class GnssRepository(context: Context) { private val appContext = context.applicationContext private val locationManager = - appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager + appContext.getSystemService(Context.LOCATION_SERVICE) as? LocationManager private val handler = Handler(Looper.getMainLooper()) private val _state = MutableStateFlow(GnssState()) @@ -75,11 +79,19 @@ class GnssRepository(context: Context) { /** Emis a chaque fix : temps GNSS - horloge systeme, en millisecondes. */ var onGnssTimeOffset: ((Long) -> Unit)? = null + /** Emis lorsque le fix GNSS devient trop ancien pour servir de reference de temps. */ + var onGnssTimeLost: (() -> Unit)? = null + private val locationListener = LocationListener { location -> if (location.provider == LocationManager.GPS_PROVIDER) { computeTimeOffset(location)?.let { onGnssTimeOffset?.invoke(it) } } - _state.value = _state.value.copy(location = location, hasFix = true) + _state.value = _state.value.copy( + location = location, + hasFix = true, + fixAgeSeconds = 0L, + issue = null + ) } private val gnssCallback = object : GnssStatus.Callback() { @@ -102,29 +114,80 @@ class GnssRepository(context: Context) { } } + /** Demarre l'ecoute du recepteur ; sans effet si la localisation est indisponible. */ @SuppressLint("MissingPermission") fun start() { - runCatching { - locationManager.registerGnssStatusCallback(gnssCallback, handler) - locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)?.let { + val manager = locationManager + if (manager == null) { + _state.value = _state.value.copy(listening = false, issue = GnssIssue.NO_SERVICE) + return + } + + val gpsEnabled = runCatching { + manager.isProviderEnabled(LocationManager.GPS_PROVIDER) + }.getOrDefault(false) + + try { + manager.registerGnssStatusCallback(gnssCallback, handler) + manager.getLastKnownLocation(LocationManager.GPS_PROVIDER)?.let { _state.value = _state.value.copy(location = it) } - locationManager.requestLocationUpdates( + manager.requestLocationUpdates( LocationManager.GPS_PROVIDER, 1000L, 0f, locationListener, Looper.getMainLooper() ) - if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { - locationManager.requestLocationUpdates( - LocationManager.NETWORK_PROVIDER, 5000L, 0f, locationListener, Looper.getMainLooper() + if (manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + manager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, 5000L, 0f, locationListener, + Looper.getMainLooper() ) } + _state.value = _state.value.copy( + listening = true, + gpsEnabled = gpsEnabled, + issue = if (gpsEnabled) null else GnssIssue.LOCATION_DISABLED + ) + } catch (e: SecurityException) { + _state.value = _state.value.copy(listening = false, issue = GnssIssue.PERMISSION_DENIED) + } catch (e: IllegalArgumentException) { + _state.value = + _state.value.copy(listening = false, issue = GnssIssue.RECEIVER_UNAVAILABLE) } } fun stop() { + val manager = locationManager ?: return runCatching { - locationManager.removeUpdates(locationListener) - locationManager.unregisterGnssStatusCallback(gnssCallback) + manager.removeUpdates(locationListener) + manager.unregisterGnssStatusCallback(gnssCallback) } + _state.value = _state.value.copy(listening = false) + } + + /** + * Reevalue l'age du fix et l'etat du service de localisation. Appele + * periodiquement : un fix trop ancien ne peut plus servir de reference de temps. + */ + fun refreshStatus() { + val manager = locationManager ?: return + val gpsEnabled = runCatching { + manager.isProviderEnabled(LocationManager.GPS_PROVIDER) + }.getOrDefault(false) + + val ageSeconds = _state.value.location?.let { fixAgeMillis(it) / 1000L } + val stale = ageSeconds != null && ageSeconds > STALE_FIX_SECONDS + if (stale || !gpsEnabled) onGnssTimeLost?.invoke() + + _state.value = _state.value.copy( + gpsEnabled = gpsEnabled, + fixAgeSeconds = ageSeconds, + hasFix = _state.value.location != null && !stale, + issue = when { + !gpsEnabled -> GnssIssue.LOCATION_DISABLED + stale -> GnssIssue.SIGNAL_LOST + _state.value.location == null && _state.value.listening -> GnssIssue.SEARCHING + else -> null + } + ) } /** @@ -135,13 +198,15 @@ class GnssRepository(context: Context) { private fun computeTimeOffset(location: Location): Long? { val fixElapsedNanos = location.elapsedRealtimeNanos if (fixElapsedNanos <= 0L) return null - val nowElapsedMillis = SystemClock.elapsedRealtime() - val fixAgeMillis = nowElapsedMillis - fixElapsedNanos / 1_000_000L - if (fixAgeMillis > 10_000L) return null - val gnssNow = location.time + fixAgeMillis + val ageMillis = fixAgeMillis(location) + if (ageMillis > FRESH_FIX_MILLIS) return null + val gnssNow = location.time + ageMillis return gnssNow - System.currentTimeMillis() } + private fun fixAgeMillis(location: Location): Long = + SystemClock.elapsedRealtime() - location.elapsedRealtimeNanos / 1_000_000L + private fun constellationName(type: Int): String = when (type) { GnssStatus.CONSTELLATION_GPS -> "GPS" GnssStatus.CONSTELLATION_GLONASS -> "GLONASS" @@ -152,4 +217,12 @@ class GnssRepository(context: Context) { GnssStatus.CONSTELLATION_IRNSS -> "NavIC" else -> "Inconnu" } + + private companion object { + /** Au dela, le fix ne sert plus de reference de temps. */ + const val FRESH_FIX_MILLIS = 10_000L + + /** Au dela, on considere le signal GPS perdu. */ + const val STALE_FIX_SECONDS = 30L + } } diff --git a/app/src/main/java/com/mikael/sattemps/gnss/SkyAnalysis.kt b/app/src/main/java/com/mikael/sattemps/gnss/SkyAnalysis.kt new file mode 100644 index 0000000..4553eb4 --- /dev/null +++ b/app/src/main/java/com/mikael/sattemps/gnss/SkyAnalysis.kt @@ -0,0 +1,54 @@ +package com.mikael.sattemps.gnss + +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin + +/** + * Analyse geometrique de la constellation recue, independante des API Android afin + * d'etre couverte par des tests unitaires. + */ +internal object SkyAnalysis { + + /** Un satellite sous cette elevation est trop bas pour orienter une antenne. */ + const val MIN_USEFUL_ELEVATION_DEG = 5f + + /** Satellite offrant le meilleur signal parmi ceux suffisamment hauts. */ + fun bestSatellite(satellites: List): Satellite? = + satellites.filter { it.elevationDeg > MIN_USEFUL_ELEVATION_DEG }.maxByOrNull { it.cn0DbHz } + + /** + * Direction ou le ciel est le mieux degage : moyenne vectorielle des azimuts des + * satellites recus, ponderee par la qualite du signal et l'elevation (un satellite + * au zenith ne designe aucune direction utile). + * + * Retourne `null` si aucun satellite n'est exploitable ou si les directions + * s'annulent exactement. + */ + fun bestSkyAzimuthDeg(satellites: List): Float? { + val visible = satellites.filter { + it.cn0DbHz > 0f && it.elevationDeg > MIN_USEFUL_ELEVATION_DEG + } + if (visible.isEmpty()) return null + + var x = 0.0 + var y = 0.0 + visible.forEach { satellite -> + val weight = satellite.cn0DbHz.toDouble() * + cos(Math.toRadians(satellite.elevationDeg.toDouble())) + val rad = Math.toRadians(satellite.azimuthDeg.toDouble()) + x += weight * cos(rad) + y += weight * sin(rad) + } + if (x == 0.0 && y == 0.0) return null + return ((Math.toDegrees(atan2(y, x)) + 360.0) % 360.0).toFloat() + } + + /** Elevation moyenne des satellites effectivement recus. */ + fun meanElevationDeg(satellites: List): Float? = + satellites.filter { it.cn0DbHz > 0f } + .map { it.elevationDeg } + .takeIf { it.isNotEmpty() } + ?.average() + ?.toFloat() +} diff --git a/app/src/main/java/com/mikael/sattemps/network/CellRepository.kt b/app/src/main/java/com/mikael/sattemps/network/CellRepository.kt index 4e39796..62cfe48 100644 --- a/app/src/main/java/com/mikael/sattemps/network/CellRepository.kt +++ b/app/src/main/java/com/mikael/sattemps/network/CellRepository.kt @@ -31,12 +31,22 @@ data class CellReading( val bands: String? ) +/** Cause d'indisponibilite des informations radio, traduite dans l'interface. */ +enum class MobileIssue { + NO_TELEPHONY, + PERMISSION_DENIED, + NO_CELL_INFO, + READ_FAILED +} + data class MobileState( val operator: String = "", val networkType: String = "", val nrConnected: Boolean = false, val cells: List = emptyList(), - val error: String? = null + val issue: MobileIssue? = null, + /** Detail technique du dernier echec, utile au diagnostic. */ + val errorDetail: String? = null ) { val serving: CellReading? get() = cells.firstOrNull { it.registered } ?: cells.firstOrNull() } @@ -49,35 +59,54 @@ class CellRepository(context: Context, private val scope: CoroutineScope) { private val appContext = context.applicationContext private val telephony = - appContext.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + appContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager private val _state = MutableStateFlow(MobileState()) val state: StateFlow = _state.asStateFlow() + /** + * Periode de lecture demandee : courte pendant la mesure directionnelle, longue + * en veille pour epargner la batterie. + */ + @Volatile + var refreshIntervalMillis: Long = IDLE_INTERVAL_MILLIS + + private var started = false + fun start() { + if (started) return + started = true scope.launch { while (isActive) { refresh() - delay(2000L) + delay(refreshIntervalMillis) } } } @SuppressLint("MissingPermission") fun refresh() { + val manager = telephony + if (manager == null) { + _state.value = _state.value.copy(issue = MobileIssue.NO_TELEPHONY) + return + } try { - val cells = telephony.allCellInfo.orEmpty().mapNotNull { toReading(it) } + val cells = manager.allCellInfo.orEmpty().mapNotNull { toReading(it) } _state.value = MobileState( - operator = telephony.networkOperatorName.orEmpty(), + operator = manager.networkOperatorName.orEmpty(), networkType = networkTypeName(), nrConnected = cells.any { it.technology.startsWith("5G") && it.registered }, cells = cells.sortedByDescending { it.registered }, - error = null + issue = if (cells.isEmpty()) MobileIssue.NO_CELL_INFO else null ) } catch (e: SecurityException) { - _state.value = _state.value.copy(error = "Permissions telephonie / localisation requises") + _state.value = _state.value.copy(issue = MobileIssue.PERMISSION_DENIED) } catch (e: Exception) { - _state.value = _state.value.copy(error = e.message) + _state.value = _state.value.copy( + issue = MobileIssue.READ_FAILED, + errorDetail = e.message ?: e.javaClass.simpleName + ) } } @@ -143,7 +172,7 @@ class CellRepository(context: Context, private val scope: CoroutineScope) { @SuppressLint("MissingPermission") private fun networkTypeName(): String = try { - when (telephony.dataNetworkType) { + when (telephony?.dataNetworkType) { TelephonyManager.NETWORK_TYPE_NR -> "5G NR" TelephonyManager.NETWORK_TYPE_LTE -> "LTE" TelephonyManager.NETWORK_TYPE_HSPAP -> "HSPA+" @@ -156,4 +185,12 @@ class CellRepository(context: Context, private val scope: CoroutineScope) { } catch (e: SecurityException) { "Inconnu" } + + companion object { + /** Lecture rapide pendant la mesure 360°. */ + const val SCAN_INTERVAL_MILLIS = 1000L + + /** Lecture ralentie hors mesure. */ + const val IDLE_INTERVAL_MILLIS = 5000L + } } diff --git a/app/src/main/java/com/mikael/sattemps/network/DirectionScanner.kt b/app/src/main/java/com/mikael/sattemps/network/DirectionScanner.kt index 3fcc0fb..68fc0aa 100644 --- a/app/src/main/java/com/mikael/sattemps/network/DirectionScanner.kt +++ b/app/src/main/java/com/mikael/sattemps/network/DirectionScanner.kt @@ -15,14 +15,33 @@ data class ScanState( ) { /** Secteur ou le signal mobile est le plus fort : direction de l'antenne relais. */ val bestSector: SectorSample? get() = sectors.maxByOrNull { it.meanDbm } + + /** + * La mesure n'a de sens qu'une fois une large partie du tour couverte : en dessous, + * le meilleur secteur reflete surtout le point de depart. + */ + val reliable: Boolean get() = coveragePercent >= MIN_RELIABLE_COVERAGE_PERCENT + + companion object { + const val MIN_RELIABLE_COVERAGE_PERCENT = 70 + } } /** * Mesure la puissance du signal mobile secteur par secteur pendant que l'utilisateur * tourne sur lui meme, afin d'identifier la direction du relais le mieux capte. + * + * Le corps de l'utilisateur masque partiellement le signal : le maximum obtenu + * indique donc la direction du relais, sans triangulation. + * + * @param sectorCount nombre de secteurs decoupant le tour d'horizon, strictement positif. */ class DirectionScanner(private val sectorCount: Int = 24) { + init { + require(sectorCount > 0) { "sectorCount doit etre strictement positif" } + } + private val sums = DoubleArray(sectorCount) private val counts = IntArray(sectorCount) private val best = IntArray(sectorCount) { Int.MIN_VALUE } @@ -30,6 +49,7 @@ class DirectionScanner(private val sectorCount: Int = 24) { val sectorWidthDeg: Float get() = 360f / sectorCount + /** Demarre une nouvelle mesure et efface les secteurs deja acquis. */ fun start() { sums.fill(0.0) counts.fill(0) @@ -37,18 +57,21 @@ class DirectionScanner(private val sectorCount: Int = 24) { running = true } + /** Arrete la mesure en conservant les secteurs deja acquis. */ fun stop() { running = false } + /** Ajoute une mesure de puissance pour le cap courant ; ignoree hors mesure. */ fun addSample(headingDeg: Float, dbm: Int) { - if (!running) return + if (!running || headingDeg.isNaN()) return val index = (((headingDeg % 360f + 360f) % 360f) / sectorWidthDeg).toInt() % sectorCount sums[index] += dbm.toDouble() counts[index] += 1 if (dbm > best[index]) best[index] = dbm } + /** Etat immuable de la mesure, destine a l'interface. */ fun snapshot(): ScanState { val sectors = (0 until sectorCount).mapNotNull { i -> if (counts[i] == 0) null else SectorSample( diff --git a/app/src/main/java/com/mikael/sattemps/sensors/CompassRepository.kt b/app/src/main/java/com/mikael/sattemps/sensors/CompassRepository.kt index 105e170..bfe80d0 100644 --- a/app/src/main/java/com/mikael/sattemps/sensors/CompassRepository.kt +++ b/app/src/main/java/com/mikael/sattemps/sensors/CompassRepository.kt @@ -16,60 +16,103 @@ data class CompassState( /** Declinaison magnetique locale (a ajouter au cap magnetique). */ val declinationDeg: Float = 0f, val accuracy: Int = SensorManager.SENSOR_STATUS_UNRELIABLE, - val available: Boolean = true + val available: Boolean = true, + /** Au moins une mesure a ete recue du capteur. */ + val hasReading: Boolean = false ) { /** Cap par rapport au nord geographique (vrai). */ val trueHeadingDeg: Float get() = (magneticHeadingDeg + declinationDeg + 360f) % 360f + + /** Le capteur demande un etalonnage (mouvement en huit) avant d'etre fiable. */ + val needsCalibration: Boolean + get() = available && hasReading && + accuracy <= SensorManager.SENSOR_STATUS_ACCURACY_LOW } -/** Boussole basee sur le vecteur de rotation, lissee pour un affichage stable. */ +/** + * Boussole basee sur le vecteur de rotation, lissee pour un affichage stable. + * + * L'absence de capteur, un tableau de valeurs non standard ou un refus + * d'enregistrement sont traites sans faire tomber l'application : [state] indique + * alors que la boussole est indisponible. + */ class CompassRepository(context: Context) : SensorEventListener { private val sensorManager = - context.applicationContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager + context.applicationContext.getSystemService(Context.SENSOR_SERVICE) as? SensorManager private val rotationSensor: Sensor? = - sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) - ?: sensorManager.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR) + sensorManager?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) + ?: sensorManager?.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR) private val rotationMatrix = FloatArray(9) private val orientation = FloatArray(3) + private val rotationVector4 = FloatArray(4) + private val rotationVector3 = FloatArray(3) private var smoothedSin = 0.0 private var smoothedCos = 0.0 + private var registered = false private val _state = MutableStateFlow(CompassState(available = rotationSensor != null)) val state: StateFlow = _state.asStateFlow() fun start() { - rotationSensor?.let { - sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) - } + val manager = sensorManager ?: return + val sensor = rotationSensor ?: return + if (registered) return + registered = runCatching { + manager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_UI) + }.getOrDefault(false) + if (!registered) _state.value = _state.value.copy(available = false) } - fun stop() = sensorManager.unregisterListener(this) + fun stop() { + val manager = sensorManager ?: return + runCatching { manager.unregisterListener(this) } + registered = false + } /** Met a jour la declinaison magnetique a partir de la position courante. */ fun updateDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long) { - val field = GeomagneticField( - latitude.toFloat(), longitude.toFloat(), altitude.toFloat(), timeMillis - ) - _state.value = _state.value.copy(declinationDeg = field.declination) + val declination = runCatching { + GeomagneticField( + latitude.toFloat(), longitude.toFloat(), altitude.toFloat(), timeMillis + ).declination + }.getOrNull() ?: return + _state.value = _state.value.copy(declinationDeg = declination) } override fun onSensorChanged(event: SensorEvent) { - SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) - SensorManager.getOrientation(rotationMatrix, orientation) - val azimuthRad = orientation[0].toDouble() + // Certains constructeurs livrent plus de composantes que la norme : on ne + // garde que le quaternion attendu par getRotationMatrixFromVector. + val size = minOf(event.values.size, rotationVector4.size) + if (size < 3) return + val vector = if (size >= 4) rotationVector4 else rotationVector3 + for (i in vector.indices) vector[i] = event.values[i] - val alpha = 0.15 - smoothedSin = smoothedSin * (1 - alpha) + Math.sin(azimuthRad) * alpha - smoothedCos = smoothedCos * (1 - alpha) + Math.cos(azimuthRad) * alpha - val heading = ((Math.toDegrees(Math.atan2(smoothedSin, smoothedCos)) + 360.0) % 360.0).toFloat() + val computed = runCatching { + SensorManager.getRotationMatrixFromVector(rotationMatrix, vector) + SensorManager.getOrientation(rotationMatrix, orientation) + }.isSuccess + if (!computed) return - _state.value = _state.value.copy(magneticHeadingDeg = heading) + val azimuthRad = orientation[0].toDouble() + smoothedSin = smoothedSin * (1 - SMOOTHING) + Math.sin(azimuthRad) * SMOOTHING + smoothedCos = smoothedCos * (1 - SMOOTHING) + Math.cos(azimuthRad) * SMOOTHING + val heading = ((Math.toDegrees(Math.atan2(smoothedSin, smoothedCos)) + 360.0) % 360.0) + + _state.value = _state.value.copy( + magneticHeadingDeg = heading.toFloat(), + hasReading = true + ) } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { _state.value = _state.value.copy(accuracy = accuracy) } + + private companion object { + /** Filtre exponentiel : compromis entre stabilite et reactivite du cap. */ + const val SMOOTHING = 0.15 + } } diff --git a/app/src/main/java/com/mikael/sattemps/time/NtpTimestamp.kt b/app/src/main/java/com/mikael/sattemps/time/NtpTimestamp.kt new file mode 100644 index 0000000..5a49fe8 --- /dev/null +++ b/app/src/main/java/com/mikael/sattemps/time/NtpTimestamp.kt @@ -0,0 +1,45 @@ +package com.mikael.sattemps.time + +/** + * Encodage des horodatages NTP (RFC 5905) : 64 bits, 32 bits de secondes depuis 1900 + * suivis de 32 bits de fraction de seconde. + * + * Isole du client reseau pour pouvoir etre verifie par des tests unitaires. + */ +internal object NtpTimestamp { + + /** Secondes entre l'epoque NTP (1900-01-01) et l'epoque Unix (1970-01-01). */ + const val OFFSET_1900_TO_1970 = 2208988800L + + private const val FRACTION_SCALE = 0x100000000L + + /** Lit un horodatage NTP et le convertit en millisecondes UTC depuis l'epoque Unix. */ + fun read(buffer: ByteArray, offset: Int): Long { + val seconds = readUnsigned32(buffer, offset) + val fraction = readUnsigned32(buffer, offset + 4) + return (seconds - OFFSET_1900_TO_1970) * 1000L + (fraction * 1000L) / FRACTION_SCALE + } + + /** Ecrit un instant Unix (millisecondes) au format NTP dans le tampon. */ + fun write(buffer: ByteArray, offset: Int, timeMillis: Long) { + var seconds = timeMillis / 1000L + OFFSET_1900_TO_1970 + val milliseconds = timeMillis % 1000L + for (i in 3 downTo 0) { + buffer[offset + i] = (seconds and 0xFF).toByte() + seconds = seconds shr 8 + } + var fraction = milliseconds * FRACTION_SCALE / 1000L + for (i in 3 downTo 0) { + buffer[offset + 4 + i] = (fraction and 0xFF).toByte() + fraction = fraction shr 8 + } + } + + private fun readUnsigned32(buffer: ByteArray, offset: Int): Long { + var value = 0L + for (i in 0 until 4) { + value = (value shl 8) or (buffer[offset + i].toLong() and 0xFF) + } + return value + } +} diff --git a/app/src/main/java/com/mikael/sattemps/time/SntpClient.kt b/app/src/main/java/com/mikael/sattemps/time/SntpClient.kt index b9648cf..bfe7b37 100644 --- a/app/src/main/java/com/mikael/sattemps/time/SntpClient.kt +++ b/app/src/main/java/com/mikael/sattemps/time/SntpClient.kt @@ -14,8 +14,9 @@ object SntpClient { private const val NTP_PORT = 123 private const val NTP_PACKET_SIZE = 48 private const val NTP_MODE_CLIENT = 3 + private const val NTP_MODE_SERVER = 4 private const val NTP_VERSION = 3 - private const val OFFSET_1900_TO_1970 = 2208988800L + private const val MAX_STRATUM = 15 data class Result( /** Decalage a ajouter a System.currentTimeMillis() pour obtenir le temps NTP. */ @@ -26,7 +27,25 @@ object SntpClient { val elapsedRealtimeAtSync: Long ) - fun request(host: String, timeoutMillis: Int = 4000): Result? { + /** Nature de l'echec, traduite dans l'interface. */ + enum class FailureReason { UNREACHABLE, INVALID_RESPONSE } + + /** Resultat d'une interrogation : mesure exploitable ou motif d'echec. */ + sealed interface Outcome { + data class Success(val result: Result) : Outcome + data class Failure( + val server: String, + val reason: FailureReason, + /** Detail technique (message d'exception), utile au diagnostic. */ + val detail: String? = null + ) : Outcome + } + + /** + * Interroge [host] en conservant le motif d'echec, afin de pouvoir l'afficher a + * l'utilisateur (serveur injoignable, reponse invalide). + */ + fun query(host: String, timeoutMillis: Int = DEFAULT_TIMEOUT_MILLIS): Outcome { var socket: DatagramSocket? = null return try { val address = InetAddress.getByName(host) @@ -36,60 +55,63 @@ object SntpClient { val requestTime = System.currentTimeMillis() val requestTicks = SystemClock.elapsedRealtime() - writeTimestamp(buffer, 40, requestTime) + NtpTimestamp.write(buffer, TRANSMIT_OFFSET, requestTime) socket.send(DatagramPacket(buffer, buffer.size, address, NTP_PORT)) - val response = DatagramPacket(buffer, buffer.size) - socket.receive(response) - - val responseTicks = SystemClock.elapsedRealtime() - val responseTime = requestTime + (responseTicks - requestTicks) - - val originateTime = readTimestamp(buffer, 24) - val receiveTime = readTimestamp(buffer, 32) - val transmitTime = readTimestamp(buffer, 40) - - val roundTrip = (responseTicks - requestTicks) - (transmitTime - receiveTime) - val offset = ((receiveTime - originateTime) + (transmitTime - responseTime)) / 2 + socket.receive(DatagramPacket(buffer, buffer.size)) - Result( - offsetMillis = offset, - roundTripMillis = roundTrip.coerceAtLeast(0), + evaluate(buffer, host, requestTime, requestTicks, SystemClock.elapsedRealtime()) + } catch (e: Exception) { + Outcome.Failure( server = host, - elapsedRealtimeAtSync = responseTicks + reason = FailureReason.UNREACHABLE, + detail = e.message ?: e.javaClass.simpleName ) - } catch (e: Exception) { - null } finally { socket?.close() } } - private fun readTimestamp(buffer: ByteArray, offset: Int): Long { - val seconds = readUnsigned32(buffer, offset) - val fraction = readUnsigned32(buffer, offset + 4) - return (seconds - OFFSET_1900_TO_1970) * 1000L + (fraction * 1000L) / 0x100000000L - } + /** + * Valide la reponse NTP et en deduit le decalage d'horloge. + * + * Les temps monotones sont fournis par l'appelant, ce qui rend le calcul + * independant d'Android et donc testable : [requestTicks] et [responseTicks] + * encadrent l'echange reseau, [requestTime] est l'heure systeme au depart. + */ + internal fun evaluate( + buffer: ByteArray, + host: String, + requestTime: Long, + requestTicks: Long, + responseTicks: Long + ): Outcome { + val mode = buffer[0].toInt() and 0x7 + val stratum = buffer[1].toInt() and 0xFF + val originateTime = NtpTimestamp.read(buffer, ORIGINATE_OFFSET) + val receiveTime = NtpTimestamp.read(buffer, RECEIVE_OFFSET) + val transmitTime = NtpTimestamp.read(buffer, TRANSMIT_OFFSET) - private fun writeTimestamp(buffer: ByteArray, offset: Int, time: Long) { - var seconds = time / 1000L + OFFSET_1900_TO_1970 - val milliseconds = time % 1000L - for (i in 3 downTo 0) { - buffer[offset + i] = (seconds and 0xFF).toByte() - seconds = seconds shr 8 - } - var fraction = milliseconds * 0x100000000L / 1000L - for (i in 3 downTo 0) { - buffer[offset + 4 + i] = (fraction and 0xFF).toByte() - fraction = fraction shr 8 + if (mode != NTP_MODE_SERVER || stratum == 0 || stratum > MAX_STRATUM || transmitTime <= 0L) { + return Outcome.Failure(server = host, reason = FailureReason.INVALID_RESPONSE) } - } - private fun readUnsigned32(buffer: ByteArray, offset: Int): Long { - var value = 0L - for (i in 0 until 4) { - value = (value shl 8) or (buffer[offset + i].toLong() and 0xFF) - } - return value + val responseTime = requestTime + (responseTicks - requestTicks) + val roundTrip = (responseTicks - requestTicks) - (transmitTime - receiveTime) + val offset = ((receiveTime - originateTime) + (transmitTime - responseTime)) / 2 + + return Outcome.Success( + Result( + offsetMillis = offset, + roundTripMillis = roundTrip.coerceAtLeast(0), + server = host, + elapsedRealtimeAtSync = responseTicks + ) + ) } + + internal const val DEFAULT_TIMEOUT_MILLIS = 4000 + internal const val ORIGINATE_OFFSET = 24 + internal const val RECEIVE_OFFSET = 32 + internal const val TRANSMIT_OFFSET = 40 } diff --git a/app/src/main/java/com/mikael/sattemps/time/TimeSyncManager.kt b/app/src/main/java/com/mikael/sattemps/time/TimeSyncManager.kt index 622e496..4fe838b 100644 --- a/app/src/main/java/com/mikael/sattemps/time/TimeSyncManager.kt +++ b/app/src/main/java/com/mikael/sattemps/time/TimeSyncManager.kt @@ -7,9 +7,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** Origine du temps affiche, du plus precis au moins precis. */ enum class TimeSource { SYSTEME, NTP, GNSS } data class TimeSyncState( @@ -22,7 +24,11 @@ data class TimeSyncState( val ntpRoundTripMillis: Long? = null, val ntpServer: String? = null, val gnssOffsetMillis: Long? = null, - val lastSyncElapsedRealtime: Long? = null + val lastSyncElapsedRealtime: Long? = null, + /** Synchronisation NTP en cours : permet d'afficher un etat de chargement. */ + val syncing: Boolean = false, + /** Dernier echec de synchronisation NTP, `null` si tout va bien. */ + val ntpFailure: SntpClient.Outcome.Failure? = null ) { /** Instant de reference courant, en millisecondes UTC. */ fun nowMillis(): Long = System.currentTimeMillis() + offsetMillis @@ -31,9 +37,51 @@ data class TimeSyncState( get() = lastSyncElapsedRealtime?.let { (SystemClock.elapsedRealtime() - it) / 1000 } } +/** + * Choisit la meilleure source de temps disponible et l'incertitude associee. + * + * Fonction pure, sans dependance Android, afin d'etre couverte par des tests unitaires. + */ +internal fun resolveTimeSource(state: TimeSyncState): TimeSyncState = when { + state.gnssOffsetMillis != null -> state.copy( + source = TimeSource.GNSS, + offsetMillis = state.gnssOffsetMillis, + uncertaintyMillis = GNSS_UNCERTAINTY_MILLIS + ) + + state.ntpOffsetMillis != null -> state.copy( + source = TimeSource.NTP, + offsetMillis = state.ntpOffsetMillis, + uncertaintyMillis = ((state.ntpRoundTripMillis ?: 0L) / 2).coerceAtLeast(1L) + ) + + else -> state.copy( + source = TimeSource.SYSTEME, + offsetMillis = 0L, + uncertaintyMillis = SYSTEM_UNCERTAINTY_MILLIS + ) +} + +private const val GNSS_UNCERTAINTY_MILLIS = 50L +private const val SYSTEM_UNCERTAINTY_MILLIS = 1000L + +/** 5 min si tout va bien, puis 15 s doublees a chaque echec consecutif. */ +internal fun retryDelayMillis(consecutiveFailures: Int): Long = + if (consecutiveFailures <= 0) { + BASE_INTERVAL_MILLIS + } else { + 15_000L shl (consecutiveFailures - 1) + } + +private const val BASE_INTERVAL_MILLIS = 5 * 60 * 1000L +internal const val MAX_BACKOFF_STEPS = 4 + /** * Fournit un temps de reference precis : le temps GNSS (issu du fix satellite) est * prioritaire, sinon un serveur NTP, sinon l'horloge du telephone. + * + * En cas d'echec reseau, les tentatives sont espacees progressivement pour ne pas + * vider la batterie hors couverture. */ class TimeSyncManager(private val scope: CoroutineScope) { @@ -44,16 +92,18 @@ class TimeSyncManager(private val scope: CoroutineScope) { init { scope.launch { - while (true) { - syncNtp() - delay(5 * 60 * 1000L) + var failures = 0 + while (isActive) { + val ok = syncNtp() + failures = if (ok) 0 else (failures + 1).coerceAtMost(MAX_BACKOFF_STEPS) + delay(retryDelayMillis(failures)) } } } /** Decalage mesure a partir d'un fix GNSS (temps satellite - horloge systeme). */ fun onGnssTimeOffset(offsetMillis: Long) { - _state.value = recompute( + _state.value = resolveTimeSource( _state.value.copy( gnssOffsetMillis = offsetMillis, lastSyncElapsedRealtime = SystemClock.elapsedRealtime() @@ -61,37 +111,50 @@ class TimeSyncManager(private val scope: CoroutineScope) { ) } + /** Le fix GNSS est perdu ou trop ancien : on retombe sur NTP ou l'horloge systeme. */ + fun onGnssTimeLost() { + if (_state.value.gnssOffsetMillis == null) return + _state.value = resolveTimeSource(_state.value.copy(gnssOffsetMillis = null)) + } + fun forceNtpSync() { scope.launch { syncNtp() } } - private suspend fun syncNtp() { - val result = withContext(Dispatchers.IO) { - servers.firstNotNullOfOrNull { SntpClient.request(it) } - } ?: return - _state.value = recompute( + /** Interroge les serveurs NTP tour a tour ; retourne `true` des qu'une reponse est valide. */ + private suspend fun syncNtp(): Boolean { + if (_state.value.syncing) return false + _state.value = _state.value.copy(syncing = true) + val outcomes = withContext(Dispatchers.IO) { + val results = mutableListOf() + for (server in servers) { + val outcome = SntpClient.query(server) + results += outcome + if (outcome is SntpClient.Outcome.Success) break + } + results + } + val success = outcomes.filterIsInstance().firstOrNull() + + _state.value = if (success != null) { + val result = success.result + resolveTimeSource( + _state.value.copy( + ntpOffsetMillis = result.offsetMillis, + ntpRoundTripMillis = result.roundTripMillis, + ntpServer = result.server, + lastSyncElapsedRealtime = result.elapsedRealtimeAtSync, + syncing = false, + ntpFailure = null + ) + ) + } else { _state.value.copy( - ntpOffsetMillis = result.offsetMillis, - ntpRoundTripMillis = result.roundTripMillis, - ntpServer = result.server, - lastSyncElapsedRealtime = result.elapsedRealtimeAtSync + syncing = false, + ntpFailure = outcomes.filterIsInstance().lastOrNull() ) - ) + } + return success != null } - private fun recompute(state: TimeSyncState): TimeSyncState = when { - state.gnssOffsetMillis != null -> state.copy( - source = TimeSource.GNSS, - offsetMillis = state.gnssOffsetMillis, - uncertaintyMillis = 50L - ) - - state.ntpOffsetMillis != null -> state.copy( - source = TimeSource.NTP, - offsetMillis = state.ntpOffsetMillis, - uncertaintyMillis = ((state.ntpRoundTripMillis ?: 0L) / 2).coerceAtLeast(1L) - ) - - else -> state.copy(source = TimeSource.SYSTEME, offsetMillis = 0L, uncertaintyMillis = 1000L) - } } diff --git a/app/src/main/java/com/mikael/sattemps/ui/Azimuth.kt b/app/src/main/java/com/mikael/sattemps/ui/Azimuth.kt new file mode 100644 index 0000000..5a4d5d4 --- /dev/null +++ b/app/src/main/java/com/mikael/sattemps/ui/Azimuth.kt @@ -0,0 +1,34 @@ +package com.mikael.sattemps.ui + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Conversions d'azimut utilisees par l'interface, sans dependance Android ni + * ressource texte afin d'etre couvertes par des tests unitaires. + */ +internal object Azimuth { + + /** Nombre de secteurs cardinaux (N, NNE, NE, ...). */ + const val CARDINAL_COUNT = 16 + + private const val CARDINAL_WIDTH_DEG = 360.0 / CARDINAL_COUNT + + /** Ramene un azimut quelconque dans l'intervalle [0, 360[. */ + fun normalize(azimuthDeg: Double): Double = ((azimuthDeg % 360.0) + 360.0) % 360.0 + + /** Indice du secteur cardinal correspondant a l'azimut, de 0 (nord) a 15. */ + fun cardinalIndex(azimuthDeg: Double): Int = + ((normalize(azimuthDeg) + CARDINAL_WIDTH_DEG / 2) / CARDINAL_WIDTH_DEG).toInt() % CARDINAL_COUNT + + /** + * Rotation la plus courte pour passer du cap courant a la cible : positive vers la + * droite, negative vers la gauche, dans l'intervalle ]-180, 180]. + */ + fun turnDelta(currentDeg: Float, targetDeg: Float): Float = + ((targetDeg - currentDeg + 540f) % 360f) - 180f + + /** Vrai lorsque l'ecart avec la cible est inferieur au degre. */ + fun isAligned(currentDeg: Float, targetDeg: Float): Boolean = + abs(turnDelta(currentDeg, targetDeg)).roundToInt() == 0 +} diff --git a/app/src/main/java/com/mikael/sattemps/ui/MainScreen.kt b/app/src/main/java/com/mikael/sattemps/ui/MainScreen.kt index dbf615d..300e635 100644 --- a/app/src/main/java/com/mikael/sattemps/ui/MainScreen.kt +++ b/app/src/main/java/com/mikael/sattemps/ui/MainScreen.kt @@ -33,6 +33,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -40,11 +42,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.mikael.sattemps.MainViewModel +import com.mikael.sattemps.R import com.mikael.sattemps.geo.DishPointing +import com.mikael.sattemps.gnss.GnssIssue import com.mikael.sattemps.gnss.GnssState +import com.mikael.sattemps.network.MobileIssue import com.mikael.sattemps.network.MobileState import com.mikael.sattemps.network.ScanState import com.mikael.sattemps.sensors.CompassState +import com.mikael.sattemps.time.SntpClient import com.mikael.sattemps.time.TimeSyncState import java.time.Instant import java.time.ZoneId @@ -55,7 +61,6 @@ import kotlin.math.abs import kotlin.math.roundToInt private val TIME_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") -private val DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("EEEE d MMMM yyyy", Locale.FRENCH) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -69,10 +74,15 @@ fun MainScreen(viewModel: MainViewModel) { val dishes by viewModel.dishPointings.collectAsStateWithLifecycle() var tab by rememberSaveable { mutableIntStateOf(0) } - val titles = listOf("Heure", "Ciel GPS", "Parabole", "Reseau") + val titles = listOf( + stringResource(R.string.tab_time), + stringResource(R.string.tab_sky), + stringResource(R.string.tab_dish), + stringResource(R.string.tab_network) + ) Scaffold( - topBar = { TopAppBar(title = { Text("SatTemps — heure et orientation") }) } + topBar = { TopAppBar(title = { Text(stringResource(R.string.app_title)) }) } ) { padding -> Column(Modifier.padding(padding).fillMaxSize()) { TabRow(selectedTabIndex = tab) { @@ -99,12 +109,13 @@ private fun TimeTab( ) { val zoned = Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()) val utc = Instant.ofEpochMilli(nowMillis).atOffset(ZoneOffset.UTC) + val dateFormat = DateTimeFormatter.ofPattern("EEEE d MMMM yyyy", Locale.getDefault()) Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - SectionCard("Heure exacte") { + SectionCard(stringResource(R.string.section_exact_time)) { Text( text = zoned.format(TIME_FORMAT), fontSize = 56.sp, @@ -114,33 +125,84 @@ private fun TimeTab( modifier = Modifier.fillMaxWidth() ) Text( - text = zoned.format(DATE_FORMAT).replaceFirstChar { it.uppercase() }, + text = zoned.format(dateFormat).replaceFirstChar { it.uppercase() }, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) Spacer(Modifier.height(8.dp)) - InfoRow("UTC", utc.format(TIME_FORMAT)) - InfoRow("Fuseau", ZoneId.systemDefault().id) - InfoRow("Source", time.source.name) - InfoRow("Ecart horloge du telephone", "${time.offsetMillis} ms") - InfoRow("Incertitude", "± ${time.uncertaintyMillis} ms") - time.ntpServer?.let { InfoRow("Serveur NTP", "$it (aller-retour ${time.ntpRoundTripMillis} ms)") } - time.gnssOffsetMillis?.let { InfoRow("Ecart mesure par GPS", "$it ms") } - time.ageSeconds?.let { InfoRow("Derniere synchro", "il y a $it s") } + InfoRow(stringResource(R.string.label_utc), utc.format(TIME_FORMAT)) + InfoRow(stringResource(R.string.label_timezone), ZoneId.systemDefault().id) + InfoRow(stringResource(R.string.label_source), time.source.name) + InfoRow( + stringResource(R.string.label_clock_offset), + stringResource(R.string.value_millis, time.offsetMillis) + ) + InfoRow( + stringResource(R.string.label_uncertainty), + stringResource(R.string.value_uncertainty, time.uncertaintyMillis) + ) + time.ntpServer?.let { server -> + InfoRow( + stringResource(R.string.label_ntp_server), + stringResource( + R.string.value_ntp_server, server, time.ntpRoundTripMillis ?: 0L + ) + ) + } + time.gnssOffsetMillis?.let { + InfoRow( + stringResource(R.string.label_gps_offset), + stringResource(R.string.value_millis, it) + ) + } + time.ageSeconds?.let { + InfoRow( + stringResource(R.string.label_last_sync), + stringResource(R.string.value_seconds_ago, it) + ) + } + time.ntpFailure?.let { Text(stringResource(R.string.ntp_error, ntpFailureText(it))) } Spacer(Modifier.height(8.dp)) - OutlinedButton(onClick = onSync) { Text("Resynchroniser maintenant") } + OutlinedButton(onClick = onSync, enabled = !time.syncing) { + Text( + stringResource( + if (time.syncing) R.string.action_syncing else R.string.action_resync + ) + ) + } } - SectionCard("Position GPS") { + SectionCard(stringResource(R.string.section_gps_position)) { val location = gnss.location + gnss.issue?.let { Text(gnssIssueText(it, gnss.fixAgeSeconds)) } if (location == null) { - Text("Recherche de la position... sortez a ciel ouvert.") + if (gnss.issue == null) Text(stringResource(R.string.gnss_searching)) } else { - InfoRow("Latitude", String.format(Locale.FRENCH, "%.6f°", location.latitude)) - InfoRow("Longitude", String.format(Locale.FRENCH, "%.6f°", location.longitude)) - InfoRow("Altitude", String.format(Locale.FRENCH, "%.0f m", location.altitude)) - InfoRow("Precision", String.format(Locale.FRENCH, "%.1f m", location.accuracy)) - InfoRow("Satellites utilises", "${gnss.satellitesUsed} / ${gnss.satellites.size}") + InfoRow(stringResource(R.string.label_latitude), formatDegrees(location.latitude, 6)) + InfoRow( + stringResource(R.string.label_longitude), + formatDegrees(location.longitude, 6) + ) + InfoRow( + stringResource(R.string.label_altitude), + String.format(Locale.getDefault(), "%.0f m", location.altitude) + ) + InfoRow( + stringResource(R.string.label_accuracy), + String.format(Locale.getDefault(), "%.1f m", location.accuracy) + ) + InfoRow( + stringResource(R.string.label_satellites_used), + stringResource( + R.string.value_satellite_ratio, gnss.satellitesUsed, gnss.satellites.size + ) + ) + gnss.fixAgeSeconds?.let { + InfoRow( + stringResource(R.string.label_fix_age), + stringResource(R.string.value_seconds, it) + ) + } } } } @@ -159,36 +221,58 @@ private fun SkyTab(gnss: GnssState, compass: CompassState) { satellites = gnss.satellites, headingDeg = compass.trueHeadingDeg, targetAzimuthDeg = bestSky, - targetLabel = "Meilleur ciel" + targetLabel = stringResource(R.string.label_best_sky) ) - SectionCard("Meilleure orientation GPS") { - InfoRow("Cap du telephone", formatAzimuth(compass.trueHeadingDeg.toDouble())) + SectionCard(stringResource(R.string.section_best_gps_orientation)) { + CompassWarning(compass) + InfoRow( + stringResource(R.string.label_phone_heading), + formatAzimuth(compass.trueHeadingDeg.toDouble()) + ) if (bestSky != null) { - InfoRow("Meilleure direction (ciel degage)", formatAzimuth(bestSky.toDouble())) - InfoRow("Rotation a effectuer", formatTurn(compass.trueHeadingDeg, bestSky)) + InfoRow(stringResource(R.string.label_best_sky), formatAzimuth(bestSky.toDouble())) + InfoRow( + stringResource(R.string.label_turn), + formatTurn(compass.trueHeadingDeg, bestSky) + ) } else { - Text("Aucun satellite recu pour le moment.") + Text(stringResource(R.string.gnss_no_satellite)) } best?.let { InfoRow( - "Meilleur satellite", - "${it.constellation} #${it.svid} · ${it.cn0DbHz.roundToInt()} dB-Hz · " + - "az ${it.azimuthDeg.roundToInt()}° el ${it.elevationDeg.roundToInt()}°" + stringResource(R.string.label_best_satellite), + stringResource( + R.string.value_best_satellite, + it.constellation, + it.svid, + it.cn0DbHz.roundToInt(), + it.azimuthDeg.roundToInt(), + it.elevationDeg.roundToInt() + ) ) } - InfoRow("Declinaison magnetique", String.format(Locale.FRENCH, "%.1f°", compass.declinationDeg)) + InfoRow( + stringResource(R.string.label_declination), + formatDegrees(compass.declinationDeg.toDouble(), 1) + ) } - SectionCard("Satellites recus (${gnss.satellites.size})") { - gnss.satellites.take(20).forEach { satellite -> + SectionCard(stringResource(R.string.section_satellites, gnss.satellites.size)) { + val usedMark = stringResource(R.string.value_used_in_fix) + gnss.satellites.take(MAX_SATELLITE_ROWS).forEach { satellite -> InfoRow( - "${satellite.constellation} #${satellite.svid}${if (satellite.usedInFix) " ✓" else ""}", - "az ${satellite.azimuthDeg.roundToInt()}° · el ${satellite.elevationDeg.roundToInt()}° · " + - "${satellite.cn0DbHz.roundToInt()} dB-Hz" + "${satellite.constellation} #${satellite.svid}" + + if (satellite.usedInFix) " $usedMark" else "", + stringResource( + R.string.value_satellite_row, + satellite.azimuthDeg.roundToInt(), + satellite.elevationDeg.roundToInt(), + satellite.cn0DbHz.roundToInt() + ) ) } - if (gnss.satellites.isEmpty()) Text("En attente du recepteur GNSS...") + if (gnss.satellites.isEmpty()) Text(stringResource(R.string.gnss_waiting_receiver)) } } } @@ -202,7 +286,10 @@ private fun DishTab(dishes: List, compass: CompassState, gnss: Gns verticalArrangement = Arrangement.spacedBy(12.dp) ) { if (gnss.location == null) { - SectionCard("Parabole satellite") { Text("Position GPS requise pour calculer la visee.") } + SectionCard(stringResource(R.string.section_dish)) { + Text(stringResource(R.string.dish_needs_position)) + gnss.issue?.let { Text(gnssIssueText(it, gnss.fixAgeSeconds)) } + } return@Column } @@ -213,30 +300,49 @@ private fun DishTab(dishes: List, compass: CompassState, gnss: Gns targetLabel = best?.satellite?.name ) - SectionCard("Meilleure visee geostationnaire") { + SectionCard(stringResource(R.string.section_dish_best)) { if (best == null) { - Text("Aucun satellite geostationnaire visible depuis cette position.") + Text(stringResource(R.string.dish_none_visible)) } else { - InfoRow("Satellite", best.satellite.name) - InfoRow("Azimut (nord vrai)", formatAzimuth(best.azimuthDeg)) - InfoRow("Elevation", String.format(Locale.FRENCH, "%.1f°", best.elevationDeg)) - InfoRow("Inclinaison LNB", String.format(Locale.FRENCH, "%.1f°", best.lnbSkewDeg)) - InfoRow("Distance", String.format(Locale.FRENCH, "%.0f km", best.rangeKm)) - InfoRow("Rotation a effectuer", formatTurn(compass.trueHeadingDeg, best.azimuthDeg.toFloat())) + CompassWarning(compass) + InfoRow(stringResource(R.string.label_satellite), best.satellite.name) + InfoRow( + stringResource(R.string.label_azimuth_true), + formatAzimuth(best.azimuthDeg) + ) + InfoRow( + stringResource(R.string.label_elevation), + formatDegrees(best.elevationDeg, 1) + ) + InfoRow( + stringResource(R.string.label_lnb_skew), + formatDegrees(best.lnbSkewDeg, 1) + ) + InfoRow( + stringResource(R.string.label_distance), + String.format(Locale.getDefault(), "%.0f km", best.rangeKm) + ) + InfoRow( + stringResource(R.string.label_turn), + formatTurn(compass.trueHeadingDeg, best.azimuthDeg.toFloat()) + ) } } - SectionCard("Tous les satellites") { + SectionCard(stringResource(R.string.section_dish_all)) { + val belowHorizon = stringResource(R.string.value_below_horizon) dishes.forEach { pointing -> InfoRow( pointing.satellite.name, if (pointing.visible) { - String.format( - Locale.FRENCH, "az %.1f° · el %.1f° · LNB %.1f°", - pointing.azimuthDeg, pointing.elevationDeg, pointing.lnbSkewDeg + stringResource( + R.string.value_dish_row, + formatDegrees(pointing.azimuthDeg, 1), + formatDegrees(pointing.elevationDeg, 1), + formatDegrees(pointing.lnbSkewDeg, 1) ) } else { - "sous l'horizon" + belowHorizon } ) } @@ -251,66 +357,125 @@ private fun NetworkTab( scan: ScanState, onToggleScan: () -> Unit ) { + val dash = stringResource(R.string.value_dash) + LazyColumn( Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { item { - SectionCard("Reseau mobile") { - mobile.error?.let { Text(it) } - InfoRow("Operateur", mobile.operator.ifEmpty { "-" }) - InfoRow("Technologie", mobile.networkType) - InfoRow("5G NR", if (mobile.nrConnected) "connectee" else "non connectee") + SectionCard(stringResource(R.string.section_mobile)) { + mobile.issue?.let { Text(mobileIssueText(it, mobile.errorDetail)) } + InfoRow(stringResource(R.string.label_operator), mobile.operator.ifEmpty { dash }) + InfoRow( + stringResource(R.string.label_technology), + mobile.networkType.ifEmpty { dash } + ) + InfoRow( + stringResource(R.string.label_5g), + stringResource( + if (mobile.nrConnected) { + R.string.value_connected + } else { + R.string.value_not_connected + } + ) + ) val serving = mobile.serving if (serving != null) { HorizontalDivider(Modifier.padding(vertical = 6.dp)) - InfoRow("Cellule servante", serving.technology) - InfoRow("Puissance", serving.dbm?.let { "$it dBm" } ?: "-") - serving.quality?.let { InfoRow("Qualite (RSRQ)", "$it dB") } - serving.sinr?.let { InfoRow("SINR", "$it dB") } - serving.bands?.let { InfoRow("Bandes", it) } - InfoRow("Identite", serving.identity) + InfoRow(stringResource(R.string.label_serving_cell), serving.technology) + InfoRow( + stringResource(R.string.label_power), + serving.dbm?.let { stringResource(R.string.value_dbm, it) } ?: dash + ) + serving.quality?.let { + InfoRow( + stringResource(R.string.label_quality_rsrq), + stringResource(R.string.value_db, it) + ) + } + serving.sinr?.let { + InfoRow( + stringResource(R.string.label_sinr), + stringResource(R.string.value_db, it) + ) + } + serving.bands?.let { InfoRow(stringResource(R.string.label_bands), it) } + InfoRow(stringResource(R.string.label_identity), serving.identity) } } } item { - SectionCard("Orientation de l'antenne relais") { - Text( - "Lancez la mesure puis tournez lentement sur vous meme (360°) en gardant " + - "le telephone devant vous : l'application retient la direction ou le signal est le plus fort." - ) + SectionCard(stringResource(R.string.section_antenna_direction)) { + Text(stringResource(R.string.scan_help)) + CompassWarning(compass) Spacer(Modifier.height(8.dp)) - InfoRow("Cap actuel", formatAzimuth(compass.trueHeadingDeg.toDouble())) - InfoRow("Couverture de la mesure", "${scan.coveragePercent} %") + InfoRow( + stringResource(R.string.label_current_heading), + formatAzimuth(compass.trueHeadingDeg.toDouble()) + ) + InfoRow( + stringResource(R.string.label_scan_coverage), + "${scan.coveragePercent} %" + ) LinearProgressIndicator( progress = { scan.coveragePercent / 100f }, modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) ) scan.bestSector?.let { sector -> - InfoRow("Meilleure direction", formatAzimuth(sector.azimuthDeg.toDouble())) InfoRow( - "Signal dans ce secteur", - String.format(Locale.FRENCH, "%.1f dBm (max %d dBm)", sector.meanDbm, sector.bestDbm) + stringResource(R.string.label_best_sky), + formatAzimuth(sector.azimuthDeg.toDouble()) + ) + InfoRow( + stringResource(R.string.label_sector_signal), + stringResource( + R.string.value_sector_mean, + formatDecimal(sector.meanDbm.toDouble(), 1), + sector.bestDbm + ) + ) + InfoRow( + stringResource(R.string.label_turn), + formatTurn(compass.trueHeadingDeg, sector.azimuthDeg) + ) + } + if (scan.running && !scan.reliable) { + Text( + stringResource( + R.string.scan_need_coverage, + ScanState.MIN_RELIABLE_COVERAGE_PERCENT + ) ) - InfoRow("Rotation a effectuer", formatTurn(compass.trueHeadingDeg, sector.azimuthDeg)) } Spacer(Modifier.height(8.dp)) Button(onClick = onToggleScan) { - Text(if (scan.running) "Arreter la mesure" else "Demarrer la mesure 360°") + Text( + stringResource( + if (scan.running) { + R.string.action_stop_scan + } else { + R.string.action_start_scan + } + ) + ) } } } if (scan.sectors.isNotEmpty()) { item { - SectionCard("Signal par secteur") { + SectionCard(stringResource(R.string.section_sector_signal)) { scan.sectors.sortedByDescending { it.meanDbm }.forEach { sector -> InfoRow( formatAzimuth(sector.azimuthDeg.toDouble()), - String.format( - Locale.FRENCH, "%.1f dBm · %d mesures", sector.meanDbm, sector.sampleCount + stringResource( + R.string.value_sector_samples, + formatDecimal(sector.meanDbm.toDouble(), 1), + sector.sampleCount ) ) } @@ -319,17 +484,43 @@ private fun NetworkTab( } items(mobile.cells) { cell -> - SectionCard(cell.technology + if (cell.registered) " (servante)" else " (voisine)") { - InfoRow("Puissance", cell.dbm?.let { "$it dBm" } ?: "-") - cell.quality?.let { InfoRow("Qualite", "$it dB") } - cell.sinr?.let { InfoRow("SINR", "$it dB") } - cell.bands?.let { InfoRow("Bandes", it) } - InfoRow("Identite", cell.identity) + val title = stringResource( + if (cell.registered) R.string.cell_serving else R.string.cell_neighbour, + cell.technology + ) + SectionCard(title) { + InfoRow( + stringResource(R.string.label_power), + cell.dbm?.let { stringResource(R.string.value_dbm, it) } ?: dash + ) + cell.quality?.let { + InfoRow( + stringResource(R.string.label_quality), + stringResource(R.string.value_db, it) + ) + } + cell.sinr?.let { + InfoRow( + stringResource(R.string.label_sinr), + stringResource(R.string.value_db, it) + ) + } + cell.bands?.let { InfoRow(stringResource(R.string.label_bands), it) } + InfoRow(stringResource(R.string.label_identity), cell.identity) } } } } +/** Avertit lorsque la boussole est absente ou demande un etalonnage. */ +@Composable +private fun CompassWarning(compass: CompassState) { + when { + !compass.available -> Text(stringResource(R.string.compass_unavailable)) + compass.needsCalibration -> Text(stringResource(R.string.compass_calibration)) + } +} + @Composable private fun SectionCard(title: String, content: @Composable () -> Unit) { Card( @@ -362,17 +553,61 @@ private fun InfoRow(label: String, value: String) { } } +@Composable +private fun gnssIssueText(issue: GnssIssue, fixAgeSeconds: Long?): String = when (issue) { + GnssIssue.NO_SERVICE -> stringResource(R.string.gnss_no_service) + GnssIssue.PERMISSION_DENIED -> stringResource(R.string.gnss_permission_denied) + GnssIssue.RECEIVER_UNAVAILABLE -> stringResource(R.string.gnss_receiver_unavailable) + GnssIssue.LOCATION_DISABLED -> stringResource(R.string.gnss_location_disabled) + GnssIssue.SEARCHING -> stringResource(R.string.gnss_searching) + GnssIssue.SIGNAL_LOST -> stringResource(R.string.gnss_signal_lost, fixAgeSeconds ?: 0L) +} + +@Composable +private fun mobileIssueText(issue: MobileIssue, detail: String?): String = when (issue) { + MobileIssue.NO_TELEPHONY -> stringResource(R.string.mobile_no_telephony) + MobileIssue.PERMISSION_DENIED -> stringResource(R.string.mobile_permission_denied) + MobileIssue.NO_CELL_INFO -> stringResource(R.string.mobile_no_cell_info) + MobileIssue.READ_FAILED -> stringResource( + R.string.mobile_read_failed, + detail ?: stringResource(R.string.value_dash) + ) +} + +@Composable +private fun ntpFailureText(failure: SntpClient.Outcome.Failure): String = when (failure.reason) { + SntpClient.FailureReason.UNREACHABLE -> + stringResource(R.string.ntp_unreachable, failure.server) + + SntpClient.FailureReason.INVALID_RESPONSE -> + stringResource(R.string.ntp_invalid_response, failure.server) +} + +@Composable private fun formatAzimuth(azimuthDeg: Double): String { - val normalized = ((azimuthDeg % 360) + 360) % 360 - val cardinals = listOf("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSO", "SO", "OSO", "O", "ONO", "NO", "NNO") - val cardinal = cardinals[((normalized + 11.25) / 22.5).toInt() % 16] - return String.format(Locale.FRENCH, "%.1f° (%s)", normalized, cardinal) + val cardinals = stringArrayResource(R.array.cardinal_points) + val normalized = Azimuth.normalize(azimuthDeg) + return stringResource( + R.string.value_azimuth, + formatDegrees(normalized, 1), + cardinals[Azimuth.cardinalIndex(normalized)] + ) } +@Composable private fun formatTurn(currentDeg: Float, targetDeg: Float): String { - var delta = ((targetDeg - currentDeg + 540f) % 360f) - 180f - if (abs(delta) < 1f) return "aligne" - val direction = if (delta > 0) "vers la droite" else "vers la gauche" - delta = abs(delta) - return String.format(Locale.FRENCH, "%.0f° %s", delta, direction) + if (Azimuth.isAligned(currentDeg, targetDeg)) return stringResource(R.string.value_aligned) + val delta = Azimuth.turnDelta(currentDeg, targetDeg) + return stringResource( + if (delta > 0) R.string.value_turn_right else R.string.value_turn_left, + abs(delta).roundToInt() + ) } + +private fun formatDegrees(value: Double, decimals: Int): String = + formatDecimal(value, decimals) + "°" + +private fun formatDecimal(value: Double, decimals: Int): String = + String.format(Locale.getDefault(), "%.${decimals}f", value) + +private const val MAX_SATELLITE_ROWS = 20 diff --git a/app/src/main/java/com/mikael/sattemps/ui/SkyView.kt b/app/src/main/java/com/mikael/sattemps/ui/SkyView.kt index 273b7ec..75808ec 100644 --- a/app/src/main/java/com/mikael/sattemps/ui/SkyView.kt +++ b/app/src/main/java/com/mikael/sattemps/ui/SkyView.kt @@ -13,7 +13,9 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.unit.dp +import com.mikael.sattemps.R import com.mikael.sattemps.gnss.Satellite import kotlin.math.cos import kotlin.math.sin @@ -21,6 +23,10 @@ import kotlin.math.sin /** * Vue du ciel : projection polaire des satellites GNSS (zenith au centre, horizon au * bord), orientee selon le cap du telephone, avec une aiguille vers la direction cible. + * + * @param headingDeg cap du telephone par rapport au nord vrai. + * @param targetAzimuthDeg direction a viser, `null` si aucune cible connue. + * @param targetLabel etiquette dessinee au bout de l'aiguille. */ @Composable fun SkyView( @@ -30,6 +36,9 @@ fun SkyView( targetLabel: String?, modifier: Modifier = Modifier ) { + val cardinals = stringArrayResource(R.array.cardinal_points) + val quadrants = listOf(cardinals[0], cardinals[4], cardinals[8], cardinals[12]) + Box(modifier.fillMaxWidth().aspectRatio(1f)) { Canvas(Modifier.fillMaxWidth().aspectRatio(1f)) { val center = Offset(size.width / 2f, size.height / 2f) @@ -46,7 +55,7 @@ fun SkyView( // Le ciel tourne a l'inverse du telephone pour rester cale sur le nord vrai. rotate(degrees = -headingDeg, pivot = center) { - drawCardinals(center, radius) + drawCardinals(center, radius, quadrants) satellites.forEach { satellite -> if (satellite.elevationDeg < 0f) return@forEach @@ -86,15 +95,15 @@ fun SkyView( } } -private fun DrawScope.drawCardinals(center: Offset, radius: Float) { +private fun DrawScope.drawCardinals(center: Offset, radius: Float, labels: List) { val paint = android.graphics.Paint().apply { color = android.graphics.Color.WHITE textSize = 34f textAlign = android.graphics.Paint.Align.CENTER isAntiAlias = true } - listOf("N" to 0.0, "E" to 90.0, "S" to 180.0, "O" to 270.0).forEach { (label, azimuth) -> - val rad = Math.toRadians(azimuth) + labels.forEachIndexed { index, label -> + val rad = Math.toRadians(index * 90.0) val x = center.x + ((radius + 30f) * sin(rad)).toFloat() val y = center.y - ((radius + 30f) * cos(rad)).toFloat() + 12f drawContext.canvas.nativeCanvas.drawText(label, x, y, paint) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..5f5b748 --- /dev/null +++ b/app/src/main/res/values-en/strings.xml @@ -0,0 +1,130 @@ + + SatTemps — time and orientation + + Time + GPS sky + Dish + Network + + SatTemps needs precise location (GPS) and phone state to read satellites, GNSS time and 5G / LTE cells. + Allow + Open settings + Permission denied: allow precise location in the app settings. + + Exact time + GPS position + Best GPS orientation + Received satellites (%1$d) + Best geostationary aim + All satellites + Satellite dish + Mobile network + Cell tower direction + Signal per sector + + UTC + Time zone + Source + Phone clock offset + Uncertainty + NTP server + Offset measured by GPS + Last sync + Resync now + Syncing… + + Latitude + Longitude + Altitude + Accuracy + Satellites used + Fix age + + Phone heading + Best direction (clear sky) + Turn + Best satellite + Magnetic declination + + Satellite + Azimuth (true north) + Elevation + LNB skew + Distance + + Operator + Technology + 5G NR + Serving cell + Power + Quality (RSRQ) + Quality + SINR + Bands + Identity + Current heading + Scan coverage + Signal in this sector + + connected + not connected + %1$d s ago + %1$s (round trip %2$d ms) + az %1$d° · el %2$d° · %3$d dB-Hz + %1$s dBm (max %2$d dBm) + %1$s dBm · %2$d samples + aligned + %1$d° to the right + %1$d° to the left + below horizon + + %1$s (serving) + %1$s (neighbour) + + Start 360° scan + Stop scan + Start the scan then turn slowly through 360° holding the phone in front of you: the app keeps the direction where the signal is strongest. + Keep turning: the direction settles beyond %1$d %% coverage. + + This phone exposes no location service. + Precise location permission denied. + GPS receiver unavailable on this device. + Location turned off in the phone settings. + Searching for position: move to an open sky area. + GPS signal lost: last position %1$d s ago. + No satellite received yet. + Waiting for the GNSS receiver… + + This phone exposes no radio information. + Telephony and location permissions required. + No cell reported by the modem (some vendors hide them). + Radio read failed: %1$s + + No rotation sensor: orientation is unavailable. + Compass needs calibration: move the phone in a figure eight. + + NTP sync unavailable: %1$s + %1$s unreachable + invalid response from %1$s + GPS position required to compute the aim. + No geostationary satellite visible from this position. + + + N + NNE + NE + ENE + E + ESE + SE + SSE + S + SSW + SW + WSW + W + WNW + NW + NNW + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19e165f..a6f7f91 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,142 @@ - - SatTemps + SatTemps + SatTemps — heure et orientation + + Heure + Ciel GPS + Parabole + Reseau + + SatTemps a besoin de la localisation precise (GPS) et de l\'etat du telephone pour lire les satellites, l\'heure GNSS et les cellules 5G / LTE. + Autoriser + Ouvrir les reglages + Permission refusee : autorisez la localisation precise dans les reglages de l\'application. + + Heure exacte + Position GPS + Meilleure orientation GPS + Satellites recus (%1$d) + Meilleure visee geostationnaire + Tous les satellites + Parabole satellite + Reseau mobile + Orientation de l\'antenne relais + Signal par secteur + + UTC + Fuseau + Source + Ecart horloge du telephone + Incertitude + Serveur NTP + Ecart mesure par GPS + Derniere synchro + Resynchroniser maintenant + Synchronisation… + + Latitude + Longitude + Altitude + Precision + Satellites utilises + Age du fix + + Cap du telephone + Meilleure direction (ciel degage) + Rotation a effectuer + Meilleur satellite + Declinaison magnetique + + Satellite + Azimut (nord vrai) + Elevation + Inclinaison LNB + Distance + + Operateur + Technologie + 5G NR + Cellule servante + Puissance + Qualite (RSRQ) + Qualite + SINR + Bandes + Identite + Cap actuel + Couverture de la mesure + Signal dans ce secteur + + connectee + non connectee + - + %1$d ms + ± %1$d ms + %1$s (aller-retour %2$d ms) + il y a %1$d s + %1$d s + %1$d dBm + %1$d dB + %1$s (%2$s) + %1$d / %2$d + %1$s #%2$d · %3$d dB-Hz · az %4$d° el %5$d° + az %1$d° · el %2$d° · %3$d dB-Hz + az %1$s · el %2$s · LNB %3$s + %1$s dBm (max %2$d dBm) + %1$s dBm · %2$d mesures + aligne + %1$d° vers la droite + %1$d° vers la gauche + sous l\'horizon + + + %1$s (servante) + %1$s (voisine) + + Demarrer la mesure 360° + Arreter la mesure + Lancez la mesure puis tournez lentement sur vous meme (360°) en gardant le telephone devant vous : l\'application retient la direction ou le signal est le plus fort. + Continuez a tourner : la direction se stabilise au dela de %1$d %% de couverture. + + Ce telephone n\'expose pas de service de localisation. + Permission de localisation precise refusee. + Recepteur GPS indisponible sur cet appareil. + Localisation desactivee dans les reglages du telephone. + Recherche de la position : placez-vous a ciel ouvert. + Signal GPS perdu : derniere position il y a %1$d s. + Aucun satellite recu pour le moment. + En attente du recepteur GNSS… + + Ce telephone n\'expose pas d\'informations radio. + Permissions telephonie et localisation requises. + Aucune cellule remontee par le modem (certains constructeurs les masquent). + Lecture radio impossible : %1$s + + Aucun capteur de rotation : l\'orientation n\'est pas disponible. + Boussole a etalonner : decrivez un 8 avec le telephone. + + Synchronisation NTP indisponible : %1$s + %1$s injoignable + reponse invalide de %1$s + Position GPS requise pour calculer la visee. + Aucun satellite geostationnaire visible depuis cette position. + + + N + NNE + NE + ENE + E + ESE + SE + SSE + S + SSO + SO + OSO + O + ONO + NO + NNO + diff --git a/app/src/test/java/com/mikael/sattemps/geo/GeoSatellitesTest.kt b/app/src/test/java/com/mikael/sattemps/geo/GeoSatellitesTest.kt new file mode 100644 index 0000000..1534363 --- /dev/null +++ b/app/src/test/java/com/mikael/sattemps/geo/GeoSatellitesTest.kt @@ -0,0 +1,88 @@ +package com.mikael.sattemps.geo + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +private const val PARIS_LAT = 48.8566 +private const val PARIS_LON = 2.3522 + +class GeoSatellitesTest { + + @Test + fun `visee d'Astra 19_2E depuis Paris pointe vers le sud-est`() { + val pointing = GeoSatellites.pointing(GeoSatellite("Astra", 19.2), PARIS_LAT, PARIS_LON) + + assertTrue(pointing.azimuthDeg in 150.0..165.0, "azimut ${pointing.azimuthDeg}") + assertTrue(pointing.elevationDeg in 28.0..35.0, "elevation ${pointing.elevationDeg}") + assertTrue(pointing.rangeKm in 37_000.0..40_000.0, "distance ${pointing.rangeKm}") + assertTrue(pointing.visible) + } + + @Test + fun `un satellite a l'oppose du globe est sous l'horizon`() { + val pointing = GeoSatellites.pointing(GeoSatellite("Test", -170.0), PARIS_LAT, PARIS_LON) + + assertTrue(pointing.elevationDeg < 0.0, "elevation ${pointing.elevationDeg}") + assertFalse(pointing.visible) + } + + @Test + fun `un satellite a la meme longitude est plein sud depuis l'hemisphere nord`() { + val pointing = GeoSatellites.pointing(GeoSatellite("Test", PARIS_LON), PARIS_LAT, PARIS_LON) + + assertEquals(180.0, pointing.azimuthDeg, 0.001) + assertEquals(0.0, pointing.lnbSkewDeg, 0.001) + } + + @Test + fun `l'inclinaison LNB change de signe de part et d'autre du meridien local`() { + val east = GeoSatellites.pointing(GeoSatellite("Est", 30.0), PARIS_LAT, PARIS_LON) + val west = GeoSatellites.pointing(GeoSatellite("Ouest", -30.0), PARIS_LAT, PARIS_LON) + + assertTrue(east.lnbSkewDeg > 0.0, "est ${east.lnbSkewDeg}") + assertTrue(west.lnbSkewDeg < 0.0, "ouest ${west.lnbSkewDeg}") + } + + @Test + fun `depuis l'equateur le satellite de meme longitude est au zenith`() { + val pointing = GeoSatellites.pointing(GeoSatellite("Test", 0.0), 0.0, 0.0) + + assertEquals(90.0, pointing.elevationDeg, 0.001) + } + + @Test + fun `best retourne le satellite visible le plus haut`() { + val best = assertNotNull(GeoSatellites.best(PARIS_LAT, PARIS_LON)) + val all = GeoSatellites.all(PARIS_LAT, PARIS_LON) + + assertTrue(best.visible) + assertEquals(all.first { it.visible }.satellite.name, best.satellite.name) + } + + @Test + fun `all trie les satellites par elevation decroissante`() { + val elevations = GeoSatellites.all(PARIS_LAT, PARIS_LON).map { it.elevationDeg } + + assertEquals(GeoSatellites.common.size, elevations.size) + assertEquals(elevations.sortedDescending(), elevations) + } + + @Test + fun `l'altitude reduit legerement la distance oblique`() { + val sol = GeoSatellites.pointing(GeoSatellite("Astra", 19.2), PARIS_LAT, PARIS_LON, 0.0) + val montagne = + GeoSatellites.pointing(GeoSatellite("Astra", 19.2), PARIS_LAT, PARIS_LON, 3000.0) + + assertTrue(montagne.rangeKm < sol.rangeKm) + assertEquals(sol.azimuthDeg, montagne.azimuthDeg, 0.001) + } + + @Test + fun `l'apex de l'arc de Clarke depend de l'hemisphere`() { + assertEquals(180.0, GeoSatellites.clarkeArcApexAzimuth(PARIS_LAT)) + assertEquals(0.0, GeoSatellites.clarkeArcApexAzimuth(-33.9)) + } +} diff --git a/app/src/test/java/com/mikael/sattemps/gnss/SkyAnalysisTest.kt b/app/src/test/java/com/mikael/sattemps/gnss/SkyAnalysisTest.kt new file mode 100644 index 0000000..06ee0d5 --- /dev/null +++ b/app/src/test/java/com/mikael/sattemps/gnss/SkyAnalysisTest.kt @@ -0,0 +1,134 @@ +package com.mikael.sattemps.gnss + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +private fun satellite( + azimuthDeg: Float, + elevationDeg: Float, + cn0DbHz: Float, + usedInFix: Boolean = true, + svid: Int = 1 +) = Satellite( + constellation = "GPS", + svid = svid, + azimuthDeg = azimuthDeg, + elevationDeg = elevationDeg, + cn0DbHz = cn0DbHz, + usedInFix = usedInFix, + hasEphemeris = true +) + +class SkyAnalysisTest { + + @Test + fun `aucun satellite ne donne aucune direction`() { + assertNull(SkyAnalysis.bestSatellite(emptyList())) + assertNull(SkyAnalysis.bestSkyAzimuthDeg(emptyList())) + assertNull(SkyAnalysis.meanElevationDeg(emptyList())) + } + + @Test + fun `le meilleur satellite est le plus fort au dessus de l'elevation minimale`() { + val best = SkyAnalysis.bestSatellite( + listOf( + satellite(azimuthDeg = 10f, elevationDeg = 2f, cn0DbHz = 48f, svid = 1), + satellite(azimuthDeg = 90f, elevationDeg = 40f, cn0DbHz = 35f, svid = 2), + satellite(azimuthDeg = 200f, elevationDeg = 20f, cn0DbHz = 30f, svid = 3) + ) + ) + + assertEquals(2, assertNotNull(best).svid) + } + + @Test + fun `les satellites trop bas sont ignores pour la direction`() { + val satellites = listOf(satellite(azimuthDeg = 90f, elevationDeg = 1f, cn0DbHz = 45f)) + + assertNull(SkyAnalysis.bestSatellite(satellites)) + assertNull(SkyAnalysis.bestSkyAzimuthDeg(satellites)) + } + + @Test + fun `un satellite sans signal est ignore pour la direction`() { + val satellites = listOf(satellite(azimuthDeg = 90f, elevationDeg = 30f, cn0DbHz = 0f)) + + assertNull(SkyAnalysis.bestSkyAzimuthDeg(satellites)) + } + + @Test + fun `la direction est la moyenne vectorielle ponderee des azimuts`() { + val azimuth = SkyAnalysis.bestSkyAzimuthDeg( + listOf( + satellite(azimuthDeg = 80f, elevationDeg = 30f, cn0DbHz = 40f, svid = 1), + satellite(azimuthDeg = 100f, elevationDeg = 30f, cn0DbHz = 40f, svid = 2) + ) + ) + + assertEquals(90f, assertNotNull(azimuth), 0.01f) + } + + @Test + fun `la moyenne d'azimuts autour du nord ne passe pas par le sud`() { + val azimuth = SkyAnalysis.bestSkyAzimuthDeg( + listOf( + satellite(azimuthDeg = 350f, elevationDeg = 30f, cn0DbHz = 40f, svid = 1), + satellite(azimuthDeg = 10f, elevationDeg = 30f, cn0DbHz = 40f, svid = 2) + ) + ) + + assertEquals(0f, assertNotNull(azimuth), 0.01f) + } + + @Test + fun `la direction reste dans un tour complet`() { + val azimuth = SkyAnalysis.bestSkyAzimuthDeg( + listOf( + satellite(azimuthDeg = 300f, elevationDeg = 20f, cn0DbHz = 40f, svid = 1), + satellite(azimuthDeg = 340f, elevationDeg = 20f, cn0DbHz = 40f, svid = 2) + ) + ) + + assertEquals(320f, assertNotNull(azimuth), 0.01f) + } + + @Test + fun `le signal le plus fort tire la direction vers lui`() { + val azimuth = SkyAnalysis.bestSkyAzimuthDeg( + listOf( + satellite(azimuthDeg = 0f, elevationDeg = 30f, cn0DbHz = 45f, svid = 1), + satellite(azimuthDeg = 90f, elevationDeg = 30f, cn0DbHz = 15f, svid = 2) + ) + ) + + val value = assertNotNull(azimuth) + assertEquals(true, value in 0f..45f, "azimut $value") + } + + @Test + fun `l'elevation moyenne ne compte que les satellites recus`() { + val mean = SkyAnalysis.meanElevationDeg( + listOf( + satellite(azimuthDeg = 0f, elevationDeg = 20f, cn0DbHz = 40f, svid = 1), + satellite(azimuthDeg = 90f, elevationDeg = 40f, cn0DbHz = 30f, svid = 2), + satellite(azimuthDeg = 180f, elevationDeg = 80f, cn0DbHz = 0f, svid = 3) + ) + ) + + assertEquals(30f, assertNotNull(mean), 0.01f) + } + + @Test + fun `l'etat GNSS expose les analyses du ciel`() { + val state = GnssState( + satellites = listOf( + satellite(azimuthDeg = 90f, elevationDeg = 45f, cn0DbHz = 42f, svid = 7) + ) + ) + + assertEquals(7, assertNotNull(state.bestSatellite).svid) + assertEquals(90f, assertNotNull(state.bestSkyAzimuthDeg), 0.01f) + } +} diff --git a/app/src/test/java/com/mikael/sattemps/network/DirectionScannerTest.kt b/app/src/test/java/com/mikael/sattemps/network/DirectionScannerTest.kt new file mode 100644 index 0000000..f9d167f --- /dev/null +++ b/app/src/test/java/com/mikael/sattemps/network/DirectionScannerTest.kt @@ -0,0 +1,149 @@ +package com.mikael.sattemps.network + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DirectionScannerTest { + + @Test + fun `aucune mesure n'est retenue avant le demarrage`() { + val scanner = DirectionScanner() + + scanner.addSample(headingDeg = 90f, dbm = -70) + val state = scanner.snapshot() + + assertFalse(state.running) + assertTrue(state.sectors.isEmpty()) + assertEquals(0, state.coveragePercent) + assertNull(state.bestSector) + } + + @Test + fun `la mesure est affectee au secteur du cap`() { + val scanner = DirectionScanner(sectorCount = 24) + scanner.start() + + scanner.addSample(headingDeg = 100f, dbm = -80) + val sector = assertNotNull(scanner.snapshot().sectors.singleOrNull()) + + // 100° tombe dans le secteur 6 (90° a 105°), dont le centre est 97,5°. + assertEquals(97.5f, sector.azimuthDeg, 0.01f) + assertEquals(-80f, sector.meanDbm, 0.01f) + assertEquals(-80, sector.bestDbm) + assertEquals(1, sector.sampleCount) + } + + @Test + fun `un cap negatif ou superieur a un tour retombe dans le bon secteur`() { + val scanner = DirectionScanner(sectorCount = 24) + scanner.start() + + scanner.addSample(headingDeg = -350f, dbm = -70) + scanner.addSample(headingDeg = 370f, dbm = -90) + + val sector = assertNotNull(scanner.snapshot().sectors.singleOrNull()) + assertEquals(7.5f, sector.azimuthDeg, 0.01f) + assertEquals(2, sector.sampleCount) + } + + @Test + fun `le secteur retient la moyenne et le meilleur signal`() { + val scanner = DirectionScanner(sectorCount = 4) + scanner.start() + + scanner.addSample(headingDeg = 10f, dbm = -100) + scanner.addSample(headingDeg = 20f, dbm = -80) + + val sector = assertNotNull(scanner.snapshot().sectors.singleOrNull()) + assertEquals(-90f, sector.meanDbm, 0.01f) + assertEquals(-80, sector.bestDbm) + assertEquals(2, sector.sampleCount) + } + + @Test + fun `le meilleur secteur est celui dont la moyenne est la plus forte`() { + val scanner = DirectionScanner(sectorCount = 4) + scanner.start() + + scanner.addSample(headingDeg = 10f, dbm = -100) + scanner.addSample(headingDeg = 100f, dbm = -75) + scanner.addSample(headingDeg = 190f, dbm = -95) + + val best = assertNotNull(scanner.snapshot().bestSector) + assertEquals(135f, best.azimuthDeg, 0.01f) + } + + @Test + fun `la couverture progresse avec les secteurs visites`() { + val scanner = DirectionScanner(sectorCount = 4) + scanner.start() + + scanner.addSample(headingDeg = 0f, dbm = -80) + assertEquals(25, scanner.snapshot().coveragePercent) + + scanner.addSample(headingDeg = 180f, dbm = -80) + assertEquals(50, scanner.snapshot().coveragePercent) + } + + @Test + fun `la mesure n'est fiable qu'au dela du seuil de couverture`() { + val scanner = DirectionScanner(sectorCount = 4) + scanner.start() + + scanner.addSample(headingDeg = 0f, dbm = -80) + scanner.addSample(headingDeg = 90f, dbm = -80) + assertFalse(scanner.snapshot().reliable) + + scanner.addSample(headingDeg = 180f, dbm = -80) + assertTrue(scanner.snapshot().reliable) + } + + @Test + fun `un decoupage vide est refuse`() { + assertFailsWith { DirectionScanner(sectorCount = 0) } + assertFailsWith { DirectionScanner(sectorCount = -1) } + } + + @Test + fun `un cap invalide est ignore`() { + val scanner = DirectionScanner(sectorCount = 4) + scanner.start() + + scanner.addSample(headingDeg = Float.NaN, dbm = -80) + + assertTrue(scanner.snapshot().sectors.isEmpty()) + } + + @Test + fun `un nouveau demarrage efface les mesures precedentes`() { + val scanner = DirectionScanner(sectorCount = 4) + scanner.start() + scanner.addSample(headingDeg = 0f, dbm = -80) + scanner.stop() + + scanner.start() + + val state = scanner.snapshot() + assertTrue(state.running) + assertTrue(state.sectors.isEmpty()) + assertEquals(0, state.coveragePercent) + } + + @Test + fun `l'arret conserve les mesures deja acquises`() { + val scanner = DirectionScanner(sectorCount = 4) + scanner.start() + scanner.addSample(headingDeg = 0f, dbm = -80) + + scanner.stop() + + val state = scanner.snapshot() + assertFalse(state.running) + assertEquals(1, state.sectors.size) + } +} diff --git a/app/src/test/java/com/mikael/sattemps/time/NtpTimestampTest.kt b/app/src/test/java/com/mikael/sattemps/time/NtpTimestampTest.kt new file mode 100644 index 0000000..0a9b43f --- /dev/null +++ b/app/src/test/java/com/mikael/sattemps/time/NtpTimestampTest.kt @@ -0,0 +1,59 @@ +package com.mikael.sattemps.time + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NtpTimestampTest { + + @Test + fun `l'ecriture puis la lecture restituent l'instant a la milliseconde`() { + val buffer = ByteArray(48) + val time = 1_724_930_123_456L + + NtpTimestamp.write(buffer, 40, time) + + // La fraction NTP est tronquee : au plus une milliseconde d'ecart. + assertTrue(kotlin.math.abs(NtpTimestamp.read(buffer, 40) - time) <= 1L) + } + + @Test + fun `l'epoque Unix correspond au decalage NTP de 1900`() { + val buffer = ByteArray(16) + + NtpTimestamp.write(buffer, 0, 0L) + + val seconds = (0 until 4).fold(0L) { acc, i -> + (acc shl 8) or (buffer[i].toLong() and 0xFF) + } + assertEquals(NtpTimestamp.OFFSET_1900_TO_1970, seconds) + assertEquals(0L, NtpTimestamp.read(buffer, 0)) + } + + @Test + fun `la fraction de seconde est encodee sur les 32 bits de poids faible`() { + val buffer = ByteArray(16) + + NtpTimestamp.write(buffer, 0, 1_000L + 500L) + + // 0,5 s correspond au bit de poids fort de la fraction. + assertEquals(0x80.toByte(), buffer[4]) + assertEquals(1_500L, NtpTimestamp.read(buffer, 0)) + } + + @Test + fun `un horodatage non ecrit est lu comme anterieur a l'epoque Unix`() { + assertTrue(NtpTimestamp.read(ByteArray(48), 40) < 0L) + } + + @Test + fun `plusieurs horodatages coexistent dans le meme paquet`() { + val buffer = ByteArray(48) + + NtpTimestamp.write(buffer, SntpClient.RECEIVE_OFFSET, 1_000L) + NtpTimestamp.write(buffer, SntpClient.TRANSMIT_OFFSET, 2_000L) + + assertEquals(1_000L, NtpTimestamp.read(buffer, SntpClient.RECEIVE_OFFSET)) + assertEquals(2_000L, NtpTimestamp.read(buffer, SntpClient.TRANSMIT_OFFSET)) + } +} diff --git a/app/src/test/java/com/mikael/sattemps/time/SntpClientTest.kt b/app/src/test/java/com/mikael/sattemps/time/SntpClientTest.kt new file mode 100644 index 0000000..0b04132 --- /dev/null +++ b/app/src/test/java/com/mikael/sattemps/time/SntpClientTest.kt @@ -0,0 +1,166 @@ +package com.mikael.sattemps.time + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +private const val HOST = "time.example.org" + +/** + * Construit une reponse serveur : le serveur recoit la requete a [receiveTime] et + * repond a [transmitTime], en millisecondes Unix. + * + * Les millisecondes utilisees dans les tests sont des multiples de 125 : la fraction + * NTP les encode alors exactement, sans troncature. + */ +private fun serverResponse( + originateTime: Long, + receiveTime: Long, + transmitTime: Long, + mode: Int = 4, + stratum: Int = 2 +): ByteArray { + val buffer = ByteArray(48) + buffer[0] = (mode or (3 shl 3)).toByte() + buffer[1] = stratum.toByte() + NtpTimestamp.write(buffer, SntpClient.ORIGINATE_OFFSET, originateTime) + NtpTimestamp.write(buffer, SntpClient.RECEIVE_OFFSET, receiveTime) + NtpTimestamp.write(buffer, SntpClient.TRANSMIT_OFFSET, transmitTime) + return buffer +} + +class SntpClientTest { + + @Test + fun `une horloge en avance donne un decalage negatif`() { + val systemTime = 1_700_000_010_000L + val serverTime = 1_700_000_000_000L + + val outcome = SntpClient.evaluate( + buffer = serverResponse( + originateTime = systemTime, + receiveTime = serverTime + 125L, + transmitTime = serverTime + 250L + ), + host = HOST, + requestTime = systemTime, + requestTicks = 1_000L, + responseTicks = 1_200L + ) + + val result = assertIs(outcome).result + assertEquals(-9_912L, result.offsetMillis, "decalage ${result.offsetMillis}") + assertEquals(75L, result.roundTripMillis) + assertEquals(HOST, result.server) + assertEquals(1_200L, result.elapsedRealtimeAtSync) + } + + @Test + fun `une horloge juste donne un decalage nul`() { + val time = 1_700_000_000_000L + + val outcome = SntpClient.evaluate( + buffer = serverResponse( + originateTime = time, + receiveTime = time + 125L, + transmitTime = time + 125L + ), + host = HOST, + requestTime = time, + requestTicks = 0L, + responseTicks = 250L + ) + + val result = assertIs(outcome).result + assertEquals(0L, result.offsetMillis) + assertEquals(250L, result.roundTripMillis) + } + + @Test + fun `un aller-retour incoherent est ramene a zero`() { + val time = 1_700_000_000_000L + + val outcome = SntpClient.evaluate( + buffer = serverResponse( + originateTime = time, + receiveTime = time, + transmitTime = time + 500L + ), + host = HOST, + requestTime = time, + requestTicks = 0L, + responseTicks = 10L + ) + + assertEquals(0L, assertIs(outcome).result.roundTripMillis) + } + + @Test + fun `un paquet en mode client est rejete`() { + val time = 1_700_000_000_000L + + val outcome = SntpClient.evaluate( + buffer = serverResponse(time, time, time, mode = 3), + host = HOST, + requestTime = time, + requestTicks = 0L, + responseTicks = 10L + ) + + val failure = assertIs(outcome) + assertEquals(SntpClient.FailureReason.INVALID_RESPONSE, failure.reason) + assertEquals(HOST, failure.server) + } + + @Test + fun `un serveur non synchronise est rejete`() { + val time = 1_700_000_000_000L + + val outcome = SntpClient.evaluate( + buffer = serverResponse(time, time, time, stratum = 0), + host = HOST, + requestTime = time, + requestTicks = 0L, + responseTicks = 10L + ) + + assertEquals( + SntpClient.FailureReason.INVALID_RESPONSE, + assertIs(outcome).reason + ) + } + + @Test + fun `un stratum reserve est rejete`() { + val time = 1_700_000_000_000L + + val outcome = SntpClient.evaluate( + buffer = serverResponse(time, time, time, stratum = 16), + host = HOST, + requestTime = time, + requestTicks = 0L, + responseTicks = 10L + ) + + assertEquals( + SntpClient.FailureReason.INVALID_RESPONSE, + assertIs(outcome).reason + ) + } + + @Test + fun `un paquet vide est rejete`() { + val outcome = SntpClient.evaluate( + buffer = ByteArray(48), + host = HOST, + requestTime = 1_700_000_000_000L, + requestTicks = 0L, + responseTicks = 10L + ) + + assertEquals( + SntpClient.FailureReason.INVALID_RESPONSE, + assertIs(outcome).reason + ) + } +} diff --git a/app/src/test/java/com/mikael/sattemps/time/TimeSyncTest.kt b/app/src/test/java/com/mikael/sattemps/time/TimeSyncTest.kt new file mode 100644 index 0000000..3935e16 --- /dev/null +++ b/app/src/test/java/com/mikael/sattemps/time/TimeSyncTest.kt @@ -0,0 +1,89 @@ +package com.mikael.sattemps.time + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TimeSyncTest { + + @Test + fun `sans reference on garde l'horloge systeme`() { + val state = resolveTimeSource(TimeSyncState()) + + assertEquals(TimeSource.SYSTEME, state.source) + assertEquals(0L, state.offsetMillis) + assertTrue(state.uncertaintyMillis >= 1000L) + } + + @Test + fun `le temps NTP est utilise en l'absence de GNSS`() { + val state = resolveTimeSource( + TimeSyncState(ntpOffsetMillis = -320L, ntpRoundTripMillis = 48L) + ) + + assertEquals(TimeSource.NTP, state.source) + assertEquals(-320L, state.offsetMillis) + assertEquals(24L, state.uncertaintyMillis) + } + + @Test + fun `l'incertitude NTP ne descend jamais a zero`() { + val state = resolveTimeSource( + TimeSyncState(ntpOffsetMillis = 5L, ntpRoundTripMillis = 0L) + ) + + assertEquals(1L, state.uncertaintyMillis) + } + + @Test + fun `le temps GNSS est prioritaire sur le temps NTP`() { + val state = resolveTimeSource( + TimeSyncState( + ntpOffsetMillis = -320L, + ntpRoundTripMillis = 48L, + gnssOffsetMillis = -280L + ) + ) + + assertEquals(TimeSource.GNSS, state.source) + assertEquals(-280L, state.offsetMillis) + assertEquals(50L, state.uncertaintyMillis) + } + + @Test + fun `la perte du GNSS fait retomber sur le temps NTP`() { + val gnss = resolveTimeSource( + TimeSyncState( + ntpOffsetMillis = -320L, + ntpRoundTripMillis = 48L, + gnssOffsetMillis = -280L + ) + ) + + val fallback = resolveTimeSource(gnss.copy(gnssOffsetMillis = null)) + + assertEquals(TimeSource.NTP, fallback.source) + assertEquals(-320L, fallback.offsetMillis) + } + + @Test + fun `la perte du GNSS sans NTP revient a l'horloge systeme`() { + val fallback = resolveTimeSource( + TimeSyncState(source = TimeSource.GNSS, offsetMillis = -280L) + ) + + assertEquals(TimeSource.SYSTEME, fallback.source) + assertEquals(0L, fallback.offsetMillis) + } + + @Test + fun `le delai de reessai augmente a chaque echec puis reste borne`() { + val nominal = retryDelayMillis(0) + + assertEquals(5 * 60 * 1000L, nominal) + assertEquals(15_000L, retryDelayMillis(1)) + assertEquals(30_000L, retryDelayMillis(2)) + assertEquals(60_000L, retryDelayMillis(3)) + assertTrue(retryDelayMillis(MAX_BACKOFF_STEPS) <= 4 * 60 * 1000L) + } +} diff --git a/app/src/test/java/com/mikael/sattemps/ui/AzimuthTest.kt b/app/src/test/java/com/mikael/sattemps/ui/AzimuthTest.kt new file mode 100644 index 0000000..cff5a07 --- /dev/null +++ b/app/src/test/java/com/mikael/sattemps/ui/AzimuthTest.kt @@ -0,0 +1,50 @@ +package com.mikael.sattemps.ui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AzimuthTest { + + @Test + fun `la normalisation ramene tous les azimuts dans un tour`() { + assertEquals(0.0, Azimuth.normalize(360.0), 0.001) + assertEquals(350.0, Azimuth.normalize(-10.0), 0.001) + assertEquals(45.0, Azimuth.normalize(765.0), 0.001) + } + + @Test + fun `chaque azimut tombe dans le bon secteur cardinal`() { + assertEquals(0, Azimuth.cardinalIndex(0.0)) + assertEquals(0, Azimuth.cardinalIndex(-5.0)) + assertEquals(1, Azimuth.cardinalIndex(22.5)) + assertEquals(4, Azimuth.cardinalIndex(90.0)) + assertEquals(8, Azimuth.cardinalIndex(180.0)) + assertEquals(12, Azimuth.cardinalIndex(270.0)) + assertEquals(15, Azimuth.cardinalIndex(345.0)) + } + + @Test + fun `l'indice cardinal reste dans les bornes du tableau`() { + (-720..720).forEach { degrees -> + val index = Azimuth.cardinalIndex(degrees.toDouble()) + assertTrue(index in 0 until Azimuth.CARDINAL_COUNT, "azimut $degrees -> $index") + } + } + + @Test + fun `la rotation la plus courte peut passer par le nord`() { + assertEquals(20f, Azimuth.turnDelta(currentDeg = 350f, targetDeg = 10f), 0.01f) + assertEquals(-20f, Azimuth.turnDelta(currentDeg = 10f, targetDeg = 350f), 0.01f) + assertEquals(90f, Azimuth.turnDelta(currentDeg = 0f, targetDeg = 90f), 0.01f) + assertEquals(-90f, Azimuth.turnDelta(currentDeg = 90f, targetDeg = 0f), 0.01f) + } + + @Test + fun `l'alignement tolere moins d'un demi degre`() { + assertTrue(Azimuth.isAligned(currentDeg = 120f, targetDeg = 120.2f)) + assertFalse(Azimuth.isAligned(currentDeg = 120f, targetDeg = 122f)) + assertTrue(Azimuth.isAligned(currentDeg = 359.9f, targetDeg = 0f)) + } +} diff --git a/dist/SatTemps-v1.1.0-debug.apk b/dist/SatTemps-v1.1.0-debug.apk new file mode 100644 index 0000000..37cbc2e Binary files /dev/null and b/dist/SatTemps-v1.1.0-debug.apk differ