mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cdc2952dc | ||
|
|
bd51044690 | ||
|
|
1982c2d3dc | ||
|
|
2f4e3f5802 | ||
|
|
24eebdef74 | ||
|
|
3f5b48f270 | ||
|
|
060fa2dfcd | ||
|
|
8b5960282f | ||
|
|
10eb84690e | ||
|
|
bf25292bf8 | ||
|
|
8fbfcb3712 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 | ||
|
|
f585372594 | ||
|
|
4585332b1a | ||
|
|
6d2b0ced49 | ||
|
|
e86bc2c700 | ||
|
|
9a52fdfde5 | ||
|
|
07504a2a95 | ||
|
|
81525b6dd3 | ||
|
|
c7089ab314 | ||
|
|
77dfb7bb05 | ||
|
|
58fb688593 | ||
|
|
dc596b4348 | ||
|
|
f9b1a97a37 | ||
|
|
d700432829 | ||
|
|
b17ea2d918 | ||
|
|
976fdfd949 | ||
|
|
d8db258bd8 | ||
|
|
7b6fb5eb8f | ||
|
|
f4aef4f4f0 | ||
|
|
9147323db2 | ||
|
|
81d397a09d | ||
|
|
ec1a55c93d | ||
|
|
e5033cb2e9 | ||
|
|
39b786ae82 | ||
|
|
79809aaec0 | ||
|
|
f7f5a73c4c | ||
|
|
18c8ac1822 | ||
|
|
22607e7710 | ||
|
|
ad0ff6859e | ||
|
|
a39b1716e7 | ||
|
|
8a2339cfbf | ||
|
|
821fa0dfe7 | ||
|
|
ce8c2a4834 | ||
|
|
8cc4ef5610 | ||
|
|
dde22faf46 | ||
|
|
7ad81e32e1 | ||
|
|
5af963f3c9 | ||
|
|
bfb9fb6ca8 | ||
|
|
df24c66ef3 | ||
|
|
b166e896c2 | ||
|
|
18f8ab517d | ||
|
|
d3b8e951dd | ||
|
|
6e5a3cb0bb | ||
|
|
31d329a19b | ||
|
|
3df358b88c | ||
|
|
7d5bca300d | ||
|
|
fb2cd85b11 | ||
|
|
99158e74fa | ||
|
|
8265b73d75 | ||
|
|
d2b3184084 | ||
|
|
0c5d3699c0 | ||
|
|
a94f95874b | ||
|
|
4a3a01729b | ||
|
|
aadc285d7f | ||
|
|
2df0b9ba3d | ||
|
|
868155c532 | ||
|
|
3f981c09d5 | ||
|
|
b79fec13f4 | ||
|
|
6588291997 | ||
|
|
be96602c76 | ||
|
|
b59f9a6c17 | ||
|
|
9afe2ab69e | ||
|
|
2edc892dc2 | ||
|
|
45eb6915c2 | ||
|
|
706c912b13 | ||
|
|
e523eb5517 | ||
|
|
0bef2ffddc | ||
|
|
279594c425 | ||
|
|
8c1df334d8 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# GitHub Copilot Instructions
|
||||
|
||||
Read `AGENTS.md` first, then `CLAUDE.md`.
|
||||
|
||||
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "bundler"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "never"
|
||||
@@ -16,59 +16,64 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Assemble Artifacts
|
||||
run: |
|
||||
./gradlew assembleRelease
|
||||
./gradlew bundleRelease
|
||||
run: ./gradlew assembleRelease bundleRelease
|
||||
|
||||
- name: Sign APK
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
id: sign_apk
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/release
|
||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
run: |
|
||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||
${BUILD_TOOLS}apksigner sign \
|
||||
--ks keystore.jks \
|
||||
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||
--out app/build/outputs/apk/release/look4sat.apk \
|
||||
"$APK"
|
||||
rm keystore.jks
|
||||
|
||||
- name: Sign Bundle
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
id: sign_bundle
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/bundle/release
|
||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Rename Artifacts
|
||||
run: |
|
||||
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk
|
||||
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab
|
||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||
-keystore keystore.jks \
|
||||
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
||||
-keypass ${{ secrets.KEY_PASSWORD }} \
|
||||
"$AAB" ${{ secrets.KEY_ALIAS }}
|
||||
rm keystore.jks
|
||||
|
||||
- name: Deploy Bundle
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}}
|
||||
packageName: com.rtbishop.look4sat
|
||||
releaseFiles: app/build/outputs/apk/release/look4sat.aab
|
||||
track: production
|
||||
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew
|
||||
ruby-version: '3.4'
|
||||
|
||||
- name: Deploy Bundle to Google Play
|
||||
run: |
|
||||
gem install multi_json
|
||||
gem install fastlane --no-document
|
||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||
fastlane supply \
|
||||
--aab "$AAB" \
|
||||
--json_key service_account.json \
|
||||
--package_name com.rtbishop.look4sat \
|
||||
--track production \
|
||||
--skip_upload_images true \
|
||||
--skip_upload_screenshots true \
|
||||
rm service_account.json
|
||||
|
||||
- name: Create Release
|
||||
run: |
|
||||
|
||||
@@ -64,3 +64,4 @@ fastlane/readme.md
|
||||
/app/release/output-metadata.json
|
||||
/app/release/
|
||||
/.kotlin/sessions/
|
||||
.hermes/
|
||||
@@ -0,0 +1,121 @@
|
||||
# Look4Sat AI Agent Instructions
|
||||
|
||||
This is the canonical project guide for all AI assistants working on Look4Sat.
|
||||
All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
||||
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||
no tracking, no network required after initial data download.
|
||||
|
||||
## Architecture & Design
|
||||
|
||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||
- Jetpack Compose UI observes state and recomposes reactively
|
||||
|
||||
**Clean Architecture layers:**
|
||||
|
||||
| Module | Responsibility |
|
||||
|----------------------|---------------------------------------------------------------------|
|
||||
| `app` | Entry point. Aggregates all modules |
|
||||
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
||||
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
||||
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
||||
| `feature:map` | OSMDroid map with ground tracks |
|
||||
| `feature:passes` | Pass predictions and upcoming events |
|
||||
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
|
||||
| `feature:satellites` | Satellite list, filtering, selection |
|
||||
| `feature:settings` | User preferences |
|
||||
|
||||
**Feature isolation:**
|
||||
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
||||
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
||||
|
||||
## Build & Platform
|
||||
|
||||
```shell
|
||||
# Debug build
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Release build (minified, shrunk resources)
|
||||
./gradlew assembleRelease
|
||||
|
||||
# Run tests
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||
- **OkHttp** 5.x for data downloads
|
||||
- **OSMDroid** for map rendering
|
||||
- **Kotlin Serialization** for navigation args and parsing
|
||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||
|
||||
## Data Formats & Migration
|
||||
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
|
||||
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
||||
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||
- Existing code already supports transparent source transition without feature changes
|
||||
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||
|
||||
## Engineering Heuristics (Lazy = Efficient)
|
||||
|
||||
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||
1. Does this need to be built now? (YAGNI)
|
||||
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||
3. Does Kotlin/Java stdlib already solve it?
|
||||
4. Does the Android/platform API already solve it?
|
||||
5. Does an already-installed dependency solve it?
|
||||
6. Can this be simpler (including one-liner simple)?
|
||||
7. Only then: write the minimum code that works.
|
||||
- Prefer deletion to addition, boring over clever, and the fewest touched files.
|
||||
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
|
||||
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
|
||||
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
|
||||
- When two options are similar in size, choose the edge-case-correct one.
|
||||
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
|
||||
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
|
||||
|
||||
## Bug-Fix Policy
|
||||
|
||||
- Fix root cause, not just the reported symptom.
|
||||
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||
- The smallest correct diff wins only after behavior is understood.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||
|
||||
## Copilot Working Mode: Code-Only
|
||||
|
||||
- Default to code changes only. Provide explanations in chat only.
|
||||
- If documentation seems useful, ask first before creating files.
|
||||
- Do NOT create any `.md` documentation files unless explicitly requested.
|
||||
- Do NOT add README, guides, summaries, migration notes, or how-to files unless asked.
|
||||
- Prefer minimal diffs focused on requested implementation.
|
||||
- Default validation is static checks (`get_errors`). Do NOT run Gradle compile/test tasks unless explicitly requested.
|
||||
@@ -0,0 +1,5 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Read `AGENTS.md` first, then follow the instructions there.
|
||||
|
||||
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||
@@ -33,3 +33,13 @@ It is now and always will be completely ad-free and open-source.
|
||||
* Showing the satellite positional data, footprint and ground track on the map
|
||||
* Custom TLE satellite data import is available via Three Line Element .txt files
|
||||
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -1,3 +1,3 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.androidAppPlugin)
|
||||
alias(libs.plugins.convention.applicationPlugin)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission
|
||||
@@ -11,11 +12,14 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
|
||||
<activity
|
||||
@@ -26,6 +30,14 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- <intent-filter android:autoVerify="true">-->
|
||||
<!-- <action android:name="android.intent.action.VIEW" />-->
|
||||
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||
<!-- <data android:scheme="https" />-->
|
||||
<!-- <data android:host="github.com" />-->
|
||||
<!-- <data android:pathPattern="/rt-bishop/Look4Sat/passes.*" />-->
|
||||
<!-- </intent-filter>-->
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
|
||||
@@ -19,12 +19,21 @@ package com.rtbishop.look4sat
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@@ -38,8 +47,37 @@ class MainActivity : ComponentActivity() {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeNightFilterState() {
|
||||
val mainContainer = (applicationContext as IContainerProvider).getMainContainer()
|
||||
lifecycleScope.launch {
|
||||
mainContainer.settingsRepo.otherSettings
|
||||
.map { it.stateOfNightMode }
|
||||
.distinctUntilChanged()
|
||||
.collect { nightMode -> applyNightFilter(nightMode) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyNightFilter(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
val nightMatrix = ColorMatrix(
|
||||
floatArrayOf(
|
||||
1f, 0f, 0f, 0f, 0f, // R → R
|
||||
0f, 0f, 0f, 0f, 0f, // G → 0
|
||||
0f, 0f, 0f, 0f, 0f, // B → 0
|
||||
0f, 0f, 0f, 1f, 0f // A → A
|
||||
)
|
||||
)
|
||||
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, Paint().apply {
|
||||
colorFilter = ColorMatrixColorFilter(nightMatrix)
|
||||
})
|
||||
} else {
|
||||
window.decorView.setLayerType(View.LAYER_TYPE_NONE, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,74 +17,254 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||
import com.rtbishop.look4sat.core.presentation.Screen
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||
import com.rtbishop.look4sat.feature.map.mapDestination
|
||||
import com.rtbishop.look4sat.feature.passes.passesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.radarDestination
|
||||
import com.rtbishop.look4sat.feature.satellites.satellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.settingsDestination
|
||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navController: NavHostController = rememberNavController()) {
|
||||
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
||||
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route
|
||||
val startDestination = Screen.Passes.route
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
items.forEach {
|
||||
item(
|
||||
icon = { Icon(painterResource(it.iconResId), stringResource(it.titleResId)) },
|
||||
label = { Text(stringResource(it.titleResId)) },
|
||||
selected = currentDestination?.contains(it.route) ?: false,
|
||||
onClick = {
|
||||
if (currentDestination?.contains(it.route) ?: false) return@item
|
||||
navController.navigate(it.route) {
|
||||
popUpTo(startDestination) { saveState = false }
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
})
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
}
|
||||
}, navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
), layoutType = when {
|
||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
|
||||
fadeOut(animationSpec = tween(300))
|
||||
val navItems =
|
||||
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
low = otherSettings.lowElevation,
|
||||
high = otherSettings.highElevation
|
||||
)
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
enterTransition = { fadeIn(animationSpec = tween(350)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(350)) }
|
||||
) {
|
||||
satellitesDestination { navController.navigateUp() }
|
||||
passesDestination { catNum: Int, aosTime: Long ->
|
||||
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
|
||||
navController.navigate(radarRoute)
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
navItems.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Status -> screen is Screen.Status
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
item(
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(screen.iconResId),
|
||||
contentDescription = stringResource(screen.titleResId)
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(screen.titleResId)) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (isSelected) return@item
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
if (screen !is Screen.Passes) backStack.add(screen)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
),
|
||||
layoutType = when {
|
||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = Modifier.weight(1f),
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { fadeTransition },
|
||||
popTransitionSpec = { fadeTransition },
|
||||
predictivePopTransitionSpec = { fadeTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Satellites> {
|
||||
SatellitesDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Status> { SatStatusDestination() }
|
||||
entry<Screen.Map> { MapDestination() }
|
||||
entry<Screen.Settings> { SettingsDestination() }
|
||||
}
|
||||
)
|
||||
if (trackingState.isActive) {
|
||||
TrackingBanner(
|
||||
state = trackingState,
|
||||
onClick = {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
navigateToRadar()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
radarDestination { navController.navigateUp() }
|
||||
mapDestination()
|
||||
settingsDestination()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.4f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulseAlpha"
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Tracking: ${state.currentPass?.name ?: ""}",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val connections = listOfNotNull(
|
||||
"TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected }
|
||||
)
|
||||
Text(
|
||||
text = connections.joinToString("/"),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
@@ -24,25 +24,25 @@ group = "com.rtbishop.look4sat.build_logic.convention"
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
register("androidAppPlugin") {
|
||||
id = libs.plugins.convention.androidAppPlugin.get().pluginId
|
||||
implementationClass = "com.rtbishop.look4sat.convention.AndroidAppPlugin"
|
||||
register("applicationPlugin") {
|
||||
id = libs.plugins.convention.applicationPlugin.get().pluginId
|
||||
implementationClass = "com.rtbishop.look4sat.convention.ApplicationPlugin"
|
||||
}
|
||||
register("androidLibraryPlugin") {
|
||||
id = libs.plugins.convention.androidLibraryPlugin.get().pluginId
|
||||
implementationClass = "com.rtbishop.look4sat.convention.AndroidLibraryPlugin"
|
||||
register("coreDataPlugin") {
|
||||
id = libs.plugins.convention.coreDataPlugin.get().pluginId
|
||||
implementationClass = "com.rtbishop.look4sat.convention.CoreDataPlugin"
|
||||
}
|
||||
register("composeFeaturePlugin") {
|
||||
id = libs.plugins.convention.composeFeaturePlugin.get().pluginId
|
||||
implementationClass = "com.rtbishop.look4sat.convention.ComposeFeaturePlugin"
|
||||
register("coreDomainPlugin") {
|
||||
id = libs.plugins.convention.coreDomainPlugin.get().pluginId
|
||||
implementationClass = "com.rtbishop.look4sat.convention.CoreDomainPlugin"
|
||||
}
|
||||
register("composeLibraryPlugin") {
|
||||
id = libs.plugins.convention.composeLibraryPlugin.get().pluginId
|
||||
implementationClass = "com.rtbishop.look4sat.convention.ComposeLibraryPlugin"
|
||||
register("corePresentationPlugin") {
|
||||
id = libs.plugins.convention.corePresentationPlugin.get().pluginId
|
||||
implementationClass = "com.rtbishop.look4sat.convention.CorePresentationPlugin"
|
||||
}
|
||||
register("kotlinLibraryPlugin") {
|
||||
id = libs.plugins.convention.kotlinLibraryPlugin.get().pluginId
|
||||
implementationClass = "com.rtbishop.look4sat.convention.KotlinLibraryPlugin"
|
||||
register("featurePlugin") {
|
||||
id = libs.plugins.convention.featurePlugin.get().pluginId
|
||||
implementationClass = "com.rtbishop.look4sat.convention.FeaturePlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-19
@@ -22,25 +22,25 @@ import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.dependencies
|
||||
|
||||
@Suppress("Unused")
|
||||
internal class AndroidAppPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {
|
||||
with(target) {
|
||||
setupAndroidApplication()
|
||||
setupComposeFeature()
|
||||
setupKotlinToolchain()
|
||||
setupAndroidTestDependencies()
|
||||
setupTestDependencies()
|
||||
dependencies {
|
||||
IMPLEMENTATION(project(":core:data"))
|
||||
IMPLEMENTATION(project(":core:domain"))
|
||||
IMPLEMENTATION(project(":core:presentation"))
|
||||
IMPLEMENTATION(project(":feature:map"))
|
||||
IMPLEMENTATION(project(":feature:passes"))
|
||||
IMPLEMENTATION(project(":feature:radar"))
|
||||
IMPLEMENTATION(project(":feature:satellites"))
|
||||
IMPLEMENTATION(project(":feature:settings"))
|
||||
IMPLEMENTATION(libs.androidx.core.splashscreen)
|
||||
}
|
||||
internal class ApplicationPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) = with(target) {
|
||||
setupAndroidApp()
|
||||
setupCompose()
|
||||
setupKotlin()
|
||||
dependencies {
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:presentation"))
|
||||
implementation(project(":feature:map"))
|
||||
implementation(project(":feature:passes"))
|
||||
implementation(project(":feature:radar"))
|
||||
implementation(project(":feature:satellites"))
|
||||
implementation(project(":feature:settings"))
|
||||
implementation(project(":feature:status"))
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.compose.material3.adaptive)
|
||||
implementation(libs.compose.navigation3)
|
||||
androidTestImplementation(libs.bundles.androidTest)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-19
@@ -22,25 +22,19 @@ import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.dependencies
|
||||
|
||||
@Suppress("Unused")
|
||||
internal class AndroidLibraryPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {
|
||||
with(target) {
|
||||
with(pluginManager) {
|
||||
alias(libs.plugins.google.ksp)
|
||||
}
|
||||
setupCommonLibrary()
|
||||
setupKotlinToolchain()
|
||||
setupAndroidTestDependencies()
|
||||
setupTestDependencies()
|
||||
dependencies {
|
||||
IMPLEMENTATION(project(":core:domain"))
|
||||
IMPLEMENTATION(libs.androidx.core.ktx)
|
||||
IMPLEMENTATION(libs.androidx.room.asProvider())
|
||||
IMPLEMENTATION(libs.androidx.room.runtime)
|
||||
KSP(libs.androidx.room.compiler)
|
||||
IMPLEMENTATION(libs.other.coroutines)
|
||||
IMPLEMENTATION(libs.other.okhttp)
|
||||
}
|
||||
internal class CoreDataPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) = with(target) {
|
||||
applyPlugin(libs.plugins.google.ksp)
|
||||
setupAndroidLib()
|
||||
setupKotlin()
|
||||
dependencies {
|
||||
implementation(project(":core:domain"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.room)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.other.okhttp)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-12
@@ -22,18 +22,14 @@ import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.dependencies
|
||||
|
||||
@Suppress("Unused")
|
||||
internal class KotlinLibraryPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {
|
||||
with(target) {
|
||||
with(pluginManager) {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
setupKotlinToolchain()
|
||||
setupTestDependencies()
|
||||
dependencies {
|
||||
IMPLEMENTATION(libs.other.coroutines)
|
||||
IMPLEMENTATION(libs.other.json)
|
||||
}
|
||||
internal class CoreDomainPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) = with(target) {
|
||||
applyPlugin(libs.plugins.kotlin.jvm)
|
||||
applyPlugin(libs.plugins.kotlin.serialization)
|
||||
setupKotlin()
|
||||
dependencies {
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.kotlin.serialization)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -22,18 +22,17 @@ import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.dependencies
|
||||
|
||||
@Suppress("Unused")
|
||||
internal class ComposeLibraryPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {
|
||||
with(target) {
|
||||
setupCommonLibrary()
|
||||
setupComposeFeature()
|
||||
setupKotlinToolchain()
|
||||
setupAndroidTestDependencies()
|
||||
setupTestDependencies()
|
||||
dependencies {
|
||||
IMPLEMENTATION(project(":core:domain"))
|
||||
IMPLEMENTATION(libs.androidx.core.splashscreen)
|
||||
}
|
||||
internal class CorePresentationPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) = with(target) {
|
||||
applyPlugin(libs.plugins.kotlin.serialization)
|
||||
setupAndroidLib()
|
||||
setupCompose()
|
||||
setupKotlin()
|
||||
dependencies {
|
||||
implementation(project(":core:domain"))
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.kotlin.serialization)
|
||||
implementation(libs.compose.material3.adaptive)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-12
@@ -22,18 +22,14 @@ import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.dependencies
|
||||
|
||||
@Suppress("Unused")
|
||||
internal class ComposeFeaturePlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {
|
||||
with(target) {
|
||||
setupCommonLibrary()
|
||||
setupComposeFeature()
|
||||
setupKotlinToolchain()
|
||||
setupAndroidTestDependencies()
|
||||
setupTestDependencies()
|
||||
dependencies {
|
||||
IMPLEMENTATION(project(":core:domain"))
|
||||
IMPLEMENTATION(project(":core:presentation"))
|
||||
}
|
||||
internal class FeaturePlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) = with(target) {
|
||||
setupAndroidLib()
|
||||
setupCompose()
|
||||
setupKotlin()
|
||||
dependencies {
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:presentation"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-40
@@ -21,7 +21,7 @@ import com.android.build.api.dsl.ApplicationExtension
|
||||
import com.android.build.api.dsl.CommonExtension
|
||||
import org.gradle.accessors.dm.LibrariesForLibs
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.plugins.PluginManager
|
||||
import org.gradle.api.artifacts.dsl.DependencyHandler
|
||||
import org.gradle.api.provider.Provider
|
||||
import org.gradle.kotlin.dsl.accessors.runtime.extensionOf
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
@@ -29,23 +29,30 @@ import org.gradle.kotlin.dsl.dependencies
|
||||
import org.gradle.plugin.use.PluginDependency
|
||||
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
|
||||
|
||||
internal const val ANDROID_TEST_IMPLEMENTATION = "androidTestImplementation"
|
||||
internal const val DEBUG_IMPLEMENTATION = "debugImplementation"
|
||||
internal const val IMPLEMENTATION = "implementation"
|
||||
internal const val KSP = "ksp"
|
||||
internal const val TEST_IMPLEMENTATION = "testImplementation"
|
||||
|
||||
internal val Project.libs
|
||||
get(): LibrariesForLibs = extensionOf(this, "libs") as LibrariesForLibs
|
||||
|
||||
internal fun PluginManager.alias(notation: Provider<PluginDependency>) {
|
||||
apply(notation.get().pluginId)
|
||||
internal fun Project.applyPlugin(notation: Provider<PluginDependency>) {
|
||||
pluginManager.apply(notation.get().pluginId)
|
||||
}
|
||||
|
||||
internal fun Project.setupAndroidApplication() {
|
||||
with(pluginManager) {
|
||||
alias(libs.plugins.android.application)
|
||||
}
|
||||
internal fun DependencyHandler.implementation(dependencyNotation: Any) =
|
||||
add("implementation", dependencyNotation)
|
||||
|
||||
internal fun DependencyHandler.debugImplementation(dependencyNotation: Any) =
|
||||
add("debugImplementation", dependencyNotation)
|
||||
|
||||
internal fun DependencyHandler.testImplementation(dependencyNotation: Any) =
|
||||
add("testImplementation", dependencyNotation)
|
||||
|
||||
internal fun DependencyHandler.androidTestImplementation(dependencyNotation: Any) =
|
||||
add("androidTestImplementation", dependencyNotation)
|
||||
|
||||
internal fun DependencyHandler.ksp(dependencyNotation: Any) =
|
||||
add("ksp", dependencyNotation)
|
||||
|
||||
internal fun Project.setupAndroidApp() {
|
||||
applyPlugin(libs.plugins.android.application)
|
||||
extensions.configure<ApplicationExtension> {
|
||||
namespace = libs.versions.packageName.get()
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
@@ -55,9 +62,6 @@ internal fun Project.setupAndroidApplication() {
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
@@ -70,49 +74,38 @@ internal fun Project.setupAndroidApplication() {
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters.addAll(listOf("en", "es", "ru", "si", "uk", "zh"))
|
||||
localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
|
||||
}
|
||||
packaging { resources { excludes += listOf("META-INF/*") } }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Project.setupCommonLibrary() {
|
||||
with(pluginManager) {
|
||||
alias(libs.plugins.android.library)
|
||||
}
|
||||
internal fun Project.setupAndroidLib() {
|
||||
applyPlugin(libs.plugins.android.library)
|
||||
extensions.configure<CommonExtension> {
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
defaultConfig.minSdk = libs.versions.minSdk.get().toInt()
|
||||
}
|
||||
dependencies {
|
||||
androidTestImplementation(libs.bundles.androidTest)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Project.setupComposeFeature() {
|
||||
with(pluginManager) {
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
internal fun Project.setupCompose() {
|
||||
applyPlugin(libs.plugins.compose.compiler)
|
||||
extensions.configure<CommonExtension> {
|
||||
buildFeatures.compose = true
|
||||
}
|
||||
dependencies {
|
||||
IMPLEMENTATION(platform(libs.compose.bom))
|
||||
IMPLEMENTATION(libs.bundles.composeAll)
|
||||
DEBUG_IMPLEMENTATION(libs.bundles.composeDebug)
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.bundles.composeAll)
|
||||
debugImplementation(libs.bundles.composeDebug)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Project.setupKotlinToolchain() {
|
||||
internal fun Project.setupKotlin() {
|
||||
kotlinExtension.jvmToolchain(libs.versions.jdkVersion.get().toInt())
|
||||
}
|
||||
|
||||
internal fun Project.setupAndroidTestDependencies() {
|
||||
dependencies {
|
||||
ANDROID_TEST_IMPLEMENTATION(libs.bundles.androidTest)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Project.setupTestDependencies() {
|
||||
dependencies {
|
||||
TEST_IMPLEMENTATION(libs.test.coroutines)
|
||||
TEST_IMPLEMENTATION(libs.test.junit4)
|
||||
testImplementation(libs.bundles.unitTest)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ plugins {
|
||||
alias(libs.plugins.compose.compiler) apply false
|
||||
alias(libs.plugins.google.ksp) apply false
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete::class.java) {
|
||||
description = "Cleans the build directory"
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.androidLibraryPlugin)
|
||||
alias(libs.plugins.convention.coreDataPlugin)
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission
|
||||
@@ -11,5 +12,6 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
</manifest>
|
||||
@@ -45,7 +45,12 @@ interface Look4SatDao {
|
||||
@Query("DELETE FROM entries")
|
||||
suspend fun deleteEntries()
|
||||
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
||||
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
||||
"""
|
||||
)
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM radios")
|
||||
|
||||
@@ -25,11 +25,11 @@ data class SatRadio(
|
||||
@PrimaryKey val uuid: String,
|
||||
val info: String,
|
||||
val isAlive: Boolean,
|
||||
var downlinkLow: Long?,
|
||||
var downlinkHigh: Long?,
|
||||
val downlinkLow: Long?,
|
||||
val downlinkHigh: Long?,
|
||||
val downlinkMode: String?,
|
||||
var uplinkLow: Long?,
|
||||
var uplinkHigh: Long?,
|
||||
val uplinkLow: Long?,
|
||||
val uplinkHigh: Long?,
|
||||
val uplinkMode: String?,
|
||||
val isInverted: Boolean,
|
||||
val catnum: Int?
|
||||
|
||||
+77
-86
@@ -20,121 +20,112 @@ package com.rtbishop.look4sat.core.data.framework
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.repository.BtService
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
|
||||
data class DeviceConnection(
|
||||
var socket: BluetoothSocket? = null,
|
||||
var outputStream: OutputStream? = null,
|
||||
var connected: Boolean = false,
|
||||
var connecting: Boolean = false,
|
||||
var connectionJob: Job? = null
|
||||
)
|
||||
|
||||
class BluetoothReporter(
|
||||
private val bluetoothManager: BluetoothManager,
|
||||
private val reporterScope: CoroutineScope
|
||||
) : IReporterRepo<WithoutExtParams> {
|
||||
private val reporterScope: CoroutineScope,
|
||||
private val rotatorDeviceId: String,
|
||||
private val frequencyDeviceId: String
|
||||
) : IReporter {
|
||||
|
||||
private val tag = "BTReporter"
|
||||
private val sppid: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private val serviceToDevice = mutableMapOf<BtService, String>()
|
||||
private val deviceConnections = mutableMapOf<String, DeviceConnection>()
|
||||
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
override fun isConnected(service: BtService): Boolean {
|
||||
val deviceId = serviceToDevice[service] ?: return false
|
||||
return deviceConnections[deviceId]?.connected == true
|
||||
}
|
||||
private var rotatorSocket: BluetoothSocket? = null
|
||||
private var rotatorStream: OutputStream? = null
|
||||
private var rotatorConnected = false
|
||||
private var rotatorConnecting = false
|
||||
|
||||
override fun isConnecting(service: BtService): Boolean {
|
||||
val deviceId = serviceToDevice[service] ?: return false
|
||||
return deviceConnections[deviceId]?.connecting == true
|
||||
}
|
||||
private var frequencySocket: BluetoothSocket? = null
|
||||
private var frequencyStream: OutputStream? = null
|
||||
private var frequencyConnected = false
|
||||
private var frequencyConnecting = false
|
||||
|
||||
override fun connect(service: BtService, deviceId: String) {
|
||||
serviceToDevice[service] = deviceId
|
||||
val connection = deviceConnections.getOrPut(deviceId) {
|
||||
DeviceConnection()
|
||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||
reporterScope.launch {
|
||||
ensureRotatorConnected()
|
||||
if (!rotatorConnected) return@launch
|
||||
val el = if (elevation > 0.0) elevation else 0.0
|
||||
val command = format
|
||||
.replace($$"$AZ", azimuth.toString())
|
||||
.replace($$"$EL", el.toString())
|
||||
.unescapeControlChars()
|
||||
write(rotatorStream, command) { rotatorConnected = false }
|
||||
}
|
||||
if (connection.connected || connection.connecting) return
|
||||
connection.connectionJob = reporterScope.launch {
|
||||
}
|
||||
|
||||
override fun reportFrequency(format: String, frequency: Long) {
|
||||
reporterScope.launch {
|
||||
ensureFrequencyConnected()
|
||||
if (!frequencyConnected) return@launch
|
||||
val command = format
|
||||
.replace($$"$FREQ", frequency.toString())
|
||||
.unescapeControlChars()
|
||||
write(frequencyStream, command) { frequencyConnected = false }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureRotatorConnected() {
|
||||
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
|
||||
reporterScope.launch {
|
||||
try {
|
||||
connection.connecting = true
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceId)
|
||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppid)
|
||||
rotatorConnecting = true
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
|
||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
socket.connect()
|
||||
connection.socket = socket
|
||||
connection.outputStream = socket.outputStream
|
||||
connection.connected = true
|
||||
Log.i(tag, "$tag: Connected to $deviceId")
|
||||
rotatorSocket = socket
|
||||
rotatorStream = socket.outputStream
|
||||
rotatorConnected = true
|
||||
Log.i(tag, "Rotator connected to $rotatorDeviceId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "$tag: ${e.message}")
|
||||
connection.connected = false
|
||||
Log.e(tag, "Rotator connect error: ${e.message}")
|
||||
rotatorConnected = false
|
||||
} finally {
|
||||
connection.connecting = false
|
||||
rotatorConnecting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(service: BtService, buffer: String) {
|
||||
val deviceId = serviceToDevice[service] ?: return
|
||||
val connection = deviceConnections[deviceId] ?: return
|
||||
if (!connection.connected) return
|
||||
private fun ensureFrequencyConnected() {
|
||||
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
|
||||
reporterScope.launch {
|
||||
try {
|
||||
frequencyConnecting = true
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
|
||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
socket.connect()
|
||||
frequencySocket = socket
|
||||
frequencyStream = socket.outputStream
|
||||
frequencyConnected = true
|
||||
Log.i(tag, "Frequency connected to $frequencyDeviceId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Frequency connect error: ${e.message}")
|
||||
frequencyConnected = false
|
||||
} finally {
|
||||
frequencyConnecting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(stream: OutputStream?, data: String, onError: () -> Unit) {
|
||||
try {
|
||||
writeMutex.withLock {
|
||||
connection.outputStream?.write(buffer.toByteArray())
|
||||
stream?.write(data.toByteArray())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "$tag: Write failed ${e.message}")
|
||||
connection.connected = false
|
||||
Log.e(tag, "Write error: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: WithoutExtParams) {
|
||||
reporterScope.launch {
|
||||
if (!isConnected(BtService.ROTATOR)) return@launch
|
||||
val newElevation = if (elevation > 0.0) elevation else 0.0
|
||||
val azimuthString = intToStringWithLeadingZeroes(azimuth.toInt())
|
||||
val elevationString = intToStringWithLeadingZeroes(newElevation.toInt())
|
||||
val buffer = format
|
||||
.replace($$"$AZ", azimuthString)
|
||||
.replace($$"$EL", elevationString)
|
||||
.replace("\\r", "\r")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\t", "\t")
|
||||
write(BtService.ROTATOR, buffer)
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportFrequency(format: String, frequency: Long, params: WithoutExtParams) {
|
||||
reporterScope.launch {
|
||||
if (!isConnected(BtService.FREQUENCY)) return@launch
|
||||
val buffer = format
|
||||
.replace($$"$FREQ", frequency.toString())
|
||||
.replace("\\r", "\r")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\t", "\t")
|
||||
write(BtService.FREQUENCY, buffer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun intToStringWithLeadingZeroes(value: Int): String {
|
||||
return if (value > 0) {
|
||||
if (value < 10) "00$value" else if (value < 100) "0$value" else "$value"
|
||||
} else {
|
||||
val absValue = abs(value)
|
||||
if (value > -10) "-00$absValue" else if (value > -100) "-0$absValue" else "-$absValue"
|
||||
}
|
||||
}
|
||||
private fun String.unescapeControlChars(): String =
|
||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
object Ft817CatProtocol {
|
||||
|
||||
const val CMD_SET_FREQ: Byte = 0x01
|
||||
const val CMD_READ_FREQ_MODE: Byte = 0x03
|
||||
const val CMD_SET_MODE: Byte = 0x07
|
||||
const val CMD_PTT_ON: Byte = 0x08
|
||||
const val CMD_PTT_OFF: Byte = 0x88.toByte()
|
||||
const val CMD_CTCSS_MODE: Byte = 0x0A
|
||||
const val CMD_CTCSS_TONE: Byte = 0x0B
|
||||
|
||||
const val CTCSS_ENC_ON: Byte = 0x2A
|
||||
const val CTCSS_OFF: Byte = 0x8A.toByte()
|
||||
|
||||
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
|
||||
"LSB" to 0x00,
|
||||
"USB" to 0x01,
|
||||
"CW" to 0x02,
|
||||
"CW-R" to 0x03,
|
||||
"AM" to 0x04,
|
||||
"FM" to 0x08,
|
||||
"DIG" to 0x0A,
|
||||
"PKT" to 0x0C
|
||||
)
|
||||
|
||||
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
|
||||
|
||||
/**
|
||||
* Encode frequency in Hz to 4-byte BCD with 10 Hz resolution.
|
||||
* Example: 145500000 Hz → [0x14, 0x55, 0x00, 0x00]
|
||||
*/
|
||||
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||
val freq10Hz = frequencyHz / 10
|
||||
val bcd = ByteArray(4)
|
||||
val digits = String.format(Locale.US, "%08d", freq10Hz)
|
||||
for (i in 0 until 4) {
|
||||
val high = digits[i * 2] - '0'
|
||||
val low = digits[i * 2 + 1] - '0'
|
||||
bcd[i] = ((high shl 4) or low).toByte()
|
||||
}
|
||||
return bcd
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode 4-byte BCD frequency to Hz.
|
||||
*/
|
||||
fun decodeFrequencyBcd(bcd: ByteArray): Long {
|
||||
var freq10Hz = 0L
|
||||
for (i in 0 until 4) {
|
||||
val b = bcd[i].toInt() and 0xFF
|
||||
val high = b shr 4
|
||||
val low = b and 0x0F
|
||||
freq10Hz = freq10Hz * 100 + high * 10 + low
|
||||
}
|
||||
return freq10Hz * 10
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode CTCSS tone frequency (in Hz, e.g. 67.0) to 2-byte BCD.
|
||||
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
||||
*/
|
||||
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||
val tone01Hz = (toneHz * 10).roundToLong()
|
||||
val digits = String.format(Locale.US, "%04d", tone01Hz)
|
||||
val bcd = ByteArray(2)
|
||||
for (i in 0 until 2) {
|
||||
val high = digits[i * 2] - '0'
|
||||
val low = digits[i * 2 + 1] - '0'
|
||||
bcd[i] = ((high shl 4) or low).toByte()
|
||||
}
|
||||
return bcd
|
||||
}
|
||||
|
||||
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
|
||||
val bcd = encodeFrequencyBcd(frequencyHz)
|
||||
return byteArrayOf(bcd[0], bcd[1], bcd[2], bcd[3], CMD_SET_FREQ)
|
||||
}
|
||||
|
||||
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
||||
}
|
||||
|
||||
fun buildReadFreqModeCommand(): ByteArray {
|
||||
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_READ_FREQ_MODE)
|
||||
}
|
||||
|
||||
fun buildPttOnCommand(): ByteArray {
|
||||
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_ON)
|
||||
}
|
||||
|
||||
fun buildPttOffCommand(): ByteArray {
|
||||
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_OFF)
|
||||
}
|
||||
|
||||
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
|
||||
val sub = if (enabled) CTCSS_ENC_ON else CTCSS_OFF
|
||||
return byteArrayOf(sub, 0x00, 0x00, 0x00, CMD_CTCSS_MODE)
|
||||
}
|
||||
|
||||
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
|
||||
val bcd = encodeCtcssToneBcd(toneHz)
|
||||
return byteArrayOf(bcd[0], bcd[1], 0x00, 0x00, CMD_CTCSS_TONE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the 5-byte response from a READ FREQ+MODE command.
|
||||
* Returns (frequencyHz, modeString) or null if parsing fails.
|
||||
*/
|
||||
fun parseReadResponse(response: ByteArray): Pair<Long, String>? {
|
||||
if (response.size < 5) return null
|
||||
val freqBcd = response.copyOfRange(0, 4)
|
||||
val frequencyHz = decodeFrequencyBcd(freqBcd)
|
||||
val modeByte = response[4]
|
||||
val mode = BYTE_TO_MODE[modeByte] ?: return null
|
||||
return Pair(frequencyHz, mode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class Ft817Controller(
|
||||
private val bluetoothManager: BluetoothManager,
|
||||
private val deviceAddress: String
|
||||
) : IRadioController {
|
||||
|
||||
private val tag = "FT817"
|
||||
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||
private val ioMutex = Mutex()
|
||||
private val commandDelayMs = 200L
|
||||
private val maxAckReadFailures = 3
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var outputStream: OutputStream? = null
|
||||
private var inputStream: InputStream? = null
|
||||
private var ackReadFailureCount = 0
|
||||
|
||||
override var isConnected: Boolean = false
|
||||
private set
|
||||
|
||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (isConnected) return@withContext true
|
||||
if (deviceAddress.isBlank()) return@withContext false
|
||||
try {
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
btSocket.connect()
|
||||
socket = btSocket
|
||||
outputStream = btSocket.outputStream
|
||||
inputStream = btSocket.inputStream
|
||||
ackReadFailureCount = 0
|
||||
isConnected = true
|
||||
Log.i(tag, "Connected to $deviceAddress")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Connect error: ${e.message}")
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disconnect() {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
inputStream?.close()
|
||||
outputStream?.close()
|
||||
socket?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Disconnect error: ${e.message}")
|
||||
} finally {
|
||||
inputStream = null
|
||||
outputStream = null
|
||||
socket = null
|
||||
ackReadFailureCount = 0
|
||||
isConnected = false
|
||||
Log.i(tag, "Disconnected from $deviceAddress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock {
|
||||
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
|
||||
val cmd = Ft817CatProtocol.buildSetModeCommand(mode) ?: return@withContext false
|
||||
ioMutex.withLock { sendCommandWithAck(cmd) }
|
||||
}
|
||||
|
||||
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock {
|
||||
sendCommandWithAck(Ft817CatProtocol.buildCtcssModeCommand(enabled))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock {
|
||||
sendCommandWithAck(Ft817CatProtocol.buildSetCtcssToneCommand(toneHz))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock {
|
||||
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
||||
if (!sent) return@withContext null
|
||||
delay(commandDelayMs.milliseconds)
|
||||
val response = readResponse() ?: return@withContext null
|
||||
Ft817CatProtocol.parseReadResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOnCommand()) }
|
||||
}
|
||||
|
||||
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOffCommand()) }
|
||||
}
|
||||
|
||||
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
outputStream?.write(bytes) ?: return@withContext false
|
||||
outputStream?.flush()
|
||||
delay(commandDelayMs.milliseconds)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Send error: ${e.message}")
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
||||
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
||||
if (!sendCommand(bytes)) return false
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val ack = inputStream?.read() ?: run {
|
||||
ackReadFailureCount = 0
|
||||
isConnected = false
|
||||
return@withContext false
|
||||
}
|
||||
if (ack < 0) {
|
||||
ackReadFailureCount = 0
|
||||
Log.i(tag, "ACK stream closed by remote device")
|
||||
isConnected = false
|
||||
return@withContext false
|
||||
}
|
||||
ackReadFailureCount = 0
|
||||
ack == 0x00
|
||||
} catch (e: Exception) {
|
||||
ackReadFailureCount += 1
|
||||
Log.w(tag, "ACK read error (${ackReadFailureCount}/$maxAckReadFailures): ${e.message}")
|
||||
if (ackReadFailureCount >= maxAckReadFailures) {
|
||||
Log.e(tag, "Too many ACK read errors, marking radio disconnected")
|
||||
isConnected = false
|
||||
false
|
||||
} else {
|
||||
true // Command was sent, treat transient ACK read failures as best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readResponse(): ByteArray? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val responseSize = 5
|
||||
val buffer = ByteArray(responseSize)
|
||||
var read = 0
|
||||
while (read < responseSize) {
|
||||
val count = inputStream?.read(buffer, read, responseSize - read) ?: run {
|
||||
isConnected = false
|
||||
return@withContext null
|
||||
}
|
||||
if (count < 0) {
|
||||
Log.i(tag, "Response stream closed by remote device")
|
||||
isConnected = false
|
||||
return@withContext null
|
||||
}
|
||||
read += count
|
||||
}
|
||||
buffer
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Read error: ${e.message}")
|
||||
isConnected = false
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Icom IC-705 CI-V controller over Bluetooth SPP.
|
||||
*
|
||||
* The IC-705 emits broadcast frames continuously (band scope, UTC, signal
|
||||
* level, …). A reply to any command we send may therefore be buried in
|
||||
* that noise. All response reads drain up to [ACK_TIMEOUT_MS] and scan the
|
||||
* entire accumulated buffer for the frame we expect rather than assuming
|
||||
* the very next byte is the response.
|
||||
*/
|
||||
class Ic705Controller(
|
||||
private val bluetoothManager: BluetoothManager,
|
||||
private val deviceAddress: String
|
||||
) : IRadioController {
|
||||
|
||||
private val tag = "IC705"
|
||||
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||
private val ioMutex = Mutex()
|
||||
|
||||
/** Time budget (ms) to wait for a response amid broadcast noise. */
|
||||
private val ACK_TIMEOUT_MS = 500L
|
||||
/** Polling interval while draining the input buffer. */
|
||||
private val POLL_INTERVAL_MS = 20L
|
||||
/** Small pause after writing a command before reading the response. */
|
||||
private val WRITE_SETTLE_MS = 50L
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var outputStream: OutputStream? = null
|
||||
private var inputStream: InputStream? = null
|
||||
|
||||
override var isConnected: Boolean = false
|
||||
private set
|
||||
|
||||
// ── Connection ──────────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (isConnected) return@withContext true
|
||||
if (deviceAddress.isBlank()) return@withContext false
|
||||
try {
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
btSocket.connect()
|
||||
socket = btSocket
|
||||
outputStream = btSocket.outputStream
|
||||
inputStream = btSocket.inputStream
|
||||
isConnected = true
|
||||
// Enter VFO mode — frequency/mode commands return FA if the radio
|
||||
// is in memory-channel mode. Safe to send regardless of current state.
|
||||
Log.i(tag, "Connected to $deviceAddress — entering VFO mode")
|
||||
val vfoCmd = IcomCivProtocol.buildEnterVfoModeCommand()
|
||||
Log.d(tag, "CMD enterVfoMode → ${IcomCivProtocol.toHex(vfoCmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(vfoCmd) }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Connect error: ${e.message}")
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disconnect() {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
inputStream?.close()
|
||||
outputStream?.close()
|
||||
socket?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Disconnect error: ${e.message}")
|
||||
} finally {
|
||||
inputStream = null
|
||||
outputStream = null
|
||||
socket = null
|
||||
isConnected = false
|
||||
Log.i(tag, "Disconnected from $deviceAddress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── IRadioController – standard operations ──────────────────────────────
|
||||
|
||||
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "setFrequency: ${frequencyHz}Hz")
|
||||
ioMutex.withLock {
|
||||
val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz)
|
||||
Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
sendAndWaitAck(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
|
||||
val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run {
|
||||
Log.w(tag, "setMode: unknown mode '$mode'")
|
||||
return@withContext false
|
||||
}
|
||||
Log.d(tag, "setMode: $mode")
|
||||
Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "setCtcssMode: $enabled")
|
||||
val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled)
|
||||
Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "setCtcssTone: ${toneHz}Hz")
|
||||
val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz)
|
||||
Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock {
|
||||
val cmd = IcomCivProtocol.buildReadFreqCommand()
|
||||
Log.d(tag, "CMD readFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_READ_FREQ) ?: return@withContext null
|
||||
// Read-freq reply payload: [cmd byte already stripped by parseResponse] [5 freq bytes] [mode] [filter]
|
||||
IcomCivProtocol.parseFreqModePayload(payload).also {
|
||||
if (it != null) Log.d(tag, "readFreqMode: ${it.first}Hz, ${it.second}")
|
||||
else Log.w(tag, "readFreqMode: parse failed, payload=${IcomCivProtocol.toHex(payload)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.w(tag, "pttOn: not used for IC-705")
|
||||
true
|
||||
}
|
||||
|
||||
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.w(tag, "pttOff: not used for IC-705")
|
||||
true
|
||||
}
|
||||
|
||||
// ── IRadioController – IC-705 extended operations ───────────────────────
|
||||
|
||||
/** Select the band for [frequencyHz] via CMD 0x1A sub 0x00 (band stacking register). */
|
||||
override suspend fun setBand(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
val cmd = IcomCivProtocol.buildBandSelectCommand(frequencyHz) ?: run {
|
||||
Log.w(tag, "setBand: no band code for ${frequencyHz}Hz — skipping")
|
||||
return@withContext false
|
||||
}
|
||||
Log.d(tag, "CMD setBand (${frequencyHz}Hz) → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
/** Select VFO-A (main/RX) or VFO-B (sub/TX). */
|
||||
override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||
val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand()
|
||||
else IcomCivProtocol.buildSelectVfoBCommand()
|
||||
Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO).
|
||||
*/
|
||||
override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||
val cmd = IcomCivProtocol.buildSplitModeCommand(enabled)
|
||||
Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00).
|
||||
* In split mode the radio automatically switches active VFO on PTT, so
|
||||
* always writing to the active VFO is the correct strategy.
|
||||
*/
|
||||
override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "setWorkingFrequency (0x25/00): ${frequencyHz}Hz")
|
||||
val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz)
|
||||
Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO).
|
||||
* Sent every tracking cycle in split mode alongside [setWorkingFrequency].
|
||||
*/
|
||||
override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz")
|
||||
val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz)
|
||||
Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the frequency of the currently active VFO (CMD 0x25 sub 0x00).
|
||||
* Used for tuning detection in split mode.
|
||||
*/
|
||||
override suspend fun readWorkingFrequency(): Long? = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock {
|
||||
val cmd = IcomCivProtocol.buildReadWorkingFreqCommand()
|
||||
Log.d(tag, "CMD readWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
||||
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
||||
Log.d(tag, "readWorkingFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
||||
if (payload.size < 6) {
|
||||
Log.w(tag, "readWorkingFreq: payload too short (${payload.size} bytes)")
|
||||
return@withContext null
|
||||
}
|
||||
val freqBcd = payload.sliceArray(1..5)
|
||||
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
||||
Log.d(tag, "readWorkingFreq: ${freq}Hz")
|
||||
freq
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the frequency of the inactive/TX VFO (CMD 0x25 sub 0x01).
|
||||
* Used for tuning detection in split mode.
|
||||
*/
|
||||
override suspend fun readTxVfoFrequency(): Long? = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock {
|
||||
val cmd = IcomCivProtocol.buildReadTxVfoFreqCommand()
|
||||
Log.d(tag, "CMD readTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
||||
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
||||
Log.d(tag, "readTxVfoFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
||||
if (payload.size < 6) {
|
||||
Log.w(tag, "readTxVfoFreq: payload too short (${payload.size} bytes)")
|
||||
return@withContext null
|
||||
}
|
||||
val freqBcd = payload.sliceArray(1..5)
|
||||
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
||||
Log.d(tag, "readTxVfoFreq: ${freq}Hz")
|
||||
freq
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal I/O helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write [cmd] to the radio and drain the input stream for up to
|
||||
* [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame.
|
||||
*/
|
||||
private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean {
|
||||
if (!write(cmd)) return false
|
||||
delay(WRITE_SETTLE_MS)
|
||||
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
||||
val ok = IcomCivProtocol.containsAck(buf)
|
||||
if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
||||
return ok
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [cmd] to the radio and drain the input stream for up to
|
||||
* [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd].
|
||||
* Returns the payload bytes of that frame, or null on timeout/error.
|
||||
*/
|
||||
private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? {
|
||||
if (!write(cmd)) return null
|
||||
delay(WRITE_SETTLE_MS)
|
||||
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
||||
val response = IcomCivProtocol.parseResponse(buf, expectCmd)
|
||||
if (response == null) {
|
||||
Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " +
|
||||
"in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
||||
}
|
||||
return response?.payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain whatever bytes the radio has buffered within a [timeoutMs] window.
|
||||
* Exits early as soon as a complete CI-V frame addressed to us is present
|
||||
* in the buffer (i.e., FE FE E0 A4 … FD), so we don't waste the remaining
|
||||
* timeout on responses that already arrived.
|
||||
*/
|
||||
private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray {
|
||||
val result = mutableListOf<Byte>()
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
val stream = inputStream ?: return ByteArray(0)
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
try {
|
||||
val available = stream.available()
|
||||
if (available > 0) {
|
||||
val chunk = ByteArray(available)
|
||||
val read = stream.read(chunk)
|
||||
if (read > 0) {
|
||||
result.addAll(chunk.take(read))
|
||||
// Exit early once we have a complete frame for us
|
||||
if (hasCompleteFrameForUs(result)) break
|
||||
}
|
||||
} else {
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Drain error: ${e.message}")
|
||||
isConnected = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return result.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if [buf] contains a complete CI-V frame addressed to the
|
||||
* controller (FE FE [ADDR_CTRL] [ADDR_IC705] … FD).
|
||||
* CI-V data bytes cannot be 0xFD, so the first 0xFD after the header is
|
||||
* always the frame terminator.
|
||||
*/
|
||||
private fun hasCompleteFrameForUs(buf: List<Byte>): Boolean {
|
||||
var i = 0
|
||||
while (i < buf.size - 4) {
|
||||
if (buf[i] == IcomCivProtocol.PREAMBLE &&
|
||||
buf[i + 1] == IcomCivProtocol.PREAMBLE &&
|
||||
buf[i + 2] == IcomCivProtocol.ADDR_CTRL &&
|
||||
buf[i + 3] == IcomCivProtocol.ADDR_IC705
|
||||
) {
|
||||
for (k in i + 4 until buf.size) {
|
||||
if (buf[k] == IcomCivProtocol.END_OF_MSG) return true
|
||||
}
|
||||
return false // header found but no FD yet
|
||||
}
|
||||
i++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun write(bytes: ByteArray): Boolean {
|
||||
return try {
|
||||
outputStream?.write(bytes)
|
||||
outputStream?.flush()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Write error: ${e.message}")
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Icom CI-V protocol encoder/decoder for the IC-705.
|
||||
*
|
||||
* Frame structure:
|
||||
* FE FE <DEST> <SRC> <CMD> [<SUB>] [<DATA...>] FD
|
||||
*
|
||||
* IC-705 default CI-V address : 0xA4
|
||||
* Controller (us) address : 0xE0
|
||||
*/
|
||||
object IcomCivProtocol {
|
||||
|
||||
// ── Framing constants ──────────────────────────────────────────────────
|
||||
const val PREAMBLE: Byte = 0xFE.toByte()
|
||||
const val END_OF_MSG: Byte = 0xFD.toByte()
|
||||
const val ACK_OK: Byte = 0xFB.toByte()
|
||||
const val ACK_NG: Byte = 0xFA.toByte()
|
||||
|
||||
// ── Address constants ──────────────────────────────────────────────────
|
||||
/** Default CI-V address of the IC-705. */
|
||||
const val ADDR_IC705: Byte = 0xA4.toByte()
|
||||
/** Default CI-V address of the controller (us). */
|
||||
const val ADDR_CTRL: Byte = 0xE0.toByte()
|
||||
|
||||
// ── Command bytes ──────────────────────────────────────────────────────
|
||||
/** Read operating frequency (main VFO). */
|
||||
const val CMD_READ_FREQ: Byte = 0x03
|
||||
/** Set operating frequency (main VFO). */
|
||||
const val CMD_SET_FREQ: Byte = 0x05
|
||||
/** Set operating mode. */
|
||||
const val CMD_SET_MODE: Byte = 0x06
|
||||
/** Select VFO / memory. */
|
||||
const val CMD_SELECT_VFO: Byte = 0x07
|
||||
/**
|
||||
* Select operating mode (VFO vs memory-channel).
|
||||
* Sub 0x00 = VFO mode. Must be sent after connect if the radio is in
|
||||
* memory-channel mode — frequency/mode commands return FA until it is.
|
||||
*/
|
||||
const val CMD_SELECT_OP_MODE: Byte = 0x08
|
||||
/** Set repeater duplex / SPLIT. */
|
||||
const val CMD_DUPLEX_SPLIT: Byte = 0x0F
|
||||
/** Band stacking register / band select (sub 0x00 = select, data = BCD band number). */
|
||||
const val CMD_BAND_SELECT: Byte = 0x1A
|
||||
/** Read/write CTCSS tone frequency. */
|
||||
const val CMD_CTCSS_TONE: Byte = 0x1B
|
||||
/** Read/write misc settings (used for enabling CTCSS encode). */
|
||||
const val CMD_MISC_SETTING: Byte = 0x16
|
||||
/** Read/write selected-VFO frequency (cmd 0x25). */
|
||||
const val CMD_SELECTED_VFO_FREQ: Byte = 0x25
|
||||
|
||||
// ── Sub-command bytes ──────────────────────────────────────────────────
|
||||
/** Sub for CMD_SELECT_VFO: select VFO-A (main). */
|
||||
const val SUB_VFO_A: Byte = 0x00
|
||||
/** Sub for CMD_SELECT_VFO: select VFO-B (sub). */
|
||||
const val SUB_VFO_B: Byte = 0x01
|
||||
/** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */
|
||||
const val SUB_SPLIT_OFF: Byte = 0x00
|
||||
/** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */
|
||||
const val SUB_SPLIT_ON: Byte = 0x01
|
||||
/** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */
|
||||
const val SUB_SELECTED_VFO: Byte = 0x00
|
||||
/** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */
|
||||
const val SUB_UNSELECTED_VFO: Byte = 0x01
|
||||
/** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */
|
||||
const val SUB_CTCSS_SETTING: Byte = 0x42.toByte()
|
||||
|
||||
// ── Mode bytes ────────────────────────────────────────────────────────
|
||||
/** Maps mode strings (upper-case) → IC-705 mode bytes. */
|
||||
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
|
||||
"LSB" to 0x00,
|
||||
"USB" to 0x01,
|
||||
"AM" to 0x02,
|
||||
"CW" to 0x03,
|
||||
"RTTY" to 0x04,
|
||||
"FM" to 0x05,
|
||||
"WFM" to 0x06,
|
||||
"CW-R" to 0x07,
|
||||
"RTTY-R" to 0x08,
|
||||
"DV" to 0x12,
|
||||
"AFSK" to 0x05 // AFSK uses FM modulation
|
||||
)
|
||||
|
||||
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
|
||||
|
||||
// ── Frequency BCD encoding ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encode a frequency in Hz to the IC-705's 5-byte BCD format.
|
||||
*
|
||||
* The IC-705 uses 5 bytes, LSB pair first, with 1 Hz resolution.
|
||||
* Example: 145,500,000 Hz → "0145500000" → pairs LSB→MSB:
|
||||
* [00, 00, 50, 45, 01]
|
||||
*/
|
||||
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||
val digits = String.format(Locale.US, "%010d", frequencyHz)
|
||||
val bcd = ByteArray(5)
|
||||
for (i in 0 until 5) {
|
||||
// digits are MSB first; we want pair index 0 = LSB pair
|
||||
val pairIndex = 4 - i
|
||||
val high = digits[pairIndex * 2] - '0'
|
||||
val low = digits[pairIndex * 2 + 1] - '0'
|
||||
bcd[i] = ((high shl 4) or low).toByte()
|
||||
}
|
||||
return bcd
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode 5-byte BCD frequency (LSB pair first) to Hz.
|
||||
*/
|
||||
fun decodeFrequencyBcd(bcd: ByteArray): Long {
|
||||
// Build digit string MSB→LSB by reversing the byte order
|
||||
var freqHz = 0L
|
||||
for (i in 4 downTo 0) {
|
||||
val b = bcd[i].toInt() and 0xFF
|
||||
val high = b shr 4
|
||||
val low = b and 0x0F
|
||||
freqHz = freqHz * 100 + high * 10 + low
|
||||
}
|
||||
return freqHz
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a CTCSS tone (Hz, e.g. 67.0) to 2-byte BCD (0.1 Hz resolution).
|
||||
* 67.0 → 670 (tenths of Hz) → BCD bytes [0x06, 0x70].
|
||||
*/
|
||||
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||
val tone01 = (toneHz * 10).toLong()
|
||||
val digits = String.format(Locale.US, "%04d", tone01)
|
||||
return byteArrayOf(
|
||||
((digits[0] - '0') shl 4 or (digits[1] - '0')).toByte(),
|
||||
((digits[2] - '0') shl 4 or (digits[3] - '0')).toByte()
|
||||
)
|
||||
}
|
||||
|
||||
// ── Message builders ───────────────────────────────────────────────────
|
||||
|
||||
/** Wrap payload bytes in a CI-V frame: FE FE DEST SRC ... FD. */
|
||||
private fun frame(vararg payload: Byte): ByteArray {
|
||||
return byteArrayOf(PREAMBLE, PREAMBLE, ADDR_IC705, ADDR_CTRL) +
|
||||
payload +
|
||||
byteArrayOf(END_OF_MSG)
|
||||
}
|
||||
|
||||
/** Set operating frequency via CMD 0x05 (main VFO). */
|
||||
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
|
||||
return frame(CMD_SET_FREQ, *encodeFrequencyBcd(frequencyHz))
|
||||
}
|
||||
|
||||
/**
|
||||
* Set selected-VFO frequency via CMD 0x25 sub 0x00.
|
||||
* This updates whichever VFO is currently active (RX or TX after split).
|
||||
*/
|
||||
fun buildSetWorkingFreqCommand(frequencyHz: Long): ByteArray {
|
||||
return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
||||
}
|
||||
|
||||
/**
|
||||
* Set unselected-VFO frequency via CMD 0x25 sub 0x01.
|
||||
* In split mode while PTT is pressed the IC-705 makes VFO-B active, so
|
||||
* this command targets VFO-A (the RX VFO) — and vice-versa when in RX.
|
||||
* Use this to update the TX VFO when PTT is on.
|
||||
*/
|
||||
fun buildSetUnselectedVfoFreqCommand(frequencyHz: Long): ByteArray {
|
||||
return frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
||||
}
|
||||
|
||||
/** Read operating frequency (CMD 0x03). */
|
||||
fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ)
|
||||
|
||||
/** Read selected (active) VFO frequency (CMD 0x25 sub 0x00). */
|
||||
fun buildReadWorkingFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO)
|
||||
|
||||
/** Read unselected (inactive/TX in split) VFO frequency (CMD 0x25 sub 0x01). */
|
||||
fun buildReadTxVfoFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO)
|
||||
|
||||
/**
|
||||
* Select band via CMD 0x1A sub 0x00.
|
||||
* Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm.
|
||||
* Returns null if [frequencyHz] doesn't fall in a known amateur band.
|
||||
*/
|
||||
fun buildBandSelectCommand(frequencyHz: Long): ByteArray? {
|
||||
val code = bandCodeForFrequency(frequencyHz) ?: return null
|
||||
return frame(CMD_BAND_SELECT, 0x00, code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a frequency in Hz to the IC-705 band stacking register code.
|
||||
* Codes are BCD (band number in decimal expressed as hex nibbles).
|
||||
*/
|
||||
fun bandCodeForFrequency(frequencyHz: Long): Byte? = when {
|
||||
frequencyHz in 1_800_000L ..1_999_999L -> 0x01 // 160 m
|
||||
frequencyHz in 3_500_000L ..3_999_999L -> 0x02 // 80 m
|
||||
frequencyHz in 7_000_000L ..7_299_999L -> 0x03 // 40 m
|
||||
frequencyHz in 10_100_000L ..10_149_999L -> 0x04 // 30 m
|
||||
frequencyHz in 14_000_000L ..14_349_999L -> 0x05 // 20 m
|
||||
frequencyHz in 18_068_000L ..18_167_999L -> 0x06 // 17 m
|
||||
frequencyHz in 21_000_000L ..21_449_999L -> 0x07 // 15 m
|
||||
frequencyHz in 24_890_000L ..24_989_999L -> 0x08 // 12 m
|
||||
frequencyHz in 28_000_000L ..29_699_999L -> 0x09 // 10 m
|
||||
frequencyHz in 50_000_000L ..53_999_999L -> 0x10 // 6 m (BCD 10)
|
||||
frequencyHz in 144_000_000L ..147_999_999L -> 0x11 // 2 m (BCD 11)
|
||||
frequencyHz in 420_000_000L ..449_999_999L -> 0x12 // 70 cm (BCD 12)
|
||||
frequencyHz in 1_240_000_000L ..1_299_999_999L -> 0x13 // 23 cm (BCD 13)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Set operating mode (CMD 0x06). Filter byte is omitted — radio uses its default filter for the mode. */
|
||||
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||
return frame(CMD_SET_MODE, modeByte)
|
||||
}
|
||||
|
||||
/** Select VFO-A (CMD 0x07 sub 0x00). */
|
||||
fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A)
|
||||
|
||||
/** Select VFO-B (CMD 0x07 sub 0x01). */
|
||||
fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B)
|
||||
|
||||
/**
|
||||
* Enter VFO operating mode (CMD 0x08 sub 0x00).
|
||||
* Sent after connect — if the radio is in memory-channel mode frequency
|
||||
* and mode commands return FA until this is issued.
|
||||
*/
|
||||
fun buildEnterVfoModeCommand(): ByteArray = frame(CMD_SELECT_OP_MODE, 0x00)
|
||||
|
||||
/** Enable or disable SPLIT mode (CMD 0x0F). */
|
||||
fun buildSplitModeCommand(enable: Boolean): ByteArray {
|
||||
val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF
|
||||
return frame(CMD_DUPLEX_SPLIT, sub)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable CTCSS encode (CMD 0x16 sub 0x42).
|
||||
* 0x01 = CTCSS encoder ON, 0x00 = OFF.
|
||||
*/
|
||||
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
|
||||
val value: Byte = if (enabled) 0x01 else 0x00
|
||||
return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set CTCSS tone frequency (CMD 0x1B sub 0x00).
|
||||
*/
|
||||
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
|
||||
val bcd = encodeCtcssToneBcd(toneHz)
|
||||
return frame(CMD_CTCSS_TONE, 0x00, *bcd)
|
||||
}
|
||||
|
||||
// ── Response parsing ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find and parse a complete CI-V response frame from a buffer.
|
||||
*
|
||||
* Returns the bytes between "FE FE E0 A4 <CMD>" and FD, or null if no
|
||||
* complete frame was found. The search is tolerant of interleaved
|
||||
* broadcast traffic.
|
||||
*
|
||||
* @param buf bytes accumulated from the radio
|
||||
* @param expectCmd the command byte we are looking for in the reply, or
|
||||
* null to accept any command response from the radio
|
||||
*/
|
||||
fun parseResponse(buf: ByteArray, expectCmd: Byte?): ParsedResponse? {
|
||||
var i = 0
|
||||
while (i < buf.size - 5) {
|
||||
// Look for FE FE preamble
|
||||
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
|
||||
val dest = buf[i + 2]
|
||||
val src = buf[i + 3]
|
||||
val cmd = buf[i + 4]
|
||||
// We only care about frames addressed to us from the radio
|
||||
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
|
||||
// Find the terminating FD
|
||||
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
|
||||
if (fdIdx < 0) break // incomplete frame, wait for more data
|
||||
val payload = buf.copyOfRange(i + 5, fdIdx)
|
||||
if (expectCmd == null || cmd == expectCmd) {
|
||||
return ParsedResponse(cmd, payload, fdIdx + 1)
|
||||
}
|
||||
i = fdIdx + 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun ByteArray.indexOf(b: Byte, startIndex: Int): Int {
|
||||
for (k in startIndex until size) if (this[k] == b) return k
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a buffer contains an OK acknowledgement (FB FD) from
|
||||
* the radio. Tolerates broadcast noise before the ACK.
|
||||
*/
|
||||
fun containsAck(buf: ByteArray): Boolean {
|
||||
var i = 0
|
||||
while (i < buf.size - 5) {
|
||||
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
|
||||
val dest = buf[i + 2]
|
||||
val src = buf[i + 3]
|
||||
val cmd = buf[i + 4]
|
||||
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
|
||||
// Skip to FD
|
||||
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
|
||||
if (fdIdx < 0) break
|
||||
if (cmd == ACK_OK) return true
|
||||
if (cmd == ACK_NG) return false
|
||||
i = fdIdx + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
||||
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
||||
*/
|
||||
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||
if (payload.size < 6) return null
|
||||
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
||||
return freqHz to mode
|
||||
}
|
||||
|
||||
/** Hex dump of bytes, useful for debug logging. */
|
||||
fun toHex(bytes: ByteArray): String =
|
||||
bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) }
|
||||
|
||||
data class ParsedResponse(
|
||||
val cmd: Byte,
|
||||
val payload: ByteArray,
|
||||
/** Index in the source buffer immediately after the FD terminator. */
|
||||
val nextOffset: Int
|
||||
)
|
||||
}
|
||||
+107
-66
@@ -17,11 +17,10 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.BtService
|
||||
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -29,89 +28,131 @@ import java.net.InetSocketAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.SocketChannel
|
||||
|
||||
enum class NetworkService { ROTATOR, FREQUENCY }
|
||||
class NetworkReporter(
|
||||
private val reporterScope: CoroutineScope,
|
||||
private val rotatorServer: String,
|
||||
private val rotatorPort: Int,
|
||||
private val frequencyServer: String,
|
||||
private val frequencyPort: Int,
|
||||
private val frequencyOffsetHz: Long = 0L
|
||||
) : IReporter {
|
||||
|
||||
data class SocketConnection(
|
||||
var socket: SocketChannel? = null,
|
||||
var connected: Boolean = false,
|
||||
var connecting: Boolean = false,
|
||||
var connectionJob: Job? = null
|
||||
)
|
||||
|
||||
class NetworkReporter(private val reporterScope: CoroutineScope) : IReporterRepo<ExtendedParams> {
|
||||
private val serviceToAddress = mutableMapOf<NetworkService, String>()
|
||||
private val connections = mutableMapOf<String, SocketConnection>()
|
||||
private val writeMutex = Mutex()
|
||||
private val connectionMutex = Mutex()
|
||||
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
||||
|
||||
fun isConnected(service: NetworkService): Boolean {
|
||||
val addr = serviceToAddress[service] ?: return false
|
||||
return connections[addr]?.connected == true
|
||||
}
|
||||
private var rotatorSocket: SocketChannel? = null
|
||||
private var rotatorConnected = false
|
||||
|
||||
private fun getOrCreateConnection(addr: String): SocketConnection {
|
||||
return connections.getOrPut(addr) { SocketConnection() }
|
||||
}
|
||||
private var frequencySocket: SocketChannel? = null
|
||||
private var frequencyConnected = false
|
||||
|
||||
fun connect(service: NetworkService, server: String, port: Int) {
|
||||
val addr = "$server:$port"
|
||||
serviceToAddress[service] = addr
|
||||
val connection = getOrCreateConnection(addr)
|
||||
if (connection.connected || connection.connecting) return
|
||||
connection.connectionJob = reporterScope.launch {
|
||||
try {
|
||||
connection.connecting = true
|
||||
val socket = SocketChannel.open(InetSocketAddress(server, port))
|
||||
connection.socket = socket
|
||||
connection.connected = true
|
||||
println("NetworkReporter: $service connected to $addr")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter connect error: ${e.message}")
|
||||
connection.connected = false
|
||||
} finally {
|
||||
connection.connecting = false
|
||||
init {
|
||||
// Keep only the latest frequency command to avoid stale backlog and effective lag.
|
||||
reporterScope.launch {
|
||||
for (command in frequencyCommands) {
|
||||
ensureFrequencyConnected()
|
||||
if (!frequencyConnected) continue
|
||||
write(frequencySocket, command) { resetFrequencyConnection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(service: NetworkService, command: String) {
|
||||
val addr = serviceToAddress[service] ?: return
|
||||
val connection = connections[addr] ?: return
|
||||
if (!connection.connected) return
|
||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||
reporterScope.launch {
|
||||
ensureRotatorConnected()
|
||||
if (!rotatorConnected) return@launch
|
||||
val el = if (elevation > 0.0) elevation else 0.0
|
||||
val command = format
|
||||
.replace($$"$AZ", azimuth.toString())
|
||||
.replace($$"$EL", el.toString())
|
||||
.unescapeControlChars()
|
||||
write(rotatorSocket, command) { rotatorConnected = false }
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportFrequency(format: String, frequency: Long) {
|
||||
val clampedOffset = frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
||||
val command = format
|
||||
.replace($$"$FREQ", correctedFreq.toString())
|
||||
.unescapeControlChars()
|
||||
frequencyCommands.trySend(command)
|
||||
}
|
||||
|
||||
private suspend fun ensureRotatorConnected() {
|
||||
connectionMutex.withLock {
|
||||
if (rotatorConnected || rotatorServer.isBlank()) return
|
||||
try {
|
||||
resetRotatorConnection()
|
||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||
rotatorConnected = true
|
||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter rotator connect error: ${e.message}")
|
||||
resetRotatorConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureFrequencyConnected() {
|
||||
connectionMutex.withLock {
|
||||
if (frequencyConnected || frequencyServer.isBlank()) return
|
||||
try {
|
||||
resetFrequencyConnection()
|
||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||
frequencyConnected = true
|
||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter frequency connect error: ${e.message}")
|
||||
resetFrequencyConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(socket: SocketChannel?, command: String, onError: () -> Unit) {
|
||||
try {
|
||||
writeMutex.withLock {
|
||||
val buffer = ByteBuffer.wrap("\\$command\n".toByteArray())
|
||||
connection.socket?.write(buffer)
|
||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||
while (buffer.hasRemaining()) {
|
||||
socket?.write(buffer)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter write error: ${e.message}")
|
||||
connection.connected = false
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
override fun isConnected(service: BtService): Boolean = false
|
||||
private fun resetRotatorConnection() {
|
||||
rotatorConnected = false
|
||||
closeQuietly(rotatorSocket)
|
||||
rotatorSocket = null
|
||||
}
|
||||
|
||||
override fun isConnecting(service: BtService): Boolean = false
|
||||
private fun resetFrequencyConnection() {
|
||||
frequencyConnected = false
|
||||
closeQuietly(frequencySocket)
|
||||
frequencySocket = null
|
||||
}
|
||||
|
||||
override fun connect(service: BtService, deviceId: String) {}
|
||||
private fun closeQuietly(socket: SocketChannel?) {
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: ExtendedParams) {
|
||||
reporterScope.launch {
|
||||
connect(NetworkService.ROTATOR, params.server, params.port)
|
||||
if (!isConnected(NetworkService.ROTATOR)) return@launch
|
||||
val el = if (elevation > 0.0) elevation else 0.0
|
||||
val command = format
|
||||
.replace("\$AZ", azimuth.toString())
|
||||
.replace("\$EL", el.toString())
|
||||
write(NetworkService.ROTATOR, command)
|
||||
private fun Long.safeAdd(delta: Long): Long {
|
||||
return when {
|
||||
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
|
||||
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
|
||||
else -> this + delta
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportFrequency(format: String, frequency: Long, params: ExtendedParams) {
|
||||
reporterScope.launch {
|
||||
connect(NetworkService.FREQUENCY, params.server, params.port)
|
||||
if (!isConnected(NetworkService.FREQUENCY)) return@launch
|
||||
val command = format.replace("\$FREQ", frequency.toString())
|
||||
write(NetworkService.FREQUENCY, command)
|
||||
}
|
||||
}
|
||||
private fun String.unescapeControlChars(): String =
|
||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||
}
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RadioTrackingService(
|
||||
private val appScope: CoroutineScope,
|
||||
private val bluetoothManager: BluetoothManager,
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : IRadioTrackingService {
|
||||
|
||||
private val tag = "RadioTracking"
|
||||
/** Delay between each step of the split-mode init sequence (ms). */
|
||||
private val INIT_STEP_DELAY_MS = 200L
|
||||
private val _state = MutableStateFlow(RadioTrackingState())
|
||||
override val state: StateFlow<RadioTrackingState> = _state
|
||||
|
||||
private var txController: IRadioController? = null
|
||||
private var rxController: IRadioController? = null
|
||||
private var trackingJob: Job? = null
|
||||
|
||||
// ── Connection ──────────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun connectRadios() {
|
||||
txController?.disconnect()
|
||||
rxController?.disconnect()
|
||||
|
||||
val rcSettings = settingsRepo.radioControlSettings.value
|
||||
val txAddr = rcSettings.txRadioAddress
|
||||
val rxAddr = rcSettings.rxRadioAddress
|
||||
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||
val isSplit = isIcom && rcSettings.splitMode
|
||||
|
||||
Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
|
||||
|
||||
if (isSplit) {
|
||||
// Single-radio split mode: only TX slot is used
|
||||
if (txAddr.isBlank()) {
|
||||
_state.update { it.copy(errorMessage = "No radio address configured in Settings") }
|
||||
return
|
||||
}
|
||||
val tx = makeController(isIcom, txAddr)
|
||||
txController = tx
|
||||
rxController = null
|
||||
_state.update { it.copy(errorMessage = null) }
|
||||
val txOk = tx.connect()
|
||||
_state.update {
|
||||
it.copy(
|
||||
txConnected = txOk,
|
||||
rxConnected = false,
|
||||
errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null
|
||||
)
|
||||
}
|
||||
Log.i(tag, "IC-705 split mode connected: txOk=$txOk")
|
||||
} else {
|
||||
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
||||
return
|
||||
}
|
||||
val tx = makeController(isIcom, txAddr)
|
||||
val rx = makeController(isIcom, rxAddr)
|
||||
txController = tx
|
||||
rxController = rx
|
||||
_state.update { it.copy(errorMessage = null) }
|
||||
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
||||
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
||||
_state.update {
|
||||
it.copy(
|
||||
txConnected = txOk,
|
||||
rxConnected = rxOk,
|
||||
errorMessage = when {
|
||||
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
||||
!txOk -> "Could not connect to TX radio ($txAddr)"
|
||||
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
Log.i(tag, "Dual-radio connected: txOk=$txOk rxOk=$rxOk")
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeController(isIcom: Boolean, address: String): IRadioController =
|
||||
if (isIcom) Ic705Controller(bluetoothManager, address)
|
||||
else Ft817Controller(bluetoothManager, address)
|
||||
|
||||
override suspend fun disconnectRadios() {
|
||||
stopTracking()
|
||||
txController?.disconnect()
|
||||
rxController?.disconnect()
|
||||
txController = null
|
||||
rxController = null
|
||||
_state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
|
||||
}
|
||||
|
||||
// ── Tracking ────────────────────────────────────────────────────────────
|
||||
|
||||
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isActive = true,
|
||||
currentPass = pass,
|
||||
selectedTransponder = transponder,
|
||||
txBaseFrequencyHz = txBaseFreqHz
|
||||
)
|
||||
}
|
||||
trackingJob?.cancel()
|
||||
|
||||
val rcSettings = settingsRepo.radioControlSettings.value
|
||||
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||
val isSplit = isIcom && rcSettings.splitMode
|
||||
|
||||
if (isSplit) {
|
||||
trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) }
|
||||
} else {
|
||||
trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dual-radio tracking (Yaesu or two IC-705s) ──────────────────────────
|
||||
|
||||
private suspend fun runDualRadioTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
||||
val tx = txController
|
||||
val rx = rxController
|
||||
|
||||
// Initial setup: set band/mode/CTCSS on both radios
|
||||
val txMode = transponder.uplinkMode
|
||||
val rxMode = transponder.downlinkMode
|
||||
?: transponder.uplinkMode?.let {
|
||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||
}
|
||||
|
||||
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
|
||||
|
||||
if (tx != null && tx.isConnected && txMode != null) {
|
||||
Log.d(tag, "Setting TX mode: $txMode")
|
||||
tx.setMode(txMode)
|
||||
}
|
||||
if (rx != null && rx.isConnected && rxMode != null) {
|
||||
Log.d(tag, "Setting RX mode: $rxMode")
|
||||
rx.setMode(rxMode)
|
||||
}
|
||||
if (txMode?.uppercase() == "FM") {
|
||||
_state.value.ctcssTone?.let { tone ->
|
||||
Log.d(tag, "Setting CTCSS: ${tone}Hz")
|
||||
tx?.setCtcssTone(tone)
|
||||
tx?.setCtcssMode(true)
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||
|
||||
var lastSetTxFreq = 0.0
|
||||
var lastSetRxFreq = 0.0
|
||||
var tuningRadio = ""
|
||||
var lastReadFreq = 0L
|
||||
var stableCount = 0
|
||||
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val currentState = _state.value
|
||||
if (!currentState.isActive) break
|
||||
|
||||
val satPass = currentState.currentPass ?: break
|
||||
val xpdr = currentState.selectedTransponder ?: break
|
||||
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||
val txNow = txController
|
||||
val rxNow = rxController
|
||||
val v = pos.distanceRate * 1000.0
|
||||
|
||||
if (tuningRadio.isNotEmpty()) {
|
||||
val radio = if (tuningRadio == "tx") txNow else rxNow
|
||||
if (radio != null && radio.isConnected) {
|
||||
val read = radio.readFrequencyAndMode()
|
||||
if (read != null) {
|
||||
val (freq, _) = read
|
||||
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
|
||||
else { stableCount = 0; lastReadFreq = freq }
|
||||
if (stableCount >= 2) {
|
||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||
if (newBase > 0) {
|
||||
txBaseFreq = newBase
|
||||
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||
Log.i(tag, "TX tuning done → base=$newBase")
|
||||
}
|
||||
} else if (tuningRadio == "rx") {
|
||||
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||
if (newTxBase != null && newTxBase > 0) {
|
||||
txBaseFreq = newTxBase
|
||||
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
||||
}
|
||||
}
|
||||
tuningRadio = ""
|
||||
stableCount = 0
|
||||
lastSetTxFreq = 0.0
|
||||
lastSetRxFreq = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Detect manual dial changes
|
||||
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
|
||||
val read = txNow.readFrequencyAndMode()
|
||||
if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
|
||||
tuningRadio = "tx"
|
||||
lastReadFreq = read.first
|
||||
stableCount = 0
|
||||
Log.i(tag, "TX tuning detected (read=${read.first}, lastSet=$lastSetTxFreq)")
|
||||
}
|
||||
}
|
||||
if (tuningRadio.isEmpty() && rxNow != null && rxNow.isConnected && lastSetRxFreq > 0.0) {
|
||||
val read = rxNow.readFrequencyAndMode()
|
||||
if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
|
||||
tuningRadio = "rx"
|
||||
lastReadFreq = read.first
|
||||
stableCount = 0
|
||||
Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||
val rxBaseFreq = if (txBaseFreq != null) {
|
||||
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||
} else xpdr.downlinkLow
|
||||
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
||||
|
||||
if (tuningRadio.isEmpty()) {
|
||||
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||
txNow.setFrequency(txRadioFreq)
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
||||
rxNow.setFrequency(rxRadioFreq)
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
txConnected = txNow?.isConnected ?: false,
|
||||
rxConnected = rxNow?.isConnected ?: false,
|
||||
txFrequencyHz = txRadioFreq,
|
||||
rxFrequencyHz = rxRadioFreq,
|
||||
azimuth = Math.toDegrees(pos.azimuth),
|
||||
elevation = Math.toDegrees(pos.elevation),
|
||||
distance = pos.distance
|
||||
)
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// ── IC-705 split-radio tracking ─────────────────────────────────────────
|
||||
|
||||
private suspend fun runSplitTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
||||
val radio = txController ?: return
|
||||
if (!radio.isConnected) return
|
||||
|
||||
val txMode = transponder.uplinkMode
|
||||
val rxMode = transponder.downlinkMode
|
||||
?: transponder.uplinkMode?.let {
|
||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||
}
|
||||
|
||||
// Compute nominal base frequencies
|
||||
val txCenter = when {
|
||||
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
|
||||
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
|
||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||
else -> null
|
||||
}
|
||||
val rxNominal = if (txCenter != null) {
|
||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||
} else transponder.downlinkLow
|
||||
|
||||
val txBase = initialTxBaseFreqHz ?: txCenter
|
||||
Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode")
|
||||
|
||||
// ── Initial setup sequence ──────────────────────────────────────────
|
||||
// Sequence per IC-705: explicitly select VFO, then band → freq → mode.
|
||||
// ACK from each command gates the next — no fixed delays needed.
|
||||
|
||||
// VFO-A = RX (downlink)
|
||||
Log.d(tag, "Split init: selecting VFO-A for RX (downlink)")
|
||||
radio.setVfo(vfoA = true)
|
||||
if (rxNominal != null) {
|
||||
Log.d(tag, "Split init: VFO-A band for ${rxNominal}Hz")
|
||||
radio.setBand(rxNominal)
|
||||
Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz")
|
||||
radio.setFrequency(rxNominal)
|
||||
}
|
||||
if (rxMode != null) {
|
||||
Log.d(tag, "Split init: VFO-A mode=$rxMode")
|
||||
radio.setMode(rxMode)
|
||||
}
|
||||
|
||||
// VFO-B = TX (uplink)
|
||||
Log.d(tag, "Split init: selecting VFO-B for TX (uplink)")
|
||||
radio.setVfo(vfoA = false)
|
||||
if (txBase != null) {
|
||||
Log.d(tag, "Split init: VFO-B band for ${txBase}Hz")
|
||||
radio.setBand(txBase)
|
||||
Log.d(tag, "Split init: VFO-B freq=${txBase}Hz")
|
||||
radio.setFrequency(txBase)
|
||||
}
|
||||
if (txMode != null) {
|
||||
Log.d(tag, "Split init: VFO-B mode=$txMode")
|
||||
radio.setMode(txMode)
|
||||
}
|
||||
if (txMode?.uppercase() == "FM") {
|
||||
val tone = _state.value.ctcssTone
|
||||
if (tone != null) {
|
||||
Log.d(tag, "Split init: CTCSS=${tone}Hz")
|
||||
radio.setCtcssTone(tone)
|
||||
radio.setCtcssMode(true)
|
||||
} else {
|
||||
radio.setCtcssMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enable SPLIT on VFO-A (return display to RX VFO first)
|
||||
Log.d(tag, "Split init: returning to VFO-A, then enabling SPLIT mode")
|
||||
radio.setVfo(vfoA = true)
|
||||
radio.setSplitMode(enabled = true)
|
||||
|
||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) }
|
||||
Log.i(tag, "IC-705 split init done — entering tracking loop")
|
||||
|
||||
// ── Tracking loop with tuning detection ─────────────────────────────
|
||||
var lastSetTxFreq = 0.0
|
||||
var lastSetRxFreq = 0.0
|
||||
var tuningRadio = "" // "tx" or "rx" when manual tuning detected
|
||||
var lastReadFreq = 0L
|
||||
var stableCount = 0
|
||||
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val currentState = _state.value
|
||||
if (!currentState.isActive) break
|
||||
|
||||
val satPass = currentState.currentPass ?: break
|
||||
val xpdr = currentState.selectedTransponder ?: break
|
||||
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||
val v = pos.distanceRate * 1000.0
|
||||
|
||||
if (tuningRadio.isNotEmpty()) {
|
||||
// User is tuning — wait for frequency to stabilize
|
||||
val readFreq = if (tuningRadio == "tx") radio.readTxVfoFrequency() else radio.readWorkingFrequency()
|
||||
if (readFreq != null) {
|
||||
if (kotlin.math.abs(readFreq - lastReadFreq) <= 20) stableCount++
|
||||
else { stableCount = 0; lastReadFreq = readFreq }
|
||||
|
||||
if (stableCount >= 2) {
|
||||
// Frequency stable — reverse-calculate base frequency
|
||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||
val newBase = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||
if (newBase > 0) {
|
||||
txBaseFreq = newBase
|
||||
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||
Log.i(tag, "Split TX tuning done → base=$newBase")
|
||||
}
|
||||
} else if (tuningRadio == "rx") {
|
||||
val rxNominal = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||
if (newTxBase != null && newTxBase > 0) {
|
||||
txBaseFreq = newTxBase
|
||||
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||
Log.i(tag, "Split RX tuning done → txBase=$newTxBase")
|
||||
}
|
||||
}
|
||||
tuningRadio = ""
|
||||
stableCount = 0
|
||||
lastSetTxFreq = 0.0
|
||||
lastSetRxFreq = 0.0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Detect manual dial changes
|
||||
if (txBaseFreq != null && lastSetTxFreq > 0.0) {
|
||||
val readTx = radio.readTxVfoFrequency()
|
||||
if (readTx != null && kotlin.math.abs(readTx - lastSetTxFreq) >= 20.0) {
|
||||
tuningRadio = "tx"
|
||||
lastReadFreq = readTx
|
||||
stableCount = 0
|
||||
Log.i(tag, "Split TX tuning detected (read=${readTx}, lastSet=$lastSetTxFreq)")
|
||||
}
|
||||
}
|
||||
if (tuningRadio.isEmpty() && lastSetRxFreq > 0.0) {
|
||||
val readRx = radio.readWorkingFrequency()
|
||||
if (readRx != null && kotlin.math.abs(readRx - lastSetRxFreq) >= 20.0) {
|
||||
tuningRadio = "rx"
|
||||
lastReadFreq = readRx
|
||||
stableCount = 0
|
||||
Log.i(tag, "Split RX tuning detected (read=${readRx}, lastSet=$lastSetRxFreq)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine Doppler-corrected frequencies
|
||||
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||
val rxBaseCalc = if (txBaseFreq != null) {
|
||||
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||
} else xpdr.downlinkLow
|
||||
val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) }
|
||||
|
||||
if (radio.isConnected && tuningRadio.isEmpty()) {
|
||||
// Update both VFOs every cycle — no PTT polling needed.
|
||||
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
|
||||
if (rxRadioFreq != null) {
|
||||
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
|
||||
radio.setWorkingFrequency(rxRadioFreq)
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
if (txRadioFreq != null) {
|
||||
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
||||
radio.setTxVfoFrequency(txRadioFreq)
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
txConnected = radio.isConnected,
|
||||
rxConnected = false, // single radio
|
||||
txFrequencyHz = txRadioFreq,
|
||||
rxFrequencyHz = rxRadioFreq,
|
||||
azimuth = Math.toDegrees(pos.azimuth),
|
||||
elevation = Math.toDegrees(pos.elevation),
|
||||
distance = pos.distance
|
||||
)
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Other IRadioTrackingService methods ─────────────────────────────────
|
||||
|
||||
override fun stopTracking() {
|
||||
trackingJob?.cancel()
|
||||
trackingJob = null
|
||||
_state.update { it.copy(isActive = false) }
|
||||
}
|
||||
|
||||
override fun setTransponder(transponder: SatRadio) {
|
||||
appScope.launch {
|
||||
val tx = txController
|
||||
val rx = rxController
|
||||
transponder.uplinkMode?.let { tx?.setMode(it) }
|
||||
val rxMode = transponder.downlinkMode
|
||||
?: transponder.uplinkMode?.let {
|
||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||
}
|
||||
rxMode?.let { rx?.setMode(it) }
|
||||
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||
_state.value.ctcssTone?.let { tone ->
|
||||
tx?.setCtcssTone(tone)
|
||||
tx?.setCtcssMode(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
val txCenter = when {
|
||||
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
|
||||
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
|
||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||
else -> null
|
||||
}
|
||||
val rxNominal = if (txCenter != null) {
|
||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||
} else transponder.downlinkLow
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedTransponder = transponder,
|
||||
txBaseFrequencyHz = txCenter,
|
||||
txFrequencyHz = txCenter,
|
||||
rxFrequencyHz = rxNominal,
|
||||
txMode = transponder.uplinkMode,
|
||||
rxMode = transponder.downlinkMode
|
||||
?: transponder.uplinkMode?.let { m ->
|
||||
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setTxBaseFrequency(frequencyHz: Long) {
|
||||
_state.update { it.copy(txBaseFrequencyHz = frequencyHz) }
|
||||
}
|
||||
|
||||
override fun adjustTxBaseFrequency(deltaHz: Long) {
|
||||
val current = _state.value.txBaseFrequencyHz ?: return
|
||||
_state.update { it.copy(txBaseFrequencyHz = current + deltaHz) }
|
||||
}
|
||||
|
||||
override fun setCtcssTone(toneHz: Double?) {
|
||||
_state.update { it.copy(ctcssTone = toneHz) }
|
||||
appScope.launch {
|
||||
val tx = txController
|
||||
if (toneHz != null) {
|
||||
tx?.setCtcssTone(toneHz)
|
||||
tx?.setCtcssMode(true)
|
||||
} else {
|
||||
tx?.setCtcssMode(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setMode(txMode: String, rxMode: String) {
|
||||
appScope.launch {
|
||||
txController?.setMode(txMode)
|
||||
rxController?.setMode(rxMode)
|
||||
}
|
||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||
}
|
||||
}
|
||||
+89
-12
@@ -1,15 +1,35 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.injection
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.location.LocationManager
|
||||
import android.view.WindowManager
|
||||
import androidx.room.Room
|
||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||
@@ -18,19 +38,24 @@ import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
@@ -42,31 +67,83 @@ import okhttp3.OkHttpClient
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
private val localSource = provideLocalSource()
|
||||
private val remoteSource = provideRemoteSource()
|
||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||
override val settingsRepo = provideSettingsRepo()
|
||||
override val selectionRepo = provideSelectionRepo()
|
||||
override val satelliteRepo = provideSatelliteRepo()
|
||||
override val databaseRepo = provideDatabaseRepo()
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
}
|
||||
|
||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||
|
||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||
|
||||
override fun provideBluetoothReporter(): IReporterRepo<WithoutExtParams> {
|
||||
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
||||
|
||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||
|
||||
override fun provideBluetoothReporter(): IReporter {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
return BluetoothReporter(manager, CoroutineScope(Dispatchers.IO))
|
||||
val rc = settingsRepo.rcSettings.value
|
||||
return BluetoothReporter(
|
||||
manager,
|
||||
CoroutineScope(Dispatchers.IO),
|
||||
rc.bluetoothRotatorAddress,
|
||||
rc.bluetoothFrequencyAddress
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideNetworkReporter(): IReporterRepo<ExtendedParams> {
|
||||
return NetworkReporter(CoroutineScope(Dispatchers.IO))
|
||||
override fun provideNetworkReporter(): IReporter {
|
||||
val rc = settingsRepo.rcSettings.value
|
||||
return NetworkReporter(
|
||||
CoroutineScope(Dispatchers.IO),
|
||||
rc.rotatorAddress,
|
||||
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||
rc.frequencyAddress,
|
||||
rc.frequencyPort.toIntOrNull() ?: 0,
|
||||
rc.frequencyOffsetHz
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideTxRadioController(): IRadioController {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val settings = settingsRepo.radioControlSettings.value
|
||||
val address = settings.txRadioAddress
|
||||
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||
Ic705Controller(manager, address)
|
||||
} else {
|
||||
Ft817Controller(manager, address)
|
||||
}
|
||||
}
|
||||
|
||||
override fun provideRxRadioController(): IRadioController {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val settings = settingsRepo.radioControlSettings.value
|
||||
val address = settings.rxRadioAddress
|
||||
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||
Ic705Controller(manager, address)
|
||||
} else {
|
||||
Ft817Controller(manager, address)
|
||||
}
|
||||
}
|
||||
|
||||
override fun provideSensorsRepo(): ISensorsRepo {
|
||||
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
return SensorsRepo(manager,sensor,window)
|
||||
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||
return SensorsRepo(manager, displayManager)
|
||||
}
|
||||
|
||||
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
|
||||
try {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
|
||||
} catch (_: SecurityException) {}
|
||||
}
|
||||
|
||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** One report from the AMSAT API (data layer model). */
|
||||
private data class ApiReport(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val report: String,
|
||||
val gridSquare: String,
|
||||
val reportedTimeUtcSec: Long
|
||||
)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||
|
||||
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||
}
|
||||
|
||||
/** Parse catalog JSON to list of satellite names */
|
||||
private fun parseCatalog(json: String): List<String> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse reports JSON to list of ApiReport domain objects */
|
||||
private fun parseReports(json: String): List<ApiReport> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val iso = o.optString("reported_time", "")
|
||||
if (iso.isEmpty()) null else ApiReport(
|
||||
id = o.optString("id", ""),
|
||||
name = o.optString("name", ""),
|
||||
callsign = o.optString("callsign", ""),
|
||||
report = o.optString("report", ""),
|
||||
gridSquare = o.optString("grid_square", ""),
|
||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
||||
private fun parseIsoUtcSec(iso: String): Long {
|
||||
return try {
|
||||
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
val labels = (0 until 3).map { d ->
|
||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||
}
|
||||
return names.map { name ->
|
||||
val slots = (0 until 36).map { slotIdx ->
|
||||
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||
val slotEnd = nowSec - slotIdx * 7200L
|
||||
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||
if (inSlot.isEmpty()) {
|
||||
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||
} else {
|
||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||
SatSlot(
|
||||
statusColor = statusColorOf(newest.report),
|
||||
count = inSlot.size,
|
||||
reportIds = inSlot.map { it.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
val days = (0 until 3).map { d ->
|
||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toSatReport(r: ApiReport): SatReport {
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||
val y = cal.get(Calendar.YEAR)
|
||||
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
|
||||
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
|
||||
return SatReport(
|
||||
id = r.id,
|
||||
statusText = r.report,
|
||||
call = r.callsign,
|
||||
grid = r.gridSquare,
|
||||
dateUtc = "$y-$mo-$d",
|
||||
timeUtc = "$hh:$mm UTC"
|
||||
)
|
||||
}
|
||||
|
||||
/** Map status text to color value (for UI rendering). */
|
||||
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||
"heard", "crew active" -> ACTIVE_BLUE
|
||||
"telemetry only" -> TLM_ORANGE
|
||||
"not heard" -> NOT_HEARD_PINK
|
||||
else -> CONFLICT_DEEP_ORANGE
|
||||
}
|
||||
|
||||
companion object {
|
||||
// AMSAT official status colors (from amsat.org/status)
|
||||
private const val ACTIVE_BLUE = 0xFF648FFF
|
||||
private const val TLM_ORANGE = 0xFFFFB000
|
||||
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||
}
|
||||
}
|
||||
+67
-70
@@ -18,17 +18,17 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
class DatabaseRepo(
|
||||
@@ -39,78 +39,45 @@ class DatabaseRepo(
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : IDatabaseRepo {
|
||||
|
||||
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
||||
val importedSatellites = remoteSource.getFileStream(uri)?.let { dataParser.parseTLEStream(it) }
|
||||
importedSatellites?.let { localSource.insertEntries(it) }
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
||||
val radios = remoteSource.getFileStream(uri)?.let { dataParser.parseJSONStream(it) }
|
||||
radios?.let {
|
||||
if(it.isNotEmpty()) {
|
||||
localSource.deleteRadios()
|
||||
localSource.insertRadios(it)
|
||||
}
|
||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||
localSource.insertEntries(entries)
|
||||
importedCount = entries.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
importedCount
|
||||
}
|
||||
|
||||
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||
localSource.insertRadios(transceivers)
|
||||
importedCount = transceivers.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
importedCount
|
||||
}
|
||||
|
||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||
val importedEntries = mutableListOf<OrbitalData>()
|
||||
val importedRadios = mutableListOf<SatRadio>()
|
||||
val tleUrls = Sources.satelliteDataUrls.toMutableMap().apply {
|
||||
if (settingsRepo.dataSourcesSettings.value.useCustomTLE) {
|
||||
this["Other"] = settingsRepo.dataSourcesSettings.value.tleUrl
|
||||
}
|
||||
val settings = settingsRepo.dataSourcesSettings.value
|
||||
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
|
||||
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
|
||||
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// parse fetched data concurrently
|
||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
val jobsMap = tleUrls
|
||||
.filter { (_, url) -> url.isNotEmpty() }
|
||||
.mapValues { (_, url) -> async { remoteSource.getNetworkStream(url) } }
|
||||
val radioUrls = buildList {
|
||||
add(Sources.RADIO_DATA_URL)
|
||||
if (settingsRepo.dataSourcesSettings.value.useCustomTransceivers) {
|
||||
add(settingsRepo.dataSourcesSettings.value.transceiversUrl)
|
||||
}
|
||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
val jobRadios = radioUrls.associateWith { url -> async { remoteSource.getNetworkStream(url) } }
|
||||
// parse
|
||||
jobsMap.mapValues { job -> job.value.await() }.forEach { entry ->
|
||||
entry.value?.let { stream ->
|
||||
when (val type = entry.key) {
|
||||
"Amsat", "R4UAB", "Other" -> {
|
||||
// parse tle stream
|
||||
val satellites = dataParser.parseTLEStream(stream)
|
||||
val catnums = satellites.map { it.catnum }
|
||||
settingsRepo.setSatelliteTypeIds(type, catnums)
|
||||
importedEntries.addAll(satellites)
|
||||
}
|
||||
|
||||
"McCants", "Classified" -> {
|
||||
// unzip and parse tle stream
|
||||
val unzipped = ZipInputStream(stream).apply { nextEntry }
|
||||
val satellites = dataParser.parseTLEStream(unzipped)
|
||||
val catnums = satellites.map { it.catnum }
|
||||
settingsRepo.setSatelliteTypeIds(type, catnums)
|
||||
importedEntries.addAll(satellites)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// parse csv stream
|
||||
val satellites = dataParser.parseCSVStream(stream)
|
||||
val catnums = satellites.map { it.catnum }
|
||||
settingsRepo.setSatelliteTypeIds(type, catnums)
|
||||
importedEntries.addAll(satellites)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
jobRadios.values.forEach { job ->
|
||||
job.await()?.let {
|
||||
importedRadios.addAll(dataParser.parseJSONStream(it))
|
||||
}
|
||||
}
|
||||
// insert
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
localSource.insertRadios(importedRadios)
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -122,9 +89,39 @@ class DatabaseRepo(
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
private suspend fun setUpdateSuccessful(timestamp: Long) = withContext(dispatcher) {
|
||||
val numberOfRadios = localSource.getRadiosTotal()
|
||||
val numberOfSatellites = localSource.getEntriesTotal()
|
||||
settingsRepo.updateDatabaseState(DatabaseState(numberOfRadios, numberOfSatellites, timestamp))
|
||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||
val bufferedStream = stream.buffered()
|
||||
return when {
|
||||
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
|
||||
else -> dataParser.parseTLEStream(bufferedStream)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasCsvHint(url: String): Boolean {
|
||||
return url.contains("FORMAT=csv", ignoreCase = true) ||
|
||||
url.endsWith(".csv", ignoreCase = true) ||
|
||||
url.endsWith(".csv.zip", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun looksLikeCsv(stream: InputStream): Boolean {
|
||||
if (!stream.markSupported()) return false
|
||||
stream.mark(4096)
|
||||
val preview = ByteArray(4096)
|
||||
val length = stream.read(preview)
|
||||
stream.reset()
|
||||
if (length <= 0) return false
|
||||
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
|
||||
return line.contains("OBJECT_NAME", ignoreCase = true) ||
|
||||
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
|
||||
line.count { it == ',' } >= 4
|
||||
}
|
||||
|
||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||
settingsRepo.updateDatabaseState(
|
||||
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||
)
|
||||
}
|
||||
|
||||
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
|
||||
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
|
||||
}
|
||||
+145
-95
@@ -28,10 +28,17 @@ import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class SatelliteRepo(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
@@ -42,18 +49,39 @@ class SatelliteRepo(
|
||||
private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList())
|
||||
override val passes: StateFlow<List<OrbitalPass>> = _passes
|
||||
|
||||
private val _isCalculating = MutableStateFlow(false)
|
||||
override val isCalculating: StateFlow<Boolean> = _isCalculating
|
||||
|
||||
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
||||
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
||||
|
||||
private val _selectedPass = MutableStateFlow(0 to 0L)
|
||||
override val selectedPass: StateFlow<Pair<Int, Long>> = _selectedPass
|
||||
|
||||
override fun selectPass(catNum: Int, aosTime: Long) {
|
||||
_selectedPass.value = catNum to aosTime
|
||||
}
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
settingsRepo.selectedIds.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
||||
val timeNow = 1000 * ((System.currentTimeMillis() + 500) / 1000)
|
||||
calculatePasses(timeNow, hoursAhead, minElevation, modes)
|
||||
}
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settingsRepo.selectedSatModes.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||
@@ -62,7 +90,8 @@ class SatelliteRepo(
|
||||
|
||||
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> {
|
||||
return withContext(dispatcher) {
|
||||
val positions = mutableListOf<OrbitalPos>()
|
||||
val estimatedSize = ((end - start) / 15000).toInt() + 1
|
||||
val positions = ArrayList<OrbitalPos>(estimatedSize)
|
||||
var currentTime = start
|
||||
while (currentTime < end) {
|
||||
positions.add(sat.getPosition(pos, currentTime))
|
||||
@@ -80,65 +109,100 @@ class SatelliteRepo(
|
||||
): List<SatRadio> {
|
||||
return withContext(dispatcher) {
|
||||
val satPos = sat.getPosition(pos, time)
|
||||
val copiedList = radios.map { it.copy() }
|
||||
copiedList.forEach { transmitter ->
|
||||
transmitter.downlinkLow?.let { transmitter.downlinkLow = satPos.getDownlinkFreq(it) }
|
||||
transmitter.downlinkHigh?.let { transmitter.downlinkHigh = satPos.getDownlinkFreq(it) }
|
||||
transmitter.uplinkLow?.let { transmitter.uplinkLow = satPos.getUplinkFreq(it) }
|
||||
transmitter.uplinkHigh?.let { transmitter.uplinkHigh = satPos.getUplinkFreq(it) }
|
||||
radios.map { transmitter ->
|
||||
transmitter.copy(
|
||||
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
||||
downlinkHigh = transmitter.downlinkHigh?.let { satPos.getDownlinkFreq(it) },
|
||||
uplinkLow = transmitter.uplinkLow?.let { satPos.getUplinkFreq(it) },
|
||||
uplinkHigh = transmitter.uplinkHigh?.let { satPos.getUplinkFreq(it) }
|
||||
)
|
||||
}
|
||||
copiedList.map { it.copy() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
||||
return withContext(dispatcher) {
|
||||
passList.forEach { pass ->
|
||||
if (!pass.isDeepSpace) {
|
||||
val timeStart = pass.aosTime
|
||||
if (time > timeStart) {
|
||||
val deltaNow = time.minus(timeStart).toFloat()
|
||||
val deltaTotal = pass.losTime.minus(timeStart).toFloat()
|
||||
pass.progress = (deltaNow / deltaTotal).round(2)
|
||||
override suspend fun calculatePasses(
|
||||
time: Long,
|
||||
hoursAhead: Int,
|
||||
minElevation: Double,
|
||||
aosStartMinute: Int,
|
||||
aosEndMinute: Int,
|
||||
invertAosTimeWindow: Boolean,
|
||||
modes: List<String>
|
||||
) {
|
||||
_isCalculating.value = true
|
||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||
val normalizedTime = time / 60_000L * 60_000L
|
||||
val currentSatellites = _satellites.value
|
||||
withContext(dispatcher) {
|
||||
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
||||
currentSatellites
|
||||
} else {
|
||||
currentSatellites.filter { it.data.catnum in idsWithModes }
|
||||
}
|
||||
// Compute passes for each satellite in parallel
|
||||
val passLists = coroutineScope {
|
||||
filteredSatellites.map { satellite ->
|
||||
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
|
||||
}.awaitAll()
|
||||
}
|
||||
// Flatten and filter in a single pass
|
||||
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
|
||||
val newPasses = ArrayList<OrbitalPass>()
|
||||
for (list in passLists) {
|
||||
for (pass in list) {
|
||||
if (
|
||||
pass.losTime > time
|
||||
&& pass.aosTime < timeFuture
|
||||
&& pass.maxElevation > minElevation
|
||||
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||
) {
|
||||
newPasses.add(pass)
|
||||
}
|
||||
}
|
||||
}
|
||||
passList.filter { pass -> pass.progress < 1.0 }.map { it.copy() }
|
||||
newPasses.sortBy { it.aosTime }
|
||||
delay(1000.milliseconds) // Simulate loading time for better UX
|
||||
_passes.update { newPasses }
|
||||
}
|
||||
_isCalculating.value = false
|
||||
}
|
||||
|
||||
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
|
||||
if (_satellites.value.isNotEmpty()) {
|
||||
withContext(dispatcher) {
|
||||
val newPasses = mutableListOf<OrbitalPass>()
|
||||
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||
_satellites.value.forEach { satellite ->
|
||||
if (idsWithModes.isEmpty() || satellite.data.catnum in idsWithModes) {
|
||||
newPasses.addAll(satellite.getPasses(settingsRepo.stationPosition.value, time, hoursAhead))
|
||||
}
|
||||
}
|
||||
_passes.update { newPasses.filter(time, hoursAhead, minElevation) }
|
||||
}
|
||||
private fun isAosInRange(
|
||||
aosTime: Long,
|
||||
aosStartMinute: Int,
|
||||
aosEndMinute: Int,
|
||||
invertAosTimeWindow: Boolean
|
||||
): Boolean {
|
||||
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
||||
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
||||
val aosMinute = (localMillis / 60_000L).toInt()
|
||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||
aosMinute in aosStartMinute..aosEndMinute
|
||||
} else {
|
||||
_passes.update { emptyList() }
|
||||
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
||||
}
|
||||
return if (invertAosTimeWindow) !inRange else inRange
|
||||
}
|
||||
|
||||
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
||||
val passes = mutableListOf<OrbitalPass>()
|
||||
val endDate = time + hours * 60L * 60L * 1000L
|
||||
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
|
||||
val decayed = this.data.hasDecayed(time)
|
||||
var startDate = time
|
||||
var shouldRewind = true
|
||||
var lastAosDate: Long
|
||||
var count = 0
|
||||
if (this.willBeSeen(pos)) {
|
||||
if (this.data.isDeepSpace) {
|
||||
passes.add(getGeoPass(this, pos, time))
|
||||
passes.add(getGeoPass(this, pos, time, decayed))
|
||||
} else {
|
||||
do {
|
||||
if (count > 0) shouldRewind = false
|
||||
val pass = getLeoPass(this, pos, startDate, shouldRewind)
|
||||
val pass = getLeoPass(this, pos, startDate, shouldRewind, decayed)
|
||||
lastAosDate = pass.aosTime
|
||||
passes.add(pass)
|
||||
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
|
||||
@@ -149,94 +213,80 @@ class SatelliteRepo(
|
||||
return passes
|
||||
}
|
||||
|
||||
private fun List<OrbitalPass>.filter(time: Long, hoursAhead: Int, minElev: Double): List<OrbitalPass> {
|
||||
val timeFuture = time + (hoursAhead * 60L * 60L * 1000L)
|
||||
return this.filter { it.losTime > time }.filter { it.aosTime < timeFuture }
|
||||
.filter { it.maxElevation > minElev }.sortedBy { it.aosTime }
|
||||
}
|
||||
|
||||
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass {
|
||||
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, decayed: Boolean): OrbitalPass {
|
||||
val satPos = sat.getPosition(pos, time)
|
||||
val aos = time - 24 * 60L * 60L * 1000L
|
||||
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
|
||||
val az = satPos.azimuth.toDegrees().round(1)
|
||||
val elev = satPos.elevation.toDegrees().round(1)
|
||||
val alt = satPos.altitude
|
||||
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat)
|
||||
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat, hasDecayed = decayed)
|
||||
}
|
||||
|
||||
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean): OrbitalPass {
|
||||
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean, decayed: Boolean): OrbitalPass {
|
||||
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
|
||||
var calendarTimeMillis = time
|
||||
var elevation: Double
|
||||
var maxElevation = 0.0
|
||||
var alt = 0.0 // var tcaAz = 0.0
|
||||
// rewind 1/4 of an orbit
|
||||
if (rewind) calendarTimeMillis += -quarterOrbitMin * 60L * 1000L
|
||||
if (rewind) calendarTimeMillis -= quarterOrbitMin * 60L * 1000L
|
||||
|
||||
var satPos = sat.getPosition(pos, calendarTimeMillis)
|
||||
if (satPos.elevation > 0.0) {
|
||||
// Use lightweight elevation check for coarse searching
|
||||
if (sat.getElevation(pos, calendarTimeMillis) > 0.0) {
|
||||
// move forward in 30 second intervals until the sat goes below the horizon
|
||||
do {
|
||||
calendarTimeMillis += 30 * 1000L
|
||||
satPos = sat.getPosition(pos, calendarTimeMillis)
|
||||
} while (satPos.elevation > 0.0)
|
||||
} while (sat.getElevation(pos, calendarTimeMillis) > 0.0)
|
||||
// move forward 3/4 of an orbit
|
||||
calendarTimeMillis += quarterOrbitMin * 3 * 60L * 1000L
|
||||
}
|
||||
|
||||
// find the next time sat comes above the horizon
|
||||
// find the next time sat comes above the horizon (coarse: 60s steps)
|
||||
do {
|
||||
calendarTimeMillis += 60L * 1000L
|
||||
satPos = sat.getPosition(pos, calendarTimeMillis)
|
||||
elevation = satPos.elevation
|
||||
if (elevation > maxElevation) {
|
||||
maxElevation = elevation
|
||||
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
|
||||
}
|
||||
} while (satPos.elevation < 0.0)
|
||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation < 0.0)
|
||||
|
||||
// refine to 1 second
|
||||
calendarTimeMillis += -60L * 1000L
|
||||
// refine AOS to ~500ms precision
|
||||
calendarTimeMillis -= 60L * 1000L
|
||||
do {
|
||||
calendarTimeMillis += 1L * 500L
|
||||
satPos = sat.getPosition(pos, calendarTimeMillis)
|
||||
elevation = satPos.elevation
|
||||
if (elevation > maxElevation) {
|
||||
maxElevation = elevation
|
||||
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
|
||||
}
|
||||
} while (satPos.elevation < 0.0)
|
||||
calendarTimeMillis += 500L
|
||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation < 0.0)
|
||||
|
||||
val aos = 1000 * ((satPos.time + 500) / 1000)
|
||||
val aosAz = satPos.azimuth.toDegrees().round(1)
|
||||
// Get full position for AOS data (azimuth, altitude)
|
||||
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||
val aos = 1000 * ((aosPos.time + 500) / 1000)
|
||||
val aosAz = aosPos.azimuth.toDegrees().round(1)
|
||||
|
||||
// find when sat goes below
|
||||
// find when sat goes below (coarse: 30s steps)
|
||||
do {
|
||||
calendarTimeMillis += 30L * 1000L
|
||||
satPos = sat.getPosition(pos, calendarTimeMillis)
|
||||
elevation = satPos.elevation
|
||||
if (elevation > maxElevation) {
|
||||
maxElevation = elevation
|
||||
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
|
||||
}
|
||||
} while (satPos.elevation > 0.0)
|
||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation > 0.0)
|
||||
|
||||
// refine to 1 second
|
||||
calendarTimeMillis += -30L * 1000L
|
||||
// refine LOS to ~500ms precision
|
||||
calendarTimeMillis -= 30L * 1000L
|
||||
do {
|
||||
calendarTimeMillis += 1L * 500L
|
||||
satPos = sat.getPosition(pos, calendarTimeMillis)
|
||||
elevation = satPos.elevation
|
||||
if (elevation > maxElevation) {
|
||||
maxElevation = elevation
|
||||
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
|
||||
}
|
||||
} while (satPos.elevation > 0.0)
|
||||
calendarTimeMillis += 500L
|
||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation > 0.0)
|
||||
|
||||
// Get full position for LOS data (azimuth, altitude)
|
||||
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||
val los = 1000 * ((losPos.time + 500) / 1000)
|
||||
val losAz = losPos.azimuth.toDegrees().round(1)
|
||||
|
||||
// Get altitude at approximate TCA (max elevation)
|
||||
val tcaTime = (aos + los) / 2
|
||||
val tcaPos = sat.getFullPosition(pos, tcaTime)
|
||||
val alt = tcaPos.altitude
|
||||
|
||||
val los = 1000 * ((satPos.time + 500) / 1000) // val tca = (aos + los) / 2
|
||||
val losAz = satPos.azimuth.toDegrees().round(1)
|
||||
val elev = maxElevation.toDegrees().round(1)
|
||||
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat)
|
||||
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat, hasDecayed = decayed)
|
||||
}
|
||||
}
|
||||
+50
-28
@@ -38,45 +38,62 @@ class SelectionRepo(
|
||||
) : ISelectionRepo {
|
||||
|
||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
||||
private val currentQuery = MutableStateFlow("")
|
||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||
currentItems.map { items -> items.filterByTypes(types) }
|
||||
|
||||
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
||||
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = localSource.getIdsWithModes(list)
|
||||
if (ids.isEmpty()) null else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||
}
|
||||
}
|
||||
|
||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||
itemsWithTypes.map { items -> items.filterByQuery(query) }
|
||||
itemsWithModes.map { items ->
|
||||
filterByQuery(items, query).sortedWith(
|
||||
compareByDescending<SatItem> { it.isSelected }
|
||||
.thenBy { it.name }
|
||||
.thenBy { it.catnum }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentTypes() = currentTypes.value
|
||||
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||
|
||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
||||
removeAt(0)
|
||||
}
|
||||
override fun getModesList() = Sources.satelliteModes
|
||||
|
||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||
val selectedIds = settingsRepo.selectedIds.value
|
||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||
currentItems.value = localSource.getEntriesList().map { item ->
|
||||
item.copy(isSelected = item.catnum in selectedIds)
|
||||
}
|
||||
return@withContext itemsWithQuery
|
||||
}
|
||||
|
||||
override suspend fun setTypes(types: List<String>) {
|
||||
currentTypes.value = types
|
||||
settingsRepo.setSelectedTypes(types)
|
||||
override suspend fun setModes(modes: List<String>) {
|
||||
settingsRepo.setSelectedSatModes(modes)
|
||||
}
|
||||
|
||||
override suspend fun setQuery(query: String) = withContext(dispatcher) {
|
||||
override suspend fun setQuery(query: String) {
|
||||
currentQuery.value = query
|
||||
}
|
||||
|
||||
override suspend fun setSelection(selectAll: Boolean) = withContext(dispatcher) {
|
||||
setSelection(itemsWithQuery.first().map { item -> item.catnum }, selectAll)
|
||||
val visibleIds = itemsWithQuery.first().mapTo(HashSet()) { it.catnum }
|
||||
setSelection(visibleIds, selectAll)
|
||||
}
|
||||
|
||||
override suspend fun setSelection(ids: List<Int>, isTicked: Boolean) = withContext(dispatcher) {
|
||||
val idSet = ids.toHashSet()
|
||||
currentItems.value = currentItems.value.map { item ->
|
||||
if (item.catnum in ids) item.copy(isSelected = isTicked) else item
|
||||
if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,19 +102,24 @@ class SelectionRepo(
|
||||
settingsRepo.setSelectedIds(currentSelection)
|
||||
}
|
||||
|
||||
private suspend fun List<SatItem>.filterByTypes(types: List<String>) = withContext(dispatcher) {
|
||||
if (types.isEmpty()) return@withContext this@filterByTypes
|
||||
val catnums = settingsRepo.getSatelliteTypesIds(types)
|
||||
if (catnums.isEmpty()) return@withContext this@filterByTypes
|
||||
return@withContext this@filterByTypes.filter { item -> item.catnum in catnums }
|
||||
}
|
||||
|
||||
private suspend fun List<SatItem>.filterByQuery(query: String) = withContext(dispatcher) {
|
||||
if (query.isBlank()) return@withContext this@filterByQuery
|
||||
return@withContext try {
|
||||
this@filterByQuery.filter { it.catnum == query.toInt() }
|
||||
} catch (_: Exception) {
|
||||
this@filterByQuery.filter { item -> item.name.lowercase().contains(query.lowercase()) }
|
||||
/**
|
||||
* Bulk selection using a pre-built Set for O(1) lookups.
|
||||
*/
|
||||
private suspend fun setSelection(idSet: Set<Int>, isTicked: Boolean) = withContext(dispatcher) {
|
||||
currentItems.value = currentItems.value.map { item ->
|
||||
if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||
* and lowercases the query once up front instead of per-item.
|
||||
*/
|
||||
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||
if (query.isBlank()) return items
|
||||
val catnum = query.toIntOrNull()
|
||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||
val lowerQuery = query.lowercase()
|
||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,9 @@ import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.view.Display
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
@@ -31,82 +32,101 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.math.round
|
||||
|
||||
private const val SMOOTHING_FACTOR = 0.15f
|
||||
private const val SENSOR_RATE_US = 16_000
|
||||
|
||||
class SensorsRepo(
|
||||
private val sensorManager: SensorManager,
|
||||
private val sensor: Sensor?,
|
||||
private val windowManager: WindowManager
|
||||
) :
|
||||
SensorEventListener, ISensorsRepo {
|
||||
private val displayManager: DisplayManager?
|
||||
) : ISensorsRepo, SensorEventListener {
|
||||
|
||||
private val _orientation = MutableStateFlow(Pair(0f, 0f))
|
||||
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
private val rotationMatrix = FloatArray(9)
|
||||
private val tempMatrix = FloatArray(9)
|
||||
private val orientationValues = FloatArray(3)
|
||||
private var sensorAccuracy = SensorManager.SENSOR_STATUS_UNRELIABLE
|
||||
private var smoothAzimuth = 0f
|
||||
private var smoothPitch = 0f
|
||||
private var hasInitialReading = false
|
||||
|
||||
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
|
||||
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||
|
||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||
val latitude = geoPos.latitude.toFloat()
|
||||
val longitude = geoPos.longitude.toFloat()
|
||||
return GeomagneticField(latitude, longitude, geoPos.altitude.toFloat(), time).declination
|
||||
return GeomagneticField(
|
||||
geoPos.latitude.toFloat(),
|
||||
geoPos.longitude.toFloat(),
|
||||
geoPos.altitude.toFloat(),
|
||||
time
|
||||
).declination
|
||||
}
|
||||
|
||||
override fun enableSensor() {
|
||||
sensor?.let { sensorManager.registerListener(this, it, 8000) }
|
||||
hasInitialReading = false
|
||||
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||
}
|
||||
|
||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
|
||||
sensorAccuracy = accuracy
|
||||
}
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) = when {
|
||||
sensorAccuracy == SensorManager.SENSOR_STATUS_UNRELIABLE -> {}
|
||||
event.sensor == sensor -> updateOrientation(event.values)
|
||||
else -> {}
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||
}
|
||||
|
||||
private fun getDisplayRotation(): Int {
|
||||
return try {
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.rotation
|
||||
} catch (e: Exception) {
|
||||
Surface.ROTATION_0
|
||||
}
|
||||
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
|
||||
}
|
||||
|
||||
private fun transformRotationMatrix(rotationMatrix: FloatArray, rotation: Int) {
|
||||
val tempMatrix = FloatArray(9)
|
||||
when (rotation) {
|
||||
Surface.ROTATION_0 -> {
|
||||
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_X, SensorManager.AXIS_Y, tempMatrix)
|
||||
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||
}
|
||||
Surface.ROTATION_90 -> {
|
||||
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix)
|
||||
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
|
||||
}
|
||||
Surface.ROTATION_180 -> {
|
||||
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix)
|
||||
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||
}
|
||||
Surface.ROTATION_270 -> {
|
||||
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix)
|
||||
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
|
||||
}
|
||||
private fun remapForRotation(rotation: Int) {
|
||||
val remapped = when (rotation) {
|
||||
Surface.ROTATION_90 -> SensorManager.remapCoordinateSystem(
|
||||
rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix
|
||||
)
|
||||
|
||||
Surface.ROTATION_180 -> SensorManager.remapCoordinateSystem(
|
||||
rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix
|
||||
)
|
||||
|
||||
Surface.ROTATION_270 -> SensorManager.remapCoordinateSystem(
|
||||
rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix
|
||||
)
|
||||
|
||||
else -> false
|
||||
}
|
||||
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||
}
|
||||
|
||||
private fun updateOrientation(rotationVector: FloatArray) {
|
||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
|
||||
transformRotationMatrix(rotationMatrix, getDisplayRotation())
|
||||
private fun handleSensorEvent(event: SensorEvent) {
|
||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||
remapForRotation(getDisplayRotation())
|
||||
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
||||
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
|
||||
val pitch = (orientationValues[1] * RAD2DEG).toFloat() // roll [2]
|
||||
val magneticAzimuth = (azimuth + 360f) % 360f
|
||||
val roundedAzimuth = round(magneticAzimuth * 10) / 10
|
||||
val roundedPitch = round(pitch * 10) / 10
|
||||
_orientation.value = Pair(roundedAzimuth, roundedPitch)
|
||||
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
|
||||
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
||||
if (!hasInitialReading) {
|
||||
smoothAzimuth = azimuth
|
||||
smoothPitch = pitch
|
||||
hasInitialReading = true
|
||||
} else {
|
||||
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
|
||||
smoothPitch = lowPass(smoothPitch, pitch)
|
||||
}
|
||||
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
|
||||
}
|
||||
|
||||
private fun lowPass(previous: Float, current: Float): Float {
|
||||
return previous + SMOOTHING_FACTOR * (current - previous)
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-pass filter that accounts for the 0°/360° wraparound.
|
||||
* Always takes the shortest angular path between the two values.
|
||||
*/
|
||||
private fun lowPassAngle(previous: Float, current: Float): Float {
|
||||
var delta = current - previous
|
||||
while (delta > 180f) delta -= 360f
|
||||
while (delta <= -180f) delta += 360f
|
||||
return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
|
||||
}
|
||||
|
||||
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
|
||||
}
|
||||
+197
-171
@@ -28,14 +28,18 @@ import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONObject
|
||||
|
||||
class SettingsRepo(
|
||||
private val locationManager: LocationManager,
|
||||
@@ -50,8 +54,12 @@ class SettingsRepo(
|
||||
private val keyBluetoothFrequencyState = "bluetoothFrequencyState"
|
||||
private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress"
|
||||
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||
private val keyFilterMinElevation = "filterMinElevation"
|
||||
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||
private val keyFilterAosInvert = "filterAosInvert"
|
||||
private val keyNumberOfRadios = "numberOfRadios"
|
||||
private val keyNumberOfSatellites = "numberOfSatellites"
|
||||
private val keyRotatorAddress = "rotatorAddress"
|
||||
@@ -62,14 +70,15 @@ class SettingsRepo(
|
||||
private val keyFrequencyAddress = "frequencyAddress"
|
||||
private val keyFrequencyPort = "frequencyPort"
|
||||
private val keyFrequencyFormat = "frequencyFormat"
|
||||
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||
private val keySelectedIds = "selectedIds"
|
||||
private val keySelectedTypes = "selectedTypes"
|
||||
private val keySelectedModes = "selectedModes"
|
||||
private val keySelectedSatModes = "selectedSatModes"
|
||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||
private val keyStateOfSensors = "stateOfSensors"
|
||||
private val keyStateOfSweep = "stateOfSweep"
|
||||
private val keyStateOfUtc = "stateOfUtc"
|
||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||
private val keyStateOfNightMode = "stateOfNightMode"
|
||||
private val keyStationAltitude = "stationAltitude"
|
||||
private val keyStationLatitude = "stationLatitude"
|
||||
private val keyStationLongitude = "stationLongitude"
|
||||
@@ -78,17 +87,21 @@ class SettingsRepo(
|
||||
private val keyUpdateTimestamp = "updateTimestamp"
|
||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||
private val keyUseCustomTle = "useCustomTle"
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
private val keyTransceiversUrl = "transceiversUrl"
|
||||
private val keySstvMode = "sstvMode"
|
||||
private val keyLowElevation = "lowElevation"
|
||||
private val keyHighElevation = "highElevation"
|
||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||
private val keySatelliteUrls = "satelliteUrls"
|
||||
private val keyTransceiversUrls = "transceiversUrls"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
|
||||
//region # Satellites selection settings
|
||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
||||
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
||||
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) {
|
||||
val selectionString = ids.joinToString(separatorComma)
|
||||
@@ -96,10 +109,10 @@ class SettingsRepo(
|
||||
_satelliteSelection.value = ids
|
||||
}
|
||||
|
||||
override fun setSelectedTypes(types: List<String>) {
|
||||
val typesString = types.joinToString(separatorComma)
|
||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
||||
_typesSelection.value = types
|
||||
override fun setSelectedSatModes(modes: List<String>) {
|
||||
val modesString = modes.joinToString(separatorComma)
|
||||
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||
_satelliteModeSelection.value = modes
|
||||
}
|
||||
|
||||
private fun getSelectedIds(): List<Int> {
|
||||
@@ -108,10 +121,10 @@ class SettingsRepo(
|
||||
return selectionString.split(separatorComma).map { it.toInt() }
|
||||
}
|
||||
|
||||
private fun getSelectedTypes(): List<String> {
|
||||
val typesString = preferences.getString(keySelectedTypes, null)
|
||||
if (typesString.isNullOrEmpty()) return listOf("Amateur")
|
||||
return typesString.split(separatorComma)
|
||||
private fun getSelectedSatModes(): List<String> {
|
||||
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||
if (modesString.isNullOrEmpty()) return emptyList()
|
||||
return modesString.split(separatorComma).sorted()
|
||||
}
|
||||
//endregion
|
||||
|
||||
@@ -120,18 +133,30 @@ class SettingsRepo(
|
||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||
|
||||
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||
_passesSettings.value = settings
|
||||
}
|
||||
|
||||
private fun getPassesSettings(): PassesSettings {
|
||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||
val selectedModesString = preferences.getString(keySelectedModes, null)
|
||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||
return PassesSettings(hoursAhead, minElevation, selectedModes)
|
||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||
return PassesSettings(
|
||||
showDeepSpace,
|
||||
hoursAhead,
|
||||
minElevation,
|
||||
aosStartMinute,
|
||||
aosEndMinute,
|
||||
invertAosTimeWindow
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
|
||||
@@ -210,25 +235,6 @@ class SettingsRepo(
|
||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
|
||||
val idsSet = mutableSetOf<Int>()
|
||||
types.forEach { type ->
|
||||
val typeString = preferences.getString("type$type", null)
|
||||
val typeIds = if (typeString.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
typeString.split(separatorComma).map { it.toInt() }
|
||||
}
|
||||
idsSet.addAll(typeIds)
|
||||
}
|
||||
return idsSet.toList()
|
||||
}
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||
if (type == "All") return
|
||||
val typesString = ids.joinToString(separatorComma)
|
||||
preferences.edit { putString("type$type", typesString) }
|
||||
}
|
||||
|
||||
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
||||
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
||||
@@ -246,82 +252,56 @@ class SettingsRepo(
|
||||
//endregion
|
||||
|
||||
//region # RC settings
|
||||
init {
|
||||
migrateRCFormats()
|
||||
}
|
||||
|
||||
// TODO: Remove after a few releases (added in v4.2.0)
|
||||
private val keyRCFormatsMigrated = "rcFormatsMigrated"
|
||||
|
||||
private fun migrateRCFormats() {
|
||||
if (preferences.getBoolean(keyRCFormatsMigrated, false)) return
|
||||
val formatKeys = listOf(
|
||||
keyRotatorFormat, keyFrequencyFormat, keyBluetoothRotatorFormat, keyBluetoothFrequencyFormat
|
||||
)
|
||||
preferences.edit {
|
||||
for (key in formatKeys) {
|
||||
val value = preferences.getString(key, null) ?: continue
|
||||
if (value.contains("_") && !value.startsWith("\\")) {
|
||||
putString(key, "\\$value")
|
||||
}
|
||||
}
|
||||
putBoolean(keyRCFormatsMigrated, true)
|
||||
}
|
||||
}
|
||||
|
||||
private val _rcSettings = MutableStateFlow(getRCSettings())
|
||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||
|
||||
override fun setBluetoothRotatorAddress(value: String) {
|
||||
preferences.edit { putString(keyBluetoothRotatorAddress, value) }
|
||||
_rcSettings.update { it.copy(bluetoothRotatorAddress = value) }
|
||||
}
|
||||
|
||||
override fun setBluetoothRotatorFormat(value: String) {
|
||||
preferences.edit { putString(keyBluetoothRotatorFormat, value) }
|
||||
_rcSettings.update { it.copy(bluetoothRotatorFormat = value) }
|
||||
}
|
||||
|
||||
override fun setBluetoothRotatorName(value: String) {
|
||||
preferences.edit { putString(keyBluetoothRotatorName, value) }
|
||||
_rcSettings.update { it.copy(bluetoothRotatorName = value) }
|
||||
}
|
||||
|
||||
override fun setBluetoothRotatorState(value: Boolean) {
|
||||
preferences.edit { putBoolean(keyBluetoothRotatorState, value) }
|
||||
_rcSettings.update { it.copy(bluetoothRotatorState = value) }
|
||||
}
|
||||
|
||||
override fun setBluetoothFrequencyState(value: Boolean) {
|
||||
preferences.edit { putBoolean(keyBluetoothFrequencyState, value) }
|
||||
_rcSettings.update { it.copy(bluetoothFrequencyState = value) }
|
||||
}
|
||||
|
||||
override fun setBluetoothFrequencyAddress(value: String) {
|
||||
preferences.edit { putString(keyBluetoothFrequencyAddress, value) }
|
||||
_rcSettings.update { it.copy(bluetoothFrequencyAddress = value) }
|
||||
}
|
||||
|
||||
override fun setBluetoothFrequencyFormat(value: String) {
|
||||
preferences.edit { putString(keyBluetoothFrequencyFormat, value) }
|
||||
_rcSettings.update { it.copy(bluetoothFrequencyFormat = value) }
|
||||
}
|
||||
|
||||
override fun setRotatorAddress(value: String) {
|
||||
preferences.edit { putString(keyRotatorAddress, value) }
|
||||
_rcSettings.update { it.copy(rotatorAddress = value) }
|
||||
}
|
||||
|
||||
override fun setRotatorPort(value: String) {
|
||||
preferences.edit { putString(keyRotatorPort, value) }
|
||||
_rcSettings.update { it.copy(rotatorPort = value) }
|
||||
}
|
||||
|
||||
override fun setRotatorState(value: Boolean) {
|
||||
preferences.edit { putBoolean(keyRotatorState, value) }
|
||||
_rcSettings.update { it.copy(rotatorState = value) }
|
||||
}
|
||||
|
||||
override fun setRotatorFormat(value: String) {
|
||||
preferences.edit { putString(keyRotatorFormat, value) }
|
||||
_rcSettings.update { it.copy(rotatorFormat = value) }
|
||||
}
|
||||
|
||||
override fun setFrequencyState(value: Boolean) {
|
||||
preferences.edit { putBoolean(keyFrequencyState, value) }
|
||||
_rcSettings.update { it.copy(frequencyState = value) }
|
||||
}
|
||||
|
||||
override fun setFrequencyAddress(value: String) {
|
||||
preferences.edit { putString(keyFrequencyAddress, value) }
|
||||
_rcSettings.update { it.copy(frequencyAddress = value) }
|
||||
}
|
||||
|
||||
override fun setFrequencyPort(value: String) {
|
||||
preferences.edit { putString(keyFrequencyPort, value) }
|
||||
_rcSettings.update { it.copy(frequencyPort = value) }
|
||||
}
|
||||
|
||||
override fun setFrequencyFormat(value: String) {
|
||||
preferences.edit { putString(keyFrequencyFormat, value) }
|
||||
_rcSettings.update { it.copy(frequencyFormat = value) }
|
||||
override fun updateRCSettings(settings: RCSettings) {
|
||||
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
preferences.edit {
|
||||
putBoolean(keyRotatorState, settings.rotatorState)
|
||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||
putString(keyRotatorPort, settings.rotatorPort)
|
||||
putString(keyRotatorFormat, settings.rotatorFormat)
|
||||
putBoolean(keyFrequencyState, settings.frequencyState)
|
||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||
putString(keyFrequencyPort, settings.frequencyPort)
|
||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||
putString(keyBluetoothRotatorAddress, settings.bluetoothRotatorAddress)
|
||||
putBoolean(keyBluetoothFrequencyState, settings.bluetoothFrequencyState)
|
||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||
}
|
||||
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||
}
|
||||
|
||||
private fun getRCSettings(): RCSettings = RCSettings(
|
||||
@@ -332,14 +312,16 @@ class SettingsRepo(
|
||||
frequencyState = preferences.getBoolean(keyFrequencyState, false),
|
||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"set_freq $FREQ",
|
||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"W$AZ $EL",
|
||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||
bluetoothRotatorAddress = preferences.getString(keyBluetoothRotatorAddress, null) ?: "00:0C:BF:13:80:5D",
|
||||
bluetoothFrequencyState = preferences.getBoolean(keyBluetoothFrequencyState, false),
|
||||
bluetoothFrequencyAddress = preferences.getString(keyBluetoothFrequencyAddress, null) ?: "00:0C:BF:13:80:5D",
|
||||
bluetoothFrequencyFormat = preferences.getString(keyBluetoothFrequencyFormat, null) ?: $$"FA$FREQ"
|
||||
bluetoothFrequencyFormat = preferences.getString(keyBluetoothFrequencyFormat, null) ?: $$"F $FREQ"
|
||||
)
|
||||
//endregion
|
||||
|
||||
@@ -347,39 +329,26 @@ class SettingsRepo(
|
||||
private val _otherSettings = MutableStateFlow(getOtherSettings())
|
||||
override val otherSettings: StateFlow<OtherSettings> = _otherSettings
|
||||
|
||||
override fun setStateOfAutoUpdate(value: Boolean) {
|
||||
preferences.edit { putBoolean(keyStateOfAutoUpdate, value) }
|
||||
_otherSettings.update { it.copy(stateOfAutoUpdate = value) }
|
||||
}
|
||||
|
||||
override fun setStateOfSensors(value: Boolean) {
|
||||
preferences.edit { putBoolean(keyStateOfSensors, value) }
|
||||
_otherSettings.update { it.copy(stateOfSensors = value) }
|
||||
}
|
||||
|
||||
override fun setStateOfSweep(value: Boolean) {
|
||||
preferences.edit { putBoolean(keyStateOfSweep, value) }
|
||||
_otherSettings.update { it.copy(stateOfSweep = value) }
|
||||
}
|
||||
|
||||
override fun setStateOfUtc(value: Boolean) {
|
||||
preferences.edit { putBoolean(keyStateOfUtc, value) }
|
||||
_otherSettings.update { it.copy(stateOfUtc = value) }
|
||||
}
|
||||
|
||||
override fun setStateOfLightTheme(value: Boolean){
|
||||
preferences.edit { putBoolean(keyStateOfLightTheme, value) }
|
||||
_otherSettings.update { it.copy(stateOfLightTheme = value) }
|
||||
}
|
||||
|
||||
override fun setWarningDismissed() {
|
||||
preferences.edit { putBoolean(keyShouldSeeWarning, false) }
|
||||
_otherSettings.update { it.copy(shouldSeeWarning = false) }
|
||||
}
|
||||
|
||||
override fun setWhatsNewDismissed() {
|
||||
preferences.edit { putBoolean(keyShouldSeeWhatsNew, false) }
|
||||
_otherSettings.update { it.copy(shouldSeeWhatsNew = false) }
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
||||
_otherSettings.update { current ->
|
||||
val new = transform(current)
|
||||
preferences.edit {
|
||||
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
||||
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||
putString(keySstvMode, new.sstvMode)
|
||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||
}
|
||||
new
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
||||
@@ -388,8 +357,14 @@ class SettingsRepo(
|
||||
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true)
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||
)
|
||||
//endregion
|
||||
|
||||
@@ -397,31 +372,82 @@ class SettingsRepo(
|
||||
private val _dataSourcesSettings = MutableStateFlow(getDataSourcesSettings())
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||
|
||||
override fun setUseCustomTle(value: Boolean) {
|
||||
preferences.edit { putBoolean(keyUseCustomTle, value) }
|
||||
_dataSourcesSettings.update { it.copy(useCustomTLE = value) }
|
||||
}
|
||||
|
||||
override fun setUseCustomTransceivers(value: Boolean) {
|
||||
preferences.edit { putBoolean(keyUseCustomTransceivers, value) }
|
||||
_dataSourcesSettings.update { it.copy(useCustomTransceivers = value) }
|
||||
}
|
||||
|
||||
override fun setTleUrl(value: String) {
|
||||
preferences.edit { putString(keyTleUrl, value) }
|
||||
_dataSourcesSettings.update { it.copy(tleUrl = value) }
|
||||
}
|
||||
|
||||
override fun setTransceiversUrl(value: String) {
|
||||
preferences.edit { putString(keyTransceiversUrl, value) }
|
||||
_dataSourcesSettings.update { it.copy(transceiversUrl = value) }
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||
preferences.edit {
|
||||
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
|
||||
}
|
||||
_dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
||||
satelliteUrls = preferences.getString(keySatelliteUrls, null)
|
||||
?.split(separatorUrl)?.filter { it.isNotBlank() }
|
||||
?: Sources.satelliteDataUrls,
|
||||
transceiversUrls = preferences.getString(keyTransceiversUrls, null)
|
||||
?.split(separatorUrl)?.filter { it.isNotBlank() }
|
||||
?: Sources.transceiversDataUrls
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
private val keyRadioControlEnabled = "radioControlEnabled"
|
||||
private val keyRadioModel = "radioModel"
|
||||
private val keyTxRadioAddress = "txRadioAddress"
|
||||
private val keyRxRadioAddress = "rxRadioAddress"
|
||||
private val keyTxRadioName = "txRadioName"
|
||||
private val keyRxRadioName = "rxRadioName"
|
||||
private val keyRadioBaudRate = "radioBaudRate"
|
||||
private val keyRadioSplitMode = "radioSplitMode"
|
||||
|
||||
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) {
|
||||
preferences.edit {
|
||||
putBoolean(keyRadioControlEnabled, settings.enabled)
|
||||
putString(keyRadioModel, settings.radioModel)
|
||||
putString(keyTxRadioAddress, settings.txRadioAddress)
|
||||
putString(keyRxRadioAddress, settings.rxRadioAddress)
|
||||
putString(keyTxRadioName, settings.txRadioName)
|
||||
putString(keyRxRadioName, settings.rxRadioName)
|
||||
putInt(keyRadioBaudRate, settings.baudRate)
|
||||
putBoolean(keyRadioSplitMode, settings.splitMode)
|
||||
}
|
||||
_radioControlSettings.value = settings
|
||||
}
|
||||
|
||||
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
||||
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
||||
radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817,
|
||||
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
||||
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
||||
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
||||
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||
)
|
||||
|
||||
private val keySatelliteOffsets = "satelliteOffsets"
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
return try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val updated = try {
|
||||
val obj = JSONObject(json)
|
||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
"""{"$catnum": "$offset"}"""
|
||||
}
|
||||
preferences.edit { putString(keySatelliteOffsets, updated) }
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
}
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
look4SatDao.deleteRadios()
|
||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||
}
|
||||
|
||||
|
||||
@@ -45,10 +45,49 @@ class RemoteSource(
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
httpClient.newCall(networkRequest).execute().body.byteStream()
|
||||
val response = httpClient.newCall(networkRequest).execute()
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
return@withContext null
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it
|
||||
// That returns the connection to OkHttp's pool
|
||||
response.body.byteStream().buffered()
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource network stream exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||
.header("User-Agent", "Look4Sat")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource getAmSatCatalog exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
|
||||
.header("User-Agent", "Look4Sat")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource getAmSatReports exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.usecase
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import androidx.annotation.RequiresPermission
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
class AudioCapture : IAudioCapture {
|
||||
|
||||
override val sampleRate: Int = 44100
|
||||
|
||||
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||
private val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
|
||||
private val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||
.coerceAtLeast(sampleRate) // at least 1 second buffer
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.RECORD_AUDIO)
|
||||
override fun audioFlow(): Flow<FloatArray> = flow {
|
||||
val recorder = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
sampleRate,
|
||||
channelConfig,
|
||||
audioFormat,
|
||||
bufferSize * 4 // bytes for float
|
||||
)
|
||||
try {
|
||||
recorder.startRecording()
|
||||
val chunkSize = sampleRate / 10 // ~100ms chunks
|
||||
val buffer = FloatArray(chunkSize)
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val read = recorder.read(buffer, 0, chunkSize, AudioRecord.READ_BLOCKING)
|
||||
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
||||
}
|
||||
} finally {
|
||||
recorder.stop()
|
||||
recorder.release()
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.usecase
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
//Saves images to the device gallery using MediaStore (API 29+) or direct file write (API < 29)
|
||||
class SaveImage(private val context: Context) : ISaveImage {
|
||||
|
||||
override suspend fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bitmap = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val filename = "SSTV_${modeName}_$timestamp.png"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveWithMediaStore(bitmap, filename)
|
||||
} else {
|
||||
saveToExternalStorage(bitmap, filename)
|
||||
}
|
||||
bitmap.recycle()
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveWithMediaStore(bitmap: Bitmap, filename: String) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Look4Sat")
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val resolver = context.contentResolver
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
?: throw IllegalStateException("Failed to create MediaStore entry")
|
||||
resolver.openOutputStream(uri)?.use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
}
|
||||
values.clear()
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
}
|
||||
|
||||
private fun saveToExternalStorage(bitmap: Bitmap, filename: String) {
|
||||
val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||
val dir = File(picturesDir, "Look4Sat")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val file = File(dir, filename)
|
||||
FileOutputStream(file).use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class Ft817CatProtocolTest {
|
||||
|
||||
@Test
|
||||
fun encodeFrequencyBcd_145500000() {
|
||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
||||
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeFrequencyBcd_435100000() {
|
||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
||||
assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeFrequencyBcd_7074000() {
|
||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
||||
assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeFrequencyBcd_roundTrips() {
|
||||
val testFreqs = listOf(145500000L, 435100000L, 7074000L, 14200000L, 28500000L)
|
||||
for (freq in testFreqs) {
|
||||
val rounded = (freq / 10) * 10
|
||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(freq)
|
||||
assertEquals(rounded, Ft817CatProtocol.decodeFrequencyBcd(bcd))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildSetFreqCommand_correctFormat() {
|
||||
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
||||
assertEquals(5, cmd.size)
|
||||
assertEquals(0x01.toByte(), cmd[4])
|
||||
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildSetModeCommand_usb() {
|
||||
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
||||
assertNotNull(cmd)
|
||||
assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildSetModeCommand_fm() {
|
||||
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
||||
assertNotNull(cmd)
|
||||
assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildSetModeCommand_unknownReturnsNull() {
|
||||
assertNull(Ft817CatProtocol.buildSetModeCommand("INVALID"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeCtcssTone_67_0() {
|
||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
||||
assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeCtcssTone_74_4() {
|
||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
||||
assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeCtcssTone_141_3() {
|
||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
||||
assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildSetCtcssToneCommand_correctFormat() {
|
||||
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
||||
assertEquals(5, cmd.size)
|
||||
assertEquals(0x0B.toByte(), cmd[4])
|
||||
assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildCtcssModeCommand_enable() {
|
||||
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
||||
assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildCtcssModeCommand_disable() {
|
||||
val cmd = Ft817CatProtocol.buildCtcssModeCommand(false)
|
||||
assertEquals(0x8A.toByte(), cmd[0])
|
||||
assertEquals(0x0A.toByte(), cmd[4])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReadResponse_validResponse() {
|
||||
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||
assertNotNull(result)
|
||||
result ?: return
|
||||
assertEquals(145500000L, result.first)
|
||||
assertEquals("USB", result.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReadResponse_fmMode() {
|
||||
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||
assertNotNull(result)
|
||||
result ?: return
|
||||
assertEquals(146000000L, result.first)
|
||||
assertEquals("FM", result.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReadResponse_tooShort() {
|
||||
assertNull(Ft817CatProtocol.parseReadResponse(byteArrayOf(0x14, 0x55, 0x00)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReadResponse_unknownMode() {
|
||||
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x0F)
|
||||
assertNull(Ft817CatProtocol.parseReadResponse(response))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildPttCommands() {
|
||||
val on = Ft817CatProtocol.buildPttOnCommand()
|
||||
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||
|
||||
val off = Ft817CatProtocol.buildPttOffCommand()
|
||||
assertEquals(0x88.toByte(), off[4])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildReadCommand() {
|
||||
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
||||
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DatabaseRepoTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val dataParser = DataParser(dispatcher)
|
||||
|
||||
@Test
|
||||
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||
val uri = "content://look4sat/import/satellites"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[uri] = { validCsvStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateTLEFromFile(uri)
|
||||
|
||||
assertEquals(1, localSource.insertedEntries.size)
|
||||
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual satellite import keeps tle support`() = runTest(dispatcher) {
|
||||
val uri = "content://look4sat/import/legacy"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[uri] = { validTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateTLEFromFile(uri)
|
||||
|
||||
assertEquals(1, localSource.insertedEntries.size)
|
||||
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom data source imports omm csv from web`() = runTest(dispatcher) {
|
||||
val customCsvUrl = "https://example.com/custom-omm.csv"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[customCsvUrl] = { validCsvStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(customCsvUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun validTleStream(): InputStream = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
}
|
||||
|
||||
private class FakeRemoteSource : IRemoteSource {
|
||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
}
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
val insertedEntries = mutableListOf<OrbitalData>()
|
||||
private val insertedRadios = mutableListOf<SatRadio>()
|
||||
|
||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||
|
||||
override suspend fun getEntriesList(): List<SatItem> = emptyList()
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
||||
insertedEntries += entries
|
||||
}
|
||||
|
||||
override suspend fun deleteEntries() {
|
||||
insertedEntries.clear()
|
||||
}
|
||||
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
|
||||
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
insertedRadios += radios
|
||||
}
|
||||
|
||||
override suspend fun deleteRadios() {
|
||||
insertedRadios.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||
)
|
||||
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
)
|
||||
|
||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
|
||||
override fun setSelectedSatModes(modes: List<String>) = Unit
|
||||
|
||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||
|
||||
override fun setStationPosition(): Boolean = true
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
|
||||
override fun updateDatabaseState(state: DatabaseState) {
|
||||
databaseState.value = state
|
||||
}
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||
dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
return DataSourcesSettings(
|
||||
satelliteUrls = emptyList(),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SelectionRepoTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Test
|
||||
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
|
||||
val localSource = FakeLocalSource(
|
||||
entries = listOf(
|
||||
SatItem(25544, "ISS (ZARYA)", false),
|
||||
SatItem(40967, "TIANGONG", false)
|
||||
)
|
||||
)
|
||||
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
|
||||
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setModes(listOf("REMOVED_MODE"))
|
||||
|
||||
val items = flow.first()
|
||||
|
||||
assertEquals(listOf(25544, 40967), items.map { it.catnum })
|
||||
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected satellites are shown first`() = runTest(dispatcher) {
|
||||
val localSource = FakeLocalSource(
|
||||
entries = listOf(
|
||||
SatItem(44444, "Zeta", false),
|
||||
SatItem(25544, "Alpha", false),
|
||||
SatItem(40967, "Beta", false)
|
||||
)
|
||||
)
|
||||
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
|
||||
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setSelection(listOf(40967), true)
|
||||
|
||||
val items = flow.first()
|
||||
|
||||
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
|
||||
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
||||
}
|
||||
|
||||
private class FakeLocalSource(
|
||||
private val entries: List<SatItem>
|
||||
) : ILocalSource {
|
||||
override suspend fun getEntriesTotal(): Int = entries.size
|
||||
|
||||
override suspend fun getEntriesList(): List<SatItem> = entries
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
||||
|
||||
override suspend fun deleteEntries() = Unit
|
||||
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
|
||||
override suspend fun getRadiosTotal(): Int = 0
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
|
||||
|
||||
override suspend fun deleteRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepo(
|
||||
selectedModes: List<String>
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
|
||||
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||
)
|
||||
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
)
|
||||
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(emptyList(), emptyList())
|
||||
)
|
||||
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
|
||||
override fun setSelectedSatModes(modes: List<String>) {
|
||||
selectedSatModes.value = modes
|
||||
}
|
||||
|
||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||
|
||||
override fun setStationPosition(): Boolean = true
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
override fun updateDatabaseState(state: DatabaseState) {
|
||||
databaseState.value = state
|
||||
}
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.kotlinLibraryPlugin)
|
||||
alias(libs.plugins.convention.coreDomainPlugin)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
object Constants {
|
||||
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||
}
|
||||
@@ -17,16 +17,20 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SatRadio(
|
||||
val uuid: String,
|
||||
val info: String,
|
||||
val isAlive: Boolean,
|
||||
var downlinkLow: Long?,
|
||||
var downlinkHigh: Long?,
|
||||
val downlinkMode: String?,
|
||||
var uplinkLow: Long?,
|
||||
var uplinkHigh: Long?,
|
||||
val uplinkMode: String?,
|
||||
val isInverted: Boolean,
|
||||
val catnum: Int?
|
||||
@SerialName("uuid") val uuid: String,
|
||||
@SerialName("description") val info: String,
|
||||
@SerialName("alive") val isAlive: Boolean,
|
||||
@SerialName("downlink_low") val downlinkLow: Long?,
|
||||
@SerialName("downlink_high") val downlinkHigh: Long?,
|
||||
@SerialName("mode") val downlinkMode: String?,
|
||||
@SerialName("uplink_low") val uplinkLow: Long?,
|
||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||
@SerialName("invert") val isInverted: Boolean,
|
||||
@SerialName("norad_cat_id") val catnum: Int?
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One satellite status report (AMSAT site tooltip data) */
|
||||
data class SatReport(
|
||||
val id: String, // 报告 ID(a885153)
|
||||
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||
val call: String, // 呼号
|
||||
val grid: String, // 网格坐标(可为空)
|
||||
val dateUtc: String, // 2026-08-04
|
||||
val timeUtc: String // 2:46-:59 UTC
|
||||
)
|
||||
|
||||
/** State of one 2-hour slot */
|
||||
data class SatSlot(
|
||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||
val count: Int, // 报告数量(0 = 无)
|
||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||
)
|
||||
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
data class SatDay(
|
||||
val dateLabel: String, // "Aug 4"
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** One satellite, 5 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 5 天(新→旧)
|
||||
)
|
||||
|
||||
/** Overall page parse result */
|
||||
data class SatStatusPage(
|
||||
val fetchedAtUtcMs: Long,
|
||||
val statuses: List<SatStatus>,
|
||||
val reports: Map<String, SatReport> // id → 报告
|
||||
)
|
||||
@@ -24,9 +24,12 @@ data class DatabaseState(
|
||||
)
|
||||
|
||||
data class PassesSettings(
|
||||
val showDeepSpace: Boolean = true,
|
||||
val hoursAhead: Int,
|
||||
val minElevation: Double,
|
||||
val selectedModes: List<String>
|
||||
val aosStartMinute: Int = 0,
|
||||
val aosEndMinute: Int = 23 * 60 + 59,
|
||||
val invertAosTimeWindow: Boolean = false
|
||||
)
|
||||
|
||||
data class RCSettings(
|
||||
@@ -38,6 +41,7 @@ data class RCSettings(
|
||||
val frequencyAddress: String,
|
||||
val frequencyPort: String,
|
||||
val frequencyFormat: String,
|
||||
val frequencyOffsetHz: Long = 0L,
|
||||
val bluetoothRotatorState: Boolean,
|
||||
val bluetoothRotatorFormat: String,
|
||||
val bluetoothRotatorName: String,
|
||||
@@ -53,13 +57,42 @@ data class OtherSettings(
|
||||
val stateOfSweep: Boolean,
|
||||
val stateOfUtc: Boolean,
|
||||
val stateOfLightTheme: Boolean,
|
||||
val stateOfNightMode: Boolean = false,
|
||||
val shouldSeeWarning: Boolean,
|
||||
val shouldSeeWhatsNew: Boolean
|
||||
val shouldSeeWhatsNew: Boolean,
|
||||
val sstvMode: String = "Auto",
|
||||
val lowElevation: Double = 15.0,
|
||||
val highElevation: Double = 45.0,
|
||||
val radarCompassOffset: Float = 0f,
|
||||
val radarCompassOffsetElev: Float = 0f
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
val useCustomTLE: Boolean,
|
||||
val useCustomTransceivers: Boolean,
|
||||
val tleUrl: String,
|
||||
val transceiversUrl: String
|
||||
val satelliteUrls: List<String>,
|
||||
val transceiversUrls: List<String>
|
||||
)
|
||||
|
||||
data class RadioControlSettings(
|
||||
val enabled: Boolean,
|
||||
val radioModel: String,
|
||||
val txRadioAddress: String,
|
||||
val rxRadioAddress: String,
|
||||
val txRadioName: String,
|
||||
val rxRadioName: String,
|
||||
val baudRate: Int,
|
||||
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
|
||||
val splitMode: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
|
||||
const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
|
||||
const val MODEL_ICOM_IC705 = "Icom IC-705"
|
||||
|
||||
val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705)
|
||||
|
||||
/** Baud rates available for Yaesu radios. */
|
||||
val BAUD_RATES_YAESU = listOf(4800, 9600, 38400)
|
||||
/** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */
|
||||
val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200)
|
||||
}
|
||||
}
|
||||
+667
@@ -0,0 +1,667 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.predict
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toRadians
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.acos
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.tan
|
||||
|
||||
/**
|
||||
* Standalone celestial computations extracted from PREDICT v2.2.5.
|
||||
* Provides Sun position, Moon position, satellite visibility classification,
|
||||
* orbital metadata, RA/Dec conversion, and rise/set finding for Sun and Moon.
|
||||
*
|
||||
* All angles are in degrees unless noted. Time is Unix epoch milliseconds.
|
||||
*
|
||||
* Shared math utilities (thetaGJD, modulus, mod2PI, deltaET, millisToDaynum,
|
||||
* solarPositionECI, eciToGeodetic) live in OrbitalMath.kt in the same package.
|
||||
*/
|
||||
object CelestialComputer {
|
||||
|
||||
// ── Result types ──
|
||||
|
||||
/** Sun position as seen from a ground observer. */
|
||||
data class SunPosition(
|
||||
val azimuth: Double, // degrees, 0=N, 90=E
|
||||
val elevation: Double, // degrees, >0 above horizon
|
||||
val distance: Double, // normalized: 1.0 + ((range - AU) / AU)
|
||||
val rangeRate: Double, // km/s
|
||||
val latitude: Double, // sub-solar point latitude, degrees
|
||||
val longitude: Double, // sub-solar point longitude, degrees
|
||||
val rightAscension: Double, // degrees
|
||||
val declination: Double // degrees
|
||||
)
|
||||
|
||||
/** Moon position as seen from a ground observer. */
|
||||
data class MoonPosition(
|
||||
val azimuth: Double, // degrees, 0=N, 90=E
|
||||
val elevation: Double, // degrees
|
||||
val rightAscension: Double, // degrees
|
||||
val declination: Double, // degrees
|
||||
val gha: Double, // Greenwich Hour Angle, degrees
|
||||
val angularDiameter: Double, // apparent diameter relative to Earth's diameter
|
||||
val radialVelocity: Double // m/s, Doppler radial velocity for EME
|
||||
)
|
||||
|
||||
/**
|
||||
* 3-state satellite visibility classification.
|
||||
* - [VISIBLE]: satellite is sunlit, observer is in darkness (sun below -12°) — optically visible
|
||||
* - [DAYLIGHT]: satellite is sunlit, observer is in daylight
|
||||
* - [ECLIPSED]: satellite is in Earth's shadow
|
||||
*/
|
||||
enum class SatVisibility { VISIBLE, DAYLIGHT, ECLIPSED }
|
||||
|
||||
/** Orbital metadata not typically included in pass data. */
|
||||
data class OrbitalMetadata(
|
||||
val footprintDiameter: Double, // km, ground coverage circle diameter
|
||||
val orbitNumber: Long, // current orbit/revolution number
|
||||
val betaAngle: Double, // degrees, angle between orbital plane and Sun
|
||||
val orbitalPhase: Double // 0-256 phase within current orbit
|
||||
)
|
||||
|
||||
// ── Sun position ──
|
||||
|
||||
/**
|
||||
* Compute the Sun's full position as seen from [observer] at [timeMillis].
|
||||
* Includes az/el, RA/Dec, sub-solar lat/lon, range, and range rate.
|
||||
* Based on FindSun() from PREDICT v2.2.5.
|
||||
*/
|
||||
fun getSunPosition(observer: GeoPos, timeMillis: Long): SunPosition {
|
||||
val daynum = millisToDaynum(timeMillis)
|
||||
val julUtc = daynum + 2444238.5
|
||||
val sunVec = solarPositionECI(julUtc)
|
||||
val zeroVel = doubleArrayOf(0.0, 0.0, 0.0)
|
||||
val obsGeo = observerGeodetic(observer)
|
||||
|
||||
// Az, El, Range, RangeRate
|
||||
val obsSet = computeObsAngles(julUtc, sunVec, zeroVel, obsGeo)
|
||||
|
||||
// Lat/Lon of sub-solar point
|
||||
val latLon = eciToGeodetic(julUtc, sunVec)
|
||||
|
||||
// RA/Dec
|
||||
val raDec = calculateRADec(julUtc, sunVec, zeroVel, obsGeo)
|
||||
|
||||
return SunPosition(
|
||||
azimuth = obsSet[0].toDegrees(),
|
||||
elevation = obsSet[1].toDegrees(),
|
||||
distance = 1.0 + ((obsSet[2] - ASTRONOMICAL_UNIT) / ASTRONOMICAL_UNIT),
|
||||
rangeRate = 1000.0 * obsSet[3],
|
||||
latitude = latLon[0].toDegrees(),
|
||||
longitude = latLon[1].toDegrees().let { if (it > 180.0) it - 360.0 else it },
|
||||
rightAscension = raDec[0].toDegrees(),
|
||||
declination = raDec[1].toDegrees()
|
||||
)
|
||||
}
|
||||
|
||||
// ── Moon position ──
|
||||
|
||||
/**
|
||||
* Compute the Moon's position as seen from [observer] at [timeMillis].
|
||||
* Full Meeus lunar ephemeris from PREDICT v2.2.5 with expanded terms
|
||||
* and radial velocity approximation for EME Doppler.
|
||||
*/
|
||||
fun getMoonPosition(observer: GeoPos, timeMillis: Long): MoonPosition {
|
||||
val daynum = millisToDaynum(timeMillis)
|
||||
val jd = daynum + 2444238.5
|
||||
var t = (jd - 2415020.0) / 36525.0
|
||||
val t2 = t * t
|
||||
val t3 = t2 * t
|
||||
|
||||
var l1 = 270.434164 + 481267.8831 * t - 0.001133 * t2 + 0.0000019 * t3
|
||||
var mSun = 358.475833 + 35999.0498 * t - 0.00015 * t2 - 0.0000033 * t3
|
||||
var m1 = 296.104608 + 477198.8491 * t + 0.009192 * t2 + 0.0000144 * t3
|
||||
var d = 350.737486 + 445267.1142 * t - 0.001436 * t2 + 0.0000019 * t3
|
||||
var ff = 11.250889 + 483202.0251 * t - 0.003211 * t2 - 0.0000003 * t3
|
||||
val om = (259.183275 - 1934.142 * t + 0.002078 * t2 + 0.0000022 * t3) * DEG2RAD
|
||||
|
||||
val correction512 = sin((51.2 + 20.2 * t) * DEG2RAD)
|
||||
val ss = 0.003964 * sin((346.56 + 132.87 * t - 0.0091731 * t2) * DEG2RAD)
|
||||
l1 += 0.000233 * correction512 + ss + 0.001964 * sin(om)
|
||||
mSun -= 0.001778 * correction512
|
||||
m1 += 0.000817 * correction512 + ss + 0.002541 * sin(om)
|
||||
d += 0.002011 * correction512 + ss + 0.001964 * sin(om)
|
||||
ff += ss - 0.024691 * sin(om) - 0.004328 * sin(om + (275.05 - 2.3 * t) * DEG2RAD)
|
||||
|
||||
val ex = 1.0 - 0.002495 * t - 0.00000752 * t2
|
||||
l1 = primeAngle(l1); mSun = primeAngle(mSun); m1 = primeAngle(m1)
|
||||
d = primeAngle(d); ff = primeAngle(ff)
|
||||
|
||||
val mR = mSun * DEG2RAD
|
||||
val m1R = m1 * DEG2RAD
|
||||
val dR = d * DEG2RAD
|
||||
val ffR = ff * DEG2RAD
|
||||
|
||||
// Ecliptic longitude — expanded v225 terms
|
||||
var l = l1 + 6.28875 * sin(m1R) + 1.274018 * sin(2 * dR - m1R) + 0.658309 * sin(2 * dR)
|
||||
l += 0.213616 * sin(2 * m1R) - ex * 0.185596 * sin(mR) - 0.114336 * sin(2 * ffR)
|
||||
l += 0.058793 * sin(2 * dR - 2 * m1R) + ex * 0.057212 * sin(2 * dR - mR - m1R) + 0.05332 * sin(2 * dR + m1R)
|
||||
l += ex * 0.045874 * sin(2 * dR - mR) + ex * 0.041024 * sin(m1R - mR) - 0.034718 * sin(dR)
|
||||
l -= ex * 0.030465 * sin(mR + m1R) + 0.015326 * sin(2 * dR - 2 * ffR) - 0.012528 * sin(2 * ffR + m1R)
|
||||
l -= 0.01098 * sin(2 * ffR - m1R) + 0.010674 * sin(4 * dR - m1R) + 0.010034 * sin(3 * m1R)
|
||||
l += 0.008548 * sin(4 * dR - 2 * m1R) - ex * 0.00791 * sin(mR - m1R + 2 * dR)
|
||||
l -= ex * 0.006783 * sin(2 * dR + mR)
|
||||
l += 0.005162 * sin(m1R - dR) + ex * 0.005 * sin(mR + dR) + ex * 0.004049 * sin(m1R - mR + 2 * dR)
|
||||
l += 0.003996 * sin(2 * m1R + 2 * dR) + 0.003862 * sin(4 * dR) + 0.003665 * sin(2 * dR - 3 * m1R)
|
||||
l += ex * 0.002695 * sin(2 * m1R - mR) + 0.002602 * sin(m1R - 2 * ffR - 2 * dR)
|
||||
l += ex * 0.002396 * sin(2 * dR - mR - 2 * m1R)
|
||||
l -= 0.002349 * sin(m1R + dR) + ex * ex * 0.002249 * sin(2 * dR - 2 * mR)
|
||||
l -= ex * 0.002125 * sin(2 * m1R + mR)
|
||||
l -= ex * ex * 0.002079 * sin(2 * mR) + ex * ex * 0.002059 * sin(2 * dR - m1R - 2 * mR)
|
||||
l -= 0.001773 * sin(m1R + 2 * dR - 2 * ffR)
|
||||
l += ex * 0.00122 * sin(4 * dR - mR - m1R) - 0.00111 * sin(2 * m1R + 2 * ffR) + 0.000892 * sin(m1R - 3 * dR)
|
||||
l -= ex * 0.000811 * sin(mR + m1R + 2 * dR) + ex * 0.000761 * sin(4 * dR - mR - 2 * m1R)
|
||||
l += ex * ex * 0.000717 * sin(m1R - 2 * mR)
|
||||
l += ex * ex * 0.000704 * sin(m1R - 2 * mR - 2 * dR) + ex * 0.000693 * sin(mR - 2 * m1R + 2 * dR)
|
||||
l += ex * 0.000598 * sin(2 * dR - mR - 2 * ffR) + 0.00055 * sin(m1R + 4 * dR)
|
||||
l += 0.000538 * sin(4 * m1R) + ex * 0.000521 * sin(4 * dR - mR) + 0.000486 * sin(2 * m1R - dR)
|
||||
l -= 0.001595 * sin(2 * ffR + 2 * dR)
|
||||
|
||||
// Ecliptic latitude — expanded v225 terms
|
||||
var b =
|
||||
5.128189 * sin(ffR) + 0.280606 * sin(m1R + ffR) + 0.277693 * sin(m1R - ffR) + 0.173238 * sin(2 * dR - ffR)
|
||||
b += 0.055413 * sin(2 * dR + ffR - m1R) + 0.046272 * sin(2 * dR - ffR - m1R) + 0.032573 * sin(2 * dR + ffR)
|
||||
b += 0.017198 * sin(2 * m1R + ffR) + 9.266999e-03 * sin(2 * dR + m1R - ffR) + 0.008823 * sin(2 * m1R - ffR)
|
||||
b += ex * 0.008247 * sin(2 * dR - mR - ffR) + 0.004323 * sin(2 * dR - ffR - 2 * m1R)
|
||||
b += 0.0042 * sin(2 * dR + ffR + m1R)
|
||||
b += ex * 0.003372 * sin(ffR - mR - 2 * dR) + ex * 0.002472 * sin(2 * dR + ffR - mR - m1R)
|
||||
b += ex * 0.002222 * sin(2 * dR + ffR - mR)
|
||||
b += 0.002072 * sin(2 * dR - ffR - mR - m1R) + ex * 0.001877 * sin(ffR - mR + m1R)
|
||||
b += 0.001828 * sin(4 * dR - ffR - m1R)
|
||||
b -= ex * 0.001803 * sin(ffR + mR) - 0.00175 * sin(3 * ffR)
|
||||
b += ex * 0.00157 * sin(m1R - mR - ffR) - 0.001487 * sin(ffR + dR)
|
||||
b -= ex * 0.001481 * sin(ffR + mR + m1R) + ex * 0.001417 * sin(ffR - mR - m1R)
|
||||
b += ex * 0.00135 * sin(ffR - mR) + 0.00133 * sin(ffR - dR)
|
||||
b += 0.001106 * sin(ffR + 3 * m1R) + 0.00102 * sin(4 * dR - ffR) + 0.000833 * sin(ffR + 4 * dR - m1R)
|
||||
b += 0.000781 * sin(m1R - 3 * ffR) + 0.00067 * sin(ffR + 4 * dR - 2 * m1R)
|
||||
b += 0.000606 * sin(2 * dR - 3 * ffR)
|
||||
b += 0.000597 * sin(2 * dR + 2 * m1R - ffR) + ex * 0.000492 * sin(2 * dR + m1R - mR - ffR)
|
||||
b += 0.00045 * sin(2 * m1R - ffR - 2 * dR)
|
||||
b += 0.000439 * sin(3 * m1R - ffR) + 0.000423 * sin(ffR + 2 * dR + 2 * m1R)
|
||||
b += 0.000422 * sin(2 * dR - ffR - 3 * m1R)
|
||||
b -= ex * 0.000367 * sin(mR + ffR + 2 * dR - m1R) - ex * 0.000353 * sin(mR + ffR + 2 * dR)
|
||||
b += 0.000331 * sin(ffR + 4 * dR)
|
||||
b += ex * 0.000317 * sin(2 * dR + ffR - mR + m1R) + ex * ex * 0.000306 * sin(2 * dR - 2 * mR - ffR)
|
||||
b -= 0.000283 * sin(m1R + 3 * ffR)
|
||||
|
||||
val w1 = 0.0004664 * cos(om)
|
||||
val w2 = 0.0000754 * cos(om + (275.05 - 2.3 * t) * DEG2RAD)
|
||||
val bt = b * (1.0 - w1 - w2)
|
||||
|
||||
// Parallax — expanded v225 terms
|
||||
var p =
|
||||
0.950724 + 0.051818 * cos(m1R) + 0.009531 * cos(2 * dR - m1R) + 0.007843 * cos(2 * dR) + 0.002824 * cos(2 * m1R)
|
||||
p += 0.000857 * cos(2 * dR + m1R) + ex * 0.000533 * cos(2 * dR - mR) + ex * 0.000401 * cos(2 * dR - mR - m1R)
|
||||
p += 0.000173 * cos(3 * m1R) + 0.000167 * cos(4 * dR - m1R) - ex * 0.000111 * cos(mR)
|
||||
p += 0.000103 * cos(4 * dR - 2 * m1R) - 0.000084 * cos(2 * m1R - 2 * dR) - ex * 0.000083 * cos(2 * dR + mR)
|
||||
p += 0.000079 * cos(2 * dR + 2 * m1R)
|
||||
p += 0.000072 * cos(4 * dR) + ex * 0.000064 * cos(2 * dR - mR + m1R) - ex * 0.000063 * cos(2 * dR + mR - m1R)
|
||||
p += ex * 0.000041 * cos(mR + dR) + ex * 0.000035 * cos(2 * m1R - mR) - 0.000033 * cos(3 * m1R - 2 * dR)
|
||||
p -= 0.00003 * cos(m1R + dR) - 0.000029 * cos(2 * ffR - 2 * dR) - ex * 0.000029 * cos(2 * m1R + mR)
|
||||
p += ex * ex * 0.000026 * cos(2 * dR - 2 * mR) - 0.000023 * cos(2 * ffR - 2 * dR + m1R)
|
||||
p += ex * 0.000019 * cos(4 * dR - mR - m1R)
|
||||
|
||||
val bRad = bt * DEG2RAD
|
||||
val lm = l * DEG2RAD
|
||||
val moonDx = 3.0 / (PI * p)
|
||||
|
||||
// Ecliptic → equatorial
|
||||
val z = (jd - 2415020.5) / 365.2422
|
||||
val ob = (23.452294 - (0.46845 * z + 5.9e-07 * z * z) / 3600.0).toRadians()
|
||||
val dec = asin(sin(bRad) * cos(ob) + cos(bRad) * sin(ob) * sin(lm))
|
||||
var ra = acos(cos(bRad) * cos(lm) / cos(dec)); if (lm > PI) ra = TWO_PI - ra
|
||||
|
||||
val n = observer.latitude * DEG2RAD
|
||||
t = (jd - 2451545.0) / 36525.0
|
||||
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
|
||||
while (teg > 360.0) teg -= 360.0
|
||||
// LST = GMST + east longitude (positive east convention)
|
||||
val th = mod2PI((teg + observer.longitude) * DEG2RAD)
|
||||
val h = th - ra
|
||||
val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI
|
||||
val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h))
|
||||
|
||||
// Moon radial velocity approximation (from "Amateur Radio Software", GM4ANB, RSGB 1985)
|
||||
val mm = fixAngle(1.319238 + daynum * 0.228027135)
|
||||
val radT2 = 0.10976
|
||||
val radT1 = mm + radT2 * sin(mm)
|
||||
var dv = 0.01255 * moonDx * moonDx * sin(radT1) * (1.0 + radT2 * cos(mm))
|
||||
dv *= 4449.0
|
||||
val earthR = 6378.0
|
||||
val moonDist = 384401.0
|
||||
val radT3 = earthR * moonDist * (cos(dec) * cos(n) * sin(h)) /
|
||||
sqrt(moonDist * moonDist - moonDist * earthR * sin(el))
|
||||
val moonDv = dv + radT3 * 0.0753125
|
||||
|
||||
val moonRa = ra / DEG2RAD
|
||||
var moonGha = teg - moonRa
|
||||
if (moonGha < 0.0) moonGha += 360.0
|
||||
|
||||
return MoonPosition(
|
||||
azimuth = azVal / DEG2RAD,
|
||||
elevation = el / DEG2RAD,
|
||||
rightAscension = moonRa,
|
||||
declination = dec / DEG2RAD,
|
||||
gha = moonGha,
|
||||
angularDiameter = moonDx,
|
||||
radialVelocity = moonDv
|
||||
)
|
||||
}
|
||||
|
||||
// ── Satellite visibility ──
|
||||
|
||||
/**
|
||||
* Classify satellite visibility given its eclipse state and the Sun's elevation
|
||||
* at the observer's location.
|
||||
*
|
||||
* @param isEclipsed whether the satellite is in Earth's shadow
|
||||
* @param sunElevationDeg Sun elevation at observer in degrees
|
||||
* @param satElevationDeg satellite elevation at observer in degrees (must be >= 0)
|
||||
*/
|
||||
fun classifyVisibility(
|
||||
isEclipsed: Boolean,
|
||||
sunElevationDeg: Double,
|
||||
satElevationDeg: Double
|
||||
): SatVisibility {
|
||||
if (isEclipsed) return SatVisibility.ECLIPSED
|
||||
return if (sunElevationDeg <= -12.0 && satElevationDeg >= 0.0) SatVisibility.VISIBLE
|
||||
else SatVisibility.DAYLIGHT
|
||||
}
|
||||
|
||||
// ── Orbital metadata ──
|
||||
|
||||
/**
|
||||
* Compute orbital metadata for a satellite at its current position.
|
||||
*
|
||||
* @param altitudeKm satellite altitude in km
|
||||
* @param meanMotion revolutions per day from TLE
|
||||
* @param bstar drag term from TLE
|
||||
* @param meanAnomaly mean anomaly at epoch (radians)
|
||||
* @param revNumAtEpoch revolution number at TLE epoch
|
||||
* @param ageDays days since TLE epoch (julUTC - julEpoch)
|
||||
* @param phase orbital phase in radians (from SGP4/SDP4 output)
|
||||
* @param satPosECI satellite ECI position [x, y, z]
|
||||
* @param satVelECI satellite ECI velocity [vx, vy, vz]
|
||||
* @param sunPosECI sun ECI position [x, y, z]
|
||||
*/
|
||||
fun computeOrbitalMetadata(
|
||||
altitudeKm: Double,
|
||||
meanMotion: Double,
|
||||
bstar: Double,
|
||||
meanAnomaly: Double,
|
||||
revNumAtEpoch: Int,
|
||||
ageDays: Double,
|
||||
phase: Double,
|
||||
satPosECI: DoubleArray,
|
||||
satVelECI: DoubleArray,
|
||||
sunPosECI: DoubleArray
|
||||
): OrbitalMetadata {
|
||||
// Footprint diameter (km)
|
||||
val footprint = 12756.33 * acos(EARTH_RADIUS / (EARTH_RADIUS + altitudeKm))
|
||||
|
||||
// Orbit number
|
||||
val xmnpda = 1.44E3
|
||||
val orbitNum = floor(
|
||||
(meanMotion * xmnpda / TWO_PI + ageDays * bstar) * ageDays + meanAnomaly / TWO_PI
|
||||
).toLong() + revNumAtEpoch
|
||||
|
||||
// Beta angle: angle between orbital plane and Sun direction
|
||||
// Orbital plane normal = cross(pos, vel)
|
||||
val nx = satPosECI[1] * satVelECI[2] - satPosECI[2] * satVelECI[1]
|
||||
val ny = satPosECI[2] * satVelECI[0] - satPosECI[0] * satVelECI[2]
|
||||
val nz = satPosECI[0] * satVelECI[1] - satPosECI[1] * satVelECI[0]
|
||||
val nMag = sqrt(nx * nx + ny * ny + nz * nz)
|
||||
val sMag = sqrt(sunPosECI[0] * sunPosECI[0] + sunPosECI[1] * sunPosECI[1] + sunPosECI[2] * sunPosECI[2])
|
||||
val dotNS = nx * sunPosECI[0] + ny * sunPosECI[1] + nz * sunPosECI[2]
|
||||
val betaAngle = if (nMag > 0 && sMag > 0) {
|
||||
(PI / 2.0 - acos(dotNS / (nMag * sMag))).toDegrees()
|
||||
} else 0.0
|
||||
|
||||
// Phase (0-256 scale, matching PREDICT convention)
|
||||
val orbitalPhase = 256.0 * (phase / TWO_PI)
|
||||
|
||||
return OrbitalMetadata(footprint, orbitNum, betaAngle, orbitalPhase)
|
||||
}
|
||||
|
||||
// ── Satellite status checks ──
|
||||
|
||||
/** Check if a satellite is geostationary (mean motion ≈ 1.0027 rev/day). */
|
||||
fun isGeostationary(meanMotion: Double): Boolean = abs(meanMotion - 1.0027) < 0.0002
|
||||
|
||||
/**
|
||||
* Check if a satellite has likely decayed based on drag and time since epoch.
|
||||
*
|
||||
* @param meanMotion revolutions per day
|
||||
* @param drag first derivative of mean motion / 2 (from TLE line 1)
|
||||
* @param epochDaynum TLE epoch as daynum (days since 31Dec79)
|
||||
* @param currentDaynum current time as daynum
|
||||
*/
|
||||
fun hasDecayed(meanMotion: Double, drag: Double, epochDaynum: Double, currentDaynum: Double): Boolean {
|
||||
return epochDaynum + ((16.666666 - meanMotion) / (10.0 * abs(drag))) < currentDaynum
|
||||
}
|
||||
|
||||
// ── Rise/Set finding ──
|
||||
|
||||
/** Rise and set times for a celestial body. */
|
||||
data class RiseSetTimes(
|
||||
val riseTimeMillis: Long, // 0 if not found
|
||||
val setTimeMillis: Long // 0 if not found
|
||||
)
|
||||
|
||||
/**
|
||||
* Find the next sunrise and sunset times from [startMillis] for [observer].
|
||||
* Uses elevation threshold of -0.8333° to match the standard civil definition:
|
||||
* upper limb on geometric horizon with standard atmospheric refraction (~0.57°)
|
||||
* and solar semidiameter (~0.27°) corrections applied, matching USNO/timeanddate.com.
|
||||
*/
|
||||
fun findSunRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
|
||||
// Standard civil threshold: center elevation when upper limb meets geometric horizon
|
||||
// -0.8333° = standard refraction (~0.5667°) + solar semidiameter (~0.2667°)
|
||||
val threshold = 0.8333
|
||||
var daynum = millisToDaynum(startMillis)
|
||||
var sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
|
||||
// Phase 1: if sun is above threshold, fast-forward to well past sunset into night
|
||||
if (sunPos.elevation > -threshold) {
|
||||
var guard = 0
|
||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||
daynum += 0.008 // fixed ~11.5 min steps past the setting sun
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
// Now advance until sun is clearly below minimum (deep night)
|
||||
guard = 0
|
||||
while (sunPos.elevation > -12.0 && guard++ < 500) {
|
||||
daynum += 0.02
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: advance until sun starts rising toward threshold (elevation increasing)
|
||||
var guard = 0
|
||||
while (sunPos.elevation < -threshold && guard++ < 500) {
|
||||
daynum += 0.008
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
|
||||
// Phase 3: converge symmetrically on elevation = -threshold (sunrise)
|
||||
var sunrise = 0.0
|
||||
guard = 0
|
||||
while (sunrise == 0.0 && guard++ < 200) {
|
||||
val delta = sunPos.elevation + threshold
|
||||
if (abs(delta) < 0.01) {
|
||||
sunrise = daynum
|
||||
} else {
|
||||
daynum -= 0.004 * delta
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
}
|
||||
if (sunrise == 0.0) sunrise = daynum
|
||||
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
||||
daynum = sunrise
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
guard = 0
|
||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||
daynum += 0.008
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
|
||||
// Phase 5: converge symmetrically on elevation = -threshold (sunset)
|
||||
var sunset = 0.0
|
||||
guard = 0
|
||||
while (sunset == 0.0 && guard++ < 200) {
|
||||
val delta = sunPos.elevation + threshold
|
||||
if (abs(delta) < 0.01) {
|
||||
sunset = daynum
|
||||
} else {
|
||||
daynum += 0.004 * delta
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
}
|
||||
if (sunset == 0.0) sunset = daynum
|
||||
|
||||
return RiseSetTimes(daynumToMillis(sunrise), daynumToMillis(sunset))
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next moonrise and moonset times from [startMillis] for [observer].
|
||||
* Uses the adaptive iteration from PREDICT v2.2.5's PredictMoon().
|
||||
*/
|
||||
fun findMoonRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
|
||||
var daynum = millisToDaynum(startMillis)
|
||||
var moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||
|
||||
// If moon is already up, move forward until it sets
|
||||
var guard = 0
|
||||
if (moonPos.elevation > 0) {
|
||||
while (moonPos.elevation > 0 && guard++ < 1000) {
|
||||
daynum += 0.004 * sin(DEG2RAD * (moonPos.elevation + 0.5))
|
||||
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
daynum += 0.4
|
||||
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
// Find moonrise
|
||||
var moonrise = 0.0
|
||||
guard = 0
|
||||
while (moonrise == 0.0 && guard++ < 1000) {
|
||||
if (abs(moonPos.elevation) < 0.03) {
|
||||
moonrise = daynum
|
||||
} else {
|
||||
daynum -= 0.004 * moonPos.elevation
|
||||
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
}
|
||||
if (moonrise == 0.0) moonrise = daynum
|
||||
|
||||
// Find moonset from moonrise
|
||||
daynum = moonrise
|
||||
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||
guard = 0
|
||||
while (moonPos.elevation > -1 && guard++ < 1000) {
|
||||
daynum += 0.04 * cos(DEG2RAD * (moonPos.elevation + 0.5))
|
||||
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
var moonset = 0.0
|
||||
guard = 0
|
||||
while (moonset == 0.0 && guard++ < 1000) {
|
||||
if (abs(moonPos.elevation) < 0.03) {
|
||||
moonset = daynum
|
||||
} else {
|
||||
daynum += 0.004 * moonPos.elevation
|
||||
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
}
|
||||
if (moonset == 0.0) moonset = daynum
|
||||
|
||||
return RiseSetTimes(daynumToMillis(moonrise), daynumToMillis(moonset))
|
||||
}
|
||||
|
||||
// ── Visual magnitude estimation ──
|
||||
|
||||
/**
|
||||
* Estimate the apparent visual magnitude of a satellite.
|
||||
*
|
||||
* Uses the standard formula from McCants/Heavens-Above:
|
||||
* apparentMag = stdMag + 5 * log10(range / 1000) - 15 * log10(cos(phaseAngle / 2))
|
||||
*
|
||||
* @param rangeKm slant range from observer to satellite in km
|
||||
* @param phaseAngleDeg Sun-satellite-observer angle in degrees
|
||||
* @param stdMag intrinsic/standard magnitude (default 4.0)
|
||||
* @return estimated apparent visual magnitude
|
||||
*/
|
||||
fun estimateVisualMagnitude(rangeKm: Double, phaseAngleDeg: Double, stdMag: Double = 4.0): Double {
|
||||
if (rangeKm <= 0) return stdMag
|
||||
val halfPhaseRad = phaseAngleDeg.toRadians() / 2.0
|
||||
val cosHalfPhase = cos(halfPhaseRad)
|
||||
val phaseTerm = if (cosHalfPhase > 1e-6) -15.0 * log10(cosHalfPhase) else 99.0
|
||||
return stdMag + 5.0 * log10(rangeKm / 1000.0) + phaseTerm
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the phase angle (Sun-satellite-observer) in degrees.
|
||||
*
|
||||
* @param satPosECI satellite ECI position [x, y, z] in km
|
||||
* @param sunPosECI sun ECI position [x, y, z] in km
|
||||
* @param obsPosECI observer ECI position [x, y, z] in km
|
||||
* @return phase angle in degrees (0 = fully illuminated face toward observer)
|
||||
*/
|
||||
fun computePhaseAngle(satPosECI: DoubleArray, sunPosECI: DoubleArray, obsPosECI: DoubleArray): Double {
|
||||
val toSunX = sunPosECI[0] - satPosECI[0]
|
||||
val toSunY = sunPosECI[1] - satPosECI[1]
|
||||
val toSunZ = sunPosECI[2] - satPosECI[2]
|
||||
val toObsX = obsPosECI[0] - satPosECI[0]
|
||||
val toObsY = obsPosECI[1] - satPosECI[1]
|
||||
val toObsZ = obsPosECI[2] - satPosECI[2]
|
||||
val dot = toSunX * toObsX + toSunY * toObsY + toSunZ * toObsZ
|
||||
val magSun = sqrt(toSunX * toSunX + toSunY * toSunY + toSunZ * toSunZ)
|
||||
val magObs = sqrt(toObsX * toObsX + toObsY * toObsY + toObsZ * toObsZ)
|
||||
if (magSun == 0.0 || magObs == 0.0) return 90.0
|
||||
val cosAngle = (dot / (magSun * magObs)).coerceIn(-1.0, 1.0)
|
||||
return acos(cosAngle).toDegrees()
|
||||
}
|
||||
|
||||
// ── Doppler ──
|
||||
|
||||
/**
|
||||
* Compute Doppler shift for a given base frequency and range rate.
|
||||
*
|
||||
* @param frequencyHz base frequency in Hz
|
||||
* @param rangeRateKmS range rate in km/s (negative = approaching)
|
||||
* @return shifted frequency in Hz
|
||||
*/
|
||||
fun dopplerShift(frequencyHz: Double, rangeRateKmS: Double): Double {
|
||||
return frequencyHz * (299792.458 - rangeRateKmS) / 299792.458
|
||||
}
|
||||
|
||||
// ── Internal helpers ──
|
||||
|
||||
private fun observerGeodetic(pos: GeoPos): DoubleArray {
|
||||
// [lat_rad, lon_rad, alt_km] — longitude positive east, matching OrbitalObject convention.
|
||||
// LST = thetaGJD(julUtc) + obsGeo[1] = GMST + lon_rad (correct).
|
||||
return doubleArrayOf(pos.latitude * DEG2RAD, pos.longitude * DEG2RAD, pos.altitude / 1000.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert az/el observation to Right Ascension / Declination.
|
||||
* Returns [ra_rad, dec_rad].
|
||||
* Based on Calculate_RADec() from PREDICT v2.2.5 (Escobal method).
|
||||
*/
|
||||
private fun calculateRADec(
|
||||
julUtc: Double,
|
||||
targetPos: DoubleArray,
|
||||
targetVel: DoubleArray,
|
||||
obsGeo: DoubleArray
|
||||
): DoubleArray {
|
||||
val obsSet = computeObsAngles(julUtc, targetPos, targetVel, obsGeo)
|
||||
val az = obsSet[0]
|
||||
val el = obsSet[1]
|
||||
val phi = obsGeo[0]
|
||||
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
|
||||
val sinTheta = sin(theta)
|
||||
val cosTheta = cos(theta)
|
||||
val sinPhi = sin(phi)
|
||||
val cosPhi = cos(phi)
|
||||
val lxh = -cos(az) * cos(el)
|
||||
val lyh = sin(az) * cos(el)
|
||||
val lzh = sin(el)
|
||||
val sx = sinPhi * cosTheta
|
||||
val ex2 = -sinTheta
|
||||
val zx = cosTheta * cosPhi
|
||||
val sy = sinPhi * sinTheta
|
||||
val zy = sinTheta * cosPhi
|
||||
val sz = -cosPhi
|
||||
val lx = sx * lxh + ex2 * lyh + zx * lzh
|
||||
val ly = sy * lxh + cosTheta * lyh + zy * lzh
|
||||
val lz = sz * lxh + 0.0 * lyh + sinPhi * lzh
|
||||
val dec = asin(lz)
|
||||
val cosDelta = sqrt(1.0 - lz * lz)
|
||||
val sinAlpha = ly / cosDelta
|
||||
val cosAlpha = lx / cosDelta
|
||||
val ra = mod2PI(atan2(sinAlpha, cosAlpha))
|
||||
return doubleArrayOf(ra, dec)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute observer look-angles (az, el, range, rangeRate) to a target at ECI position.
|
||||
* Returns [azimuth_rad, elevation_rad, range_km, rangeRate_km/s].
|
||||
* Azimuth is north-referenced (0=N, π/2=E), matching OrbitalObject's convention.
|
||||
*/
|
||||
private fun computeObsAngles(
|
||||
julUtc: Double,
|
||||
targetPos: DoubleArray,
|
||||
targetVel: DoubleArray,
|
||||
obsGeo: DoubleArray // [lat_rad, lon_rad, alt_km]
|
||||
): DoubleArray {
|
||||
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
|
||||
val c = 1.0 / sqrt(1 + FLAT_FACT * (FLAT_FACT - 2) * sin(obsGeo[0]).pow(2))
|
||||
val sq = (1 - FLAT_FACT).pow(2) * c
|
||||
val achcp = (EARTH_RADIUS * c + obsGeo[2]) * cos(obsGeo[0])
|
||||
val ox = achcp * cos(theta)
|
||||
val oy = achcp * sin(theta)
|
||||
val oz = (EARTH_RADIUS * sq + obsGeo[2]) * sin(obsGeo[0])
|
||||
val ovx = -MFACTOR * oy
|
||||
val ovy = MFACTOR * ox
|
||||
|
||||
val rx = targetPos[0] - ox
|
||||
val ry = targetPos[1] - oy
|
||||
val rz = targetPos[2] - oz
|
||||
val rMag = sqrt(rx * rx + ry * ry + rz * rz)
|
||||
val rvx = targetVel[0] - ovx
|
||||
val rvy = targetVel[1] - ovy
|
||||
val rvz = targetVel[2]
|
||||
|
||||
val sinLat = sin(obsGeo[0])
|
||||
val cosLat = cos(obsGeo[0])
|
||||
val sinTheta = sin(theta)
|
||||
val cosTheta = cos(theta)
|
||||
val topS = sinLat * cosTheta * rx + sinLat * sinTheta * ry - cosLat * rz
|
||||
val topE = -sinTheta * rx + cosTheta * ry
|
||||
val topZ = cosLat * cosTheta * rx + cosLat * sinTheta * ry + sinLat * rz
|
||||
|
||||
// Match north-based convention (0=N, 90=E) used by OrbitalObject.calculateObs
|
||||
// Must use atan(-topE / topS) not atan2(-topE, topS) — they differ in quadrant handling
|
||||
var azim = atan(-topE / topS)
|
||||
if (topS > 0.0) azim += PI
|
||||
if (azim < 0.0) azim += TWO_PI
|
||||
val el = asin(topZ / rMag)
|
||||
val rangeRate = (rx * rvx + ry * rvy + rz * rvz) / rMag
|
||||
|
||||
return doubleArrayOf(azim, el, rMag, rangeRate)
|
||||
}
|
||||
|
||||
private const val MFACTOR = 7.292115E-5
|
||||
|
||||
private fun primeAngle(x: Double) = x - 360.0 * floor(x / 360.0)
|
||||
|
||||
private fun fixAngle(x: Double): Double {
|
||||
var a = x; while (a > TWO_PI) a -= TWO_PI; return a
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const val ASTRONOMICAL_UNIT = 1.49597870691E8
|
||||
const val DEG2RAD = 0.017453292519943295
|
||||
const val RAD2DEG = 57.29577951308232
|
||||
const val EARTH_RADIUS = 6378.137
|
||||
const val EARTH_ROT_PER_SID_DAY = 1.00273790934
|
||||
const val EPSILON = 1.0E-12
|
||||
const val FLAT_FACT = 3.35281066474748E-3
|
||||
const val J3_HARMONIC = -2.53881E-6
|
||||
|
||||
+32
-34
@@ -97,40 +97,38 @@ class DeepSpaceObject(data: OrbitalData) : OrbitalObject(data) {
|
||||
}
|
||||
|
||||
internal fun calculateSDP4(tSince: Double) {
|
||||
synchronized(this) {
|
||||
val temp = DoubleArray(12)
|
||||
val xmdf = data.xmo + dsv.xmdot * tSince
|
||||
val tsq = tSince * tSince
|
||||
val templ = t2cof * tsq
|
||||
dsv.xll = xmdf + dsv.xnodp * templ
|
||||
dsv.omgadf = data.omegao + dsv.omgdot * tSince
|
||||
val xnoddf = data.xnodeo + dsv.xnodot * tSince
|
||||
dsv.xnode = xnoddf + xnodcf * tsq
|
||||
val tempa = 1.0 - c1 * tSince
|
||||
val tempe = data.bstar * c4 * tSince
|
||||
dsv.xn = dsv.xnodp
|
||||
dsv.t = tSince
|
||||
deep.dpsec(data)
|
||||
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
|
||||
dsv.em -= tempe
|
||||
deep.dpper()
|
||||
val xl = dsv.xll + dsv.omgadf + dsv.xnode
|
||||
val beta = sqrt(1.0 - dsv.em * dsv.em)
|
||||
dsv.xn = XKE / a.pow(1.5)
|
||||
// Long period periodics
|
||||
val axn = dsv.em * cos(dsv.omgadf)
|
||||
temp[0] = invert(a * beta * beta)
|
||||
val xll = temp[0] * xlcof * axn
|
||||
val aynl = temp[0] * aycof
|
||||
val xlt = xl + xll
|
||||
val ayn = dsv.em * sin(dsv.omgadf) + aynl
|
||||
// Solve Kepler's equation
|
||||
val capu = mod2PI(xlt - dsv.xnode)
|
||||
temp[2] = capu
|
||||
converge(temp, axn, ayn, capu)
|
||||
calculatePosAndVel(temp, a, axn, ayn)
|
||||
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
|
||||
}
|
||||
val temp = DoubleArray(12)
|
||||
val xmdf = data.xmo + dsv.xmdot * tSince
|
||||
val tsq = tSince * tSince
|
||||
val templ = t2cof * tsq
|
||||
dsv.xll = xmdf + dsv.xnodp * templ
|
||||
dsv.omgadf = data.omegao + dsv.omgdot * tSince
|
||||
val xnoddf = data.xnodeo + dsv.xnodot * tSince
|
||||
dsv.xnode = xnoddf + xnodcf * tsq
|
||||
val tempa = 1.0 - c1 * tSince
|
||||
val tempe = data.bstar * c4 * tSince
|
||||
dsv.xn = dsv.xnodp
|
||||
dsv.t = tSince
|
||||
deep.dpsec(data)
|
||||
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
|
||||
dsv.em -= tempe
|
||||
deep.dpper()
|
||||
val xl = dsv.xll + dsv.omgadf + dsv.xnode
|
||||
val beta = sqrt(1.0 - dsv.em * dsv.em)
|
||||
dsv.xn = XKE / a.pow(1.5)
|
||||
// Long period periodics
|
||||
val axn = dsv.em * cos(dsv.omgadf)
|
||||
temp[0] = invert(a * beta * beta)
|
||||
val xll = temp[0] * xlcof * axn
|
||||
val aynl = temp[0] * aycof
|
||||
val xlt = xl + xll
|
||||
val ayn = dsv.em * sin(dsv.omgadf) + aynl
|
||||
// Solve Kepler's equation
|
||||
val capu = mod2PI(xlt - dsv.xnode)
|
||||
temp[2] = capu
|
||||
converge(temp, axn, ayn, capu)
|
||||
calculatePosAndVel(temp, a, axn, ayn)
|
||||
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
|
||||
}
|
||||
|
||||
private fun calculatePosAndVel(temp: DoubleArray, a: Double, axn: Double, ayn: Double) {
|
||||
|
||||
+44
-46
@@ -141,53 +141,51 @@ class NearEarthObject(data: OrbitalData) : OrbitalObject(data) {
|
||||
}
|
||||
|
||||
internal fun calculateSGP4(tSince: Double) {
|
||||
synchronized(this) {
|
||||
val temp = DoubleArray(9)
|
||||
val xmdf = data.xmo + xmdot * tSince
|
||||
val omgadf = data.omegao + omgdot * tSince
|
||||
val xnoddf = data.xnodeo + xnodot * tSince
|
||||
var omega = omgadf
|
||||
var xmp = xmdf
|
||||
val tsq = sqr(tSince)
|
||||
val xnode = xnoddf + xnodcf * tsq
|
||||
val bstar = data.bstar
|
||||
var tempa = 1.0 - c1 * tSince
|
||||
var tempe = bstar * c4 * tSince
|
||||
var templ = t2cof * tsq
|
||||
if (!sgp4Simple) {
|
||||
val delomg = omgcof * tSince
|
||||
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
|
||||
temp[0] = delomg + delm
|
||||
xmp = xmdf + temp[0]
|
||||
omega = omgadf - temp[0]
|
||||
val tcube = tsq * tSince
|
||||
val tfour = tSince * tcube
|
||||
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
|
||||
tempe += bstar * c5 * (sin(xmp) - sinmo)
|
||||
templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
|
||||
}
|
||||
val a = aodp * tempa.pow(2.0)
|
||||
val eo = data.eccn
|
||||
val e = eo - tempe
|
||||
val xl = xmp + omega + xnode + xnodp * templ
|
||||
val beta = sqrt(1.0 - e * e)
|
||||
val xn = XKE / a.pow(1.5)
|
||||
|
||||
// Long period periodics
|
||||
val axn = e * cos(omega)
|
||||
temp[0] = invert(a * sqr(beta))
|
||||
val xll = temp[0] * xlcof * axn
|
||||
val aynl = temp[0] * aycof
|
||||
val xlt = xl + xll
|
||||
val ayn = e * sin(omega) + aynl
|
||||
|
||||
// Solve Kepler's equation
|
||||
val capu = mod2PI(xlt - xnode)
|
||||
temp[2] = capu
|
||||
converge(temp, axn, ayn, capu)
|
||||
calculatePosAndVel(temp, xnode, a, xn, axn, ayn)
|
||||
calculatePhase(xlt, xnode, omgadf)
|
||||
val temp = DoubleArray(9)
|
||||
val xmdf = data.xmo + xmdot * tSince
|
||||
val omgadf = data.omegao + omgdot * tSince
|
||||
val xnoddf = data.xnodeo + xnodot * tSince
|
||||
var omega = omgadf
|
||||
var xmp = xmdf
|
||||
val tsq = tSince * tSince
|
||||
val xnode = xnoddf + xnodcf * tsq
|
||||
val bstar = data.bstar
|
||||
var tempa = 1.0 - c1 * tSince
|
||||
var tempe = bstar * c4 * tSince
|
||||
var templ = t2cof * tsq
|
||||
if (!sgp4Simple) {
|
||||
val delomg = omgcof * tSince
|
||||
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
|
||||
temp[0] = delomg + delm
|
||||
xmp = xmdf + temp[0]
|
||||
omega = omgadf - temp[0]
|
||||
val tcube = tsq * tSince
|
||||
val tfour = tSince * tcube
|
||||
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
|
||||
tempe += bstar * c5 * (sin(xmp) - sinmo)
|
||||
templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
|
||||
}
|
||||
val a = aodp * tempa * tempa
|
||||
val eo = data.eccn
|
||||
val e = eo - tempe
|
||||
val xl = xmp + omega + xnode + xnodp * templ
|
||||
val beta = sqrt(1.0 - e * e)
|
||||
val xn = XKE / a.pow(1.5)
|
||||
|
||||
// Long period periodics
|
||||
val axn = e * cos(omega)
|
||||
temp[0] = invert(a * sqr(beta))
|
||||
val xll = temp[0] * xlcof * axn
|
||||
val aynl = temp[0] * aycof
|
||||
val xlt = xl + xll
|
||||
val ayn = e * sin(omega) + aynl
|
||||
|
||||
// Solve Kepler's equation
|
||||
val capu = mod2PI(xlt - xnode)
|
||||
temp[2] = capu
|
||||
converge(temp, axn, ayn, capu)
|
||||
calculatePosAndVel(temp, xnode, a, xn, axn, ayn)
|
||||
calculatePhase(xlt, xnode, omgadf)
|
||||
}
|
||||
|
||||
private fun calculatePosAndVel(
|
||||
|
||||
@@ -27,7 +27,8 @@ data class OrbitalData(
|
||||
val argper: Double,
|
||||
val meanan: Double,
|
||||
val catnum: Int,
|
||||
val bstar: Double
|
||||
val bstar: Double,
|
||||
val ndot: Double = 0.0
|
||||
) {
|
||||
val xincl: Double = incl * DEG2RAD
|
||||
val xnodeo: Double = raan * DEG2RAD
|
||||
@@ -37,4 +38,29 @@ data class OrbitalData(
|
||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
||||
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
||||
|
||||
/** Check if satellite has likely decayed by the given time. */
|
||||
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||
if (ndot == 0.0) return false
|
||||
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
||||
val epochDaynum = epochToDaynum(epoch)
|
||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
||||
}
|
||||
|
||||
private fun epochToDaynum(epoch: Double): Double {
|
||||
var year = kotlin.math.floor(epoch * 1E-3)
|
||||
val day = (epoch * 1E-3 - year) * 1000.0
|
||||
year = if (year < 57) year + 2000 else year + 1900
|
||||
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||
val jan1Jd = julianDateOfYear(year)
|
||||
return jan1Jd + day - 2444238.5
|
||||
}
|
||||
|
||||
private fun julianDateOfYear(theYear: Double): Double {
|
||||
val aYear = theYear - 1
|
||||
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||
val b = 2 - a + a / 4
|
||||
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.predict
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// ── Shared orbital math utilities ──
|
||||
// Used by both CelestialComputer (sun/moon/celestial) and OrbitalObject (SGP4/SDP4).
|
||||
// Package-internal — not part of the public API.
|
||||
|
||||
/**
|
||||
* Greenwich Mean Sidereal Time from Julian Date, in radians [0, 2π).
|
||||
* Identical algorithm used in PREDICT v2.2.5 for both solar and satellite calculations.
|
||||
*/
|
||||
internal fun thetaGJD(jd: Double): Double {
|
||||
val ut = fraction(jd + 0.5)
|
||||
val aJD = jd - ut
|
||||
val tu = (aJD - 2451545.0) / 36525.0
|
||||
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
|
||||
gmst = modulus(gmst + SEC_PER_DAY * EARTH_ROT_PER_SID_DAY * ut, SEC_PER_DAY)
|
||||
return TWO_PI * gmst / SEC_PER_DAY
|
||||
}
|
||||
|
||||
/** Fractional part of [arg]. */
|
||||
internal fun fraction(arg: Double): Double = arg - floor(arg)
|
||||
|
||||
/** Modulo: returns [arg1] mod [arg2], result always in [0, arg2). */
|
||||
internal fun modulus(arg1: Double, arg2: Double): Double {
|
||||
var r = arg1
|
||||
val i = floor(r / arg2).toInt()
|
||||
r -= i * arg2
|
||||
if (r < 0.0) r += arg2
|
||||
return r
|
||||
}
|
||||
|
||||
/** Reduce [value] to [0, 2π). */
|
||||
internal fun mod2PI(value: Double): Double {
|
||||
var r = value
|
||||
val i = (r / TWO_PI).toInt()
|
||||
r -= i * TWO_PI
|
||||
if (r < 0.0) r += TWO_PI
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta-ET: difference between Universal Time and Ephemeris Time (seconds).
|
||||
* Based on least-squares fit from 1950 to 1991 (PREDICT v2.2.5).
|
||||
*/
|
||||
internal fun deltaET(year: Double): Double =
|
||||
26.465 + 0.747622 * (year - 1950) + 1.886913 * sin(TWO_PI * (year - 1975) / 33)
|
||||
|
||||
/**
|
||||
* Convert Unix epoch milliseconds to daynum (days since 31 Dec 1979 00:00:00 UTC).
|
||||
*/
|
||||
internal fun millisToDaynum(timeMillis: Long): Double =
|
||||
(timeMillis - 315446400000L) / 86400000.0
|
||||
|
||||
/** Convert daynum back to Unix epoch milliseconds. */
|
||||
internal fun daynumToMillis(daynum: Double): Long =
|
||||
((daynum + 3651.0) * 86400000.0).toLong()
|
||||
|
||||
/**
|
||||
* Compute the Sun's ECI position vector at [julUtc] (Julian UTC).
|
||||
* Returns [x, y, z, magnitude] in km.
|
||||
* Based on Calculate_Solar_Position() / FindSun() from PREDICT v2.2.5.
|
||||
*/
|
||||
internal fun solarPositionECI(julUtc: Double): DoubleArray {
|
||||
val mjd = julUtc - 2415020.0
|
||||
val year = 1900 + mjd / 365.25
|
||||
val t = (mjd + deltaET(year) / SEC_PER_DAY) / 36525.0
|
||||
val mDeg = mod360(358.47583 + mod360(35999.04975 * t) - (0.000150 + 0.0000033 * t) * t * t)
|
||||
val m = mDeg * DEG2RAD
|
||||
val lDeg = mod360(279.69668 + mod360(36000.76892 * t) + 0.0003025 * t * t)
|
||||
val l = lDeg * DEG2RAD
|
||||
val e = 0.01675104 - (0.0000418 + 0.000000126 * t) * t
|
||||
val cDeg = (1.919460 - (0.004789 + 0.000014 * t) * t) * sin(m) +
|
||||
(0.020094 - 0.000100 * t) * sin(2 * m) + 0.000293 * sin(3 * m)
|
||||
val c = cDeg * DEG2RAD
|
||||
val oDeg = mod360(259.18 - 1934.142 * t)
|
||||
val o = oDeg * DEG2RAD
|
||||
val lsa = mod2PI(l + c - (0.00569 - 0.00479 * sin(o)) * DEG2RAD)
|
||||
val nu = mod2PI(m + c)
|
||||
var r = 1.0000002 * (1.0 - e * e) / (1.0 + e * cos(nu))
|
||||
val epsDeg = 23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * t) * t) * t + 0.00256 * cos(o)
|
||||
val eps = epsDeg * DEG2RAD
|
||||
r *= ASTRONOMICAL_UNIT
|
||||
return doubleArrayOf(r * cos(lsa), r * sin(lsa) * cos(eps), r * sin(lsa) * sin(eps), r)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ECI position [eciPos] = [x, y, z] (km) to geodetic [lat_rad, lon_rad, alt_km].
|
||||
* Based on Calculate_LatLonAlt() from PREDICT v2.2.5.
|
||||
*/
|
||||
internal fun eciToGeodetic(julUtc: Double, eciPos: DoubleArray): DoubleArray {
|
||||
val thetaPos = atan2(eciPos[1], eciPos[0])
|
||||
val lon = mod2PI(thetaPos - thetaGJD(julUtc))
|
||||
val r = sqrt(eciPos[0] * eciPos[0] + eciPos[1] * eciPos[1])
|
||||
val e2 = FLAT_FACT * (2.0 - FLAT_FACT)
|
||||
var lat = atan2(eciPos[2], r)
|
||||
var phi: Double
|
||||
var c: Double
|
||||
var i = 0
|
||||
do {
|
||||
phi = lat
|
||||
c = 1.0 / sqrt(1.0 - e2 * sin(phi) * sin(phi))
|
||||
lat = atan2(eciPos[2] + EARTH_RADIUS * c * e2 * sin(phi), r)
|
||||
} while (i++ < 10 && abs(lat - phi) >= 1E-10)
|
||||
val alt = r / cos(lat) - EARTH_RADIUS * c
|
||||
if (lat > PI_2) lat -= TWO_PI
|
||||
return doubleArrayOf(lat, lon, alt)
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private fun mod360(x: Double): Double {
|
||||
var r = x
|
||||
val i = (r / 360.0).toInt()
|
||||
r -= i * 360.0
|
||||
if (r < 0.0) r += 360.0
|
||||
return r
|
||||
}
|
||||
+91
-66
@@ -42,6 +42,27 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
||||
var qoms24 = 0.0
|
||||
var s4 = 0.0
|
||||
|
||||
// Pre-allocated reusable vectors to avoid GC pressure in hot loops
|
||||
private val obsPos = Vector4()
|
||||
private val obsVel = Vector4()
|
||||
private val rangeVector = Vector4()
|
||||
private val rgvelVector = Vector4()
|
||||
private val squintVector = Vector4()
|
||||
|
||||
// Cached observer position data to avoid recalculation when observer hasn't moved
|
||||
private var cachedGsLat = Double.NaN
|
||||
private var cachedGsLon = Double.NaN
|
||||
private var cachedGsAlt = Double.NaN
|
||||
private var cachedSinLat = 0.0
|
||||
private var cachedCosLat = 0.0
|
||||
private var cachedObsC = 0.0
|
||||
private var cachedObsSq = 0.0
|
||||
private var cachedObsAchFactor = 0.0
|
||||
private var cachedObsZFactor = 0.0
|
||||
|
||||
// Cache for julian epoch to avoid recomputing every call
|
||||
private val julEpoch: Double = juliandDateOfEpoch(data.epoch)
|
||||
|
||||
fun willBeSeen(pos: GeoPos): Boolean {
|
||||
return if (data.meanmo < 1e-8) false
|
||||
else {
|
||||
@@ -57,15 +78,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
||||
orbitalPos = OrbitalPos()
|
||||
// Date/time at which the position and velocity were calculated
|
||||
julUTC = calcCurrentDaynum(time) + 2444238.5
|
||||
// Convert satellite's epoch time to Julian and calculate time since epoch in minutes
|
||||
val julEpoch = juliandDateOfEpoch(data.epoch)
|
||||
// Calculate time since epoch in minutes
|
||||
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
|
||||
calculateSDP4orSGP4(tsince)
|
||||
// Scale position and velocity vectors to km and km/sec
|
||||
convertSatState(position, velocity)
|
||||
// Calculate velocity of satellite
|
||||
magnitude(velocity)
|
||||
val squintVector = Vector4()
|
||||
// Angles in rads, dist in km, vel in km/S. Calculate sat Az, El, Range and Range-rate.
|
||||
calculateObs(julUTC, position, velocity, pos, squintVector)
|
||||
calculateLatLonAlt(julUTC)
|
||||
@@ -75,6 +94,38 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
||||
return orbitalPos
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight elevation-only check for pass finding. Avoids full lat/lon/alt,
|
||||
* eclipse, and squint calculations that aren't needed when just searching for
|
||||
* horizon crossings.
|
||||
*/
|
||||
fun getElevation(pos: GeoPos, time: Long): Double {
|
||||
julUTC = calcCurrentDaynum(time) + 2444238.5
|
||||
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
|
||||
calculateSDP4orSGP4(tsince)
|
||||
convertSatState(position, velocity)
|
||||
magnitude(velocity)
|
||||
calculateObs(julUTC, position, velocity, pos, squintVector)
|
||||
return orbitalPos.elevation
|
||||
}
|
||||
|
||||
/**
|
||||
* Full position calculation that also populates azimuth, altitude, etc.
|
||||
* Used when we need all fields (AOS/LOS refinement, track computation).
|
||||
*/
|
||||
fun getFullPosition(pos: GeoPos, time: Long): OrbitalPos {
|
||||
orbitalPos = OrbitalPos()
|
||||
julUTC = calcCurrentDaynum(time) + 2444238.5
|
||||
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
|
||||
calculateSDP4orSGP4(tsince)
|
||||
convertSatState(position, velocity)
|
||||
magnitude(velocity)
|
||||
calculateObs(julUTC, position, velocity, pos, squintVector)
|
||||
calculateLatLonAlt(julUTC)
|
||||
orbitalPos.time = time
|
||||
return orbitalPos
|
||||
}
|
||||
|
||||
private fun calcCurrentDaynum(now: Long): Double {
|
||||
val then = 315446400000 // time in millis on 31Dec79 00:00:00 UTC (daynum 0)
|
||||
return (now - then) / 1000.0 / 60.0 / 60.0 / 24.0
|
||||
@@ -117,38 +168,34 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
||||
gsPos: GeoPos,
|
||||
squintVector: Vector4
|
||||
) {
|
||||
val obsPos = Vector4()
|
||||
val obsVel = Vector4()
|
||||
val range = Vector4()
|
||||
val rgvel = Vector4()
|
||||
calculateUserPosVel(julianUTC, gsPos, obsPos, obsVel)
|
||||
range.setXYZ(
|
||||
rangeVector.setXYZ(
|
||||
positionVector.x - obsPos.x,
|
||||
positionVector.y - obsPos.y,
|
||||
positionVector.z - obsPos.z
|
||||
)
|
||||
// Save these values globally for calculating squint angles later
|
||||
squintVector.setXYZ(range.x, range.y, range.z)
|
||||
rgvel.setXYZ(
|
||||
squintVector.setXYZ(rangeVector.x, rangeVector.y, rangeVector.z)
|
||||
rgvelVector.setXYZ(
|
||||
velocityVector.x - obsVel.x,
|
||||
velocityVector.y - obsVel.y,
|
||||
velocityVector.z - obsVel.z
|
||||
)
|
||||
magnitude(range)
|
||||
val sinLat = sin(DEG2RAD * gsPos.latitude)
|
||||
val cosLat = cos(DEG2RAD * gsPos.latitude)
|
||||
magnitude(rangeVector)
|
||||
val sinLat = cachedSinLat
|
||||
val cosLat = cachedCosLat
|
||||
val sinTheta = sin(gsPosTheta)
|
||||
val cosTheta = cos(gsPosTheta)
|
||||
val topS = sinLat * cosTheta * range.x + sinLat * sinTheta * range.y - cosLat * range.z
|
||||
val topE = -sinTheta * range.x + cosTheta * range.y
|
||||
val topZ = cosLat * cosTheta * range.x + cosLat * sinTheta * range.y + sinLat * range.z
|
||||
val topS = sinLat * cosTheta * rangeVector.x + sinLat * sinTheta * rangeVector.y - cosLat * rangeVector.z
|
||||
val topE = -sinTheta * rangeVector.x + cosTheta * rangeVector.y
|
||||
val topZ = cosLat * cosTheta * rangeVector.x + cosLat * sinTheta * rangeVector.y + sinLat * rangeVector.z
|
||||
var azim = atan(-topE / topS)
|
||||
if (topS > 0.0) azim += PI
|
||||
if (azim < 0.0) azim += TWO_PI
|
||||
orbitalPos.azimuth = azim
|
||||
orbitalPos.elevation = asin(topZ / range.w)
|
||||
orbitalPos.distance = range.w
|
||||
orbitalPos.distanceRate = dot(range, rgvel) / range.w
|
||||
orbitalPos.elevation = asin(topZ / rangeVector.w)
|
||||
orbitalPos.distance = rangeVector.w
|
||||
orbitalPos.distanceRate = dot(rangeVector, rgvelVector) / rangeVector.w
|
||||
var elevation = orbitalPos.elevation / TWO_PI * 360.0
|
||||
if (elevation > 90) elevation = 180 - elevation
|
||||
orbitalPos.aboveHorizon = elevation - 0 > EPSILON
|
||||
@@ -163,12 +210,24 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
||||
) {
|
||||
val mFactor = 7.292115E-5
|
||||
gsPosTheta = mod2PI(thetaGJD(time) + DEG2RAD * gsPos.longitude)
|
||||
val c = invert(sqrt(1.0 + FLAT_FACT * (FLAT_FACT - 2) * sqr(sin(DEG2RAD * gsPos.latitude))))
|
||||
val sq = sqr(1.0 - FLAT_FACT) * c
|
||||
val achcp = (EARTH_RADIUS * c + gsPos.altitude / 1000.0) * cos(DEG2RAD * gsPos.latitude)
|
||||
|
||||
// Cache trig and position factors when observer position changes
|
||||
if (gsPos.latitude != cachedGsLat || gsPos.longitude != cachedGsLon || gsPos.altitude != cachedGsAlt) {
|
||||
cachedGsLat = gsPos.latitude
|
||||
cachedGsLon = gsPos.longitude
|
||||
cachedGsAlt = gsPos.altitude
|
||||
cachedSinLat = sin(DEG2RAD * gsPos.latitude)
|
||||
cachedCosLat = cos(DEG2RAD * gsPos.latitude)
|
||||
cachedObsC = invert(sqrt(1.0 + FLAT_FACT * (FLAT_FACT - 2) * sqr(cachedSinLat)))
|
||||
cachedObsSq = sqr(1.0 - FLAT_FACT) * cachedObsC
|
||||
cachedObsAchFactor = (EARTH_RADIUS * cachedObsC + gsPos.altitude / 1000.0) * cachedCosLat
|
||||
cachedObsZFactor = (EARTH_RADIUS * cachedObsSq + gsPos.altitude / 1000.0) * cachedSinLat
|
||||
}
|
||||
|
||||
obsPos.setXYZ(
|
||||
achcp * cos(gsPosTheta), achcp * sin(gsPosTheta),
|
||||
(EARTH_RADIUS * sq + gsPos.altitude / 1000.0) * sin(DEG2RAD * gsPos.latitude)
|
||||
cachedObsAchFactor * cos(gsPosTheta),
|
||||
cachedObsAchFactor * sin(gsPosTheta),
|
||||
cachedObsZFactor
|
||||
)
|
||||
obsVel.setXYZ(-mFactor * obsPos.y, mFactor * obsPos.x, 0.0)
|
||||
magnitude(obsPos)
|
||||
@@ -255,14 +314,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
||||
return 1.0 / value
|
||||
}
|
||||
|
||||
// Calculates the modulus of 2 * PI
|
||||
internal fun mod2PI(value: Double): Double {
|
||||
var retVal = value
|
||||
val i = (retVal / TWO_PI).toInt()
|
||||
retVal -= i * TWO_PI
|
||||
if (retVal < 0.0) retVal += TWO_PI
|
||||
return retVal
|
||||
}
|
||||
// Delegates to package-level mod2PI in OrbitalMath.kt
|
||||
internal fun mod2PI(value: Double): Double = com.rtbishop.look4sat.core.domain.predict.mod2PI(value)
|
||||
|
||||
// Solves Keplers' Equation
|
||||
internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) {
|
||||
@@ -364,19 +417,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
||||
return acos(dot(v1, v2) / (v1.w * v2.w))
|
||||
}
|
||||
|
||||
/**
|
||||
* The function Delta_ET has been added to allow calculations on the
|
||||
* position of the sun. It provides the difference between UT (approximately
|
||||
* the same as UTC) and ET (now referred to as TDT) This function is based
|
||||
* on a least squares fit of data from 1950 to 1991 and will need to be
|
||||
* updated periodically.
|
||||
*
|
||||
* Values determined using data from 1950-1991 in the 1990 Astronomical
|
||||
* Almanac. See DELTA_ET.WQ1 for details.
|
||||
*/
|
||||
private fun deltaEt(year: Double): Double {
|
||||
return 26.465 + 0.747622 * (year - 1950) + (1.886913 * sin(TWO_PI * (year - 1975) / 33))
|
||||
}
|
||||
// Delegates to package-level deltaET in OrbitalMath.kt
|
||||
private fun deltaEt(year: Double): Double = deltaET(year)
|
||||
|
||||
private fun radians(degrees: Double): Double {
|
||||
return degrees * DEG2RAD
|
||||
@@ -387,23 +429,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
||||
return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z
|
||||
}
|
||||
|
||||
// Returns fractional part of double argument
|
||||
private fun fraction(arg: Double): Double {
|
||||
return arg - floor(arg)
|
||||
}
|
||||
|
||||
// Calculates scalar magnitude of a vector4 argument
|
||||
private fun magnitude(v: Vector4) {
|
||||
v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z))
|
||||
}
|
||||
|
||||
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double {
|
||||
var returnValue = arg1
|
||||
val i = floor(returnValue / arg2).toInt()
|
||||
returnValue -= i * arg2
|
||||
if (returnValue < 0.0) returnValue += arg2
|
||||
return returnValue
|
||||
}
|
||||
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double =
|
||||
com.rtbishop.look4sat.core.domain.predict.modulus(arg1, arg2)
|
||||
|
||||
// Multiplies the vector v1 by the scalar k
|
||||
private fun scaleVector(k: Double, v: Vector4) {
|
||||
@@ -411,13 +443,6 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
||||
magnitude(v)
|
||||
}
|
||||
|
||||
private fun thetaGJD(theJD: Double): Double {
|
||||
val earthRotPerSidDay = 1.00273790934
|
||||
val ut = fraction(theJD + 0.5)
|
||||
val aJD = theJD - ut
|
||||
val tu = (aJD - 2451545.0) / 36525.0
|
||||
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
|
||||
gmst = modulus(gmst + SEC_PER_DAY * earthRotPerSidDay * ut)
|
||||
return TWO_PI * gmst / SEC_PER_DAY
|
||||
}
|
||||
// Delegates to package-level thetaGJD in OrbitalMath.kt
|
||||
private fun thetaGJD(theJD: Double): Double = com.rtbishop.look4sat.core.domain.predict.thetaGJD(theJD)
|
||||
}
|
||||
@@ -25,9 +25,10 @@ data class OrbitalPass(
|
||||
val altitude: Int = 1000,
|
||||
val maxElevation: Double = 75.0,
|
||||
val orbitalObject: OrbitalObject,
|
||||
var progress: Float = 0.0f
|
||||
val progress: Float = 0.0f,
|
||||
val hasDecayed: Boolean = false
|
||||
) {
|
||||
val catNum: Int = orbitalObject.data.catnum
|
||||
val name: String = orbitalObject.data.name
|
||||
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
|
||||
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import kotlin.math.acos
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@@ -50,23 +49,30 @@ data class OrbitalPos(
|
||||
}
|
||||
|
||||
fun getOrbitalVelocity(): Double {
|
||||
val earthG = 6.674 * 10.0.pow(-11)
|
||||
val earthM = 5.98 * 10.0.pow(24)
|
||||
val radius = 6.37 * 10.0.pow(6) + altitude * 10.0.pow(3)
|
||||
return sqrt(earthG * earthM / radius) / 1000
|
||||
val radius = EARTH_RADIUS_M + altitude * 1000.0
|
||||
return sqrt(GM_EARTH / radius) / 1000.0
|
||||
}
|
||||
|
||||
fun getRangeCircle(): List<GeoPos> {
|
||||
val rangeCirclePoints = mutableListOf<GeoPos>()
|
||||
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude)) // * EARTH_RADIUS = radiusKm
|
||||
val pointCount = 721
|
||||
val rangeCirclePoints = ArrayList<GeoPos>(pointCount)
|
||||
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude))
|
||||
val sinLat = sin(latitude)
|
||||
val cosLat = cos(latitude)
|
||||
val cosBeta = cos(beta)
|
||||
val sinBeta = sin(beta)
|
||||
for (azimuth in 0..720) {
|
||||
val rads = azimuth * DEG2RAD
|
||||
val lat = asin(sin(latitude) * cos(beta) + (cos(latitude) * sin(beta) * cos(rads)))
|
||||
val lon = (longitude + atan2(
|
||||
sin(rads) * sin(beta) * cos(latitude), cos(beta) - sin(latitude) * sin(lat)
|
||||
))
|
||||
val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cos(rads))
|
||||
val lon = longitude + atan2(sin(rads) * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
|
||||
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
|
||||
}
|
||||
return rangeCirclePoints
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Pre-computed constants for orbital velocity calculation
|
||||
private const val GM_EARTH = 3.986004418E14 // m^3/s^2
|
||||
private const val EARTH_RADIUS_M = 6.37E6 // meters
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
|
||||
/** AMSAT satellite status data source */
|
||||
interface IAmSatRepository {
|
||||
/** Fetch and parse the AMSAT status page; null on failure */
|
||||
suspend fun fetchStatus(): SatStatusPage?
|
||||
}
|
||||
+2
-2
@@ -18,8 +18,8 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
interface IDatabaseRepo {
|
||||
suspend fun updateTLEFromFile(uri: String)
|
||||
suspend fun updateTransceiversFromFile(uri: String)
|
||||
suspend fun updateTLEFromFile(uri: String): Int
|
||||
suspend fun updateTransceiversFromFile(uri: String): Int
|
||||
suspend fun updateFromRemote()
|
||||
suspend fun clearAllData()
|
||||
}
|
||||
+28
-2
@@ -1,6 +1,25 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@@ -10,11 +29,18 @@ interface IMainContainer {
|
||||
val selectionRepo: ISelectionRepo
|
||||
val satelliteRepo: ISatelliteRepo
|
||||
val databaseRepo: IDatabaseRepo
|
||||
val amSatRepo: IAmSatRepository
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
fun provideAddToCalendar(): IAddToCalendar
|
||||
fun provideShowToast(): IShowToast
|
||||
fun provideBluetoothReporter(): IReporterRepo<WithoutExtParams>
|
||||
fun provideNetworkReporter(): IReporterRepo<ExtendedParams>
|
||||
fun provideBluetoothReporter(): IReporter
|
||||
fun provideNetworkReporter(): IReporter
|
||||
fun provideSensorsRepo(): ISensorsRepo
|
||||
fun provideTxRadioController(): IRadioController
|
||||
fun provideRxRadioController(): IRadioController
|
||||
fun provideAudioCapture(): IAudioCapture
|
||||
fun provideSaveImage(): ISaveImage
|
||||
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||
}
|
||||
|
||||
interface IContainerProvider {
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
interface IRadioController {
|
||||
|
||||
val isConnected: Boolean
|
||||
|
||||
suspend fun connect(): Boolean
|
||||
|
||||
suspend fun disconnect()
|
||||
|
||||
suspend fun setFrequency(frequencyHz: Long): Boolean
|
||||
|
||||
suspend fun setMode(mode: String): Boolean
|
||||
|
||||
suspend fun setCtcssMode(enabled: Boolean): Boolean
|
||||
|
||||
suspend fun setCtcssTone(toneHz: Double): Boolean
|
||||
|
||||
suspend fun readFrequencyAndMode(): Pair<Long, String>?
|
||||
|
||||
suspend fun pttOn(): Boolean
|
||||
|
||||
suspend fun pttOff(): Boolean
|
||||
|
||||
// ── Extended operations (IC-705 / CI-V) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Select the band matching [frequencyHz] via the band stacking register.
|
||||
* Must be called before [setFrequency] and [setMode] when first tracking.
|
||||
* Default: no-op (Yaesu radios auto-switch band via frequency).
|
||||
*/
|
||||
suspend fun setBand(frequencyHz: Long): Boolean = false
|
||||
|
||||
/**
|
||||
* Select the active VFO.
|
||||
* @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split).
|
||||
*/
|
||||
suspend fun setVfo(vfoA: Boolean): Boolean = false
|
||||
|
||||
/**
|
||||
* Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO).
|
||||
* Default: not supported.
|
||||
*/
|
||||
suspend fun setSplitMode(enabled: Boolean): Boolean = false
|
||||
|
||||
/**
|
||||
* Set the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
||||
* Default: delegates to [setFrequency].
|
||||
*/
|
||||
suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz)
|
||||
|
||||
/**
|
||||
* Set the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
||||
* Sent every tracking cycle alongside [setWorkingFrequency] in split mode.
|
||||
* Default: delegates to [setWorkingFrequency].
|
||||
*/
|
||||
suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz)
|
||||
|
||||
/**
|
||||
* Read the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
||||
* Default: delegates to [readFrequencyAndMode].
|
||||
*/
|
||||
suspend fun readWorkingFrequency(): Long? = readFrequencyAndMode()?.first
|
||||
|
||||
/**
|
||||
* Read the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
||||
* Used for tuning detection in split mode.
|
||||
* Default: delegates to [readWorkingFrequency].
|
||||
*/
|
||||
suspend fun readTxVfoFrequency(): Long? = readWorkingFrequency()
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class RadioTrackingState(
|
||||
val isActive: Boolean = false,
|
||||
val txConnected: Boolean = false,
|
||||
val rxConnected: Boolean = false,
|
||||
val txFrequencyHz: Long? = null,
|
||||
val rxFrequencyHz: Long? = null,
|
||||
val txMode: String? = null,
|
||||
val rxMode: String? = null,
|
||||
val ctcssTone: Double? = null,
|
||||
val txBaseFrequencyHz: Long? = null,
|
||||
val selectedTransponder: SatRadio? = null,
|
||||
val currentPass: OrbitalPass? = null,
|
||||
val azimuth: Double = 0.0,
|
||||
val elevation: Double = 0.0,
|
||||
val distance: Double = 0.0,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
interface IRadioTrackingService {
|
||||
val state: StateFlow<RadioTrackingState>
|
||||
|
||||
suspend fun connectRadios()
|
||||
suspend fun disconnectRadios()
|
||||
fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?)
|
||||
fun stopTracking()
|
||||
fun setTransponder(transponder: SatRadio)
|
||||
fun setTxBaseFrequency(frequencyHz: Long)
|
||||
fun adjustTxBaseFrequency(deltaHz: Long)
|
||||
fun setCtcssTone(toneHz: Double?)
|
||||
fun setMode(txMode: String, rxMode: String)
|
||||
}
|
||||
+3
-14
@@ -17,18 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
interface IReporterParams
|
||||
|
||||
interface IReporterRepo<T : IReporterParams> {
|
||||
fun isConnected(service: BtService): Boolean
|
||||
fun isConnecting(service: BtService): Boolean
|
||||
fun connect(service: BtService, deviceId: String)
|
||||
fun reportRotation(format: String, azimuth: Double, elevation: Double, params: T)
|
||||
fun reportFrequency(format: String, frequency: Long, params: T)
|
||||
interface IReporter {
|
||||
fun reportRotation(format: String, azimuth: Double, elevation: Double)
|
||||
fun reportFrequency(format: String, frequency: Long)
|
||||
}
|
||||
|
||||
data class ExtendedParams(val server: String, val port: Int) : IReporterParams
|
||||
|
||||
data class WithoutExtParams(val dummy: Int) : IReporterParams
|
||||
|
||||
enum class BtService { ROTATOR, FREQUENCY }
|
||||
+35
-4
@@ -25,13 +25,44 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ISatelliteRepo {
|
||||
val passes: StateFlow<List<OrbitalPass>>
|
||||
/** All selected OrbitalObjects, updated when the selection changes. */
|
||||
val satellites: StateFlow<List<OrbitalObject>>
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
|
||||
/** Raw calculated passes (without live progress). Updated on selection/filter change. */
|
||||
val passes: StateFlow<List<OrbitalPass>>
|
||||
|
||||
/** Whether the repo is currently calculating passes. */
|
||||
val isCalculating: StateFlow<Boolean>
|
||||
|
||||
/** Currently selected pass (catNum + aosTime), persisted across screen navigations. */
|
||||
val selectedPass: StateFlow<Pair<Int, Long>>
|
||||
|
||||
/** Set the currently selected pass. */
|
||||
fun selectPass(catNum: Int, aosTime: Long)
|
||||
|
||||
/** Load satellite objects from DB based on the current selection. */
|
||||
suspend fun initRepository()
|
||||
|
||||
/** Recalculate passes with the given filter parameters. */
|
||||
suspend fun calculatePasses(
|
||||
time: Long,
|
||||
hoursAhead: Int,
|
||||
minElevation: Double,
|
||||
aosStartMinute: Int,
|
||||
aosEndMinute: Int,
|
||||
invertAosTimeWindow: Boolean,
|
||||
modes: List<String>
|
||||
)
|
||||
|
||||
/** Get the current position of a single satellite. */
|
||||
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
||||
|
||||
/** Get the ground track for a satellite over a time range. */
|
||||
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
|
||||
|
||||
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
|
||||
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
|
||||
suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass>
|
||||
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
|
||||
|
||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
}
|
||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ISelectionRepo {
|
||||
fun getCurrentTypes(): List<String>
|
||||
fun getTypesList(): List<String>
|
||||
fun getCurrentModes(): List<String>
|
||||
fun getModesList(): List<String>
|
||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||
suspend fun setTypes(types: List<String>)
|
||||
suspend fun setModes(modes: List<String>)
|
||||
suspend fun setQuery(query: String)
|
||||
suspend fun setSelection(selectAll: Boolean)
|
||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ISensorsRepo {
|
||||
val orientation: StateFlow<Pair<Float, Float>>
|
||||
val sensorData: StateFlow<Pair<Float, Float>>
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||
fun enableSensor()
|
||||
fun disableSensor()
|
||||
|
||||
+18
-30
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -31,9 +32,9 @@ interface ISettingsRepo {
|
||||
|
||||
//region # Satellites selection settings
|
||||
val selectedIds: StateFlow<List<Int>>
|
||||
val selectedTypes: StateFlow<List<String>>
|
||||
val selectedSatModes: StateFlow<List<String>>
|
||||
fun setSelectedIds(ids: List<Int>)
|
||||
fun setSelectedTypes(types: List<String>)
|
||||
fun setSelectedSatModes(modes: List<String>)
|
||||
//endregion
|
||||
|
||||
//region # Passes filter settings
|
||||
@@ -50,46 +51,33 @@ interface ISettingsRepo {
|
||||
|
||||
//region # Database update settings
|
||||
val databaseState: StateFlow<DatabaseState>
|
||||
fun getSatelliteTypesIds(types: List<String>): List<Int>
|
||||
fun setSatelliteTypeIds(type: String, ids: List<Int>)
|
||||
fun updateDatabaseState(state: DatabaseState)
|
||||
//endregion
|
||||
|
||||
//region # RC settings
|
||||
val rcSettings: StateFlow<RCSettings>
|
||||
fun setBluetoothRotatorAddress(value: String)
|
||||
fun setBluetoothRotatorFormat(value: String)
|
||||
fun setBluetoothRotatorName(value: String)
|
||||
fun setBluetoothRotatorState(value: Boolean)
|
||||
fun setBluetoothFrequencyAddress(value: String)
|
||||
fun setBluetoothFrequencyFormat(value: String)
|
||||
fun setBluetoothFrequencyState(value: Boolean)
|
||||
fun setRotatorAddress(value: String)
|
||||
fun setRotatorPort(value: String)
|
||||
fun setRotatorState(value: Boolean)
|
||||
fun setRotatorFormat(value: String)
|
||||
fun setFrequencyAddress(value: String)
|
||||
fun setFrequencyPort(value: String)
|
||||
fun setFrequencyState(value: Boolean)
|
||||
fun setFrequencyFormat(value: String)
|
||||
fun updateRCSettings(settings: RCSettings)
|
||||
//endregion
|
||||
|
||||
//region # Other settings
|
||||
val otherSettings: StateFlow<OtherSettings>
|
||||
fun setStateOfAutoUpdate(value: Boolean)
|
||||
fun setStateOfSensors(value: Boolean)
|
||||
fun setStateOfSweep(value: Boolean)
|
||||
fun setStateOfUtc(value: Boolean)
|
||||
fun setStateOfLightTheme(value: Boolean)
|
||||
fun setWarningDismissed()
|
||||
fun setWhatsNewDismissed()
|
||||
fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings)
|
||||
fun setWarningDismissed() = updateOtherSettings { it.copy(shouldSeeWarning = false) }
|
||||
fun setWhatsNewDismissed() = updateOtherSettings { it.copy(shouldSeeWhatsNew = false) }
|
||||
//endregion
|
||||
|
||||
//region # Transceivers settings
|
||||
val dataSourcesSettings: StateFlow<DataSourcesSettings>
|
||||
fun setUseCustomTle(value: Boolean)
|
||||
fun setUseCustomTransceivers(value: Boolean)
|
||||
fun setTleUrl(value: String)
|
||||
fun setTransceiversUrl(value: String)
|
||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
fun getSatelliteOffset(catnum: Int): String
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
}
|
||||
@@ -22,4 +22,6 @@ import java.io.InputStream
|
||||
interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||
}
|
||||
@@ -18,35 +18,25 @@
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
const val RADIO_DATA_URL = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
val satelliteDataUrls = mapOf(
|
||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
||||
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
||||
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
|
||||
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
|
||||
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
|
||||
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
|
||||
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
|
||||
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
|
||||
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
|
||||
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
|
||||
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
|
||||
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
||||
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
||||
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
||||
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
||||
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
||||
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
||||
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
||||
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
|
||||
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
|
||||
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
|
||||
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"Other" to "" // key for sats filter
|
||||
val satelliteDataUrls = listOf(
|
||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"db.satnogs.org/api/tle/?format=3le",
|
||||
"amsat.org/tle/current/nasabare.txt",
|
||||
"mmccants.org/tles/classfd.zip",
|
||||
"r4uab.ru/satonline.txt",
|
||||
"live.ariss.org/iss.txt"
|
||||
)
|
||||
val transceiversDataUrls = listOf(
|
||||
"db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||
"r4uab.ru/transmitters.json"
|
||||
)
|
||||
val satelliteModes = listOf(
|
||||
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
||||
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
|
||||
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
|
||||
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
|
||||
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
|
||||
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
|
||||
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.sstv
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.round
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class SstvFrame(
|
||||
val scopePixels: IntArray?,
|
||||
val scopeWidth: Int,
|
||||
val scopeHeight: Int,
|
||||
val imagePixels: IntArray?,
|
||||
val imageWidth: Int,
|
||||
val imageHeight: Int,
|
||||
val modeName: String,
|
||||
val imageComplete: Boolean,
|
||||
val inputRms: Float,
|
||||
val appliedGain: Float,
|
||||
val syncHitRate: Float,
|
||||
val predictedLineBursts: Int,
|
||||
val maxPredictedStreak: Int,
|
||||
val timingErrorSamples: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Decoder quality metrics for diagnostics and logging. Useful for profiling decode
|
||||
* performance on noisy recordings.
|
||||
*/
|
||||
data class SstvQualityMetrics(
|
||||
val syncHitRate: Float,
|
||||
val predictedLineBursts: Int,
|
||||
val maxPredictedStreak: Int,
|
||||
val timingErrorSamples: Int
|
||||
)
|
||||
|
||||
enum class LineRecoveryStrategy {
|
||||
Look4SatLimited,
|
||||
Robot36Compatible
|
||||
}
|
||||
|
||||
class SstvDiagnosticsHandle internal constructor(
|
||||
val enabled: Boolean,
|
||||
val metrics: StateFlow<SstvQualityMetrics?>
|
||||
)
|
||||
|
||||
class SstvDecoder(
|
||||
sampleRate: Int = 44100,
|
||||
scopeWidth: Int = 320,
|
||||
scopeHeight: Int = 256,
|
||||
private val channelSelect: Int = 0,
|
||||
targetRmsLevel: Float = 0.25f,
|
||||
private val includeScopeData: Boolean = false,
|
||||
private val enableRmsNormalization: Boolean = true,
|
||||
preFilterCutoffHz: Double = 500.0,
|
||||
enablePreFilter: Boolean = true,
|
||||
private val enableDiagnosticsHandle: Boolean = false,
|
||||
lineRecoveryStrategy: LineRecoveryStrategy = LineRecoveryStrategy.Look4SatLimited
|
||||
) {
|
||||
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
||||
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
||||
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate, lineRecoveryStrategy)
|
||||
private val _frames = MutableSharedFlow<SstvFrame>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
private var lastInputRms = 0f
|
||||
private var lastAppliedGain = 1f
|
||||
private val _qualityMetrics = MutableStateFlow<SstvQualityMetrics?>(null)
|
||||
private val preFilter = if (enablePreFilter) HighPassFilter(preFilterCutoffHz, sampleRate.toDouble()) else null
|
||||
private val diagnosticsHandle = SstvDiagnosticsHandle(enableDiagnosticsHandle, _qualityMetrics)
|
||||
val frames: SharedFlow<SstvFrame> = _frames
|
||||
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
||||
|
||||
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
||||
// Optional pre-filtering: remove DC offset and subsonic noise that can mask
|
||||
// weak signals and corrupt the RMS normalization baseline.
|
||||
preFilter?.apply(samples)
|
||||
|
||||
// Optional RMS normalization: bring input to a consistent level so the
|
||||
// FM demodulator operates in a predictable region. However, this amplifies
|
||||
// noise proportionally. Aggressive RMS targets (e.g., 0.25) can hurt weak
|
||||
// signals by boosting noise floor. Safer defaults: 0.35-0.50 for noisy inputs.
|
||||
// Disable entirely for direct line-level inputs (e.g., receiver discriminator).
|
||||
val gain = if (enableRmsNormalization) normalise(samples) else GainInfo(0f, 1f)
|
||||
lastInputRms = gain.inputRms
|
||||
lastAppliedGain = gain.appliedGain
|
||||
val hasNewLines = decoder.process(samples, channelSelect)
|
||||
if (hasNewLines) emitFrame()
|
||||
}
|
||||
|
||||
fun lockMode(modeName: String) = decoder.setMode(modeName)
|
||||
|
||||
fun clearPixels() {
|
||||
imageBuffer.line = -1
|
||||
imageBuffer.pixels.fill(0)
|
||||
decoder.resetQuality()
|
||||
if (enableDiagnosticsHandle) _qualityMetrics.value = null
|
||||
}
|
||||
|
||||
fun getDiagnosticsHandle(): SstvDiagnosticsHandle? = diagnosticsHandle.takeIf { it.enabled }
|
||||
|
||||
/**
|
||||
* Export quality metrics for logging/diagnostics. Useful for profiling decode
|
||||
* performance on noisy recordings. Returns null before first frame is emitted.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
fun getQualityMetrics(): SstvQualityMetrics? {
|
||||
if (enableDiagnosticsHandle) return _qualityMetrics.value
|
||||
val q = decoder.quality()
|
||||
return SstvQualityMetrics(
|
||||
syncHitRate = q.syncHitRate,
|
||||
predictedLineBursts = q.predictedLineBursts,
|
||||
maxPredictedStreak = q.maxPredictedStreak,
|
||||
timingErrorSamples = q.timingErrorSamples
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitFrame() {
|
||||
val imageWidth = imageBuffer.width
|
||||
val imageHeight = imageBuffer.height
|
||||
val quality = decoder.quality()
|
||||
if (enableDiagnosticsHandle) {
|
||||
_qualityMetrics.value = SstvQualityMetrics(
|
||||
syncHitRate = quality.syncHitRate,
|
||||
predictedLineBursts = quality.predictedLineBursts,
|
||||
maxPredictedStreak = quality.maxPredictedStreak,
|
||||
timingErrorSamples = quality.timingErrorSamples
|
||||
)
|
||||
}
|
||||
val imageComplete = imageBuffer.line >= imageHeight && imageBuffer.line > 0
|
||||
// Copy only the active image region — imageBuffer.pixels is pre-allocated
|
||||
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
|
||||
// the entire array and send padding pixels to the observer.
|
||||
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
|
||||
val modeName = decoder.currentMode.name
|
||||
val scopePixels = if (includeScopeData) scopeBuffer.pixels.copyOf() else null
|
||||
val scopeWidth = if (includeScopeData) scopeBuffer.width else 0
|
||||
val scopeHeight = if (includeScopeData) scopeBuffer.height else 0
|
||||
_frames.tryEmit(
|
||||
SstvFrame(
|
||||
scopePixels = scopePixels,
|
||||
scopeWidth = scopeWidth,
|
||||
scopeHeight = scopeHeight,
|
||||
imagePixels = imagePixels,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
modeName = modeName,
|
||||
imageComplete = imageComplete,
|
||||
inputRms = lastInputRms,
|
||||
appliedGain = lastAppliedGain,
|
||||
syncHitRate = quality.syncHitRate,
|
||||
predictedLineBursts = quality.predictedLineBursts,
|
||||
maxPredictedStreak = quality.maxPredictedStreak,
|
||||
timingErrorSamples = quality.timingErrorSamples
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
|
||||
// FM demodulator well above its noise floor regardless of input gain.
|
||||
// TUNING GUIDE:
|
||||
// - 0.20-0.25: Aggressive, best for clean direct-coupled inputs, worst for mic noise
|
||||
// - 0.35-0.40: Moderate, good balance for typical phone/mic inputs (RECOMMENDED)
|
||||
// - 0.50-0.60: Conservative, best for noisy environments, reduces amplitude resolution
|
||||
// Disable RMS normalization entirely if using a professional receiver discriminator output.
|
||||
private val targetRms = targetRmsLevel.coerceIn(0.05f, 0.8f)
|
||||
|
||||
private class GainInfo(
|
||||
val inputRms: Float,
|
||||
val appliedGain: Float
|
||||
)
|
||||
|
||||
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
|
||||
// both decode reliably. The guard prevents amplifying pure silence into noise.
|
||||
private fun normalise(buffer: FloatArray): GainInfo {
|
||||
var sumSq = 0f
|
||||
for (s in buffer) sumSq += s * s
|
||||
val rms = sqrt(sumSq / buffer.size)
|
||||
if (rms > 1e-6f) {
|
||||
val gain = targetRms / rms
|
||||
for (i in buffer.indices) buffer[i] *= gain
|
||||
return GainInfo(rms, gain)
|
||||
}
|
||||
return GainInfo(rms, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple high-pass filter to remove DC offset and subsonic interference before RMS
|
||||
* normalization. Improves noise floor estimation and prevents low-freq noise from
|
||||
* corrupting the gain calculation.
|
||||
*
|
||||
* Design: First-order butterworth (pole at cutoff frequency). Fast, minimal latency,
|
||||
* suitable for real-time preprocessing.
|
||||
*/
|
||||
internal class HighPassFilter(cutoffHz: Double, sampleRateHz: Double) {
|
||||
private val alpha: Float
|
||||
private var prevInput = 0f
|
||||
private var prevOutput = 0f
|
||||
|
||||
init {
|
||||
// First-order pole placement: alpha = wc / (wc + ws) where wc = 2*pi*fc, ws = 2*pi*fs
|
||||
val omega = 2f * PI.toFloat() * (cutoffHz / sampleRateHz).toFloat()
|
||||
alpha = omega / (omega + 1f)
|
||||
}
|
||||
|
||||
fun apply(buffer: FloatArray) {
|
||||
for (i in buffer.indices) {
|
||||
val input = buffer[i]
|
||||
prevOutput = alpha * (prevOutput + input - prevInput)
|
||||
buffer[i] = prevOutput
|
||||
prevInput = input
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class DecoderQuality(
|
||||
val syncHitRate: Float,
|
||||
val predictedLineBursts: Int,
|
||||
val maxPredictedStreak: Int,
|
||||
val timingErrorSamples: Int
|
||||
)
|
||||
|
||||
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
||||
|
||||
internal class SyncPulseDetector(sampleRate: Int) {
|
||||
|
||||
companion object {
|
||||
const val SYNC_FREQ = 1200.0
|
||||
const val BLACK_FREQ = 1500.0
|
||||
const val WHITE_FREQ = 2300.0
|
||||
}
|
||||
|
||||
var detectedWidth: SyncPulseWidth = SyncPulseWidth.NineMs; private set
|
||||
var pulseOffset: Int = 0; private set
|
||||
var freqOffset: Float = 0f; private set
|
||||
|
||||
private val bandwidth = WHITE_FREQ - BLACK_FREQ
|
||||
private val fm = FmDemodulator(bandwidth, sampleRate.toDouble())
|
||||
private val min5ms: Int = round(0.0025 * sampleRate).toInt()
|
||||
private val max5ms: Int = round(0.007 * sampleRate).toInt()
|
||||
private val max9ms: Int = round(0.0145 * sampleRate).toInt()
|
||||
private val max20ms: Int = round(0.025 * sampleRate).toInt()
|
||||
private val filterDelay: Int
|
||||
private val avgFilter: MovingAverage
|
||||
private val delayLine: Delay
|
||||
private val lowPass: ComplexFirFilter
|
||||
private val oscillator: Phasor
|
||||
private val syncFreqValue: Float
|
||||
private val syncFreqTolerance: Float
|
||||
private val trigger: SchmittTrigger
|
||||
private var counter = 0
|
||||
private var baseBand = Complex()
|
||||
|
||||
init {
|
||||
val filterLen = round(0.0025 * sampleRate).toInt() or 1
|
||||
filterDelay = (filterLen - 1) / 2
|
||||
avgFilter = MovingAverage(filterLen)
|
||||
delayLine = Delay(filterLen)
|
||||
val loFreq = 1000.0
|
||||
val hiFreq = 2800.0
|
||||
val cutoff = (hiFreq - loFreq) / 2
|
||||
val lpLen = round(0.002 * sampleRate).toInt() or 1
|
||||
lowPass = ComplexFirFilter(lpLen)
|
||||
for (i in 0 until lpLen)
|
||||
lowPass.taps[i] = (WindowFunctions.kaiser(2.0, i, lpLen) * WindowFunctions.sinc(
|
||||
cutoff,
|
||||
sampleRate.toDouble(),
|
||||
i,
|
||||
lpLen
|
||||
)).toFloat()
|
||||
val center = (loFreq + hiFreq) / 2
|
||||
oscillator = Phasor(-center, sampleRate.toDouble())
|
||||
syncFreqValue = ((SYNC_FREQ - center) * 2 / bandwidth).toFloat()
|
||||
syncFreqTolerance = (50 * 2 / bandwidth).toFloat()
|
||||
val porchFreq = 1500.0
|
||||
val hiThresh = (SYNC_FREQ + porchFreq) / 2
|
||||
val loThresh = (SYNC_FREQ + hiThresh) / 2
|
||||
trigger = SchmittTrigger(
|
||||
((loThresh - center) * 2 / bandwidth).toFloat(),
|
||||
((hiThresh - center) * 2 / bandwidth).toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
fun process(buffer: FloatArray, channelSelect: Int): Boolean {
|
||||
var detected = false
|
||||
val channels = if (channelSelect > 0) 2 else 1
|
||||
// NOTE: buffer[i] is overwritten in-place with the FM-demodulated frequency
|
||||
// value for every mono sample (channelSelect == 0). DecoderEngine.process()
|
||||
// reads back these values to populate scanLineBuffer. Callers must not reuse
|
||||
// the buffer after this call.
|
||||
for (i in 0 until buffer.size / channels) {
|
||||
when (channelSelect) {
|
||||
1 -> baseBand.set(buffer[2 * i])
|
||||
2 -> baseBand.set(buffer[2 * i + 1])
|
||||
3 -> baseBand.set(buffer[2 * i] + buffer[2 * i + 1])
|
||||
4 -> baseBand.set(buffer[2 * i], buffer[2 * i + 1])
|
||||
else -> baseBand.set(buffer[i])
|
||||
}
|
||||
baseBand = lowPass.filter(baseBand.mul(oscillator.rotate()))
|
||||
val freq = fm.demodulate(baseBand)
|
||||
val avg = avgFilter.avg(freq)
|
||||
val delayed = delayLine.push(avg)
|
||||
buffer[i] = freq
|
||||
if (!trigger.process(avg)) {
|
||||
++counter
|
||||
} else if (counter !in min5ms..max20ms || abs(delayed - syncFreqValue) > syncFreqTolerance) {
|
||||
counter = 0
|
||||
} else {
|
||||
detectedWidth = when {
|
||||
counter < max5ms -> SyncPulseWidth.FiveMs
|
||||
counter < max9ms -> SyncPulseWidth.NineMs
|
||||
else -> SyncPulseWidth.TwentyMs
|
||||
}
|
||||
pulseOffset = i - filterDelay
|
||||
freqOffset = delayed - syncFreqValue
|
||||
detected = true
|
||||
counter = 0
|
||||
}
|
||||
}
|
||||
return detected
|
||||
}
|
||||
}
|
||||
|
||||
internal class DecoderEngine(
|
||||
private val scopeBuffer: PixelBuffer,
|
||||
private val imageBuffer: PixelBuffer,
|
||||
rawName: String,
|
||||
sampleRate: Int,
|
||||
lineRecoveryStrategy: LineRecoveryStrategy
|
||||
) {
|
||||
private val pixelBuffer = PixelBuffer(800, 2)
|
||||
private val detector = SyncPulseDetector(sampleRate)
|
||||
private val pulseFilter: MovingAverage
|
||||
private val pulseFilterDelay: Int
|
||||
private val scanLineBuffer: FloatArray
|
||||
private val scratch: FloatArray
|
||||
private val sync5ms = IntArray(5)
|
||||
private val sync9ms = IntArray(5)
|
||||
private val sync20ms = IntArray(5)
|
||||
private val lines5ms = IntArray(4)
|
||||
private val lines9ms = IntArray(4)
|
||||
private val lines20ms = IntArray(4)
|
||||
private val offsets5ms = FloatArray(5)
|
||||
private val offsets9ms = FloatArray(5)
|
||||
private val offsets20ms = FloatArray(5)
|
||||
private val visFreqs = FloatArray(10)
|
||||
private val scanLineMin: Int
|
||||
private val syncTolerance: Int
|
||||
private val lineTolerance: Int
|
||||
private val leaderLen: Int
|
||||
private val leaderTol: Int
|
||||
private val transition: Int
|
||||
private val visBitLen: Int
|
||||
private val visLen: Int
|
||||
private val rawMode: SstvMode
|
||||
private val modes5ms: ArrayList<SstvMode>
|
||||
private val modes9ms: ArrayList<SstvMode>
|
||||
private val modes20ms: ArrayList<SstvMode>
|
||||
|
||||
var currentMode: SstvMode; private set
|
||||
val allModes: List<SstvMode> get() = modes5ms + modes9ms + modes20ms
|
||||
|
||||
private var lockMode = false
|
||||
private var sample = 0
|
||||
private var leaderBreak = 0
|
||||
private var lastSync = 0
|
||||
private var curLineSamples: Int
|
||||
private var lastOffset = 0f
|
||||
private var syncChecks = 0
|
||||
private var syncHits = 0
|
||||
private var predictedLineBursts = 0
|
||||
private var predictedStreak = 0
|
||||
private var maxPredictedStreak = 0
|
||||
private var timingErrorSamples = 0
|
||||
|
||||
// Look4Sat strategy limits synthetic lines to avoid visible vertical collapse.
|
||||
// Robot36 strategy preserves legacy behavior by allowing unlimited synthesis.
|
||||
private val maxConsecutivePredictedLines = when (lineRecoveryStrategy) {
|
||||
LineRecoveryStrategy.Look4SatLimited -> 2
|
||||
LineRecoveryStrategy.Robot36Compatible -> Int.MAX_VALUE
|
||||
}
|
||||
|
||||
init {
|
||||
imageBuffer.line = -1
|
||||
// Pre-allocate for the largest possible mode (PD-290: 800×616 = 492 800 ints)
|
||||
// so that handleHeader/processPulse can reuse the array with a fill(0) instead
|
||||
// of allocating a fresh IntArray on every new image, reducing GC pressure.
|
||||
imageBuffer.pixels = IntArray(800 * 616)
|
||||
val pfLen = round(0.0025 * sampleRate).toInt() or 1
|
||||
pulseFilterDelay = (pfLen - 1) / 2
|
||||
pulseFilter = MovingAverage(pfLen)
|
||||
scanLineBuffer = FloatArray(round(7.0 * sampleRate).toInt())
|
||||
scratch = FloatArray(round(1.1 * sampleRate).toInt())
|
||||
leaderLen = round(0.3 * sampleRate).toInt()
|
||||
leaderTol = round(0.06 * sampleRate).toInt()
|
||||
transition = round(0.0005 * sampleRate).toInt()
|
||||
visBitLen = round(0.03 * sampleRate).toInt()
|
||||
visLen = round(0.3 * sampleRate).toInt()
|
||||
scanLineMin = round(0.05 * sampleRate).toInt()
|
||||
syncTolerance = round(0.03 * sampleRate).toInt()
|
||||
lineTolerance = round(0.001 * sampleRate).toInt()
|
||||
rawMode = RawMode(rawName, sampleRate)
|
||||
val robot36 = Robot36Mode(sampleRate)
|
||||
currentMode = robot36
|
||||
curLineSamples = robot36.scanLineSamples
|
||||
modes5ms = arrayListOf(
|
||||
RgbMode.wraaseSc2180(sampleRate),
|
||||
RgbMode.martin("1", 44, 0.146432, sampleRate),
|
||||
RgbMode.martin("2", 40, 0.073216, sampleRate)
|
||||
)
|
||||
modes9ms = arrayListOf(
|
||||
robot36, Robot72Mode(sampleRate),
|
||||
RgbMode.scottie("1", 60, 0.138240, sampleRate),
|
||||
RgbMode.scottie("2", 56, 0.088064, sampleRate),
|
||||
RgbMode.scottie("DX", 76, 0.3456, sampleRate)
|
||||
)
|
||||
modes20ms = arrayListOf(
|
||||
PdMode("50", 93, 320, 256, 0.09152, sampleRate),
|
||||
PdMode("90", 99, 320, 256, 0.17024, sampleRate),
|
||||
PdMode("120", 95, 640, 496, 0.1216, sampleRate),
|
||||
PdMode("160", 98, 512, 400, 0.195584, sampleRate),
|
||||
PdMode("180", 96, 640, 496, 0.18304, sampleRate),
|
||||
PdMode("240", 97, 640, 496, 0.24448, sampleRate),
|
||||
PdMode("290", 94, 800, 616, 0.2288, sampleRate)
|
||||
)
|
||||
}
|
||||
|
||||
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
||||
var newLines = false
|
||||
val detected = detector.process(recordBuffer, channelSelect)
|
||||
syncChecks++
|
||||
if (detected) {
|
||||
syncHits++
|
||||
predictedStreak = 0
|
||||
}
|
||||
var syncIdx = sample + detector.pulseOffset
|
||||
val channels = if (channelSelect > 0) 2 else 1
|
||||
for (j in 0 until recordBuffer.size / channels) {
|
||||
if (sample >= scanLineBuffer.size) {
|
||||
shift(curLineSamples)
|
||||
syncIdx -= curLineSamples
|
||||
if (sample >= scanLineBuffer.size) sample = scanLineBuffer.size - 1
|
||||
}
|
||||
scanLineBuffer[sample++] = recordBuffer[j]
|
||||
}
|
||||
if (detected) {
|
||||
when (detector.detectedWidth) {
|
||||
SyncPulseWidth.FiveMs -> newLines = processPulse(modes5ms, offsets5ms, sync5ms, lines5ms, syncIdx)
|
||||
SyncPulseWidth.NineMs -> {
|
||||
leaderBreak = syncIdx; newLines = processPulse(modes9ms, offsets9ms, sync9ms, lines9ms, syncIdx)
|
||||
}
|
||||
|
||||
SyncPulseWidth.TwentyMs -> {
|
||||
leaderBreak = syncIdx; newLines = processPulse(modes20ms, offsets20ms, sync20ms, lines20ms, syncIdx)
|
||||
}
|
||||
}
|
||||
} else if (handleHeader()) {
|
||||
predictedStreak = 0
|
||||
newLines = true
|
||||
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
||||
newLines = decodePredictedLine()
|
||||
}
|
||||
return newLines
|
||||
}
|
||||
|
||||
fun setMode(name: String) {
|
||||
val mode = allModes.firstOrNull { it.name == name }
|
||||
if (mode == currentMode) {
|
||||
lockMode = true; return
|
||||
}
|
||||
if (mode != null) {
|
||||
lockMode = true; imageBuffer.line = -1; currentMode = mode; curLineSamples = mode.scanLineSamples; return
|
||||
}
|
||||
lockMode = false
|
||||
}
|
||||
|
||||
fun quality(): DecoderQuality {
|
||||
val hitRate = if (syncChecks > 0) syncHits.toFloat() / syncChecks else 0f
|
||||
return DecoderQuality(
|
||||
syncHitRate = hitRate,
|
||||
predictedLineBursts = predictedLineBursts,
|
||||
maxPredictedStreak = maxPredictedStreak,
|
||||
timingErrorSamples = timingErrorSamples
|
||||
)
|
||||
}
|
||||
|
||||
fun resetQuality() {
|
||||
syncChecks = 0
|
||||
syncHits = 0
|
||||
predictedLineBursts = 0
|
||||
predictedStreak = 0
|
||||
maxPredictedStreak = 0
|
||||
timingErrorSamples = 0
|
||||
}
|
||||
|
||||
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
|
||||
private fun stdDev(a: IntArray, m: Double): Double {
|
||||
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
|
||||
}
|
||||
|
||||
private fun meanF(a: FloatArray): Float {
|
||||
var s = 0f; for (v in a) s += v; return s / a.size
|
||||
}
|
||||
|
||||
private fun detectMode(modes: ArrayList<SstvMode>, samples: Int): SstvMode {
|
||||
var best: SstvMode = rawMode
|
||||
var bestD = Int.MAX_VALUE
|
||||
for (m in modes) {
|
||||
val d = abs(samples - m.scanLineSamples); if (d <= lineTolerance && d < bestD) {
|
||||
bestD = d; best = m
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// scopeBuffer is twice the display height. Each decoded scan line is written
|
||||
// to both the current rolling position (top half, wraps at height/2) and the
|
||||
// same row offset in the bottom half. The UI displays a window that always
|
||||
// spans the half-height boundary, giving a seamless non-wrapping scroll effect.
|
||||
private fun copyUnscaled() {
|
||||
val w = minOf(scopeBuffer.width, pixelBuffer.width)
|
||||
for (row in 0 until pixelBuffer.height) {
|
||||
val line = scopeBuffer.width * scopeBuffer.line
|
||||
pixelBuffer.pixels.copyInto(scopeBuffer.pixels, line, row * pixelBuffer.width, row * pixelBuffer.width + w)
|
||||
scopeBuffer.pixels.fill(0, line + w, line + scopeBuffer.width)
|
||||
|
||||
scopeBuffer.pixels.copyInto(
|
||||
scopeBuffer.pixels,
|
||||
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
|
||||
line,
|
||||
line + scopeBuffer.width
|
||||
)
|
||||
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyScaled(scale: Int) {
|
||||
for (row in 0 until pixelBuffer.height) {
|
||||
val line = scopeBuffer.width * scopeBuffer.line
|
||||
for (col in 0 until pixelBuffer.width) for (i in 0 until scale) scopeBuffer.pixels[line + col * scale + i] =
|
||||
pixelBuffer.pixels[pixelBuffer.width * row + col]
|
||||
scopeBuffer.pixels.fill(0, line + pixelBuffer.width * scale, line + scopeBuffer.width)
|
||||
|
||||
scopeBuffer.pixels.copyInto(
|
||||
scopeBuffer.pixels,
|
||||
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
|
||||
line,
|
||||
line + scopeBuffer.width
|
||||
)
|
||||
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||
repeat(scale - 1) {
|
||||
|
||||
scopeBuffer.pixels.copyInto(
|
||||
scopeBuffer.pixels, scopeBuffer.width * scopeBuffer.line, line, line + scopeBuffer.width
|
||||
)
|
||||
|
||||
scopeBuffer.pixels.copyInto(
|
||||
scopeBuffer.pixels,
|
||||
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
|
||||
line,
|
||||
line + scopeBuffer.width
|
||||
)
|
||||
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyLines(ok: Boolean) {
|
||||
if (!ok) return
|
||||
var finish = false
|
||||
if (imageBuffer.line in 0 until imageBuffer.height && imageBuffer.width == pixelBuffer.width) {
|
||||
val w = imageBuffer.width
|
||||
for (row in 0 until pixelBuffer.height) {
|
||||
if (imageBuffer.line >= imageBuffer.height) break
|
||||
pixelBuffer.pixels.copyInto(imageBuffer.pixels, imageBuffer.line * w, row * w, row * w + w)
|
||||
imageBuffer.line++
|
||||
}
|
||||
finish = imageBuffer.line == imageBuffer.height
|
||||
}
|
||||
val scale = scopeBuffer.width / pixelBuffer.width
|
||||
if (scale <= 1) copyUnscaled() else copyScaled(scale)
|
||||
if (finish) drawLines(0xff000000.toInt(), 10)
|
||||
}
|
||||
|
||||
private fun decodePredictedLine(): Boolean {
|
||||
// Avoid long streaks of synthetic lines; once we exceed the cap we wait for
|
||||
// real sync to reduce visible vertical compression on weak/noisy signals.
|
||||
if (predictedStreak >= maxConsecutivePredictedLines) return false
|
||||
val expectedSync = lastSync + curLineSamples
|
||||
timingErrorSamples = sample - expectedSync
|
||||
val decoded = currentMode.decodeScanLine(
|
||||
pixelBuffer,
|
||||
scratch,
|
||||
scanLineBuffer,
|
||||
scopeBuffer.width,
|
||||
lastSync,
|
||||
curLineSamples,
|
||||
lastOffset
|
||||
)
|
||||
copyLines(decoded)
|
||||
lastSync = expectedSync
|
||||
predictedLineBursts++
|
||||
predictedStreak++
|
||||
if (predictedStreak > maxPredictedStreak) maxPredictedStreak = predictedStreak
|
||||
return decoded
|
||||
}
|
||||
|
||||
private fun drawLines(color: Int, count: Int) {
|
||||
repeat(count) {
|
||||
scopeBuffer.pixels.fill(
|
||||
color,
|
||||
scopeBuffer.line * scopeBuffer.width,
|
||||
(scopeBuffer.line + 1) * scopeBuffer.width
|
||||
)
|
||||
scopeBuffer.pixels.fill(
|
||||
color,
|
||||
(scopeBuffer.line + scopeBuffer.height / 2) * scopeBuffer.width,
|
||||
(scopeBuffer.line + 1 + scopeBuffer.height / 2) * scopeBuffer.width
|
||||
)
|
||||
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun adjust(pulses: IntArray, shift: Int) {
|
||||
for (i in pulses.indices) pulses[i] -= shift
|
||||
}
|
||||
|
||||
private fun shift(amount: Int) {
|
||||
if ((amount <= 0) || (amount > sample)) return
|
||||
sample -= amount; leaderBreak -= amount; lastSync -= amount
|
||||
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount)
|
||||
// Discard already-decoded samples by sliding the live region back to index 0.
|
||||
// System.arraycopy handles the overlapping regions correctly and is a native
|
||||
// memcpy on JVM, so this is fast despite moving the full remaining window.
|
||||
scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
|
||||
}
|
||||
|
||||
private fun handleHeader(): Boolean {
|
||||
if (leaderBreak < visBitLen + leaderTol || sample < leaderBreak + leaderLen + leaderTol + visLen + visBitLen) return false
|
||||
val bp = leaderBreak; leaderBreak = 0
|
||||
var preFreq = 0f
|
||||
for (i in 0 until leaderTol) preFreq += scanLineBuffer[bp - visBitLen - leaderTol + i]
|
||||
val toneFreq = 1900f
|
||||
val center = 1900f
|
||||
val tol = 50f
|
||||
val halfBw = 400f
|
||||
preFreq = preFreq * halfBw / leaderTol + center
|
||||
if (abs(preFreq - toneFreq) > tol) return false
|
||||
var ldrFreq = 0f
|
||||
for (i in transition until leaderLen - leaderTol) ldrFreq += scanLineBuffer[bp + i]
|
||||
val ldrOffset = ldrFreq / (leaderLen - transition - leaderTol)
|
||||
ldrFreq = ldrOffset * halfBw + center
|
||||
if (abs(ldrFreq - toneFreq) > tol) return false
|
||||
val stopFreq = 1200f
|
||||
val pulseThr = ((stopFreq + toneFreq) / 2 - center) / halfBw
|
||||
var vBegin = bp + leaderLen - leaderTol
|
||||
val vEnd = bp + leaderLen + leaderTol + visBitLen
|
||||
repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[vBegin++] - ldrOffset) }
|
||||
while (++vBegin < vEnd) if (pulseFilter.avg(scanLineBuffer[vBegin] - ldrOffset) < pulseThr) break
|
||||
if (vBegin >= vEnd) return false
|
||||
vBegin -= pulseFilterDelay
|
||||
val visEnd = vBegin + visLen
|
||||
visFreqs.fill(0f)
|
||||
for (j in 0 until 10) for (i in transition until visBitLen - transition) visFreqs[j] += scanLineBuffer[vBegin + visBitLen * j + i] - ldrOffset
|
||||
for (i in 0 until 10) visFreqs[i] = visFreqs[i] * halfBw / (visBitLen - 2 * transition) + center
|
||||
if (abs(visFreqs[0] - stopFreq) > tol || abs(visFreqs[9] - stopFreq) > tol) return false
|
||||
for (i in 1 until 9) if (abs(visFreqs[i] - 1100f) > tol && abs(visFreqs[i] - 1300f) > tol) return false
|
||||
var vis = 0
|
||||
for (i in 0 until 8) vis = vis or ((if (visFreqs[i + 1] < stopFreq) 1 else 0) shl i)
|
||||
var chk = true; for (i in 0 until 8) chk = chk xor ((vis and (1 shl i)) != 0)
|
||||
vis = vis and 127; if (!chk) return false
|
||||
val syncThr = ((1200f + 1500f) / 2 - center) / halfBw
|
||||
var sIdx = visEnd - visBitLen
|
||||
val sMax = visEnd + visBitLen
|
||||
repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[sIdx++] - ldrOffset) }
|
||||
while (++sIdx < sMax) if (pulseFilter.avg(scanLineBuffer[sIdx] - ldrOffset) > syncThr) break
|
||||
if (sIdx >= sMax) return false
|
||||
sIdx -= pulseFilterDelay
|
||||
val mode: SstvMode
|
||||
val pulses: IntArray
|
||||
val lines: IntArray
|
||||
val f5 = modes5ms.firstOrNull { it.visCode == vis }
|
||||
val f9 = modes9ms.firstOrNull { it.visCode == vis }
|
||||
val f20 = modes20ms.firstOrNull { it.visCode == vis }
|
||||
when {
|
||||
f5 != null -> {
|
||||
mode = f5; pulses = sync5ms; lines = lines5ms
|
||||
}
|
||||
|
||||
f9 != null -> {
|
||||
mode = f9; pulses = sync9ms; lines = lines9ms
|
||||
}
|
||||
|
||||
f20 != null -> {
|
||||
mode = f20; pulses = sync20ms; lines = lines20ms
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (!lockMode) drawLines(0xffff0000.toInt(), 8); return false
|
||||
}
|
||||
}
|
||||
if (lockMode && mode != currentMode) return false
|
||||
mode.resetState()
|
||||
imageBuffer.width = mode.width; imageBuffer.height = mode.height
|
||||
imageBuffer.pixels.fill(0, 0, mode.width * mode.height); imageBuffer.line = 0
|
||||
currentMode = mode
|
||||
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
|
||||
var oldest = lastSync - (pulses.size - 1) * curLineSamples
|
||||
if (mode.firstSyncPulseIndex > 0) oldest -= curLineSamples
|
||||
for (i in pulses.indices) pulses[i] = oldest + i * curLineSamples
|
||||
lines.fill(curLineSamples)
|
||||
shift(lastSync + mode.firstPixelSampleIndex)
|
||||
drawLines(0xff00ff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun processPulse(
|
||||
modes: ArrayList<SstvMode>,
|
||||
freqOffs: FloatArray,
|
||||
syncPulses: IntArray,
|
||||
lineLen: IntArray,
|
||||
latest: Int
|
||||
): Boolean {
|
||||
predictedStreak = 0
|
||||
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
||||
syncPulses[syncPulses.size - 1] = latest
|
||||
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
||||
lineLen[lineLen.size - 1] = syncPulses.last() - syncPulses[syncPulses.size - 2]
|
||||
for (i in 1 until freqOffs.size) freqOffs[i - 1] = freqOffs[i]
|
||||
freqOffs[freqOffs.size - 1] = detector.freqOffset
|
||||
if (lineLen[0] == 0) return false
|
||||
val m = mean(lineLen)
|
||||
val lineSamples = round(m).toInt()
|
||||
if (lineSamples < scanLineMin || lineSamples > scratch.size) return false
|
||||
if (stdDev(lineLen, m) > lineTolerance) return false
|
||||
var changed = false
|
||||
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
|
||||
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false
|
||||
// Try continuous decoding
|
||||
if (lockMode && imageBuffer.line == -1 && currentMode != rawMode) {
|
||||
currentMode.resetState()
|
||||
imageBuffer.width = currentMode.width
|
||||
imageBuffer.height = currentMode.height
|
||||
imageBuffer.pixels.fill(0, 0, currentMode.width * currentMode.height)
|
||||
imageBuffer.line = 0
|
||||
drawLines(0xff000000.toInt(), 10); drawLines(0xffffff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
|
||||
}
|
||||
} else {
|
||||
val prev = currentMode; currentMode = detectMode(modes, lineSamples)
|
||||
changed =
|
||||
currentMode != prev || abs(curLineSamples - lineSamples) > lineTolerance || abs(lastSync + lineSamples - syncPulses.last()) > syncTolerance
|
||||
}
|
||||
if (changed) {
|
||||
drawLines(0xff000000.toInt(), 10); drawLines(0xff00ffff.toInt(), 8); drawLines(0xff000000.toInt(), 10)
|
||||
}
|
||||
val offset = meanF(freqOffs)
|
||||
if (syncPulses[0] >= lineSamples && changed) {
|
||||
val end = syncPulses[0]
|
||||
val extra = end / lineSamples
|
||||
val first = end - extra * lineSamples
|
||||
var p = first; while (p < end) {
|
||||
copyLines(
|
||||
currentMode.decodeScanLine(
|
||||
pixelBuffer,
|
||||
scratch,
|
||||
scanLineBuffer,
|
||||
scopeBuffer.width,
|
||||
p,
|
||||
lineSamples,
|
||||
offset
|
||||
)
|
||||
); p += lineSamples
|
||||
}
|
||||
}
|
||||
val start = if (changed) 0 else lineLen.size - 1
|
||||
for (i in start until lineLen.size) copyLines(
|
||||
currentMode.decodeScanLine(
|
||||
pixelBuffer,
|
||||
scratch,
|
||||
scanLineBuffer,
|
||||
scopeBuffer.width,
|
||||
syncPulses[i],
|
||||
lineLen[i],
|
||||
offset
|
||||
)
|
||||
)
|
||||
lastSync = syncPulses.last(); curLineSamples = lineSamples; lastOffset = offset
|
||||
shift(lastSync + currentMode.firstPixelSampleIndex)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.sstv
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.round
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
internal class Complex(var real: Float = 0f, var imag: Float = 0f) {
|
||||
|
||||
fun set(real: Float, imag: Float): Complex {
|
||||
this.real = real; this.imag = imag; return this
|
||||
}
|
||||
|
||||
fun set(real: Float): Complex = set(real, 0f)
|
||||
|
||||
fun abs(): Float = sqrt(real * real + imag * imag)
|
||||
|
||||
fun mul(other: Complex): Complex {
|
||||
val tmp = real * other.real - imag * other.imag
|
||||
imag = real * other.imag + imag * other.real
|
||||
real = tmp
|
||||
return this
|
||||
}
|
||||
|
||||
fun div(value: Float): Complex {
|
||||
real /= value; imag /= value; return this
|
||||
}
|
||||
}
|
||||
|
||||
internal object WindowFunctions {
|
||||
|
||||
fun sinc(cutoff: Double, rate: Double, n: Int, nN: Int): Double {
|
||||
val f = 2 * cutoff / rate
|
||||
val x = n - (nN - 1) / 2.0
|
||||
val fx = f * x
|
||||
return if (fx == 0.0) f else f * sin(PI * fx) / (PI * fx)
|
||||
}
|
||||
|
||||
fun kaiser(a: Double, n: Int, nN: Int): Double {
|
||||
fun square(v: Double) = v * v
|
||||
fun i0(x: Double): Double {
|
||||
val terms = DoubleArray(35)
|
||||
terms[0] = 1.0
|
||||
var v = 1.0
|
||||
for (m in 1 until 35) {
|
||||
v *= x / (2 * m); terms[m] = square(v)
|
||||
}
|
||||
terms.sort()
|
||||
var sum = 0.0; for (m in 34 downTo 0) sum += terms[m]
|
||||
return sum
|
||||
}
|
||||
return i0(PI * a * sqrt(1 - square((2.0 * n) / (nN - 1) - 1))) / i0(PI * a)
|
||||
}
|
||||
}
|
||||
|
||||
// O(1) ring buffer — simpler and faster than a segment tree for the short window
|
||||
// lengths used here (≤512 samples). Float32 accumulated drift over such windows
|
||||
// is ~6e-5, negligible for audio-frequency processing.
|
||||
internal open class MovingSum(val length: Int) {
|
||||
private val buf = FloatArray(length)
|
||||
private var pos = 0
|
||||
private var runningSum = 0f
|
||||
|
||||
fun add(input: Float) {
|
||||
runningSum += input - buf[pos]
|
||||
buf[pos] = input
|
||||
if (++pos >= length) pos = 0
|
||||
}
|
||||
|
||||
fun sum(): Float = runningSum
|
||||
fun sum(input: Float): Float {
|
||||
add(input); return sum()
|
||||
}
|
||||
}
|
||||
|
||||
internal class MovingAverage(length: Int) : MovingSum(length) {
|
||||
fun avg(input: Float): Float = sum(input) / length
|
||||
}
|
||||
|
||||
internal class Ema {
|
||||
private var alpha: Float = 1f
|
||||
private var prev: Float = 0f
|
||||
|
||||
fun process(input: Float): Float {
|
||||
prev = prev * (1 - alpha) + alpha * input; return prev
|
||||
}
|
||||
|
||||
fun setCutoff(freq: Double, rate: Double, order: Int = 1) {
|
||||
alpha = computeAlpha(freq, rate, order)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
prev = 0f
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun computeAlpha(freq: Double, rate: Double, order: Int = 1): Float {
|
||||
val x = cos(2 * PI * (freq.coerceAtMost(rate * 0.499)) / rate)
|
||||
val discriminant = (x * (x - 4) + 3).coerceAtLeast(0.0)
|
||||
return (x - 1 + sqrt(discriminant)).coerceIn(0.0, 1.0).pow(1.0 / order).toFloat()
|
||||
}
|
||||
|
||||
fun withCutoff(freq: Double, rate: Double, order: Int = 1): Ema {
|
||||
return Ema().also { it.setCutoff(freq, rate, order) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class Phasor(freq: Double, rate: Double) {
|
||||
private val value = Complex(1f, 0f)
|
||||
private val delta: Complex = run {
|
||||
val omega = 2 * PI * freq / rate
|
||||
Complex(cos(omega).toFloat(), sin(omega).toFloat())
|
||||
}
|
||||
private var count = 0
|
||||
|
||||
// Renormalize every 512 rotations to prevent magnitude drift accumulation,
|
||||
// eliminating the per-sample sqrt without sacrificing demodulation accuracy.
|
||||
fun rotate(): Complex {
|
||||
value.mul(delta)
|
||||
if (++count == 512) { value.div(value.abs()); count = 0 }
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
|
||||
private val scale = (sampleRate / (bandwidth * PI)).toFloat()
|
||||
private val pi = PI.toFloat()
|
||||
private val twoPi = (2 * PI).toFloat()
|
||||
private var prev = 0f
|
||||
|
||||
fun demodulate(input: Complex): Float {
|
||||
// Use fast polynomial atan2 instead of the exact trigonometric call.
|
||||
// Max error ~0.005 rad translates to <1 Hz frequency error at 44100 Hz,
|
||||
// well within the 50 Hz sync tolerance.
|
||||
val phase = fastAtan2(input.imag, input.real)
|
||||
var delta = phase - prev; prev = phase
|
||||
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
|
||||
return scale * delta
|
||||
}
|
||||
|
||||
// Rajan's polynomial approximation of atan2 — avoids a transcendental call
|
||||
// in the per-sample hot path (~44 k calls/s at 44100 Hz sample rate).
|
||||
private fun fastAtan2(y: Float, x: Float): Float {
|
||||
val absY = kotlin.math.abs(y) + 1e-10f
|
||||
val r: Float
|
||||
val angle: Float
|
||||
if (x >= 0f) {
|
||||
r = (x - absY) / (x + absY)
|
||||
angle = 0.1963f * r * r * r - 0.9817f * r + pi / 4f
|
||||
} else {
|
||||
r = (x + absY) / (absY - x)
|
||||
angle = 0.1963f * r * r * r - 0.9817f * r + 3f * pi / 4f
|
||||
}
|
||||
return if (y < 0f) -angle else angle
|
||||
}
|
||||
}
|
||||
|
||||
internal class ComplexFirFilter(val length: Int) {
|
||||
private val real = FloatArray(length)
|
||||
private val imag = FloatArray(length)
|
||||
private val sum = Complex()
|
||||
private var pos = 0
|
||||
|
||||
val taps = FloatArray(length)
|
||||
|
||||
fun filter(input: Complex): Complex {
|
||||
real[pos] = input.real; imag[pos] = input.imag
|
||||
if (++pos >= length) pos = 0
|
||||
sum.real = 0f; sum.imag = 0f
|
||||
for (tap in taps) {
|
||||
sum.real += tap * real[pos]; sum.imag += tap * imag[pos]; if (++pos >= length) pos = 0
|
||||
}
|
||||
return sum
|
||||
}
|
||||
}
|
||||
|
||||
internal class Delay(val length: Int) {
|
||||
private val buf = FloatArray(length)
|
||||
private var pos = 0
|
||||
|
||||
fun push(input: Float): Float {
|
||||
val tmp = buf[pos]; buf[pos] = input; if (++pos >= length) pos = 0; return tmp
|
||||
}
|
||||
}
|
||||
|
||||
internal class SchmittTrigger(private val low: Float, private val high: Float) {
|
||||
private var state = false
|
||||
|
||||
fun process(input: Float): Boolean {
|
||||
if (state) {
|
||||
if (input < low) state = false
|
||||
} else {
|
||||
if (input > high) state = true
|
||||
}
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
internal object ColorConverter {
|
||||
|
||||
private fun clamp(v: Int) = v.coerceIn(0, 255)
|
||||
private fun toInt(level: Float) = clamp(round(255 * level).toInt())
|
||||
private fun compress(level: Float) = toInt(sqrt(level.coerceIn(0f, 1f)))
|
||||
|
||||
private fun yuv2rgb(yY: Int, uU: Int, vV: Int): Int {
|
||||
val y = yY - 16
|
||||
val u = uU - 128
|
||||
val v = vV - 128
|
||||
val r = clamp((298 * y + 409 * v + 128) shr 8)
|
||||
val g = clamp((298 * y - 100 * u - 208 * v + 128) shr 8)
|
||||
val b = clamp((298 * y + 516 * u + 128) shr 8)
|
||||
return 0xff000000.toInt() or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
|
||||
fun gray(level: Float): Int = 0xff000000.toInt() or (0x00010101 * compress(level))
|
||||
fun rgb(r: Float, g: Float, b: Float): Int = 0xff000000.toInt() or (toInt(r) shl 16) or (toInt(g) shl 8) or toInt(b)
|
||||
fun yuv2rgb(yY: Float, uU: Float, vV: Float): Int = yuv2rgb(toInt(yY), toInt(uU), toInt(vV))
|
||||
fun yuv2rgb(packed: Int): Int = yuv2rgb((packed shr 16) and 0xff, (packed shr 8) and 0xff, packed and 0xff)
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.sstv
|
||||
|
||||
import kotlin.math.round
|
||||
|
||||
internal class PixelBuffer(var width: Int, var height: Int) {
|
||||
var pixels = IntArray(width * height)
|
||||
var line = 0
|
||||
}
|
||||
|
||||
internal sealed interface SstvMode {
|
||||
val name: String
|
||||
val visCode: Int
|
||||
val width: Int
|
||||
val height: Int
|
||||
val firstPixelSampleIndex: Int
|
||||
val firstSyncPulseIndex: Int
|
||||
val scanLineSamples: Int
|
||||
|
||||
fun resetState() {}
|
||||
|
||||
fun decodeScanLine(
|
||||
pixelBuffer: PixelBuffer,
|
||||
scratch: FloatArray,
|
||||
scanLine: FloatArray,
|
||||
scopeWidth: Int,
|
||||
syncPulseIndex: Int,
|
||||
lineSamples: Int,
|
||||
freqOffset: Float
|
||||
): Boolean
|
||||
}
|
||||
|
||||
internal class RgbMode(
|
||||
override val name: String,
|
||||
override val visCode: Int,
|
||||
private val hPixels: Int,
|
||||
private val vPixels: Int,
|
||||
override val firstSyncPulseIndex: Int,
|
||||
override val scanLineSamples: Int,
|
||||
override val firstPixelSampleIndex: Int,
|
||||
private val redBegin: Int,
|
||||
private val redLen: Int,
|
||||
private val greenBegin: Int,
|
||||
private val greenLen: Int,
|
||||
private val blueBegin: Int,
|
||||
private val blueLen: Int,
|
||||
private val endSamples: Int,
|
||||
) : SstvMode {
|
||||
override val width get() = hPixels
|
||||
override val height get() = vPixels
|
||||
private val ema = Ema.withCutoff(hPixels.toDouble(), (2 * greenLen).toDouble(), 2)
|
||||
|
||||
override fun decodeScanLine(
|
||||
pixelBuffer: PixelBuffer,
|
||||
scratch: FloatArray,
|
||||
scanLine: FloatArray,
|
||||
scopeWidth: Int,
|
||||
syncPulseIndex: Int,
|
||||
lineSamples: Int,
|
||||
freqOffset: Float
|
||||
): Boolean {
|
||||
val begin = firstPixelSampleIndex
|
||||
if (syncPulseIndex + begin < 0 || syncPulseIndex + endSamples > scanLine.size) return false
|
||||
ema.reset()
|
||||
for (i in 0 until endSamples - begin) scratch[i] = ema.process(scanLine[syncPulseIndex + begin + i])
|
||||
ema.reset()
|
||||
for (i in endSamples - begin - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||
for (i in 0 until hPixels) {
|
||||
val r = redBegin + (i * redLen) / hPixels
|
||||
val g = greenBegin + (i * greenLen) / hPixels
|
||||
val b = blueBegin + (i * blueLen) / hPixels
|
||||
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[r], scratch[g], scratch[b])
|
||||
}
|
||||
pixelBuffer.width = hPixels; pixelBuffer.height = 1
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun martin(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
|
||||
val sync = 0.004862
|
||||
val sep = 0.000572
|
||||
val scanLine = sync + sep + 3 * (channelSec + sep)
|
||||
val gEnd = sep + channelSec
|
||||
val bBegin = gEnd + sep
|
||||
val bEnd = bBegin + channelSec
|
||||
val rBegin = bEnd + sep
|
||||
val rEnd = rBegin + channelSec
|
||||
return fromSeconds(
|
||||
name = "Martin $variant",
|
||||
code = code,
|
||||
firstSyncSec = 0.0,
|
||||
scanLineSec = scanLine,
|
||||
beginSec = sep,
|
||||
rBeginSec = rBegin,
|
||||
rEndSec = rEnd,
|
||||
gBeginSec = sep,
|
||||
gEndSec = gEnd,
|
||||
bBeginSec = bBegin,
|
||||
bEndSec = bEnd,
|
||||
endSec = rEnd,
|
||||
sr = sampleRate
|
||||
)
|
||||
}
|
||||
|
||||
fun scottie(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
|
||||
val sync = 0.009
|
||||
val sep = 0.0015
|
||||
val firstSync = sync + 2 * (sep + channelSec)
|
||||
val scanLine = sync + 3 * (channelSec + sep)
|
||||
val bEnd = -sync
|
||||
val bBegin = bEnd - channelSec
|
||||
val gEnd = bBegin - sep
|
||||
val gBegin = gEnd - channelSec
|
||||
val rEnd = sep + channelSec
|
||||
return fromSeconds(
|
||||
name = "Scottie $variant",
|
||||
code = code,
|
||||
firstSyncSec = firstSync,
|
||||
scanLineSec = scanLine,
|
||||
beginSec = gBegin,
|
||||
rBeginSec = sep,
|
||||
rEndSec = rEnd,
|
||||
gBeginSec = gBegin,
|
||||
gEndSec = gEnd,
|
||||
bBeginSec = bBegin,
|
||||
bEndSec = bEnd,
|
||||
endSec = rEnd,
|
||||
sr = sampleRate
|
||||
)
|
||||
}
|
||||
|
||||
fun wraaseSc2180(sampleRate: Int): RgbMode {
|
||||
val sync = 0.0055225
|
||||
val porch = 0.0005
|
||||
val ch = 0.235
|
||||
val scanLine = sync + porch + 3 * ch
|
||||
val rEnd = porch + ch
|
||||
val gEnd = rEnd + ch
|
||||
val bEnd = gEnd + ch
|
||||
return fromSeconds(
|
||||
name = "Wraase SC2-180",
|
||||
code = 55,
|
||||
firstSyncSec = 0.0,
|
||||
scanLineSec = scanLine,
|
||||
beginSec = porch,
|
||||
rBeginSec = porch,
|
||||
rEndSec = rEnd,
|
||||
gBeginSec = rEnd,
|
||||
gEndSec = gEnd,
|
||||
bBeginSec = gEnd,
|
||||
bEndSec = bEnd,
|
||||
endSec = bEnd,
|
||||
sr = sampleRate
|
||||
)
|
||||
}
|
||||
|
||||
private fun fromSeconds(
|
||||
name: String, code: Int, w: Int = 320, h: Int = 256,
|
||||
firstSyncSec: Double, scanLineSec: Double, beginSec: Double,
|
||||
rBeginSec: Double, rEndSec: Double, gBeginSec: Double, gEndSec: Double,
|
||||
bBeginSec: Double, bEndSec: Double, endSec: Double, sr: Int
|
||||
): RgbMode {
|
||||
val begin = round(beginSec * sr).toInt()
|
||||
return RgbMode(
|
||||
name = name, visCode = code, hPixels = w, vPixels = h,
|
||||
firstSyncPulseIndex = round(firstSyncSec * sr).toInt(),
|
||||
scanLineSamples = round(scanLineSec * sr).toInt(),
|
||||
firstPixelSampleIndex = begin,
|
||||
redBegin = round(rBeginSec * sr).toInt() - begin,
|
||||
redLen = round((rEndSec - rBeginSec) * sr).toInt(),
|
||||
greenBegin = round(gBeginSec * sr).toInt() - begin,
|
||||
greenLen = round((gEndSec - gBeginSec) * sr).toInt(),
|
||||
blueBegin = round(bBeginSec * sr).toInt() - begin,
|
||||
blueLen = round((bEndSec - bBeginSec) * sr).toInt(),
|
||||
endSamples = round(endSec * sr).toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class Robot36Mode(sampleRate: Int) : SstvMode {
|
||||
override val name = "Robot 36 Color"
|
||||
override val visCode = 8
|
||||
override val width = 320
|
||||
override val height = 240
|
||||
override val firstSyncPulseIndex = 0
|
||||
override val scanLineSamples: Int
|
||||
override val firstPixelSampleIndex: Int
|
||||
|
||||
private val lumSamples: Int
|
||||
private val sepSamples: Int
|
||||
private val chromSamples: Int
|
||||
private val lumBegin: Int
|
||||
private val sepBegin: Int
|
||||
private val chromBegin: Int
|
||||
private val end: Int
|
||||
private val ema: Ema
|
||||
private var lastEven = false
|
||||
|
||||
init {
|
||||
val syncPorch = 0.003
|
||||
val lum = 0.088
|
||||
val sep = 0.0045
|
||||
val porch = 0.0015
|
||||
val chrom = 0.044
|
||||
scanLineSamples = round((0.009 + syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
|
||||
lumSamples = round(lum * sampleRate).toInt(); sepSamples = round(sep * sampleRate).toInt()
|
||||
chromSamples = round(chrom * sampleRate).toInt()
|
||||
lumBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = lumBegin
|
||||
sepBegin = round((syncPorch + lum) * sampleRate).toInt()
|
||||
chromBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
|
||||
end = round((syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
|
||||
ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
|
||||
}
|
||||
|
||||
override fun resetState() {
|
||||
lastEven = false
|
||||
}
|
||||
|
||||
override fun decodeScanLine(
|
||||
pixelBuffer: PixelBuffer,
|
||||
scratch: FloatArray,
|
||||
scanLine: FloatArray,
|
||||
scopeWidth: Int,
|
||||
syncPulseIndex: Int,
|
||||
lineSamples: Int,
|
||||
freqOffset: Float
|
||||
): Boolean {
|
||||
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
|
||||
var sep = 0f
|
||||
for (i in 0 until sepSamples) sep += scanLine[syncPulseIndex + sepBegin + i]
|
||||
sep = sep / sepSamples - freqOffset
|
||||
var even = sep < 0
|
||||
if (sep < -1.1f || (sep > -0.9f && sep < 0.9f) || sep > 1.1f) even = !lastEven
|
||||
lastEven = even
|
||||
ema.reset()
|
||||
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||
ema.reset()
|
||||
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||
for (i in 0 until width) {
|
||||
val lPos = lumBegin + (i * lumSamples) / width
|
||||
val cPos = chromBegin + (i * chromSamples) / width
|
||||
if (even) {
|
||||
// Even line: store Y in the red channel slot and Cr in the blue slot,
|
||||
// using ColorConverter.rgb() as a convenient 3×byte packer (not RGB).
|
||||
// The odd line will read these back and combine with its own Cb to
|
||||
// produce the final YUV→RGB conversion for both rows.
|
||||
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
|
||||
} else {
|
||||
val evenYuv = pixelBuffer.pixels[i]
|
||||
// Even pixel packing: 0xAARRGGBB → Y=RR, Cb=GG(unused), Cr=BB
|
||||
// Odd pixel: Y=lPos, Cb=cPos, Cr=(borrowed from even's BB slot)
|
||||
// Merge: take Y+Cr from even row (bits 0x00ff00ff) and Cb from odd (0x0000ff00).
|
||||
val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
|
||||
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
|
||||
pixelBuffer.pixels[i + width] =
|
||||
ColorConverter.yuv2rgb((oddYuv and 0x00ffff00) or (evenYuv and 0x000000ff))
|
||||
}
|
||||
}
|
||||
pixelBuffer.width = width; pixelBuffer.height = 2
|
||||
return !even
|
||||
}
|
||||
}
|
||||
|
||||
internal class Robot72Mode(sampleRate: Int) : SstvMode {
|
||||
override val name = "Robot 72 Color"
|
||||
override val visCode = 12
|
||||
override val width = 320
|
||||
override val height = 240
|
||||
override val firstSyncPulseIndex = 0
|
||||
override val scanLineSamples: Int
|
||||
override val firstPixelSampleIndex: Int
|
||||
|
||||
private val lumSamples: Int
|
||||
private val chromSamples: Int
|
||||
private val yBegin: Int
|
||||
private val vBegin: Int
|
||||
private val uBegin: Int
|
||||
private val end: Int
|
||||
private val ema: Ema
|
||||
|
||||
init {
|
||||
val syncPorch = 0.003
|
||||
val lum = 0.138
|
||||
val sep = 0.0045
|
||||
val porch = 0.0015
|
||||
val chrom = 0.069
|
||||
scanLineSamples = round((0.009 + syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
|
||||
lumSamples = round(lum * sampleRate).toInt(); chromSamples = round(chrom * sampleRate).toInt()
|
||||
yBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yBegin
|
||||
vBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
|
||||
uBegin = round((syncPorch + lum + sep + porch + chrom + sep + porch) * sampleRate).toInt()
|
||||
end = round((syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
|
||||
ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
|
||||
}
|
||||
|
||||
override fun decodeScanLine(
|
||||
pixelBuffer: PixelBuffer,
|
||||
scratch: FloatArray,
|
||||
scanLine: FloatArray,
|
||||
scopeWidth: Int,
|
||||
syncPulseIndex: Int,
|
||||
lineSamples: Int,
|
||||
freqOffset: Float
|
||||
): Boolean {
|
||||
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
|
||||
ema.reset()
|
||||
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||
ema.reset()
|
||||
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||
for (i in 0 until width) {
|
||||
val yP = yBegin + (i * lumSamples) / width
|
||||
val uP = uBegin + (i * chromSamples) / width
|
||||
val vP = vBegin + (i * chromSamples) / width
|
||||
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb(scratch[yP], scratch[uP], scratch[vP])
|
||||
}
|
||||
pixelBuffer.width = width; pixelBuffer.height = 1
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
internal class PdMode(
|
||||
variant: String,
|
||||
override val visCode: Int,
|
||||
private val hPixels: Int,
|
||||
private val vPixels: Int,
|
||||
channelSec: Double,
|
||||
sampleRate: Int
|
||||
) : SstvMode {
|
||||
override val name = "PD $variant"
|
||||
override val width get() = hPixels
|
||||
override val height get() = vPixels
|
||||
override val firstSyncPulseIndex = 0
|
||||
override val scanLineSamples: Int
|
||||
override val firstPixelSampleIndex: Int
|
||||
|
||||
private val chSamples: Int
|
||||
private val yEvenBegin: Int
|
||||
private val vAvgBegin: Int
|
||||
private val uAvgBegin: Int
|
||||
private val yOddBegin: Int
|
||||
private val end: Int
|
||||
private val ema: Ema
|
||||
|
||||
init {
|
||||
val syncPorch = 0.00208
|
||||
scanLineSamples = round((0.02 + syncPorch + 4 * channelSec) * sampleRate).toInt()
|
||||
chSamples = round(channelSec * sampleRate).toInt()
|
||||
yEvenBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yEvenBegin
|
||||
vAvgBegin = round((syncPorch + channelSec) * sampleRate).toInt()
|
||||
uAvgBegin = round((syncPorch + 2 * channelSec) * sampleRate).toInt()
|
||||
yOddBegin = round((syncPorch + 3 * channelSec) * sampleRate).toInt()
|
||||
end = round((syncPorch + 4 * channelSec) * sampleRate).toInt()
|
||||
ema = Ema.withCutoff(hPixels.toDouble(), (2 * chSamples).toDouble(), 2)
|
||||
}
|
||||
|
||||
override fun decodeScanLine(
|
||||
pixelBuffer: PixelBuffer,
|
||||
scratch: FloatArray,
|
||||
scanLine: FloatArray,
|
||||
scopeWidth: Int,
|
||||
syncPulseIndex: Int,
|
||||
lineSamples: Int,
|
||||
freqOffset: Float
|
||||
): Boolean {
|
||||
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
|
||||
ema.reset()
|
||||
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||
ema.reset()
|
||||
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||
for (i in 0 until hPixels) {
|
||||
val pos = (i * chSamples) / hPixels
|
||||
pixelBuffer.pixels[i] =
|
||||
ColorConverter.yuv2rgb(scratch[pos + yEvenBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
|
||||
pixelBuffer.pixels[i + hPixels] =
|
||||
ColorConverter.yuv2rgb(scratch[pos + yOddBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
|
||||
}
|
||||
pixelBuffer.width = hPixels; pixelBuffer.height = 2
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
|
||||
override val visCode = -1
|
||||
override val width = -1
|
||||
override val height = -1
|
||||
override val firstPixelSampleIndex = 0
|
||||
override val firstSyncPulseIndex = -1
|
||||
override val scanLineSamples = -1
|
||||
|
||||
private val smallMax = round(0.125 * sampleRate).toInt()
|
||||
private val medMax = round(0.175 * sampleRate).toInt()
|
||||
private val ema = Ema()
|
||||
|
||||
override fun decodeScanLine(
|
||||
pixelBuffer: PixelBuffer,
|
||||
scratch: FloatArray,
|
||||
scanLine: FloatArray,
|
||||
scopeWidth: Int,
|
||||
syncPulseIndex: Int,
|
||||
lineSamples: Int,
|
||||
freqOffset: Float
|
||||
): Boolean {
|
||||
if (syncPulseIndex < 0 || syncPulseIndex + lineSamples > scanLine.size) return false
|
||||
var px = scopeWidth
|
||||
if (lineSamples < smallMax) px /= 2
|
||||
if (lineSamples < medMax) px /= 2
|
||||
ema.setCutoff(px.toDouble(), (2 * lineSamples).toDouble(), 2); ema.reset()
|
||||
for (i in 0 until lineSamples) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||
ema.reset()
|
||||
for (i in lineSamples - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||
for (i in 0 until px) pixelBuffer.pixels[i] = ColorConverter.gray(scratch[(i * lineSamples) / px])
|
||||
pixelBuffer.width = px; pixelBuffer.height = 1
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun freqToLevel(frequency: Float, offset: Float): Float = 0.5f * (frequency - offset + 1f)
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.usecase
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Platform abstraction for microphone audio capture.
|
||||
* Produces a flow of mono Float PCM buffers at the configured sample rate.
|
||||
*/
|
||||
interface IAudioCapture {
|
||||
val sampleRate: Int
|
||||
|
||||
/**
|
||||
* Start capturing audio. Emits FloatArray buffers continuously until the flow is canceled.
|
||||
* Caller is responsible for holding RECORD_AUDIO permission before calling this.
|
||||
*/
|
||||
fun audioFlow(): Flow<FloatArray>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.usecase
|
||||
|
||||
/**
|
||||
* Saves a decoded SSTV image to the device gallery.
|
||||
* @return true if the image was saved successfully
|
||||
*/
|
||||
interface ISaveImage {
|
||||
suspend operator fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean
|
||||
}
|
||||
+69
-109
@@ -21,134 +21,94 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import java.io.InputStream
|
||||
import kotlin.math.pow
|
||||
|
||||
class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
val parsedItems = mutableListOf<OrbitalData>()
|
||||
stream.bufferedReader().useLines { lines ->
|
||||
lines.forEachIndexed { index, line ->
|
||||
if (index != 0) {
|
||||
val values = line.split(",")
|
||||
parseCSV(values)?.let { tle -> parsedItems.add(tle) }
|
||||
}
|
||||
}
|
||||
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
||||
}
|
||||
return@withContext parsedItems
|
||||
}
|
||||
|
||||
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
val tleStrings = mutableListOf(String(), String(), String())
|
||||
val parsedItems = mutableListOf<OrbitalData>()
|
||||
var lineIndex = 0
|
||||
stream.bufferedReader().forEachLine { line ->
|
||||
tleStrings[lineIndex] = line
|
||||
if (lineIndex < 2) {
|
||||
lineIndex++
|
||||
} else {
|
||||
val isLineOneValid = tleStrings[1].substring(0, 1) == "1"
|
||||
val isLineTwoValid = tleStrings[2].substring(0, 1) == "2"
|
||||
if (!isLineOneValid && !isLineTwoValid) return@forEachLine
|
||||
parseTLE(tleStrings)?.let { tle -> parsedItems.add(tle) }
|
||||
lineIndex = 0
|
||||
}
|
||||
}
|
||||
return@withContext parsedItems
|
||||
stream.bufferedReader().readLines()
|
||||
.chunked(3)
|
||||
.filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") }
|
||||
.mapNotNull { parseTLE(it) }
|
||||
}
|
||||
|
||||
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
|
||||
val parsedItems = mutableListOf<SatRadio>()
|
||||
try {
|
||||
val jsonArray = JSONArray(stream.bufferedReader().readText())
|
||||
for (index in 0 until jsonArray.length()) {
|
||||
val jsonObject = jsonArray.getJSONObject(index)
|
||||
parseJSON(jsonObject)?.let { parsedItems.add(it) }
|
||||
}
|
||||
return@withContext parsedItems
|
||||
} catch (_: Exception) {
|
||||
return@withContext parsedItems
|
||||
}
|
||||
runCatching {
|
||||
val root = json.parseToJsonElement(stream.bufferedReader().readText())
|
||||
(root as? JsonArray)?.mapNotNull { element ->
|
||||
runCatching { json.decodeFromJsonElement<SatRadio>(element) }
|
||||
.onFailure { println("JSON parsing exception: $it") }
|
||||
.getOrNull()
|
||||
} ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun isLeapYear(year: Int): Boolean {
|
||||
return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)
|
||||
}
|
||||
|
||||
private fun parseCSV(values: List<String>): OrbitalData? = try {
|
||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||
val name = values[0]
|
||||
val year = values[2].substring(0, 4)
|
||||
val month = values[2].substring(5, 7)
|
||||
val dayOfMonth = values[2].substring(8, 10)
|
||||
val dayInt = getDayOfYear(year.toInt(), month.toInt(), dayOfMonth.toInt())
|
||||
val day = if (dayInt < 10) "00$dayInt" else if (dayInt < 100) "0$dayInt" else "$dayInt"
|
||||
val hour = values[2].substring(11, 13).toInt() * 3600000 // ms in one hour
|
||||
val min = values[2].substring(14, 16).toInt() * 60000 // ms in one minute
|
||||
val sec = values[2].substring(17, 19).toInt() * 1000 // ms in one second
|
||||
val ms = values[2].substring(20, 26).toInt() / 1000.0 // microseconds to ms
|
||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString()
|
||||
val epoch = "${year.substring(2)}$day${frac.substring(1)}".toDouble()
|
||||
val meanmo = values[3].toDouble()
|
||||
val eccn = values[4].toDouble()
|
||||
val incl = values[5].toDouble()
|
||||
val raan = values[6].toDouble()
|
||||
val argper = values[7].toDouble()
|
||||
val meanan = values[8].toDouble()
|
||||
val catnum = values[11].toInt()
|
||||
val bstar = values[14].toDouble()
|
||||
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
|
||||
} catch (exception: Exception) {
|
||||
println("CSV parsing exception: $exception")
|
||||
null
|
||||
}
|
||||
val timestamp = values[2]
|
||||
val year = timestamp.substring(0, 4)
|
||||
val month = timestamp.substring(5, 7).toInt()
|
||||
val dayOfMonth = timestamp.substring(8, 10).toInt()
|
||||
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
|
||||
val day = dayInt.toString().padStart(3, '0')
|
||||
val hour = timestamp.substring(11, 13).toInt() * 3600000
|
||||
val min = timestamp.substring(14, 16).toInt() * 60000
|
||||
val sec = timestamp.substring(17, 19).toInt() * 1000
|
||||
val ms = timestamp.substring(20, 26).toInt() / 1000.0
|
||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
|
||||
val epoch = "${year.substring(2)}$day$frac".toDouble()
|
||||
OrbitalData(
|
||||
name = name,
|
||||
epoch = epoch,
|
||||
meanmo = values[3].toDouble(),
|
||||
eccn = values[4].toDouble(),
|
||||
incl = values[5].toDouble(),
|
||||
raan = values[6].toDouble(),
|
||||
argper = values[7].toDouble(),
|
||||
meanan = values[8].toDouble(),
|
||||
catnum = values[11].toInt(),
|
||||
bstar = values[14].toDouble(),
|
||||
ndot = values[15].toDouble()
|
||||
)
|
||||
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||
|
||||
private fun parseTLE(tle: List<String>): OrbitalData? = try {
|
||||
val name: String = tle[0].trim()
|
||||
val epoch: Double = tle[1].substring(18, 32).toDouble()
|
||||
val meanmo: Double = tle[2].substring(52, 63).toDouble()
|
||||
val eccn: Double = tle[2].substring(26, 33).toDouble() / 10000000.0
|
||||
val incl: Double = tle[2].substring(8, 16).toDouble()
|
||||
val raan: Double = tle[2].substring(17, 25).toDouble()
|
||||
val argper: Double = tle[2].substring(34, 42).toDouble()
|
||||
val meanan: Double = tle[2].substring(43, 51).toDouble()
|
||||
val catnum: Int = tle[1].substring(2, 7).trim().toInt()
|
||||
val bstar: Double = 1.0e-5 * tle[1].substring(53, 59).toDouble() / 10.0.pow(tle[1].substring(60, 61).toDouble())
|
||||
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
|
||||
} catch (exception: Exception) {
|
||||
println("TLE parsing exception: $exception")
|
||||
null
|
||||
}
|
||||
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
|
||||
val line1 = tle[1]
|
||||
val line2 = tle[2]
|
||||
OrbitalData(
|
||||
name = tle[0].trim().removePrefix("0 "),
|
||||
epoch = line1.substring(18, 32).toDouble(),
|
||||
meanmo = line2.substring(52, 63).toDouble(),
|
||||
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
||||
incl = line2.substring(8, 16).toDouble(),
|
||||
raan = line2.substring(17, 25).toDouble(),
|
||||
argper = line2.substring(34, 42).toDouble(),
|
||||
meanan = line2.substring(43, 51).toDouble(),
|
||||
catnum = line1.substring(2, 7).trim().toInt(),
|
||||
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
||||
ndot = line1.substring(33, 43).trim().toDouble()
|
||||
)
|
||||
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
||||
|
||||
private fun parseJSON(json: JSONObject): SatRadio? = try {
|
||||
val uuid = json.getString("uuid")
|
||||
val info = json.getString("description")
|
||||
val alive = json.getBoolean("alive")
|
||||
val dlinkLow = if (json.isNull("downlink_low")) null else json.getLong("downlink_low")
|
||||
val dlinkHigh = if (json.isNull("downlink_high")) null else json.getLong("downlink_high")
|
||||
val dlinkMode = if (json.isNull("mode")) null else json.getString("mode")
|
||||
val ulinkLow = if (json.isNull("uplink_low")) null else json.getLong("uplink_low")
|
||||
val ulinkHigh = if (json.isNull("uplink_high")) null else json.getLong("uplink_high")
|
||||
val ulinkMode = if (json.isNull("uplink_mode")) null else json.getString("uplink_mode")
|
||||
val inverted = json.getBoolean("invert")
|
||||
val catnum = if (json.isNull("norad_cat_id")) null else json.getInt("norad_cat_id")
|
||||
SatRadio(uuid, info, alive, dlinkLow, dlinkHigh, dlinkMode, ulinkLow, ulinkHigh, ulinkMode, inverted, catnum)
|
||||
} catch (exception: Exception) {
|
||||
println("JSON parsing exception: $exception")
|
||||
null
|
||||
}
|
||||
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
|
||||
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||
if (month == 1) return dayOfMonth
|
||||
val daysArray = arrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||
var dayOfYear = dayOfMonth
|
||||
// If leap year increment Feb days
|
||||
if (isLeapYear(year)) daysArray[1]++
|
||||
for (i in 0 until month - 1) {
|
||||
dayOfYear += daysArray[i]
|
||||
}
|
||||
return dayOfYear
|
||||
fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||
*
|
||||
* For a linear (passband) transponder, uplink and downlink frequencies are
|
||||
* related by a fixed passband offset. When the satellite moves, both are
|
||||
* Doppler-shifted. Given one, this computes the other:
|
||||
*
|
||||
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
||||
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
||||
*/
|
||||
object DopplerFrequencyCalculator {
|
||||
|
||||
/**
|
||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
*/
|
||||
fun computeUplinkFromDownlink(
|
||||
downlinkHz: Long,
|
||||
transponder: SatRadio,
|
||||
orbitalPos: OrbitalPos
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
||||
return orbitalPos.getUplinkFreq(baseUplink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||
* with an offset applied to the downlink (in Hz).
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
*
|
||||
* The user-entered downlink frequency already includes the offset, so subtract
|
||||
* it before mapping the downlink passband position back to the uplink.
|
||||
*/
|
||||
fun computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz: Long,
|
||||
transponder: SatRadio,
|
||||
orbitalPos: OrbitalPos,
|
||||
offsetHz: Long
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||
return orbitalPos.getUplinkFreq(baseUplink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
*/
|
||||
fun computeDownlinkFromUplink(
|
||||
uplinkHz: Long,
|
||||
transponder: SatRadio,
|
||||
orbitalPos: OrbitalPos
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||
return orbitalPos.getDownlinkFreq(baseDownlink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
|
||||
* with an offset applied to the downlink (in Hz).
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
*/
|
||||
fun computeDownlinkFromUplinkWithOffset(
|
||||
uplinkHz: Long,
|
||||
transponder: SatRadio,
|
||||
orbitalPos: OrbitalPos,
|
||||
offsetHz: Long
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
||||
}
|
||||
|
||||
/** True if this transponder supports linear passband mapping. */
|
||||
fun isLinearTransponder(transponder: SatRadio): Boolean {
|
||||
val upLow = transponder.uplinkLow
|
||||
val upHigh = transponder.uplinkHigh
|
||||
val downLow = transponder.downlinkLow
|
||||
val downHigh = transponder.downlinkHigh
|
||||
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||
&& upLow != upHigh && downLow != downHigh
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the radio entry that should drive the standalone Calculator page.
|
||||
*
|
||||
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||
* can also have low/high frequencies. The calculator is meant for named linear
|
||||
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||
*/
|
||||
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||
if (!isLinearTransponder(transponder)) return false
|
||||
|
||||
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||
.joinToString(separator = " ")
|
||||
.lowercase(Locale.ENGLISH)
|
||||
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||
info.contains("xponder") || info.contains("xpdr")
|
||||
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||
|
||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||
(hasLinearName && hasLinearMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate transponder entries that describe the same physical
|
||||
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
||||
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
||||
* "CW Transponder" and "SSB Transponder").
|
||||
*
|
||||
* Entries sharing the same uplink/downlink frequency range are considered
|
||||
* the same transponder. The non-CW entry is preferred because its invert
|
||||
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
||||
*/
|
||||
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
||||
return radios.groupBy { radio ->
|
||||
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
||||
}.values.map { group ->
|
||||
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,11 @@ import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun Long.toTimerString(): String {
|
||||
val millis = coerceAtLeast(0L)
|
||||
val format = "%02d:%02d:%02d"
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(this)
|
||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(this) % 60
|
||||
val seconds = TimeUnit.MILLISECONDS.toSeconds(this) % 60
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(millis)
|
||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
|
||||
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
|
||||
return String.format(Locale.ENGLISH, format, hours, minutes, seconds)
|
||||
}
|
||||
|
||||
@@ -40,6 +41,21 @@ fun Double.round(decimals: Int): Double {
|
||||
return kotlin.math.round(this * multiplier) / multiplier
|
||||
}
|
||||
|
||||
fun String.aprsPasscode(): Int {
|
||||
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
|
||||
var hash = 0x73E2
|
||||
var i = 0
|
||||
while (i < callsign.length) {
|
||||
hash = hash xor (callsign[i].code shl 8)
|
||||
i++
|
||||
if (i < callsign.length) {
|
||||
hash = hash xor callsign[i].code
|
||||
i++
|
||||
}
|
||||
}
|
||||
return hash and 0x7FFF
|
||||
}
|
||||
|
||||
//fun String.getHash(type: String = "SHA-256"): String {
|
||||
// val hexChars = "0123456789ABCDEF"
|
||||
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
||||
|
||||
@@ -17,11 +17,18 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||
import kotlin.math.acos
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
private const val AVG_EARTH_RADIUS_KM = 6371.009
|
||||
private const val MIN_LATITUDE = -85.05112877980658
|
||||
private const val MAX_LATITUDE = 85.05112877980658
|
||||
private const val MIN_LONGITUDE = -180.0
|
||||
@@ -48,6 +55,27 @@ fun Double.toRadians(): Double = this * DEG2RAD
|
||||
// return MIN_LONGITUDE + (MAX_LONGITUDE - MIN_LONGITUDE) * this
|
||||
//}
|
||||
|
||||
// Great-circle distance between two positions in kilometers using the spherical law of cosines.
|
||||
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val lat1R = lat1.toRadians()
|
||||
val lat2R = lat2.toRadians()
|
||||
val lon1R = lon1.toRadians()
|
||||
val lon2R = lon2.toRadians()
|
||||
return acos(
|
||||
sin(lat1R) * sin(lat2R) + cos(lat1R) * cos(lat2R) * cos(lon2R - lon1R)
|
||||
) * AVG_EARTH_RADIUS_KM
|
||||
}
|
||||
|
||||
// Initial bearing (azimuth) from position 1 to position 2, in degrees (0-360).
|
||||
fun bearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val lat1R = lat1.toRadians()
|
||||
val lat2R = lat2.toRadians()
|
||||
val dLon = (lon2 - lon1).toRadians()
|
||||
val y = sin(dLon) * cos(lat2R)
|
||||
val x = cos(lat1R) * sin(lat2R) - sin(lat1R) * cos(lat2R) * cos(dLon)
|
||||
return (atan2(y, x).toDegrees() + 360) % 360
|
||||
}
|
||||
|
||||
fun clipLat(latitude: Double): Double {
|
||||
return clip(latitude, MIN_LATITUDE, MAX_LATITUDE)
|
||||
}
|
||||
@@ -59,6 +87,13 @@ fun clipLon(longitude: Double): Double {
|
||||
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
||||
}
|
||||
|
||||
fun OrbitalPos.toMapGeoPos(): GeoPos {
|
||||
return GeoPos(
|
||||
latitude = clipLat(latitude.toDegrees()),
|
||||
longitude = clipLon(longitude.toDegrees())
|
||||
)
|
||||
}
|
||||
|
||||
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
|
||||
return min(max(currentValue, minValue), maxValue)
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
|
||||
object TransponderMapper {
|
||||
|
||||
fun mapUplinkToDownlink(txFreqHz: Long, transponder: SatRadio): Long? {
|
||||
val uplinkLow = transponder.uplinkLow ?: return null
|
||||
val downlinkLow = transponder.downlinkLow ?: return null
|
||||
|
||||
// Single-frequency transponder (FM repeater) - just return the downlink freq
|
||||
val uplinkHigh = transponder.uplinkHigh
|
||||
val downlinkHigh = transponder.downlinkHigh
|
||||
if (uplinkHigh == null || downlinkHigh == null
|
||||
|| uplinkLow == uplinkHigh || downlinkLow == downlinkHigh
|
||||
) {
|
||||
return downlinkLow
|
||||
}
|
||||
|
||||
// Passband transponder (linear) - map within the band
|
||||
val offset = txFreqHz - uplinkLow
|
||||
return if (transponder.isInverted) {
|
||||
downlinkHigh - offset
|
||||
} else {
|
||||
downlinkLow + offset
|
||||
}
|
||||
}
|
||||
|
||||
fun mapDownlinkToUplink(rxFreqHz: Long, transponder: SatRadio): Long? {
|
||||
val uplinkLow = transponder.uplinkLow ?: return null
|
||||
val downlinkLow = transponder.downlinkLow ?: return null
|
||||
|
||||
val uplinkHigh = transponder.uplinkHigh
|
||||
val downlinkHigh = transponder.downlinkHigh
|
||||
if (uplinkHigh == null || downlinkHigh == null
|
||||
|| uplinkLow == uplinkHigh || downlinkLow == downlinkHigh
|
||||
) {
|
||||
return uplinkLow
|
||||
}
|
||||
|
||||
return if (transponder.isInverted) {
|
||||
uplinkLow + (downlinkHigh - rxFreqHz)
|
||||
} else {
|
||||
uplinkLow + (rxFreqHz - downlinkLow)
|
||||
}
|
||||
}
|
||||
|
||||
fun mapUplinkModeToDownlinkMode(txMode: String, isInverted: Boolean): String {
|
||||
if (!isInverted) return txMode
|
||||
return when (txMode.uppercase()) {
|
||||
"USB" -> "LSB"
|
||||
"LSB" -> "USB"
|
||||
else -> txMode
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -59,10 +60,53 @@ class DataParserTest {
|
||||
@Test
|
||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||
assert(parsedList.size == 2)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
assert(parsedList[1].epoch == 24069.23963816)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid CSV stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
|
||||
val csvStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||
assert(sat.name == "ISS (ZARYA)")
|
||||
assert(sat.catnum == 25544)
|
||||
assert(sat.meanmo == 15.48582035)
|
||||
assert(sat.eccn == 0.0004694)
|
||||
assert(sat.incl == 51.6447)
|
||||
assert(sat.raan == 309.4881)
|
||||
assert(sat.argper == 203.6966)
|
||||
assert(sat.meanan == 299.8876)
|
||||
assert(sat.bstar == 0.31985E-4)
|
||||
assert(sat.ndot == 0.1288E-4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid CSV stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
|
||||
val csvStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||
// ISS is healthy, should not be decayed even years later
|
||||
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV with high drag satellite detects decay`() = runTest(testDispatcher) {
|
||||
// Simulate a satellite with high drag and old epoch that should have decayed
|
||||
val csvStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
DEBRIS,2020-001A,2020-01-15T00:00:00.000000,15.9,.001,51.0,100.0,200.0,300.0,0,U,99999,1,100,.5E-3,.05,0
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||
// High mean motion (15.9) + high drag (.05) + old epoch → should be decayed by now
|
||||
assert(sat.hasDecayed(System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
||||
@@ -71,10 +115,41 @@ class DataParserTest {
|
||||
@Test
|
||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||
assert(parsedList.size == 2)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
assert(parsedList[1].epoch == 24069.23963816)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid TLE stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
|
||||
val tleStream = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||
assert(sat.name == "ISS (ZARYA)")
|
||||
assert(sat.catnum == 25544)
|
||||
assert(sat.meanmo == 15.48582035)
|
||||
assert(sat.eccn == 0.0004694)
|
||||
assert(sat.incl == 51.6447)
|
||||
assert(sat.raan == 309.4881)
|
||||
assert(sat.argper == 203.6966)
|
||||
assert(sat.meanan == 299.8876)
|
||||
assert(sat.ndot == 0.00001288)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid TLE stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
|
||||
val tleStream = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
|
||||
@@ -85,6 +160,43 @@ class DataParserTest {
|
||||
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid JSON stream all radio fields are parsed correctly`() = runTest(testDispatcher) {
|
||||
val jsonStream = """
|
||||
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":145900000,"uplink_high":146000000,"uplink_drift":null,"downlink_low":136658500,"downlink_high":136700000,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":"FM","invert":true,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED","service":"Unknown","coordination":"","coordination_url":""}]
|
||||
""".trimIndent().byteInputStream()
|
||||
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||
assert(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
|
||||
assert(radio.info == "Upper side band (drifting)")
|
||||
assert(radio.isAlive)
|
||||
assert(radio.downlinkLow == 136658500L)
|
||||
assert(radio.downlinkHigh == 136700000L)
|
||||
assert(radio.downlinkMode == "USB")
|
||||
assert(radio.uplinkLow == 145900000L)
|
||||
assert(radio.uplinkHigh == 146000000L)
|
||||
assert(radio.uplinkMode == "FM")
|
||||
assert(radio.isInverted)
|
||||
assert(radio.catnum == 965)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given JSON with null optional fields parses without error`() = runTest(testDispatcher) {
|
||||
val jsonStream = """
|
||||
[{"uuid":"abc123","description":"Beacon","alive":false,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":145800000,"downlink_high":null,"downlink_drift":null,"mode":null,"mode_id":null,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"TEST","norad_cat_id":12345,"status":"active","updated":"2024-01-01T00:00:00Z","citation":"","service":"Unknown","coordination":"","coordination_url":""}]
|
||||
""".trimIndent().byteInputStream()
|
||||
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||
assert(radio.uuid == "abc123")
|
||||
assert(!radio.isAlive)
|
||||
assert(radio.downlinkLow == 145800000L)
|
||||
assert(radio.downlinkHigh == null)
|
||||
assert(radio.downlinkMode == null)
|
||||
assert(radio.uplinkLow == null)
|
||||
assert(radio.uplinkHigh == null)
|
||||
assert(radio.uplinkMode == null)
|
||||
assert(!radio.isInverted)
|
||||
assert(radio.catnum == 12345)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
|
||||
@@ -96,10 +208,42 @@ class DataParserTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
|
||||
val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
|
||||
val answers = listOf(false, true, false, true, false, true, false, true)
|
||||
fun `isLeapYear returns correct results`() {
|
||||
val years = listOf(1900, 1984, 1994, 2000, 2016, 2022, 2024, 2042, 2048, 2100)
|
||||
val expected = listOf(false, true, false, true, true, false, true, false, true, false)
|
||||
val results = years.map { dataParser.isLeapYear(it) }
|
||||
assert(results == answers)
|
||||
assert(results == expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for January 1st`() {
|
||||
assert(dataParser.getDayOfYear(2024, 1, 1) == 1)
|
||||
assert(dataParser.getDayOfYear(2023, 1, 1) == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for March 1st in leap and non-leap years`() {
|
||||
// 2024 is leap: Jan(31) + Feb(29) + 1 = 61
|
||||
assert(dataParser.getDayOfYear(2024, 3, 1) == 61)
|
||||
// 2023 is not leap: Jan(31) + Feb(28) + 1 = 60
|
||||
assert(dataParser.getDayOfYear(2023, 3, 1) == 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for December 31st`() {
|
||||
assert(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
|
||||
assert(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for November 16th`() {
|
||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check APRS passcode calculation`() {
|
||||
assert("M7LNB".aprsPasscode() == 12443)
|
||||
assert("N0CALL".aprsPasscode() == 13023)
|
||||
}
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DopplerFrequencyCalculatorTest {
|
||||
|
||||
private fun linearTransponder(
|
||||
uuid: String = "linear",
|
||||
upLow: Long = 145_000_000L,
|
||||
upHigh: Long = 145_500_000L,
|
||||
downLow: Long = 435_000_000L,
|
||||
downHigh: Long? = 435_500_000L,
|
||||
inverted: Boolean = false,
|
||||
info: String = "Linear Transponder",
|
||||
downlinkMode: String? = "USB",
|
||||
uplinkMode: String? = "LSB"
|
||||
) = SatRadio(
|
||||
uuid = uuid, info = info, isAlive = true,
|
||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||
)
|
||||
|
||||
private fun fmTransponder() = SatRadio(
|
||||
uuid = "fm", info = "FM Repeater", isAlive = true,
|
||||
downlinkLow = 435_600_000L, downlinkHigh = null,
|
||||
downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null,
|
||||
uplinkMode = "FM", isInverted = false, catnum = 99999
|
||||
)
|
||||
|
||||
private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply {
|
||||
this.distanceRate = distanceRateKmS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isLinearTransponder_returnsTrueForLinear() {
|
||||
assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isLinearTransponder_returnsFalseForFM() {
|
||||
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isLinearTransponder_returnsFalseForNullDownlinkHigh() {
|
||||
val xpdr = linearTransponder(downHigh = null)
|
||||
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
||||
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
||||
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
|
||||
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
|
||||
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
|
||||
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
|
||||
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
|
||||
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
|
||||
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
|
||||
// "Downlink" contains "lin" but is not a linear-transponder name
|
||||
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_mergesSameFrequencyRange() {
|
||||
// AO-7's Mode A: same range, SSB and CW entries
|
||||
val ssb = linearTransponder(
|
||||
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
|
||||
downlinkMode = "USB", uplinkMode = "USB"
|
||||
)
|
||||
val cw = linearTransponder(
|
||||
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
|
||||
downlinkMode = "CW", uplinkMode = "CW"
|
||||
)
|
||||
val modeB = linearTransponder(
|
||||
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
|
||||
upLow = 432_125_000L, upHigh = 432_175_000L,
|
||||
downLow = 145_925_000L, downHigh = 145_975_000L,
|
||||
downlinkMode = "USB", uplinkMode = "LSB"
|
||||
)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
|
||||
assertEquals(2, result.size)
|
||||
// SSB entry should be preferred over CW (same range)
|
||||
assertEquals("ssb-uuid", result[0].uuid)
|
||||
assertEquals("modeb-uuid", result[1].uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_prefersNonCwEntry() {
|
||||
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
|
||||
val cw = linearTransponder(
|
||||
uuid = "cw-uuid", info = "U/V CW Transponder",
|
||||
downlinkMode = "CW", uplinkMode = "CW",
|
||||
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||
downLow = 145_855_000L, downHigh = 145_875_000L
|
||||
)
|
||||
val ssb = linearTransponder(
|
||||
uuid = "ssb-uuid", info = "U/V SSB Transponder",
|
||||
downlinkMode = "USB", uplinkMode = "LSB",
|
||||
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||
downLow = 145_855_000L, downHigh = 145_875_000L,
|
||||
inverted = true
|
||||
)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("ssb-uuid", result[0].uuid)
|
||||
// Verify the correct invert flag is preserved
|
||||
assertTrue(result[0].isInverted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_preservesUniqueEntries() {
|
||||
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
|
||||
downLow = 435_000_000L, downHigh = 435_500_000L)
|
||||
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
|
||||
downLow = 145_000_000L, downHigh = 145_500_000L)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
assertEquals(145_200_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeDownlinkFromUplink_linear_noDoppler() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(downlink)
|
||||
assertEquals(435_200_000L, downlink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
||||
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(7.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
assertTrue(uplink!! > 145_200_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
|
||||
val orbitalPos = pos()
|
||||
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
|
||||
val orbitalPos = pos()
|
||||
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
uplinkHz = 145_200_000L,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = 2_500L
|
||||
)
|
||||
assertEquals(435_202_500L, downlink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz = 435_202_500L,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = 2_500L
|
||||
)
|
||||
assertEquals(145_200_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeOffsetRoundTrip_handlesNegativeOffset() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
uplinkHz = 145_200_000L,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = -2_500L
|
||||
)
|
||||
assertEquals(435_197_500L, downlink)
|
||||
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz = downlink!!,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = -2_500L
|
||||
)
|
||||
assertEquals(145_200_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||
val orbitalPos = pos(0.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
assertEquals(145_300_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_roundTrip() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(3.5)
|
||||
val originalDownlink = 435_250_000L
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||
assertNotNull(roundTripDownlink)
|
||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class TransponderMapperTest {
|
||||
|
||||
private fun makeRadio(
|
||||
uplinkLow: Long? = 435000000L,
|
||||
uplinkHigh: Long? = 435100000L,
|
||||
downlinkLow: Long? = 145800000L,
|
||||
downlinkHigh: Long? = 145900000L,
|
||||
isInverted: Boolean = true
|
||||
) = SatRadio(
|
||||
uuid = "test",
|
||||
info = "Test",
|
||||
isAlive = true,
|
||||
downlinkLow = downlinkLow,
|
||||
downlinkHigh = downlinkHigh,
|
||||
downlinkMode = "USB",
|
||||
uplinkLow = uplinkLow,
|
||||
uplinkHigh = uplinkHigh,
|
||||
uplinkMode = "USB",
|
||||
isInverted = isInverted,
|
||||
catnum = 0
|
||||
)
|
||||
|
||||
@Test
|
||||
fun invertedTransponder_mapsFrequencyCorrectly() {
|
||||
val radio = makeRadio(isInverted = true)
|
||||
// TX at uplinkLow → RX should be downlinkHigh
|
||||
assertEquals(145900000L, TransponderMapper.mapUplinkToDownlink(435000000L, radio))
|
||||
// TX at uplinkHigh → RX should be downlinkLow
|
||||
assertEquals(145800000L, TransponderMapper.mapUplinkToDownlink(435100000L, radio))
|
||||
// TX at center → RX at center
|
||||
assertEquals(145850000L, TransponderMapper.mapUplinkToDownlink(435050000L, radio))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonInvertedTransponder_mapsFrequencyCorrectly() {
|
||||
val radio = makeRadio(isInverted = false)
|
||||
// TX at uplinkLow → RX should be downlinkLow
|
||||
assertEquals(145800000L, TransponderMapper.mapUplinkToDownlink(435000000L, radio))
|
||||
// TX at uplinkHigh → RX should be downlinkHigh
|
||||
assertEquals(145900000L, TransponderMapper.mapUplinkToDownlink(435100000L, radio))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullFrequencies_returnsNull() {
|
||||
assertNull(TransponderMapper.mapUplinkToDownlink(435000000L, makeRadio(uplinkLow = null)))
|
||||
assertNull(TransponderMapper.mapUplinkToDownlink(435000000L, makeRadio(downlinkLow = null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleFrequencyTransponder_returnsDownlinkLow() {
|
||||
// FM repeater style: no passband, just single frequencies
|
||||
val radio = makeRadio(
|
||||
uplinkLow = 145990000L, uplinkHigh = null,
|
||||
downlinkLow = 436300000L, downlinkHigh = null,
|
||||
isInverted = false
|
||||
)
|
||||
assertEquals(436300000L, TransponderMapper.mapUplinkToDownlink(145990000L, radio))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun equalLowHighTransponder_returnsDownlinkLow() {
|
||||
val radio = makeRadio(
|
||||
uplinkLow = 145990000L, uplinkHigh = 145990000L,
|
||||
downlinkLow = 436300000L, downlinkHigh = 436300000L,
|
||||
isInverted = false
|
||||
)
|
||||
assertEquals(436300000L, TransponderMapper.mapUplinkToDownlink(145990000L, radio))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invertedMode_swapsUsbLsb() {
|
||||
assertEquals("LSB", TransponderMapper.mapUplinkModeToDownlinkMode("USB", true))
|
||||
assertEquals("USB", TransponderMapper.mapUplinkModeToDownlinkMode("LSB", true))
|
||||
assertEquals("FM", TransponderMapper.mapUplinkModeToDownlinkMode("FM", true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonInvertedMode_keepsSame() {
|
||||
assertEquals("USB", TransponderMapper.mapUplinkModeToDownlinkMode("USB", false))
|
||||
assertEquals("LSB", TransponderMapper.mapUplinkModeToDownlinkMode("LSB", false))
|
||||
assertEquals("FM", TransponderMapper.mapUplinkModeToDownlinkMode("FM", false))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.composeLibraryPlugin)
|
||||
alias(libs.plugins.convention.corePresentationPlugin)
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Shader
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.core.graphics.withRotation
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
|
||||
@Immutable
|
||||
data class BubblesStyle(
|
||||
val bgColor: Color = Color.Black,
|
||||
val bubbleCount: Int = 16,
|
||||
val minBubbleCount: Int = 8,
|
||||
val maxBubbleCount: Int = 16,
|
||||
val adaptiveBubbleCount: Boolean = true,
|
||||
val spawnIntervalMs: Long = 800L,
|
||||
val bubbleRadiusFraction: Float = 0.24f,
|
||||
val adaptiveSizing: Boolean = true,
|
||||
val referenceMinSizePx: Float = 360f,
|
||||
val referenceAreaPx: Float = 360f * 800f,
|
||||
val minBubbleRadiusPx: Float = 16f,
|
||||
val maxBubbleRadiusPx: Float = 256f,
|
||||
val speedScale: Float = 0.99f,
|
||||
val minVelocity: Float = 0.8f,
|
||||
val maxVelocity: Float = 3.2f,
|
||||
val hueRotationSpeedDps: Float = 60f, // degrees per second
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun BubblesEffect(
|
||||
modifier: Modifier = Modifier,
|
||||
isRunning: Boolean = true,
|
||||
style: BubblesStyle = BubblesStyle(),
|
||||
) {
|
||||
val renderer = remember { BubblesRenderer() }
|
||||
var frameSignal by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Animation loop
|
||||
LaunchedEffect(isRunning, style) {
|
||||
if (!isRunning) return@LaunchedEffect
|
||||
var previousNanos = 0L
|
||||
while (isActive) {
|
||||
withFrameNanos { now ->
|
||||
if (previousNanos == 0L) previousNanos = now
|
||||
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||
previousNanos = now
|
||||
renderer.update(deltaSec, style)
|
||||
frameSignal++
|
||||
}
|
||||
}
|
||||
}
|
||||
Canvas(modifier = modifier) {
|
||||
frameSignal
|
||||
renderer.ensureLayout(size)
|
||||
renderer.draw(this, style)
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_STEP_NANOS = 16_666_667L // ~60 FPS
|
||||
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||
private val SHELL_GRADIENT_STOPS = floatArrayOf(0f, 0.52f, 0.66f, 0.79f, 0.90f, 0.968f, 0.993f, 1f)
|
||||
private val INNER_GRADIENT_STOPS = floatArrayOf(0f, 0.34f, 0.68f, 1f)
|
||||
|
||||
private data class Velocity(var x: Float, var y: Float)
|
||||
|
||||
private data class Bubble(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
val radius: Float,
|
||||
val velocity: Velocity,
|
||||
val baseHue: Float, // 0-360, unique for each bubble
|
||||
)
|
||||
|
||||
private class BubblesRenderer {
|
||||
private var width = 0f
|
||||
private var height = 0f
|
||||
private val bubbles = mutableListOf<Bubble>()
|
||||
private val random = Random(System.currentTimeMillis())
|
||||
private var globalHueRotation = 0f // degrees, rotates all bubbles hues
|
||||
private var spawnAccumulatorSec = 0f
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val shellColors = IntArray(8)
|
||||
private val innerColors = IntArray(4)
|
||||
|
||||
fun ensureLayout(size: Size) {
|
||||
val targetWidth = size.width
|
||||
val targetHeight = size.height
|
||||
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||
val shouldRebuild = width != targetWidth || height != targetHeight
|
||||
if (shouldRebuild) {
|
||||
width = targetWidth
|
||||
height = targetHeight
|
||||
bubbles.clear()
|
||||
spawnAccumulatorSec = 0f
|
||||
}
|
||||
}
|
||||
|
||||
fun spawnBubble(style: BubblesStyle): Boolean {
|
||||
if (width <= 0f || height <= 0f) return false
|
||||
val radius = resolveBubbleRadius(style)
|
||||
val (velocityMin, velocityMax) = resolveVelocityRange(style)
|
||||
val bubble = Bubble(
|
||||
x = radius,
|
||||
y = height - radius,
|
||||
radius = radius,
|
||||
velocity = Velocity(
|
||||
x = randomInRange(velocityMin, velocityMax),
|
||||
y = -randomInRange(velocityMin, velocityMax),
|
||||
),
|
||||
baseHue = random.nextFloat() * 360f, // Random starting hue for this bubble
|
||||
)
|
||||
bubbles.add(bubble)
|
||||
return true
|
||||
}
|
||||
|
||||
fun update(deltaSeconds: Float, style: BubblesStyle) {
|
||||
if (width <= 0f || height <= 0f) return
|
||||
|
||||
spawnMissingBubbles(deltaSeconds, style)
|
||||
|
||||
// Rotate hue for all bubbles
|
||||
globalHueRotation += style.hueRotationSpeedDps * deltaSeconds
|
||||
if (globalHueRotation >= 360f) globalHueRotation -= 360f
|
||||
val frameScale = deltaSeconds * 60f
|
||||
for (i in bubbles.indices) {
|
||||
val bubble = bubbles[i]
|
||||
// Update position
|
||||
bubble.x += bubble.velocity.x * frameScale
|
||||
bubble.y += bubble.velocity.y * frameScale
|
||||
// Bounce off walls
|
||||
if (bubble.x > width - bubble.radius) {
|
||||
bubble.x = width - bubble.radius
|
||||
bubble.velocity.x *= -1
|
||||
}
|
||||
if (bubble.x < bubble.radius) {
|
||||
bubble.x = bubble.radius
|
||||
bubble.velocity.x *= -1
|
||||
}
|
||||
if (bubble.y > height - bubble.radius) {
|
||||
bubble.y = height - bubble.radius
|
||||
bubble.velocity.y *= -1
|
||||
}
|
||||
if (bubble.y < bubble.radius) {
|
||||
bubble.y = bubble.radius
|
||||
bubble.velocity.y *= -1
|
||||
}
|
||||
}
|
||||
// Collision detection and resolution
|
||||
for (i in bubbles.indices) {
|
||||
for (j in (i + 1) until bubbles.size) {
|
||||
val b1 = bubbles[i]
|
||||
val b2 = bubbles[j]
|
||||
if (isCollided(b1, b2)) resolveCollision(b1, b2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun spawnMissingBubbles(deltaSeconds: Float, style: BubblesStyle) {
|
||||
val targetCount = resolveTargetBubbleCount(style)
|
||||
if (bubbles.size >= targetCount) return
|
||||
|
||||
val spawnIntervalSec = style.spawnIntervalMs.coerceAtLeast(1L) / 1_000f
|
||||
spawnAccumulatorSec += deltaSeconds
|
||||
|
||||
while (bubbles.size < targetCount && spawnAccumulatorSec >= spawnIntervalSec) {
|
||||
if (!spawnBubble(style)) break
|
||||
spawnAccumulatorSec -= spawnIntervalSec
|
||||
}
|
||||
}
|
||||
|
||||
fun draw(scope: DrawScope, style: BubblesStyle) {
|
||||
if (width <= 0f || height <= 0f) return
|
||||
scope.drawRect(style.bgColor)
|
||||
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||
for (i in bubbles.indices) {
|
||||
drawBubbleWithGradient(canvas, bubbles[i])
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawBubbleWithGradient(canvas: android.graphics.Canvas, bubble: Bubble) {
|
||||
val hue = (bubble.baseHue + globalHueRotation) % 360f
|
||||
val lit = 53.33f + maxOf(0f, (70f - kotlin.math.abs(244f - hue)) / 4f)
|
||||
val rgb = hueToRgb(hue, lit)
|
||||
val shellColor = mixWithWhite(rgb, 0.04f)
|
||||
val innerColor = mixWithWhite(rgb, 0.10f)
|
||||
val shimmerColor = mixWithWhite(rgb, 0.35f)
|
||||
// Outer shell
|
||||
val shellGradient = RadialGradient(
|
||||
bubble.x, bubble.y, bubble.radius,
|
||||
buildShellColors(shellColor),
|
||||
SHELL_GRADIENT_STOPS,
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.shader = shellGradient
|
||||
canvas.drawCircle(bubble.x, bubble.y, bubble.radius, paint)
|
||||
// Subtle thin rim
|
||||
paint.shader = null
|
||||
paint.style = Paint.Style.STROKE
|
||||
paint.strokeWidth = maxOf(1f, bubble.radius * 0.024f)
|
||||
paint.color = colorWithAlpha(mixWithWhite(shellColor, 0.08f), 0.30f)
|
||||
canvas.drawCircle(bubble.x, bubble.y, bubble.radius - paint.strokeWidth * 0.5f, paint)
|
||||
// Top internal bubble
|
||||
val innerTop = bubble.y - bubble.radius * 0.96f
|
||||
val innerBottom = bubble.y + bubble.radius * 0.48f
|
||||
val innerLeft = bubble.x - bubble.radius * 0.84f
|
||||
val innerRight = bubble.x + bubble.radius * 0.84f
|
||||
val innerGradient = LinearGradient(
|
||||
bubble.x,
|
||||
innerTop,
|
||||
bubble.x,
|
||||
innerBottom,
|
||||
buildInnerColors(innerColor),
|
||||
INNER_GRADIENT_STOPS,
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.shader = innerGradient
|
||||
canvas.drawOval(
|
||||
innerLeft,
|
||||
innerTop,
|
||||
innerRight,
|
||||
innerBottom,
|
||||
paint
|
||||
)
|
||||
// Top-left shimmer
|
||||
paint.shader = null
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.color = colorWithAlpha(shimmerColor, 0.95f)
|
||||
val shimmerCx = bubble.x - bubble.radius * 0.40f
|
||||
val shimmerCy = bubble.y - bubble.radius * 0.72f
|
||||
val shimmerHalfWidth = bubble.radius * 0.06f
|
||||
val shimmerHalfHeight = bubble.radius * 0.21f
|
||||
canvas.withRotation(60f, shimmerCx, shimmerCy) {
|
||||
drawOval(
|
||||
shimmerCx - shimmerHalfWidth,
|
||||
shimmerCy - shimmerHalfHeight,
|
||||
shimmerCx + shimmerHalfWidth,
|
||||
shimmerCy + shimmerHalfHeight,
|
||||
paint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCollided(bubble1: Bubble, bubble2: Bubble): Boolean {
|
||||
val dx = bubble1.x - bubble2.x
|
||||
val dy = bubble1.y - bubble2.y
|
||||
val radius = (bubble1.radius + bubble2.radius) * 0.9f
|
||||
return dx * dx + dy * dy < radius * radius
|
||||
}
|
||||
|
||||
private fun resolveCollision(particle: Bubble, otherParticle: Bubble) {
|
||||
val xVelocityDiff = particle.velocity.x - otherParticle.velocity.x
|
||||
val yVelocityDiff = particle.velocity.y - otherParticle.velocity.y
|
||||
val xDist = otherParticle.x - particle.x
|
||||
val yDist = otherParticle.y - particle.y
|
||||
// Prevent accidental overlap
|
||||
if (xVelocityDiff * xDist + yVelocityDiff * yDist >= 0) {
|
||||
val angle = -atan2(otherParticle.y - particle.y, otherParticle.x - particle.x)
|
||||
val m1 = 1f
|
||||
val m2 = 1f
|
||||
val u1 = rotate(particle.velocity, angle)
|
||||
val u2 = rotate(otherParticle.velocity, angle)
|
||||
val v1 = Velocity(
|
||||
x = (u1.x * (m1 - m2)) / (m1 + m2) + (u2.x * 2 * m2) / (m1 + m2),
|
||||
y = u1.y,
|
||||
)
|
||||
val v2 = Velocity(
|
||||
x = (u2.x * (m1 - m2)) / (m1 + m2) + (u1.x * 2 * m2) / (m1 + m2),
|
||||
y = u2.y,
|
||||
)
|
||||
val vFinal1 = rotate(v1, -angle)
|
||||
val vFinal2 = rotate(v2, -angle)
|
||||
particle.velocity.x = vFinal1.x
|
||||
particle.velocity.y = vFinal1.y
|
||||
otherParticle.velocity.x = vFinal2.x
|
||||
otherParticle.velocity.y = vFinal2.y
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotate(velocity: Velocity, angle: Float): Velocity {
|
||||
return Velocity(
|
||||
x = velocity.x * cos(angle) - velocity.y * sin(angle),
|
||||
y = velocity.x * sin(angle) + velocity.y * cos(angle),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildShellColors(shellColor: Int): IntArray {
|
||||
shellColors[0] = colorWithAlpha(shellColor, 0f)
|
||||
shellColors[1] = colorWithAlpha(shellColor, 0f)
|
||||
shellColors[2] = colorWithAlpha(shellColor, 0.04f)
|
||||
shellColors[3] = colorWithAlpha(shellColor, 0.12f)
|
||||
shellColors[4] = colorWithAlpha(shellColor, 0.28f)
|
||||
shellColors[5] = colorWithAlpha(shellColor, 0.44f)
|
||||
shellColors[6] = colorWithAlpha(shellColor, 0.58f)
|
||||
shellColors[7] = colorWithAlpha(shellColor, 0.64f)
|
||||
return shellColors
|
||||
}
|
||||
|
||||
private fun buildInnerColors(innerColor: Int): IntArray {
|
||||
innerColors[0] = colorWithAlpha(innerColor, 0.48f)
|
||||
innerColors[1] = colorWithAlpha(innerColor, 0.36f)
|
||||
innerColors[2] = colorWithAlpha(innerColor, 0.08f)
|
||||
innerColors[3] = colorWithAlpha(innerColor, 0f)
|
||||
return innerColors
|
||||
}
|
||||
|
||||
private fun resolveBubbleRadius(style: BubblesStyle): Float {
|
||||
val minDimension = min(width, height)
|
||||
val baseRadius = minDimension * style.bubbleRadiusFraction
|
||||
val minRadius = style.minBubbleRadiusPx.coerceAtLeast(1f)
|
||||
val maxRadius = maxOf(minRadius, style.maxBubbleRadiusPx)
|
||||
if (!style.adaptiveSizing || minDimension <= 0f) {
|
||||
return baseRadius.coerceIn(minRadius, maxRadius)
|
||||
}
|
||||
|
||||
val reference = style.referenceMinSizePx.coerceAtLeast(1f)
|
||||
val dampening = sqrt((reference / minDimension).coerceAtMost(1f))
|
||||
return (baseRadius * dampening).coerceIn(minRadius, maxRadius)
|
||||
}
|
||||
|
||||
private fun resolveTargetBubbleCount(style: BubblesStyle): Int {
|
||||
val baseCount = style.bubbleCount.coerceAtLeast(1)
|
||||
if (!style.adaptiveBubbleCount) return baseCount
|
||||
|
||||
val area = width * height
|
||||
val referenceArea = style.referenceAreaPx.coerceAtLeast(1f)
|
||||
val areaScale = sqrt((area / referenceArea).coerceAtLeast(0.25f))
|
||||
val scaledCount = (baseCount * areaScale).toInt()
|
||||
val minCount = style.minBubbleCount.coerceAtLeast(1)
|
||||
val maxCount = maxOf(minCount, style.maxBubbleCount.coerceAtLeast(1))
|
||||
return scaledCount.coerceIn(minCount, maxCount)
|
||||
}
|
||||
|
||||
private fun resolveVelocityRange(style: BubblesStyle): Pair<Float, Float> {
|
||||
val referenceMaxVelocity = maxOf(height / 300f, 1f)
|
||||
val velocityMin = (style.minVelocity * style.speedScale).coerceAtLeast(0.05f)
|
||||
val velocityMax = maxOf(velocityMin, min(style.maxVelocity, referenceMaxVelocity) * style.speedScale)
|
||||
return velocityMin to velocityMax
|
||||
}
|
||||
|
||||
private fun randomInRange(min: Float, max: Float): Float {
|
||||
return random.nextFloat() * (max - min) + min
|
||||
}
|
||||
}
|
||||
|
||||
private fun hueToRgb(h: Float, l: Float): Int {
|
||||
val hNorm = h / 360f
|
||||
val lNorm = l / 100f
|
||||
val sNorm = 1f
|
||||
val c = (1f - kotlin.math.abs(2f * lNorm - 1f)) * sNorm
|
||||
val hp = hNorm * 6f
|
||||
val x = c * (1f - kotlin.math.abs((hp % 2f) - 1f))
|
||||
val m = lNorm - c / 2f
|
||||
val (r, g, b) = when {
|
||||
hp < 1f -> Triple(c, x, 0f)
|
||||
hp < 2f -> Triple(x, c, 0f)
|
||||
hp < 3f -> Triple(0f, c, x)
|
||||
hp < 4f -> Triple(0f, x, c)
|
||||
hp < 5f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
val r8 = ((r + m) * 255).toInt().coerceIn(0, 255)
|
||||
val g8 = ((g + m) * 255).toInt().coerceIn(0, 255)
|
||||
val b8 = ((b + m) * 255).toInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (r8 shl 16) or (g8 shl 8) or b8
|
||||
}
|
||||
|
||||
private fun colorWithAlpha(color: Int, alpha: Float): Int {
|
||||
val a = (alpha * 255).toInt().coerceIn(0, 255)
|
||||
val r = (color shr 16) and 0xFF
|
||||
val g = (color shr 8) and 0xFF
|
||||
val b = color and 0xFF
|
||||
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
|
||||
private fun mixWithWhite(color: Int, amount: Float): Int {
|
||||
val t = amount.coerceIn(0f, 1f)
|
||||
val r = (color shr 16) and 0xFF
|
||||
val g = (color shr 8) and 0xFF
|
||||
val b = color and 0xFF
|
||||
val mixedR = (r + (255 - r) * t).toInt().coerceIn(0, 255)
|
||||
val mixedG = (g + (255 - g) * t).toInt().coerceIn(0, 255)
|
||||
val mixedB = (b + (255 - b) * t).toInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (mixedR shl 16) or (mixedG shl 8) or mixedB
|
||||
}
|
||||
+323
-160
@@ -27,43 +27,70 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedButton
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
|
||||
import java.util.TimeZone
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
@@ -76,19 +103,23 @@ private fun TopBarPreview() = MainTheme {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopBar(content: @Composable (RowScope.() -> Unit)) {
|
||||
fun TopBar(content: @Composable RowScope.() -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.height(48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) { content() }
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val aosColor = if (isTimeAos) colorScheme.primary else colorScheme.onSurface
|
||||
val losColor = if (!isTimeAos) colorScheme.primary else colorScheme.onSurface
|
||||
val (aosColor, losColor) = if (isTimeAos) {
|
||||
colorScheme.primary to colorScheme.onSurface
|
||||
} else {
|
||||
colorScheme.onSurface to colorScheme.primary
|
||||
}
|
||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -100,7 +131,7 @@ fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
|
||||
text = timeString,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = colorScheme.primary
|
||||
)
|
||||
Text(text = "LOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = losColor)
|
||||
}
|
||||
@@ -114,7 +145,13 @@ private fun NextPassRowPreview() = MainTheme {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
|
||||
fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc: Boolean = false) {
|
||||
val timeZone = remember(isUtc) {
|
||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
}
|
||||
val sdfTime = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||
}
|
||||
ElevatedCard(
|
||||
modifier = modifier
|
||||
.height(48.dp)
|
||||
@@ -124,13 +161,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
|
||||
verticalArrangement = Arrangement.spacedBy((-2).dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// .background(color = MaterialTheme.colorScheme.background)
|
||||
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
||||
) {
|
||||
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "$passSatId - ",
|
||||
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
@@ -143,16 +178,17 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val elevColor = elevationColor(pass.maxElevation)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_elevation),
|
||||
painter = painterResource(R.drawable.ic_elevation),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
tint = elevColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${pass.maxElevation}°",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = elevColor
|
||||
)
|
||||
}
|
||||
Row(
|
||||
@@ -160,68 +196,57 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = sdfTime.format(Date(pass.aosTime)),
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = sdfTime.format(Date(pass.aosTime)),
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_altitude),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${pass.altitude} km",
|
||||
fontSize = 15.sp
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.pass_aosLos,
|
||||
pass.aosAzimuth.toInt(),
|
||||
pass.losAzimuth.toInt()
|
||||
),
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.pass_aosLos, pass.aosAzimuth.toInt(), pass.losAzimuth.toInt()),
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLocale(): Locale {
|
||||
val locale = Locale.getDefault()
|
||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||
ElevatedButton(
|
||||
onClick = { onClick() }, colors = ButtonDefaults.buttonColors(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
), shape = MaterialTheme.shapes.small, modifier = modifier,
|
||||
),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
|
||||
) { Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
|
||||
val clickableMod = Modifier.clickable { action() }
|
||||
ElevatedCard(modifier = Modifier.size(48.dp)) {
|
||||
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
fun IconCard(
|
||||
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true, containerColor: Color = Color.Unspecified
|
||||
) {
|
||||
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
|
||||
else CardDefaults.elevatedCardColors(containerColor = containerColor)
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||
}
|
||||
}
|
||||
@@ -229,14 +254,16 @@ fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
|
||||
|
||||
@Composable
|
||||
fun PrimaryIconCard(modifier: Modifier = Modifier, resId: Int, onClick: () -> Unit) {
|
||||
val clickableMod = modifier.clickable { onClick() }
|
||||
ElevatedCard(
|
||||
modifier = Modifier.size(102.dp, 48.dp),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(id = resId), contentDescription = null)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) { Icon(painter = painterResource(resId), contentDescription = null) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,111 +289,159 @@ fun EmptyListCard(message: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultPass(): OrbitalPass {
|
||||
val orbitalData = OrbitalData(
|
||||
""" ¯\_(ツ)_/¯ ⚠️""",
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
0.0
|
||||
)
|
||||
val satellite = NearEarthObject(orbitalData)
|
||||
return OrbitalPass(0L, 0.0, Long.MAX_VALUE, 0.0, 0, 0.0, satellite, 0f)
|
||||
private val defaultOrbitalData = OrbitalData(
|
||||
name = """ ¯\_(ツ)_/¯ ⚠️""",
|
||||
epoch = 0.0, meanmo = 0.0, eccn = 0.0, incl = 0.0,
|
||||
raan = 0.0, argper = 0.0, meanan = 0.0, catnum = 0, bstar = 0.0
|
||||
)
|
||||
|
||||
fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
||||
aosTime = 0L, aosAzimuth = 0.0, losTime = Long.MAX_VALUE, losAzimuth = 0.0,
|
||||
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InfoDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedDialog(
|
||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||
fun ConfirmDialog(
|
||||
title: String,
|
||||
onCancel: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onCancel) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = padding)
|
||||
)
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WhatsNewDialog(onDismiss: () -> Unit) {
|
||||
InfoDialog(
|
||||
title = stringResource(R.string.pass_whatsnew_title),
|
||||
onDismiss = onDismiss,
|
||||
onAccept = onDismiss
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.pass_whatsnew_message),
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DialogShell(
|
||||
onDismissRequest: () -> Unit,
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
Dialog(onDismissRequest = { onCancel() }) {
|
||||
ElevatedCard {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(padding)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
||||
)
|
||||
content()
|
||||
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
|
||||
CardButton(onClick = onCancel, text = stringResource(id = R.string.btn_cancel))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
CardButton(onClick = onAccept, text = stringResource(id = R.string.btn_accept))
|
||||
}
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
||||
val maxSheetHeight = containerHeight - statusBarHeight
|
||||
val stopSheetFling = remember {
|
||||
object : NestedScrollConnection {
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
return available
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset = Offset.Zero
|
||||
}
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
dragHandle = null,
|
||||
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(padding),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = maxSheetHeight)
|
||||
.nestedScroll(stopSheetFling)
|
||||
) {
|
||||
content(padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
|
||||
val padding = LocalSpacing.current.large
|
||||
Dialog(onDismissRequest = {}) {
|
||||
ElevatedCard {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(padding)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = padding)
|
||||
)
|
||||
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
CardButton(
|
||||
onClick = onDismiss,
|
||||
text = stringResource(id = R.string.btn_accept)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun hasEnoughHeight(): Boolean =
|
||||
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||
|
||||
@Composable
|
||||
fun hasEnoughHeight(): Boolean {
|
||||
return currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||
}
|
||||
fun hasEnoughWidth(): Boolean =
|
||||
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
||||
|
||||
@Composable
|
||||
fun hasEnoughWidth(): Boolean {
|
||||
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
||||
}
|
||||
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
||||
|
||||
@Composable
|
||||
fun isVerticalLayout(): Boolean {
|
||||
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600).not()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.infiniteMarquee(): Modifier {
|
||||
return basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
||||
}
|
||||
fun Modifier.infiniteMarquee(): Modifier =
|
||||
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
||||
|
||||
@Composable
|
||||
fun Modifier.layoutPadding(): Modifier {
|
||||
val statusBarMod = this.statusBarsPadding()
|
||||
val spacing = LocalSpacing.current.extraSmall
|
||||
return statusBarMod.padding(start = spacing, top = 0.dp, end = spacing, bottom = spacing)
|
||||
return statusBarsPadding().padding(start = spacing, top = 0.dp, end = spacing, bottom = spacing)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -375,15 +450,15 @@ fun ScreenColumn(
|
||||
floatingBar: @Composable () -> Unit = {},
|
||||
content: @Composable (Boolean) -> Unit = {}
|
||||
) {
|
||||
val isVerticalLayout = isVerticalLayout()
|
||||
val isVertical = isVerticalLayout()
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
Box(modifier = Modifier.layoutPadding(), contentAlignment = Alignment.BottomCenter) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.extraSmall)) {
|
||||
topBar(isVerticalLayout)
|
||||
topBar(isVertical)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) { content(isVerticalLayout) }
|
||||
) { content(isVertical) }
|
||||
}
|
||||
floatingBar()
|
||||
}
|
||||
@@ -394,21 +469,109 @@ fun ScreenColumn(
|
||||
fun TopBar(
|
||||
isVerticalLayout: Boolean,
|
||||
startAction: @Composable () -> Unit,
|
||||
topInfo: @Composable (RowScope.() -> Unit),
|
||||
bottomInfo: @Composable (RowScope.() -> Unit),
|
||||
topInfo: @Composable RowScope.() -> Unit,
|
||||
bottomInfo: @Composable RowScope.() -> Unit,
|
||||
endAction: @Composable () -> Unit
|
||||
) {
|
||||
val topBarRow = @Composable { content: @Composable RowScope.() -> Unit ->
|
||||
Row(
|
||||
modifier = Modifier.height(48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) { content() }
|
||||
}
|
||||
if (isVerticalLayout) {
|
||||
topBarRow { startAction().also { topInfo() }.also { endAction() } }
|
||||
topBarRow { bottomInfo() }
|
||||
TopBar { startAction(); topInfo(); endAction() }
|
||||
TopBar { bottomInfo() }
|
||||
} else {
|
||||
topBarRow { startAction().also { topInfo() }.also { bottomInfo() }.also { endAction() } }
|
||||
TopBar { startAction(); topInfo(); bottomInfo(); endAction() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun elevationColor(elevation: Double): Color {
|
||||
val thresholds = LocalElevationThresholds.current
|
||||
return when {
|
||||
elevation < thresholds.low -> ElevationLowColor // soft red for low elevation
|
||||
elevation < thresholds.high -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
||||
else -> ElevationHighColor // soft green for high elevation
|
||||
}
|
||||
}
|
||||
|
||||
/** User-configurable elevation highlight thresholds (in degrees). */
|
||||
data class ElevationThresholds(val low: Double = 15.0, val high: Double = 45.0)
|
||||
|
||||
/** Provided at the app root from settings; defaults keep the original 15°/45° behavior. */
|
||||
val LocalElevationThresholds = compositionLocalOf { ElevationThresholds() }
|
||||
|
||||
/** Soft red used for elevations below the low threshold. */
|
||||
val ElevationLowColor = Color(0xFFEF5350)
|
||||
|
||||
/** Soft green used for elevations above the high threshold. */
|
||||
val ElevationHighColor = Color(0xFF66BB6A)
|
||||
|
||||
@Composable
|
||||
fun OutlinedText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fillColor: Color = Color.Unspecified,
|
||||
outlineColor: Color,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
fontStyle: FontStyle? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontFamily: FontFamily? = null,
|
||||
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||
textDecoration: TextDecoration? = null,
|
||||
textAlign: TextAlign? = null,
|
||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
softWrap: Boolean = true,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
minLines: Int = 1,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
outlineDrawStyle: Stroke = Stroke(width = 8f),
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.semantics { hideFromAccessibility() },
|
||||
color = outlineColor,
|
||||
fontSize = fontSize,
|
||||
fontStyle = fontStyle,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
letterSpacing = letterSpacing,
|
||||
textDecoration = null,
|
||||
textAlign = textAlign,
|
||||
lineHeight = lineHeight,
|
||||
overflow = overflow,
|
||||
softWrap = softWrap,
|
||||
maxLines = maxLines,
|
||||
minLines = minLines,
|
||||
onTextLayout = onTextLayout,
|
||||
style = style.copy(shadow = null, drawStyle = outlineDrawStyle),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
color = fillColor,
|
||||
fontSize = fontSize,
|
||||
fontStyle = fontStyle,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
letterSpacing = letterSpacing,
|
||||
textDecoration = textDecoration,
|
||||
textAlign = textAlign,
|
||||
lineHeight = lineHeight,
|
||||
overflow = overflow,
|
||||
softWrap = softWrap,
|
||||
maxLines = maxLines,
|
||||
minLines = minLines,
|
||||
onTextLayout = onTextLayout,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Formats a frequency in Hz as "MMM.KKK.HHH" (e.g. 145.825.000) or "---"
|
||||
fun formatFrequency(frequencyHz: Long): String {
|
||||
if (frequencyHz <= 0) return "---"
|
||||
val mhz = frequencyHz / 1_000_000
|
||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||
val hz = frequencyHz % 1_000
|
||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||
}
|
||||
+8
-4
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFF1E1BB),
|
||||
onSecondaryContainer = Color(0xFF221B04),
|
||||
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
|
||||
onTertiaryContainer = Color(0xFF000000),
|
||||
background = Color(0xFFFFF8F0),
|
||||
onBackground = Color(0xFF1E1B13),
|
||||
surface = Color(0xFFFFF8F0),
|
||||
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
|
||||
onSecondary = Color(0xFF000000),
|
||||
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
||||
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
||||
// tertiary = Color(0xFF121212),
|
||||
// onTertiary = Color(0xFF121212),
|
||||
// tertiaryContainer = Color(0xFF121212),
|
||||
// onTertiaryContainer = Color(0xFF121212),
|
||||
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
|
||||
onTertiary = Color(0xFF000000),
|
||||
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
|
||||
onTertiaryContainer = Color(0xFF000000),
|
||||
background = Color(0xFF121212),
|
||||
onBackground = Color(0xFFE0E0E0),
|
||||
surface = Color(0xFF202020), // card background
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.random.Random
|
||||
|
||||
@Immutable
|
||||
data class MatrixStyle(
|
||||
val bgColor: Color = Color.Black,
|
||||
val bodyColor: Color = Color(0xFF29C94A),
|
||||
val headColor: Color = Color(0xFFC2FFC6),
|
||||
val tailAlphaFloor: Float = 0.14f, // 0.14f - 0.18f,
|
||||
val fontSize: TextUnit = 14.sp, // 11.sp - 14.sp
|
||||
val minStreamLength: Int = 8, // 6 - 8
|
||||
val maxStreamLength: Int = 28, // 20 - 28
|
||||
val minSpeedRps: Float = 10f, // 10f - 15f
|
||||
val maxSpeedRps: Float = 38f, // 38f - 45f
|
||||
val resetPauseSec: ClosedFloatingPointRange<Float> = 0.1f..1.0f,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MatrixEffect(
|
||||
modifier: Modifier = Modifier,
|
||||
isRunning: Boolean = true,
|
||||
style: MatrixStyle = MatrixStyle(),
|
||||
symbols: String = DEFAULT_SYMBOLS,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val renderer = remember { MatrixRenderer() }
|
||||
var frameSignal by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(isRunning, style, symbols) {
|
||||
if (!isRunning) return@LaunchedEffect
|
||||
|
||||
var previousNanos = 0L
|
||||
while (isActive) {
|
||||
withFrameNanos { now ->
|
||||
if (previousNanos == 0L) previousNanos = now
|
||||
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||
previousNanos = now
|
||||
renderer.update(deltaSec, style)
|
||||
frameSignal++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
frameSignal
|
||||
renderer.ensureLayout(size, density.density, style, symbols)
|
||||
renderer.draw(this, style)
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_STEP_NANOS = 33_333_333L
|
||||
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||
private const val DEFAULT_SYMBOLS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>=*+-~:;/[]{}()"
|
||||
|
||||
private fun Color.toArgb(): Int {
|
||||
val a = (alpha.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
val r = (red.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
val g = (green.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
val b = (blue.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
|
||||
private class MatrixRenderer {
|
||||
private var columns = 0
|
||||
private var rows = 0
|
||||
private var width = 0f
|
||||
private var height = 0f
|
||||
private var fontSizePx = 0f
|
||||
private var charWidth = 0f
|
||||
private var charHeight = 0f
|
||||
private var baselineOffset = 0f
|
||||
private var symbolSet = ""
|
||||
private var symbols = charArrayOf()
|
||||
|
||||
private var glyphs = charArrayOf()
|
||||
private var streams = emptyArray<StreamState>()
|
||||
|
||||
private val bodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||
private val headPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||
private val charBuffer = CharArray(1)
|
||||
private val random = Random(System.currentTimeMillis())
|
||||
|
||||
fun ensureLayout(size: Size, density: Float, style: MatrixStyle, symbols: String) {
|
||||
val targetFontPx = style.fontSize.value * density
|
||||
val targetWidth = size.width
|
||||
val targetHeight = size.height
|
||||
val targetSymbols = symbols.ifBlank { DEFAULT_SYMBOLS }
|
||||
|
||||
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||
|
||||
val shouldRebuild = width != targetWidth ||
|
||||
height != targetHeight ||
|
||||
fontSizePx != targetFontPx ||
|
||||
symbolSet != targetSymbols
|
||||
|
||||
if (!shouldRebuild) return
|
||||
|
||||
width = targetWidth
|
||||
height = targetHeight
|
||||
fontSizePx = targetFontPx
|
||||
symbolSet = targetSymbols
|
||||
this.symbols = targetSymbols.toCharArray()
|
||||
|
||||
bodyPaint.textSize = targetFontPx
|
||||
headPaint.textSize = targetFontPx
|
||||
charWidth = max(bodyPaint.measureText("W"), 1f)
|
||||
val metrics = bodyPaint.fontMetrics
|
||||
charHeight = max(metrics.descent - metrics.ascent, 1f)
|
||||
baselineOffset = -metrics.ascent
|
||||
|
||||
columns = max((width / charWidth).toInt(), 1)
|
||||
rows = max((height / charHeight).toInt() + 2, 1)
|
||||
|
||||
glyphs = CharArray(columns * rows) { randomGlyph() }
|
||||
streams = Array(columns) { StreamState.random(rows, style, random) }
|
||||
}
|
||||
|
||||
fun update(deltaSeconds: Float, style: MatrixStyle) {
|
||||
if (columns == 0 || rows == 0 || deltaSeconds <= 0f) return
|
||||
|
||||
for (column in streams.indices) {
|
||||
val stream = streams[column]
|
||||
stream.pauseSec -= deltaSeconds
|
||||
if (stream.pauseSec > 0f) continue
|
||||
|
||||
val previousHead = floor(stream.headRow).toInt()
|
||||
stream.headRow += stream.speedRps * deltaSeconds
|
||||
val newHead = floor(stream.headRow).toInt()
|
||||
|
||||
if (newHead > previousHead) {
|
||||
for (row in (previousHead + 1)..newHead) {
|
||||
if (row in 0 until rows) {
|
||||
glyphs[row * columns + column] = randomGlyph()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newHead - stream.length > rows) {
|
||||
stream.reset(rows, style, random)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun draw(scope: androidx.compose.ui.graphics.drawscope.DrawScope, style: MatrixStyle) {
|
||||
if (columns == 0 || rows == 0) return
|
||||
|
||||
scope.drawRect(style.bgColor)
|
||||
|
||||
bodyPaint.color = style.bodyColor.toArgb()
|
||||
headPaint.color = style.headColor.toArgb()
|
||||
val tailAlphaFloor = style.tailAlphaFloor.coerceIn(0f, 1f)
|
||||
|
||||
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||
for (column in streams.indices) {
|
||||
val stream = streams[column]
|
||||
if (stream.pauseSec > 0f) continue
|
||||
|
||||
val head = floor(stream.headRow).toInt()
|
||||
val startRow = max(0, head - stream.length + 1)
|
||||
val endRow = min(rows - 1, head)
|
||||
if (startRow > endRow) continue
|
||||
|
||||
for (row in endRow downTo startRow) {
|
||||
val tailIndex = head - row
|
||||
|
||||
val paint = if (tailIndex == 0) headPaint else bodyPaint
|
||||
if (tailIndex != 0) {
|
||||
val normalized = ((stream.length - tailIndex).toFloat() / stream.length).coerceIn(0f, 1f)
|
||||
val alpha = tailAlphaFloor + (1f - tailAlphaFloor) * normalized
|
||||
paint.alpha = (alpha * 255).toInt()
|
||||
} else {
|
||||
paint.alpha = 255
|
||||
}
|
||||
|
||||
val glyph = glyphs[row * columns + column]
|
||||
charBuffer[0] = glyph
|
||||
val x = column * charWidth
|
||||
val y = row * charHeight + baselineOffset
|
||||
canvas.drawText(charBuffer, 0, 1, x, y, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun randomGlyph(): Char = symbols[random.nextInt(symbols.size)]
|
||||
}
|
||||
|
||||
private data class StreamState(
|
||||
var headRow: Float,
|
||||
var length: Int,
|
||||
var speedRps: Float,
|
||||
var pauseSec: Float,
|
||||
) {
|
||||
fun reset(rows: Int, style: MatrixStyle, random: Random) {
|
||||
val randomOffset = random.nextFloat() * rows
|
||||
headRow = -randomOffset
|
||||
length = random.nextInt(
|
||||
from = style.minStreamLength.coerceAtLeast(2),
|
||||
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||
)
|
||||
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps
|
||||
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||
style.resetPauseSec.start
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun random(rows: Int, style: MatrixStyle, random: Random): StreamState {
|
||||
val length = random.nextInt(
|
||||
from = style.minStreamLength.coerceAtLeast(2),
|
||||
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||
)
|
||||
return StreamState(
|
||||
headRow = -random.nextFloat() * rows,
|
||||
length = length,
|
||||
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps,
|
||||
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||
style.resetPauseSec.start,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-6
@@ -1,9 +1,70 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
sealed class Screen(val route: String, val iconResId: Int, val titleResId: Int) {
|
||||
data object Satellites : Screen("satellites", R.drawable.ic_satellites, R.string.nav_sat)
|
||||
data object Passes : Screen("passes", R.drawable.ic_passes, R.string.nav_pass)
|
||||
data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar)
|
||||
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
|
||||
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat)
|
||||
|
||||
@Serializable
|
||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||
|
||||
@Serializable
|
||||
data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
|
||||
|
||||
@Serializable
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data object RadarDestination : NavKey
|
||||
|
||||
interface IDeeplinkMatcher {
|
||||
fun match(deeplink: String): NavKey?
|
||||
}
|
||||
|
||||
object PassDetailsMatcher : IDeeplinkMatcher {
|
||||
val passDetailsRegex = """https://github.com/rt-bishop/Look4Sat/passes/(.*)""".toRegex()
|
||||
|
||||
override fun match(deeplink: String): NavKey? {
|
||||
val passMatch = passDetailsRegex.find(deeplink)
|
||||
passMatch?.let { match ->
|
||||
val passId = match.groupValues[1]
|
||||
if (passId.isNotEmpty()) return RadarDestination
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes) {
|
||||
|
||||
private val matchers: List<IDeeplinkMatcher> = listOf(PassDetailsMatcher)
|
||||
|
||||
fun resolve(deeplink: String): NavKey {
|
||||
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
|
||||
return fallbackDestination
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.draw.innerShadow
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @Composable () -> Unit) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
||||
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
||||
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
LaunchedEffect(willTrigger) {
|
||||
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
||||
if (willTrigger) hapticFeedback.performHapticFeedback(feedbackType)
|
||||
}
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = true,
|
||||
enableDismissFromEndToStart = true,
|
||||
backgroundContent = {
|
||||
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
||||
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
||||
},
|
||||
content = { content() },
|
||||
onDismiss = {
|
||||
val targetValue = dismissState.targetValue
|
||||
when (targetValue) {
|
||||
SwipeToDismissBoxValue.StartToEnd -> onSwipeRight()
|
||||
SwipeToDismissBoxValue.EndToStart -> onSwipeLeft()
|
||||
SwipeToDismissBoxValue.Settled -> {}
|
||||
}
|
||||
if (targetValue != SwipeToDismissBoxValue.Settled) {
|
||||
coroutineScope.launch { dismissState.snapTo(SwipeToDismissBoxValue.Settled) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwipeBackground(isSwipeRight: Boolean, willTrigger: Boolean, bgColor: Color = Color(0x00000000)) {
|
||||
val shape = RectangleShape
|
||||
Box(
|
||||
contentAlignment = if (isSwipeRight) Alignment.CenterStart else Alignment.CenterEnd,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.border(width = 1.dp, shape = shape, color = bgColor)
|
||||
.dropShadow(shape = shape) {
|
||||
color = bgColor
|
||||
radius = 40f
|
||||
alpha = if (willTrigger) .2f else 0f
|
||||
}
|
||||
.innerShadow(shape = shape) {
|
||||
color = bgColor
|
||||
radius = 40f
|
||||
alpha = if (willTrigger) 1f else .2f
|
||||
}
|
||||
) {
|
||||
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
|
||||
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_filter),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.fillMaxHeight()
|
||||
.rotate(if (isSwipeRight) 0f else 180f)
|
||||
.graphicsLayer {
|
||||
scaleX = iconScale
|
||||
scaleY = iconScale
|
||||
translationX = slide.toPx()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="15"
|
||||
android:viewportHeight="15">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12.34,2.02C6.59,1.82 2,6.42 2,12c0,5.52 4.48,10 10,10c3.71,0 6.93,-2.02 8.66,-5.02C13.15,16.73 8.57,8.55 12.34,2.02z" />
|
||||
</vector>
|
||||
+1
-1
@@ -5,5 +5,5 @@
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
|
||||
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M8,5v14l11,-7z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||
</vector>
|
||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user