mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
148
Commits
v4.3.0
...
ultra-function
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba8d306d98 | ||
|
|
0ef8f05dc4 | ||
|
|
f7402a3531 | ||
|
|
dd378990fe | ||
|
|
a23bf34ccf | ||
|
|
a724c0823d | ||
|
|
cda6cf8583 | ||
|
|
c09e87fa4e | ||
|
|
0bcf73e1f9 | ||
|
|
ab1a0c0891 | ||
|
|
1a55241791 | ||
|
|
9d437a90c9 | ||
|
|
5351afe508 | ||
|
|
c80e351212 | ||
|
|
916a0d0f0a | ||
|
|
a333192df5 | ||
|
|
38cf7a3569 | ||
|
|
760a26b007 | ||
|
|
47f3e424ff | ||
|
|
f731775346 | ||
|
|
3c5486cc5c | ||
|
|
544515feba | ||
|
|
9a54da0808 | ||
|
|
8bc1f16e2c | ||
|
|
5dc7ce6a22 | ||
|
|
ff408747cd | ||
|
|
6636dd8e98 | ||
|
|
15ab82f1c8 | ||
|
|
0ea9c0ede1 | ||
|
|
c496d90d5a | ||
|
|
c101870b71 | ||
|
|
9a4ceddcb0 | ||
|
|
50233cadc1 | ||
|
|
8d88100b15 | ||
|
|
59d6e2ae05 | ||
|
|
e2b5c6d673 | ||
|
|
da89d6426f | ||
|
|
c19209655b | ||
|
|
d456bdf4b5 | ||
|
|
f06b17a194 | ||
|
|
675194075e | ||
|
|
0ecccc4746 | ||
|
|
a7b41c95c7 | ||
|
|
88d1498eac | ||
|
|
31ae04329d | ||
|
|
eae6304124 | ||
|
|
97c6d609ac | ||
|
|
44c442ddd3 | ||
|
|
f9250ec1f1 | ||
|
|
bff3e1af09 | ||
|
|
6dbe30b3f6 | ||
|
|
8a920acead | ||
|
|
929f652b8e | ||
|
|
610768501e | ||
|
|
427e6e1862 | ||
|
|
bfa5e5f474 | ||
|
|
213425379c | ||
|
|
c3444aed24 | ||
|
|
368bd199e5 | ||
|
|
2298d8eea4 | ||
|
|
7bb0bbbb46 | ||
|
|
deec5581ae | ||
|
|
1de5632428 | ||
|
|
2dfd76825b | ||
|
|
06fc8bc2fb | ||
|
|
75322390b8 | ||
|
|
d1312f1f09 | ||
|
|
75acbbd633 | ||
|
|
a37fb8f773 | ||
|
|
9d0d971500 | ||
|
|
1813e1bfa4 | ||
|
|
bd09ad22ff | ||
|
|
7e90071c4d | ||
|
|
d1cc944be2 | ||
|
|
fb28283679 | ||
|
|
81ae8772d2 | ||
|
|
571145838d | ||
|
|
017b178f2e | ||
|
|
670444858c | ||
|
|
7bdff5df81 | ||
|
|
df083840ac | ||
|
|
a0279bac17 | ||
|
|
3d630041e1 | ||
|
|
ea92d3c150 | ||
|
|
86ee3dcbac | ||
|
|
43788ba7a7 | ||
|
|
1a3f1bb34c | ||
|
|
998c98267b | ||
|
|
28fa373db0 | ||
|
|
4ca33c37ad | ||
|
|
bd42e643ea | ||
|
|
4baf3821e5 | ||
|
|
d7e1ce11c9 | ||
|
|
5de370983d | ||
|
|
f272e268ab | ||
|
|
e59507a4d3 | ||
|
|
0bae306312 | ||
|
|
65f61a5147 | ||
|
|
1064dc739e | ||
|
|
8aedd38b51 | ||
|
|
ad8f08a577 | ||
|
|
2afce54472 | ||
|
|
8e50fcf8fb | ||
|
|
e11b03d56b | ||
|
|
fa55b82a5a | ||
|
|
d57832b0d4 | ||
|
|
1d0e8921a9 | ||
|
|
23d95b8241 | ||
|
|
8af80866bc | ||
|
|
b9e5ff70f5 | ||
|
|
c176d6ad88 | ||
|
|
5a6b162e4f | ||
|
|
33712c29fc | ||
|
|
eac1e6e273 | ||
|
|
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 |
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,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -4,10 +4,15 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- v**
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'Release tag name (e.g. v4.4.3)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -16,61 +21,40 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Assemble Artifacts
|
||||
run: |
|
||||
./gradlew assembleRelease
|
||||
./gradlew bundleRelease
|
||||
- name: Assemble APK
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- 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"
|
||||
|
||||
- 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
|
||||
|
||||
- name: Deploy Bundle
|
||||
uses: r0adkll/upload-google-play@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
|
||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||
VERSION=${TAG_NAME#v}
|
||||
SIGNED_APK="app/build/outputs/apk/release/Look4Sat-Pro-${VERSION}.apk"
|
||||
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 "$SIGNED_APK" \
|
||||
"$APK"
|
||||
rm keystore.jks
|
||||
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
||||
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
||||
gh release upload $TAG_NAME "$SIGNED_APK"
|
||||
+4
-2
@@ -1,6 +1,9 @@
|
||||
# Built application files
|
||||
*.ap_
|
||||
|
||||
# Hermes AI plans and metadata
|
||||
.hermes/
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
@@ -36,8 +39,7 @@ captures/
|
||||
.idea/
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following line if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
*.jks
|
||||
/*.properties
|
||||
/keystore.properties
|
||||
/app/keystore.jks
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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 TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||
data download.
|
||||
|
||||
## Architecture
|
||||
|
||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface → user intents 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:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||
|
||||
## Build & Run
|
||||
|
||||
```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**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||
|
||||
## Key Libraries
|
||||
|
||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||
- **OkHttp** 5.x for TLE downloads
|
||||
- **OSMDroid** for map rendering
|
||||
- **Kotlin Serialization** for navigation args and data parsing
|
||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
||||
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
||||
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
||||
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
||||
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
||||
|
||||
## Data Formats & Migration
|
||||
|
||||
**TLE vs. OMM/CSV format:**
|
||||
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||
|
||||
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
||||
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
||||
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
||||
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
||||
|
||||
**Current implementation:**
|
||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
||||
- TLE data is downloaded from configured sources and stored in Room database
|
||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
||||
|
||||
**Migration path:**
|
||||
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
||||
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
||||
transition to OMM/CSV automatically.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
||||
|
||||
## 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/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
||||
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||
|
||||
## 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://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -1,3 +1,36 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.convention.applicationPlugin)
|
||||
}
|
||||
|
||||
// Load signing config from keystore.properties (gitignored, never commit credentials)
|
||||
val keystoreProperties = Properties().apply {
|
||||
val propsFile = rootProject.file("keystore.properties")
|
||||
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
// CW 解码 native 库仅 armeabi-v7a(照搬 Morse Expert 1.15): 全 ABI 打包会在
|
||||
// arm64 设备 loadLibrary 失败, 强制 32 位兼容(用户设备为 32 位软件)
|
||||
defaultConfig {
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a")
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
if (keystoreProperties["storeFile"] != null) {
|
||||
create("release") {
|
||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,26 +12,38 @@
|
||||
<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
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||
<intent-filter>
|
||||
<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
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="com.rtbishop.look4sat" />
|
||||
android:value="com.rtbishop.look4sat.bg7nta" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -17,14 +17,25 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
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
|
||||
@@ -32,6 +43,7 @@ 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
|
||||
@@ -39,12 +51,18 @@ 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.Scaffold
|
||||
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.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -55,163 +73,300 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.MutualPassData
|
||||
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.cw.CwDecodeScreen
|
||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.radiocontrol.RadioControlDestination
|
||||
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusScreen
|
||||
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let {
|
||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||
rootBackStack.add(destination)
|
||||
}
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { slideInTransition },
|
||||
popTransitionSpec = { slideOutTransition },
|
||||
predictivePopTransitionSpec = { slideOutTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||
entry<RadarDestination> {
|
||||
Scaffold { innerPadding ->
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
innerPadding.calculateTopPadding()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeAt(backStack.size - 1) }
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||
// val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||
// val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar(), Screen.Map, Screen.Settings)
|
||||
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
|
||||
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.Radar -> screen is Screen.Radar
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
item(
|
||||
icon = { Icon(painterResource(screen.iconResId), 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)
|
||||
}
|
||||
)
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
|
||||
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
|
||||
.sortedBy { screen ->
|
||||
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
|
||||
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
||||
if (idx != -1) idx
|
||||
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
|
||||
if (it != -1) it else Int.MAX_VALUE
|
||||
}
|
||||
},
|
||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
),
|
||||
layoutType = when {
|
||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||
}
|
||||
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
|
||||
// 4.5.1 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
|
||||
// 老用户迁移: 已持久化的 subMenuOrder 不含新页面 WavelogLog → 追加到子菜单尾部
|
||||
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
|
||||
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
|
||||
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
|
||||
val mainNavItems = remember(allNavItems, subOrder) {
|
||||
allNavItems.filter { it.screenId !in subOrder }.take(5)
|
||||
}
|
||||
val moreNavItems = remember(allNavItems, subOrder) {
|
||||
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
}
|
||||
var moreExpanded by remember { mutableStateOf(false) }
|
||||
// 更多菜单打开时拦截返回: 先关菜单
|
||||
BackHandler(enabled = moreExpanded) { moreExpanded = false }
|
||||
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||
val mutualViewModel: MutualViewModel = viewModel(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
factory = MutualViewModel.factory(container)
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
low = otherSettings.lowElevation,
|
||||
high = otherSettings.highElevation
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = Modifier.weight(1f),
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { fadeTransition },
|
||||
popTransitionSpec = { fadeTransition },
|
||||
predictivePopTransitionSpec = { fadeTransition },
|
||||
entryDecorators = listOf(
|
||||
// Required for saving Compose state per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
// Required for ViewModel scoping per entry
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Satellites> {
|
||||
SatellitesDestination(navigateUp = navigateBack)
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
mainNavItems.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||
is Screen.AmSat -> screen is Screen.AmSat
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
backStack.add(Screen.Radar(catNum, aosTime))
|
||||
item(
|
||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||
label = { Text(stringResource(screen.titleResId)) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (isSelected) return@item
|
||||
moreExpanded = false
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
if (screen !is Screen.Passes) backStack.add(screen)
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> { route ->
|
||||
RadarDestination(
|
||||
catNum = route.catNum,
|
||||
aosTime = route.aosTime,
|
||||
navigateUp = navigateBack,
|
||||
navigateToRadioControl = { catNum, aosTime ->
|
||||
backStack.add(Screen.RadioControl(catNum, aosTime))
|
||||
)
|
||||
}
|
||||
// 更多菜单按钮(固定第 6 槽, 子菜单非空才显示)
|
||||
if (moreNavItems.isNotEmpty()) {
|
||||
item(
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(com.rtbishop.look4sat.R.drawable.ic_more),
|
||||
stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)) },
|
||||
selected = moreExpanded,
|
||||
onClick = { moreExpanded = !moreExpanded }
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
),
|
||||
layoutType = when {
|
||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||
}
|
||||
) {
|
||||
Box {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { fadeTransition },
|
||||
popTransitionSpec = { fadeTransition },
|
||||
predictivePopTransitionSpec = { fadeTransition },
|
||||
entryDecorators = listOf(
|
||||
// Required for saving Compose state per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
// Required for ViewModel scoping per entry
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Satellites> {
|
||||
SatellitesDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
}
|
||||
entry<Screen.Mutual> {
|
||||
MutualScreen(
|
||||
viewModel = mutualViewModel,
|
||||
navigateUp = navigateBack,
|
||||
navigateToRadar = { catNum, aosTime, pass ->
|
||||
container.setMutualPassData(pass ?: MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.Roaming> {
|
||||
RoamingScreen()
|
||||
}
|
||||
entry<Screen.CwDecode> {
|
||||
CwDecodeScreen()
|
||||
}
|
||||
entry<Screen.AmSat> {
|
||||
SatStatusScreen(container = container)
|
||||
}
|
||||
entry<Screen.WavelogLog> {
|
||||
WavelogLogScreen(queue = container.wavelogQueue)
|
||||
}
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
}
|
||||
}
|
||||
)
|
||||
// Radio tracking status banner
|
||||
if (trackingState.isActive) {
|
||||
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"
|
||||
)
|
||||
}
|
||||
entry<Screen.RadioControl> { route ->
|
||||
RadioControlDestination(
|
||||
catNum = route.catNum,
|
||||
aosTime = route.aosTime,
|
||||
navigateUp = navigateBack
|
||||
)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
}
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
}
|
||||
}
|
||||
.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 = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, trackingState.currentPass?.name ?: ""),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||
Text(
|
||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
// Radio tracking status banner
|
||||
if (trackingState.isActive) {
|
||||
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 {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
backStack.add(Screen.RadioControl(pass.catNum, pass.aosTime))
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
// 更多菜单弹出面板(覆盖在内容上, 底部栏上方; spring 弹跳)
|
||||
AnimatedVisibility(
|
||||
visible = moreExpanded,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enter = expandVertically(
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||
Text(
|
||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
MoreMenuPopup(
|
||||
items = moreNavItems,
|
||||
currentKey = currentKey,
|
||||
onDismiss = { moreExpanded = false },
|
||||
onSelect = { screen ->
|
||||
moreExpanded = false
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
if (screen !is Screen.Passes) backStack.add(screen)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* MoreMenuPopup.kt — 底部导航「更多」二级菜单弹出面板(4.5.1)。
|
||||
*
|
||||
* 覆盖在内容区上(底部栏上方, 右对齐), 竖排菜单项(图标+文本+箭头),
|
||||
* 当前页高亮; 点击遮罩关闭, 点击项跳转。弹出/收起动画由调用处
|
||||
* (MainScreen 的 AnimatedVisibility + spring)驱动。
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
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.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.rtbishop.look4sat.core.presentation.Screen
|
||||
|
||||
@Composable
|
||||
fun MoreMenuPopup(
|
||||
items: List<Screen>,
|
||||
currentKey: NavKey?,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (Screen) -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
|
||||
.clickable(onClick = onDismiss)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(12.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
items.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(screen) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(screen.iconResId),
|
||||
contentDescription = stringResource(screen.titleResId),
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(screen.titleResId),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "›",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="#FF000000"
|
||||
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
+4
-1
@@ -31,12 +31,15 @@ internal class ApplicationPlugin : Plugin<Project> {
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:presentation"))
|
||||
implementation(project(":feature:cw"))
|
||||
implementation(project(":feature:map"))
|
||||
implementation(project(":feature:mutual"))
|
||||
implementation(project(":feature:passes"))
|
||||
implementation(project(":feature:radar"))
|
||||
implementation(project(":feature:radiocontrol"))
|
||||
implementation(project(":feature:roaming"))
|
||||
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)
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
|
||||
namespace = libs.versions.packageName.get()
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
defaultConfig {
|
||||
applicationId = libs.versions.packageName.get()
|
||||
applicationId = libs.versions.applicationId.get()
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
|
||||
@@ -8,5 +8,6 @@ plugins {
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete::class.java) {
|
||||
description = "Cleans the build directory"
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -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>
|
||||
+7
-4
@@ -17,6 +17,9 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
object Ft817CatProtocol {
|
||||
|
||||
const val CMD_SET_FREQ: Byte = 0x01
|
||||
@@ -50,7 +53,7 @@ object Ft817CatProtocol {
|
||||
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||
val freq10Hz = frequencyHz / 10
|
||||
val bcd = ByteArray(4)
|
||||
val digits = String.format("%08d", freq10Hz)
|
||||
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'
|
||||
@@ -78,8 +81,8 @@ object Ft817CatProtocol {
|
||||
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
||||
*/
|
||||
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||
val tone01Hz = (toneHz * 10).toLong()
|
||||
val digits = String.format("%04d", tone01Hz)
|
||||
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'
|
||||
@@ -95,7 +98,7 @@ object Ft817CatProtocol {
|
||||
}
|
||||
|
||||
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||
val modeByte = MODE_TO_BYTE[mode.uppercase()] ?: return null
|
||||
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
||||
}
|
||||
|
||||
|
||||
+67
-30
@@ -29,6 +29,7 @@ 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,
|
||||
@@ -39,10 +40,12 @@ class Ft817Controller(
|
||||
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
|
||||
@@ -57,6 +60,7 @@ class Ft817Controller(
|
||||
socket = btSocket
|
||||
outputStream = btSocket.outputStream
|
||||
inputStream = btSocket.inputStream
|
||||
ackReadFailureCount = 0
|
||||
isConnected = true
|
||||
Log.i(tag, "Connected to $deviceAddress")
|
||||
true
|
||||
@@ -79,6 +83,7 @@ class Ft817Controller(
|
||||
inputStream = null
|
||||
outputStream = null
|
||||
socket = null
|
||||
ackReadFailureCount = 0
|
||||
isConnected = false
|
||||
Log.i(tag, "Disconnected from $deviceAddress")
|
||||
}
|
||||
@@ -112,8 +117,8 @@ class Ft817Controller(
|
||||
ioMutex.withLock {
|
||||
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
||||
if (!sent) return@withContext null
|
||||
delay(commandDelayMs)
|
||||
val response = readResponse(5) ?: return@withContext null
|
||||
delay(commandDelayMs.milliseconds)
|
||||
val response = readResponse() ?: return@withContext null
|
||||
Ft817CatProtocol.parseReadResponse(response)
|
||||
}
|
||||
}
|
||||
@@ -127,44 +132,76 @@ class Ft817Controller(
|
||||
}
|
||||
|
||||
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
||||
return try {
|
||||
outputStream?.write(bytes) ?: return false
|
||||
outputStream?.flush()
|
||||
delay(commandDelayMs)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Send error: ${e.message}")
|
||||
isConnected = false
|
||||
false
|
||||
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 try {
|
||||
val ack = inputStream?.read() ?: return false
|
||||
ack == 0x00
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "ACK read error: ${e.message}")
|
||||
true // command was sent, ACK read failed - continue anyway
|
||||
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 fun readResponse(length: Int): ByteArray? {
|
||||
return try {
|
||||
val buffer = ByteArray(length)
|
||||
var read = 0
|
||||
while (read < length) {
|
||||
val count = inputStream?.read(buffer, read, length - read) ?: return null
|
||||
if (count < 0) return null
|
||||
read += count
|
||||
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
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
+403
-204
@@ -19,8 +19,10 @@ 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
|
||||
@@ -29,6 +31,7 @@ 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
|
||||
@@ -44,6 +47,8 @@ class RadioTrackingService(
|
||||
) : 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
|
||||
|
||||
@@ -51,227 +56,427 @@ class RadioTrackingService(
|
||||
private var rxController: IRadioController? = null
|
||||
private var trackingJob: Job? = null
|
||||
|
||||
// ── Connection ──────────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun connectRadios() {
|
||||
// Disconnect old controllers if any
|
||||
txController?.disconnect()
|
||||
rxController?.disconnect()
|
||||
|
||||
// Read current addresses from settings
|
||||
val rcSettings = settingsRepo.radioControlSettings.value
|
||||
val txAddr = rcSettings.txRadioAddress
|
||||
val rxAddr = rcSettings.rxRadioAddress
|
||||
val txAddr = rcSettings.txRadioAddress
|
||||
val rxAddr = rcSettings.rxRadioAddress
|
||||
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||
val isSplit = isIcom && rcSettings.splitMode
|
||||
|
||||
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
||||
Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
|
||||
|
||||
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||
_state.update { it.copy(errorMessage = "No radio addresses configured. Set them in Settings → FT-817.") }
|
||||
return
|
||||
}
|
||||
|
||||
val tx = Ft817Controller(bluetoothManager, txAddr)
|
||||
val rx = Ft817Controller(bluetoothManager, 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
|
||||
}
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
_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
|
||||
isActive = true,
|
||||
currentPass = pass,
|
||||
selectedTransponder = transponder,
|
||||
txBaseFrequencyHz = txBaseFreqHz
|
||||
)
|
||||
}
|
||||
trackingJob?.cancel()
|
||||
trackingJob = appScope.launch {
|
||||
// Set modes on both radios at tracking start
|
||||
val tx = txController
|
||||
val rx = rxController
|
||||
val txMode = transponder.uplinkMode
|
||||
val rxMode = transponder.downlinkMode
|
||||
?: transponder.uplinkMode?.let {
|
||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||
}
|
||||
if (tx != null && tx.isConnected && txMode != null) {
|
||||
tx.setMode(txMode)
|
||||
Log.i(tag, "TX mode set to $txMode")
|
||||
}
|
||||
if (rx != null && rx.isConnected && rxMode != null) {
|
||||
rx.setMode(rxMode)
|
||||
Log.i(tag, "RX mode set to $rxMode")
|
||||
}
|
||||
// Set CTCSS if FM
|
||||
if (txMode?.uppercase() == "FM") {
|
||||
_state.value.ctcssTone?.let { tone ->
|
||||
tx?.setCtcssTone(tone)
|
||||
tx?.setCtcssMode(true)
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||
|
||||
var lastSetTxFreq = 0.0
|
||||
var lastSetRxFreq = 0.0
|
||||
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
|
||||
var lastReadFreq = 0L
|
||||
var stableCount = 0
|
||||
val rcSettings = settingsRepo.radioControlSettings.value
|
||||
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||
val isSplit = isIcom && rcSettings.splitMode
|
||||
|
||||
while (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 timeNow = System.currentTimeMillis()
|
||||
|
||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
||||
val tx = txController
|
||||
val rx = rxController
|
||||
val hasUplink = txBaseFreq != null
|
||||
val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
||||
val v = pos.distanceRate * 1000.0
|
||||
|
||||
if (tuningRadio.isNotEmpty()) {
|
||||
// --- User is tuning: keep reading, wait for stabilization ---
|
||||
val radio = if (tuningRadio == "tx") tx else rx
|
||||
if (radio != null && radio.isConnected) {
|
||||
val readResult = radio.readFrequencyAndMode()
|
||||
if (readResult != null) {
|
||||
val (freq, _) = readResult
|
||||
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
|
||||
stableCount++
|
||||
} else {
|
||||
stableCount = 0
|
||||
lastReadFreq = freq
|
||||
}
|
||||
// Stable for 2 reads → user stopped turning
|
||||
if (stableCount >= 2) {
|
||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||
val newBase = (freq.toDouble() * c / (c + 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() * c / (c - 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 {
|
||||
// --- Normal tracking: read, detect changes, command ---
|
||||
|
||||
// TX dial feedback
|
||||
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
|
||||
val readResult = tx.readFrequencyAndMode()
|
||||
if (readResult != null) {
|
||||
val (actualTxFreq, _) = readResult
|
||||
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
|
||||
tuningRadio = "tx"
|
||||
lastReadFreq = actualTxFreq
|
||||
stableCount = 0
|
||||
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RX dial feedback (only if TX not tuning)
|
||||
if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) {
|
||||
val readResult = rx.readFrequencyAndMode()
|
||||
if (readResult != null) {
|
||||
val (actualRxFreq, _) = readResult
|
||||
if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) {
|
||||
tuningRadio = "rx"
|
||||
lastReadFreq = actualRxFreq
|
||||
stableCount = 0
|
||||
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute Doppler-corrected frequencies
|
||||
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) }
|
||||
|
||||
// Command radios (only when not tuning)
|
||||
if (tuningRadio.isEmpty()) {
|
||||
if (tx != null && tx.isConnected && txRadioFreq != null) {
|
||||
tx.setFrequency(txRadioFreq)
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
if (rx != null && rx.isConnected && rxRadioFreq != null) {
|
||||
rx.setFrequency(rxRadioFreq)
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
txConnected = tx?.isConnected ?: false,
|
||||
rxConnected = rx?.isConnected ?: false,
|
||||
txFrequencyHz = txRadioFreq,
|
||||
rxFrequencyHz = rxRadioFreq,
|
||||
azimuth = Math.toDegrees(pos.azimuth),
|
||||
elevation = Math.toDegrees(pos.elevation),
|
||||
distance = pos.distance
|
||||
)
|
||||
}
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
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
|
||||
@@ -288,7 +493,6 @@ class RadioTrackingService(
|
||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||
}
|
||||
rxMode?.let { rx?.setMode(it) }
|
||||
|
||||
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||
_state.value.ctcssTone?.let { tone ->
|
||||
tx?.setCtcssTone(tone)
|
||||
@@ -302,21 +506,17 @@ class RadioTrackingService(
|
||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||
else -> null
|
||||
}
|
||||
// Show nominal frequencies immediately
|
||||
val rxNominal = if (txCenter != null) {
|
||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||
} else {
|
||||
// Downlink-only transponder (beacon etc.) - use downlink directly
|
||||
transponder.downlinkLow
|
||||
}
|
||||
} else transponder.downlinkLow
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedTransponder = transponder,
|
||||
txBaseFrequencyHz = txCenter,
|
||||
txFrequencyHz = txCenter,
|
||||
rxFrequencyHz = rxNominal,
|
||||
txMode = transponder.uplinkMode,
|
||||
rxMode = transponder.downlinkMode
|
||||
txBaseFrequencyHz = txCenter,
|
||||
txFrequencyHz = txCenter,
|
||||
rxFrequencyHz = rxNominal,
|
||||
txMode = transponder.uplinkMode,
|
||||
rxMode = transponder.downlinkMode
|
||||
?: transponder.uplinkMode?.let { m ->
|
||||
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
||||
}
|
||||
@@ -353,5 +553,4 @@ class RadioTrackingService(
|
||||
}
|
||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||
}
|
||||
|
||||
}
|
||||
+82
-14
@@ -1,17 +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
|
||||
@@ -20,7 +38,13 @@ 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.wavelog.IWavelogQueueStore
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||
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.IRadioController
|
||||
@@ -30,35 +54,64 @@ 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.MutualPassData
|
||||
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
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
private val localSource = provideLocalSource()
|
||||
private val remoteSource by lazy { 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 = AmSatRepository(remoteSource)
|
||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
}
|
||||
|
||||
private val _mutualPassData = MutableStateFlow(MutualPassData())
|
||||
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
|
||||
|
||||
override fun setMutualPassData(data: MutualPassData) {
|
||||
_mutualPassData.value = data
|
||||
}
|
||||
|
||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||
|
||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||
|
||||
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
||||
|
||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||
|
||||
// WaveLog 日志(4.5.2): 本地队列 + 上传器(共享实例)
|
||||
override val wavelogQueue: WavelogQueue by lazy {
|
||||
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
|
||||
WavelogQueue(object : IWavelogQueueStore {
|
||||
override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
|
||||
override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
|
||||
})
|
||||
}
|
||||
override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
|
||||
|
||||
override fun provideBluetoothReporter(): IReporter {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val rc = settingsRepo.rcSettings.value
|
||||
@@ -82,28 +135,36 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
}
|
||||
|
||||
override fun provideTxRadioController(): IRadioController {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val address = settingsRepo.radioControlSettings.value.txRadioAddress
|
||||
return Ft817Controller(manager, address)
|
||||
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 address = settingsRepo.radioControlSettings.value.rxRadioAddress
|
||||
return Ft817Controller(manager, address)
|
||||
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)
|
||||
}
|
||||
|
||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||
val dbDispatcher = Dispatchers.Default
|
||||
val dataParser = DataParser(dbDispatcher)
|
||||
val remoteSource = provideRemoteSource()
|
||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
}
|
||||
|
||||
@@ -114,7 +175,14 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
}
|
||||
|
||||
private fun provideRemoteSource(): IRemoteSource {
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
||||
return RemoteSource(
|
||||
Dispatchers.IO, context.contentResolver,
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||
@@ -130,6 +198,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
val appPrefsFileName = "${context.packageName}_preferences"
|
||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
||||
return SettingsRepo(context, manager, appPreferences, appVersionName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.data.source.AmSatParser
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
|
||||
/** AMSAT 状态仓库: 抓 HTML → 解析 → SatStatusPage */
|
||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||
|
||||
override suspend fun fetchStatus(): SatStatusPage? {
|
||||
val html = remoteSource.getStatusHtml() ?: return null
|
||||
if (html.isBlank()) return null
|
||||
return try {
|
||||
AmSatParser.parse(html, System.currentTimeMillis())
|
||||
} catch (exception: Exception) {
|
||||
println("AmSatRepository parse exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
-38
@@ -40,57 +40,65 @@ class DatabaseRepo(
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : IDatabaseRepo {
|
||||
|
||||
private companion object {
|
||||
val tleTypes = setOf("Amsat", "R4UAB", "Other")
|
||||
val zippedTleTypes = setOf("McCants", "Classified")
|
||||
}
|
||||
private val customSourceType = "Other"
|
||||
|
||||
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = dataParser.parseTLEStream(stream)
|
||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||
localSource.insertEntries(entries)
|
||||
settingsRepo.setSatelliteTypeIds("Other", entries.map { it.catnum })
|
||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||
importedCount = entries.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
importedCount
|
||||
}
|
||||
|
||||
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
||||
remoteSource.getFileStream(uri)
|
||||
?.let { dataParser.parseJSONStream(it) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let {
|
||||
localSource.deleteRadios()
|
||||
localSource.insertRadios(it)
|
||||
}
|
||||
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 dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = buildMap {
|
||||
putAll(Sources.satelliteDataUrls)
|
||||
if (dataSourcesSettings.useCustomTLE) put("Other", dataSourcesSettings.tleUrl)
|
||||
}.filterValues { it.isNotEmpty() }
|
||||
|
||||
val radioUrls = buildList {
|
||||
add(Sources.RADIO_DATA_URL)
|
||||
if (dataSourcesSettings.useCustomTransceivers) add(dataSourcesSettings.transceiversUrl)
|
||||
}
|
||||
|
||||
// 开关开且 URL 非空 -> All 用自定义 URL;否则用默认 URL(在线更新默认源)
|
||||
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
||||
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
val radioUrls = buildMap {
|
||||
putAll(Sources.transceiversDataUrls)
|
||||
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
||||
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.map { url -> async { remoteSource.getNetworkStream(url) } }
|
||||
|
||||
// parse satellite data
|
||||
val importedEntries = tleJobs.awaitAll().flatMap { (type, stream) ->
|
||||
stream?.let { parseSatelliteStream(type, it) }.orEmpty().also { satellites ->
|
||||
settingsRepo.setSatelliteTypeIds(type, satellites.map { it.catnum })
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// 统计成功源数: 0 成功视为更新失败(不刷时间戳, 抛异常让 UI 提示)
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
||||
if (successCount == 0) {
|
||||
throw java.io.IOException("All data sources failed to download")
|
||||
}
|
||||
// parse fetched data concurrently and associate with types
|
||||
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||
}
|
||||
}
|
||||
|
||||
// parse radio data
|
||||
val importedRadios = radioJobs.awaitAll().filterNotNull().flatMap { dataParser.parseJSONStream(it) }
|
||||
|
||||
val importedRadios = radioResults.flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
localSource.insertRadios(importedRadios)
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -102,10 +110,31 @@ class DatabaseRepo(
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
private suspend fun parseSatelliteStream(type: String, stream: InputStream): List<OrbitalData> = when (type) {
|
||||
in tleTypes -> dataParser.parseTLEStream(stream)
|
||||
in zippedTleTypes -> dataParser.parseTLEStream(ZipInputStream(stream).apply { nextEntry })
|
||||
else -> dataParser.parseCSVStream(stream)
|
||||
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) {
|
||||
@@ -113,4 +142,7 @@ class DatabaseRepo(
|
||||
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
|
||||
}
|
||||
+76
-31
@@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
|
||||
class SatelliteRepo(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
@@ -52,13 +53,28 @@ class SatelliteRepo(
|
||||
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
|
||||
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
||||
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 = settings.selectedModes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,14 +95,8 @@ class SatelliteRepo(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRadios(
|
||||
sat: OrbitalObject,
|
||||
pos: GeoPos,
|
||||
radios: List<SatRadio>,
|
||||
time: Long
|
||||
): List<SatRadio> {
|
||||
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
|
||||
return withContext(dispatcher) {
|
||||
val satPos = sat.getPosition(pos, time)
|
||||
radios.map { transmitter ->
|
||||
transmitter.copy(
|
||||
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
||||
@@ -98,7 +108,15 @@ class SatelliteRepo(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
|
||||
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
|
||||
@@ -123,7 +141,12 @@ class SatelliteRepo(
|
||||
val newPasses = ArrayList<OrbitalPass>()
|
||||
for (list in passLists) {
|
||||
for (pass in list) {
|
||||
if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) {
|
||||
if (
|
||||
pass.losTime > time
|
||||
&& pass.aosTime < timeFuture
|
||||
&& pass.maxElevation > minElevation
|
||||
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||
) {
|
||||
newPasses.add(pass)
|
||||
}
|
||||
}
|
||||
@@ -135,21 +158,39 @@ class SatelliteRepo(
|
||||
_isCalculating.value = false
|
||||
}
|
||||
|
||||
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 {
|
||||
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
||||
}
|
||||
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
|
||||
@@ -160,17 +201,17 @@ class SatelliteRepo(
|
||||
return passes
|
||||
}
|
||||
|
||||
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
|
||||
@@ -195,13 +236,16 @@ class SatelliteRepo(
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation < 0.0)
|
||||
|
||||
// refine AOS to ~500ms precision
|
||||
calendarTimeMillis -= 60L * 1000L
|
||||
do {
|
||||
calendarTimeMillis += 500L
|
||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation < 0.0)
|
||||
// refine AOS to ~500ms precision via binary search.
|
||||
// Elevation is monotonic across the horizon crossing, so binary search
|
||||
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
|
||||
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
|
||||
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
|
||||
while (aosHi - aosLo > 500L) {
|
||||
val mid = (aosLo + aosHi) / 2
|
||||
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
|
||||
}
|
||||
calendarTimeMillis = aosHi
|
||||
|
||||
// Get full position for AOS data (azimuth, altitude)
|
||||
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||
@@ -215,13 +259,14 @@ class SatelliteRepo(
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation > 0.0)
|
||||
|
||||
// refine LOS to ~500ms precision
|
||||
calendarTimeMillis -= 30L * 1000L
|
||||
do {
|
||||
calendarTimeMillis += 500L
|
||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation > 0.0)
|
||||
// refine LOS to ~500ms precision via binary search (same monotonic argument)
|
||||
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
|
||||
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
|
||||
while (losHi - losLo > 500L) {
|
||||
val mid = (losLo + losHi) / 2
|
||||
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
|
||||
}
|
||||
calendarTimeMillis = losHi
|
||||
|
||||
// Get full position for LOS data (azimuth, altitude)
|
||||
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||
@@ -234,6 +279,6 @@ class SatelliteRepo(
|
||||
val alt = tcaPos.altitude
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,13 +32,16 @@ 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)
|
||||
@@ -45,11 +49,7 @@ class SensorsRepo(
|
||||
private var smoothPitch = 0f
|
||||
private var hasInitialReading = false
|
||||
|
||||
companion object {
|
||||
private const val SMOOTHING_FACTOR = 0.15f
|
||||
}
|
||||
|
||||
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
|
||||
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||
|
||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||
return GeomagneticField(
|
||||
@@ -62,7 +62,7 @@ class SensorsRepo(
|
||||
|
||||
override fun enableSensor() {
|
||||
hasInitialReading = false
|
||||
sensor?.let { sensorManager.registerListener(this, it, 8000) }
|
||||
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||
}
|
||||
|
||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||
@@ -70,16 +70,11 @@ class SensorsRepo(
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (event.sensor == sensor) updateOrientation(event.values)
|
||||
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||
}
|
||||
|
||||
private fun getDisplayRotation(): Int {
|
||||
return try {
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.rotation
|
||||
} catch (_: Exception) {
|
||||
Surface.ROTATION_0
|
||||
}
|
||||
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
|
||||
}
|
||||
|
||||
private fun remapForRotation(rotation: Int) {
|
||||
@@ -101,30 +96,23 @@ class SensorsRepo(
|
||||
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||
}
|
||||
|
||||
private fun updateOrientation(rotationVector: FloatArray) {
|
||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
|
||||
private fun handleSensorEvent(event: SensorEvent) {
|
||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||
remapForRotation(getDisplayRotation())
|
||||
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
||||
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
|
||||
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
|
||||
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
||||
val magneticAzimuth = (azimuth + 360f) % 360f
|
||||
|
||||
if (!hasInitialReading) {
|
||||
smoothAzimuth = magneticAzimuth
|
||||
smoothAzimuth = azimuth
|
||||
smoothPitch = pitch
|
||||
hasInitialReading = true
|
||||
} else {
|
||||
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth)
|
||||
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
|
||||
smoothPitch = lowPass(smoothPitch, pitch)
|
||||
}
|
||||
|
||||
_orientation.value = Pair(
|
||||
round(smoothAzimuth * 10) / 10,
|
||||
round(smoothPitch * 10) / 10
|
||||
)
|
||||
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
|
||||
}
|
||||
|
||||
/** Standard exponential low-pass filter. */
|
||||
private fun lowPass(previous: Float, current: Float): Float {
|
||||
return previous + SMOOTHING_FACTOR * (current - previous)
|
||||
}
|
||||
@@ -135,9 +123,10 @@ class SensorsRepo(
|
||||
*/
|
||||
private fun lowPassAngle(previous: Float, current: Float): Float {
|
||||
var delta = current - previous
|
||||
// Normalise delta into the range (-180, 180]
|
||||
while (delta > 180f) delta -= 360f
|
||||
while (delta <= -180f) delta += 360f
|
||||
return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f
|
||||
return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
|
||||
}
|
||||
|
||||
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
|
||||
}
|
||||
+108
-30
@@ -29,6 +29,7 @@ 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.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
@@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class SettingsRepo(
|
||||
private val context: android.content.Context,
|
||||
private val locationManager: LocationManager,
|
||||
private val preferences: SharedPreferences,
|
||||
override val appVersionName: String
|
||||
@@ -54,6 +56,9 @@ class SettingsRepo(
|
||||
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"
|
||||
@@ -73,6 +78,8 @@ class SettingsRepo(
|
||||
private val keyStateOfUtc = "stateOfUtc"
|
||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||
private val keyStateOfNightMode = "stateOfNightMode"
|
||||
private val keyHiddenScreens = "hiddenScreens"
|
||||
private val keyScreenOrder = "screenOrder"
|
||||
private val keyStationAltitude = "stationAltitude"
|
||||
private val keyStationLatitude = "stationLatitude"
|
||||
private val keyStationLongitude = "stationLongitude"
|
||||
@@ -81,10 +88,18 @@ class SettingsRepo(
|
||||
private val keyUpdateTimestamp = "updateTimestamp"
|
||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||
private val keySstvMode = "sstvMode"
|
||||
private val keyLowElevation = "lowElevation"
|
||||
private val keyHighElevation = "highElevation"
|
||||
private val keyUseCustomTle = "useCustomTle"
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
private val keyTransceiversUrl = "transceiversUrl"
|
||||
private val keySubMenuOrder = "subMenuOrder"
|
||||
private val keyWavelogUrl = "wavelogUrl"
|
||||
private val keyWavelogApiKey = "wavelogApiKey"
|
||||
private val keyWavelogStationId = "wavelogStationId"
|
||||
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
||||
private val separatorComma = ","
|
||||
|
||||
//region # Satellites selection settings
|
||||
@@ -112,8 +127,8 @@ class SettingsRepo(
|
||||
}
|
||||
|
||||
private fun getSelectedTypes(): List<String> {
|
||||
val typesString = preferences.getString(keySelectedTypes, null)
|
||||
if (typesString.isNullOrEmpty()) return listOf("Amateur")
|
||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||
if (typesString.isNullOrEmpty()) return emptyList()
|
||||
return typesString.split(separatorComma)
|
||||
}
|
||||
//endregion
|
||||
@@ -126,6 +141,9 @@ class SettingsRepo(
|
||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||
_passesSettings.value = settings
|
||||
}
|
||||
@@ -134,9 +152,20 @@ class SettingsRepo(
|
||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||
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)
|
||||
val selectedModesString = preferences.getString(keySelectedModes, null)
|
||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||
return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
|
||||
return PassesSettings(
|
||||
showDeepSpace,
|
||||
hoursAhead,
|
||||
minElevation,
|
||||
aosStartMinute,
|
||||
aosEndMinute,
|
||||
invertAosTimeWindow,
|
||||
selectedModes
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
|
||||
@@ -158,23 +187,41 @@ class SettingsRepo(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setStationPosition(): Boolean {
|
||||
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||
try {
|
||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
|
||||
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
|
||||
val location = locationManager.getLastKnownLocation(providerDef)
|
||||
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
|
||||
println("Requesting location for $provider provider")
|
||||
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
|
||||
} else {
|
||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||
}
|
||||
} catch (exception: SecurityException) {
|
||||
println("No permissions were given - $exception")
|
||||
/** GPS 定位: 一次性 getCurrentLocation(GPS 优先, 15 秒超时), 拿到位置才返回 true */
|
||||
override suspend fun setStationPosition(): Boolean {
|
||||
// 权限前置: 无定位权限直接失败(不吞异常)
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
println("GPS: no fine location permission")
|
||||
return false
|
||||
}
|
||||
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
|
||||
val signal = android.os.CancellationSignal()
|
||||
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
val executor = java.util.concurrent.Executor { handler.post(it) }
|
||||
// 15 秒超时
|
||||
val timeout = handler.postDelayed({
|
||||
signal.cancel()
|
||||
if (cont.isActive) cont.resume(false) { }
|
||||
}, 15_000L)
|
||||
val listener = androidx.core.util.Consumer<Location> { location ->
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||
if (cont.isActive) cont.resume(true) { }
|
||||
}
|
||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||
val provider = if (hasGps) providerGps else providerNet
|
||||
try {
|
||||
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
|
||||
} catch (exception: SecurityException) {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
if (cont.isActive) cont.resume(false) { }
|
||||
}
|
||||
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean {
|
||||
@@ -193,8 +240,8 @@ class SettingsRepo(
|
||||
}
|
||||
|
||||
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
|
||||
val newLat = latitude.round(4)
|
||||
val newLon = longitude.round(4)
|
||||
val newLat = latitude.round(5)
|
||||
val newLon = longitude.round(5)
|
||||
val newAlt = altitude.round(1)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
|
||||
@@ -333,6 +380,16 @@ class SettingsRepo(
|
||||
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())
|
||||
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
|
||||
putString(keyScreenOrder, new.screenOrder.joinToString(","))
|
||||
putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
|
||||
putString(keyWavelogUrl, new.wavelogUrl)
|
||||
putString(keyWavelogApiKey, new.wavelogApiKey)
|
||||
putString(keyWavelogStationId, new.wavelogStationId)
|
||||
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -346,7 +403,17 @@ class SettingsRepo(
|
||||
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",
|
||||
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
|
||||
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||
subMenuOrder = preferences.getString(keySubMenuOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
|
||||
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
|
||||
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
|
||||
)
|
||||
//endregion
|
||||
|
||||
@@ -364,12 +431,20 @@ class SettingsRepo(
|
||||
_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") ?: ""
|
||||
)
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||
// 4.4.8 修复: 旧版 example.com 占位 URL 视为未配置 -> 替换为真实默认 URL 且开关强制关闭,
|
||||
// 否则在线更新的 All/SatNOGS 源会指向错误地址导致更新失败
|
||||
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
||||
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
||||
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
|
||||
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
|
||||
return DataSourcesSettings(
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
||||
tleUrl = tleUrl,
|
||||
transceiversUrl = txUrl
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
@@ -380,6 +455,7 @@ class SettingsRepo(
|
||||
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
|
||||
@@ -393,18 +469,20 @@ class SettingsRepo(
|
||||
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) ?: "Yaesu FT-817/818",
|
||||
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)
|
||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||
)
|
||||
//endregion
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.rtbishop.look4sat.core.data.source
|
||||
|
||||
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 java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* AMSAT 卫星状态页解析器(https://amsat.org/status/)
|
||||
*
|
||||
* 页面结构(静态 HTML, 2026-08 实测):
|
||||
* - 状态表: <table> 内 48 行, 表头 = Name + 6 天(每个 colspan=12)
|
||||
* 数据行 73 格: [0]=卫星名, [1..72] = 6 天 × 12 个 2 小时槽(新→旧)
|
||||
* 每格 <td width=9 bgcolor="颜色">数字</td>, 有报告时数字带
|
||||
* docTips.show('id') 链接
|
||||
* - 报告详情在页面内嵌 JS:
|
||||
* tips.a885153 = new Array(5,5,120,'状态<br>呼号<br>网格<br>日期<br>时间段 UTC')
|
||||
*
|
||||
* 状态色: #648fff=活跃, #ffb000=仅遥测, #dc267f=未听到, #fe6100=矛盾
|
||||
*/
|
||||
object AmSatParser {
|
||||
|
||||
private val STATUS_COLORS = mapOf(
|
||||
"#648fff" to 0xFF648FFF.toLong(),
|
||||
"#ffb000" to 0xFFFFB000.toLong(),
|
||||
"#dc267f" to 0xFFDC267F.toLong(),
|
||||
"#fe6100" to 0xFFFE6100.toLong()
|
||||
)
|
||||
private val GRAY = 0xFFC0C0C0.toLong()
|
||||
|
||||
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
|
||||
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
|
||||
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
|
||||
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
|
||||
private val tipRe = Pattern.compile(
|
||||
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
|
||||
)
|
||||
|
||||
/** 解析完整页面 */
|
||||
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
|
||||
val reports = parseReports(html)
|
||||
val statuses = parseStatusTable(html, reports)
|
||||
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
|
||||
}
|
||||
|
||||
/** 提取全部报告(tips.* JS 数组) */
|
||||
fun parseReports(html: String): Map<String, SatReport> {
|
||||
val map = mutableMapOf<String, SatReport>()
|
||||
val m = tipRe.matcher(html)
|
||||
while (m.find()) {
|
||||
val id = m.group(1)
|
||||
val parts = m.group(2).split("<br>")
|
||||
map[id] = SatReport(
|
||||
id = id,
|
||||
statusText = parts.getOrElse(0) { "" }.trim(),
|
||||
call = parts.getOrElse(1) { "" }.trim(),
|
||||
grid = parts.getOrElse(2) { "" }.trim(),
|
||||
dateUtc = parts.getOrElse(3) { "" }.trim(),
|
||||
timeUtc = parts.getOrElse(4) { "" }.trim()
|
||||
)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** 解析状态表(48 行卫星) */
|
||||
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
|
||||
val result = mutableListOf<SatStatus>()
|
||||
val tables = extractTables(html)
|
||||
for (table in tables) {
|
||||
val rows = rowRe.matcher(table)
|
||||
val parsed = mutableListOf<SatStatus>()
|
||||
var rowIndex = 0
|
||||
var dayHeaders: List<String> = emptyList()
|
||||
while (rows.find()) {
|
||||
val rowHtml = rows.group(1)
|
||||
val cells = cellRe.matcher(rowHtml)
|
||||
val cellList = mutableListOf<String>()
|
||||
while (cells.find()) cellList.add(cells.group(1))
|
||||
if (rowIndex == 0) {
|
||||
// 表头: Name + 6 天(每个 colspan=12)
|
||||
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
|
||||
rowIndex++
|
||||
continue
|
||||
}
|
||||
if (cellList.size < 7) { rowIndex++; continue }
|
||||
val name = stripHtml(cellList[0])
|
||||
if (name.isBlank()) { rowIndex++; continue }
|
||||
val days = mutableListOf<SatDay>()
|
||||
for (d in 0 until 6) {
|
||||
val start = 1 + d * 12
|
||||
val end = start + 12
|
||||
val slots = (start until end).mapNotNull { i ->
|
||||
cellList.getOrNull(i)?.let { cell ->
|
||||
val bg = bgRe.matcher(cell)
|
||||
val color = if (bg.find()) {
|
||||
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
|
||||
} else GRAY
|
||||
val link = linkRe.matcher(cell)
|
||||
val ids = mutableListOf<String>()
|
||||
while (link.find()) ids.add(link.group(1))
|
||||
val hasReport = ids.isNotEmpty()
|
||||
val count = if (hasReport) {
|
||||
stripHtml(cell).trim().toIntOrNull() ?: ids.size
|
||||
} else 0
|
||||
SatSlot(
|
||||
statusColor = if (hasReport) color else GRAY,
|
||||
count = count,
|
||||
reportIds = ids
|
||||
)
|
||||
}
|
||||
}
|
||||
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
|
||||
}
|
||||
parsed.add(SatStatus(name, days))
|
||||
rowIndex++
|
||||
}
|
||||
if (parsed.isNotEmpty()) {
|
||||
result.addAll(parsed)
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun extractTables(html: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
|
||||
while (m.find()) result.add(m.group())
|
||||
return result
|
||||
}
|
||||
|
||||
private fun stripHtml(s: String): String =
|
||||
s.replace(Regex("<[^>]+>"), "").trim()
|
||||
}
|
||||
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
}
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
look4SatDao.deleteRadios()
|
||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
@@ -42,10 +43,29 @@ class RemoteSource(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://amsat.org/status/")
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat status exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
httpClient.newCall(networkRequest).execute().body.byteStream()
|
||||
httpClient.newCall(networkRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
ByteArrayInputStream(response.body.bytes())
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource network stream 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
||||
override fun invoke(message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun invoke(resId: Int) {
|
||||
invoke(context.getString(resId))
|
||||
}
|
||||
}
|
||||
+20
-19
@@ -1,30 +1,29 @@
|
||||
package com.rtbishop.look4sat.core.data
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817CatProtocol
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class Ft817CatProtocolTest {
|
||||
|
||||
@Test
|
||||
fun encodeFrequencyBcd_145500000() {
|
||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
||||
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeFrequencyBcd_435100000() {
|
||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
||||
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||
assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeFrequencyBcd_7074000() {
|
||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
||||
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||
assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -42,21 +41,21 @@ class Ft817CatProtocolTest {
|
||||
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
||||
assertEquals(5, cmd.size)
|
||||
assertEquals(0x01.toByte(), cmd[4])
|
||||
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildSetModeCommand_usb() {
|
||||
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
||||
assertNotNull(cmd)
|
||||
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||
assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildSetModeCommand_fm() {
|
||||
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
||||
assertNotNull(cmd)
|
||||
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||
assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -67,19 +66,19 @@ class Ft817CatProtocolTest {
|
||||
@Test
|
||||
fun encodeCtcssTone_67_0() {
|
||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
||||
assertContentEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||
assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeCtcssTone_74_4() {
|
||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
||||
assertContentEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||
assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeCtcssTone_141_3() {
|
||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
||||
assertContentEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||
assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -87,13 +86,13 @@ class Ft817CatProtocolTest {
|
||||
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
||||
assertEquals(5, cmd.size)
|
||||
assertEquals(0x0B.toByte(), cmd[4])
|
||||
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||
assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildCtcssModeCommand_enable() {
|
||||
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
||||
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||
assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -108,6 +107,7 @@ class Ft817CatProtocolTest {
|
||||
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)
|
||||
}
|
||||
@@ -117,6 +117,7 @@ class Ft817CatProtocolTest {
|
||||
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)
|
||||
}
|
||||
@@ -135,7 +136,7 @@ class Ft817CatProtocolTest {
|
||||
@Test
|
||||
fun buildPttCommands() {
|
||||
val on = Ft817CatProtocol.buildPttOnCommand()
|
||||
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||
|
||||
val off = Ft817CatProtocol.buildPttOffCommand()
|
||||
assertEquals(0x88.toByte(), off[4])
|
||||
@@ -144,6 +145,6 @@ class Ft817CatProtocolTest {
|
||||
@Test
|
||||
fun buildReadCommand() {
|
||||
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
||||
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* 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)
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
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(
|
||||
useCustomTLE = true,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = customCsvUrl,
|
||||
transceiversUrl = ""
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||
// 新语义: 开关开 + URL 非空 -> All 源用自定义 URL, 数据归入 All 类型
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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 selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||
)
|
||||
|
||||
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, "", "", "", 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)
|
||||
)
|
||||
|
||||
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
|
||||
override fun setSelectedTypes(types: 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 getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||
satelliteTypeIdsByType[type] = ids
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
return DataSourcesSettings(
|
||||
useCustomTLE = false,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = "",
|
||||
transceiversUrl = ""
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.coreDomainPlugin)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
|
||||
compileOnly("org.json:json:20240303")
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
/**
|
||||
* Bayesian Morse timing decoder.
|
||||
* Replaces hard thresholds with probability-based decision making.
|
||||
*
|
||||
* Inspired by VE3NEA's CW Skimmer approach:
|
||||
* "Instead of making a hard decision at every input sample whether the signal
|
||||
* is present or not, compute the probability that the signal is present."
|
||||
*
|
||||
* Uses Gaussian probability density centered on expected durations:
|
||||
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
|
||||
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
|
||||
*/
|
||||
internal class CwBayesianDecoder {
|
||||
|
||||
// Morse timing parameters
|
||||
private var dotDurationMs = 60f // initial 20 WPM
|
||||
private var speedWpm = 20f
|
||||
|
||||
// Current symbol being accumulated
|
||||
private var currentSymbol = StringBuilder()
|
||||
private var textBuffer = StringBuilder()
|
||||
|
||||
// Recent dit lengths for speed estimation
|
||||
private val recentDits = mutableListOf<Float>()
|
||||
|
||||
// Output
|
||||
private var _decodedText = ""
|
||||
val decodedText: String get() = _decodedText
|
||||
|
||||
/** Gaussian probability. */
|
||||
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
|
||||
if (varianceMs <= 0f) return 0f
|
||||
val diff = durationMs - expectedMs
|
||||
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
|
||||
}
|
||||
|
||||
/** Process a tone duration. Returns the symbol type with highest probability. */
|
||||
fun processTone(durationMs: Float): ToneResult {
|
||||
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
|
||||
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
||||
|
||||
return if (ditProb > dashProb && ditProb > 0.05f) {
|
||||
currentSymbol.append('0')
|
||||
recentDits.add(durationMs)
|
||||
updateSpeed()
|
||||
ToneResult('0', ditProb)
|
||||
} else if (dashProb > 0.05f) {
|
||||
currentSymbol.append('1')
|
||||
ToneResult('1', dashProb)
|
||||
} else {
|
||||
ToneResult(null, 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/** Process a gap duration. Returns decoded character or null. */
|
||||
fun processGap(durationMs: Float): Char? {
|
||||
if (currentSymbol.isEmpty()) {
|
||||
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
||||
if (wordProb > 0.2f) {
|
||||
textBuffer.append(' ')
|
||||
_decodedText = textBuffer.toString()
|
||||
return ' '
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
||||
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
||||
|
||||
if (wordProb > interCharProb && wordProb > 0.2f) {
|
||||
val char = flushSymbol()
|
||||
textBuffer.append(' ')
|
||||
_decodedText = textBuffer.toString()
|
||||
return char
|
||||
}
|
||||
if (interCharProb > 0.15f) {
|
||||
val char = flushSymbol()
|
||||
_decodedText = textBuffer.toString()
|
||||
return char
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun flushSymbol(): Char? {
|
||||
if (currentSymbol.isEmpty()) return null
|
||||
val morse = currentSymbol.toString()
|
||||
currentSymbol.clear()
|
||||
val char = morseToChar(morse)
|
||||
if (char != null) textBuffer.append(char)
|
||||
return char
|
||||
}
|
||||
|
||||
private fun updateSpeed() {
|
||||
if (recentDits.size < 3) return
|
||||
val sorted = recentDits.sorted()
|
||||
val median = sorted[sorted.size / 2]
|
||||
if (median > 0f) {
|
||||
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
|
||||
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
|
||||
if (wpm in 5f..55f) speedWpm = wpm
|
||||
}
|
||||
}
|
||||
|
||||
fun getSpeed(): Float = speedWpm
|
||||
|
||||
fun reset() {
|
||||
dotDurationMs = 60f
|
||||
speedWpm = 20f
|
||||
recentDits.clear()
|
||||
currentSymbol.clear()
|
||||
textBuffer.clear()
|
||||
_decodedText = ""
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val MORSE_TABLE = mapOf(
|
||||
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
|
||||
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
|
||||
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
|
||||
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
|
||||
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
|
||||
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
|
||||
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
|
||||
"11100" to '8', "11110" to '9', "11111" to '0',
|
||||
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
|
||||
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
|
||||
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
|
||||
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
|
||||
"0001001" to '$', "011010" to '@'
|
||||
)
|
||||
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
|
||||
}
|
||||
}
|
||||
|
||||
data class ToneResult(val symbol: Char?, val probability: Float)
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
/**
|
||||
* Multi-channel CW signal tracker.
|
||||
* Monitors the spectrogram for active frequency bins and extracts
|
||||
* energy envelopes for each detected signal.
|
||||
*
|
||||
* Inspired by CW Skimmer's multi-channel approach:
|
||||
* tracks all active signals in the passband simultaneously,
|
||||
* selects the best one for decoded output.
|
||||
*/
|
||||
internal class CwChannelTracker(
|
||||
private val spectrogram: CwSpectrogram,
|
||||
private val maxChannels: Int = 3
|
||||
) {
|
||||
data class Channel(
|
||||
val bin: Int,
|
||||
val frequency: Float,
|
||||
var active: Boolean = false,
|
||||
var energy: Float = 0f,
|
||||
val history: MutableList<Float> = mutableListOf(),
|
||||
var confidence: Float = 0f
|
||||
)
|
||||
|
||||
private val channels = Array(maxChannels) { Channel(0, 0f) }
|
||||
|
||||
/** Scan the current spectrogram column and update channel tracking. */
|
||||
fun update(): List<Channel> {
|
||||
val col = spectrogram.getCurrentColumn()
|
||||
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
|
||||
|
||||
// Update existing channels
|
||||
for (ch in channels) {
|
||||
if (ch.active) {
|
||||
if (peaks.contains(ch.bin)) {
|
||||
ch.energy = col[ch.bin]
|
||||
ch.history.add(ch.energy)
|
||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||
ch.confidence = computeConfidence(ch.history)
|
||||
} else {
|
||||
// Signal lost — decay confidence
|
||||
ch.history.add(0f)
|
||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||
ch.confidence *= 0.9f
|
||||
if (ch.confidence < 0.1f) ch.active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign new peaks to inactive channels
|
||||
var peakIdx = 0
|
||||
for (ch in channels) {
|
||||
if (!ch.active && peakIdx < peaks.size) {
|
||||
val bin = peaks[peakIdx]
|
||||
val freq = spectrogram.binToFreq(bin)
|
||||
// Re-initialize channel
|
||||
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
|
||||
peakIdx++
|
||||
}
|
||||
}
|
||||
|
||||
return channels.filter { it.active }
|
||||
}
|
||||
|
||||
/** Find peak bins in the spectrum. */
|
||||
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
|
||||
val peaks = mutableListOf<Int>()
|
||||
for (i in 1 until spectrum.size - 1) {
|
||||
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
|
||||
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
|
||||
peaks.add(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return peaks.sortedByDescending { spectrum[it] }
|
||||
}
|
||||
|
||||
/** Compute confidence from energy history. Lower variance = higher confidence. */
|
||||
private fun computeConfidence(history: List<Float>): Float {
|
||||
if (history.size < 10) return 0.3f
|
||||
val recent = history.takeLast(10)
|
||||
val mean = recent.average().toFloat()
|
||||
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
|
||||
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
|
||||
}
|
||||
|
||||
/** Get the channel with highest confidence. */
|
||||
fun getBestChannel(): Channel? {
|
||||
return channels.filter { it.active }.maxByOrNull { it.confidence }
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
for (i in channels.indices) {
|
||||
channels[i] = Channel(0, 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
|
||||
*
|
||||
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
|
||||
* 1. FFT spectrogram creates a frequency×time matrix
|
||||
* 2. Multi-channel peak detector finds all active signals
|
||||
* 3. Per-channel energy envelope extraction
|
||||
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
|
||||
* 5. Best channel selected for output
|
||||
*
|
||||
* Timing analysis is performed per spectrogram column (hop).
|
||||
* Each column represents hopSize/sampleRate seconds of audio.
|
||||
*/
|
||||
class CwDecoder(
|
||||
val sampleRate: Int = 8000,
|
||||
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
|
||||
) {
|
||||
companion object {
|
||||
private const val FFT_SIZE = 256
|
||||
private const val HOP_SIZE = 64
|
||||
}
|
||||
|
||||
private val spectrogram = CwSpectrogram(
|
||||
fftSize = FFT_SIZE,
|
||||
hopSize = HOP_SIZE,
|
||||
sampleRate = sampleRate,
|
||||
minBin = 6,
|
||||
maxBin = 38,
|
||||
historyCols = 40
|
||||
)
|
||||
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
|
||||
private val bayesianDecoder = CwBayesianDecoder()
|
||||
|
||||
// Timing state per channel
|
||||
private data class ChannelTiming(
|
||||
var isSignal: Boolean = false,
|
||||
var toneTicks: Int = 0,
|
||||
var gapTicks: Int = 0
|
||||
)
|
||||
private val timingStates = Array(3) { ChannelTiming() }
|
||||
|
||||
// Time per spectrogram column in milliseconds
|
||||
private val tickMs = 1000f * HOP_SIZE / sampleRate
|
||||
|
||||
// Output flows
|
||||
private val _decodedTextFlow = MutableStateFlow("")
|
||||
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
|
||||
|
||||
private val _signalStrength = MutableStateFlow(0f)
|
||||
val signalStrength: StateFlow<Float> = _signalStrength
|
||||
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
|
||||
|
||||
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
||||
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
||||
|
||||
private var frameCount = 0
|
||||
|
||||
init {
|
||||
if (cwToneFreq > 0f) {
|
||||
_estimatedPitch.value = cwToneFreq
|
||||
}
|
||||
}
|
||||
|
||||
fun processBuffer(buffer: FloatArray) {
|
||||
// 1. Feed samples to spectrogram
|
||||
spectrogram.addSamples(buffer)
|
||||
|
||||
// 2. Get number of new columns generated
|
||||
val newCols = spectrogram.getNewColumns()
|
||||
if (newCols == 0) return
|
||||
|
||||
// 3. Update channel tracker (uses latest column for peak detection)
|
||||
val activeChannels = channelTracker.update()
|
||||
|
||||
// 4. Process each new column for timing analysis
|
||||
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
|
||||
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
|
||||
for (colOffset in 0 until newCols) {
|
||||
val col = spectrogram.getColumn(baseIdx + colOffset)
|
||||
|
||||
for ((idx, channel) in activeChannels.withIndex()) {
|
||||
if (idx >= timingStates.size) break
|
||||
val state = timingStates[idx]
|
||||
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
|
||||
|
||||
// Adaptive threshold
|
||||
val threshold = 0.3f + (energy - 0.3f) * 0.3f
|
||||
|
||||
if (energy > threshold) {
|
||||
if (!state.isSignal) {
|
||||
if (state.gapTicks > 0) {
|
||||
val gapMs = state.gapTicks * tickMs
|
||||
bayesianDecoder.processGap(gapMs)
|
||||
}
|
||||
state.gapTicks = 0
|
||||
state.isSignal = true
|
||||
}
|
||||
state.toneTicks++
|
||||
} else {
|
||||
if (state.isSignal) {
|
||||
if (state.toneTicks > 0) {
|
||||
val toneMs = state.toneTicks * tickMs
|
||||
bayesianDecoder.processTone(toneMs)
|
||||
}
|
||||
state.toneTicks = 0
|
||||
state.isSignal = false
|
||||
}
|
||||
state.gapTicks++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Update outputs
|
||||
frameCount++
|
||||
if (frameCount % 5 == 0) {
|
||||
val bestChannel = channelTracker.getBestChannel()
|
||||
if (bestChannel != null) {
|
||||
_estimatedPitch.value = bestChannel.frequency
|
||||
_signalStrength.value = bestChannel.confidence
|
||||
_estimatedSpeed.value = bayesianDecoder.getSpeed()
|
||||
}
|
||||
_decodedTextFlow.value = bayesianDecoder.decodedText
|
||||
}
|
||||
}
|
||||
|
||||
fun resetDecoder() {
|
||||
spectrogram.reset()
|
||||
channelTracker.reset()
|
||||
bayesianDecoder.reset()
|
||||
for (state in timingStates) {
|
||||
state.isSignal = false
|
||||
state.toneTicks = 0
|
||||
state.gapTicks = 0
|
||||
}
|
||||
frameCount = 0
|
||||
_decodedTextFlow.value = ""
|
||||
_signalStrength.value = 0f
|
||||
_estimatedPitch.value = null
|
||||
_estimatedSpeed.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* DSP utilities for CW (Morse code) decoding.
|
||||
* Pure Kotlin, no NDK required.
|
||||
*/
|
||||
internal object CwDsp {
|
||||
|
||||
/**
|
||||
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
|
||||
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
|
||||
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
|
||||
* @param taps filter length (must be odd)
|
||||
*/
|
||||
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
|
||||
val n = if (taps % 2 == 0) taps + 1 else taps
|
||||
val half = n / 2
|
||||
val coeffs = FloatArray(n)
|
||||
for (i in 0 until n) {
|
||||
val idx = i - half
|
||||
if (idx == 0) {
|
||||
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
|
||||
} else {
|
||||
val x = PI * idx
|
||||
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
|
||||
}
|
||||
// Hamming window
|
||||
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
|
||||
}
|
||||
// Normalize
|
||||
val sum = coeffs.sum()
|
||||
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
|
||||
return coeffs
|
||||
}
|
||||
|
||||
/** Apply FIR filter to a buffer. */
|
||||
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
|
||||
val out = FloatArray(buffer.size)
|
||||
for (i in buffer.indices) {
|
||||
var sum = 0f
|
||||
for (j in coeffs.indices) {
|
||||
val idx = i - j
|
||||
if (idx >= 0) sum += buffer[idx] * coeffs[j]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Simple envelope detector: abs + low-pass smoothing. */
|
||||
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
|
||||
val env = FloatArray(signal.size)
|
||||
var s = 0f
|
||||
for (i in signal.indices) {
|
||||
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
|
||||
env[i] = s
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Estimate noise floor from envelope for adaptive thresholding. */
|
||||
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
|
||||
val sorted = env.sortedArray()
|
||||
val median = sorted[sorted.size / 2]
|
||||
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
|
||||
}
|
||||
|
||||
/** Simple Goertzel to detect a specific tone frequency. */
|
||||
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
|
||||
val omega = 2.0 * PI * targetFreq / sampleRate
|
||||
val coeff = 2.0 * cos(omega)
|
||||
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
|
||||
for (sample in buffer) {
|
||||
s0 = sample.toDouble() + coeff * s1 - s2
|
||||
s2 = s1; s1 = s0
|
||||
}
|
||||
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
|
||||
return sqrt(kotlin.math.abs(power)).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Radix-2 FFT for real-valued input.
|
||||
* Produces magnitude spectrum for the first N/2+1 bins.
|
||||
* Used by CwSpectrogram for time-frequency analysis.
|
||||
*/
|
||||
internal class CwFFT(private val n: Int) {
|
||||
init {
|
||||
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
|
||||
}
|
||||
|
||||
private val cosTable = FloatArray(n / 2)
|
||||
private val sinTable = FloatArray(n / 2)
|
||||
|
||||
init {
|
||||
for (i in 0 until n / 2) {
|
||||
val angle = -2.0 * kotlin.math.PI * i / n
|
||||
cosTable[i] = cos(angle).toFloat()
|
||||
sinTable[i] = kotlin.math.sin(angle).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
|
||||
fun magnitudeSpectrum(input: FloatArray): FloatArray {
|
||||
require(input.size == n) { "Input size must be $n, got ${input.size}" }
|
||||
|
||||
val real = input.copyOf()
|
||||
val imag = FloatArray(n)
|
||||
|
||||
// Bit-reversal permutation
|
||||
var j = 0
|
||||
for (i in 1 until n) {
|
||||
var bit = n shr 1
|
||||
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
||||
j = j xor bit
|
||||
if (i < j) {
|
||||
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
||||
}
|
||||
}
|
||||
|
||||
// Radix-2 Cooley-Tukey FFT
|
||||
var len = 2
|
||||
while (len <= n) {
|
||||
val half = len / 2
|
||||
val step = n / len
|
||||
for (i in 0 until n step len) {
|
||||
for (k in 0 until half) {
|
||||
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
|
||||
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
|
||||
real[i + k + half] = real[i + k] - tReal
|
||||
imag[i + k + half] = imag[i + k] - tImag
|
||||
real[i + k] += tReal
|
||||
imag[i + k] += tImag
|
||||
}
|
||||
}
|
||||
len = len shl 1
|
||||
}
|
||||
|
||||
// Magnitude spectrum (first N/2+1 bins)
|
||||
val mag = FloatArray(n / 2 + 1)
|
||||
for (i in 0..n / 2) {
|
||||
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
|
||||
}
|
||||
return mag
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
/**
|
||||
* First-order IIR filters.
|
||||
* Ported from ggmorse/src/filter.h
|
||||
*/
|
||||
internal class CwFilter {
|
||||
private var z1 = 0f
|
||||
|
||||
companion object {
|
||||
private const val PI_F = 3.141592653589793f
|
||||
}
|
||||
|
||||
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||
val dt = 1.0f / sampleRate
|
||||
val alpha = dt / (rc + dt)
|
||||
z1 = alpha * (z1 + sample - z1)
|
||||
return sample - z1
|
||||
}
|
||||
|
||||
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||
val dt = 1.0f / sampleRate
|
||||
val alpha = dt / (rc + dt)
|
||||
z1 += alpha * (sample - z1)
|
||||
return z1
|
||||
}
|
||||
|
||||
fun reset() { z1 = 0f }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Running Goertzel filter for CW tone detection.
|
||||
* Tracks a specific frequency over time with a sliding window.
|
||||
* Ported from ggmorse/src/goertzel.h
|
||||
*/
|
||||
internal class CwGoertzel {
|
||||
private var s1 = 0.0
|
||||
private var s2 = 0.0
|
||||
private var coeff = 0.0
|
||||
|
||||
fun init(sampleRate: Float, targetFreq: Float) {
|
||||
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
|
||||
coeff = 2.0 * cos(omega)
|
||||
s1 = 0.0
|
||||
s2 = 0.0
|
||||
}
|
||||
|
||||
fun process(sample: Float) {
|
||||
val s0 = sample.toDouble() + coeff * s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
}
|
||||
|
||||
fun getPower(): Float {
|
||||
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
s1 = 0.0
|
||||
s2 = 0.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
/**
|
||||
* Simple linear resampler.
|
||||
* Downsamples from input sample rate to output sample rate.
|
||||
* Ported from ggmorse/src/resampler.h
|
||||
*/
|
||||
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
|
||||
private val ratio = inputRate / outputRate
|
||||
private var lastSample = 0f
|
||||
|
||||
fun process(input: FloatArray): FloatArray {
|
||||
if (ratio <= 0f || input.isEmpty()) return input
|
||||
val outputLen = (input.size / ratio).toInt() + 1
|
||||
val output = FloatArray(outputLen)
|
||||
var idx = 0f
|
||||
for (i in output.indices) {
|
||||
val intIdx = idx.toInt()
|
||||
val frac = idx - intIdx
|
||||
if (intIdx + 1 < input.size) {
|
||||
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
|
||||
} else if (intIdx < input.size) {
|
||||
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
|
||||
} else {
|
||||
output[i] = lastSample
|
||||
}
|
||||
idx += ratio
|
||||
}
|
||||
lastSample = input.lastOrNull() ?: lastSample
|
||||
return output
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
lastSample = 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Lightweight pitch detector using DFT at specific frequency bins.
|
||||
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
|
||||
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
|
||||
*/
|
||||
internal class CwPitchDetector(
|
||||
private val sampleRate: Float,
|
||||
private val minFreq: Float = 200f,
|
||||
private val maxFreq: Float = 1200f,
|
||||
private val stepHz: Float = 10f
|
||||
) {
|
||||
/**
|
||||
* Find the dominant pitch frequency in the buffer.
|
||||
* Returns null if no significant pitch found.
|
||||
*/
|
||||
fun findPitch(buffer: FloatArray): Float? {
|
||||
if (buffer.isEmpty()) return null
|
||||
var bestFreq = 0f
|
||||
var bestPower = 0f
|
||||
var freq = minFreq
|
||||
while (freq <= maxFreq) {
|
||||
var real = 0.0
|
||||
var imag = 0.0
|
||||
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
|
||||
for (i in buffer.indices) {
|
||||
real += buffer[i] * cos(omega * i)
|
||||
imag += buffer[i] * -sin(omega * i)
|
||||
}
|
||||
val power = (real * real + imag * imag).toFloat()
|
||||
if (power > bestPower) {
|
||||
bestPower = power
|
||||
bestFreq = freq
|
||||
}
|
||||
freq += stepHz
|
||||
}
|
||||
return if (bestPower > 0.001f) bestFreq else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
/**
|
||||
* Sliding-window spectrogram for CW decoding.
|
||||
* Maintains a time-frequency matrix updated with each audio frame.
|
||||
*
|
||||
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
|
||||
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
|
||||
* History: 40 columns (320 ms window)
|
||||
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
|
||||
*/
|
||||
internal class CwSpectrogram(
|
||||
private val fftSize: Int = 256,
|
||||
private val hopSize: Int = 64,
|
||||
private val sampleRate: Int = 4000,
|
||||
private val minBin: Int = 6,
|
||||
private val maxBin: Int = 38,
|
||||
val historyCols: Int = 40
|
||||
) {
|
||||
private val fft = CwFFT(fftSize)
|
||||
val numBins: Int get() = maxBin - minBin + 1
|
||||
|
||||
// Hanning window
|
||||
private val hanning = FloatArray(fftSize) {
|
||||
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
|
||||
}
|
||||
|
||||
// Spectrogram data: [timeCol][freqBin]
|
||||
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
|
||||
private var currentCol = 0
|
||||
private var samplesBuffered = 0
|
||||
private val buffer = FloatArray(fftSize)
|
||||
|
||||
// Per-bin running energy for normalization
|
||||
private val binEnergy = FloatArray(numBins) { 1f }
|
||||
private val alpha = 0.95f
|
||||
|
||||
// Counter for new columns generated since last check
|
||||
private var newColumnCount = 0
|
||||
|
||||
/** Add audio samples, compute FFTs for each complete hop. */
|
||||
fun addSamples(samples: FloatArray) {
|
||||
var offset = 0
|
||||
while (offset < samples.size) {
|
||||
val needed = fftSize - samplesBuffered
|
||||
val copyLen = minOf(needed, samples.size - offset)
|
||||
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
|
||||
samplesBuffered += copyLen
|
||||
offset += copyLen
|
||||
|
||||
if (samplesBuffered >= fftSize) {
|
||||
processFrame()
|
||||
newColumnCount++
|
||||
// Shift buffer: keep last (fftSize - hopSize) samples
|
||||
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
|
||||
samplesBuffered = fftSize - hopSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get number of new columns generated since the last call to this method. */
|
||||
fun getNewColumns(): Int {
|
||||
val count = newColumnCount
|
||||
newColumnCount = 0
|
||||
return count
|
||||
}
|
||||
|
||||
private fun processFrame() {
|
||||
// Apply Hanning window
|
||||
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
|
||||
|
||||
// Compute FFT magnitude spectrum
|
||||
val mag = fft.magnitudeSpectrum(windowed)
|
||||
|
||||
// Update spectrogram column
|
||||
val col = spectrogram[currentCol]
|
||||
for (b in 0 until numBins) {
|
||||
val binIdx = minBin + b
|
||||
val rawMag = mag[binIdx]
|
||||
// Running energy normalization
|
||||
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
|
||||
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
|
||||
}
|
||||
|
||||
currentCol = (currentCol + 1) % historyCols
|
||||
}
|
||||
|
||||
/** Get the current spectrogram as a 2D array in chronological order. */
|
||||
fun getSpectrogram(): Array<FloatArray> {
|
||||
val result = Array(historyCols) { i ->
|
||||
val srcIdx = (currentCol + i) % historyCols
|
||||
spectrogram[srcIdx].copyOf()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the most recent column (current energy across all frequencies). */
|
||||
fun getCurrentColumn(): FloatArray {
|
||||
val prevCol = (currentCol - 1 + historyCols) % historyCols
|
||||
return spectrogram[prevCol].copyOf()
|
||||
}
|
||||
|
||||
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
|
||||
fun getColumn(index: Int): FloatArray {
|
||||
val clamped = index.coerceIn(0, historyCols - 1)
|
||||
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
|
||||
return spectrogram[srcIdx].copyOf()
|
||||
}
|
||||
|
||||
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
|
||||
fun findPeakBin(): Int {
|
||||
val col = getCurrentColumn()
|
||||
var maxBin = -1
|
||||
var maxVal = 0f
|
||||
for (i in col.indices) {
|
||||
if (col[i] > maxVal) {
|
||||
maxVal = col[i]
|
||||
maxBin = i
|
||||
}
|
||||
}
|
||||
return if (maxVal > 0.3f) maxBin else -1
|
||||
}
|
||||
|
||||
/** Get energy at a specific bin over the last N columns in chronological order. */
|
||||
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
|
||||
val clamped = minOf(numCols, historyCols)
|
||||
val result = FloatArray(clamped)
|
||||
for (i in 0 until clamped) {
|
||||
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
|
||||
result[i] = spectrogram[colIdx][bin]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the bin index for a frequency in Hz. */
|
||||
fun freqToBin(freqHz: Float): Int {
|
||||
val bin = (freqHz * fftSize / sampleRate).toInt()
|
||||
return (bin - minBin).coerceIn(0, numBins - 1)
|
||||
}
|
||||
|
||||
/** Get the center frequency for a bin. */
|
||||
fun binToFreq(bin: Int): Float {
|
||||
return (minBin + bin).toFloat() * sampleRate / fftSize
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
for (col in spectrogram) col.fill(0f)
|
||||
currentCol = 0
|
||||
samplesBuffered = 0
|
||||
buffer.fill(0f)
|
||||
binEnergy.fill(1f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** 单条卫星状态报告(AMSAT 网站 tooltip 数据) */
|
||||
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
|
||||
)
|
||||
|
||||
/** 单个 2 小时槽的状态 */
|
||||
data class SatSlot(
|
||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||
val count: Int, // 报告数量(0 = 无)
|
||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||
)
|
||||
|
||||
/** 卫星一天的状态(12 个 2 小时槽) */
|
||||
data class SatDay(
|
||||
val dateLabel: String, // "Aug 4"
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** 单个卫星 6 天状态 */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 6 天(新→旧)
|
||||
)
|
||||
|
||||
/** 页面整体解析结果 */
|
||||
data class SatStatusPage(
|
||||
val fetchedAtUtcMs: Long,
|
||||
val statuses: List<SatStatus>,
|
||||
val reports: Map<String, SatReport> // id → 报告
|
||||
)
|
||||
@@ -27,6 +27,9 @@ data class PassesSettings(
|
||||
val showDeepSpace: Boolean = true,
|
||||
val hoursAhead: Int,
|
||||
val minElevation: Double,
|
||||
val aosStartMinute: Int = 0,
|
||||
val aosEndMinute: Int = 23 * 60 + 59,
|
||||
val invertAosTimeWindow: Boolean = false,
|
||||
val selectedModes: List<String>
|
||||
)
|
||||
|
||||
@@ -56,7 +59,21 @@ data class OtherSettings(
|
||||
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,
|
||||
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
|
||||
val hiddenScreens: List<String> = emptyList(),
|
||||
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
|
||||
val screenOrder: List<String> = emptyList(),
|
||||
// UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码)
|
||||
val subMenuOrder: List<String> = emptyList(),
|
||||
// WaveLog 日志系统(4.5.2): 服务器配置
|
||||
val wavelogUrl: String = "",
|
||||
val wavelogApiKey: String = "",
|
||||
val wavelogStationId: String = "",
|
||||
val wavelogAutoUpload: Boolean = false
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
@@ -73,12 +90,20 @@ data class RadioControlSettings(
|
||||
val rxRadioAddress: String,
|
||||
val txRadioName: String,
|
||||
val rxRadioName: String,
|
||||
val baudRate: Int
|
||||
val baudRate: Int,
|
||||
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
|
||||
val splitMode: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
val SUPPORTED_RADIOS = listOf(
|
||||
"Yaesu FT-817/818",
|
||||
"Yaesu FT-857/897"
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -242,7 +242,8 @@ object CelestialComputer {
|
||||
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
|
||||
val th = fixAngle((teg - observer.longitude) * DEG2RAD)
|
||||
// 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))
|
||||
@@ -563,9 +564,9 @@ object CelestialComputer {
|
||||
// ── Internal helpers ──
|
||||
|
||||
private fun observerGeodetic(pos: GeoPos): DoubleArray {
|
||||
// [lat_rad, lon_rad, alt_km] — longitude negated so that
|
||||
// mod2PI(thetaGJD + obsGeo[1]) == mod2PI(thetaGJD + lon_rad)
|
||||
return doubleArrayOf(pos.latitude * DEG2RAD, -pos.longitude * DEG2RAD, pos.altitude / 1000.0)
|
||||
// [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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,10 @@ data class OrbitalPass(
|
||||
val altitude: Int = 1000,
|
||||
val maxElevation: Double = 75.0,
|
||||
val orbitalObject: OrbitalObject,
|
||||
val 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
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
|
||||
/** AMSAT 卫星状态数据源 */
|
||||
interface IAmSatRepository {
|
||||
/** 抓取并解析 AMSAT 状态页; 失败返回 null */
|
||||
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()
|
||||
}
|
||||
+49
-2
@@ -1,8 +1,28 @@
|
||||
/*
|
||||
* 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.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
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface IMainContainer {
|
||||
val appScope: CoroutineScope
|
||||
@@ -10,6 +30,10 @@ interface IMainContainer {
|
||||
val selectionRepo: ISelectionRepo
|
||||
val satelliteRepo: ISatelliteRepo
|
||||
val databaseRepo: IDatabaseRepo
|
||||
val amSatRepo: IAmSatRepository
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
fun provideAddToCalendar(): IAddToCalendar
|
||||
fun provideShowToast(): IShowToast
|
||||
fun provideBluetoothReporter(): IReporter
|
||||
@@ -17,9 +41,32 @@ interface IMainContainer {
|
||||
fun provideSensorsRepo(): ISensorsRepo
|
||||
fun provideTxRadioController(): IRadioController
|
||||
fun provideRxRadioController(): IRadioController
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
fun provideAudioCapture(): IAudioCapture
|
||||
fun provideSaveImage(): ISaveImage
|
||||
// WaveLog 日志(4.5.2)
|
||||
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||
}
|
||||
|
||||
data class MutualPassData(
|
||||
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
|
||||
val trackSamples: List<TrackSampleData> = emptyList(),
|
||||
val startTime: Long = 0L,
|
||||
val endTime: Long = 0L,
|
||||
val maxElev: Double = 10.0,
|
||||
val labelA: String = "你",
|
||||
val labelB: String = "友台"
|
||||
)
|
||||
|
||||
/** Minimal track sample for cross-module sharing (angles in degrees). */
|
||||
data class TrackSampleData(
|
||||
val time: Long = 0L,
|
||||
val azimuthA: Double,
|
||||
val elevationA: Double,
|
||||
val azimuthB: Double,
|
||||
val elevationB: Double
|
||||
)
|
||||
|
||||
interface IContainerProvider {
|
||||
fun getMainContainer(): IMainContainer
|
||||
}
|
||||
+47
@@ -38,4 +38,51 @@ interface IRadioController {
|
||||
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()
|
||||
}
|
||||
+16
-2
@@ -34,11 +34,25 @@ interface ISatelliteRepo {
|
||||
/** 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, modes: List<String>)
|
||||
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
|
||||
@@ -47,7 +61,7 @@ interface ISatelliteRepo {
|
||||
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 getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
|
||||
|
||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
|
||||
+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()
|
||||
|
||||
+2
-1
@@ -45,7 +45,8 @@ interface ISettingsRepo {
|
||||
//region # Station position settings
|
||||
val stationPosition: StateFlow<GeoPos>
|
||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||
fun setStationPosition(): Boolean
|
||||
/** GPS 定位(挂起): 拿到位置才返回 true; 权限缺失/超时/无信号返回 false */
|
||||
suspend fun setStationPosition(): Boolean
|
||||
fun setStationPosition(locator: String): Boolean
|
||||
//endregion
|
||||
|
||||
|
||||
@@ -22,4 +22,7 @@ import java.io.InputStream
|
||||
interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
|
||||
/** 抓取 AMSAT 状态页 HTML(带 UA, 返回 null = 失败) */
|
||||
suspend fun getStatusHtml(): String?
|
||||
}
|
||||
@@ -18,7 +18,10 @@
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
const val RADIO_DATA_URL = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
// 在线更新的默认 URL(用户可通过"自定义URL"对话框修改/重置)
|
||||
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
|
||||
val defaultTransceiversUrl = "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",
|
||||
@@ -49,4 +52,7 @@ object Sources {
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"Other" to "" // key for sats filter
|
||||
)
|
||||
val transceiversDataUrls = mapOf(
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -19,4 +19,7 @@ package com.rtbishop.look4sat.core.domain.usecase
|
||||
|
||||
interface IShowToast {
|
||||
operator fun invoke(message: String)
|
||||
|
||||
/** 按资源 ID 显示(四语文案) */
|
||||
operator fun invoke(resId: Int)
|
||||
}
|
||||
@@ -58,8 +58,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
|
||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||
val name = values[0]
|
||||
val timestamp = values[2]
|
||||
@@ -84,7 +82,8 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
argper = values[7].toDouble(),
|
||||
meanan = values[8].toDouble(),
|
||||
catnum = values[11].toInt(),
|
||||
bstar = values[14].toDouble()
|
||||
bstar = values[14].toDouble(),
|
||||
ndot = values[15].toDouble()
|
||||
)
|
||||
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||
|
||||
@@ -101,11 +100,14 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
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())
|
||||
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 getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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, we compute the other:
|
||||
*
|
||||
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
||||
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
||||
*
|
||||
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
|
||||
*/
|
||||
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 the named linear
|
||||
* transponder entry, 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")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@
|
||||
*/
|
||||
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
|
||||
@@ -85,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)
|
||||
}
|
||||
+106
-15
@@ -19,31 +19,78 @@ package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
|
||||
/**
|
||||
* Converts a Maidenhead locator (QTH grid square) to a GeoPos.
|
||||
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
|
||||
* The returned position is the center of the finest cell encoded by the locator:
|
||||
* - 6 char: 5' lon x 2.5' lat cell center
|
||||
* - 8 char: 30" lon x 15" lat cell center
|
||||
* - 10 char: 1.25" lon x 0.625" lat cell center
|
||||
*/
|
||||
fun qthToPosition(locator: String): GeoPos? {
|
||||
val trimmedQth = locator.take(6)
|
||||
val trimmedQth = locator.trim().uppercase()
|
||||
if (!isValidLocator(trimmedQth)) return null
|
||||
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
|
||||
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
|
||||
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||
val latFirst = (trimmedQth[1].code - 65) * 10
|
||||
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
||||
val latSecond = trimmedQth[3].toString().toInt()
|
||||
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
|
||||
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
|
||||
val longitude = (lonFirst + lonSecond + lonThird).round(4)
|
||||
val latitude = (latFirst + latSecond + latThird).round(4)
|
||||
return GeoPos(latitude, longitude)
|
||||
val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
|
||||
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
|
||||
var longitude = lonFirst + lonSecond + lonThird - 180
|
||||
var latitude = latFirst + latSecond + latThird - 90
|
||||
// 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
|
||||
if (trimmedQth.length >= 8) {
|
||||
longitude += trimmedQth[6].toString().toInt() / 120.0
|
||||
latitude += trimmedQth[7].toString().toInt() / 240.0
|
||||
}
|
||||
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
|
||||
if (trimmedQth.length >= 10) {
|
||||
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
|
||||
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0
|
||||
}
|
||||
// Offset to the center of the finest encoded cell
|
||||
when (trimmedQth.length) {
|
||||
8 -> {
|
||||
longitude += 1.0 / 240.0
|
||||
latitude += 1.0 / 480.0
|
||||
}
|
||||
10 -> {
|
||||
longitude += 1.0 / 5760.0
|
||||
latitude += 1.0 / 11520.0
|
||||
}
|
||||
else -> {
|
||||
longitude += 1.0 / 24.0
|
||||
latitude += 1.0 / 48.0
|
||||
}
|
||||
}
|
||||
return GeoPos(latitude.round(6), longitude.round(6))
|
||||
}
|
||||
|
||||
fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||
/**
|
||||
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
|
||||
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
|
||||
* matching common 8-char grid square tools. Pass precision = 6 for the classic
|
||||
* 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution.
|
||||
*/
|
||||
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
|
||||
if (!isValidPosition(latitude, longitude)) return null
|
||||
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
||||
val newLongitude = longitude + 180
|
||||
val newLatitude = latitude + 90
|
||||
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
|
||||
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
||||
val lonSecond = ((newLongitude / 2) % 10).toInt()
|
||||
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
|
||||
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
|
||||
val lonSecond = ((newLongitude % 20) / 2).toInt()
|
||||
val latSecond = (newLatitude % 10).toInt()
|
||||
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
|
||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||
val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||
if (precision < 8) return qth
|
||||
val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt()
|
||||
val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt()
|
||||
val qth8 = "$qth$lonFourth$latFourth"
|
||||
if (precision < 10) return qth8
|
||||
val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar()
|
||||
val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar()
|
||||
return "$qth8$lonFifth$latFifth"
|
||||
}
|
||||
|
||||
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||
@@ -51,5 +98,49 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||
}
|
||||
|
||||
private fun isValidLocator(locator: String): Boolean {
|
||||
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
|
||||
return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
|
||||
* A 4-char input is returned as-is when valid.
|
||||
*/
|
||||
fun qthToSquare(locator: String): String {
|
||||
val upper = locator.trim().uppercase()
|
||||
return when {
|
||||
upper.length >= 4 && isValidLocator(upper) -> upper.take(4)
|
||||
upper.length == 4 && upper.matches("[a-xA-X]{2}\\d{2}".toRegex()) -> upper
|
||||
else -> "----"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
|
||||
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
|
||||
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
|
||||
* Mirrors the neighbor logic decompiled from the QTH定位器 app.
|
||||
*/
|
||||
fun qthNeighbors(square: String): List<String> {
|
||||
if (square.length != 4) return emptyList()
|
||||
val lonField = (square[0].uppercaseChar().code - 65).coerceIn(0, 17)
|
||||
val latField = (square[1].uppercaseChar().code - 65).coerceIn(0, 17)
|
||||
val lonSquare = square[2].digitToCharOrNull() ?: return emptyList()
|
||||
val latSquare = square[3].digitToCharOrNull() ?: return emptyList()
|
||||
val result = mutableListOf<String>()
|
||||
for (dLat in 1 downTo -1) { // north -> south
|
||||
for (dLon in -1..1) { // west -> east
|
||||
var lf = lonField
|
||||
var tf = latField
|
||||
var ls = lonSquare + dLon
|
||||
var ts = latSquare + dLat
|
||||
if (ls < 0) { lf -= 1; ls = 9 } else if (ls > 9) { lf += 1; ls = 0 }
|
||||
if (ts < 0) { tf -= 1; ts = 9 } else if (ts > 9) { tf += 1; ts = 0 }
|
||||
lf = (lf + 18) % 18
|
||||
tf = (tf + 18) % 18
|
||||
result += "${('A' + lf).toChar()}${('A' + tf).toChar()}$ls$ts"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Char.digitToCharOrNull(): Int? = digitToIntOrNull()
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* WaveLogApi.kt — WaveLog 日志服务器 API 客户端(4.5.2 覆盖修复 2)。
|
||||
*
|
||||
* 同时支持 v1 与 v2(用户服务器实测只有 v1, v2 返回 404):
|
||||
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON 字段)
|
||||
* v1: POST {base}/index.php/api/qso (key 在 JSON body + ADIF 字符串)
|
||||
* 策略: 优先 v2, 404 自动降级 v1。
|
||||
* 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif。
|
||||
* 站点网格: 仅 v2 有 GET api/v2/station/{id}; v1 无此端点 → 降级用用户 QTH。
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
|
||||
/** 站点信息(GET /api/v2/station/{id} 结果) */
|
||||
data class WavelogStation(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val gridsquare: String
|
||||
)
|
||||
|
||||
sealed class WavelogResult {
|
||||
data class Success(val message: String) : WavelogResult()
|
||||
data class Failure(val message: String) : WavelogResult()
|
||||
}
|
||||
|
||||
object WaveLogApi {
|
||||
|
||||
private const val TIMEOUT_MS = 15000
|
||||
|
||||
/** 规范化服务器地址: 去尾斜杠/去尾部 index.php; 无协议前缀时补 https:// */
|
||||
fun normalizeUrl(raw: String): String {
|
||||
var u = raw.trim().trimEnd('/')
|
||||
if (u.isBlank()) return ""
|
||||
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
|
||||
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
|
||||
return u
|
||||
}
|
||||
|
||||
/** 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif */
|
||||
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
|
||||
// v2: GET /index.php/api/v2/token
|
||||
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
|
||||
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
|
||||
|
||||
// v1: POST /index.php/api/get_contacts_adif(key 在 body)
|
||||
if (stationId.isNotBlank()) {
|
||||
val body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("station_id", stationId)
|
||||
put("fetchfromid", 0)
|
||||
}.toString()
|
||||
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
|
||||
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
}
|
||||
// 无 index.php 的 v1 尝试
|
||||
val body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("station_id", stationId)
|
||||
put("fetchfromid", 0)
|
||||
}.toString()
|
||||
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
|
||||
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
|
||||
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
|
||||
}
|
||||
|
||||
/** 站点信息: 仅 v2; v1 无此端点返回 null 标记(网格检测降级用用户 QTH) */
|
||||
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
|
||||
if (code in 200..299) {
|
||||
return@withContext try {
|
||||
val obj = JSONObject(resp)
|
||||
val data = obj.optJSONObject("data") ?: obj
|
||||
val station = WavelogStation(
|
||||
id = data.optInt("id"),
|
||||
name = data.optString("name"),
|
||||
callsign = data.optString("callsign"),
|
||||
gridsquare = data.optString("gridsquare")
|
||||
)
|
||||
WavelogResult.Success(JSONObject().apply {
|
||||
put("id", station.id); put("name", station.name)
|
||||
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
|
||||
}.toString())
|
||||
} catch (e: Exception) {
|
||||
WavelogResult.Failure("解析失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
// v1 无 station 端点 → 返回 Success 空(调用方降级用用户 QTH)
|
||||
WavelogResult.Success("")
|
||||
}
|
||||
|
||||
/** LoTW 认可的卫星名: 取括号前主名 + 大写(ISS 特判) */
|
||||
fun normalizeSatName(raw: String): String {
|
||||
val main = raw.substringBefore('(').trim()
|
||||
.ifBlank { raw.trim() }
|
||||
.uppercase(Locale.ENGLISH)
|
||||
// ISS 特判: 主名是 ISS/ZARYA/ARISS 变体 → ISS(LoTW 认可名)
|
||||
return when {
|
||||
main == "ZARYA" || main.startsWith("ISS") -> "ISS"
|
||||
main == "ARISS" -> "ISS"
|
||||
else -> main
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建 QSO: 优先 v2, 404 降级 v1(ADIF) */
|
||||
suspend fun postQso(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
stationProfileId: String,
|
||||
qso: WavelogQso,
|
||||
gridsquare: String
|
||||
): WavelogResult = withContext(Dispatchers.IO) {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
|
||||
val satName = normalizeSatName(qso.satName)
|
||||
|
||||
// v2: POST /index.php/api/v2/qso(JSON 字段)
|
||||
val v2Body = JSONObject().apply {
|
||||
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
|
||||
put("call", qso.call)
|
||||
put("band", "SAT")
|
||||
put("mode", qso.mode)
|
||||
put("qso_date", utcDate(qso.timeUtcMs))
|
||||
put("time_on", utcTime(qso.timeUtcMs))
|
||||
put("freq", qso.freqTxHz)
|
||||
put("freq_rx", qso.freqRxHz)
|
||||
put("gridsquare", gridsquare)
|
||||
put("sat_name", satName)
|
||||
}
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
||||
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
|
||||
|
||||
// v1: POST /index.php/api/qso(key 在 body + ADIF)
|
||||
val v1Body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("station_profile_id", stationProfileId)
|
||||
put("type", "adif")
|
||||
put("string", toAdif(qso, gridsquare, satName))
|
||||
}
|
||||
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
||||
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||
|
||||
// 无 index.php 的 v1
|
||||
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
||||
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||
|
||||
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
|
||||
}
|
||||
|
||||
/** v1 ADIF 字符串(频率 MHz, 长度=UTF-8 字节数, sat_name 已规范化) */
|
||||
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||
fun field(name: String, value: String): String {
|
||||
val bytes = value.toByteArray(Charsets.UTF_8).size
|
||||
return "<$name:$bytes>$value"
|
||||
}
|
||||
return buildString {
|
||||
append(field("call", qso.call))
|
||||
append(field("band", "SAT"))
|
||||
append(field("mode", qso.mode))
|
||||
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||
if (qso.freqRxHz > 0) {
|
||||
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
|
||||
}
|
||||
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
|
||||
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
||||
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
|
||||
if (satName.isNotBlank()) {
|
||||
append(field("sat_name", satName))
|
||||
append(field("prop_mode", "SAT"))
|
||||
}
|
||||
append("<eor>")
|
||||
}
|
||||
}
|
||||
|
||||
/** 通用 HTTP 请求(返回 code + body) */
|
||||
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
|
||||
return try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = method
|
||||
conn.connectTimeout = TIMEOUT_MS
|
||||
conn.readTimeout = TIMEOUT_MS
|
||||
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
|
||||
if (jsonBody != null) {
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
|
||||
}
|
||||
val code = conn.responseCode
|
||||
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||
val body = if (stream != null) {
|
||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
|
||||
} else ""
|
||||
code to body
|
||||
} catch (e: Exception) {
|
||||
-1 to (e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortError(body: String): String {
|
||||
if (body.startsWith("<")) return body.take(80) // HTML 错误页
|
||||
return try {
|
||||
val obj = JSONObject(body)
|
||||
val err = obj.optJSONObject("error")
|
||||
err?.optString("message")?.ifBlank { body.take(120) }
|
||||
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
|
||||
} catch (_: Exception) {
|
||||
body.take(120)
|
||||
}
|
||||
}
|
||||
|
||||
private fun utcDate(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%04d-%02d-%02d".format(
|
||||
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
}
|
||||
|
||||
private fun utcTime(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%02d:%02d:%02d".format(
|
||||
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||
cal.get(java.util.Calendar.SECOND)
|
||||
)
|
||||
}
|
||||
|
||||
private fun utcDateCompact(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%04d%02d%02d".format(
|
||||
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
}
|
||||
|
||||
private fun utcTimeCompact(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%02d%02d%02d".format(
|
||||
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||
cal.get(java.util.Calendar.SECOND)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* WavelogQueue.kt — WaveLog 本地日志队列(4.5.2)。
|
||||
*
|
||||
* 纯 Kotlin(不依赖 Android): 存储走 IWavelogQueueStore 接口,
|
||||
* 由 core/data 用 SharedPreferences 实现。
|
||||
* 队列上限 500 条(超出丢最旧)。
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** 存储抽象(SharedPreferences 实现见 core/data) */
|
||||
interface IWavelogQueueStore {
|
||||
fun load(): String
|
||||
fun save(json: String)
|
||||
}
|
||||
|
||||
/** 待上传的 QSO 条目(本地队列元素,与 POST /api/v2/qso 字段对应) */
|
||||
data class WavelogQso(
|
||||
val id: String, // 本地唯一 id(UUID)
|
||||
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
|
||||
val call: String,
|
||||
val mode: String,
|
||||
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
||||
val freqRxHz: Long, // 下行
|
||||
val satName: String,
|
||||
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
||||
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
||||
)
|
||||
|
||||
class WavelogQueue(private val store: IWavelogQueueStore) {
|
||||
|
||||
private val key = "wavelog_queue"
|
||||
|
||||
fun all(): List<WavelogQso> {
|
||||
val raw = store.load()
|
||||
return try {
|
||||
val arr = JSONArray(raw)
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
WavelogQso(
|
||||
id = o.getString("id"),
|
||||
timeUtcMs = o.getLong("timeUtcMs"),
|
||||
call = o.optString("call"),
|
||||
mode = o.optString("mode"),
|
||||
freqTxHz = o.optLong("freqTxHz"),
|
||||
freqRxHz = o.optLong("freqRxHz"),
|
||||
satName = o.optString("satName"),
|
||||
sessionId = o.optString("sessionId"),
|
||||
uploaded = o.optBoolean("uploaded", false)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun add(qso: WavelogQso) {
|
||||
val list = all().toMutableList()
|
||||
list.add(0, qso) // 最新在前
|
||||
if (list.size > 500) list.removeAt(list.size - 1)
|
||||
save(list)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun remove(id: String) {
|
||||
save(all().filter { it.id != id })
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeAll(ids: Set<String>) {
|
||||
save(all().filter { it.id !in ids })
|
||||
}
|
||||
|
||||
/** 标记为已上传(保留在队列, 表格打勾) */
|
||||
@Synchronized
|
||||
fun markUploaded(id: String) {
|
||||
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
|
||||
}
|
||||
|
||||
/** 移除所有已上传条目(可选项, 保持队列精简) */
|
||||
@Synchronized
|
||||
fun removeUploaded() {
|
||||
save(all().filter { !it.uploaded })
|
||||
}
|
||||
|
||||
private fun save(list: List<WavelogQso>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { q ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
|
||||
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
||||
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
||||
put("sessionId", q.sessionId)
|
||||
put("uploaded", q.uploaded)
|
||||
})
|
||||
}
|
||||
store.save(arr.toString())
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* WavelogUploader.kt — WaveLog 队列上传调度(4.5.2)。
|
||||
*
|
||||
* 手动/周期上传共用: 逐条网格检测(用户 QTH 前 4 位 vs 台站网格前 4 位)
|
||||
* → POST /api/v2/qso → 成功移出队列。
|
||||
* 网格不一致: 返回 NeedConfirm(由 UI 弹窗「忽略并上传/取消」),
|
||||
* 确认后带 force=true 重试本批。
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import org.json.JSONObject
|
||||
|
||||
sealed class UploadOutcome {
|
||||
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
||||
data class Done(
|
||||
val successCount: Int,
|
||||
val failedCount: Int,
|
||||
val message: String,
|
||||
val firstError: String = ""
|
||||
) : UploadOutcome()
|
||||
}
|
||||
|
||||
class WavelogUploader(
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val queue: WavelogQueue
|
||||
) {
|
||||
|
||||
// 台站网格缓存(每次上传前刷新; 失败用旧值)
|
||||
private var cachedStationGrid: String? = null
|
||||
|
||||
/** 上传整个队列。force=true 跳过网格确认(用户已选「忽略并上传」) */
|
||||
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
|
||||
val settings = settingsRepo.otherSettings.value
|
||||
val url = settings.wavelogUrl
|
||||
val apiKey = settings.wavelogApiKey
|
||||
val stationId = settings.wavelogStationId
|
||||
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
||||
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
|
||||
}
|
||||
|
||||
// 1. 拉站点信息(拿台站网格); v1 无此端点时降级用用户 QTH
|
||||
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
||||
if (stationGrid.isNullOrBlank()) {
|
||||
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
||||
}
|
||||
|
||||
// 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位(v1 降级时网格相同, 跳过)
|
||||
if (!force) {
|
||||
val userGrid = userQthGrid()
|
||||
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
|
||||
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 逐条上传(成功后标记 uploaded, 保留在本地供日志页打勾)
|
||||
val entries = queue.all()
|
||||
var ok = 0
|
||||
var fail = 0
|
||||
var firstError = ""
|
||||
for (qso in entries) {
|
||||
if (qso.uploaded) { ok++; continue }
|
||||
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, stationGrid)
|
||||
if (result is WavelogResult.Success) {
|
||||
ok++
|
||||
queue.markUploaded(qso.id)
|
||||
} else {
|
||||
fail++
|
||||
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
||||
}
|
||||
}
|
||||
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
||||
return UploadOutcome.Done(ok, fail, message, firstError)
|
||||
}
|
||||
|
||||
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
||||
val result = WaveLogApi.getStation(url, apiKey, stationId)
|
||||
if (result is WavelogResult.Success) {
|
||||
return try {
|
||||
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
|
||||
?: cachedStationGrid
|
||||
} catch (_: Exception) { cachedStationGrid }
|
||||
}
|
||||
return cachedStationGrid
|
||||
}
|
||||
|
||||
/** 用户当前 QTH 网格(前 4 位; 无 QTH 返回 null = 跳过检测) */
|
||||
private fun userQthGrid(): String? {
|
||||
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,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 +114,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 +159,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 +207,36 @@ 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)
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
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.*
|
||||
import org.junit.Test
|
||||
|
||||
class DopplerFrequencyCalculatorTest {
|
||||
|
||||
private fun linearTransponder(
|
||||
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 = "linear", 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_returnsFalseForFmRepeater() {
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
assertTrue(uplink!! > 0)
|
||||
// With zero Doppler, result equals mapDownlinkToUplink output
|
||||
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) // ~7 km/s receding
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
// Uplink freq should be Doppler shifted UP (compensating for receding)
|
||||
assertTrue(uplink!! > 145_200_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_fm_transponder_returnsNull() {
|
||||
val orbitalPos = pos()
|
||||
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeDownlinkFromUplink_fm_transponder_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)
|
||||
// Inverted: offset from high end → maps to high end of uplink
|
||||
assertEquals(145_300_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_roundTrip() {
|
||||
// downlink → uplink → downlink should round-trip
|
||||
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)
|
||||
// Doppler round-trip: small residual due to freq-dependent Doppler
|
||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
||||
import org.junit.Test
|
||||
|
||||
class QthConverterTest {
|
||||
@@ -26,21 +28,36 @@ class QthConverterTest {
|
||||
@Test
|
||||
fun `Given valid QTH returns correct POS`() {
|
||||
var result = qthToPosition("io91VL39FX")
|
||||
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
|
||||
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
|
||||
result = qthToPosition("gf15vc")
|
||||
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
||||
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
|
||||
// 8-char locators: finer 30" x 15" cell center
|
||||
result = qthToPosition("io91vl47")
|
||||
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
|
||||
result = qthToPosition("jn58td25")
|
||||
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid QTH returns null`() {
|
||||
assert(qthToPosition("ZZ00zz") == null)
|
||||
assert(qthToPosition("JN58") == null)
|
||||
assert(qthToPosition("io9") == null)
|
||||
assert(qthToPosition("IO91VL7") == null)
|
||||
assert(qthToPosition("IO91VL4X") == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid POS returns correct QTH`() {
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58td")
|
||||
// default precision is 8 chars
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
|
||||
// 6-char precision still available for backwards compatibility
|
||||
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
|
||||
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
|
||||
// 10-char precision
|
||||
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
|
||||
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -48,4 +65,61 @@ class QthConverterTest {
|
||||
assert(positionToQth(91.0542, -170.1142) == null)
|
||||
assert(positionToQth(89.0542, -240.1142) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given boundary POS stays in valid grid`() {
|
||||
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
|
||||
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
||||
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
|
||||
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
||||
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
|
||||
val positions = listOf(
|
||||
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
|
||||
Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727)
|
||||
)
|
||||
positions.forEach { (lat, lon) ->
|
||||
val qth = positionToQth(lat, lon, 8)
|
||||
val pos = qthToPosition(qth!!)
|
||||
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
|
||||
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given square returns correct 3x3 neighbors`() {
|
||||
// Reference grid from the QTH定位器 screenshot: OL42
|
||||
val neighbors = qthNeighbors("OL42")
|
||||
assert(neighbors == listOf(
|
||||
"OL33", "OL43", "OL53",
|
||||
"OL32", "OL42", "OL52",
|
||||
"OL31", "OL41", "OL51"
|
||||
)) { "OL42 grid mismatch: $neighbors" }
|
||||
// Center cell must be the input itself
|
||||
assert(neighbors[4] == "OL42")
|
||||
// 9 cells, all distinct
|
||||
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given boundary square wraps fields correctly`() {
|
||||
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
|
||||
val sw = qthNeighbors("AA00")
|
||||
assert(sw.size == 9 && sw.toSet().size == 9)
|
||||
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
|
||||
// North-east corner: RR99 wraps to AA00
|
||||
val ne = qthNeighbors("RR99")
|
||||
assert(ne.size == 9 && ne.toSet().size == 9)
|
||||
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
|
||||
// Field boundary: IO91's east neighbors cross into J field
|
||||
val london = qthNeighbors("IO91")
|
||||
assert(london[2] == "JO02" && london[5] == "JO01")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given full locator returns square part`() {
|
||||
assert(qthToSquare("OL42ih45") == "OL42")
|
||||
assert(qthToSquare("io91VL39FX") == "IO91")
|
||||
assert(qthToSquare("JN58") == "JN58")
|
||||
assert(qthToSquare("garbage!!") == "----")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
class CwDecoderTest {
|
||||
|
||||
// --- Morse table ---
|
||||
|
||||
@Test
|
||||
fun morseToChar_basicLetters() {
|
||||
assertEquals('A', CwBayesianDecoder.morseToChar("01"))
|
||||
assertEquals('S', CwBayesianDecoder.morseToChar("000"))
|
||||
assertEquals('O', CwBayesianDecoder.morseToChar("111"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun morseToChar_numbers() {
|
||||
assertEquals('1', CwBayesianDecoder.morseToChar("01111"))
|
||||
assertEquals('0', CwBayesianDecoder.morseToChar("11111"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun morseToChar_unknown_returnsNull() {
|
||||
assertNull(CwBayesianDecoder.morseToChar("......."))
|
||||
assertNull(CwBayesianDecoder.morseToChar(""))
|
||||
}
|
||||
|
||||
// --- FFT ---
|
||||
|
||||
@Test
|
||||
fun fft_magnitudeSpectrum_detectsTone() {
|
||||
val fft = CwFFT(256)
|
||||
val sampleRate = 8000f
|
||||
val freq = 700f
|
||||
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / sampleRate)).toFloat() }
|
||||
val mag = fft.magnitudeSpectrum(buffer)
|
||||
// Peak should be at bin around 700 * 256 / 8000 ≈ 22.4
|
||||
var maxBin = 0
|
||||
var maxVal = 0f
|
||||
for (i in mag.indices) {
|
||||
if (mag[i] > maxVal) { maxVal = mag[i]; maxBin = i }
|
||||
}
|
||||
assertTrue("Peak bin $maxBin should be near 22", maxBin in 18..26)
|
||||
assertTrue("Peak value $maxVal should be positive", maxVal > 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fft_magnitudeSpectrum_silence_isFlat() {
|
||||
val fft = CwFFT(256)
|
||||
val buffer = FloatArray(256) { 0f }
|
||||
val mag = fft.magnitudeSpectrum(buffer)
|
||||
for (v in mag) assertEquals("Silence spectrum should be 0, got $v", 0f, v, 1e-6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fft_rejectsWrongSize() {
|
||||
assertThrows(IllegalArgumentException::class.java) { CwFFT(100) }
|
||||
}
|
||||
|
||||
// --- Spectrogram ---
|
||||
|
||||
@Test
|
||||
fun spectrogram_addSamples_updatesEnergy() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
val freq = 700f
|
||||
// Feed multiple frames to stabilize energy normalization
|
||||
for (i in 0..5) {
|
||||
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / 8000.0)).toFloat() }
|
||||
spec.addSamples(buffer)
|
||||
}
|
||||
val col = spec.getCurrentColumn()
|
||||
val peakBin = spec.findPeakBin()
|
||||
assertTrue("Peak bin $peakBin should be >= 0", peakBin >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spectrogram_findPeakBin_returnsValidBin() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
// Add multiple frames of 700 Hz tone
|
||||
for (i in 0..5) {
|
||||
val buffer = FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
|
||||
spec.addSamples(buffer)
|
||||
}
|
||||
val peakBin = spec.findPeakBin()
|
||||
assertTrue("Peak bin should be >= 0, got $peakBin", peakBin >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spectrogram_freqToBin_roundtrip() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
val freq = 700f
|
||||
val bin = spec.freqToBin(freq)
|
||||
val backFreq = spec.binToFreq(bin)
|
||||
assertTrue("Freq $freq → bin $bin → freq $backFreq", backFreq > 600f && backFreq < 800f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spectrogram_getBinEnergy_returnsCorrectLength() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
val energy = spec.getBinEnergy(0, 10)
|
||||
assertEquals(10, energy.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spectrogram_reset() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
spec.addSamples(FloatArray(256) { 1f })
|
||||
spec.reset()
|
||||
assertEquals(-1, spec.findPeakBin())
|
||||
}
|
||||
|
||||
// --- Bayesian decoder ---
|
||||
|
||||
@Test
|
||||
fun bayesian_processTone_dit() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
// At 20 WPM, dot = 60 ms
|
||||
val result = decoder.processTone(60f)
|
||||
assertEquals('0', result.symbol)
|
||||
assertTrue("Dit probability should be positive", result.probability > 0.1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_processTone_dash() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
// Dash = 3 * dot = 180 ms
|
||||
val result = decoder.processTone(180f)
|
||||
assertEquals('1', result.symbol)
|
||||
assertTrue("Dash probability should be positive", result.probability > 0.1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_processTone_unknown_returnsNull() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
// Very long tone — low probability for both dit and dash
|
||||
val result = decoder.processTone(5000f)
|
||||
assertNull(result.symbol)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_processGap_interChar_returnsChar() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
decoder.processTone(60f) // dit
|
||||
decoder.processTone(60f) // dit
|
||||
decoder.processTone(60f) // dit
|
||||
// 3 dots = "000" = 'S'
|
||||
val char = decoder.processGap(180f) // 3 * dot = inter-char gap
|
||||
assertEquals('S', char)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_processGap_wordGap_addsSpace() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
decoder.processTone(60f) // dit = 'E'
|
||||
decoder.processGap(180f) // inter-char gap
|
||||
// Now word gap
|
||||
val space = decoder.processGap(420f) // 7 * dot
|
||||
assertEquals(' ', space)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_decodedText_accumulates() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
decoder.processTone(60f) // dit = 'E'
|
||||
decoder.processGap(180f) // inter-char
|
||||
assertTrue(decoder.decodedText.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_reset() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
decoder.processTone(60f)
|
||||
decoder.reset()
|
||||
assertEquals("", decoder.decodedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_getSpeed() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
// Send 3 dits at 20 WPM (60 ms each)
|
||||
decoder.processTone(60f)
|
||||
decoder.processTone(60f)
|
||||
decoder.processTone(60f)
|
||||
val speed = decoder.getSpeed()
|
||||
assertTrue("Speed should be ~20 WPM, got $speed", speed > 15f && speed < 30f)
|
||||
}
|
||||
|
||||
// --- Channel tracker ---
|
||||
|
||||
@Test
|
||||
fun channelTracker_initialState() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
val tracker = CwChannelTracker(spec)
|
||||
val channels = tracker.update()
|
||||
assertTrue("No channels should be active initially", channels.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelTracker_detectsTone() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
// Feed a tone
|
||||
for (i in 0..5) {
|
||||
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||
}
|
||||
val tracker = CwChannelTracker(spec)
|
||||
val channels = tracker.update()
|
||||
assertTrue("Should detect at least 1 channel", channels.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelTracker_bestChannel() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
for (i in 0..5) {
|
||||
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||
}
|
||||
val tracker = CwChannelTracker(spec)
|
||||
tracker.update()
|
||||
val best = tracker.getBestChannel()
|
||||
assertNotNull("Best channel should exist", best)
|
||||
if (best != null) assertTrue(best.frequency in 600f..800f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelTracker_reset() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
for (i in 0..5) {
|
||||
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||
}
|
||||
val tracker = CwChannelTracker(spec)
|
||||
tracker.update()
|
||||
tracker.reset()
|
||||
assertNull(tracker.getBestChannel())
|
||||
}
|
||||
|
||||
// --- Full decoder ---
|
||||
|
||||
@Test
|
||||
fun decoder_initialState() {
|
||||
val decoder = CwDecoder()
|
||||
assertEquals("", decoder.decodedTextFlow.value)
|
||||
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoder_processSilence_doesNotCrash() {
|
||||
val decoder = CwDecoder()
|
||||
decoder.processBuffer(FloatArray(256) { 0f })
|
||||
assertEquals("", decoder.decodedTextFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoder_processNoise_doesNotCrash() {
|
||||
val decoder = CwDecoder()
|
||||
decoder.processBuffer(FloatArray(256) { (Math.random() * 2 - 1).toFloat() * 0.1f })
|
||||
assertNotNull(decoder.decodedTextFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoder_processTone_doesNotCrash() {
|
||||
val decoder = CwDecoder()
|
||||
for (i in 0..20) {
|
||||
decoder.processBuffer(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||
}
|
||||
assertNotNull(decoder.decodedTextFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoder_reset() {
|
||||
val decoder = CwDecoder()
|
||||
decoder.processBuffer(FloatArray(256) { 1f })
|
||||
decoder.resetDecoder()
|
||||
assertEquals("", decoder.decodedTextFlow.value)
|
||||
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoder_withFixedPitch() {
|
||||
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
|
||||
assertEquals(700f, decoder.estimatedPitch.value)
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
+190
-55
@@ -39,24 +39,44 @@ 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.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
|
||||
@@ -137,30 +157,28 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = pass.name,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 6.dp)
|
||||
.infiniteMarquee(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val elevColor = elevationColor(pass.maxElevation)
|
||||
Icon(
|
||||
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(
|
||||
@@ -178,12 +196,6 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(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)
|
||||
}
|
||||
Text(
|
||||
@@ -212,14 +224,11 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
|
||||
ElevatedCard(modifier = Modifier.size(48.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = action)
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) { Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) }
|
||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,55 +284,87 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
||||
fun SharedDialog(
|
||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||
) {
|
||||
DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) {
|
||||
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
||||
content()
|
||||
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
|
||||
DialogShell(title = title, titleFontSize = 18, onDismissRequest = {}) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = it)
|
||||
)
|
||||
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
CardButton(onClick = onDismiss, text = stringResource(R.string.btn_accept))
|
||||
fun SharedDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onCancel: (() -> Unit)? = null,
|
||||
onAccept: (() -> Unit)? = null,
|
||||
titleFontSize: Int = 16,
|
||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onDismissRequest) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
if (onCancel != null) {
|
||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = titleFontSize.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = titleTextAlign,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||
)
|
||||
if (onAccept != null) {
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
}
|
||||
content(padding)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DialogShell(
|
||||
title: String,
|
||||
titleFontSize: Int,
|
||||
onDismissRequest: () -> Unit,
|
||||
content: @Composable (padding: androidx.compose.ui.unit.Dp) -> Unit
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
ElevatedCard {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(padding)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = titleFontSize.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
||||
)
|
||||
content(padding)
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
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 = MaterialTheme.shapes.medium,
|
||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(padding),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.nestedScroll(stopSheetFling)
|
||||
) {
|
||||
content(padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,3 +426,97 @@ fun TopBar(
|
||||
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
|
||||
return String.format(Locale.ENGLISH, "%d.%03d", mhz, khz)
|
||||
}
|
||||
+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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-7
@@ -21,23 +21,71 @@ import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites")
|
||||
|
||||
@Serializable
|
||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
|
||||
|
||||
@Serializable
|
||||
data class Radar(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar, "Radar")
|
||||
|
||||
@Serializable
|
||||
data class RadioControl(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(0, 0)
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map")
|
||||
|
||||
@Serializable
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual, "Mutual")
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming, "Roaming")
|
||||
|
||||
@Serializable
|
||||
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
|
||||
|
||||
@Serializable
|
||||
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
|
||||
|
||||
@Serializable
|
||||
data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT")
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
||||
}
|
||||
|
||||
// UI 设置: 默认主菜单页面顺序(底部栏 5 槽)
|
||||
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
|
||||
|
||||
// UI 设置: 默认更多菜单顺序(进「更多」的页面)
|
||||
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
|
||||
|
||||
@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,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- Morse style icon: a dot above a dash -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 8 4 L 16 4 L 16 8 L 8 8 Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 3 16 L 21 16 L 21 20 L 3 20 Z" />
|
||||
</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="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6,2h9l5,5v13c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V4c0,-1.1 0.9,-2 2,-2zM14,3.5V8h4.5L14,3.5zM8,12h8v2H8v-2zM8,16h8v2H8v-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,22 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="96"
|
||||
android:viewportHeight="96">
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="6"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="6"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
|
||||
</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>
|
||||
@@ -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="M12,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,25 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="96"
|
||||
android:viewportHeight="96">
|
||||
|
||||
<!-- Crosshair / grid-locator style icon: concentric rings + center dot -->
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="6"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M 48 12 L 48 84 M 12 48 L 84 48" />
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="6"
|
||||
android:pathData="M 48 26 A 22 22 0 1 0 48.1 26" />
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M 44 44 L 52 44 L 52 52 L 44 52 Z" />
|
||||
</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="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z" />
|
||||
</vector>
|
||||
@@ -31,7 +31,10 @@
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Filtrar pases</string>
|
||||
<string name="pass_filter_elev">Elevación mínima</string>
|
||||
<string name="pass_filter_hours">Horas por delante</string>
|
||||
<string name="pass_filter_hours">Tiempo por delante</string>
|
||||
<string name="pass_filter_aos_time">Ventana AOS</string>
|
||||
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
||||
<string name="pass_modes_title">Tipo de modulación</string>
|
||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||
@@ -132,12 +135,19 @@
|
||||
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
|
||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
||||
|
||||
<string name="prefs_other_title">Otros ajustes</string>
|
||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||
|
||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">Media %1$d-%2$d°</string>
|
||||
<string name="prefs_highlight_high">Alta > %1$d°</string>
|
||||
|
||||
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
|
||||
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Look4Sat Pro</string>
|
||||
<string name="btn_accept">Terima</string>
|
||||
<string name="btn_cancel">Batal</string>
|
||||
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
||||
<string name="nav_sat">Satelit</string>
|
||||
<string name="nav_pass">Lintasan</string>
|
||||
<string name="nav_radar">Radar</string>
|
||||
<string name="nav_mutual">Pasangan</string>
|
||||
<string name="nav_roaming">Jelajah</string>
|
||||
<string name="nav_cw">Dekoder CW</string>
|
||||
<string name="nav_map">Peta</string>
|
||||
<string name="nav_prefs">Pengaturan</string>
|
||||
<string name="nav_more">Lainnya</string>
|
||||
<string name="nav_log">Log</string>
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">Status Satelit AMSAT</string>
|
||||
<string name="amsat_updated">Diperbarui:</string>
|
||||
<string name="amsat_active">Aktif</string>
|
||||
<string name="amsat_tlm">TLM/beacon</string>
|
||||
<string name="amsat_not_heard">Tidak terdengar</string>
|
||||
<string name="amsat_conflict">Konflik</string>
|
||||
<string name="amsat_name">Name</string>
|
||||
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
|
||||
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
|
||||
<string name="amsat_close">Tutup</string>
|
||||
|
||||
<string name="sat_type_hint">Jenis: %s</string>
|
||||
<string name="sat_type_title">Pilih jenis satelit</string>
|
||||
<string name="sat_search_hint">Id - Nama</string>
|
||||
<string name="sat_search_clear">Bersihkan</string>
|
||||
<string name="sat_clear_all">Bersihkan semua</string>
|
||||
<string name="sat_select_all">Pilih semua</string>
|
||||
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
||||
<string name="sat_warning_title">Peringatan\!</string>
|
||||
<string name="sat_warning_message">
|
||||
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
||||
Tidak masuk akal untuk melacak semuanya sekaligus.
|
||||
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
||||
Anda minati melalui pencarian dan pemilih jenis.</string>
|
||||
<string name="pass_filter_title">Filter lintasan</string>
|
||||
<string name="pass_filter_elev">Elevasi minimal</string>
|
||||
<string name="pass_filter_hours">Waktu ke depan</string>
|
||||
<string name="pass_filter_aos_time">Jendela AOS</string>
|
||||
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
||||
<string name="pass_satId" translatable="false">%05d</string>
|
||||
<string name="pass_elevation">Elevasi: %.1f°</string>
|
||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||
<string name="pass_altitude">Ketinggian: %d km</string>
|
||||
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
||||
<string name="pass_empty_list_message">
|
||||
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
||||
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
||||
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* BARU: Halaman status satelit AMSAT - tabel status langsung (warna resmi), jumlah laporan, laporan detail per satelit/hari
|
||||
* Log dikelompokkan per lintasan satelit (sesi = satelit + waktu AOS), garis pemisah antar sesi
|
||||
* GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin)
|
||||
* Pesan kegagalan jelas saat database satelit gagal diunduh
|
||||
* Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan
|
||||
* Perbaikan bahasa Indonesia (values-id)</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
<string name="radar_az_value" translatable="false">%.1f°</string>
|
||||
<string name="radar_el_text">Elevasi</string>
|
||||
<string name="radar_el_value" translatable="false">%.1f°</string>
|
||||
<string name="radar_alt_text">Ketinggian</string>
|
||||
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
||||
<string name="radar_dist_text">Jarak</string>
|
||||
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
||||
<string name="radar_eclipsed">Gerhana</string>
|
||||
<string name="radar_downlink">Downlink</string>
|
||||
<string name="radar_uplink">Uplink</string>
|
||||
<string name="radar_link_low" translatable="false">%.4f</string>
|
||||
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
||||
<string name="radar_no_link" translatable="false">- - . - -</string>
|
||||
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
||||
<string name="radar_add">Tambah</string>
|
||||
<string name="radar_data">Transceiver satelit:</string>
|
||||
<string name="radar_inverted">Terbalik: %s</string>
|
||||
<string name="radar_mode">Mode: %s</string>
|
||||
<string name="radar_string_no">Tidak</string>
|
||||
<string name="radar_string_yes">Ya</string>
|
||||
<string name="radar_visible">Terlihat</string>
|
||||
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
||||
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
||||
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
||||
<string name="radar_cw_decoder">Dekoder CW</string>
|
||||
<string name="radar_cw_start">Mulai</string>
|
||||
<string name="radar_cw_stop">Berhenti</string>
|
||||
<string name="radar_cw_reset">Bersihkan</string>
|
||||
<string name="map_prev">Sebelumnya</string>
|
||||
<string name="map_next">Berikutnya</string>
|
||||
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
|
||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||
<string name="map_elevation">Elevasi: %.1f°</string>
|
||||
<string name="map_altitude">Ketinggian: %.0f km</string>
|
||||
<string name="map_distance">Jarak: %.0f km</string>
|
||||
<string name="map_latitude">Lintang: %.1f°</string>
|
||||
<string name="map_longitude">Bujur: %.1f°</string>
|
||||
<string name="map_qth" translatable="false">QTH: %s</string>
|
||||
<string name="map_phase">Fase: %.1f°</string>
|
||||
<string name="map_eclipsed">Gerhana</string>
|
||||
<string name="map_period">Periode: %.0f mnt</string>
|
||||
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
||||
<string name="map_visibility">Visibilitas: %s</string>
|
||||
<string name="map_visible">Terlihat</string>
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_donate_title">Donasi</string>
|
||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||
<string name="prefs_updated_title">Diperbarui: %s</string>
|
||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||
<string name="prefs_loc_title">Posisi stasiun</string>
|
||||
<string name="prefs_loc_gps_title">GPS</string>
|
||||
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
||||
<string name="prefs_loc_input_title">Input</string>
|
||||
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
||||
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
||||
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
||||
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
||||
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
||||
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
||||
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
||||
<string name="prefs_data_title">Data satelit</string>
|
||||
<string name="prefs_data_entries">Satelit: %s</string>
|
||||
<string name="prefs_data_radios">Transceiver: %s</string>
|
||||
<string name="prefs_data_update">Perbarui</string>
|
||||
<string name="prefs_data_import">URL Kustom</string>
|
||||
<string name="prefs_data_clear">Bersihkan</string>
|
||||
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
||||
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
||||
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
||||
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
||||
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
||||
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
||||
<string name="prefs_data_output_title">Keluaran data</string>
|
||||
<string name="prefs_net_output">Jaringan</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
<string name="nav_radiocontrol">Kontrol Radio</string>
|
||||
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
||||
<string name="rc_radio_model">Model Radio</string>
|
||||
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
||||
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
||||
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
||||
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
||||
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
||||
<string name="prefs_net_title">Keluaran data jaringan</string>
|
||||
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
||||
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
||||
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
||||
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
||||
<string name="tracking_status">Melacak: %s</string>
|
||||
<string name="loc_lat_text">Lintang: %s°</string>
|
||||
<string name="loc_lon_text">Bujur: %s°</string>
|
||||
<string name="loc_qth_text">QTH: %s</string>
|
||||
<string name="connect_radio">Hubungkan</string>
|
||||
<string name="disconnect_radio">Putuskan</string>
|
||||
<string name="track_radio">Lacak</string>
|
||||
<string name="stop_radio">Berhenti</string>
|
||||
<string name="prefs_wavelog_title">Server log WaveLog</string>
|
||||
<string name="prefs_wavelog_url_hint">Alamat server</string>
|
||||
<string name="prefs_wavelog_key_hint">Kunci API</string>
|
||||
<string name="prefs_wavelog_station_hint">ID stasiun</string>
|
||||
<string name="prefs_wavelog_auto">Unggah otomatis</string>
|
||||
<string name="prefs_wavelog_test">Uji koneksi</string>
|
||||
<string name="prefs_wavelog_upload">Unggah sekarang</string>
|
||||
<string name="wavelog_title">Log</string>
|
||||
<string name="wavelog_select_transponder">Pilih transponder</string>
|
||||
<string name="wavelog_call_hint">Tanda panggil</string>
|
||||
<string name="wavelog_mode_hint">Mode</string>
|
||||
<string name="wavelog_saved">Tersimpan ke log lokal</string>
|
||||
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
|
||||
<string name="wavelog_empty">Belum ada entri log</string>
|
||||
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
|
||||
<string name="wavelog_ignore">Abaikan dan unggah</string>
|
||||
<string name="wavelog_cancel">Batal</string>
|
||||
<string name="wavelog_copy">Salin</string>
|
||||
<string name="wavelog_delete_undo">Urungkan</string>
|
||||
<string name="wavelog_col_time">Waktu</string>
|
||||
<string name="wavelog_col_freq">Frek.</string>
|
||||
<string name="wavelog_col_sat">Satelit</string>
|
||||
<string name="wavelog_col_call">Panggilan</string>
|
||||
<string name="wavelog_col_uploaded">Terkirim</string>
|
||||
<string name="prefs_other_title">Pengaturan lainnya</string>
|
||||
<string name="prefs_ui_title">Pengaturan UI</string>
|
||||
<string name="prefs_ui_order_title">Urutan halaman</string>
|
||||
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
||||
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
||||
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
||||
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
||||
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
||||
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
||||
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
||||
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
||||
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
||||
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
||||
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
||||
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
||||
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
||||
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
* Pengguna dan kontributor Look4Sat!
|
||||
\n* David A. B. Johnson (predict4java)
|
||||
\n* Dave Moten (predict4java)
|
||||
\n* Alexandru Csete (Gpredict)
|
||||
\n* Dr T.S. Kelso (Celestrak)
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl dan kontributor Robot36!
|
||||
\n* BA7OPF (fitur pencocokan lintasan)
|
||||
\n* BG7NTA</string>
|
||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,252 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Look4Sat Pro</string>
|
||||
<string name="btn_accept">Terima</string>
|
||||
<string name="btn_cancel">Batal</string>
|
||||
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
||||
<string name="nav_sat">Satelit</string>
|
||||
<string name="nav_pass">Lintasan</string>
|
||||
<string name="nav_radar">Radar</string>
|
||||
<string name="nav_mutual">Pasangan</string>
|
||||
<string name="nav_roaming">Jelajah</string>
|
||||
<string name="nav_cw">Dekoder CW</string>
|
||||
<string name="nav_map">Peta</string>
|
||||
<string name="nav_prefs">Pengaturan</string>
|
||||
<string name="nav_more">Lainnya</string>
|
||||
<string name="nav_log">Log</string>
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">Status Satelit AMSAT</string>
|
||||
<string name="amsat_updated">Diperbarui:</string>
|
||||
<string name="amsat_active">Aktif</string>
|
||||
<string name="amsat_tlm">TLM/beacon</string>
|
||||
<string name="amsat_not_heard">Tidak terdengar</string>
|
||||
<string name="amsat_conflict">Konflik</string>
|
||||
<string name="amsat_name">Name</string>
|
||||
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
|
||||
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
|
||||
<string name="amsat_close">Tutup</string>
|
||||
<string name="sat_type_hint">Jenis: %s</string>
|
||||
<string name="sat_type_title">Pilih jenis satelit</string>
|
||||
<string name="sat_search_hint">Id - Nama</string>
|
||||
<string name="sat_search_clear">Bersihkan</string>
|
||||
<string name="sat_clear_all">Bersihkan semua</string>
|
||||
<string name="sat_select_all">Pilih semua</string>
|
||||
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
||||
<string name="sat_warning_title">Peringatan\!</string>
|
||||
<string name="sat_warning_message">
|
||||
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
||||
Tidak masuk akal untuk melacak semuanya sekaligus.
|
||||
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
||||
Anda minati melalui pencarian dan pemilih jenis.</string>
|
||||
<string name="pass_filter_title">Filter lintasan</string>
|
||||
<string name="pass_filter_elev">Elevasi minimal</string>
|
||||
<string name="pass_filter_hours">Waktu ke depan</string>
|
||||
<string name="pass_filter_aos_time">Jendela AOS</string>
|
||||
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
||||
<string name="pass_satId" translatable="false">%05d</string>
|
||||
<string name="pass_elevation">Elevasi: %.1f°</string>
|
||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||
<string name="pass_altitude">Ketinggian: %d km</string>
|
||||
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
||||
<string name="pass_empty_list_message">
|
||||
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
||||
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
||||
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* BARU: Halaman status satelit AMSAT - tabel status langsung (warna resmi), jumlah laporan, laporan detail per satelit/hari
|
||||
* Log dikelompokkan per lintasan satelit (sesi = satelit + waktu AOS), garis pemisah antar sesi
|
||||
* GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin)
|
||||
* Pesan kegagalan jelas saat database satelit gagal diunduh
|
||||
* Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan
|
||||
* Perbaikan bahasa Indonesia (values-id)</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
<string name="radar_az_value" translatable="false">%.1f°</string>
|
||||
<string name="radar_el_text">Elevasi</string>
|
||||
<string name="radar_el_value" translatable="false">%.1f°</string>
|
||||
<string name="radar_alt_text">Ketinggian</string>
|
||||
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
||||
<string name="radar_dist_text">Jarak</string>
|
||||
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
||||
<string name="radar_eclipsed">Gerhana</string>
|
||||
<string name="radar_downlink">Downlink</string>
|
||||
<string name="radar_uplink">Uplink</string>
|
||||
<string name="radar_link_low" translatable="false">%.4f</string>
|
||||
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
||||
<string name="radar_no_link" translatable="false">- - . - -</string>
|
||||
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
||||
<string name="radar_add">Tambah</string>
|
||||
<string name="radar_data">Transceiver satelit:</string>
|
||||
<string name="radar_inverted">Terbalik: %s</string>
|
||||
<string name="radar_mode">Mode: %s</string>
|
||||
<string name="radar_string_no">Tidak</string>
|
||||
<string name="radar_string_yes">Ya</string>
|
||||
<string name="radar_visible">Terlihat</string>
|
||||
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
||||
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
||||
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
||||
<string name="radar_cw_decoder">Dekoder CW</string>
|
||||
<string name="radar_cw_start">Mulai</string>
|
||||
<string name="radar_cw_stop">Berhenti</string>
|
||||
<string name="radar_cw_reset">Bersihkan</string>
|
||||
<string name="map_prev">Sebelumnya</string>
|
||||
<string name="map_next">Berikutnya</string>
|
||||
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
|
||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||
<string name="map_elevation">Elevasi: %.1f°</string>
|
||||
<string name="map_altitude">Ketinggian: %.0f km</string>
|
||||
<string name="map_distance">Jarak: %.0f km</string>
|
||||
<string name="map_latitude">Lintang: %.1f°</string>
|
||||
<string name="map_longitude">Bujur: %.1f°</string>
|
||||
<string name="map_qth" translatable="false">QTH: %s</string>
|
||||
<string name="map_phase">Fase: %.1f°</string>
|
||||
<string name="map_eclipsed">Gerhana</string>
|
||||
<string name="map_period">Periode: %.0f mnt</string>
|
||||
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
||||
<string name="map_visibility">Visibilitas: %s</string>
|
||||
<string name="map_visible">Terlihat</string>
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_donate_title">Donasi</string>
|
||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||
<string name="prefs_updated_title">Diperbarui: %s</string>
|
||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||
<string name="prefs_loc_title">Posisi stasiun</string>
|
||||
<string name="prefs_loc_gps_title">GPS</string>
|
||||
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
||||
<string name="prefs_loc_input_title">Input</string>
|
||||
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
||||
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
||||
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
||||
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
||||
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
||||
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
||||
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
||||
<string name="prefs_data_title">Data satelit</string>
|
||||
<string name="prefs_data_entries">Satelit: %s</string>
|
||||
<string name="prefs_data_radios">Transceiver: %s</string>
|
||||
<string name="prefs_data_update">Perbarui</string>
|
||||
<string name="prefs_data_import">URL Kustom</string>
|
||||
<string name="prefs_data_clear">Bersihkan</string>
|
||||
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
||||
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
||||
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
||||
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
||||
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
||||
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
||||
<string name="prefs_data_output_title">Keluaran data</string>
|
||||
<string name="prefs_net_output">Jaringan</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
<string name="nav_radiocontrol">Kontrol Radio</string>
|
||||
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
||||
<string name="rc_radio_model">Model Radio</string>
|
||||
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
||||
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
||||
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
||||
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
||||
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
||||
<string name="prefs_net_title">Keluaran data jaringan</string>
|
||||
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
||||
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
||||
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
||||
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
||||
<string name="tracking_status">Melacak: %s</string>
|
||||
<string name="loc_lat_text">Lintang: %s°</string>
|
||||
<string name="loc_lon_text">Bujur: %s°</string>
|
||||
<string name="loc_qth_text">QTH: %s</string>
|
||||
<string name="connect_radio">Hubungkan</string>
|
||||
<string name="disconnect_radio">Putuskan</string>
|
||||
<string name="track_radio">Lacak</string>
|
||||
<string name="stop_radio">Berhenti</string>
|
||||
<string name="prefs_wavelog_title">Server log WaveLog</string>
|
||||
<string name="prefs_wavelog_url_hint">Alamat server</string>
|
||||
<string name="prefs_wavelog_key_hint">Kunci API</string>
|
||||
<string name="prefs_wavelog_station_hint">ID stasiun</string>
|
||||
<string name="prefs_wavelog_auto">Unggah otomatis</string>
|
||||
<string name="prefs_wavelog_test">Uji koneksi</string>
|
||||
<string name="prefs_wavelog_upload">Unggah sekarang</string>
|
||||
<string name="wavelog_title">Log</string>
|
||||
<string name="wavelog_select_transponder">Pilih transponder</string>
|
||||
<string name="wavelog_call_hint">Tanda panggil</string>
|
||||
<string name="wavelog_mode_hint">Mode</string>
|
||||
<string name="wavelog_saved">Tersimpan ke log lokal</string>
|
||||
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
|
||||
<string name="wavelog_empty">Belum ada entri log</string>
|
||||
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
|
||||
<string name="wavelog_ignore">Abaikan dan unggah</string>
|
||||
<string name="wavelog_cancel">Batal</string>
|
||||
<string name="wavelog_copy">Salin</string>
|
||||
<string name="wavelog_delete_undo">Urungkan</string>
|
||||
<string name="wavelog_col_time">Waktu</string>
|
||||
<string name="wavelog_col_freq">Frek.</string>
|
||||
<string name="wavelog_col_sat">Satelit</string>
|
||||
<string name="wavelog_col_call">Panggilan</string>
|
||||
<string name="wavelog_col_uploaded">Terkirim</string>
|
||||
<string name="wavelog_ungrouped">Tidak dikelompokkan</string>
|
||||
<string name="prefs_other_title">Pengaturan lainnya</string>
|
||||
<string name="prefs_ui_title">Pengaturan UI</string>
|
||||
<string name="prefs_ui_order_title">Urutan halaman</string>
|
||||
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
||||
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
||||
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
||||
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
||||
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
||||
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
||||
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
||||
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
||||
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
||||
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
||||
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
||||
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
||||
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
||||
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
* Pengguna dan kontributor Look4Sat!
|
||||
\n* David A. B. Johnson (predict4java)
|
||||
\n* Dave Moten (predict4java)
|
||||
\n* Alexandru Csete (Gpredict)
|
||||
\n* Dr T.S. Kelso (Celestrak)
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl dan kontributor Robot36!
|
||||
\n* BA7OPF (fitur pencocokan lintasan)
|
||||
\n* BG7NTA</string>
|
||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,252 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Look4Sat Pro</string>
|
||||
<string name="btn_accept">Terima</string>
|
||||
<string name="btn_cancel">Batal</string>
|
||||
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
||||
<string name="nav_sat">Satelit</string>
|
||||
<string name="nav_pass">Lintasan</string>
|
||||
<string name="nav_radar">Radar</string>
|
||||
<string name="nav_mutual">Pasangan</string>
|
||||
<string name="nav_roaming">Jelajah</string>
|
||||
<string name="nav_cw">Dekoder CW</string>
|
||||
<string name="nav_map">Peta</string>
|
||||
<string name="nav_prefs">Pengaturan</string>
|
||||
<string name="nav_more">Lainnya</string>
|
||||
<string name="nav_log">Log</string>
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">Status Satelit AMSAT</string>
|
||||
<string name="amsat_updated">Diperbarui:</string>
|
||||
<string name="amsat_active">Aktif</string>
|
||||
<string name="amsat_tlm">TLM/beacon</string>
|
||||
<string name="amsat_not_heard">Tidak terdengar</string>
|
||||
<string name="amsat_conflict">Konflik</string>
|
||||
<string name="amsat_name">Name</string>
|
||||
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
|
||||
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
|
||||
<string name="amsat_close">Tutup</string>
|
||||
<string name="sat_type_hint">Jenis: %s</string>
|
||||
<string name="sat_type_title">Pilih jenis satelit</string>
|
||||
<string name="sat_search_hint">Id - Nama</string>
|
||||
<string name="sat_search_clear">Bersihkan</string>
|
||||
<string name="sat_clear_all">Bersihkan semua</string>
|
||||
<string name="sat_select_all">Pilih semua</string>
|
||||
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
||||
<string name="sat_warning_title">Peringatan\!</string>
|
||||
<string name="sat_warning_message">
|
||||
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
||||
Tidak masuk akal untuk melacak semuanya sekaligus.
|
||||
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
||||
Anda minati melalui pencarian dan pemilih jenis.</string>
|
||||
<string name="pass_filter_title">Filter lintasan</string>
|
||||
<string name="pass_filter_elev">Elevasi minimal</string>
|
||||
<string name="pass_filter_hours">Waktu ke depan</string>
|
||||
<string name="pass_filter_aos_time">Jendela AOS</string>
|
||||
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
||||
<string name="pass_satId" translatable="false">%05d</string>
|
||||
<string name="pass_elevation">Elevasi: %.1f°</string>
|
||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||
<string name="pass_altitude">Ketinggian: %d km</string>
|
||||
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
||||
<string name="pass_empty_list_message">
|
||||
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
||||
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
||||
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* BARU: Halaman status satelit AMSAT - tabel status langsung (warna resmi), jumlah laporan, laporan detail per satelit/hari
|
||||
* Log dikelompokkan per lintasan satelit (sesi = satelit + waktu AOS), garis pemisah antar sesi
|
||||
* GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin)
|
||||
* Pesan kegagalan jelas saat database satelit gagal diunduh
|
||||
* Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan
|
||||
* Perbaikan bahasa Indonesia (values-id)</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
<string name="radar_az_value" translatable="false">%.1f°</string>
|
||||
<string name="radar_el_text">Elevasi</string>
|
||||
<string name="radar_el_value" translatable="false">%.1f°</string>
|
||||
<string name="radar_alt_text">Ketinggian</string>
|
||||
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
||||
<string name="radar_dist_text">Jarak</string>
|
||||
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
||||
<string name="radar_eclipsed">Gerhana</string>
|
||||
<string name="radar_downlink">Downlink</string>
|
||||
<string name="radar_uplink">Uplink</string>
|
||||
<string name="radar_link_low" translatable="false">%.4f</string>
|
||||
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
||||
<string name="radar_no_link" translatable="false">- - . - -</string>
|
||||
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
||||
<string name="radar_add">Tambah</string>
|
||||
<string name="radar_data">Transceiver satelit:</string>
|
||||
<string name="radar_inverted">Terbalik: %s</string>
|
||||
<string name="radar_mode">Mode: %s</string>
|
||||
<string name="radar_string_no">Tidak</string>
|
||||
<string name="radar_string_yes">Ya</string>
|
||||
<string name="radar_visible">Terlihat</string>
|
||||
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
||||
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
||||
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
||||
<string name="radar_cw_decoder">Dekoder CW</string>
|
||||
<string name="radar_cw_start">Mulai</string>
|
||||
<string name="radar_cw_stop">Berhenti</string>
|
||||
<string name="radar_cw_reset">Bersihkan</string>
|
||||
<string name="map_prev">Sebelumnya</string>
|
||||
<string name="map_next">Berikutnya</string>
|
||||
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
|
||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||
<string name="map_elevation">Elevasi: %.1f°</string>
|
||||
<string name="map_altitude">Ketinggian: %.0f km</string>
|
||||
<string name="map_distance">Jarak: %.0f km</string>
|
||||
<string name="map_latitude">Lintang: %.1f°</string>
|
||||
<string name="map_longitude">Bujur: %.1f°</string>
|
||||
<string name="map_qth" translatable="false">QTH: %s</string>
|
||||
<string name="map_phase">Fase: %.1f°</string>
|
||||
<string name="map_eclipsed">Gerhana</string>
|
||||
<string name="map_period">Periode: %.0f mnt</string>
|
||||
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
||||
<string name="map_visibility">Visibilitas: %s</string>
|
||||
<string name="map_visible">Terlihat</string>
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_donate_title">Donasi</string>
|
||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||
<string name="prefs_updated_title">Diperbarui: %s</string>
|
||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||
<string name="prefs_loc_title">Posisi stasiun</string>
|
||||
<string name="prefs_loc_gps_title">GPS</string>
|
||||
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
||||
<string name="prefs_loc_input_title">Input</string>
|
||||
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
||||
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
||||
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
||||
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
||||
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
||||
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
||||
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
||||
<string name="prefs_data_title">Data satelit</string>
|
||||
<string name="prefs_data_entries">Satelit: %s</string>
|
||||
<string name="prefs_data_radios">Transceiver: %s</string>
|
||||
<string name="prefs_data_update">Perbarui</string>
|
||||
<string name="prefs_data_import">URL Kustom</string>
|
||||
<string name="prefs_data_clear">Bersihkan</string>
|
||||
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
||||
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
||||
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
||||
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
||||
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
||||
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
||||
<string name="prefs_data_output_title">Keluaran data</string>
|
||||
<string name="prefs_net_output">Jaringan</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
<string name="nav_radiocontrol">Kontrol Radio</string>
|
||||
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
||||
<string name="rc_radio_model">Model Radio</string>
|
||||
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
||||
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
||||
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
||||
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
||||
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
||||
<string name="prefs_net_title">Keluaran data jaringan</string>
|
||||
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
||||
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
||||
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
||||
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
||||
<string name="tracking_status">Melacak: %s</string>
|
||||
<string name="loc_lat_text">Lintang: %s°</string>
|
||||
<string name="loc_lon_text">Bujur: %s°</string>
|
||||
<string name="loc_qth_text">QTH: %s</string>
|
||||
<string name="connect_radio">Hubungkan</string>
|
||||
<string name="disconnect_radio">Putuskan</string>
|
||||
<string name="track_radio">Lacak</string>
|
||||
<string name="stop_radio">Berhenti</string>
|
||||
<string name="prefs_wavelog_title">Server log WaveLog</string>
|
||||
<string name="prefs_wavelog_url_hint">Alamat server</string>
|
||||
<string name="prefs_wavelog_key_hint">Kunci API</string>
|
||||
<string name="prefs_wavelog_station_hint">ID stasiun</string>
|
||||
<string name="prefs_wavelog_auto">Unggah otomatis</string>
|
||||
<string name="prefs_wavelog_test">Uji koneksi</string>
|
||||
<string name="prefs_wavelog_upload">Unggah sekarang</string>
|
||||
<string name="wavelog_title">Log</string>
|
||||
<string name="wavelog_select_transponder">Pilih transponder</string>
|
||||
<string name="wavelog_call_hint">Tanda panggil</string>
|
||||
<string name="wavelog_mode_hint">Mode</string>
|
||||
<string name="wavelog_saved">Tersimpan ke log lokal</string>
|
||||
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
|
||||
<string name="wavelog_empty">Belum ada entri log</string>
|
||||
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
|
||||
<string name="wavelog_ignore">Abaikan dan unggah</string>
|
||||
<string name="wavelog_cancel">Batal</string>
|
||||
<string name="wavelog_copy">Salin</string>
|
||||
<string name="wavelog_delete_undo">Urungkan</string>
|
||||
<string name="wavelog_col_time">Waktu</string>
|
||||
<string name="wavelog_col_freq">Frek.</string>
|
||||
<string name="wavelog_col_sat">Satelit</string>
|
||||
<string name="wavelog_col_call">Panggilan</string>
|
||||
<string name="wavelog_col_uploaded">Terkirim</string>
|
||||
<string name="wavelog_ungrouped">Tidak dikelompokkan</string>
|
||||
<string name="prefs_other_title">Pengaturan lainnya</string>
|
||||
<string name="prefs_ui_title">Pengaturan UI</string>
|
||||
<string name="prefs_ui_order_title">Urutan halaman</string>
|
||||
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
||||
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
||||
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
||||
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
||||
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
||||
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
||||
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
||||
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
||||
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
||||
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
||||
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
||||
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
||||
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
||||
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
* Pengguna dan kontributor Look4Sat!
|
||||
\n* David A. B. Johnson (predict4java)
|
||||
\n* Dave Moten (predict4java)
|
||||
\n* Alexandru Csete (Gpredict)
|
||||
\n* Dr T.S. Kelso (Celestrak)
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl dan kontributor Robot36!
|
||||
\n* BA7OPF (fitur pencocokan lintasan)
|
||||
\n* BG7NTA</string>
|
||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||
</resources>
|
||||
@@ -30,7 +30,10 @@
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||
<string name="pass_filter_elev">Минимальная элевация</string>
|
||||
<string name="pass_filter_hours">Количество часов</string>
|
||||
<string name="pass_filter_hours">Период расчёта</string>
|
||||
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||
<string name="pass_modes_title">Выберите тип модуляции</string>
|
||||
<string name="pass_elevation">Элевация: %.1f°</string>
|
||||
<string name="pass_altitude">Высота: %d км</string>
|
||||
@@ -131,12 +134,19 @@
|
||||
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Формат</string>
|
||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
||||
|
||||
<string name="prefs_other_title">Другие настройки</string>
|
||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||
|
||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">Средне %1$d-%2$d°</string>
|
||||
<string name="prefs_highlight_high">Высоко > %1$d°</string>
|
||||
|
||||
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
||||
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
||||
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
||||
<string name="pass_filter_hours">ඉදිරි පැය</string>
|
||||
<string name="pass_filter_hours">ඉදිරි කාලය</string>
|
||||
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
||||
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
||||
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
|
||||
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
||||
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
||||
@@ -132,12 +135,19 @@
|
||||
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
|
||||
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
||||
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
||||
|
||||
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||
|
||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">මධ්ය %1$d-%2$d°</string>
|
||||
<string name="prefs_highlight_high">ඉහළ > %1$d°</string>
|
||||
|
||||
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
||||
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
||||
|
||||
@@ -10,8 +10,22 @@
|
||||
<string name="nav_sat">Uydular</string>
|
||||
<string name="nav_pass">Geçişler</string>
|
||||
<string name="nav_radar">Radar</string>
|
||||
<string name="nav_cw">CW Dekoder</string>
|
||||
<string name="nav_map">Harita</string>
|
||||
<string name="nav_prefs">Ayarlar</string>
|
||||
<string name="nav_more">Daha Fazla</string>
|
||||
<string name="nav_log">Günlük</string>
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">AMSAT Uydu Durumu</string>
|
||||
<string name="amsat_updated">Güncellendi:</string>
|
||||
<string name="amsat_active">Aktif</string>
|
||||
<string name="amsat_tlm">TLM/işaret</string>
|
||||
<string name="amsat_not_heard">Duyulmadı</string>
|
||||
<string name="amsat_conflict">Çelişkili</string>
|
||||
<string name="amsat_name">Name</string>
|
||||
<string name="amsat_load_failed">Yükleme başarısız - ağı kontrol edip tekrar deneyin</string>
|
||||
<string name="amsat_no_reports">Bu dönem için rapor yok</string>
|
||||
<string name="amsat_close">Kapat</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Uydu türü: %s</string>
|
||||
@@ -31,7 +45,10 @@
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Geçişleri filtrele</string>
|
||||
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
|
||||
<string name="pass_filter_hours">Gösterilecek saat aralığı</string>
|
||||
<string name="pass_filter_hours">Gösterilecek zaman</string>
|
||||
<string name="pass_filter_aos_time">AOS aralığı</string>
|
||||
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||
<string name="pass_modes_title">Modulasyon türü seçin</string>
|
||||
<string name="pass_satId" translatable="false">%05d</string>
|
||||
@@ -47,13 +64,13 @@
|
||||
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
|
||||
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Switched to short-form commands in NetworkReporter, by theojalba
|
||||
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
||||
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
|
||||
\n* Added multiple performance tweaks, improved passes calculation
|
||||
\n* Fixed UTC pass time problem mentioned in issue #201
|
||||
</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* YENİ: AMSAT uydu durum sayfası - canlı durum tablosu (resmi renkler), rapor sayıları, uydu/gün bazında detaylı raporlar
|
||||
* Günlükler uydu geçişine göre gruplanır (oturum = uydu + AOS zamanı), oturumlar arasında ayırıcı çizgi
|
||||
* GPS artık yalnızca gerçek konum alındığında başarı gösterir (15 sn zaman aşımı, izin kontrolü)
|
||||
* Uydu veritabanı indirilemediğinde net hata mesajı gösterilir
|
||||
* Günlük frekansları Doppler panelini gerçek zamanlı takip eder; kaydırarak silme görselleri düzeltildi
|
||||
* Endonezya dili düzeltmesi (values-id)</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Geri</string>
|
||||
@@ -100,7 +117,7 @@
|
||||
<string name="map_visible">Görünür</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_donate_title">Bağış yap</string>
|
||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||
@@ -118,7 +135,7 @@
|
||||
|
||||
<string name="prefs_loc_title">İstasyon konumu</string>
|
||||
<string name="prefs_loc_gps_title">GPS</string>
|
||||
<string name="prefs_loc_gps_error">Konum izinlerinizi kontrol edin</string>
|
||||
<string name="prefs_loc_gps_error">Konum alınamıyor - izinleri ve GPS/ağ sinyalini kontrol edin</string>
|
||||
<string name="prefs_loc_input_title">Manuel giriş</string>
|
||||
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
@@ -134,15 +151,17 @@
|
||||
<string name="prefs_data_entries">Uydular: %s</string>
|
||||
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
|
||||
<string name="prefs_data_update">Güncelle</string>
|
||||
<string name="prefs_data_import">İçe aktar</string>
|
||||
<string name="prefs_data_import">Özel URL</string>
|
||||
<string name="prefs_data_clear">Temizle</string>
|
||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||
<string name="prefs_data_update_failed">Güncelleme başarısız - ağınızı kontrol edin</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_reset_url">Varsayılan URL\'ye sıfırla</string>
|
||||
|
||||
<string name="prefs_data_output_title">Veri aktarımı</string>
|
||||
<string name="prefs_net_output">Ağ</string>
|
||||
@@ -175,21 +194,71 @@
|
||||
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
|
||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||
|
||||
<string name="tracking_status">İzleniyor: %s</string>
|
||||
<string name="loc_lat_text">Enlem: %s°</string>
|
||||
<string name="loc_lon_text">Boylam: %s°</string>
|
||||
<string name="loc_qth_text">QTH: %s</string>
|
||||
<string name="connect_radio">Bağlan</string>
|
||||
<string name="disconnect_radio">Bağlantıyı Kes</string>
|
||||
<string name="track_radio">Takip</string>
|
||||
<string name="stop_radio">Dur</string>
|
||||
<string name="prefs_wavelog_title">WaveLog günlük sunucusu</string>
|
||||
<string name="prefs_wavelog_url_hint">Sunucu adresi</string>
|
||||
<string name="prefs_wavelog_key_hint">API anahtarı</string>
|
||||
<string name="prefs_wavelog_station_hint">İstasyon kimliği</string>
|
||||
<string name="prefs_wavelog_auto">Otomatik yükleme</string>
|
||||
<string name="prefs_wavelog_test">Bağlantıyı test et</string>
|
||||
<string name="prefs_wavelog_upload">Şimdi yükle</string>
|
||||
<string name="wavelog_title">Günlük</string>
|
||||
<string name="wavelog_select_transponder">Transponder seç</string>
|
||||
<string name="wavelog_call_hint">Çağrı işareti</string>
|
||||
<string name="wavelog_mode_hint">Mod</string>
|
||||
<string name="wavelog_saved">Yerel günlüğe kaydedildi</string>
|
||||
<string name="wavelog_need_config">Önce Ayarlar\'da WaveLog\'u yapılandırın</string>
|
||||
<string name="wavelog_empty">Henüz günlük kaydı yok</string>
|
||||
<string name="wavelog_grid_mismatch">QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi?</string>
|
||||
<string name="wavelog_ignore">Yoksay ve yükle</string>
|
||||
<string name="wavelog_cancel">İptal</string>
|
||||
<string name="wavelog_copy">Kopyala</string>
|
||||
<string name="wavelog_delete_undo">Geri al</string>
|
||||
<string name="wavelog_col_time">Zaman</string>
|
||||
<string name="wavelog_col_freq">Frekans</string>
|
||||
<string name="wavelog_col_sat">Uydu</string>
|
||||
<string name="wavelog_col_call">Çağrı</string>
|
||||
<string name="wavelog_col_uploaded">Gönderildi</string>
|
||||
<string name="wavelog_ungrouped">Gruplanmamış</string>
|
||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||
<string name="prefs_ui_title">UI Ayarları</string>
|
||||
<string name="prefs_ui_order_title">Sayfa sırası</string>
|
||||
<string name="prefs_ui_main_title">Ana menü (alt çubuk)</string>
|
||||
<string name="prefs_ui_sub_title">Daha fazla menü</string>
|
||||
<string name="prefs_ui_move_out">Daha fazla menüye taşı</string>
|
||||
<string name="prefs_ui_move_back">Ana menüye geri taşı</string>
|
||||
<string name="prefs_ui_order_locked">Sabit (taşınamaz)</string>
|
||||
<string name="prefs_ui_order_reset">Sıralamayı sıfırla</string>
|
||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
||||
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
||||
|
||||
<string name="prefs_outro_title">Teşekkürler</string>
|
||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
|
||||
<string name="prefs_highlight_high">Yüksek > %1$d°</string>
|
||||
|
||||
<string name="prefs_outro_title">BG7NTA ve orijinal yazar teşekkür eder</string>
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
• Look4Sat users and contributors!
|
||||
\n• David A. B. Johnson (predict4java)
|
||||
\n• Dave Moten (predict4java)
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)</string>
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• BA7OPF (pass matching feature)
|
||||
\n• BG7NTA</string>
|
||||
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||
|
||||
</resources>
|
||||
@@ -30,8 +30,11 @@
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||
<string name="pass_filter_elev">Мінімальне піднесення</string>
|
||||
<string name="pass_filter_hours">Кількість годин</string>
|
||||
<string name="pass_filter_elev">Мінімальне піднесень</string>
|
||||
<string name="pass_filter_hours">Час вперед</string>
|
||||
<string name="pass_filter_aos_time">Вікно AOS</string>
|
||||
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
||||
<string name="pass_modes_title">Виберіть тип модуляції</string>
|
||||
<string name="pass_elevation">Піднес.: %.1f°</string>
|
||||
<string name="pass_altitude">Висота: %.0f км</string>
|
||||
@@ -66,7 +69,7 @@
|
||||
<string name="map_prev">Попередній</string>
|
||||
<string name="map_next">Наступний</string>
|
||||
<string name="map_azimuth">Азимут: %.1f°</string>
|
||||
<string name="map_elevation">Піднесення: %.1f°</string>
|
||||
<string name="map_elevation">Піднесень: %.1f°</string>
|
||||
<string name="map_altitude">Висота: %.0f км</string>
|
||||
<string name="map_distance">Дистанція: %.0f км</string>
|
||||
<string name="map_latitude">Широта: %.1f°</string>
|
||||
@@ -132,12 +135,19 @@
|
||||
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
||||
|
||||
<string name="prefs_other_title">Інші налаштування</string>
|
||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||
|
||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">Середньо %1$d-%2$d°</string>
|
||||
<string name="prefs_highlight_high">Високо > %1$d°</string>
|
||||
|
||||
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
||||
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
||||
|
||||
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user