Compare commits

...
182 Commits
Author SHA1 Message Date
atsunatsu ba8d306d98 Merge pull request #2 from MCKero6423/ultra-function
ultra function: WaveLog logging, CW decoder, AMSAT status page, GPS/data fixes, i18n
2026-08-06 10:16:06 +08:00
mckero 0ef8f05dc4 fix(i18n): keep legacy "in" locale config for old Indonesian devices
Root cause: aapt2 merges values-in into values-id (in is the legacy
alias of id), so the APK only carried the (id) config. New devices
report "id" and match; older devices report "in" and find no (in)
config -> fall back to English (friend's report: only system date
showed Indonesian).

Fixes:
- Add values-in-rID (core/presentation + feature/cw) so the APK
  keeps a real "in" language config; values-in and values-id both
  get translatable="false" on the 27 entries that English marks
  (aapt2 rejects those with multiple %-substitutions otherwise)
- Verified: aapt2 compile of values/values-in/values-id/values-in-rID
  OK; :core:presentation:mergeDebugResources + :feature:cw:mergeDebugResources
  BUILD SUCCESSFUL
2026-08-04 16:04:04 +00:00
mckero f7402a3531 feat(status): 4.5.3 AMSAT satellite status page + radar log pass divider
New feature/status module: fetches https://amsat.org/status/ and
renders a live status grid in the official site colors:
- Parser (AmSatParser): 47 satellites x 6 days x 12 two-hour slots,
  official colors (blue=Active, orange=TLM/Beacon, pink=Not Heard,
  deep-orange=Conflicting, gray=none); 598+ report details extracted
  from inline JS tooltips (callsign/date/time/grid)
- Three-level viz: color grid -> report count -> tap day cell opens
  report list dialog
- Manual refresh with spin animation + last-updated timestamp +
  legend row; loading/error states
- New "AMSAT" entry in the More menu (Screen.AmSat), integrated with
  page-order / hide-page settings (SettingsScreen screens list,
  defaultSubMenuOrder, allNavItems, migration for existing users)
- AmSatRepository via IRemoteSource.getStatusHtml() (UA header);
  shared remoteSource promoted to a lazy class property in MainContainer

Radar page Log tab: local entries now grouped by pass session with a
thick divider + satellite label between groups (matches the log page).

What's new updated in EN/ZH/TR/IN/ID. Version bumped to 4.5.3/453.
Not released (user gates all releases).
2026-08-04 15:57:41 +00:00
mckero dd378990fe feat(wavelog): group log entries by pass session (satellite + AOS timestamp)
User request: logs should be separated by satellite/pass. Each pass
session gets an ID = satellite name + AOS timestamp (the second the
elevation hits 0, from OrbitalPass.aosTime), e.g.
"ASRTU-1-20260804-2014". Log page groups entries by session:
group title (satellite - local time) + thick divider line between
groups (md --- style); legacy entries without sessionId fall into
"Ungrouped" at the end.

- WavelogQso: +sessionId (persisted in queue JSON)
- LogTab: sessionId built from satelliteName + aosTimeMs (radar page
  passes currentPass.aosTime)
- WavelogLogScreen: grouped rendering + wavelog_ungrouped string (4 locales)
- sessionId UTC yyyyMMdd-HHmm; display converts to local time

Verified: check_strings OK (9 files); :app:compileDebugKotlin
BUILD SUCCESSFUL. Not released (batched).
2026-08-04 14:39:01 +00:00
mckero a23bf34ccf fix(radar): swipe-delete visuals restored (yellow trash + undo countdown)
Previous fix added the content-layer background OUTSIDE the offset
modifier, so the background stayed at the original position and
permanently covered the swipe-reveal area - the yellow trash icon,
75% undo button and 5s countdown were invisible while swiping.
Moved the background INSIDE the offset (background follows the
content): idle = fully covered (no bleed into Sent column), swiping
= yellow trash / undo countdown revealed as before.

Verified: :feature:radar:compileDebugKotlin BUILD SUCCESSFUL.
Not released (batched with pending fixes).
2026-08-04 14:22:27 +00:00
mckero a724c0823d fix(i18n): add values-id resource dir so Indonesian devices pick the locale
Friend's device (system language Bahasa Indonesia) fell back to
English even though values-in exists. Modern Android devices report
the Indonesian locale as "id" (ISO-639-1 current code; "in" is the
legacy alias) and resource matching is strict. Added values-id as a
copy of values-in in core/presentation and feature/cw (both language
directories ship the same translations).

Verified: check_strings OK (9 files incl. values-id);
:app:compileDebugKotlin BUILD SUCCESSFUL. Not released (batch with
pending fixes).
2026-08-04 14:18:00 +00:00
mckero cda6cf8583 fix(gps,data): official-code fixes - real GPS result callback, no fake update success (4.5.2, release pending)
Official code issues found during review (user-reported):
1. GPS "success" was shown instantly even when no fix was obtained:
   - setStationPosition() always returned true (permissions exception
     swallowed, async requestLocationUpdates without waiting)
   - now: suspend + LocationManagerCompat.getCurrentLocation (GPS
     first, network fallback), permission check upfront, 15s timeout,
     success only when onLocationChanged fires; SettingsRepo takes
     Context for the permission check; ViewModel waits for the real
     result and shows "Unable to get location - check permission and
     GPS/network signal" on failure (4 locales)
2. Data update faked success on total failure:
   - updateFromRemote now counts successful sources; 0 success throws
     IOException -> timestamp NOT refreshed, Toast "Update failed -
     check your network" (4 locales, new IShowToast resId overload)
   - OkHttp timeouts widened: connect 15s / read 20s / write 20s

Verified: check_strings OK; all modules compile.
Release intentionally NOT triggered (user: fix everything first, then
one release).
2026-08-04 14:08:38 +00:00
mckero c09e87fa4e fix(radar,settings): realtime doppler freq in Log tab, trash overlay fix, sub-menu swap (4.5.2 overwrite 5)
User-test fixes:
1. Log tab frequency now refreshes every second with the transponder
   panel (multi-Doppler): selectedRadio derived by uuid from the
   per-second transceivers list instead of a remembered stale
   reference; display and upload use radio.uplinkLow/downlinkLow
   (the same doppler-corrected values the transceiver panel shows).
2. SwipeDeleteRow: content layer now has an opaque background so the
   trash icon only appears while swiping (was bleeding through into
   the "Sent" column).
3. Settings page order: More-menu items can now move back into the
   main menu - button always visible; when the main menu is full (5),
   the last non-Settings item is automatically swapped into More.

Verified: check_strings OK (8 files); :app:compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-04 13:39:22 +00:00
mckero 0bcf73e1f9 fix(wavelog,radar): sat name LoTW normalization, full grid lines, RX freq restored (4.5.2 overwrite 4) 2026-08-04 12:29:31 +00:00
mckero ab1a0c0891 fix(nav,radar,wavelog): 4.5.2 third overwrite - log page entry visible, freq format 3 decimals, direct freq extraction
User-test fixes (3rd overwrite, version stays 4.5.2/452):

1. Log page was invisible everywhere outside the radar tab:
   - allNavItems in MainScreen.kt was missing Screen.WavelogLog ->
     not in bottom nav, not in More menu
   - SettingsScreen UI-order lists were missing the WavelogLog row ->
     not in page order settings either
   Now the Log page appears in the More menu (default sub menu tail,
   migration appends it) AND in UI settings page-order lists.
2. Frequency display: formatFrequency was MHz.kHz.Hz (145.900.000);
   now MHz.kHz (145.900) per request. Applies to transceiver panel
   and Log page alike (shared formatter).
3. Log tab frequency: now extracts the exact numbers shown in the
   transceiver panel (txBaseFrequencyHz for TX, uplinkLow/uplinkHigh
   range for linear) - no re-computation, no extra decimals. Upload
   uses the same tuned frequency (Hz precision kept internally).
4. Log page (More menu) table: date+time column (MM-dd HH:mm),
   row separators (table lines), uploaded checkmark kept.

Verified: check_strings OK (8 files); :app:compileDebugKotlin
BUILD SUCCESSFUL (includes upstream merge 1a552417).
2026-08-04 12:07:45 +00:00
mckero 1a55241791 merge: upstream atsunatsu CW decoder + linear calculator (keep ours, absorb Calculator tab)
Upstream added a333192d (CW decoder and linear calculator page) and
c80e3512 (linear calculator offset mapping fix). Merged with all 7
conflicts resolved by hand:

- app/build.gradle.kts: keep ours (keystore signing config + abiFilters)
- CwDecodeScreen.kt: keep ours (only difference is localized strings
  vs upstream hardcoded English)
- g3/d.java: keep ours (identical lookup table, 4-line diff)
- app_values.xml: keep ours (3 CW permission strings upstream lacks)
- feature/radar/build.gradle.kts: keep ours (same dependency line)
- TransceiversPage.kt: take upstream (includes CalculatorPage +
  our CW panel + INV title logic; conflicts were comment-only)
- RadarScreen.kt: both sides - keep our wavelog imports + Log tab,
  take upstream DopplerFrequencyCalculator import + Calculator tab;
  pages = Transceivers, Log, [Calculator if any named linear
  transponder per upstream isNamedLinearTransponder], Sstv

Result: radar page tabs are Transceivers / Log / Calculator (linear
sats only, upstream logic) / SSTV. WaveLog Log tab and all our
localization/signing preserved.

NOTE: committed WITHOUT building per user request - merge compile
verification deferred.
2026-08-04 11:59:15 +00:00
mckero 9d437a90c9 fix(wavelog,radar): 4.5.2 second overwrite - v1 API support, error dialog with copy, frequency sync
Background: user's WaveLog server has NO v2 API (all /api/v2/* return
404; /api/qso v1 works - verified with curl). First fix only tried v2
paths, so uploads still failed with 404. Also: Log tab frequencies did
not match the Doppler panel, linear transponders showed a single
frequency instead of the passband range, and errors were toast-only
(no copy).

Changes:
- WaveLogApi: full v1 support with auto fallback
  - v2 first (Bearer header + JSON fields), on 404 fall back to v1
    (key inside JSON body + ADIF string) - both with and without
    index.php prefix
  - test connection: v2 GET api/v2/token -> v1 POST
    api/get_contacts_adif (validates key + station id)
  - station gridsquare is v2-only; on v1 the uploader falls back to
    the user's QTH grid (grid check skipped/equal)
  - v1 ADIF: call/band=SAT/mode/freq+freq_rx (MHz)/qso_date/time_on
    (UTC, compact)/gridsquare(4)/sat_name/prop_mode=SAT, byte-length
    field prefixes
  - 409 duplicate counts as success in both versions
- Error dialog with copy: test/upload failures now open an AlertDialog
  with the full error (incl. actual URL + HTTP code), Copy button
  (ClipboardManager) and Cancel; uploader collects the first failure
  message
- Log tab frequency sync: LogTab receives txBaseFrequencyHz from the
  radar page (the tuned frequency shown in the transceiver panel);
  RX is computed through the same Doppler mapping as the Doppler
  panel; linear transponders show the full uplink/downlink range
  (Doppler-corrected low-high) instead of a single frequency
- Restored uploadWavelogQueue (lost in an earlier patch)

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL.
2026-08-04 11:47:06 +00:00
mckero 5351afe508 fix(wavelog,radar): 4.5.2 user-test fixes (404 upload, swipe delete, transponder picker, log page)
Background: user tested 4.5.2 and reported 4 issues. Same-version
overwrite per user (4.5.2 exists solely for the logbook system).

Fixes:
1. Upload 404 — root cause: user server URL ending in /index.php was
   concatenated again (/index.php/index.php/api/v2/...) -> 404. Now:
   - normalizeUrl strips trailing /index.php
   - every request tries the index.php path first, falls back to the
     rewritten path on 404
   - 409 conflict (duplicate QSO) counts as success (moves out of queue)
   - failure messages include the actual URL + HTTP code for debugging
2. Swipe-to-delete was dead: rowWidth was never measured (0) so the
   75% threshold was 0 and the drag was clamped to 0. Now measured via
   onSizeChanged + smooth spring/tween snap-back animation.
3. Transponder picker: long card list replaced with an
   ExposedDropdownMenuBox dropdown (scrollable menu, pick one).
4. New Log page under the More menu: table view (time / frequency /
   satellite / callsign / uploaded checkmark). WavelogQso gains
   uploaded flag; uploader marks instead of removing; queue keeps
   uploaded entries (500 cap). Old persisted subMenuOrder gets
   WavelogLog appended (migration).

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL.
2026-08-04 11:06:47 +00:00
atsunatsu c80e351212 fix: correct linear calculator offset mapping 2026-08-04 18:41:53 +08:00
mckero 916a0d0f0a feat(wavelog,radar,settings): WaveLog logbook integration + Log tab
Background: satellite operators want to log QSOs during a pass while
watching live frequencies. 4.5.2 adds WaveLog (logbook server) API v2
integration: log from the radar page, upload to a self-hosted WaveLog
instance with grid-mismatch protection.

Changes:
- Radar page: new third tab "Log" between Transceivers and SSTV
  - pick a transponder, watch live TX/RX Doppler-corrected frequencies
  - enter callsign, Enter stores locally (UTC time + that second's
    frequencies sampled together)
  - local entry list shows time/frequency/callsign only (no upload
    status, per user: proves the entry was saved)
  - swipe-to-delete: yellow trash while swiping, turns into Undo at
    75%, 5s countdown before auto-delete (custom gesture, no
    SwipeToDismissBox)
- Settings: new WaveLog card (server URL / API key / station ID /
  auto-upload switch / test connection / upload now buttons) matching
  the user's reference screenshot layout
- Upload pipeline: POST /index.php/api/v2/qso with required fields
  (station_profile_id, call, band=SAT, mode, qso_date, time_on UTC)
  plus freq/freq_rx (Hz), gridsquare (from station profile via
  GET /api/v2/station/{id}), sat_name; RST omitted per user
- Grid check: station gridsquare (first 4) vs user QTH (first 4);
  mismatch shows a confirm dialog (ignore & upload / cancel)
- Auto upload: 10-minute in-app retry loop (only when switch on);
  manual upload button; local queue capped at 500, all entries stored
  locally regardless of switch
- Fixed: subMenuOrder was never persisted (4.5.1 regression)
- core/domain: compileOnly org.json (runtime uses Android's)
- Version 4.5.2 (452)

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL locally.
2026-08-04 10:29:40 +00:00
atsunatsu a333192df5 feat: add CW decoder and linear calculator page 2026-08-04 17:49:28 +08:00
mckero 38cf7a3569 feat(nav,settings): bottom nav 5+N collapsible menu + Indonesian locale
Background: 8 bottom-nav items squeeze long English labels on narrow
screens. 4.5.1 introduces the 5+N pattern: 5 main tabs plus a fixed
6th "More" button that pops a second-level menu (spring bounce) with
the remaining pages.

Changes:
- MainScreen: nav split into main (<=5, screenOrder-driven) + more
  (subMenuOrder); More button with popup panel + spring animation;
  BackHandler closes the menu before navigating back
- MoreMenuPopup: bottom-end card, current page highlighted, scrim
  click to dismiss
- UI Settings: page order card now has two zones (main menu, max 5,
  Settings locked last with no drag handle / more menu); move buttons
  between zones, drag-to-reorder within zones; new subMenuOrder pref
- Defaults: main = Satellites/Passes/Radar/Map/Settings,
  more = Mutual/Roaming/CwDecode; old screenOrder migrates by
  classifying pages against the default sub menu
- Hard-coded UI strings localized (Tracking/Lat/Lon/Qth/Connect/
  Track/Stop/CW permission prompts) into EN/ZH/TR
- NEW Indonesian locale (values-in, 181 strings + cw module strings)
  - user rule: every future release must update EN/ZH/TR/IN
- Version 4.5.1 (451)

Verified: check_strings.py OK (8 files, no bare apostrophes);
:app:compileDebugKotlin BUILD SUCCESSFUL locally.
2026-08-04 08:58:05 +00:00
mckero 760a26b007 Merge remote-tracking branch 'atsunatsu/main' 2026-08-04 08:10:17 +00:00
mckero 47f3e424ff feat(radar,cw): transponder CW panel switches to the Morse Expert engine
Background: the transponder panel CW decoder (added by the upstream
fork author) used a lightweight Kotlin Bayesian engine (core/domain/cw,
kept untouched as a fallback). This change makes the panel use the
Morse Expert engine ported in 4.5.0, so both CW entry points share the
same decoder with a live waterfall.

Changes:
- New mini layout cw_panel_main.xml (waterfall 80dp + decoded text,
  status line hidden but ID kept for controller lookup)
- MainActivity.onCreate overload with applyImmersive flag; panel binds
  with false so the host window system bars are not touched
- CwDecoderPanel now embeds the mini layout via AndroidView and drives
  the MainActivity controller: start/stop/reset map to the engine,
  lifecycle follows panel expand (start) / collapse (release mic)
- radar module now depends on feature:cw (+ constraintlayout 2.2.1,
  same as cw) for layout + controller reuse
- What's new rewritten in EN/TR/ZH for this release only

Verified: :feature:radar:compileDebugKotlin and :app:compileDebugKotlin
BUILD SUCCESSFUL locally; check_strings.py OK (7 files, no bare
apostrophes).
2026-08-04 07:56:11 +00:00
mckero f731775346 fix(cw): repair FFT pipeline (NaN twiddle table + dead cond_22 path)
The waterfall showed mirrored/upside-down garbage because the FFT
never produced a valid spectrum:
1. g3.c.f() (high-precision sin) had its quadrant-0 case mangled by
   jadx into a nested-if that returned NaN for small angles - the
   twiddle factor table ended up with 329/1024 NaN entries.
   Restored the smali switch: case0->g(), case1->c(), case2->-g(),
   case3->-c().
2. i3.d.k() routed the runtime FFT (a5==0, single-thread path) into
   the else of if(a5!=1) instead of if(a5!=0), so the FFT never ran
   and the output stayed in the time domain (peak at bin 357 for a
   669Hz tone instead of bin 86).

Verified with a JVM harness (static-block tables + pure-tone inputs):
200Hz->bin26, 400Hz->bin51, 669Hz->bin86, 1000Hz->bin128, all exact.
Twiddle table NaN count: 329 -> 0.
2026-08-04 06:55:03 +00:00
mckero 3c5486cc5c fix(cw): restore FFT dispatch (cond_22), CW UI settings entry, default order
Decode page showed stats but empty waterfall and no decoded text:
the ported i3/d.k() FFT dispatch was broken by a jadx structure
misplacement - the runtime path (k=1 -> a5=0 -> cond_22 single-thread
FFT) was replaced by a hallucinated `throw null` else-branch while the
real cond_22 code sat in a dead else. Verified against smali
(7030-7263): j3.c.q forward FFT + post-processing loop + tail + small-
array branch now live in the a5==0 branch.

Also:
- UiSettingsCard now lists CwDecode (toggle + drag-reorder) between
  Roaming and Map
- unknown screenIds in persisted screenOrder fall back to
  defaultScreenOrder position (CwDecode lands between Roaming and Map
  for existing users instead of trailing after Settings)

Verified: :feature:cw + :feature:settings + :app compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-04 05:36:48 +00:00
mckero 544515feba fix(cw): restore app label, fix R8 code stripping, force 32-bit ABI
Three release-breaking issues found in the v4.5.0 APK (app crashed on
launch, label showed "Morse Expert", dex shrank to 282KB vs 3.5MB):
1. app_name: the ported app_values.xml shipped a "Morse Expert"
   app_name string which overrode Look4Sat Pro's label during resource
   merging - removed (no other string collisions).
2. R8 stripped nearly all code: the in-app sun.misc.Unsafe/Cleaner
   stubs clashed with android.jar library classes. Moved stubs to
   com.rtbishop.look4sat.feature.cw.suncompat and updated k3.d/s/r
   imports (k3.r keeps the reflective Class.forName("sun.misc.Unsafe")
   string, which returns null on Android hidden-API limits).
3. proguard-rules.pro added (AGP 9 variant-level
   CanProduceConsumerProguardFiles): keep pas.** (JNI RegisterNatives
   resolves by class name) plus all ported CW classes.
4. app-level ndk abiFilters forced to armeabi-v7a: the ported
   libnativedecoderjni.so is v7a-only, so a multi-ABI APK would crash
   with UnsatisfiedLinkError on arm64 devices.

Verified: :feature:cw:compileDebugKotlin BUILD SUCCESSFUL.
2026-08-04 04:56:41 +00:00
mckero 9a54da0808 build(cw): bump to 4.5.0 with what's-new for CW decoder release
- versionCode 449 -> 450, versionName 4.4.9 -> 4.5.0
- pass_whatsnew_message rewritten (en/zh/tr) to describe ONLY this
  release: new CW Decoder page (live Morse decoding + waterfall) and
  its settings (message type, font size, 9 color themes)

Verified: check_strings.py OK (no bare apostrophes).
2026-08-04 04:21:19 +00:00
mckero 8bc1f16e2c feat(cw): wire CW decoder page into navigation (between Roaming and Map)
Compose integration of the ported CW decoder engine:
- CwDecodeScreen: AndroidView embedding the ported activity_main.xml,
  lifecycle delegated to the ported MainActivity controller (onCreate ->
  onResume, onDispose -> onPause/onDestroy), RECORD_AUDIO runtime
  permission flow (with permanent-denial -> app settings), original
  options_menu actions as a top button row (pause/clear/save/record/
  settings), double-back-to-exit preserved
- CwSettingsDialog: message_type (general_text/ham_radio_qso),
  text_font_size (7-99), and the 9 color keys (bg_color/text_color/...)
  reading/writing the same prefs keys as the original app
  (getPackageName()+"_preferences"), colors sourced from I2.b tables
- Navigation: Screen.CwDecode ("CwDecode") placed between Roaming and
  Map in the default order; defaultScreenOrder updated; ic_cw morse icon;
  nav_cw strings (en/zh/tr); app depends on :feature:cw

Verified: :feature:cw:compileDebugKotlin + :app:compileDebugKotlin
BUILD SUCCESSFUL (first pass, no errors).
2026-08-04 04:06:31 +00:00
mckero 5dc7ce6a22 feat(cw): port Morse Expert 1.15 CW decoder engine (feature:cw compiles)
Full port of the CW/Morse decoder from Morse Expert 1.15
(com.ve3nea.morse_expert), preserving obfuscated class names and logic.
Ads (gms.ads), billing (BillingClient), and library code were removed per
user; SettingsActivity deferred to Compose integration.

Ported modules:
- pas.*: decoder engine interface + JNI (libnativedecoderjni.so, armeabi-v7a)
- k3.*: FFT library (pseudo-enum q rewritten from smali; sun.misc stubs
  for Unsafe/Cleaner since Android lacks them; FFT path uses float[] branch)
- i3.*, g3.*, j3.*, s.*, d1.AbstractC1518b: DSP/sin-cos tables (long[] tables
  f/f11589g restored from smali)
- H2.a/b: audio capture + decode control core (jadx catch-block illusions
  cleaned per smali; recording try/catch restored around write block)
- J2.a/b: waterfall OpenGL renderer + palette (r10 = anchor color per smali)
- E2.g: waterfall touch frequency picker (height = ScaleView per smali)
- B0.b/B.RunnableC0001b: status-bar + decoded-text UI runnables (real
  constructors recovered from smali)
- MainActivity → controller class (Activity injected; immersive status bar
  inlined); C1646n trimmed to view holder; D.n keeps waterfall texture case
- F2.a: text selection menu (Save/Share)

Verified: :feature:cw:compileDebugJavaWithJavac BUILD SUCCESSFUL.
2026-08-04 03:57:25 +00:00
mckero ff408747cd fix(settings): drag-reorder tracks live position; grip becomes a single dot
Two drag issues from user testing:
1. Only adjacent swaps worked - the drag gesture could not move an
   item past multiple positions. Root cause: the pointerInput closure
   kept the index captured at composition; after the live swap
   (items.add(target, removeAt(index))) the closure's index was
   stale, so subsequent targets were computed from the wrong origin
   and the saved order got corrupted (also made previously moved
   items snap back).
   Fix: track the dragged item's live position via draggingIndex -
   onDragStart resolves it with items.indexOf(screen), onDrag computes
   the target from draggingIndex and updates it after each swap.
2. The six-dot grip icon was ugly; replaced with a single 8dp themed
   dot (Box + CircleShape inside the 48dp touch area, onSurfaceVariant
   color). ic_drag.xml removed.

What's-new rewritten in en/zh/tr with ONLY this release's changes
(user rule: replace, never append history). Version stays 4.4.9
(覆盖 per user). Verified: settings + app compile, check_strings.py
clean.
2026-08-04 01:10:13 +00:00
mckero 6636dd8e98 fix(settings): crash when scrolling Settings (nested LazyColumn unbounded height)
v4.4.9 page-order list crashed the Settings screen on scroll: the
drag-reorder LazyColumn sits inside the Settings LazyVerticalGrid
item, and a vertically scrollable child measured with infinite max
height throws IllegalStateException, killing the app before the UI
Settings card even renders.

Fix: give the LazyColumn a bounded height (itemHeight * items.size
= 7 rows × 48dp = 336dp). Drag logic, animation and persistence
unchanged.

What's-new rewritten in en/zh/tr with ONLY this fix (user rule:
replace, never append history). Version stays 4.4.9 (覆盖 per user).
Verified: settings + app compile; check_strings.py clean.
2026-08-04 00:52:43 +00:00
mckero 15ab82f1c8 fix(strings): escape apostrophe in tr whatsnew entry
"UI Ayarları'nda" broke aapt again (Invalid unicode escape
sequence). Escape the apostrophe as \'.
2026-08-04 00:36:07 +00:00
mckero 0ea9c0ede1 feat(settings): drag-to-reorder page order in UI Settings card
Users can now change the bottom navigation order (previously fixed):

- New "Page order" section under the Settings toggle in the UI
  Settings card: vertical list of all 7 pages, each with a drag
  handle (new ic_drag drawable, Material drag_indicator glyph) on
  the right.
- Drag a handle up/down: the item follows the finger with live
  swap + animateItem() placement animation; on release the order is
  persisted (OtherSettings.screenOrder, comma-separated in prefs so
  order survives; StringSet would not).
- "Reset order" button restores the default order
  (Satellites/Passes/Radar/Mutual/Roaming/Map/Settings).
- MainScreen now sorts navItems by screenOrder (empty = default,
  stable sort keeps the canonical order); hiddenScreens filtering
  unchanged; Settings entry always visible.
- New strings prefs_ui_order_title / prefs_ui_order_reset in
  en/zh/tr; What's-new updated in all three locales.

Version stays 4.4.9 (覆盖 per user). Verified: core:data tests
pass, settings + app compile.
2026-08-04 00:28:53 +00:00
mckero c496d90d5a fix(version): actually bump to 4.4.9 (449)
The previous commit message claimed the bump but the edit never
landed (script aborted on a syntax error before touching the toml);
versionName stayed 4.4.8/448. Fix the version now - the v4.4.9 tag
must carry versionName 4.4.9.
2026-08-03 18:21:52 +00:00
mckero c101870b71 feat(settings,ui): custom-source toggles default off; new UI Settings card; bump to 4.4.9
Two changes per user request:

1. Data source toggles default OFF with legacy-URL migration
   (continuation of the 4.4.8 fix, now also in the dialog): old
   example.com placeholder URLs are replaced by the real defaults and
   the custom toggles stay off, so the online update never points at a
   dead source after upgrading.

2. New "UI Settings" card between Other Settings and Credits:
   one switch per bottom-navigation page (卫星/过境/雷达/匹配/漫游/地图/设置
   in fixed order). Turning a page off removes it from the nav bar and
   the remaining items close up automatically; order is never
   rearranged. The Settings entry is always visible (switch disabled)
   so the user can never lose access to settings. Persisted via
   OtherSettings.hiddenScreens (StringSet of Screen.screenId; screenId
   added to the Screen sealed class - simpleName is unsafe under R8).

Version bump per fork convention: 4.4.8 -> 4.4.9, versionCode 449.
What's-new updated in en/zh/tr.

Verified: core:data + core:domain tests pass, all modules compile.
2026-08-03 18:14:53 +00:00
mckero 9a4ceddcb0 fix(settings): force custom-source toggles off when legacy example.com URL present
Old installs that once enabled the custom TLE/transceiver toggles kept
the legacy "https://example.com/tle.txt" placeholder in preferences.
Under the new "toggle replaces the online-update default source"
semantics this pointed the All/SatNOGS fetch at a dead URL and broke
satellite updates after upgrading to 4.4.8.

Migration in getDataSourcesSettings(): example.com placeholders are
replaced by the real default URLs (Celestrak All / SatNOGS) and the
toggles are forced OFF unless a non-default, real user URL is set.
Also disable the reset (loop) icon buttons while their toggle is off.

Verified: core:data tests pass, settings + app compile. Version stays
4.4.8 (覆盖 per user).
2026-08-03 17:03:48 +00:00
mckero 50233cadc1 fix(drawable): remove unsupported colorControlNormal tint from ic_reset
Pure-Compose project has no attr/colorControlNormal; aapt fails at
release resource linking. Icon composable applies its own tint.
2026-08-03 16:48:02 +00:00
mckero 8d88100b15 feat(settings): custom URL now overrides the online-update default source
The "Custom URL" dialog's two URL fields now control the default
source used by the online update, per the user's intent (one-time
setup, no extra steps per update):

- Defaults are real URLs now: TLE = Celestrak "All" (active group
  CSV), transceivers = SatNOGS API. The example.com placeholders are
  gone.
- updateFromRemote(): the "All" TLE entry and the "SatNOGS" entry are
  replaced by the user's URLs when the corresponding custom toggle is
  ON and the URL is non-blank; otherwise the real defaults are used.
  The old "Other" appended-source logic is removed - the toggle now
  means "use my URL for this source" instead of "download an extra
  source". Each source (TLE / transceivers) is independent.
- Each URL field gained a reset (loop) icon button on the right that
  restores the default URL text (new ic_reset drawable, Autorenew
  vector). Strings added in en/zh/tr.

Test updated: custom TLE URL data now lands under the "All" type
instead of "Other". Verified: core:data tests pass, settings + app
compile. Version stays 4.4.8 (覆盖 per user).
2026-08-03 16:41:14 +00:00
mckero 59d6e2ae05 fix(strings): escape apostrophe in tr whatsnew entry
"URL'leri" in the new Turkish whatsnew line broke aapt again
(Invalid unicode escape sequence). Escape the apostrophe as \'.
2026-08-03 16:22:23 +00:00
mckero e2b5c6d673 feat(settings): rename Import button to Custom URL; bump to 4.4.8
The online update always pulls every preset Celestrak/amsat URL and
the custom URL lived hidden behind the "Import" dialog's toggle. The
user wants a discoverable entry point to edit the custom source URLs.

Rename the data card button "Import" -> "Custom URL" (en: Custom URL,
zh: 自定义URL, tr: Özel URL). The existing dialog already hosts both
URL text fields (TLE + transceivers) and the file-import buttons, so
no dialog changes are needed - the import feature stays inside, as
the user requested.

Version bump per fork convention (feature change): 4.4.7 -> 4.4.8,
versionCode 447 -> 448. v4.4.7 release stays downloadable.

What's-new dialog updated in all three locales with this change.

Verified: settings + app compile, unit tests unaffected.
2026-08-03 16:15:02 +00:00
mckero da89d6426f fix(strings): escape apostrophe in tr whatsnew text
A bare apostrophe in "QTH定位器 2.0'dan" broke aapt resource
compilation (Invalid unicode escape sequence) and failed the release
build. Escape it as \' per Android string resource rules.
2026-08-03 15:52:57 +00:00
mckero c19209655b fix(roaming,settings): theme colors for night mode; unclamp cards; brand title and APK name
Three detail fixes plus branding, per user review:

1. Night mode visibility (roaming page): hardcoded reference blues
   (#01DDFF/#0BACF1) collapse to black under the red ColorMatrix
   filter (R channel only), making the page unreadable. Replace ALL
   colors with MaterialTheme.colorScheme (background/surface/
   surfaceVariant/onSurface/onSurfaceVariant/primary/error). GPS
   status dots become themed circles (primary/error) so they survive
   the red filter; red marker keeps the original pnt drawable.

2. Settings cards unclamped:
   - OtherCard: fixed height(268.dp) squeezed the last toggle row
     (spacing 42/42/42/15px on device); drop the fixed height.
   - CardCredits: same 268dp + SpaceBetween overflowed, gluing the
     last thanks entry to the warranty line; drop fixed height, use
     spacedBy(8.dp) and insert 24dp before the warranty text.

3. Branding:
   - Settings top title: "Look4Sat v%s" -> "Look4Sat Pro v%s" (en/tr);
     title Text no longer marquees and wraps instead (heightIn(min=48)).
   - APK asset name: look4sat-<ver>.apk -> Look4Sat-Pro-<ver>.apk.
   - What's-new dialog (pass_whatsnew_message) rewritten in all three
     locales (en/zh/tr) with this release's changes; zh gets its own
     localized title "Look4Sat Pro 更新内容".

Verified: roaming/settings/passes/app compile, 11 unit tests pass.
2026-08-03 15:46:21 +00:00
mckero d456bdf4b5 fix(roaming): stretch side cells to 80dp like the reference RelativeLayout
The user spotted two white "beams" (20dp gaps) between the center
cell and the side cells. Root cause: the reference app's RelativeLayout
ignores the fixed 60dp width when a child has BOTH a left rule
(alignParentLeft) and a right rule (toLeftOf=center cell) - the width
is stretched to right-rule minus left-rule, i.e. 80dp on a 360dp
screen. The three cells therefore sit flush with only 2dp margins
between them, no gaps.

Port that behavior: side cells 60dp -> 80dp (6 places), center cell
stays 200dp centered. Verified against the reference screenshot pixel
measurements (side cells ~76.5dp incl. margins, center ~198dp).

Verified: feature + app compile, 11 unit tests pass. Marker lookup,
grid math and GPS logic untouched.
2026-08-03 15:22:08 +00:00
mckero f06b17a194 fix(roaming): align grid columns like reference and lift footer
Two remaining proportion issues on the user's device:

1. Right-side gap: the three grid rows used a continuous Row
   (60+200+60dp), leaving ~84px of blank space on the right of the
   screen. The reference app uses a RelativeLayout where the right
   column is pinned to the screen edge and the 20dp gaps sit on both
   sides of the centered middle column. Convert each row to a Box:
   left cell align(CenterStart), middle cell align(Center), right
   cell align(CenterEnd) - pixel-identical to the reference.

2. Footer legibility: the credit line ("制作:US1PM  汉化:BA7LCE")
   had a fixed 15dp height with no bottom margin, so it sat directly
   against the navigation bar and part of the text was hard to read.
   Drop the fixed height and add 10dp bottom padding so the text
   renders fully with breathing room above the nav bar.

Verified: feature + app compile, 11 unit tests pass. Marker lookup,
grid math and GPS logic untouched.
2026-08-03 15:09:45 +00:00
mckero 675194075e fix(roaming): inset page content from system bars
The ported page rendered edge-to-edge: the GPS bar started right at
the top of the screen and the footer sat against the navigation bar,
so system UI overlapped the content (user: "顶头"). The reference app
is not edge-to-edge and keeps its content inside the safe area.

Add windowInsetsPadding(WindowInsets.systemBars) on the page root
column, shrinking the top and bottom by the status/navigation bar
height exactly as the user requested ("上方和下方往里面缩一点点").
Grid columns (60/200/60dp), rows, marker lookup and all logic are
untouched - verified pixel-identical column ratios vs the reference
screenshot (147:395:147 vs 146:395:148).

Verified: feature + app compile, 11 unit tests pass.
2026-08-03 14:55:46 +00:00
mckero 0ecccc4746 feat(roaming): port QTH定位器 2.0 page and logic verbatim
The roaming page was repeatedly rebuilt by hand and the red marker
still rendered at the wrong spot on the user's device. Per the user's
explicit instruction the whole page is now a faithful, line-by-line
port of the reference app (QTH定位器 2.0, com.us1pm.gridsquarelocator)
with zero UI or logic changes.

UI (res/layout/main.xml, byte-verified via aapt2 dump):
- 25dp holo-blue GPS bar: "GPS" 14sp, green/red status dot 15dp
  (original mipmaps copied as drawables), centered date, right time,
  translucent-yellow "设置启用GPS" button that opens location settings
- Latitude/longitude rows: 16sp black labels, right-aligned decimal
  values, DMS label format "纬度  22° 18' 50" N" exactly as reference
- 43sp bold black locator, centered, with progress spinner + notice
- 3x3 continuous grid: 60/200/60dp columns, middle row fixed 205dp,
  edge cells #0BACF1, center cell 200x200dp holo-blue (#01DDFF as
  shown on the user's device), center label 100x80dp 30sp bold white
  (textColorHighlight) centered, bottom "制作:US1PM  汉化:BA7LCE"
- Red marker: original pnt.png (red square with white outline),
  10x10dp, absolutely positioned by the reference lookup tables
  (lon 3rd pair a..x -> leftMargin -2..190dp, lat 3rd pair a..x ->
  topMargin 190..-2dp, screen-Y inverted)

Logic (MainActivity.java showLocation/checkEnabled/onResume):
- 8-char locator via the reference range-lookup tables
- 3x3 neighbor grid via parseInt3 five-branch logic incl. all four
  corner-carry tables (00/09/99/90)
- Live GPS + network updates 10s/10m while the page is shown,
  provider filtered to gps/network, checkEnabled three-state
  (green dot / red dot + settings button) exactly like the reference
- Time = cached hour prefix + fix minutes; date "dd MMM yyyy"

Dropped only the Play-store ad banner (conflicts with GPL project).

Verified: 11 unit tests pass (locator, marker lookup, grid, DMS,
time, all edge branches); feature + app modules compile.

RoamingState.kt removed - state and math now live in RoamingScreen.kt.
2026-08-03 14:29:44 +00:00
atsunatsu a7b41c95c7 fix: improve match header and daily sun times
Match page UI:
- Always show a meaningful status line in the top bar instead of leaving the second row blank on first entry.
- Add a compact status chip for waiting, calculating, result, no-match, and error states.
- Remove the duplicate intro card so the first screen starts directly with station inputs.

Pass list sun times:
- Compute sunrise/sunset from each visible date group's 00:00 in the selected timezone.
- Avoid using an arbitrary pass AOS as the rise/set search start, which could jump later-day headers to the following day's events.
2026-08-03 21:38:57 +08:00
mckero 88d1498eac fix(roaming): red marker tracks fix in BOTH axes via 3rd-pair lookup
The marker's vertical position was hardcoded (10dp below the label) — markerY never participated, so the dot could only move horizontally and could not reflect where the GPS fix sits inside the 4-char square.

Now the marker is positioned in both axes from the cell's top-left corner:
- x = markerX * cellWidth, y = markerY * cellHeight (screen Y)
- markerX/markerY come from the 3rd character pair via the reference lookup tables (lon a=-2..x=190 as leftMargin; lat a=190..x=-2 as topMargin, i.e. latitude inverted on screen)
- Verified numerically against the decompiled tables: a=0/1.0, i=0.333/0.667, x=0.958/0.042 — matching within 3% (the reference table is slightly non-uniform)

Label stays at upper-middle (28sp bold); marker is only drawn when a valid locator exists.
2026-08-03 12:55:37 +00:00
mckero 31ae04329d fix(roaming): clock ticker moves the header time; GPS mirrors station pos
Two bugs from the last release:

1. The header clock was frozen: Date() was only evaluated during recomposition, and with no state changes the time never moved. Added a 1s LaunchedEffect ticker that updates a now-state, so the date/time text re-renders and actually advances — matching the reference app, which refreshes the clock on every location callback.

2. GPS indicator: replaced the 10-minute freshness check with a direct 'has a real fix' check (timestamp > 0 and coords non-zero). The page mirrors the station position (站位) from the shared StateFlow, so the GPS dot is green whenever the station has a fix — GPS shows exactly what the station GPS says, nothing more.
2026-08-03 12:48:17 +00:00
mckero eae6304124 feat(roaming): faithful continuous-table grid layout, not card-based
Reworks the Roaming grid to structurally match the reference app instead of a card-style panel:

- Removed the outer ElevatedCard, rounded cells, cell gaps and inner padding: the 3x3 grid is now one continuous table that fills the panel, cells connected edge-to-edge.
- Cells separated by 2dp divider lines that run the full width/height of each row/column, crossing at right angles like a real coordinate grid (the reference app's continuous separator lines).
- Cells are square-cornered (no rounded corners), background fills each cell fully.
- Column widths 21.4% : 56.2% : 21.4%, row heights 31.5% : 35.9% : 31.7% retained.
- Center cell: OL42 is larger (28sp bold) and placed at upper-middle; the red marker sits BELOW the text with a 10dp gap, horizontally offset by the 3rd-pair fraction — never overlapping the label, matching the reference 'text above, marker below' layout.
- Surrounding labels bumped to 16sp Medium (larger/stronger than before).
- Info header also switched from an ElevatedCard to a flat continuous block so the page reads as one continuous surface, like the reference.

The grid proportions, locator algorithm, marker mapping and boundary logic were already faithful; this change makes the visual structure faithful too.
2026-08-03 12:32:45 +00:00
mckero 97c6d609ac fix(roaming): drop auto GPS polling, show station position directly
Reverts the auto-update machinery after review — the page now simply mirrors the station position (站位) from the shared settingsRepo.stationPosition StateFlow, exactly what the Settings page shows:

- RoamingScreen: removed the LocationManager listeners, the 30s re-request loop, the provider filter and the location-disabled hint. No polling, no auto-updates; coordinates are whatever the station GPS says.
- Settings: removed the '漫游位置实时更新' toggle (stateOfRoamingLive, key, action, strings en/zh/tr) that caused the 'Other' card to overflow — the sixth unlabeled switch clipped past the card's rounded bottom edge was that row overflowing a fixed-height card. Card height back to 268.dp, five rows fit again.

Verified: core:domain tests, roaming/settings/app compile clean; zero references to RoamingLive remain.
2026-08-03 12:24:07 +00:00
mckero 44c442ddd3 feat(roaming): port live GPS tracking and location-disabled hint
Completes the port of the QTH定位器 app's location logic (read from the decompiled MainActivity):

1. Live location updates: a LocationListener registers GPS+NETWORK providers (10s / 10m, matching the reference onResume) while the Roaming page is visible and removes itself on dispose. Every fix is pushed through settingsRepo.setStationPosition, so the shared stationPosition StateFlow updates the Settings page and the map in lockstep — the page now refreshes in real time instead of only showing stale cached coordinates.

2. Provider filtering: only gps/network fixes are accepted, mirroring the reference showLocation() guard that rejects passive fixes.

3. Location-disabled hint: when GPS is off or permission is missing, the header shows a tappable '定位未开启,点击前往系统设置' row that opens ACTION_LOCATION_SOURCE_SETTINGS — the port of the reference btnLocationSettings button. The GPS status dot now has three states: fresh fix (primary), provider on but stale (error), provider off (outline).

4. Periodic recovery: a 30s re-request loop (honoring the 漫游位置实时更新 toggle) re-arms the location request after the chip falls idle.

Not ported (conflict, deliberate): the reference app's Play-store ad banner, 'New! Grid Square with map' promo and GP_IN preferences — commercial advertising does not belong in a GPL satellite tracker.
2026-08-03 11:59:31 +00:00
mckero f9250ec1f1 fix(roaming): live coords from shared flow, marker by locator pair, versioned apk
Addresses three review findings:

1. Coordinates now come straight from settingsRepo.stationPosition in the screen (collectAsStateWithLifecycle) — the exact same StateFlow the Settings page shows. Previously a separate ViewModel re-derived them, and it could lag behind the Settings page (user: '设置页更新了站位但漫游页死活不更新'). With the shared source the two pages can never disagree. RoamingViewModel removed; state derivation moved to RoamingState.fromPosition().

2. Red marker placement ported faithfully from the QTH定位器 app: it is driven by the 3rd character pair of the 8-char locator (the 'ih' in OL42ih45), mapped to a 0..1 fraction (lon a=west..x=east, lat inverted a=south..x=north), then scaled to the actual center-cell size. The grid now uses the reference proportions (columns 21.4/56.2/21.4, rows 31.5/35.9/31.7) and fills the screen, so the marker lands accurately on any device.

3. Workflow now uploads a versioned APK (look4sat-<version>.apk instead of look4sat.apk).

Also: Settings 'Other' card rows got vertical spacing (Arrangement.spacedBy) so the new roaming toggle is not glued to the night-mode row.
2026-08-03 11:49:46 +00:00
mckero bff3e1af09 chore: bump version to 4.4.7
Next release: versionCode 446 -> 447, versionName 4.4.6 -> 4.4.7. Includes the reworked Roaming page (faithful QTH定位器 port, live-update toggle, night-mode-safe theme colors).
2026-08-03 11:27:25 +00:00
mckero 6dbe30b3f6 feat(roaming): faithful QTH定位器 port, live-update toggle, night-mode safe
Rework the Roaming page after user review. It is now a faithful port of the QTH定位器 location panel, not a loose re-skin:

UI (matching the reference layout):
- GPS status dot (real: green when a fresh fix exists, outline when stale/missing) + date + time header
- Lat/Lon rows with DMS and 5-decimal display, big 8-char locator centered below
- 3x3 grid of neighboring 4-char squares with the reference proportions: center column ~2.6x wider (21.4% : 56.2% : 21.4%) and center row the tallest (31.5% : 35.9% : 31.7%); red position marker now placed at the fractional position of the fix inside the center cell (was fixed center)
- No oversized GPS button: live updating is now a Settings toggle '漫游位置实时更新' (stateOfRoamingLive, default on) that drives periodic location refresh

Style & night-mode safety:
- All colors come from MaterialTheme.colorScheme (surfaceVariant/secondaryContainer/error) — no hardcoded cyan/blue from the original app. The red night filter (ColorMatrix keeping only the R channel) blanked the old hardcoded palette; theme colors survive it.
- Chinese nav label '漫游' added to values-zh (was missing, showing English 'Roaming')

Settings:
- OtherSettings.stateOfRoamingLive persisted (default true), toggle row in Other card, height adjusted

Verified: feature:roaming, feature:settings, app compile clean.
2026-08-03 11:18:35 +00:00
mckero 8a920acead build: revert gradle heap cap, keep CI builds unconstrained
The 768m heap cap broke GitHub Actions (KSP OutOfMemoryError: Metaspace) because CI runners read the same gradle.properties. Restore -Xmx6g for CI; the 2GB local server should pass a one-off -Dorg.gradle.jvmargs instead.
2026-08-03 10:49:59 +00:00
mckero 929f652b8e chore: bump version to 4.4.6
Next release: versionCode 445 -> 446, versionName 4.4.5 -> 4.4.6. Adds the Roaming page (QTH定位器-style 3x3 Maidenhead grid) between Match and Settings, credits BG7NTA & the original author in the thanks title, and caps local Gradle heap for the 2GB build server.
2026-08-03 10:47:39 +00:00
mckero 610768501e build: add roaming module gradle file, cap local JVM heap
Include feature:roaming/build.gradle.kts (missed from the module commit) and keep the reduced Gradle heap (-Xmx768m) so local light builds don't freeze the 2GB server.
2026-08-03 10:46:32 +00:00
mckero 427e6e1862 i18n: credit BG7NTA and original author in thanks title
The settings outro title now reads 'BG7NTA & the original author would like to thank' (en) / 'BG7NTA 与原作者感谢' (zh) / 'BG7NTA ve orijinal yazar teşekkür eder' (tr), alongside the BA7OPF/BG7NTA entries added to the credits list.
2026-08-03 10:46:20 +00:00
mckero bfa5e5f474 feat(roaming): add Roaming page with 3x3 Maidenhead grid
Ports the QTH定位器 (com.us1pm.gridsquarelocator) location panel into Look4Sat as a new 'Roaming' page, restyled with the app's own look.

UI (top to bottom):
- Info header: GPS status dot, big 8-char locator, Lat/Lon rows with DMS + 5-decimal display, and a GPS 定位 button
- 3x3 grid panel: the current 4-char Maidenhead square (e.g. OL42) centered, surrounded by its 8 neighbors (OL33..OL51), with a red position marker in the center cell

Logic:
- QthConverter gains qthNeighbors(square) building the 3x3 grid with field/square carry at boundaries (AA00 wraps to RR99, IO91 crosses into J field), and qthToSquare(locator) extracting the 4-char square
- Verified against the decompiled app algorithm and the reference screenshot (OL42 grid matches exactly); 9 unit tests cover normal, boundary and field-wrap cases

Navigation:
- New bottom-nav item 'Roaming' between Match and Settings, with a crosshair icon
- New feature:roaming module (ViewModel + Compose screen) registered in the app

Build config:
- Lowered Gradle JVM heap from -Xmx6g to 768m: the 2GB build server froze on the old value; heavy release builds stay on GitHub Actions
2026-08-03 10:46:14 +00:00
mckero 213425379c merge: merge atsunatsu/main (mutual radar overlay arrows)
Merge upstream commit 2298d8ee 'feat: add mutual radar overlay arrows'. Clean auto-merge: upstream changed RadarView.kt arrow drawing, our sweep optimization stayed intact. No conflicts.

First v4.4.5 was built from c3444aed; this release rebuilds from the merged tree.
2026-08-03 10:04:09 +00:00
mckero c3444aed24 chore: bump version to 4.4.5
Next release: versionCode 444 -> 445, versionName 4.4.4 -> 4.4.5, following the upstream scheme without suffix. Includes the station panel layout fix, 5-decimal coordinates, and credits additions already merged on main.
2026-08-03 09:43:10 +00:00
mckero 368bd199e5 fix: station panel layout wraps QTH to its own line
With 5-decimal coordinates the Lat/Lon/Qth row overflowed, pushing the QTH value to a wrapped line. Split into two rows: Lat + Lon on the first line, Qth on the second.

Also append BA7OPF (pass matching feature) and BG7NTA to the credits list in en/zh/tr string resources.
2026-08-03 09:33:40 +00:00
atsunatsu 2298d8eea4 feat: add mutual radar overlay arrows 2026-08-03 17:27:48 +08:00
mckero 7bb0bbbb46 build: load release signing config from local keystore.properties
Conditionally applies the BG7NTA signingConfig when keystore.properties exists (gitignored). CI signs via apksigner with GitHub Secrets, so this only affects local builds.
2026-08-03 09:25:53 +00:00
mckero deec5581ae feat: rebrand to Look4Sat Pro, bump version to 4.4.4
App display name changed from Look4Sat to Look4Sat Pro (fork identity). Version follows upstream scheme without suffix: 4.4.3 -> 4.4.4, versionCode 443 -> 444.
2026-08-03 09:20:41 +00:00
mckero 1de5632428 feat: increase station coordinate precision to 5 decimals
Station lat/lon was stored rounded to 4 decimals (~11 m). Bump to 5 decimals (~1.1 m), slightly better than GPS hardware accuracy without showing noise.

- SettingsRepo.setStationPosition: round(4) -> round(5)
- QthConverter.qthToPosition: round(4) -> round(6) so 10-char locators fully roundtrip
- Update QthConverterTest expected values to 6-decimal precision
2026-08-03 09:14:13 +00:00
mckero 2dfd76825b fix: fork under own applicationId com.rtbishop.look4sat.bg7nta
The official app signs com.rtbishop.look4sat with its own certificate. A fork sharing that applicationId cannot be installed over the official build (signature mismatch) and users saw overwrite/install failures.

- Add applicationId version catalog entry; namespace stays com.rtbishop.look4sat so source imports are untouched, applicationId becomes com.rtbishop.look4sat.bg7nta.
- Update PROPERTY_SATELLITE_DATA_OPTIMIZED meta-data to the fork id.
- Document the fork-applicationId requirement in the version catalog.
2026-08-03 08:38:21 +00:00
mckero 06fc8bc2fb ci: set GH_TOKEN for release creation step
gh CLI in Actions requires GH_TOKEN to authenticate. Use the built-in github.token.
2026-08-03 08:15:20 +00:00
mckero 75322390b8 ci: allow specifying release tag on manual dispatch
workflow_dispatch defaults TAG_NAME to the branch name (main), which breaks gh release create. Accept an optional tag_name input and fall back to github.ref_name for tag-triggered runs.
2026-08-03 08:12:20 +00:00
mckero d1312f1f09 ci: add manual workflow_dispatch trigger
Allow triggering the release build from the Actions tab in case tag events are not picked up.
2026-08-03 08:06:19 +00:00
mckero 75acbbd633 ci: simplify release workflow for fork builds
Remove Google Play upload (needs SERVICE_ACCOUNT_JSON we don't have) and AAB signing. Use built-in GITHUB_TOKEN instead of RELEASE_TOKEN secret. Pin actions to stable versions (checkout@v4, setup-java@v4, setup-gradle@v4). Builds assembleRelease, signs APK via apksigner with KEY_STORE secrets, creates GitHub release with APK. Triggered by v** tags.
2026-08-03 08:03:16 +00:00
mckero a37fb8f773 feat(domain): support 6/8/10-char Maidenhead grid square conversion
Port the 8-char (4-pair) Maidenhead grid algorithm from the
"QTH定位器 2.0" app (com.us1pm.gridsquarelocator) into QthConverter
so locator precision matches common grid tools instead of being
truncated to 6 chars.

Previously positionToQth() emitted only 6-char locators and
qthToPosition() discarded everything past the 6th character via
take(6), losing the finer 30" x 15" resolution carried by 8-char
grid squares.

What changed:
- positionToQth(lat, lon, precision = 8) now emits 8-char locators
  by default; precision = 6 / 10 available for backwards
  compatibility and maximum resolution (1.25" x 0.625").
- qthToPosition() parses 6/8/10-char locators and returns the center
  of the finest encoded cell (30" x 15" for 8-char, 1.25" x 0.625"
  for 10-char) instead of the 6-char cell center.
- Locator validation regex tightened: 6/8/10 chars accepted,
  4-char strings like "JN58" are now rejected as invalid.
- Boundary clamping added so lat = 90 / lon = 180 no longer overflow
  the A-R / 0-9 / a-x alphabet (previously produced invalid chars).

Also fixed a pre-existing compile error in RadarView.kt: a delegated
property was assigned after declaration ("by" on an already declared
val). Converted the sweep angle to an if/else expression.

Verification:
- QthConverterTest extended to 5 test cases covering 6/8/10-char
  roundtrips, invalid input, boundary coordinates and roundtrip
  stability.
- Cross-checked against a Python reference model of the decompiled
  APK algorithm: 20k random roundtrips at 8 and 10 chars, 0 failures.
- :core:domain:test green; :app:compileDebugKotlin passes.
2026-08-03 07:25:31 +00:00
mckero 9d0d971500 refine: AOS binary-search refinement, getRadios signature cleanup
Refine AOS crossing to ~500ms via binary search instead of linear stepping; simplify getRadios to take satPos; tidy radar view/viewmodel.
2026-08-03 06:47:49 +00:00
atsunatsu 1813e1bfa4 fix: refine mutual pass matching and radar overlay 2026-08-03 13:04:42 +08:00
atsunatsu bd09ad22ff chore: remove .hermes from git tracking, add to gitignore 2026-08-03 08:55:04 +08:00
atsunatsu 7e90071c4d fix: revert radar pager to Transceivers tab, default minElev to 0
- Radar pager now defaults to Transceivers tab (initialPage=0)
- Transceivers don't auto-expand (selectedUuid defaults to null)
- Mutual page default minElev changed from settings value to 0.0
- Display filters out portions below the minElev threshold
2026-08-03 03:58:45 +08:00
atsunatsu d1cc944be2 feat: filter mutual page curves to minElev window (satlover.de style)
Elevation curve chart and mutual radar plot now only show the portion
where both stations are above their respective minimum elevation.
The curves are filtered at display time, the pass search still uses
the 0° horizon boundary for consistency with the Passes page.
2026-08-03 03:47:25 +08:00
atsunatsu fb28283679 feat: default pager to SSTV, remove radar plot drag, filter B track by elevation>0 2026-08-03 03:44:37 +08:00
atsunatsu 81ae8772d2 feat: add 'current exact position' button for station A
Removed the automatic posA=stationPos override. Added a button
'当前精确位置' below the grid input that fills in the exact station
position from settings (lat/lon + grid). Users can now freely edit
the position fields and use the button when they want the exact
position.
2026-08-03 03:32:03 +08:00
atsunatsu 571145838d fix: gridToLatLon normalization bug + bidirectional grid/latlon sync
Root cause: gridToLatLon used (lon+180)%360-180 and (lat+90)%180-90
for normalization, treating grid values as from prime meridian/equator
when they are actually from IDL/South Pole. A 6-char grid like OL62AA
(112°E, 22°N) was returning -67.96°, -67.98° (Atlantic Ocean).

Also added bidirectional sync: entering a grid auto-fills lat/lon,
entering lat/lon auto-fills the 6-char grid, so users can verify.
2026-08-03 03:27:15 +08:00
atsunatsu 017b178f2e fix: use refineEdge with exact station position to get correct AOS/LOS boundaries 2026-08-03 03:15:39 +08:00
atsunatsu 670444858c fix: use getElevation for elevation sampling (same function as getLeoPass)
getFullPosition and getElevation both call calculateObs internally,
but the elevation values reported by the user (-60°) suggest they may
differ. Now elevation is computed via getElevation (same as getLeoPass),
while getFullPosition is only used for azimuth.
2026-08-03 03:13:11 +08:00
atsunatsu 7bdff5df81 fix: use passes list with corrected station position, fallback to independent search 2026-08-03 03:08:35 +08:00
atsunatsu df083840ac fix: use independent search algorithm directly, no passes list reuse
The passes list AOS/LOS times can be stale or computed for a different
station position, causing elevation curves to show -60° at pass start.
The independent search (refineEdge + sampleMutualPass) always computes
elevation for the actual positions. With posA now set to the exact
station position, the pass times should match the main page.
2026-08-03 03:03:08 +08:00
atsunatsu a0279bac17 fix: use pass AOS/LOS times directly without refineEdge
The refineEdge function re-computed boundaries using the grid-center
position (posA), which differs from the exact station position that
the passes list was computed with. This caused all passes to be
filtered out. Now we trust the pass list's AOS/LOS times directly
and just sample the elevation/azimuth curves.
2026-08-03 02:47:09 +08:00
atsunatsu 3d630041e1 chore: add debug info when mutual pass search finds nothing
Shows pass count and satellite count in the error message to help
diagnose why the main pass list yields no results.
2026-08-03 02:27:01 +08:00
atsunatsu ea92d3c150 fix: add fallback pass search when main pass list yields no results
If the main pass list (satelliteRepo.passes) is empty or the common
window check filters everything out, fall back to the independent
search algorithm so the user always gets results.
2026-08-03 02:23:51 +08:00
atsunatsu 86ee3dcbac fix: reuse main page pass list for mutual search
findMutualPasses was computing passes independently with its own
search algorithm, which produced different results from the main
page's getLeoPass. Now it directly reuses satelliteRepo.passes,
which is the same list shown on the main passes page. The only
additional computation is refining the common window for station B
and sampling elevation/azimuth curves.
2026-08-03 02:16:15 +08:00
atsunatsu 43788ba7a7 fix: mutual pass search now matches main radar pass definition
Root cause: mutual search used the min-elevation threshold (default 10°)
as the AOS/LOS boundary, while the main radar uses the 0° horizon
(getLeoPass). With identical stations the windows therefore never
matched. Also, an in-progress pass was counted as a new one.

- AOS/LOS boundaries now at the 0° horizon (refined to 1s)
- Skip in-progress mutual windows at search start (like getLeoPass)
- Filter requires BOTH stations to reach their min elevation
- Default min elevation follows the main radar passes setting
2026-08-03 02:04:13 +08:00
atsunatsu 1a3f1bb34c fix: radar track rendering bugs
- Elevation ring labels were inverted (90/60/30 from outer to inner);
  now 30/60/90 correctly from outer ring to center
- Split track paths at the 0/360° azimuth wrap in both MutualRadarView
  and main RadarView, so passes crossing due north no longer draw a
  line straight across the plot
2026-08-03 01:53:37 +08:00
atsunatsu 998c98267b fix: draw full station-B track on main radar, live dot on top
Track line is no longer time-limited (always shows the whole mutual
arc). The station-B live position is drawn separately on the line
using the same pulsing-dot mode as the local station.
2026-08-03 01:29:17 +08:00
atsunatsu 28fa373db0 feat: bidirectional drag sync between elevation curve and radar track
- ElevationCurveChart is now controlled (progress + onProgressChange)
- MutualRadarView shows shared time-cursor positions for both stations
  and is draggable/tappable to move the cursor
- MutualPassCard owns a single dragProgress feeding both charts, so
  dragging either chart moves the other in sync
2026-08-03 00:58:09 +08:00
atsunatsu 4ca33c37ad feat: keep mutual state across nav + overlay station-B track on main radar
- MutualViewModel is Activity-scoped, so returning from Radar keeps query results
- TrackSampleData gains time field for live cut-off
- Removed standalone mutual card from radar page
- RadarViewCompose draws optional dashed station-B track + current dot
- RadarScreen builds trackB from mutual data up to current time
2026-08-03 00:55:05 +08:00
atsunatsu bd42e643ea feat: dual-station radar track plot (satlover/satmatch style)
- MutualPass now carries TrackSample (azimuth/elevation for both stations)
- New MutualRadarView: polar plot with elevation rings (30/60/90),
  cardinal spokes, solid A-track vs dashed B-track, AOS/LOS markers
- Shown in expanded mutual pass card and radar page mutual card
2026-08-03 00:21:20 +08:00
atsunatsu 4baf3821e5 fix: elevation curve x-axis float precision loss
Absolute epoch millis (~1.7e12) exceeds Float precision (ULP ~131s
at that magnitude), so 5s-spaced samples collapsed onto identical
x coordinates, producing a stepped/jagged curve. Use Long relative
time deltas (sample - start) before converting to Float.
2026-08-02 23:36:59 +08:00
atsunatsu d7e1ce11c9 fix: smooth dual-station elevation curve
- Fix cubic Bezier control points (proper Catmull-Rom to Bezier conversion)
- Refine AOS/LOS more robustly by walking from edge into the pass window
- Still 5s sampling for smooth curves
2026-08-02 23:07:28 +08:00
atsunatsu 5de370983d fix: lock screen orientation to portrait 2026-08-02 22:19:37 +08:00
atsunatsu f272e268ab fix: smooth elevation curves using cubic Bezier instead of lineTo
Replace Path.lineTo() with Path.cubicTo() using Catmull-Rom
to Bezier conversion for smooth elevation curves.
2026-08-02 22:10:17 +08:00
atsunatsu e59507a4d3 fix: smoother elevation curves with refined AOS/LOS and 5s sampling
- Reduce sample interval from 10s to 5s for smoother curves
- Add refineEdge() to find exact AOS/LOS at 1s resolution
- Curve now starts/ends at the correct horizon-crossing points
2026-08-02 21:59:51 +08:00
atsunatsu 0bae306312 fix: default station A grid, find all mutual passes, color toArgb()
- Pre-fill station A grid from settingsRepo.stationPosition
- Find ALL mutual passes per satellite (not just first)
- Fix Color.hashCode() -> Color.toArgb() for native canvas paint
- Use 2min gap between pass searches to avoid duplicates
2026-08-02 21:44:31 +08:00
atsunatsu 65f61a5147 fix: mutual pass elevation unit (radians->degrees) + grid input
Bug: OrbitalObject.getElevation() returns radians, but
MutualViewModel was comparing it directly with degree values
from the slider (10-90), causing no matches to be found.

Fix: add elevationDeg() helper that converts to degrees.

Also add Maidenhead grid square input support (4/6/8 chars)
alongside existing lat/lon fields. Grid takes priority when
filled.
2026-08-02 21:36:44 +08:00
atsunatsu 1064dc739e feat: add mutual pass query with dual-station elevation curve
Port satlover.de dual-station pass matching feature:
- New feature/mutual module with mutual pass data model
- MutualViewModel: compute overlapping passes for two stations
- ElevationCurveChart: Canvas-based dual elevation curve with drag
- MutualScreen: input form + results with expandable cards
- Navigation: add Mutual tab to bottom navigation bar
- i18n: add Chinese/English strings for new feature
2026-08-02 21:22:42 +08:00
atsunatsu 8aedd38b51 fix: consistent Chinese date format for pass headers and sun times
Extract dateFormat() helper to ensure computeSunTimes and
groupPasses use identical date format, fixing sunrise/sunset
display bug.

For Chinese locale: "2026年8月1日 星期六"
For English locale: "Sat, 01 Aug 2026"
2026-08-02 20:28:56 +08:00
atsunatsu ad8f08a577 fix: revert DateFormat.FULL to SimpleDateFormat with locale
DateFormat.FULL may cause inconsistent date labels between
computeSunTimes and groupPasses, leading to missing or
wrong sunrise/sunset times. Revert to the original pattern
"EEE, dd MMM yyyy" but with Locale.getDefault() so day
names follow the system language.
2026-08-02 20:26:01 +08:00
atsunatsu 2afce54472 fix: restore satellite catalog number in satellite selection list
Keep number removed from pass list (Components.kt) and
pass detail (PassesScreen.kt) as requested, but restore
it in the satellite selection list (SatellitesScreen.kt).
2026-08-02 20:09:51 +08:00
atsunatsu 8e50fcf8fb style: use primary color for satellite name (same as former number)
Use MaterialTheme.colorScheme.primary for satellite name
text in all three lists (Components, SatellitesScreen,
PassesScreen), matching the color that was previously
used for the catalog number.
2026-08-02 19:58:00 +08:00
atsunatsu e11b03d56b refactor: remove satellite catalog number from UI
Remove the NORAD catalog number (e.g. '44444 - ') prefix
from satellite names in three places:
- Pass list items (Components.kt)
- Satellite selection list (SatellitesScreen.kt)
- Pass detail view (PassesScreen.kt)

The number is still accessible via satellite details if needed.
2026-08-02 19:48:14 +08:00
atsunatsu fa55b82a5a fix(i18n): use locale-aware full date format for Chinese convention
Replace SimpleDateFormat("EEE, dd MMM yyyy") with
DateFormat.getDateInstance(DateFormat.FULL, locale) which
automatically uses the correct format for each locale:

- Chinese: "2026年8月1日 星期六"
- English: "Saturday, August 1, 2026"
- Other locales: their respective conventions
2026-08-02 19:34:43 +08:00
atsunatsu d57832b0d4 fix(i18n): use system locale for date formatting instead of English
PassesViewModel and PassesScreen used Locale.ENGLISH for
SimpleDateFormat, causing day-of-week names like 'Sun' to
always show in English. Changed to Locale.getDefault() so
the device's language setting is respected.

Affects: 'EEE, dd MMM yyyy' date labels in the pass list
(group headers and sun times), and 'HH:mm:ss' time format.
2026-08-02 19:30:42 +08:00
atsunatsu 1d0e8921a9 i18n(zh): complete Chinese translation with new features
- Add Doppler calculator strings (多普勒频率计算器)
- Add CW decoder strings (CW 解码器、开始、停止、清空)
- Add CAT radio control strings (电台控制、CAT 电台控制等)
- Add data import error messages
- Update outro thanks with xdsopl and Robot36 contributors
- Preserve all existing 154 lines of translation
- 20 new strings added
2026-08-02 19:23:17 +08:00
atsunatsu 23d95b8241 fix(cw): fix timing analysis - process per spectrogram column
- Add newColumnCount tracking to CwSpectrogram
- CwDecoder now processes each new column individually for timing
- Each column = 8ms at 8000 Hz sample rate
- Proper per-column iteration through spectrogram history
2026-08-01 19:52:39 +08:00
atsunatsu 8af80866bc feat(cw): v3 spectrogram-based multi-channel Bayesian decoder
Complete rewrite inspired by Morse Expert / CW Skimmer (VE3NEA):
- CwFFT: radix-2 FFT (256-point) for time-frequency analysis
- CwSpectrogram: sliding-window waterfall (40 cols x 33 bins, 8ms resolution)
- CwBayesianDecoder: Gaussian probability replaces hard dit/dash thresholds
- CwChannelTracker: multi-channel peak detection (up to 3 signals)
- CwDecoder: integrates all components, monitors 200-1200 Hz simultaneously

Key advantages over v2 (ggmorse):
- Frequency-agnostic: full spectrum monitored, not locked to one tone
- Multi-channel: tracks multiple signals in parallel
- Bayesian: probability-based decisions, not hard ratios
- Doppler tolerant: frequency drift just moves energy between bins
2026-08-01 19:41:32 +08:00
atsunatsu b9e5ff70f5 feat(cw): add continuous pitch tracking for Doppler drift
- After initial pitch lock, re-scan every ~8 seconds in a narrow
  ±100 Hz window around the current pitch estimate
- Only re-lock if pitch change > 20 Hz (avoids jitter)
- Helps maintain lock when Doppler tracking has residual error
2026-08-01 19:25:27 +08:00
atsunatsu c176d6ad88 feat(cw): port ggmorse algorithms - auto pitch/speed detection, adaptive threshold
- Add CwResampler: linear resampler (downsample to 4 kHz base rate)
- Add CwFilter: first-order IIR high-pass (200 Hz) + low-pass (1200 Hz)
- Add CwGoertzel: running Goertzel filter for tone tracking
- Add CwPitchDetector: DFT-based pitch detection (200-1200 Hz, 10 Hz steps)
- Rewrite CwDecoder: auto pitch detection, auto speed estimation (5-55 WPM),
  adaptive threshold with exponential moving average, resampled 4 kHz pipeline
- 22 unit tests covering resampler, filter, Goertzel, pitch detector, decoder state
- All existing UI/ViewModel code unchanged (same class interface)
2026-08-01 19:22:14 +08:00
atsunatsu 5a6b162e4f fix(cw): add microphone permission check before starting decoder
- Dispatch CwPermissionResult alongside SstvPermissionResult
- Add requestMicPermission callback through TransceiversPage chain
- Check cw.hasPermission before starting audio capture
- Prevent crash when RECORD_AUDIO not granted
2026-07-31 19:00:56 +08:00
atsunatsu 33712c29fc feat(cw): add built-in CW Morse code decoder for linear transponders
- Add CwDsp with FIR bandpass filter, envelope detection, Goertzel tone detector
- Add CwDecoder with real-time Morse timing analysis and character lookup
- Add CW state/actions to RadarState, wire into RadarViewModel
- Add collapsible CW decoder panel to transceivers page
- 19 unit tests covering DSP, Morse table, and decoder state
- Shares IAudioCapture with SSTV, auto-stops SSTV when CW starts
2026-07-31 18:44:49 +08:00
atsunatsu eac1e6e273 feat: add Doppler frequency calculator for linear transponders (issue #91)
- Add DopplerFrequencyCalculator utility with passband mapping + Doppler correction
- Add MHz input UI with real-time 1Hz refresh during pass
- Add kHz offset for downlink frequency correction
- TX/RX fields on same row for compact layout
- 40 unit tests covering linear/FM/inverted transponders
2026-07-31 14:17:19 +08:00
Arty Bishop f585372594 Added cleartext traffic support for custom TLE URLs #227 2026-07-30 17:15:02 +02:00
Arty Bishop 4585332b1a Fixed manual OMM (.csv) data import, tweaked messaging 2026-07-30 15:56:50 +02:00
Arty Bishop 6d2b0ced49 Implemented AOS window and elevation highlight filters 2026-07-30 13:28:37 +02:00
Arty Bishop e86bc2c700 Added a few tweaks to SSTV sensitivity and reception 2026-07-30 12:01:54 +02:00
Arty Bishop 9a52fdfde5 Added support for Icom IC-705 CAT (#229) 2026-07-30 11:43:03 +02:00
Rui Oliveira 07504a2a95 Add the option to override the frequency from the radio 2026-07-24 20:29:51 +01:00
Rui Oliveira 81525b6dd3 Map AFSK (e.g. ISS APRS) to FM 2026-07-24 20:10:37 +01:00
Rui Oliveira c7089ab314 Fix setting the mode (e.g. FM, USB, LSB) 2026-07-24 20:10:10 +01:00
Rui Oliveira 77dfb7bb05 Remove PTT-aware logic, which is not necessary
I was setting the Tx frequency only when we were
in Tx mode, but the Icom 705 will accept the
command to set the Tx frequency even when in Rx mode, so
there is no need to check the PTT state before sending the command.
2026-07-24 20:09:43 +01:00
Rui Oliveira 58fb688593 Fix bugs in setting the Tx frequency
Uses 0x25 01 command to set the Tx frequency,
which is the correct command when in split mode.
2026-07-24 20:09:01 +01:00
Rui Oliveira dc596b4348 Fix bugs in setting Rx frequency 2026-07-24 20:08:46 +01:00
Rui Oliveira f9b1a97a37 Refactor "Disconnect" and "Track" logic 2026-07-24 20:08:23 +01:00
Rui Oliveira d700432829 Add initial support for Icom IC-705 radio
- Added support for Icom IC-705 radio model with split mode functionality.
- Updated RadioTrackingService to handle single-radio split mode and dual-radio configurations.
- Modified MainContainer to provide appropriate radio controllers based on selected model.
- Enhanced SettingsRepo to include split mode preference in radio control settings.
- Updated UI in SettingsDialog to allow toggling of split mode for IC-705 and adjust device selection accordingly.
- Improved handling of baud rates based on selected radio model.
- Added extended operations in IRadioController for IC-705 specific commands.
2026-07-24 20:07:11 +01:00
Arty Bishop b17ea2d918 v4.4.3 - Added required tweaks to support Android 17 (API 37) 2026-06-27 14:15:05 +01:00
Arty Bishop 976fdfd949 Added Star History graph to README, tweaked AGENTS 2026-06-27 12:31:24 +01:00
dependabot[bot] d8db258bd8 Bump actions/checkout from 6 to 7 (#221)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 16:38:32 +01:00
Arty Bishop 7b6fb5eb8f v4.4.2 - Fixed transceivers retention, various minor tweaks 2026-06-21 15:19:47 +01:00
Arty Bishop f4aef4f4f0 Fixed SSTV frequency display and "All" category retention 2026-06-21 15:10:28 +01:00
Arty Bishop 9147323db2 Cleaned up SensorsRepo class, removed deprecated calls 2026-06-21 15:03:45 +01:00
Arty Bishop 81d397a09d v4.4.1 - Continuous SSTV decoding, frequency display 2026-06-07 17:23:19 +01:00
Arty Bishop ec1a55c93d Added initial code for possible future deeplink nav support 2026-06-07 16:01:23 +01:00
Arty Bishop e5033cb2e9 Added continuous SSTV decoding of manually selected type 2026-06-06 19:20:35 +01:00
Arty Bishop 39b786ae82 v4.4.0 - Implemented CAT and SSTV support in RadarScreen 2026-06-02 20:40:06 +01:00
Arty Bishop 79809aaec0 Added SSTV image decoding functionality to RadarScreen 2026-06-02 20:06:22 +01:00
Arty Bishop f7f5a73c4c Added colored elevation and decay check to satellite passes 2026-05-30 15:35:52 +01:00
Arty Bishop 18c8ac1822 Merged RadarScreen and RadioControlScreen functionality 2026-05-30 14:14:40 +01:00
Arty Bishop 22607e7710 v4.3.2 - Implemented Swipe-to-Focus behavior, minor fixes 2026-05-30 13:16:14 +01:00
Arty Bishop ad0ff6859e Updated release workflow to avoid third-party dependencies 2026-05-25 18:30:49 +01:00
Arty Bishop a39b1716e7 Added consistent pass selection to Radar and Map screens 2026-05-25 18:30:49 +01:00
Arty Bishop 8a2339cfbf Implemented Swipe-to-Focus behavior for satellite passes 2026-05-25 18:30:48 +01:00
Arty Bishop 821fa0dfe7 v4.3.1 - Zipped custom sources handling, translation fixes 2026-05-04 16:55:15 +01:00
Arty Bishop ce8c2a4834 Added zipped data sources handling to DatabaseRepo 2026-05-04 15:36:42 +01:00
Arty Bishop 8cc4ef5610 Tweaked passes list to show DeepSpace ones at the top 2026-05-04 15:36:42 +01:00
Mubi-Baihua dde22faf46 Added minor tweaks to Chinese translation (#216) 2026-05-02 19:32:18 +01:00
Arty Bishop 7ad81e32e1 v4.3.0 - Sticky header, sun/moon positions, red night mode 2026-04-30 19:32:17 +01:00
Arty Bishop 5af963f3c9 Added several tweaks to sunrise/sunset time calculations 2026-04-28 21:00:43 +01:00
Arty Bishop bfb9fb6ca8 Tweaked ViewModel retention to mirror the Nav2 behavior 2026-04-26 14:44:29 +01:00
Emre Can AkdaşandArty Bishop df24c66ef3 Fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
Co-authored-by: Arty Bishop <44072814+rt-bishop@users.noreply.github.com>
2026-04-26 14:42:16 +01:00
Arty Bishop b166e896c2 Added current moon/sun positions to the RadarScreen 2026-04-26 14:32:16 +01:00
Arty Bishop 18f8ab517d Added current moon/sun positions to the MapScreen 2026-04-26 00:11:08 +01:00
Arty Bishop d3b8e951dd Added red night mode filter overlay for the whole app 2026-04-25 19:22:32 +01:00
Arty Bishop 6e5a3cb0bb Added sticky header with a date and sunrise/sunset time 2026-04-25 19:20:22 +01:00
Arty Bishop 31d329a19b Added CelestialComputer, extracted common functionality 2026-04-25 18:18:00 +01:00
Arty Bishop 3df358b88c v4.2.2 - Kotlin Serialization, Navigation3, translation fixes 2026-04-24 17:24:00 +01:00
Arty Bishop 7d5bca300d Migrated to Compose Navigation3, updated dependencies 2026-04-24 17:20:23 +01:00
Arty Bishop fb2cd85b11 Migrated to KotlinX Serialization library for JSON parsing 2026-04-24 17:18:06 +01:00
Arty Bishop 99158e74fa v4.2.1 - Turkish translation, DeepSpace filter, various tweaks 2026-04-19 13:14:31 +01:00
Arty Bishop 8265b73d75 Added DeepSpace passes filter, fixed refresh issue (#208) 2026-04-18 15:56:28 +01:00
Emre Can Akdaş d2b3184084 Added Turkish translation, by Emre Can Akdaş (TA3ECR) (#211) 2026-04-18 10:38:42 +01:00
Arty Bishop 0c5d3699c0 v4.2.0 - CAT control, HamLib commands, performance tweaks 2026-04-11 16:48:24 +01:00
Arty Bishop a94f95874b Added several tweaks to Settings and RadioControl screens 2026-04-10 14:13:53 +01:00
Arty Bishop 4a3a01729b Added several tweaks to Radar and RadioControl screens 2026-04-10 12:21:03 +01:00
Arty Bishop aadc285d7f Added multiple tweaks to Satellites, Passes and Map screens 2026-04-10 11:50:32 +01:00
jcmerg 2df0b9ba3d Add CAT radio control for full-duplex satellite operation (#207) 2026-04-10 11:21:52 +01:00
Arty Bishop 868155c532 Simplified data output code, aligned with HamLib defaults 2026-04-06 12:48:06 +01:00
Arty Bishop 3f981c09d5 Tweaked output dialogs ui, fixed manual import category 2026-04-06 11:57:12 +01:00
Arty Bishop b79fec13f4 Removed the eclipsed badge, tweaked visual indication 2026-04-05 11:45:07 +01:00
Arty Bishop 6588291997 Fixed UTC pass time problem mentioned in issue #201 2026-04-05 11:27:27 +01:00
Arty Bishop be96602c76 Added normalized time for AOS/LOS, fixed passes refresh 2026-04-04 18:43:05 +01:00
Arty Bishop b59f9a6c17 Simplified SettingsRepo.kt and SettingsScreen.kt classes 2026-04-04 15:44:51 +01:00
Arty Bishop 9afe2ab69e Heavily optimized MapScreen.kt and position calculations 2026-04-03 16:45:02 +01:00
theojalba 2edc892dc2 Switch to short-form commands in NetworkReporter (#206) 2026-04-03 16:42:18 +01:00
Arty Bishop 45eb6915c2 Optimized common composables and satellite selection 2026-04-02 08:28:37 +01:00
Arty Bishop 706c912b13 Simplified BluetoothReporter and NetworkReporter 2026-03-30 22:02:49 +01:00
Arty Bishop e523eb5517 Added tweaks to DataParser.kt and DatabaseRepo.kt 2026-03-29 13:37:04 +01:00
Arty Bishop 0bef2ffddc Added tweaks to positions and passes calculation 2026-03-29 13:09:03 +01:00
Arty Bishop 279594c425 Simplified RadarScreen/View, added sensors smoothing 2026-03-28 20:04:03 +00:00
Arty Bishop 8c1df334d8 Added even more tweaks to convention plugins setup 2026-03-28 20:03:11 +00:00
247 changed files with 33607 additions and 3405 deletions

No files matched your search

+5
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+30 -46
View File
@@ -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
View File
@@ -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
+122
View File
@@ -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.
+5
View File
@@ -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.
+10
View File
@@ -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>
+34 -1
View File
@@ -1,3 +1,36 @@
import java.util.Properties
plugins {
alias(libs.plugins.convention.androidAppPlugin)
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")
}
}
}
+14 -1
View File
@@ -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>
@@ -19,12 +19,21 @@ package com.rtbishop.look4sat
import android.content.Context
import android.content.res.Configuration
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.MainTheme
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
@@ -38,8 +47,37 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
observeNightFilterState()
setContent {
MainTheme(isDarkTheme = true) { MainScreen() }
}
}
private fun observeNightFilterState() {
val mainContainer = (applicationContext as IContainerProvider).getMainContainer()
lifecycleScope.launch {
mainContainer.settingsRepo.otherSettings
.map { it.stateOfNightMode }
.distinctUntilChanged()
.collect { nightMode -> applyNightFilter(nightMode) }
}
}
private fun applyNightFilter(enabled: Boolean) {
if (enabled) {
val nightMatrix = ColorMatrix(
floatArrayOf(
1f, 0f, 0f, 0f, 0f, // R → R
0f, 0f, 0f, 0f, 0f, // G → 0
0f, 0f, 0f, 0f, 0f, // B → 0
0f, 0f, 0f, 1f, 0f // A → A
)
)
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, Paint().apply {
colorFilter = ColorMatrixColorFilter(nightMatrix)
})
} else {
window.decorView.setLayerType(View.LAYER_TYPE_NONE, null)
}
}
}
@@ -17,74 +17,356 @@
*/
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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.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.map.mapDestination
import com.rtbishop.look4sat.feature.passes.passesDestination
import com.rtbishop.look4sat.feature.radar.radarDestination
import com.rtbishop.look4sat.feature.satellites.satellitesDestination
import com.rtbishop.look4sat.feature.settings.settingsDestination
import com.rtbishop.look4sat.feature.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.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(navController: NavHostController = rememberNavController()) {
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route
val startDestination = Screen.Passes.route
NavigationSuiteScaffold(
navigationSuiteItems = {
items.forEach {
item(
icon = { Icon(painterResource(it.iconResId), stringResource(it.titleResId)) },
label = { Text(stringResource(it.titleResId)) },
selected = currentDestination?.contains(it.route) ?: false,
onClick = {
if (currentDestination?.contains(it.route) ?: false) return@item
navController.navigate(it.route) {
popUpTo(startDestination) { saveState = false }
launchSingleTop = true
restoreState = false
}
})
}
}, navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
), layoutType = when {
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
else -> NavigationSuiteType.ShortNavigationBarMedium
}
) {
NavHost(
navController = navController,
startDestination = startDestination,
enterTransition = { fadeIn(animationSpec = tween(350)) },
exitTransition = { fadeOut(animationSpec = tween(350)) }
) {
satellitesDestination { navController.navigateUp() }
passesDestination { catNum: Int, aosTime: Long ->
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
navController.navigate(radarRoute)
}
radarDestination { navController.navigateUp() }
mapDestination()
settingsDestination()
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.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// 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
}
}
.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
)
) {
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
}
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)
}
)
}
// 更多菜单按钮(固定第 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"
)
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
)
}
}
}
// 更多菜单弹出面板(覆盖在内容上, 底部栏上方; spring 弹跳)
AnimatedVisibility(
visible = moreExpanded,
modifier = Modifier.fillMaxSize(),
enter = expandVertically(
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
) + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
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
)
}
}
}
}
}
}
+9
View File
@@ -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>
+15 -15
View File
@@ -24,25 +24,25 @@ group = "com.rtbishop.look4sat.build_logic.convention"
gradlePlugin {
plugins {
register("androidAppPlugin") {
id = libs.plugins.convention.androidAppPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.AndroidAppPlugin"
register("applicationPlugin") {
id = libs.plugins.convention.applicationPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.ApplicationPlugin"
}
register("androidLibraryPlugin") {
id = libs.plugins.convention.androidLibraryPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.AndroidLibraryPlugin"
register("coreDataPlugin") {
id = libs.plugins.convention.coreDataPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.CoreDataPlugin"
}
register("composeFeaturePlugin") {
id = libs.plugins.convention.composeFeaturePlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.ComposeFeaturePlugin"
register("coreDomainPlugin") {
id = libs.plugins.convention.coreDomainPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.CoreDomainPlugin"
}
register("composeLibraryPlugin") {
id = libs.plugins.convention.composeLibraryPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.ComposeLibraryPlugin"
register("corePresentationPlugin") {
id = libs.plugins.convention.corePresentationPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.CorePresentationPlugin"
}
register("kotlinLibraryPlugin") {
id = libs.plugins.convention.kotlinLibraryPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.KotlinLibraryPlugin"
register("featurePlugin") {
id = libs.plugins.convention.featurePlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.FeaturePlugin"
}
}
}
@@ -1,46 +0,0 @@
/*
* 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.convention
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class AndroidAppPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
setupAndroidApplication()
setupComposeFeature()
setupKotlinToolchain()
setupAndroidTestDependencies()
setupTestDependencies()
dependencies {
IMPLEMENTATION(project(":core:data"))
IMPLEMENTATION(project(":core:domain"))
IMPLEMENTATION(project(":core:presentation"))
IMPLEMENTATION(project(":feature:map"))
IMPLEMENTATION(project(":feature:passes"))
IMPLEMENTATION(project(":feature:radar"))
IMPLEMENTATION(project(":feature:satellites"))
IMPLEMENTATION(project(":feature:settings"))
IMPLEMENTATION(libs.androidx.core.splashscreen)
}
}
}
}
@@ -0,0 +1,49 @@
/*
* 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.convention
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class ApplicationPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
setupAndroidApp()
setupCompose()
setupKotlin()
dependencies {
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:cw"))
implementation(project(":feature:map"))
implementation(project(":feature:mutual"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
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)
androidTestImplementation(libs.bundles.androidTest)
}
}
}
@@ -22,25 +22,19 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class AndroidLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
alias(libs.plugins.google.ksp)
}
setupCommonLibrary()
setupKotlinToolchain()
setupAndroidTestDependencies()
setupTestDependencies()
dependencies {
IMPLEMENTATION(project(":core:domain"))
IMPLEMENTATION(libs.androidx.core.ktx)
IMPLEMENTATION(libs.androidx.room.asProvider())
IMPLEMENTATION(libs.androidx.room.runtime)
KSP(libs.androidx.room.compiler)
IMPLEMENTATION(libs.other.coroutines)
IMPLEMENTATION(libs.other.okhttp)
}
internal class CoreDataPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.google.ksp)
setupAndroidLib()
setupKotlin()
dependencies {
implementation(project(":core:domain"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.room)
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation(libs.kotlin.coroutines)
implementation(libs.other.okhttp)
}
}
}
@@ -22,18 +22,14 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class KotlinLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
alias(libs.plugins.kotlin.jvm)
}
setupKotlinToolchain()
setupTestDependencies()
dependencies {
IMPLEMENTATION(libs.other.coroutines)
IMPLEMENTATION(libs.other.json)
}
internal class CoreDomainPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.kotlin.jvm)
applyPlugin(libs.plugins.kotlin.serialization)
setupKotlin()
dependencies {
implementation(libs.kotlin.coroutines)
implementation(libs.kotlin.serialization)
}
}
}
@@ -22,18 +22,17 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class ComposeLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
setupCommonLibrary()
setupComposeFeature()
setupKotlinToolchain()
setupAndroidTestDependencies()
setupTestDependencies()
dependencies {
IMPLEMENTATION(project(":core:domain"))
IMPLEMENTATION(libs.androidx.core.splashscreen)
}
internal class CorePresentationPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.kotlin.serialization)
setupAndroidLib()
setupCompose()
setupKotlin()
dependencies {
implementation(project(":core:domain"))
implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlin.serialization)
implementation(libs.compose.material3.adaptive)
}
}
}
@@ -22,18 +22,14 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class ComposeFeaturePlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
setupCommonLibrary()
setupComposeFeature()
setupKotlinToolchain()
setupAndroidTestDependencies()
setupTestDependencies()
dependencies {
IMPLEMENTATION(project(":core:domain"))
IMPLEMENTATION(project(":core:presentation"))
}
internal class FeaturePlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
setupAndroidLib()
setupCompose()
setupKotlin()
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
}
}
}
@@ -21,7 +21,7 @@ import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension
import org.gradle.accessors.dm.LibrariesForLibs
import org.gradle.api.Project
import org.gradle.api.plugins.PluginManager
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.accessors.runtime.extensionOf
import org.gradle.kotlin.dsl.configure
@@ -29,35 +29,39 @@ import org.gradle.kotlin.dsl.dependencies
import org.gradle.plugin.use.PluginDependency
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
internal const val ANDROID_TEST_IMPLEMENTATION = "androidTestImplementation"
internal const val DEBUG_IMPLEMENTATION = "debugImplementation"
internal const val IMPLEMENTATION = "implementation"
internal const val KSP = "ksp"
internal const val TEST_IMPLEMENTATION = "testImplementation"
internal val Project.libs
get(): LibrariesForLibs = extensionOf(this, "libs") as LibrariesForLibs
internal fun PluginManager.alias(notation: Provider<PluginDependency>) {
apply(notation.get().pluginId)
internal fun Project.applyPlugin(notation: Provider<PluginDependency>) {
pluginManager.apply(notation.get().pluginId)
}
internal fun Project.setupAndroidApplication() {
with(pluginManager) {
alias(libs.plugins.android.application)
}
internal fun DependencyHandler.implementation(dependencyNotation: Any) =
add("implementation", dependencyNotation)
internal fun DependencyHandler.debugImplementation(dependencyNotation: Any) =
add("debugImplementation", dependencyNotation)
internal fun DependencyHandler.testImplementation(dependencyNotation: Any) =
add("testImplementation", dependencyNotation)
internal fun DependencyHandler.androidTestImplementation(dependencyNotation: Any) =
add("androidTestImplementation", dependencyNotation)
internal fun DependencyHandler.ksp(dependencyNotation: Any) =
add("ksp", dependencyNotation)
internal fun Project.setupAndroidApp() {
applyPlugin(libs.plugins.android.application)
extensions.configure<ApplicationExtension> {
namespace = libs.versions.packageName.get()
compileSdk = libs.versions.compileSdk.get().toInt()
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()
}
buildFeatures {
compose = true
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
@@ -70,49 +74,38 @@ internal fun Project.setupAndroidApplication() {
}
androidResources {
generateLocaleConfig = true
localeFilters.addAll(listOf("en", "es", "ru", "si", "uk", "zh"))
localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
}
packaging { resources { excludes += listOf("META-INF/*") } }
}
}
internal fun Project.setupCommonLibrary() {
with(pluginManager) {
alias(libs.plugins.android.library)
}
internal fun Project.setupAndroidLib() {
applyPlugin(libs.plugins.android.library)
extensions.configure<CommonExtension> {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig.minSdk = libs.versions.minSdk.get().toInt()
}
dependencies {
androidTestImplementation(libs.bundles.androidTest)
}
}
internal fun Project.setupComposeFeature() {
with(pluginManager) {
alias(libs.plugins.compose.compiler)
}
internal fun Project.setupCompose() {
applyPlugin(libs.plugins.compose.compiler)
extensions.configure<CommonExtension> {
buildFeatures.compose = true
}
dependencies {
IMPLEMENTATION(platform(libs.compose.bom))
IMPLEMENTATION(libs.bundles.composeAll)
DEBUG_IMPLEMENTATION(libs.bundles.composeDebug)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.composeAll)
debugImplementation(libs.bundles.composeDebug)
}
}
internal fun Project.setupKotlinToolchain() {
internal fun Project.setupKotlin() {
kotlinExtension.jvmToolchain(libs.versions.jdkVersion.get().toInt())
}
internal fun Project.setupAndroidTestDependencies() {
dependencies {
ANDROID_TEST_IMPLEMENTATION(libs.bundles.androidTest)
}
}
internal fun Project.setupTestDependencies() {
dependencies {
TEST_IMPLEMENTATION(libs.test.coroutines)
TEST_IMPLEMENTATION(libs.test.junit4)
testImplementation(libs.bundles.unitTest)
}
}
+2
View File
@@ -4,8 +4,10 @@ plugins {
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
tasks.register("clean", Delete::class.java) {
description = "Cleans the build directory"
delete(rootProject.layout.buildDirectory)
}
+1 -1
View File
@@ -1,5 +1,5 @@
plugins {
alias(libs.plugins.convention.androidLibraryPlugin)
alias(libs.plugins.convention.coreDataPlugin)
}
android {
+2
View File
@@ -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>
@@ -25,11 +25,11 @@ data class SatRadio(
@PrimaryKey val uuid: String,
val info: String,
val isAlive: Boolean,
var downlinkLow: Long?,
var downlinkHigh: Long?,
val downlinkLow: Long?,
val downlinkHigh: Long?,
val downlinkMode: String?,
var uplinkLow: Long?,
var uplinkHigh: Long?,
val uplinkLow: Long?,
val uplinkHigh: Long?,
val uplinkMode: String?,
val isInverted: Boolean,
val catnum: Int?
@@ -20,121 +20,112 @@ package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.util.Log
import com.rtbishop.look4sat.core.domain.repository.BtService
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.OutputStream
import java.util.UUID
import kotlin.math.abs
data class DeviceConnection(
var socket: BluetoothSocket? = null,
var outputStream: OutputStream? = null,
var connected: Boolean = false,
var connecting: Boolean = false,
var connectionJob: Job? = null
)
class BluetoothReporter(
private val bluetoothManager: BluetoothManager,
private val reporterScope: CoroutineScope
) : IReporterRepo<WithoutExtParams> {
private val reporterScope: CoroutineScope,
private val rotatorDeviceId: String,
private val frequencyDeviceId: String
) : IReporter {
private val tag = "BTReporter"
private val sppid: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val serviceToDevice = mutableMapOf<BtService, String>()
private val deviceConnections = mutableMapOf<String, DeviceConnection>()
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val writeMutex = Mutex()
override fun isConnected(service: BtService): Boolean {
val deviceId = serviceToDevice[service] ?: return false
return deviceConnections[deviceId]?.connected == true
}
private var rotatorSocket: BluetoothSocket? = null
private var rotatorStream: OutputStream? = null
private var rotatorConnected = false
private var rotatorConnecting = false
override fun isConnecting(service: BtService): Boolean {
val deviceId = serviceToDevice[service] ?: return false
return deviceConnections[deviceId]?.connecting == true
}
private var frequencySocket: BluetoothSocket? = null
private var frequencyStream: OutputStream? = null
private var frequencyConnected = false
private var frequencyConnecting = false
override fun connect(service: BtService, deviceId: String) {
serviceToDevice[service] = deviceId
val connection = deviceConnections.getOrPut(deviceId) {
DeviceConnection()
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
reporterScope.launch {
ensureRotatorConnected()
if (!rotatorConnected) return@launch
val el = if (elevation > 0.0) elevation else 0.0
val command = format
.replace($$"$AZ", azimuth.toString())
.replace($$"$EL", el.toString())
.unescapeControlChars()
write(rotatorStream, command) { rotatorConnected = false }
}
if (connection.connected || connection.connecting) return
connection.connectionJob = reporterScope.launch {
}
override fun reportFrequency(format: String, frequency: Long) {
reporterScope.launch {
ensureFrequencyConnected()
if (!frequencyConnected) return@launch
val command = format
.replace($$"$FREQ", frequency.toString())
.unescapeControlChars()
write(frequencyStream, command) { frequencyConnected = false }
}
}
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
reporterScope.launch {
try {
connection.connecting = true
val device = bluetoothManager.adapter.getRemoteDevice(deviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppid)
rotatorConnecting = true
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
socket.connect()
connection.socket = socket
connection.outputStream = socket.outputStream
connection.connected = true
Log.i(tag, "$tag: Connected to $deviceId")
rotatorSocket = socket
rotatorStream = socket.outputStream
rotatorConnected = true
Log.i(tag, "Rotator connected to $rotatorDeviceId")
} catch (e: Exception) {
Log.e(tag, "$tag: ${e.message}")
connection.connected = false
Log.e(tag, "Rotator connect error: ${e.message}")
rotatorConnected = false
} finally {
connection.connecting = false
rotatorConnecting = false
}
}
}
private suspend fun write(service: BtService, buffer: String) {
val deviceId = serviceToDevice[service] ?: return
val connection = deviceConnections[deviceId] ?: return
if (!connection.connected) return
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
reporterScope.launch {
try {
frequencyConnecting = true
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
socket.connect()
frequencySocket = socket
frequencyStream = socket.outputStream
frequencyConnected = true
Log.i(tag, "Frequency connected to $frequencyDeviceId")
} catch (e: Exception) {
Log.e(tag, "Frequency connect error: ${e.message}")
frequencyConnected = false
} finally {
frequencyConnecting = false
}
}
}
private suspend fun write(stream: OutputStream?, data: String, onError: () -> Unit) {
try {
writeMutex.withLock {
connection.outputStream?.write(buffer.toByteArray())
stream?.write(data.toByteArray())
}
} catch (e: Exception) {
Log.e(tag, "$tag: Write failed ${e.message}")
connection.connected = false
Log.e(tag, "Write error: ${e.message}")
onError()
}
}
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: WithoutExtParams) {
reporterScope.launch {
if (!isConnected(BtService.ROTATOR)) return@launch
val newElevation = if (elevation > 0.0) elevation else 0.0
val azimuthString = intToStringWithLeadingZeroes(azimuth.toInt())
val elevationString = intToStringWithLeadingZeroes(newElevation.toInt())
val buffer = format
.replace($$"$AZ", azimuthString)
.replace($$"$EL", elevationString)
.replace("\\r", "\r")
.replace("\\n", "\n")
.replace("\\t", "\t")
write(BtService.ROTATOR, buffer)
}
}
override fun reportFrequency(format: String, frequency: Long, params: WithoutExtParams) {
reporterScope.launch {
if (!isConnected(BtService.FREQUENCY)) return@launch
val buffer = format
.replace($$"$FREQ", frequency.toString())
.replace("\\r", "\r")
.replace("\\n", "\n")
.replace("\\t", "\t")
write(BtService.FREQUENCY, buffer)
}
}
private fun intToStringWithLeadingZeroes(value: Int): String {
return if (value > 0) {
if (value < 10) "00$value" else if (value < 100) "0$value" else "$value"
} else {
val absValue = abs(value)
if (value > -10) "-00$absValue" else if (value > -100) "-0$absValue" else "-$absValue"
}
}
private fun String.unescapeControlChars(): String =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
}
@@ -0,0 +1,139 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import java.util.Locale
import kotlin.math.roundToLong
object Ft817CatProtocol {
const val CMD_SET_FREQ: Byte = 0x01
const val CMD_READ_FREQ_MODE: Byte = 0x03
const val CMD_SET_MODE: Byte = 0x07
const val CMD_PTT_ON: Byte = 0x08
const val CMD_PTT_OFF: Byte = 0x88.toByte()
const val CMD_CTCSS_MODE: Byte = 0x0A
const val CMD_CTCSS_TONE: Byte = 0x0B
const val CTCSS_ENC_ON: Byte = 0x2A
const val CTCSS_OFF: Byte = 0x8A.toByte()
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
"LSB" to 0x00,
"USB" to 0x01,
"CW" to 0x02,
"CW-R" to 0x03,
"AM" to 0x04,
"FM" to 0x08,
"DIG" to 0x0A,
"PKT" to 0x0C
)
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
/**
* Encode frequency in Hz to 4-byte BCD with 10 Hz resolution.
* Example: 145500000 Hz → [0x14, 0x55, 0x00, 0x00]
*/
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
val freq10Hz = frequencyHz / 10
val bcd = ByteArray(4)
val digits = String.format(Locale.US, "%08d", freq10Hz)
for (i in 0 until 4) {
val high = digits[i * 2] - '0'
val low = digits[i * 2 + 1] - '0'
bcd[i] = ((high shl 4) or low).toByte()
}
return bcd
}
/**
* Decode 4-byte BCD frequency to Hz.
*/
fun decodeFrequencyBcd(bcd: ByteArray): Long {
var freq10Hz = 0L
for (i in 0 until 4) {
val b = bcd[i].toInt() and 0xFF
val high = b shr 4
val low = b and 0x0F
freq10Hz = freq10Hz * 100 + high * 10 + low
}
return freq10Hz * 10
}
/**
* Encode CTCSS tone frequency (in Hz, e.g. 67.0) to 2-byte BCD.
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
*/
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
val tone01Hz = (toneHz * 10).roundToLong()
val digits = String.format(Locale.US, "%04d", tone01Hz)
val bcd = ByteArray(2)
for (i in 0 until 2) {
val high = digits[i * 2] - '0'
val low = digits[i * 2 + 1] - '0'
bcd[i] = ((high shl 4) or low).toByte()
}
return bcd
}
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
val bcd = encodeFrequencyBcd(frequencyHz)
return byteArrayOf(bcd[0], bcd[1], bcd[2], bcd[3], CMD_SET_FREQ)
}
fun buildSetModeCommand(mode: String): ByteArray? {
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
}
fun buildReadFreqModeCommand(): ByteArray {
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_READ_FREQ_MODE)
}
fun buildPttOnCommand(): ByteArray {
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_ON)
}
fun buildPttOffCommand(): ByteArray {
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_OFF)
}
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
val sub = if (enabled) CTCSS_ENC_ON else CTCSS_OFF
return byteArrayOf(sub, 0x00, 0x00, 0x00, CMD_CTCSS_MODE)
}
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
val bcd = encodeCtcssToneBcd(toneHz)
return byteArrayOf(bcd[0], bcd[1], 0x00, 0x00, CMD_CTCSS_TONE)
}
/**
* Parse the 5-byte response from a READ FREQ+MODE command.
* Returns (frequencyHz, modeString) or null if parsing fails.
*/
fun parseReadResponse(response: ByteArray): Pair<Long, String>? {
if (response.size < 5) return null
val freqBcd = response.copyOfRange(0, 4)
val frequencyHz = decodeFrequencyBcd(freqBcd)
val modeByte = response[4]
val mode = BYTE_TO_MODE[modeByte] ?: return null
return Pair(frequencyHz, mode)
}
}
@@ -0,0 +1,207 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.util.Log
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
class Ft817Controller(
private val bluetoothManager: BluetoothManager,
private val deviceAddress: String
) : IRadioController {
private val tag = "FT817"
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val ioMutex = Mutex()
private val commandDelayMs = 200L
private val maxAckReadFailures = 3
private var socket: BluetoothSocket? = null
private var outputStream: OutputStream? = null
private var inputStream: InputStream? = null
private var ackReadFailureCount = 0
override var isConnected: Boolean = false
private set
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
if (isConnected) return@withContext true
if (deviceAddress.isBlank()) return@withContext false
try {
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
btSocket.connect()
socket = btSocket
outputStream = btSocket.outputStream
inputStream = btSocket.inputStream
ackReadFailureCount = 0
isConnected = true
Log.i(tag, "Connected to $deviceAddress")
true
} catch (e: Exception) {
Log.e(tag, "Connect error: ${e.message}")
isConnected = false
false
}
}
override suspend fun disconnect() {
withContext(Dispatchers.IO) {
try {
inputStream?.close()
outputStream?.close()
socket?.close()
} catch (e: Exception) {
Log.e(tag, "Disconnect error: ${e.message}")
} finally {
inputStream = null
outputStream = null
socket = null
ackReadFailureCount = 0
isConnected = false
Log.i(tag, "Disconnected from $deviceAddress")
}
}
}
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
ioMutex.withLock {
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
}
}
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
val cmd = Ft817CatProtocol.buildSetModeCommand(mode) ?: return@withContext false
ioMutex.withLock { sendCommandWithAck(cmd) }
}
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
ioMutex.withLock {
sendCommandWithAck(Ft817CatProtocol.buildCtcssModeCommand(enabled))
}
}
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
ioMutex.withLock {
sendCommandWithAck(Ft817CatProtocol.buildSetCtcssToneCommand(toneHz))
}
}
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
if (!sent) return@withContext null
delay(commandDelayMs.milliseconds)
val response = readResponse() ?: return@withContext null
Ft817CatProtocol.parseReadResponse(response)
}
}
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOnCommand()) }
}
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOffCommand()) }
}
private suspend fun sendCommand(bytes: ByteArray): Boolean {
return withContext(Dispatchers.IO) {
try {
outputStream?.write(bytes) ?: return@withContext false
outputStream?.flush()
delay(commandDelayMs.milliseconds)
true
} catch (e: Exception) {
Log.e(tag, "Send error: ${e.message}")
isConnected = false
false
}
}
}
/** Send command and read the 1-byte ACK response (0x00 = OK). */
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
if (!sendCommand(bytes)) return false
return withContext(Dispatchers.IO) {
try {
val ack = inputStream?.read() ?: run {
ackReadFailureCount = 0
isConnected = false
return@withContext false
}
if (ack < 0) {
ackReadFailureCount = 0
Log.i(tag, "ACK stream closed by remote device")
isConnected = false
return@withContext false
}
ackReadFailureCount = 0
ack == 0x00
} catch (e: Exception) {
ackReadFailureCount += 1
Log.w(tag, "ACK read error (${ackReadFailureCount}/$maxAckReadFailures): ${e.message}")
if (ackReadFailureCount >= maxAckReadFailures) {
Log.e(tag, "Too many ACK read errors, marking radio disconnected")
isConnected = false
false
} else {
true // Command was sent, treat transient ACK read failures as best-effort
}
}
}
}
private suspend fun readResponse(): ByteArray? {
return withContext(Dispatchers.IO) {
try {
val responseSize = 5
val buffer = ByteArray(responseSize)
var read = 0
while (read < responseSize) {
val count = inputStream?.read(buffer, read, responseSize - read) ?: run {
isConnected = false
return@withContext null
}
if (count < 0) {
Log.i(tag, "Response stream closed by remote device")
isConnected = false
return@withContext null
}
read += count
}
buffer
} catch (e: Exception) {
Log.e(tag, "Read error: ${e.message}")
isConnected = false
null
}
}
}
}
@@ -0,0 +1,364 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.util.Log
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
/**
* Icom IC-705 CI-V controller over Bluetooth SPP.
*
* The IC-705 emits broadcast frames continuously (band scope, UTC, signal
* level, …). A reply to any command we send may therefore be buried in
* that noise. All response reads drain up to [ACK_TIMEOUT_MS] and scan the
* entire accumulated buffer for the frame we expect rather than assuming
* the very next byte is the response.
*/
class Ic705Controller(
private val bluetoothManager: BluetoothManager,
private val deviceAddress: String
) : IRadioController {
private val tag = "IC705"
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val ioMutex = Mutex()
/** Time budget (ms) to wait for a response amid broadcast noise. */
private val ACK_TIMEOUT_MS = 500L
/** Polling interval while draining the input buffer. */
private val POLL_INTERVAL_MS = 20L
/** Small pause after writing a command before reading the response. */
private val WRITE_SETTLE_MS = 50L
private var socket: BluetoothSocket? = null
private var outputStream: OutputStream? = null
private var inputStream: InputStream? = null
override var isConnected: Boolean = false
private set
// ── Connection ──────────────────────────────────────────────────────────
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
if (isConnected) return@withContext true
if (deviceAddress.isBlank()) return@withContext false
try {
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
btSocket.connect()
socket = btSocket
outputStream = btSocket.outputStream
inputStream = btSocket.inputStream
isConnected = true
// Enter VFO mode — frequency/mode commands return FA if the radio
// is in memory-channel mode. Safe to send regardless of current state.
Log.i(tag, "Connected to $deviceAddress — entering VFO mode")
val vfoCmd = IcomCivProtocol.buildEnterVfoModeCommand()
Log.d(tag, "CMD enterVfoMode → ${IcomCivProtocol.toHex(vfoCmd)}")
ioMutex.withLock { sendAndWaitAck(vfoCmd) }
true
} catch (e: Exception) {
Log.e(tag, "Connect error: ${e.message}")
isConnected = false
false
}
}
override suspend fun disconnect() {
withContext(Dispatchers.IO) {
try {
inputStream?.close()
outputStream?.close()
socket?.close()
} catch (e: Exception) {
Log.e(tag, "Disconnect error: ${e.message}")
} finally {
inputStream = null
outputStream = null
socket = null
isConnected = false
Log.i(tag, "Disconnected from $deviceAddress")
}
}
}
// ── IRadioController – standard operations ──────────────────────────────
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setFrequency: ${frequencyHz}Hz")
ioMutex.withLock {
val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz)
Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}")
sendAndWaitAck(cmd)
}
}
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run {
Log.w(tag, "setMode: unknown mode '$mode'")
return@withContext false
}
Log.d(tag, "setMode: $mode")
Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setCtcssMode: $enabled")
val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled)
Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setCtcssTone: ${toneHz}Hz")
val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz)
Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val cmd = IcomCivProtocol.buildReadFreqCommand()
Log.d(tag, "CMD readFreq → ${IcomCivProtocol.toHex(cmd)}")
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_READ_FREQ) ?: return@withContext null
// Read-freq reply payload: [cmd byte already stripped by parseResponse] [5 freq bytes] [mode] [filter]
IcomCivProtocol.parseFreqModePayload(payload).also {
if (it != null) Log.d(tag, "readFreqMode: ${it.first}Hz, ${it.second}")
else Log.w(tag, "readFreqMode: parse failed, payload=${IcomCivProtocol.toHex(payload)}")
}
}
}
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
Log.w(tag, "pttOn: not used for IC-705")
true
}
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
Log.w(tag, "pttOff: not used for IC-705")
true
}
// ── IRadioController – IC-705 extended operations ───────────────────────
/** Select the band for [frequencyHz] via CMD 0x1A sub 0x00 (band stacking register). */
override suspend fun setBand(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
val cmd = IcomCivProtocol.buildBandSelectCommand(frequencyHz) ?: run {
Log.w(tag, "setBand: no band code for ${frequencyHz}Hz — skipping")
return@withContext false
}
Log.d(tag, "CMD setBand (${frequencyHz}Hz) → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/** Select VFO-A (main/RX) or VFO-B (sub/TX). */
override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) {
val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand()
else IcomCivProtocol.buildSelectVfoBCommand()
Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO).
*/
override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
val cmd = IcomCivProtocol.buildSplitModeCommand(enabled)
Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00).
* In split mode the radio automatically switches active VFO on PTT, so
* always writing to the active VFO is the correct strategy.
*/
override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setWorkingFrequency (0x25/00): ${frequencyHz}Hz")
val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz)
Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO).
* Sent every tracking cycle in split mode alongside [setWorkingFrequency].
*/
override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz")
val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz)
Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Read the frequency of the currently active VFO (CMD 0x25 sub 0x00).
* Used for tuning detection in split mode.
*/
override suspend fun readWorkingFrequency(): Long? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val cmd = IcomCivProtocol.buildReadWorkingFreqCommand()
Log.d(tag, "CMD readWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
Log.d(tag, "readWorkingFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
if (payload.size < 6) {
Log.w(tag, "readWorkingFreq: payload too short (${payload.size} bytes)")
return@withContext null
}
val freqBcd = payload.sliceArray(1..5)
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
Log.d(tag, "readWorkingFreq: ${freq}Hz")
freq
}
}
/**
* Read the frequency of the inactive/TX VFO (CMD 0x25 sub 0x01).
* Used for tuning detection in split mode.
*/
override suspend fun readTxVfoFrequency(): Long? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val cmd = IcomCivProtocol.buildReadTxVfoFreqCommand()
Log.d(tag, "CMD readTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
Log.d(tag, "readTxVfoFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
if (payload.size < 6) {
Log.w(tag, "readTxVfoFreq: payload too short (${payload.size} bytes)")
return@withContext null
}
val freqBcd = payload.sliceArray(1..5)
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
Log.d(tag, "readTxVfoFreq: ${freq}Hz")
freq
}
}
// ── Internal I/O helpers ────────────────────────────────────────────────
/**
* Write [cmd] to the radio and drain the input stream for up to
* [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame.
*/
private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean {
if (!write(cmd)) return false
delay(WRITE_SETTLE_MS)
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
val ok = IcomCivProtocol.containsAck(buf)
if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
return ok
}
/**
* Write [cmd] to the radio and drain the input stream for up to
* [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd].
* Returns the payload bytes of that frame, or null on timeout/error.
*/
private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? {
if (!write(cmd)) return null
delay(WRITE_SETTLE_MS)
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
val response = IcomCivProtocol.parseResponse(buf, expectCmd)
if (response == null) {
Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " +
"in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
}
return response?.payload
}
/**
* Drain whatever bytes the radio has buffered within a [timeoutMs] window.
* Exits early as soon as a complete CI-V frame addressed to us is present
* in the buffer (i.e., FE FE E0 A4 … FD), so we don't waste the remaining
* timeout on responses that already arrived.
*/
private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray {
val result = mutableListOf<Byte>()
val deadline = System.currentTimeMillis() + timeoutMs
val stream = inputStream ?: return ByteArray(0)
while (System.currentTimeMillis() < deadline) {
try {
val available = stream.available()
if (available > 0) {
val chunk = ByteArray(available)
val read = stream.read(chunk)
if (read > 0) {
result.addAll(chunk.take(read))
// Exit early once we have a complete frame for us
if (hasCompleteFrameForUs(result)) break
}
} else {
delay(POLL_INTERVAL_MS)
}
} catch (e: Exception) {
Log.e(tag, "Drain error: ${e.message}")
isConnected = false
break
}
}
return result.toByteArray()
}
/**
* Returns true if [buf] contains a complete CI-V frame addressed to the
* controller (FE FE [ADDR_CTRL] [ADDR_IC705] … FD).
* CI-V data bytes cannot be 0xFD, so the first 0xFD after the header is
* always the frame terminator.
*/
private fun hasCompleteFrameForUs(buf: List<Byte>): Boolean {
var i = 0
while (i < buf.size - 4) {
if (buf[i] == IcomCivProtocol.PREAMBLE &&
buf[i + 1] == IcomCivProtocol.PREAMBLE &&
buf[i + 2] == IcomCivProtocol.ADDR_CTRL &&
buf[i + 3] == IcomCivProtocol.ADDR_IC705
) {
for (k in i + 4 until buf.size) {
if (buf[k] == IcomCivProtocol.END_OF_MSG) return true
}
return false // header found but no FD yet
}
i++
}
return false
}
private fun write(bytes: ByteArray): Boolean {
return try {
outputStream?.write(bytes)
outputStream?.flush()
true
} catch (e: Exception) {
Log.e(tag, "Write error: ${e.message}")
isConnected = false
false
}
}
}
@@ -0,0 +1,352 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import java.util.Locale
/**
* Icom CI-V protocol encoder/decoder for the IC-705.
*
* Frame structure:
* FE FE <DEST> <SRC> <CMD> [<SUB>] [<DATA...>] FD
*
* IC-705 default CI-V address : 0xA4
* Controller (us) address : 0xE0
*/
object IcomCivProtocol {
// ── Framing constants ──────────────────────────────────────────────────
const val PREAMBLE: Byte = 0xFE.toByte()
const val END_OF_MSG: Byte = 0xFD.toByte()
const val ACK_OK: Byte = 0xFB.toByte()
const val ACK_NG: Byte = 0xFA.toByte()
// ── Address constants ──────────────────────────────────────────────────
/** Default CI-V address of the IC-705. */
const val ADDR_IC705: Byte = 0xA4.toByte()
/** Default CI-V address of the controller (us). */
const val ADDR_CTRL: Byte = 0xE0.toByte()
// ── Command bytes ──────────────────────────────────────────────────────
/** Read operating frequency (main VFO). */
const val CMD_READ_FREQ: Byte = 0x03
/** Set operating frequency (main VFO). */
const val CMD_SET_FREQ: Byte = 0x05
/** Set operating mode. */
const val CMD_SET_MODE: Byte = 0x06
/** Select VFO / memory. */
const val CMD_SELECT_VFO: Byte = 0x07
/**
* Select operating mode (VFO vs memory-channel).
* Sub 0x00 = VFO mode. Must be sent after connect if the radio is in
* memory-channel mode — frequency/mode commands return FA until it is.
*/
const val CMD_SELECT_OP_MODE: Byte = 0x08
/** Set repeater duplex / SPLIT. */
const val CMD_DUPLEX_SPLIT: Byte = 0x0F
/** Band stacking register / band select (sub 0x00 = select, data = BCD band number). */
const val CMD_BAND_SELECT: Byte = 0x1A
/** Read/write CTCSS tone frequency. */
const val CMD_CTCSS_TONE: Byte = 0x1B
/** Read/write misc settings (used for enabling CTCSS encode). */
const val CMD_MISC_SETTING: Byte = 0x16
/** Read/write selected-VFO frequency (cmd 0x25). */
const val CMD_SELECTED_VFO_FREQ: Byte = 0x25
// ── Sub-command bytes ──────────────────────────────────────────────────
/** Sub for CMD_SELECT_VFO: select VFO-A (main). */
const val SUB_VFO_A: Byte = 0x00
/** Sub for CMD_SELECT_VFO: select VFO-B (sub). */
const val SUB_VFO_B: Byte = 0x01
/** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */
const val SUB_SPLIT_OFF: Byte = 0x00
/** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */
const val SUB_SPLIT_ON: Byte = 0x01
/** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */
const val SUB_SELECTED_VFO: Byte = 0x00
/** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */
const val SUB_UNSELECTED_VFO: Byte = 0x01
/** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */
const val SUB_CTCSS_SETTING: Byte = 0x42.toByte()
// ── Mode bytes ────────────────────────────────────────────────────────
/** Maps mode strings (upper-case) → IC-705 mode bytes. */
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
"LSB" to 0x00,
"USB" to 0x01,
"AM" to 0x02,
"CW" to 0x03,
"RTTY" to 0x04,
"FM" to 0x05,
"WFM" to 0x06,
"CW-R" to 0x07,
"RTTY-R" to 0x08,
"DV" to 0x12,
"AFSK" to 0x05 // AFSK uses FM modulation
)
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
// ── Frequency BCD encoding ─────────────────────────────────────────────
/**
* Encode a frequency in Hz to the IC-705's 5-byte BCD format.
*
* The IC-705 uses 5 bytes, LSB pair first, with 1 Hz resolution.
* Example: 145,500,000 Hz → "0145500000" → pairs LSB→MSB:
* [00, 00, 50, 45, 01]
*/
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
val digits = String.format(Locale.US, "%010d", frequencyHz)
val bcd = ByteArray(5)
for (i in 0 until 5) {
// digits are MSB first; we want pair index 0 = LSB pair
val pairIndex = 4 - i
val high = digits[pairIndex * 2] - '0'
val low = digits[pairIndex * 2 + 1] - '0'
bcd[i] = ((high shl 4) or low).toByte()
}
return bcd
}
/**
* Decode 5-byte BCD frequency (LSB pair first) to Hz.
*/
fun decodeFrequencyBcd(bcd: ByteArray): Long {
// Build digit string MSB→LSB by reversing the byte order
var freqHz = 0L
for (i in 4 downTo 0) {
val b = bcd[i].toInt() and 0xFF
val high = b shr 4
val low = b and 0x0F
freqHz = freqHz * 100 + high * 10 + low
}
return freqHz
}
/**
* Encode a CTCSS tone (Hz, e.g. 67.0) to 2-byte BCD (0.1 Hz resolution).
* 67.0 → 670 (tenths of Hz) → BCD bytes [0x06, 0x70].
*/
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
val tone01 = (toneHz * 10).toLong()
val digits = String.format(Locale.US, "%04d", tone01)
return byteArrayOf(
((digits[0] - '0') shl 4 or (digits[1] - '0')).toByte(),
((digits[2] - '0') shl 4 or (digits[3] - '0')).toByte()
)
}
// ── Message builders ───────────────────────────────────────────────────
/** Wrap payload bytes in a CI-V frame: FE FE DEST SRC ... FD. */
private fun frame(vararg payload: Byte): ByteArray {
return byteArrayOf(PREAMBLE, PREAMBLE, ADDR_IC705, ADDR_CTRL) +
payload +
byteArrayOf(END_OF_MSG)
}
/** Set operating frequency via CMD 0x05 (main VFO). */
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
return frame(CMD_SET_FREQ, *encodeFrequencyBcd(frequencyHz))
}
/**
* Set selected-VFO frequency via CMD 0x25 sub 0x00.
* This updates whichever VFO is currently active (RX or TX after split).
*/
fun buildSetWorkingFreqCommand(frequencyHz: Long): ByteArray {
return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
}
/**
* Set unselected-VFO frequency via CMD 0x25 sub 0x01.
* In split mode while PTT is pressed the IC-705 makes VFO-B active, so
* this command targets VFO-A (the RX VFO) — and vice-versa when in RX.
* Use this to update the TX VFO when PTT is on.
*/
fun buildSetUnselectedVfoFreqCommand(frequencyHz: Long): ByteArray {
return frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
}
/** Read operating frequency (CMD 0x03). */
fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ)
/** Read selected (active) VFO frequency (CMD 0x25 sub 0x00). */
fun buildReadWorkingFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO)
/** Read unselected (inactive/TX in split) VFO frequency (CMD 0x25 sub 0x01). */
fun buildReadTxVfoFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO)
/**
* Select band via CMD 0x1A sub 0x00.
* Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm.
* Returns null if [frequencyHz] doesn't fall in a known amateur band.
*/
fun buildBandSelectCommand(frequencyHz: Long): ByteArray? {
val code = bandCodeForFrequency(frequencyHz) ?: return null
return frame(CMD_BAND_SELECT, 0x00, code)
}
/**
* Map a frequency in Hz to the IC-705 band stacking register code.
* Codes are BCD (band number in decimal expressed as hex nibbles).
*/
fun bandCodeForFrequency(frequencyHz: Long): Byte? = when {
frequencyHz in 1_800_000L ..1_999_999L -> 0x01 // 160 m
frequencyHz in 3_500_000L ..3_999_999L -> 0x02 // 80 m
frequencyHz in 7_000_000L ..7_299_999L -> 0x03 // 40 m
frequencyHz in 10_100_000L ..10_149_999L -> 0x04 // 30 m
frequencyHz in 14_000_000L ..14_349_999L -> 0x05 // 20 m
frequencyHz in 18_068_000L ..18_167_999L -> 0x06 // 17 m
frequencyHz in 21_000_000L ..21_449_999L -> 0x07 // 15 m
frequencyHz in 24_890_000L ..24_989_999L -> 0x08 // 12 m
frequencyHz in 28_000_000L ..29_699_999L -> 0x09 // 10 m
frequencyHz in 50_000_000L ..53_999_999L -> 0x10 // 6 m (BCD 10)
frequencyHz in 144_000_000L ..147_999_999L -> 0x11 // 2 m (BCD 11)
frequencyHz in 420_000_000L ..449_999_999L -> 0x12 // 70 cm (BCD 12)
frequencyHz in 1_240_000_000L ..1_299_999_999L -> 0x13 // 23 cm (BCD 13)
else -> null
}
/** Set operating mode (CMD 0x06). Filter byte is omitted — radio uses its default filter for the mode. */
fun buildSetModeCommand(mode: String): ByteArray? {
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
return frame(CMD_SET_MODE, modeByte)
}
/** Select VFO-A (CMD 0x07 sub 0x00). */
fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A)
/** Select VFO-B (CMD 0x07 sub 0x01). */
fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B)
/**
* Enter VFO operating mode (CMD 0x08 sub 0x00).
* Sent after connect — if the radio is in memory-channel mode frequency
* and mode commands return FA until this is issued.
*/
fun buildEnterVfoModeCommand(): ByteArray = frame(CMD_SELECT_OP_MODE, 0x00)
/** Enable or disable SPLIT mode (CMD 0x0F). */
fun buildSplitModeCommand(enable: Boolean): ByteArray {
val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF
return frame(CMD_DUPLEX_SPLIT, sub)
}
/**
* Enable/disable CTCSS encode (CMD 0x16 sub 0x42).
* 0x01 = CTCSS encoder ON, 0x00 = OFF.
*/
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
val value: Byte = if (enabled) 0x01 else 0x00
return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value)
}
/**
* Set CTCSS tone frequency (CMD 0x1B sub 0x00).
*/
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
val bcd = encodeCtcssToneBcd(toneHz)
return frame(CMD_CTCSS_TONE, 0x00, *bcd)
}
// ── Response parsing ───────────────────────────────────────────────────
/**
* Find and parse a complete CI-V response frame from a buffer.
*
* Returns the bytes between "FE FE E0 A4 <CMD>" and FD, or null if no
* complete frame was found. The search is tolerant of interleaved
* broadcast traffic.
*
* @param buf bytes accumulated from the radio
* @param expectCmd the command byte we are looking for in the reply, or
* null to accept any command response from the radio
*/
fun parseResponse(buf: ByteArray, expectCmd: Byte?): ParsedResponse? {
var i = 0
while (i < buf.size - 5) {
// Look for FE FE preamble
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
val dest = buf[i + 2]
val src = buf[i + 3]
val cmd = buf[i + 4]
// We only care about frames addressed to us from the radio
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
// Find the terminating FD
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
if (fdIdx < 0) break // incomplete frame, wait for more data
val payload = buf.copyOfRange(i + 5, fdIdx)
if (expectCmd == null || cmd == expectCmd) {
return ParsedResponse(cmd, payload, fdIdx + 1)
}
i = fdIdx + 1
}
return null
}
private fun ByteArray.indexOf(b: Byte, startIndex: Int): Int {
for (k in startIndex until size) if (this[k] == b) return k
return -1
}
/**
* Check whether a buffer contains an OK acknowledgement (FB FD) from
* the radio. Tolerates broadcast noise before the ACK.
*/
fun containsAck(buf: ByteArray): Boolean {
var i = 0
while (i < buf.size - 5) {
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
val dest = buf[i + 2]
val src = buf[i + 3]
val cmd = buf[i + 4]
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
// Skip to FD
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
if (fdIdx < 0) break
if (cmd == ACK_OK) return true
if (cmd == ACK_NG) return false
i = fdIdx + 1
}
return false
}
/**
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
*/
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
if (payload.size < 6) return null
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
val mode = BYTE_TO_MODE[payload[5]] ?: return null
return freqHz to mode
}
/** Hex dump of bytes, useful for debug logging. */
fun toHex(bytes: ByteArray): String =
bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) }
data class ParsedResponse(
val cmd: Byte,
val payload: ByteArray,
/** Index in the source buffer immediately after the FD terminator. */
val nextOffset: Int
)
}
@@ -17,11 +17,8 @@
*/
package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.domain.repository.BtService
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -29,89 +26,94 @@ import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.channels.SocketChannel
enum class NetworkService { ROTATOR, FREQUENCY }
class NetworkReporter(
private val reporterScope: CoroutineScope,
private val rotatorServer: String,
private val rotatorPort: Int,
private val frequencyServer: String,
private val frequencyPort: Int
) : IReporter {
data class SocketConnection(
var socket: SocketChannel? = null,
var connected: Boolean = false,
var connecting: Boolean = false,
var connectionJob: Job? = null
)
class NetworkReporter(private val reporterScope: CoroutineScope) : IReporterRepo<ExtendedParams> {
private val serviceToAddress = mutableMapOf<NetworkService, String>()
private val connections = mutableMapOf<String, SocketConnection>()
private val writeMutex = Mutex()
fun isConnected(service: NetworkService): Boolean {
val addr = serviceToAddress[service] ?: return false
return connections[addr]?.connected == true
private var rotatorSocket: SocketChannel? = null
private var rotatorConnected = false
private var rotatorConnecting = false
private var frequencySocket: SocketChannel? = null
private var frequencyConnected = false
private var frequencyConnecting = false
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
reporterScope.launch {
ensureRotatorConnected()
if (!rotatorConnected) return@launch
val el = if (elevation > 0.0) elevation else 0.0
val command = format
.replace($$"$AZ", azimuth.toString())
.replace($$"$EL", el.toString())
.unescapeControlChars()
write(rotatorSocket, command) { rotatorConnected = false }
}
}
private fun getOrCreateConnection(addr: String): SocketConnection {
return connections.getOrPut(addr) { SocketConnection() }
override fun reportFrequency(format: String, frequency: Long) {
reporterScope.launch {
ensureFrequencyConnected()
if (!frequencyConnected) return@launch
val command = format
.replace($$"$FREQ", frequency.toString())
.unescapeControlChars()
write(frequencySocket, command) { frequencyConnected = false }
}
}
fun connect(service: NetworkService, server: String, port: Int) {
val addr = "$server:$port"
serviceToAddress[service] = addr
val connection = getOrCreateConnection(addr)
if (connection.connected || connection.connecting) return
connection.connectionJob = reporterScope.launch {
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
reporterScope.launch {
try {
connection.connecting = true
val socket = SocketChannel.open(InetSocketAddress(server, port))
connection.socket = socket
connection.connected = true
println("NetworkReporter: $service connected to $addr")
rotatorConnecting = true
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) {
println("NetworkReporter connect error: ${e.message}")
connection.connected = false
println("NetworkReporter rotator connect error: ${e.message}")
rotatorConnected = false
} finally {
connection.connecting = false
rotatorConnecting = false
}
}
}
private suspend fun write(service: NetworkService, command: String) {
val addr = serviceToAddress[service] ?: return
val connection = connections[addr] ?: return
if (!connection.connected) return
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
reporterScope.launch {
try {
frequencyConnecting = true
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}")
frequencyConnected = false
} finally {
frequencyConnecting = false
}
}
}
private suspend fun write(socket: SocketChannel?, command: String, onError: () -> Unit) {
try {
writeMutex.withLock {
val buffer = ByteBuffer.wrap("\\$command\n".toByteArray())
connection.socket?.write(buffer)
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
socket?.write(buffer)
}
} catch (e: Exception) {
println("NetworkReporter write error: ${e.message}")
connection.connected = false
onError()
}
}
override fun isConnected(service: BtService): Boolean = false
override fun isConnecting(service: BtService): Boolean = false
override fun connect(service: BtService, deviceId: String) {}
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: ExtendedParams) {
reporterScope.launch {
connect(NetworkService.ROTATOR, params.server, params.port)
if (!isConnected(NetworkService.ROTATOR)) return@launch
val el = if (elevation > 0.0) elevation else 0.0
val command = format
.replace("\$AZ", azimuth.toString())
.replace("\$EL", el.toString())
write(NetworkService.ROTATOR, command)
}
}
override fun reportFrequency(format: String, frequency: Long, params: ExtendedParams) {
reporterScope.launch {
connect(NetworkService.FREQUENCY, params.server, params.port)
if (!isConnected(NetworkService.FREQUENCY)) return@launch
val command = format.replace("\$FREQ", frequency.toString())
write(NetworkService.FREQUENCY, command)
}
}
private fun String.unescapeControlChars(): String =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
}
@@ -0,0 +1,556 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager
import android.util.Log
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class RadioTrackingService(
private val appScope: CoroutineScope,
private val bluetoothManager: BluetoothManager,
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo
) : IRadioTrackingService {
private val tag = "RadioTracking"
/** Delay between each step of the split-mode init sequence (ms). */
private val INIT_STEP_DELAY_MS = 200L
private val _state = MutableStateFlow(RadioTrackingState())
override val state: StateFlow<RadioTrackingState> = _state
private var txController: IRadioController? = null
private var rxController: IRadioController? = null
private var trackingJob: Job? = null
// ── Connection ──────────────────────────────────────────────────────────
override suspend fun connectRadios() {
txController?.disconnect()
rxController?.disconnect()
val rcSettings = settingsRepo.radioControlSettings.value
val txAddr = rcSettings.txRadioAddress
val rxAddr = rcSettings.rxRadioAddress
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
val isSplit = isIcom && rcSettings.splitMode
Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
if (isSplit) {
// Single-radio split mode: only TX slot is used
if (txAddr.isBlank()) {
_state.update { it.copy(errorMessage = "No radio address configured in Settings") }
return
}
val tx = makeController(isIcom, txAddr)
txController = tx
rxController = null
_state.update { it.copy(errorMessage = null) }
val txOk = tx.connect()
_state.update {
it.copy(
txConnected = txOk,
rxConnected = false,
errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null
)
}
Log.i(tag, "IC-705 split mode connected: txOk=$txOk")
} else {
if (txAddr.isBlank() && rxAddr.isBlank()) {
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
return
}
val tx = makeController(isIcom, txAddr)
val rx = makeController(isIcom, rxAddr)
txController = tx
rxController = rx
_state.update { it.copy(errorMessage = null) }
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
_state.update {
it.copy(
txConnected = txOk,
rxConnected = rxOk,
errorMessage = when {
!txOk && !rxOk -> "Could not connect to TX and RX radios"
!txOk -> "Could not connect to TX radio ($txAddr)"
!rxOk -> "Could not connect to RX radio ($rxAddr)"
else -> null
}
)
}
Log.i(tag, "Dual-radio connected: txOk=$txOk rxOk=$rxOk")
}
}
private fun makeController(isIcom: Boolean, address: String): IRadioController =
if (isIcom) Ic705Controller(bluetoothManager, address)
else Ft817Controller(bluetoothManager, address)
override suspend fun disconnectRadios() {
stopTracking()
txController?.disconnect()
rxController?.disconnect()
txController = null
rxController = null
_state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
}
// ── Tracking ────────────────────────────────────────────────────────────
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
_state.update {
it.copy(
isActive = true,
currentPass = pass,
selectedTransponder = transponder,
txBaseFrequencyHz = txBaseFreqHz
)
}
trackingJob?.cancel()
val rcSettings = settingsRepo.radioControlSettings.value
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
val isSplit = isIcom && rcSettings.splitMode
if (isSplit) {
trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) }
} else {
trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) }
}
}
// ── Dual-radio tracking (Yaesu or two IC-705s) ──────────────────────────
private suspend fun runDualRadioTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
val tx = txController
val rx = rxController
// Initial setup: set band/mode/CTCSS on both radios
val txMode = transponder.uplinkMode
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
if (tx != null && tx.isConnected && txMode != null) {
Log.d(tag, "Setting TX mode: $txMode")
tx.setMode(txMode)
}
if (rx != null && rx.isConnected && rxMode != null) {
Log.d(tag, "Setting RX mode: $rxMode")
rx.setMode(rxMode)
}
if (txMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone ->
Log.d(tag, "Setting CTCSS: ${tone}Hz")
tx?.setCtcssTone(tone)
tx?.setCtcssMode(true)
}
}
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
var lastSetTxFreq = 0.0
var lastSetRxFreq = 0.0
var tuningRadio = ""
var lastReadFreq = 0L
var stableCount = 0
while (currentCoroutineContext().isActive) {
val currentState = _state.value
if (!currentState.isActive) break
val satPass = currentState.currentPass ?: break
val xpdr = currentState.selectedTransponder ?: break
var txBaseFreq = currentState.txBaseFrequencyHz
val stationPos = settingsRepo.stationPosition.value
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
val txNow = txController
val rxNow = rxController
val v = pos.distanceRate * 1000.0
if (tuningRadio.isNotEmpty()) {
val radio = if (tuningRadio == "tx") txNow else rxNow
if (radio != null && radio.isConnected) {
val read = radio.readFrequencyAndMode()
if (read != null) {
val (freq, _) = read
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
else { stableCount = 0; lastReadFreq = freq }
if (stableCount >= 2) {
if (tuningRadio == "tx" && txBaseFreq != null) {
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
if (newBase > 0) {
txBaseFreq = newBase
_state.update { it.copy(txBaseFrequencyHz = newBase) }
Log.i(tag, "TX tuning done → base=$newBase")
}
} else if (tuningRadio == "rx") {
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
if (newTxBase != null && newTxBase > 0) {
txBaseFreq = newTxBase
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
Log.i(tag, "RX tuning done → txBase=$newTxBase")
}
}
tuningRadio = ""
stableCount = 0
lastSetTxFreq = 0.0
lastSetRxFreq = 0.0
}
}
}
} else {
// Detect manual dial changes
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
val read = txNow.readFrequencyAndMode()
if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
tuningRadio = "tx"
lastReadFreq = read.first
stableCount = 0
Log.i(tag, "TX tuning detected (read=${read.first}, lastSet=$lastSetTxFreq)")
}
}
if (tuningRadio.isEmpty() && rxNow != null && rxNow.isConnected && lastSetRxFreq > 0.0) {
val read = rxNow.readFrequencyAndMode()
if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
tuningRadio = "rx"
lastReadFreq = read.first
stableCount = 0
Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
}
}
}
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
val rxBaseFreq = if (txBaseFreq != null) {
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
} else xpdr.downlinkLow
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
if (tuningRadio.isEmpty()) {
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
txNow.setFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
rxNow.setFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
}
}
_state.update {
it.copy(
txConnected = txNow?.isConnected ?: false,
rxConnected = rxNow?.isConnected ?: false,
txFrequencyHz = txRadioFreq,
rxFrequencyHz = rxRadioFreq,
azimuth = Math.toDegrees(pos.azimuth),
elevation = Math.toDegrees(pos.elevation),
distance = pos.distance
)
}
delay(1000)
}
}
// ── IC-705 split-radio tracking ─────────────────────────────────────────
private suspend fun runSplitTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
val radio = txController ?: return
if (!radio.isConnected) return
val txMode = transponder.uplinkMode
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
// Compute nominal base frequencies
val txCenter = when {
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
transponder.uplinkLow != null -> transponder.uplinkLow!!
else -> null
}
val rxNominal = if (txCenter != null) {
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
} else transponder.downlinkLow
val txBase = initialTxBaseFreqHz ?: txCenter
Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode")
// ── Initial setup sequence ──────────────────────────────────────────
// Sequence per IC-705: explicitly select VFO, then band → freq → mode.
// ACK from each command gates the next — no fixed delays needed.
// VFO-A = RX (downlink)
Log.d(tag, "Split init: selecting VFO-A for RX (downlink)")
radio.setVfo(vfoA = true)
if (rxNominal != null) {
Log.d(tag, "Split init: VFO-A band for ${rxNominal}Hz")
radio.setBand(rxNominal)
Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz")
radio.setFrequency(rxNominal)
}
if (rxMode != null) {
Log.d(tag, "Split init: VFO-A mode=$rxMode")
radio.setMode(rxMode)
}
// VFO-B = TX (uplink)
Log.d(tag, "Split init: selecting VFO-B for TX (uplink)")
radio.setVfo(vfoA = false)
if (txBase != null) {
Log.d(tag, "Split init: VFO-B band for ${txBase}Hz")
radio.setBand(txBase)
Log.d(tag, "Split init: VFO-B freq=${txBase}Hz")
radio.setFrequency(txBase)
}
if (txMode != null) {
Log.d(tag, "Split init: VFO-B mode=$txMode")
radio.setMode(txMode)
}
if (txMode?.uppercase() == "FM") {
val tone = _state.value.ctcssTone
if (tone != null) {
Log.d(tag, "Split init: CTCSS=${tone}Hz")
radio.setCtcssTone(tone)
radio.setCtcssMode(true)
} else {
radio.setCtcssMode(false)
}
}
// Enable SPLIT on VFO-A (return display to RX VFO first)
Log.d(tag, "Split init: returning to VFO-A, then enabling SPLIT mode")
radio.setVfo(vfoA = true)
radio.setSplitMode(enabled = true)
_state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) }
Log.i(tag, "IC-705 split init done — entering tracking loop")
// ── Tracking loop with tuning detection ─────────────────────────────
var lastSetTxFreq = 0.0
var lastSetRxFreq = 0.0
var tuningRadio = "" // "tx" or "rx" when manual tuning detected
var lastReadFreq = 0L
var stableCount = 0
while (currentCoroutineContext().isActive) {
val currentState = _state.value
if (!currentState.isActive) break
val satPass = currentState.currentPass ?: break
val xpdr = currentState.selectedTransponder ?: break
var txBaseFreq = currentState.txBaseFrequencyHz
val stationPos = settingsRepo.stationPosition.value
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
val v = pos.distanceRate * 1000.0
if (tuningRadio.isNotEmpty()) {
// User is tuning — wait for frequency to stabilize
val readFreq = if (tuningRadio == "tx") radio.readTxVfoFrequency() else radio.readWorkingFrequency()
if (readFreq != null) {
if (kotlin.math.abs(readFreq - lastReadFreq) <= 20) stableCount++
else { stableCount = 0; lastReadFreq = readFreq }
if (stableCount >= 2) {
// Frequency stable — reverse-calculate base frequency
if (tuningRadio == "tx" && txBaseFreq != null) {
val newBase = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
if (newBase > 0) {
txBaseFreq = newBase
_state.update { it.copy(txBaseFrequencyHz = newBase) }
Log.i(tag, "Split TX tuning done → base=$newBase")
}
} else if (tuningRadio == "rx") {
val rxNominal = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
if (newTxBase != null && newTxBase > 0) {
txBaseFreq = newTxBase
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
Log.i(tag, "Split RX tuning done → txBase=$newTxBase")
}
}
tuningRadio = ""
stableCount = 0
lastSetTxFreq = 0.0
lastSetRxFreq = 0.0
}
}
} else {
// Detect manual dial changes
if (txBaseFreq != null && lastSetTxFreq > 0.0) {
val readTx = radio.readTxVfoFrequency()
if (readTx != null && kotlin.math.abs(readTx - lastSetTxFreq) >= 20.0) {
tuningRadio = "tx"
lastReadFreq = readTx
stableCount = 0
Log.i(tag, "Split TX tuning detected (read=${readTx}, lastSet=$lastSetTxFreq)")
}
}
if (tuningRadio.isEmpty() && lastSetRxFreq > 0.0) {
val readRx = radio.readWorkingFrequency()
if (readRx != null && kotlin.math.abs(readRx - lastSetRxFreq) >= 20.0) {
tuningRadio = "rx"
lastReadFreq = readRx
stableCount = 0
Log.i(tag, "Split RX tuning detected (read=${readRx}, lastSet=$lastSetRxFreq)")
}
}
}
// Determine Doppler-corrected frequencies
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
val rxBaseCalc = if (txBaseFreq != null) {
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
} else xpdr.downlinkLow
val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) }
if (radio.isConnected && tuningRadio.isEmpty()) {
// Update both VFOs every cycle — no PTT polling needed.
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
if (rxRadioFreq != null) {
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
radio.setWorkingFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
}
if (txRadioFreq != null) {
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
radio.setTxVfoFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
}
_state.update {
it.copy(
txConnected = radio.isConnected,
rxConnected = false, // single radio
txFrequencyHz = txRadioFreq,
rxFrequencyHz = rxRadioFreq,
azimuth = Math.toDegrees(pos.azimuth),
elevation = Math.toDegrees(pos.elevation),
distance = pos.distance
)
}
delay(1000)
}
}
// ── Other IRadioTrackingService methods ─────────────────────────────────
override fun stopTracking() {
trackingJob?.cancel()
trackingJob = null
_state.update { it.copy(isActive = false) }
}
override fun setTransponder(transponder: SatRadio) {
appScope.launch {
val tx = txController
val rx = rxController
transponder.uplinkMode?.let { tx?.setMode(it) }
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
rxMode?.let { rx?.setMode(it) }
if (transponder.uplinkMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone ->
tx?.setCtcssTone(tone)
tx?.setCtcssMode(true)
}
}
}
val txCenter = when {
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
transponder.uplinkLow != null -> transponder.uplinkLow!!
else -> null
}
val rxNominal = if (txCenter != null) {
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
} else transponder.downlinkLow
_state.update {
it.copy(
selectedTransponder = transponder,
txBaseFrequencyHz = txCenter,
txFrequencyHz = txCenter,
rxFrequencyHz = rxNominal,
txMode = transponder.uplinkMode,
rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let { m ->
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
}
)
}
}
override fun setTxBaseFrequency(frequencyHz: Long) {
_state.update { it.copy(txBaseFrequencyHz = frequencyHz) }
}
override fun adjustTxBaseFrequency(deltaHz: Long) {
val current = _state.value.txBaseFrequencyHz ?: return
_state.update { it.copy(txBaseFrequencyHz = current + deltaHz) }
}
override fun setCtcssTone(toneHz: Double?) {
_state.update { it.copy(ctcssTone = toneHz) }
appScope.launch {
val tx = txController
if (toneHz != null) {
tx?.setCtcssTone(toneHz)
tx?.setCtcssMode(true)
} else {
tx?.setCtcssMode(false)
}
}
}
override fun setMode(txMode: String, rxMode: String) {
appScope.launch {
txController?.setMode(txMode)
rxController?.setMode(rxMode)
}
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
}
}
@@ -1,15 +1,35 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.injection
import android.bluetooth.BluetoothManager
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.location.LocationManager
import android.view.WindowManager
import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
@@ -18,61 +38,133 @@ import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams
import com.rtbishop.look4sat.core.domain.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.IReporterRepo
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
import com.rtbishop.look4sat.core.domain.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 provideBluetoothReporter(): IReporterRepo<WithoutExtParams> {
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
return BluetoothReporter(manager, CoroutineScope(Dispatchers.IO))
val rc = settingsRepo.rcSettings.value
return BluetoothReporter(
manager,
CoroutineScope(Dispatchers.IO),
rc.bluetoothRotatorAddress,
rc.bluetoothFrequencyAddress
)
}
override fun provideNetworkReporter(): IReporterRepo<ExtendedParams> {
return NetworkReporter(CoroutineScope(Dispatchers.IO))
override fun provideNetworkReporter(): IReporter {
val rc = settingsRepo.rcSettings.value
return NetworkReporter(
CoroutineScope(Dispatchers.IO),
rc.rotatorAddress,
rc.rotatorPort.toIntOrNull() ?: 0,
rc.frequencyAddress,
rc.frequencyPort.toIntOrNull() ?: 0
)
}
override fun provideTxRadioController(): IRadioController {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val settings = settingsRepo.radioControlSettings.value
val address = settings.txRadioAddress
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
Ic705Controller(manager, address)
} else {
Ft817Controller(manager, address)
}
}
override fun provideRxRadioController(): IRadioController {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val settings = settingsRepo.radioControlSettings.value
val address = settings.rxRadioAddress
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
Ic705Controller(manager, address)
} else {
Ft817Controller(manager, address)
}
}
override fun provideSensorsRepo(): ISensorsRepo {
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
return SensorsRepo(manager,sensor,window)
val displayManager = context.getSystemService(DisplayManager::class.java)
return SensorsRepo(manager, displayManager)
}
private fun provideDatabaseRepo(): IDatabaseRepo {
val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
}
@@ -83,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 {
@@ -99,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
}
}
}
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -28,7 +27,9 @@ import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.util.zip.ZipInputStream
class DatabaseRepo(
@@ -39,78 +40,65 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo
) : IDatabaseRepo {
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
val importedSatellites = remoteSource.getFileStream(uri)?.let { dataParser.parseTLEStream(it) }
importedSatellites?.let { localSource.insertEntries(it) }
setUpdateSuccessful(System.currentTimeMillis())
}
private val customSourceType = "Other"
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
val radios = remoteSource.getFileStream(uri)?.let { dataParser.parseJSONStream(it) }
radios?.let {
if(it.isNotEmpty()) {
localSource.deleteRadios()
localSource.insertRadios(it)
}
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
importedCount = entries.size
}
setUpdateSuccessful(System.currentTimeMillis())
importedCount
}
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers)
importedCount = transceivers.size
}
setUpdateSuccessful(System.currentTimeMillis())
importedCount
}
override suspend fun updateFromRemote() = withContext(dispatcher) {
val importedEntries = mutableListOf<OrbitalData>()
val importedRadios = mutableListOf<SatRadio>()
val tleUrls = Sources.satelliteDataUrls.toMutableMap().apply {
if (settingsRepo.dataSourcesSettings.value.useCustomTLE) {
this["Other"] = settingsRepo.dataSourcesSettings.value.tleUrl
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
// 开关开且 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.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 })
}
}
val jobsMap = tleUrls
.filter { (_, url) -> url.isNotEmpty() }
.mapValues { (_, url) -> async { remoteSource.getNetworkStream(url) } }
val radioUrls = buildList {
add(Sources.RADIO_DATA_URL)
if (settingsRepo.dataSourcesSettings.value.useCustomTransceivers) {
add(settingsRepo.dataSourcesSettings.value.transceiversUrl)
}
val importedRadios = radioResults.flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
}
val jobRadios = radioUrls.associateWith { url -> async { remoteSource.getNetworkStream(url) } }
// parse
jobsMap.mapValues { job -> job.value.await() }.forEach { entry ->
entry.value?.let { stream ->
when (val type = entry.key) {
"Amsat", "R4UAB", "Other" -> {
// parse tle stream
val satellites = dataParser.parseTLEStream(stream)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
"McCants", "Classified" -> {
// unzip and parse tle stream
val unzipped = ZipInputStream(stream).apply { nextEntry }
val satellites = dataParser.parseTLEStream(unzipped)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
else -> {
// parse csv stream
val satellites = dataParser.parseCSVStream(stream)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
}
}
}
jobRadios.values.forEach { job ->
job.await()?.let {
importedRadios.addAll(dataParser.parseJSONStream(it))
}
}
// insert
// insert parsed data into the database
localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis())
@@ -122,9 +110,39 @@ class DatabaseRepo(
setUpdateSuccessful(0L)
}
private suspend fun setUpdateSuccessful(timestamp: Long) = withContext(dispatcher) {
val numberOfRadios = localSource.getRadiosTotal()
val numberOfSatellites = localSource.getEntriesTotal()
settingsRepo.updateDatabaseState(DatabaseState(numberOfRadios, numberOfSatellites, timestamp))
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
val bufferedStream = stream.buffered()
return when {
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
else -> dataParser.parseTLEStream(bufferedStream)
}
}
private fun hasCsvHint(url: String): Boolean {
return url.contains("FORMAT=csv", ignoreCase = true) ||
url.endsWith(".csv", ignoreCase = true) ||
url.endsWith(".csv.zip", ignoreCase = true)
}
private fun looksLikeCsv(stream: InputStream): Boolean {
if (!stream.markSupported()) return false
stream.mark(4096)
val preview = ByteArray(4096)
val length = stream.read(preview)
stream.reset()
if (length <= 0) return false
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
return line.contains("OBJECT_NAME", ignoreCase = true) ||
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
line.count { it == ',' } >= 4
}
private suspend fun setUpdateSuccessful(timestamp: Long) {
settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
)
}
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
}
@@ -28,10 +28,15 @@ import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.TimeZone
class SatelliteRepo(
private val dispatcher: CoroutineDispatcher,
@@ -42,17 +47,34 @@ class SatelliteRepo(
private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList())
override val passes: StateFlow<List<OrbitalPass>> = _passes
private val _isCalculating = MutableStateFlow(false)
override val isCalculating: StateFlow<Boolean> = _isCalculating
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
private val _selectedPass = MutableStateFlow(0 to 0L)
override val selectedPass: StateFlow<Pair<Int, Long>> = _selectedPass
override fun selectPass(catNum: Int, aosTime: Long) {
_selectedPass.value = catNum to aosTime
}
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
val timeNow = 1000 * ((System.currentTimeMillis() + 500) / 1000)
calculatePasses(timeNow, hoursAhead, minElevation, modes)
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
)
}
}
@@ -62,7 +84,8 @@ class SatelliteRepo(
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> {
return withContext(dispatcher) {
val positions = mutableListOf<OrbitalPos>()
val estimatedSize = ((end - start) / 15000).toInt() + 1
val positions = ArrayList<OrbitalPos>(estimatedSize)
var currentTime = start
while (currentTime < end) {
positions.add(sat.getPosition(pos, currentTime))
@@ -72,73 +95,102 @@ 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)
val copiedList = radios.map { it.copy() }
copiedList.forEach { transmitter ->
transmitter.downlinkLow?.let { transmitter.downlinkLow = satPos.getDownlinkFreq(it) }
transmitter.downlinkHigh?.let { transmitter.downlinkHigh = satPos.getDownlinkFreq(it) }
transmitter.uplinkLow?.let { transmitter.uplinkLow = satPos.getUplinkFreq(it) }
transmitter.uplinkHigh?.let { transmitter.uplinkHigh = satPos.getUplinkFreq(it) }
radios.map { transmitter ->
transmitter.copy(
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
downlinkHigh = transmitter.downlinkHigh?.let { satPos.getDownlinkFreq(it) },
uplinkLow = transmitter.uplinkLow?.let { satPos.getUplinkFreq(it) },
uplinkHigh = transmitter.uplinkHigh?.let { satPos.getUplinkFreq(it) }
)
}
copiedList.map { it.copy() }
}
}
override suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
return withContext(dispatcher) {
passList.forEach { pass ->
if (!pass.isDeepSpace) {
val timeStart = pass.aosTime
if (time > timeStart) {
val deltaNow = time.minus(timeStart).toFloat()
val deltaTotal = pass.losTime.minus(timeStart).toFloat()
pass.progress = (deltaNow / deltaTotal).round(2)
override suspend fun calculatePasses(
time: Long,
hoursAhead: Int,
minElevation: Double,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
modes: List<String>
) {
_isCalculating.value = true
// Normalize to the start of the current minute so that coarse 60-second stepping
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
val normalizedTime = time / 60_000L * 60_000L
val currentSatellites = _satellites.value
withContext(dispatcher) {
val idsWithModes = localStorage.getIdsWithModes(modes)
val stationPos = settingsRepo.stationPosition.value
val filteredSatellites = if (idsWithModes.isEmpty()) {
currentSatellites
} else {
currentSatellites.filter { it.data.catnum in idsWithModes }
}
// Compute passes for each satellite in parallel
val passLists = coroutineScope {
filteredSatellites.map { satellite ->
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
}.awaitAll()
}
// Flatten and filter in a single pass
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
val newPasses = ArrayList<OrbitalPass>()
for (list in passLists) {
for (pass in list) {
if (
pass.losTime > time
&& pass.aosTime < timeFuture
&& pass.maxElevation > minElevation
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
) {
newPasses.add(pass)
}
}
}
passList.filter { pass -> pass.progress < 1.0 }.map { it.copy() }
newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
_passes.update { newPasses }
}
_isCalculating.value = false
}
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
if (_satellites.value.isNotEmpty()) {
withContext(dispatcher) {
val newPasses = mutableListOf<OrbitalPass>()
val idsWithModes = localStorage.getIdsWithModes(modes)
_satellites.value.forEach { satellite ->
if (idsWithModes.isEmpty() || satellite.data.catnum in idsWithModes) {
newPasses.addAll(satellite.getPasses(settingsRepo.stationPosition.value, time, hoursAhead))
}
}
_passes.update { newPasses.filter(time, hoursAhead, minElevation) }
}
private fun isAosInRange(
aosTime: Long,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean
): Boolean {
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
val aosMinute = (localMillis / 60_000L).toInt()
val inRange = if (aosStartMinute <= aosEndMinute) {
aosMinute in aosStartMinute..aosEndMinute
} else {
_passes.update { emptyList() }
aosMinute >= 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
@@ -149,94 +201,84 @@ class SatelliteRepo(
return passes
}
private fun List<OrbitalPass>.filter(time: Long, hoursAhead: Int, minElev: Double): List<OrbitalPass> {
val timeFuture = time + (hoursAhead * 60L * 60L * 1000L)
return this.filter { it.losTime > time }.filter { it.aosTime < timeFuture }
.filter { it.maxElevation > minElev }.sortedBy { it.aosTime }
}
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass {
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, decayed: Boolean): OrbitalPass {
val satPos = sat.getPosition(pos, time)
val aos = time - 24 * 60L * 60L * 1000L
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
val az = satPos.azimuth.toDegrees().round(1)
val elev = satPos.elevation.toDegrees().round(1)
val alt = satPos.altitude
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat)
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat, hasDecayed = decayed)
}
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean): OrbitalPass {
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean, decayed: Boolean): OrbitalPass {
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
var calendarTimeMillis = time
var elevation: Double
var maxElevation = 0.0
var alt = 0.0 // var tcaAz = 0.0
// rewind 1/4 of an orbit
if (rewind) calendarTimeMillis += -quarterOrbitMin * 60L * 1000L
if (rewind) calendarTimeMillis -= quarterOrbitMin * 60L * 1000L
var satPos = sat.getPosition(pos, calendarTimeMillis)
if (satPos.elevation > 0.0) {
// Use lightweight elevation check for coarse searching
if (sat.getElevation(pos, calendarTimeMillis) > 0.0) {
// move forward in 30 second intervals until the sat goes below the horizon
do {
calendarTimeMillis += 30 * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
} while (satPos.elevation > 0.0)
} while (sat.getElevation(pos, calendarTimeMillis) > 0.0)
// move forward 3/4 of an orbit
calendarTimeMillis += quarterOrbitMin * 3 * 60L * 1000L
}
// find the next time sat comes above the horizon
// find the next time sat comes above the horizon (coarse: 60s steps)
do {
calendarTimeMillis += 60L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation < 0.0)
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// refine to 1 second
calendarTimeMillis += -60L * 1000L
do {
calendarTimeMillis += 1L * 500L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation < 0.0)
// 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
val aos = 1000 * ((satPos.time + 500) / 1000)
val aosAz = satPos.azimuth.toDegrees().round(1)
// Get full position for AOS data (azimuth, altitude)
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
val aos = 1000 * ((aosPos.time + 500) / 1000)
val aosAz = aosPos.azimuth.toDegrees().round(1)
// find when sat goes below
// find when sat goes below (coarse: 30s steps)
do {
calendarTimeMillis += 30L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation > 0.0)
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// refine to 1 second
calendarTimeMillis += -30L * 1000L
do {
calendarTimeMillis += 1L * 500L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation > 0.0)
// 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)
val los = 1000 * ((losPos.time + 500) / 1000)
val losAz = losPos.azimuth.toDegrees().round(1)
// Get altitude at approximate TCA (max elevation)
val tcaTime = (aos + los) / 2
val tcaPos = sat.getFullPosition(pos, tcaTime)
val alt = tcaPos.altitude
val los = 1000 * ((satPos.time + 500) / 1000) // val tca = (aos + los) / 2
val losAz = satPos.azimuth.toDegrees().round(1)
val elev = maxElevation.toDegrees().round(1)
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat)
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat, hasDecayed = decayed)
}
}
@@ -40,11 +40,23 @@ class SelectionRepo(
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
private val currentQuery = MutableStateFlow("")
// Resolve type IDs once when types change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
currentItems.map { items -> items.filterByTypes(types) }
val catnumSet: Set<Int>? = if (types.isEmpty()) {
null // null = no filtering
} else {
val ids = settingsRepo.getSatelliteTypesIds(types)
if (ids.isEmpty()) null else ids.toHashSet()
}
currentItems.map { items ->
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
}
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> items.filterByQuery(query) }
itemsWithTypes.map { items -> filterByQuery(items, query) }
}
override fun getCurrentTypes() = currentTypes.value
@@ -54,7 +66,7 @@ class SelectionRepo(
}
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
currentItems.value = localSource.getEntriesList().map { item ->
item.copy(isSelected = item.catnum in selectedIds)
}
@@ -66,17 +78,19 @@ class SelectionRepo(
settingsRepo.setSelectedTypes(types)
}
override suspend fun setQuery(query: String) = withContext(dispatcher) {
override suspend fun setQuery(query: String) {
currentQuery.value = query
}
override suspend fun setSelection(selectAll: Boolean) = withContext(dispatcher) {
setSelection(itemsWithQuery.first().map { item -> item.catnum }, selectAll)
val visibleIds = itemsWithQuery.first().mapTo(HashSet()) { it.catnum }
setSelection(visibleIds, selectAll)
}
override suspend fun setSelection(ids: List<Int>, isTicked: Boolean) = withContext(dispatcher) {
val idSet = ids.toHashSet()
currentItems.value = currentItems.value.map { item ->
if (item.catnum in ids) item.copy(isSelected = isTicked) else item
if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
}
}
@@ -85,19 +99,24 @@ class SelectionRepo(
settingsRepo.setSelectedIds(currentSelection)
}
private suspend fun List<SatItem>.filterByTypes(types: List<String>) = withContext(dispatcher) {
if (types.isEmpty()) return@withContext this@filterByTypes
val catnums = settingsRepo.getSatelliteTypesIds(types)
if (catnums.isEmpty()) return@withContext this@filterByTypes
return@withContext this@filterByTypes.filter { item -> item.catnum in catnums }
}
private suspend fun List<SatItem>.filterByQuery(query: String) = withContext(dispatcher) {
if (query.isBlank()) return@withContext this@filterByQuery
return@withContext try {
this@filterByQuery.filter { it.catnum == query.toInt() }
} catch (_: Exception) {
this@filterByQuery.filter { item -> item.name.lowercase().contains(query.lowercase()) }
/**
* Bulk selection using a pre-built Set for O(1) lookups.
*/
private suspend fun setSelection(idSet: Set<Int>, isTicked: Boolean) = withContext(dispatcher) {
currentItems.value = currentItems.value.map { item ->
if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
}
}
/**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* and lowercases the query once up front instead of per-item.
*/
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
if (query.isBlank()) return items
val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase()
return items.filter { it.name.lowercase().contains(lowerQuery) }
}
}
@@ -22,8 +22,9 @@ import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.view.Display
import android.view.Surface
import android.view.WindowManager
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
@@ -31,82 +32,101 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.math.round
private const val SMOOTHING_FACTOR = 0.15f
private const val SENSOR_RATE_US = 16_000
class SensorsRepo(
private val sensorManager: SensorManager,
private val sensor: Sensor?,
private val windowManager: WindowManager
) :
SensorEventListener, ISensorsRepo {
private val displayManager: DisplayManager?
) : ISensorsRepo, SensorEventListener {
private val _orientation = MutableStateFlow(Pair(0f, 0f))
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
private val rotationMatrix = FloatArray(9)
private val tempMatrix = FloatArray(9)
private val orientationValues = FloatArray(3)
private var sensorAccuracy = SensorManager.SENSOR_STATUS_UNRELIABLE
private var smoothAzimuth = 0f
private var smoothPitch = 0f
private var hasInitialReading = false
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
val latitude = geoPos.latitude.toFloat()
val longitude = geoPos.longitude.toFloat()
return GeomagneticField(latitude, longitude, geoPos.altitude.toFloat(), time).declination
return GeomagneticField(
geoPos.latitude.toFloat(),
geoPos.longitude.toFloat(),
geoPos.altitude.toFloat(),
time
).declination
}
override fun enableSensor() {
sensor?.let { sensorManager.registerListener(this, it, 8000) }
hasInitialReading = false
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
}
override fun disableSensor() = sensorManager.unregisterListener(this)
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
sensorAccuracy = accuracy
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
override fun onSensorChanged(event: SensorEvent) = when {
sensorAccuracy == SensorManager.SENSOR_STATUS_UNRELIABLE -> {}
event.sensor == sensor -> updateOrientation(event.values)
else -> {}
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
}
private fun getDisplayRotation(): Int {
return try {
@Suppress("DEPRECATION")
windowManager.defaultDisplay.rotation
} catch (e: Exception) {
Surface.ROTATION_0
}
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
}
private fun transformRotationMatrix(rotationMatrix: FloatArray, rotation: Int) {
val tempMatrix = FloatArray(9)
when (rotation) {
Surface.ROTATION_0 -> {
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_X, SensorManager.AXIS_Y, tempMatrix)
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
}
Surface.ROTATION_90 -> {
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix)
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
}
Surface.ROTATION_180 -> {
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix)
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
}
Surface.ROTATION_270 -> {
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix)
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
}
private fun remapForRotation(rotation: Int) {
val remapped = when (rotation) {
Surface.ROTATION_90 -> SensorManager.remapCoordinateSystem(
rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix
)
Surface.ROTATION_180 -> SensorManager.remapCoordinateSystem(
rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix
)
Surface.ROTATION_270 -> SensorManager.remapCoordinateSystem(
rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix
)
else -> false
}
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
}
private fun updateOrientation(rotationVector: FloatArray) {
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
transformRotationMatrix(rotationMatrix, getDisplayRotation())
private fun handleSensorEvent(event: SensorEvent) {
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
remapForRotation(getDisplayRotation())
SensorManager.getOrientation(rotationMatrix, orientationValues)
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
val pitch = (orientationValues[1] * RAD2DEG).toFloat() // roll [2]
val magneticAzimuth = (azimuth + 360f) % 360f
val roundedAzimuth = round(magneticAzimuth * 10) / 10
val roundedPitch = round(pitch * 10) / 10
_orientation.value = Pair(roundedAzimuth, roundedPitch)
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
if (!hasInitialReading) {
smoothAzimuth = azimuth
smoothPitch = pitch
hasInitialReading = true
} else {
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
smoothPitch = lowPass(smoothPitch, pitch)
}
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
}
private fun lowPass(previous: Float, current: Float): Float {
return previous + SMOOTHING_FACTOR * (current - previous)
}
/**
* Low-pass filter that accounts for the 0°/360° wraparound.
* Always takes the shortest angular path between the two values.
*/
private fun lowPassAngle(previous: Float, current: Float): Float {
var delta = current - previous
while (delta > 180f) delta -= 360f
while (delta <= -180f) delta += 360f
return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
}
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
}
@@ -28,6 +28,8 @@ 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.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
@@ -38,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
@@ -50,8 +53,12 @@ class SettingsRepo(
private val keyBluetoothFrequencyState = "bluetoothFrequencyState"
private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress"
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
private val keyFilterHoursAhead = "filterHoursAhead"
private val keyFilterMinElevation = "filterMinElevation"
private val keyFilterAosStartMinute = "filterAosStartMinute"
private val keyFilterAosEndMinute = "filterAosEndMinute"
private val keyFilterAosInvert = "filterAosInvert"
private val keyNumberOfRadios = "numberOfRadios"
private val keyNumberOfSatellites = "numberOfSatellites"
private val keyRotatorAddress = "rotatorAddress"
@@ -70,6 +77,9 @@ class SettingsRepo(
private val keyStateOfSweep = "stateOfSweep"
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"
@@ -78,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
@@ -109,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
@@ -120,18 +138,34 @@ class SettingsRepo(
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
putInt(keyFilterHoursAhead, settings.hoursAhead)
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
_passesSettings.value = settings
}
private fun getPassesSettings(): PassesSettings {
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
val 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(hoursAhead, minElevation, selectedModes)
return PassesSettings(
showDeepSpace,
hoursAhead,
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
selectedModes
)
}
//endregion
@@ -153,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 {
@@ -188,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")
@@ -246,82 +298,51 @@ class SettingsRepo(
//endregion
//region # RC settings
init {
migrateRCFormats()
}
// TODO: Remove after a few releases (added in v4.2.0)
private val keyRCFormatsMigrated = "rcFormatsMigrated"
private fun migrateRCFormats() {
if (preferences.getBoolean(keyRCFormatsMigrated, false)) return
val formatKeys = listOf(
keyRotatorFormat, keyFrequencyFormat, keyBluetoothRotatorFormat, keyBluetoothFrequencyFormat
)
preferences.edit {
for (key in formatKeys) {
val value = preferences.getString(key, null) ?: continue
if (value.contains("_") && !value.startsWith("\\")) {
putString(key, "\\$value")
}
}
putBoolean(keyRCFormatsMigrated, true)
}
}
private val _rcSettings = MutableStateFlow(getRCSettings())
override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun setBluetoothRotatorAddress(value: String) {
preferences.edit { putString(keyBluetoothRotatorAddress, value) }
_rcSettings.update { it.copy(bluetoothRotatorAddress = value) }
}
override fun setBluetoothRotatorFormat(value: String) {
preferences.edit { putString(keyBluetoothRotatorFormat, value) }
_rcSettings.update { it.copy(bluetoothRotatorFormat = value) }
}
override fun setBluetoothRotatorName(value: String) {
preferences.edit { putString(keyBluetoothRotatorName, value) }
_rcSettings.update { it.copy(bluetoothRotatorName = value) }
}
override fun setBluetoothRotatorState(value: Boolean) {
preferences.edit { putBoolean(keyBluetoothRotatorState, value) }
_rcSettings.update { it.copy(bluetoothRotatorState = value) }
}
override fun setBluetoothFrequencyState(value: Boolean) {
preferences.edit { putBoolean(keyBluetoothFrequencyState, value) }
_rcSettings.update { it.copy(bluetoothFrequencyState = value) }
}
override fun setBluetoothFrequencyAddress(value: String) {
preferences.edit { putString(keyBluetoothFrequencyAddress, value) }
_rcSettings.update { it.copy(bluetoothFrequencyAddress = value) }
}
override fun setBluetoothFrequencyFormat(value: String) {
preferences.edit { putString(keyBluetoothFrequencyFormat, value) }
_rcSettings.update { it.copy(bluetoothFrequencyFormat = value) }
}
override fun setRotatorAddress(value: String) {
preferences.edit { putString(keyRotatorAddress, value) }
_rcSettings.update { it.copy(rotatorAddress = value) }
}
override fun setRotatorPort(value: String) {
preferences.edit { putString(keyRotatorPort, value) }
_rcSettings.update { it.copy(rotatorPort = value) }
}
override fun setRotatorState(value: Boolean) {
preferences.edit { putBoolean(keyRotatorState, value) }
_rcSettings.update { it.copy(rotatorState = value) }
}
override fun setRotatorFormat(value: String) {
preferences.edit { putString(keyRotatorFormat, value) }
_rcSettings.update { it.copy(rotatorFormat = value) }
}
override fun setFrequencyState(value: Boolean) {
preferences.edit { putBoolean(keyFrequencyState, value) }
_rcSettings.update { it.copy(frequencyState = value) }
}
override fun setFrequencyAddress(value: String) {
preferences.edit { putString(keyFrequencyAddress, value) }
_rcSettings.update { it.copy(frequencyAddress = value) }
}
override fun setFrequencyPort(value: String) {
preferences.edit { putString(keyFrequencyPort, value) }
_rcSettings.update { it.copy(frequencyPort = value) }
}
override fun setFrequencyFormat(value: String) {
preferences.edit { putString(keyFrequencyFormat, value) }
_rcSettings.update { it.copy(frequencyFormat = value) }
override fun updateRCSettings(settings: RCSettings) {
preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress)
putString(keyRotatorPort, settings.rotatorPort)
putString(keyRotatorFormat, settings.rotatorFormat)
putBoolean(keyFrequencyState, settings.frequencyState)
putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
putString(keyBluetoothRotatorAddress, settings.bluetoothRotatorAddress)
putBoolean(keyBluetoothFrequencyState, settings.bluetoothFrequencyState)
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
}
_rcSettings.value = settings
}
private fun getRCSettings(): RCSettings = RCSettings(
@@ -332,14 +353,14 @@ class SettingsRepo(
frequencyState = preferences.getBoolean(keyFrequencyState, false),
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"set_freq $FREQ",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"W$AZ $EL",
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
bluetoothRotatorAddress = preferences.getString(keyBluetoothRotatorAddress, null) ?: "00:0C:BF:13:80:5D",
bluetoothFrequencyState = preferences.getBoolean(keyBluetoothFrequencyState, false),
bluetoothFrequencyAddress = preferences.getString(keyBluetoothFrequencyAddress, null) ?: "00:0C:BF:13:80:5D",
bluetoothFrequencyFormat = preferences.getString(keyBluetoothFrequencyFormat, null) ?: $$"FA$FREQ"
bluetoothFrequencyFormat = preferences.getString(keyBluetoothFrequencyFormat, null) ?: $$"F $FREQ"
)
//endregion
@@ -347,39 +368,31 @@ class SettingsRepo(
private val _otherSettings = MutableStateFlow(getOtherSettings())
override val otherSettings: StateFlow<OtherSettings> = _otherSettings
override fun setStateOfAutoUpdate(value: Boolean) {
preferences.edit { putBoolean(keyStateOfAutoUpdate, value) }
_otherSettings.update { it.copy(stateOfAutoUpdate = value) }
}
override fun setStateOfSensors(value: Boolean) {
preferences.edit { putBoolean(keyStateOfSensors, value) }
_otherSettings.update { it.copy(stateOfSensors = value) }
}
override fun setStateOfSweep(value: Boolean) {
preferences.edit { putBoolean(keyStateOfSweep, value) }
_otherSettings.update { it.copy(stateOfSweep = value) }
}
override fun setStateOfUtc(value: Boolean) {
preferences.edit { putBoolean(keyStateOfUtc, value) }
_otherSettings.update { it.copy(stateOfUtc = value) }
}
override fun setStateOfLightTheme(value: Boolean){
preferences.edit { putBoolean(keyStateOfLightTheme, value) }
_otherSettings.update { it.copy(stateOfLightTheme = value) }
}
override fun setWarningDismissed() {
preferences.edit { putBoolean(keyShouldSeeWarning, false) }
_otherSettings.update { it.copy(shouldSeeWarning = false) }
}
override fun setWhatsNewDismissed() {
preferences.edit { putBoolean(keyShouldSeeWhatsNew, false) }
_otherSettings.update { it.copy(shouldSeeWhatsNew = false) }
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
_otherSettings.update { current ->
val new = transform(current)
preferences.edit {
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
putBoolean(keyStateOfSensors, new.stateOfSensors)
putBoolean(keyStateOfSweep, new.stateOfSweep)
putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
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
}
}
private fun getOtherSettings(): OtherSettings = OtherSettings(
@@ -388,8 +401,19 @@ class SettingsRepo(
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true)
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
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
@@ -397,31 +421,68 @@ class SettingsRepo(
private val _dataSourcesSettings = MutableStateFlow(getDataSourcesSettings())
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun setUseCustomTle(value: Boolean) {
preferences.edit { putBoolean(keyUseCustomTle, value) }
_dataSourcesSettings.update { it.copy(useCustomTLE = value) }
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
preferences.edit {
putBoolean(keyUseCustomTle, settings.useCustomTLE)
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
putString(keyTleUrl, settings.tleUrl)
putString(keyTransceiversUrl, settings.transceiversUrl)
}
_dataSourcesSettings.value = settings
}
override fun setUseCustomTransceivers(value: Boolean) {
preferences.edit { putBoolean(keyUseCustomTransceivers, value) }
_dataSourcesSettings.update { it.copy(useCustomTransceivers = value) }
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
private val keyRadioControlEnabled = "radioControlEnabled"
private val keyRadioModel = "radioModel"
private val keyTxRadioAddress = "txRadioAddress"
private val keyRxRadioAddress = "rxRadioAddress"
private val keyTxRadioName = "txRadioName"
private val keyRxRadioName = "rxRadioName"
private val keyRadioBaudRate = "radioBaudRate"
private val keyRadioSplitMode = "radioSplitMode"
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
override fun updateRadioControlSettings(settings: RadioControlSettings) {
preferences.edit {
putBoolean(keyRadioControlEnabled, settings.enabled)
putString(keyRadioModel, settings.radioModel)
putString(keyTxRadioAddress, settings.txRadioAddress)
putString(keyRxRadioAddress, settings.rxRadioAddress)
putString(keyTxRadioName, settings.txRadioName)
putString(keyRxRadioName, settings.rxRadioName)
putInt(keyRadioBaudRate, settings.baudRate)
putBoolean(keyRadioSplitMode, settings.splitMode)
}
_radioControlSettings.value = settings
}
override fun setTleUrl(value: String) {
preferences.edit { putString(keyTleUrl, value) }
_dataSourcesSettings.update { it.copy(tleUrl = value) }
}
override fun setTransceiversUrl(value: String) {
preferences.edit { putString(keyTransceiversUrl, value) }
_dataSourcesSettings.update { it.copy(transceiversUrl = value) }
}
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 getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817,
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
)
//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))
}
}
@@ -0,0 +1,150 @@
package com.rtbishop.look4sat.core.data.framework
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class Ft817CatProtocolTest {
@Test
fun encodeFrequencyBcd_145500000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
}
@Test
fun encodeFrequencyBcd_435100000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
}
@Test
fun encodeFrequencyBcd_7074000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
}
@Test
fun decodeFrequencyBcd_roundTrips() {
val testFreqs = listOf(145500000L, 435100000L, 7074000L, 14200000L, 28500000L)
for (freq in testFreqs) {
val rounded = (freq / 10) * 10
val bcd = Ft817CatProtocol.encodeFrequencyBcd(freq)
assertEquals(rounded, Ft817CatProtocol.decodeFrequencyBcd(bcd))
}
}
@Test
fun buildSetFreqCommand_correctFormat() {
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
assertEquals(5, cmd.size)
assertEquals(0x01.toByte(), cmd[4])
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
}
@Test
fun buildSetModeCommand_usb() {
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
assertNotNull(cmd)
assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
}
@Test
fun buildSetModeCommand_fm() {
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
assertNotNull(cmd)
assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
}
@Test
fun buildSetModeCommand_unknownReturnsNull() {
assertNull(Ft817CatProtocol.buildSetModeCommand("INVALID"))
}
@Test
fun encodeCtcssTone_67_0() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
}
@Test
fun encodeCtcssTone_74_4() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
}
@Test
fun encodeCtcssTone_141_3() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
}
@Test
fun buildSetCtcssToneCommand_correctFormat() {
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
assertEquals(5, cmd.size)
assertEquals(0x0B.toByte(), cmd[4])
assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
}
@Test
fun buildCtcssModeCommand_enable() {
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
}
@Test
fun buildCtcssModeCommand_disable() {
val cmd = Ft817CatProtocol.buildCtcssModeCommand(false)
assertEquals(0x8A.toByte(), cmd[0])
assertEquals(0x0A.toByte(), cmd[4])
}
@Test
fun parseReadResponse_validResponse() {
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
val result = Ft817CatProtocol.parseReadResponse(response)
assertNotNull(result)
result ?: return
assertEquals(145500000L, result.first)
assertEquals("USB", result.second)
}
@Test
fun parseReadResponse_fmMode() {
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
val result = Ft817CatProtocol.parseReadResponse(response)
assertNotNull(result)
result ?: return
assertEquals(146000000L, result.first)
assertEquals("FM", result.second)
}
@Test
fun parseReadResponse_tooShort() {
assertNull(Ft817CatProtocol.parseReadResponse(byteArrayOf(0x14, 0x55, 0x00)))
}
@Test
fun parseReadResponse_unknownMode() {
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x0F)
assertNull(Ft817CatProtocol.parseReadResponse(response))
}
@Test
fun buildPttCommands() {
val on = Ft817CatProtocol.buildPttOnCommand()
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
val off = Ft817CatProtocol.buildPttOffCommand()
assertEquals(0x88.toByte(), off[4])
}
@Test
fun buildReadCommand() {
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
}
}
@@ -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 = ""
)
}
+6 -1
View File
@@ -1,3 +1,8 @@
plugins {
alias(libs.plugins.convention.kotlinLibraryPlugin)
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)
}
}
@@ -17,16 +17,20 @@
*/
package com.rtbishop.look4sat.core.domain.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SatRadio(
val uuid: String,
val info: String,
val isAlive: Boolean,
var downlinkLow: Long?,
var downlinkHigh: Long?,
val downlinkMode: String?,
var uplinkLow: Long?,
var uplinkHigh: Long?,
val uplinkMode: String?,
val isInverted: Boolean,
val catnum: Int?
@SerialName("uuid") val uuid: String,
@SerialName("description") val info: String,
@SerialName("alive") val isAlive: Boolean,
@SerialName("downlink_low") val downlinkLow: Long?,
@SerialName("downlink_high") val downlinkHigh: Long?,
@SerialName("mode") val downlinkMode: String?,
@SerialName("uplink_low") val uplinkLow: Long?,
@SerialName("uplink_high") val uplinkHigh: Long?,
@SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int?
)
@@ -0,0 +1,37 @@
package com.rtbishop.look4sat.core.domain.model
/** 单条卫星状态报告(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 → 报告
)
@@ -24,8 +24,12 @@ data class DatabaseState(
)
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>
)
@@ -53,8 +57,23 @@ data class OtherSettings(
val stateOfSweep: Boolean,
val stateOfUtc: Boolean,
val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0,
// 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(
@@ -63,3 +82,28 @@ data class DataSourcesSettings(
val tleUrl: String,
val transceiversUrl: String
)
data class RadioControlSettings(
val enabled: Boolean,
val radioModel: String,
val txRadioAddress: String,
val rxRadioAddress: String,
val txRadioName: String,
val rxRadioName: String,
val baudRate: Int,
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
val splitMode: Boolean = false
) {
companion object {
const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
const val MODEL_ICOM_IC705 = "Icom IC-705"
val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705)
/** Baud rates available for Yaesu radios. */
val BAUD_RATES_YAESU = listOf(4800, 9600, 38400)
/** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */
val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200)
}
}
@@ -0,0 +1,667 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.predict
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toRadians
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.tan
/**
* Standalone celestial computations extracted from PREDICT v2.2.5.
* Provides Sun position, Moon position, satellite visibility classification,
* orbital metadata, RA/Dec conversion, and rise/set finding for Sun and Moon.
*
* All angles are in degrees unless noted. Time is Unix epoch milliseconds.
*
* Shared math utilities (thetaGJD, modulus, mod2PI, deltaET, millisToDaynum,
* solarPositionECI, eciToGeodetic) live in OrbitalMath.kt in the same package.
*/
object CelestialComputer {
// ── Result types ──
/** Sun position as seen from a ground observer. */
data class SunPosition(
val azimuth: Double, // degrees, 0=N, 90=E
val elevation: Double, // degrees, >0 above horizon
val distance: Double, // normalized: 1.0 + ((range - AU) / AU)
val rangeRate: Double, // km/s
val latitude: Double, // sub-solar point latitude, degrees
val longitude: Double, // sub-solar point longitude, degrees
val rightAscension: Double, // degrees
val declination: Double // degrees
)
/** Moon position as seen from a ground observer. */
data class MoonPosition(
val azimuth: Double, // degrees, 0=N, 90=E
val elevation: Double, // degrees
val rightAscension: Double, // degrees
val declination: Double, // degrees
val gha: Double, // Greenwich Hour Angle, degrees
val angularDiameter: Double, // apparent diameter relative to Earth's diameter
val radialVelocity: Double // m/s, Doppler radial velocity for EME
)
/**
* 3-state satellite visibility classification.
* - [VISIBLE]: satellite is sunlit, observer is in darkness (sun below -12°) — optically visible
* - [DAYLIGHT]: satellite is sunlit, observer is in daylight
* - [ECLIPSED]: satellite is in Earth's shadow
*/
enum class SatVisibility { VISIBLE, DAYLIGHT, ECLIPSED }
/** Orbital metadata not typically included in pass data. */
data class OrbitalMetadata(
val footprintDiameter: Double, // km, ground coverage circle diameter
val orbitNumber: Long, // current orbit/revolution number
val betaAngle: Double, // degrees, angle between orbital plane and Sun
val orbitalPhase: Double // 0-256 phase within current orbit
)
// ── Sun position ──
/**
* Compute the Sun's full position as seen from [observer] at [timeMillis].
* Includes az/el, RA/Dec, sub-solar lat/lon, range, and range rate.
* Based on FindSun() from PREDICT v2.2.5.
*/
fun getSunPosition(observer: GeoPos, timeMillis: Long): SunPosition {
val daynum = millisToDaynum(timeMillis)
val julUtc = daynum + 2444238.5
val sunVec = solarPositionECI(julUtc)
val zeroVel = doubleArrayOf(0.0, 0.0, 0.0)
val obsGeo = observerGeodetic(observer)
// Az, El, Range, RangeRate
val obsSet = computeObsAngles(julUtc, sunVec, zeroVel, obsGeo)
// Lat/Lon of sub-solar point
val latLon = eciToGeodetic(julUtc, sunVec)
// RA/Dec
val raDec = calculateRADec(julUtc, sunVec, zeroVel, obsGeo)
return SunPosition(
azimuth = obsSet[0].toDegrees(),
elevation = obsSet[1].toDegrees(),
distance = 1.0 + ((obsSet[2] - ASTRONOMICAL_UNIT) / ASTRONOMICAL_UNIT),
rangeRate = 1000.0 * obsSet[3],
latitude = latLon[0].toDegrees(),
longitude = latLon[1].toDegrees().let { if (it > 180.0) it - 360.0 else it },
rightAscension = raDec[0].toDegrees(),
declination = raDec[1].toDegrees()
)
}
// ── Moon position ──
/**
* Compute the Moon's position as seen from [observer] at [timeMillis].
* Full Meeus lunar ephemeris from PREDICT v2.2.5 with expanded terms
* and radial velocity approximation for EME Doppler.
*/
fun getMoonPosition(observer: GeoPos, timeMillis: Long): MoonPosition {
val daynum = millisToDaynum(timeMillis)
val jd = daynum + 2444238.5
var t = (jd - 2415020.0) / 36525.0
val t2 = t * t
val t3 = t2 * t
var l1 = 270.434164 + 481267.8831 * t - 0.001133 * t2 + 0.0000019 * t3
var mSun = 358.475833 + 35999.0498 * t - 0.00015 * t2 - 0.0000033 * t3
var m1 = 296.104608 + 477198.8491 * t + 0.009192 * t2 + 0.0000144 * t3
var d = 350.737486 + 445267.1142 * t - 0.001436 * t2 + 0.0000019 * t3
var ff = 11.250889 + 483202.0251 * t - 0.003211 * t2 - 0.0000003 * t3
val om = (259.183275 - 1934.142 * t + 0.002078 * t2 + 0.0000022 * t3) * DEG2RAD
val correction512 = sin((51.2 + 20.2 * t) * DEG2RAD)
val ss = 0.003964 * sin((346.56 + 132.87 * t - 0.0091731 * t2) * DEG2RAD)
l1 += 0.000233 * correction512 + ss + 0.001964 * sin(om)
mSun -= 0.001778 * correction512
m1 += 0.000817 * correction512 + ss + 0.002541 * sin(om)
d += 0.002011 * correction512 + ss + 0.001964 * sin(om)
ff += ss - 0.024691 * sin(om) - 0.004328 * sin(om + (275.05 - 2.3 * t) * DEG2RAD)
val ex = 1.0 - 0.002495 * t - 0.00000752 * t2
l1 = primeAngle(l1); mSun = primeAngle(mSun); m1 = primeAngle(m1)
d = primeAngle(d); ff = primeAngle(ff)
val mR = mSun * DEG2RAD
val m1R = m1 * DEG2RAD
val dR = d * DEG2RAD
val ffR = ff * DEG2RAD
// Ecliptic longitude — expanded v225 terms
var l = l1 + 6.28875 * sin(m1R) + 1.274018 * sin(2 * dR - m1R) + 0.658309 * sin(2 * dR)
l += 0.213616 * sin(2 * m1R) - ex * 0.185596 * sin(mR) - 0.114336 * sin(2 * ffR)
l += 0.058793 * sin(2 * dR - 2 * m1R) + ex * 0.057212 * sin(2 * dR - mR - m1R) + 0.05332 * sin(2 * dR + m1R)
l += ex * 0.045874 * sin(2 * dR - mR) + ex * 0.041024 * sin(m1R - mR) - 0.034718 * sin(dR)
l -= ex * 0.030465 * sin(mR + m1R) + 0.015326 * sin(2 * dR - 2 * ffR) - 0.012528 * sin(2 * ffR + m1R)
l -= 0.01098 * sin(2 * ffR - m1R) + 0.010674 * sin(4 * dR - m1R) + 0.010034 * sin(3 * m1R)
l += 0.008548 * sin(4 * dR - 2 * m1R) - ex * 0.00791 * sin(mR - m1R + 2 * dR)
l -= ex * 0.006783 * sin(2 * dR + mR)
l += 0.005162 * sin(m1R - dR) + ex * 0.005 * sin(mR + dR) + ex * 0.004049 * sin(m1R - mR + 2 * dR)
l += 0.003996 * sin(2 * m1R + 2 * dR) + 0.003862 * sin(4 * dR) + 0.003665 * sin(2 * dR - 3 * m1R)
l += ex * 0.002695 * sin(2 * m1R - mR) + 0.002602 * sin(m1R - 2 * ffR - 2 * dR)
l += ex * 0.002396 * sin(2 * dR - mR - 2 * m1R)
l -= 0.002349 * sin(m1R + dR) + ex * ex * 0.002249 * sin(2 * dR - 2 * mR)
l -= ex * 0.002125 * sin(2 * m1R + mR)
l -= ex * ex * 0.002079 * sin(2 * mR) + ex * ex * 0.002059 * sin(2 * dR - m1R - 2 * mR)
l -= 0.001773 * sin(m1R + 2 * dR - 2 * ffR)
l += ex * 0.00122 * sin(4 * dR - mR - m1R) - 0.00111 * sin(2 * m1R + 2 * ffR) + 0.000892 * sin(m1R - 3 * dR)
l -= ex * 0.000811 * sin(mR + m1R + 2 * dR) + ex * 0.000761 * sin(4 * dR - mR - 2 * m1R)
l += ex * ex * 0.000717 * sin(m1R - 2 * mR)
l += ex * ex * 0.000704 * sin(m1R - 2 * mR - 2 * dR) + ex * 0.000693 * sin(mR - 2 * m1R + 2 * dR)
l += ex * 0.000598 * sin(2 * dR - mR - 2 * ffR) + 0.00055 * sin(m1R + 4 * dR)
l += 0.000538 * sin(4 * m1R) + ex * 0.000521 * sin(4 * dR - mR) + 0.000486 * sin(2 * m1R - dR)
l -= 0.001595 * sin(2 * ffR + 2 * dR)
// Ecliptic latitude — expanded v225 terms
var b =
5.128189 * sin(ffR) + 0.280606 * sin(m1R + ffR) + 0.277693 * sin(m1R - ffR) + 0.173238 * sin(2 * dR - ffR)
b += 0.055413 * sin(2 * dR + ffR - m1R) + 0.046272 * sin(2 * dR - ffR - m1R) + 0.032573 * sin(2 * dR + ffR)
b += 0.017198 * sin(2 * m1R + ffR) + 9.266999e-03 * sin(2 * dR + m1R - ffR) + 0.008823 * sin(2 * m1R - ffR)
b += ex * 0.008247 * sin(2 * dR - mR - ffR) + 0.004323 * sin(2 * dR - ffR - 2 * m1R)
b += 0.0042 * sin(2 * dR + ffR + m1R)
b += ex * 0.003372 * sin(ffR - mR - 2 * dR) + ex * 0.002472 * sin(2 * dR + ffR - mR - m1R)
b += ex * 0.002222 * sin(2 * dR + ffR - mR)
b += 0.002072 * sin(2 * dR - ffR - mR - m1R) + ex * 0.001877 * sin(ffR - mR + m1R)
b += 0.001828 * sin(4 * dR - ffR - m1R)
b -= ex * 0.001803 * sin(ffR + mR) - 0.00175 * sin(3 * ffR)
b += ex * 0.00157 * sin(m1R - mR - ffR) - 0.001487 * sin(ffR + dR)
b -= ex * 0.001481 * sin(ffR + mR + m1R) + ex * 0.001417 * sin(ffR - mR - m1R)
b += ex * 0.00135 * sin(ffR - mR) + 0.00133 * sin(ffR - dR)
b += 0.001106 * sin(ffR + 3 * m1R) + 0.00102 * sin(4 * dR - ffR) + 0.000833 * sin(ffR + 4 * dR - m1R)
b += 0.000781 * sin(m1R - 3 * ffR) + 0.00067 * sin(ffR + 4 * dR - 2 * m1R)
b += 0.000606 * sin(2 * dR - 3 * ffR)
b += 0.000597 * sin(2 * dR + 2 * m1R - ffR) + ex * 0.000492 * sin(2 * dR + m1R - mR - ffR)
b += 0.00045 * sin(2 * m1R - ffR - 2 * dR)
b += 0.000439 * sin(3 * m1R - ffR) + 0.000423 * sin(ffR + 2 * dR + 2 * m1R)
b += 0.000422 * sin(2 * dR - ffR - 3 * m1R)
b -= ex * 0.000367 * sin(mR + ffR + 2 * dR - m1R) - ex * 0.000353 * sin(mR + ffR + 2 * dR)
b += 0.000331 * sin(ffR + 4 * dR)
b += ex * 0.000317 * sin(2 * dR + ffR - mR + m1R) + ex * ex * 0.000306 * sin(2 * dR - 2 * mR - ffR)
b -= 0.000283 * sin(m1R + 3 * ffR)
val w1 = 0.0004664 * cos(om)
val w2 = 0.0000754 * cos(om + (275.05 - 2.3 * t) * DEG2RAD)
val bt = b * (1.0 - w1 - w2)
// Parallax — expanded v225 terms
var p =
0.950724 + 0.051818 * cos(m1R) + 0.009531 * cos(2 * dR - m1R) + 0.007843 * cos(2 * dR) + 0.002824 * cos(2 * m1R)
p += 0.000857 * cos(2 * dR + m1R) + ex * 0.000533 * cos(2 * dR - mR) + ex * 0.000401 * cos(2 * dR - mR - m1R)
p += 0.000173 * cos(3 * m1R) + 0.000167 * cos(4 * dR - m1R) - ex * 0.000111 * cos(mR)
p += 0.000103 * cos(4 * dR - 2 * m1R) - 0.000084 * cos(2 * m1R - 2 * dR) - ex * 0.000083 * cos(2 * dR + mR)
p += 0.000079 * cos(2 * dR + 2 * m1R)
p += 0.000072 * cos(4 * dR) + ex * 0.000064 * cos(2 * dR - mR + m1R) - ex * 0.000063 * cos(2 * dR + mR - m1R)
p += ex * 0.000041 * cos(mR + dR) + ex * 0.000035 * cos(2 * m1R - mR) - 0.000033 * cos(3 * m1R - 2 * dR)
p -= 0.00003 * cos(m1R + dR) - 0.000029 * cos(2 * ffR - 2 * dR) - ex * 0.000029 * cos(2 * m1R + mR)
p += ex * ex * 0.000026 * cos(2 * dR - 2 * mR) - 0.000023 * cos(2 * ffR - 2 * dR + m1R)
p += ex * 0.000019 * cos(4 * dR - mR - m1R)
val bRad = bt * DEG2RAD
val lm = l * DEG2RAD
val moonDx = 3.0 / (PI * p)
// Ecliptic → equatorial
val z = (jd - 2415020.5) / 365.2422
val ob = (23.452294 - (0.46845 * z + 5.9e-07 * z * z) / 3600.0).toRadians()
val dec = asin(sin(bRad) * cos(ob) + cos(bRad) * sin(ob) * sin(lm))
var ra = acos(cos(bRad) * cos(lm) / cos(dec)); if (lm > PI) ra = TWO_PI - ra
val n = observer.latitude * DEG2RAD
t = (jd - 2451545.0) / 36525.0
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
while (teg > 360.0) teg -= 360.0
// LST = GMST + east longitude (positive east convention)
val th = mod2PI((teg + observer.longitude) * DEG2RAD)
val h = th - ra
val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI
val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h))
// Moon radial velocity approximation (from "Amateur Radio Software", GM4ANB, RSGB 1985)
val mm = fixAngle(1.319238 + daynum * 0.228027135)
val radT2 = 0.10976
val radT1 = mm + radT2 * sin(mm)
var dv = 0.01255 * moonDx * moonDx * sin(radT1) * (1.0 + radT2 * cos(mm))
dv *= 4449.0
val earthR = 6378.0
val moonDist = 384401.0
val radT3 = earthR * moonDist * (cos(dec) * cos(n) * sin(h)) /
sqrt(moonDist * moonDist - moonDist * earthR * sin(el))
val moonDv = dv + radT3 * 0.0753125
val moonRa = ra / DEG2RAD
var moonGha = teg - moonRa
if (moonGha < 0.0) moonGha += 360.0
return MoonPosition(
azimuth = azVal / DEG2RAD,
elevation = el / DEG2RAD,
rightAscension = moonRa,
declination = dec / DEG2RAD,
gha = moonGha,
angularDiameter = moonDx,
radialVelocity = moonDv
)
}
// ── Satellite visibility ──
/**
* Classify satellite visibility given its eclipse state and the Sun's elevation
* at the observer's location.
*
* @param isEclipsed whether the satellite is in Earth's shadow
* @param sunElevationDeg Sun elevation at observer in degrees
* @param satElevationDeg satellite elevation at observer in degrees (must be >= 0)
*/
fun classifyVisibility(
isEclipsed: Boolean,
sunElevationDeg: Double,
satElevationDeg: Double
): SatVisibility {
if (isEclipsed) return SatVisibility.ECLIPSED
return if (sunElevationDeg <= -12.0 && satElevationDeg >= 0.0) SatVisibility.VISIBLE
else SatVisibility.DAYLIGHT
}
// ── Orbital metadata ──
/**
* Compute orbital metadata for a satellite at its current position.
*
* @param altitudeKm satellite altitude in km
* @param meanMotion revolutions per day from TLE
* @param bstar drag term from TLE
* @param meanAnomaly mean anomaly at epoch (radians)
* @param revNumAtEpoch revolution number at TLE epoch
* @param ageDays days since TLE epoch (julUTC - julEpoch)
* @param phase orbital phase in radians (from SGP4/SDP4 output)
* @param satPosECI satellite ECI position [x, y, z]
* @param satVelECI satellite ECI velocity [vx, vy, vz]
* @param sunPosECI sun ECI position [x, y, z]
*/
fun computeOrbitalMetadata(
altitudeKm: Double,
meanMotion: Double,
bstar: Double,
meanAnomaly: Double,
revNumAtEpoch: Int,
ageDays: Double,
phase: Double,
satPosECI: DoubleArray,
satVelECI: DoubleArray,
sunPosECI: DoubleArray
): OrbitalMetadata {
// Footprint diameter (km)
val footprint = 12756.33 * acos(EARTH_RADIUS / (EARTH_RADIUS + altitudeKm))
// Orbit number
val xmnpda = 1.44E3
val orbitNum = floor(
(meanMotion * xmnpda / TWO_PI + ageDays * bstar) * ageDays + meanAnomaly / TWO_PI
).toLong() + revNumAtEpoch
// Beta angle: angle between orbital plane and Sun direction
// Orbital plane normal = cross(pos, vel)
val nx = satPosECI[1] * satVelECI[2] - satPosECI[2] * satVelECI[1]
val ny = satPosECI[2] * satVelECI[0] - satPosECI[0] * satVelECI[2]
val nz = satPosECI[0] * satVelECI[1] - satPosECI[1] * satVelECI[0]
val nMag = sqrt(nx * nx + ny * ny + nz * nz)
val sMag = sqrt(sunPosECI[0] * sunPosECI[0] + sunPosECI[1] * sunPosECI[1] + sunPosECI[2] * sunPosECI[2])
val dotNS = nx * sunPosECI[0] + ny * sunPosECI[1] + nz * sunPosECI[2]
val betaAngle = if (nMag > 0 && sMag > 0) {
(PI / 2.0 - acos(dotNS / (nMag * sMag))).toDegrees()
} else 0.0
// Phase (0-256 scale, matching PREDICT convention)
val orbitalPhase = 256.0 * (phase / TWO_PI)
return OrbitalMetadata(footprint, orbitNum, betaAngle, orbitalPhase)
}
// ── Satellite status checks ──
/** Check if a satellite is geostationary (mean motion ≈ 1.0027 rev/day). */
fun isGeostationary(meanMotion: Double): Boolean = abs(meanMotion - 1.0027) < 0.0002
/**
* Check if a satellite has likely decayed based on drag and time since epoch.
*
* @param meanMotion revolutions per day
* @param drag first derivative of mean motion / 2 (from TLE line 1)
* @param epochDaynum TLE epoch as daynum (days since 31Dec79)
* @param currentDaynum current time as daynum
*/
fun hasDecayed(meanMotion: Double, drag: Double, epochDaynum: Double, currentDaynum: Double): Boolean {
return epochDaynum + ((16.666666 - meanMotion) / (10.0 * abs(drag))) < currentDaynum
}
// ── Rise/Set finding ──
/** Rise and set times for a celestial body. */
data class RiseSetTimes(
val riseTimeMillis: Long, // 0 if not found
val setTimeMillis: Long // 0 if not found
)
/**
* Find the next sunrise and sunset times from [startMillis] for [observer].
* Uses elevation threshold of -0.8333° to match the standard civil definition:
* upper limb on geometric horizon with standard atmospheric refraction (~0.57°)
* and solar semidiameter (~0.27°) corrections applied, matching USNO/timeanddate.com.
*/
fun findSunRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
// Standard civil threshold: center elevation when upper limb meets geometric horizon
// -0.8333° = standard refraction (~0.5667°) + solar semidiameter (~0.2667°)
val threshold = 0.8333
var daynum = millisToDaynum(startMillis)
var sunPos = getSunPosition(observer, daynumToMillis(daynum))
// Phase 1: if sun is above threshold, fast-forward to well past sunset into night
if (sunPos.elevation > -threshold) {
var guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) {
daynum += 0.008 // fixed ~11.5 min steps past the setting sun
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
// Now advance until sun is clearly below minimum (deep night)
guard = 0
while (sunPos.elevation > -12.0 && guard++ < 500) {
daynum += 0.02
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
}
// Phase 2: advance until sun starts rising toward threshold (elevation increasing)
var guard = 0
while (sunPos.elevation < -threshold && guard++ < 500) {
daynum += 0.008
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
// Phase 3: converge symmetrically on elevation = -threshold (sunrise)
var sunrise = 0.0
guard = 0
while (sunrise == 0.0 && guard++ < 200) {
val delta = sunPos.elevation + threshold
if (abs(delta) < 0.01) {
sunrise = daynum
} else {
daynum -= 0.004 * delta
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
}
if (sunrise == 0.0) sunrise = daynum
// Phase 4: fast-forward through the day until sun drops back below threshold
daynum = sunrise
sunPos = getSunPosition(observer, daynumToMillis(daynum))
guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) {
daynum += 0.008
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
// Phase 5: converge symmetrically on elevation = -threshold (sunset)
var sunset = 0.0
guard = 0
while (sunset == 0.0 && guard++ < 200) {
val delta = sunPos.elevation + threshold
if (abs(delta) < 0.01) {
sunset = daynum
} else {
daynum += 0.004 * delta
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
}
if (sunset == 0.0) sunset = daynum
return RiseSetTimes(daynumToMillis(sunrise), daynumToMillis(sunset))
}
/**
* Find the next moonrise and moonset times from [startMillis] for [observer].
* Uses the adaptive iteration from PREDICT v2.2.5's PredictMoon().
*/
fun findMoonRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
var daynum = millisToDaynum(startMillis)
var moonPos = getMoonPosition(observer, daynumToMillis(daynum))
// If moon is already up, move forward until it sets
var guard = 0
if (moonPos.elevation > 0) {
while (moonPos.elevation > 0 && guard++ < 1000) {
daynum += 0.004 * sin(DEG2RAD * (moonPos.elevation + 0.5))
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
daynum += 0.4
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
// Find moonrise
var moonrise = 0.0
guard = 0
while (moonrise == 0.0 && guard++ < 1000) {
if (abs(moonPos.elevation) < 0.03) {
moonrise = daynum
} else {
daynum -= 0.004 * moonPos.elevation
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
}
if (moonrise == 0.0) moonrise = daynum
// Find moonset from moonrise
daynum = moonrise
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
guard = 0
while (moonPos.elevation > -1 && guard++ < 1000) {
daynum += 0.04 * cos(DEG2RAD * (moonPos.elevation + 0.5))
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
var moonset = 0.0
guard = 0
while (moonset == 0.0 && guard++ < 1000) {
if (abs(moonPos.elevation) < 0.03) {
moonset = daynum
} else {
daynum += 0.004 * moonPos.elevation
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
}
if (moonset == 0.0) moonset = daynum
return RiseSetTimes(daynumToMillis(moonrise), daynumToMillis(moonset))
}
// ── Visual magnitude estimation ──
/**
* Estimate the apparent visual magnitude of a satellite.
*
* Uses the standard formula from McCants/Heavens-Above:
* apparentMag = stdMag + 5 * log10(range / 1000) - 15 * log10(cos(phaseAngle / 2))
*
* @param rangeKm slant range from observer to satellite in km
* @param phaseAngleDeg Sun-satellite-observer angle in degrees
* @param stdMag intrinsic/standard magnitude (default 4.0)
* @return estimated apparent visual magnitude
*/
fun estimateVisualMagnitude(rangeKm: Double, phaseAngleDeg: Double, stdMag: Double = 4.0): Double {
if (rangeKm <= 0) return stdMag
val halfPhaseRad = phaseAngleDeg.toRadians() / 2.0
val cosHalfPhase = cos(halfPhaseRad)
val phaseTerm = if (cosHalfPhase > 1e-6) -15.0 * log10(cosHalfPhase) else 99.0
return stdMag + 5.0 * log10(rangeKm / 1000.0) + phaseTerm
}
/**
* Compute the phase angle (Sun-satellite-observer) in degrees.
*
* @param satPosECI satellite ECI position [x, y, z] in km
* @param sunPosECI sun ECI position [x, y, z] in km
* @param obsPosECI observer ECI position [x, y, z] in km
* @return phase angle in degrees (0 = fully illuminated face toward observer)
*/
fun computePhaseAngle(satPosECI: DoubleArray, sunPosECI: DoubleArray, obsPosECI: DoubleArray): Double {
val toSunX = sunPosECI[0] - satPosECI[0]
val toSunY = sunPosECI[1] - satPosECI[1]
val toSunZ = sunPosECI[2] - satPosECI[2]
val toObsX = obsPosECI[0] - satPosECI[0]
val toObsY = obsPosECI[1] - satPosECI[1]
val toObsZ = obsPosECI[2] - satPosECI[2]
val dot = toSunX * toObsX + toSunY * toObsY + toSunZ * toObsZ
val magSun = sqrt(toSunX * toSunX + toSunY * toSunY + toSunZ * toSunZ)
val magObs = sqrt(toObsX * toObsX + toObsY * toObsY + toObsZ * toObsZ)
if (magSun == 0.0 || magObs == 0.0) return 90.0
val cosAngle = (dot / (magSun * magObs)).coerceIn(-1.0, 1.0)
return acos(cosAngle).toDegrees()
}
// ── Doppler ──
/**
* Compute Doppler shift for a given base frequency and range rate.
*
* @param frequencyHz base frequency in Hz
* @param rangeRateKmS range rate in km/s (negative = approaching)
* @return shifted frequency in Hz
*/
fun dopplerShift(frequencyHz: Double, rangeRateKmS: Double): Double {
return frequencyHz * (299792.458 - rangeRateKmS) / 299792.458
}
// ── Internal helpers ──
private fun observerGeodetic(pos: GeoPos): DoubleArray {
// [lat_rad, lon_rad, alt_km] — longitude positive east, matching OrbitalObject convention.
// LST = thetaGJD(julUtc) + obsGeo[1] = GMST + lon_rad (correct).
return doubleArrayOf(pos.latitude * DEG2RAD, pos.longitude * DEG2RAD, pos.altitude / 1000.0)
}
/**
* Convert az/el observation to Right Ascension / Declination.
* Returns [ra_rad, dec_rad].
* Based on Calculate_RADec() from PREDICT v2.2.5 (Escobal method).
*/
private fun calculateRADec(
julUtc: Double,
targetPos: DoubleArray,
targetVel: DoubleArray,
obsGeo: DoubleArray
): DoubleArray {
val obsSet = computeObsAngles(julUtc, targetPos, targetVel, obsGeo)
val az = obsSet[0]
val el = obsSet[1]
val phi = obsGeo[0]
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
val sinTheta = sin(theta)
val cosTheta = cos(theta)
val sinPhi = sin(phi)
val cosPhi = cos(phi)
val lxh = -cos(az) * cos(el)
val lyh = sin(az) * cos(el)
val lzh = sin(el)
val sx = sinPhi * cosTheta
val ex2 = -sinTheta
val zx = cosTheta * cosPhi
val sy = sinPhi * sinTheta
val zy = sinTheta * cosPhi
val sz = -cosPhi
val lx = sx * lxh + ex2 * lyh + zx * lzh
val ly = sy * lxh + cosTheta * lyh + zy * lzh
val lz = sz * lxh + 0.0 * lyh + sinPhi * lzh
val dec = asin(lz)
val cosDelta = sqrt(1.0 - lz * lz)
val sinAlpha = ly / cosDelta
val cosAlpha = lx / cosDelta
val ra = mod2PI(atan2(sinAlpha, cosAlpha))
return doubleArrayOf(ra, dec)
}
/**
* Compute observer look-angles (az, el, range, rangeRate) to a target at ECI position.
* Returns [azimuth_rad, elevation_rad, range_km, rangeRate_km/s].
* Azimuth is north-referenced (0=N, π/2=E), matching OrbitalObject's convention.
*/
private fun computeObsAngles(
julUtc: Double,
targetPos: DoubleArray,
targetVel: DoubleArray,
obsGeo: DoubleArray // [lat_rad, lon_rad, alt_km]
): DoubleArray {
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
val c = 1.0 / sqrt(1 + FLAT_FACT * (FLAT_FACT - 2) * sin(obsGeo[0]).pow(2))
val sq = (1 - FLAT_FACT).pow(2) * c
val achcp = (EARTH_RADIUS * c + obsGeo[2]) * cos(obsGeo[0])
val ox = achcp * cos(theta)
val oy = achcp * sin(theta)
val oz = (EARTH_RADIUS * sq + obsGeo[2]) * sin(obsGeo[0])
val ovx = -MFACTOR * oy
val ovy = MFACTOR * ox
val rx = targetPos[0] - ox
val ry = targetPos[1] - oy
val rz = targetPos[2] - oz
val rMag = sqrt(rx * rx + ry * ry + rz * rz)
val rvx = targetVel[0] - ovx
val rvy = targetVel[1] - ovy
val rvz = targetVel[2]
val sinLat = sin(obsGeo[0])
val cosLat = cos(obsGeo[0])
val sinTheta = sin(theta)
val cosTheta = cos(theta)
val topS = sinLat * cosTheta * rx + sinLat * sinTheta * ry - cosLat * rz
val topE = -sinTheta * rx + cosTheta * ry
val topZ = cosLat * cosTheta * rx + cosLat * sinTheta * ry + sinLat * rz
// Match north-based convention (0=N, 90=E) used by OrbitalObject.calculateObs
// Must use atan(-topE / topS) not atan2(-topE, topS) — they differ in quadrant handling
var azim = atan(-topE / topS)
if (topS > 0.0) azim += PI
if (azim < 0.0) azim += TWO_PI
val el = asin(topZ / rMag)
val rangeRate = (rx * rvx + ry * rvy + rz * rvz) / rMag
return doubleArrayOf(azim, el, rMag, rangeRate)
}
private const val MFACTOR = 7.292115E-5
private fun primeAngle(x: Double) = x - 360.0 * floor(x / 360.0)
private fun fixAngle(x: Double): Double {
var a = x; while (a > TWO_PI) a -= TWO_PI; return a
}
}
@@ -21,6 +21,7 @@ const val ASTRONOMICAL_UNIT = 1.49597870691E8
const val DEG2RAD = 0.017453292519943295
const val RAD2DEG = 57.29577951308232
const val EARTH_RADIUS = 6378.137
const val EARTH_ROT_PER_SID_DAY = 1.00273790934
const val EPSILON = 1.0E-12
const val FLAT_FACT = 3.35281066474748E-3
const val J3_HARMONIC = -2.53881E-6
@@ -97,40 +97,38 @@ class DeepSpaceObject(data: OrbitalData) : OrbitalObject(data) {
}
internal fun calculateSDP4(tSince: Double) {
synchronized(this) {
val temp = DoubleArray(12)
val xmdf = data.xmo + dsv.xmdot * tSince
val tsq = tSince * tSince
val templ = t2cof * tsq
dsv.xll = xmdf + dsv.xnodp * templ
dsv.omgadf = data.omegao + dsv.omgdot * tSince
val xnoddf = data.xnodeo + dsv.xnodot * tSince
dsv.xnode = xnoddf + xnodcf * tsq
val tempa = 1.0 - c1 * tSince
val tempe = data.bstar * c4 * tSince
dsv.xn = dsv.xnodp
dsv.t = tSince
deep.dpsec(data)
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
dsv.em -= tempe
deep.dpper()
val xl = dsv.xll + dsv.omgadf + dsv.xnode
val beta = sqrt(1.0 - dsv.em * dsv.em)
dsv.xn = XKE / a.pow(1.5)
// Long period periodics
val axn = dsv.em * cos(dsv.omgadf)
temp[0] = invert(a * beta * beta)
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = dsv.em * sin(dsv.omgadf) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - dsv.xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, a, axn, ayn)
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
}
val temp = DoubleArray(12)
val xmdf = data.xmo + dsv.xmdot * tSince
val tsq = tSince * tSince
val templ = t2cof * tsq
dsv.xll = xmdf + dsv.xnodp * templ
dsv.omgadf = data.omegao + dsv.omgdot * tSince
val xnoddf = data.xnodeo + dsv.xnodot * tSince
dsv.xnode = xnoddf + xnodcf * tsq
val tempa = 1.0 - c1 * tSince
val tempe = data.bstar * c4 * tSince
dsv.xn = dsv.xnodp
dsv.t = tSince
deep.dpsec(data)
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
dsv.em -= tempe
deep.dpper()
val xl = dsv.xll + dsv.omgadf + dsv.xnode
val beta = sqrt(1.0 - dsv.em * dsv.em)
dsv.xn = XKE / a.pow(1.5)
// Long period periodics
val axn = dsv.em * cos(dsv.omgadf)
temp[0] = invert(a * beta * beta)
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = dsv.em * sin(dsv.omgadf) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - dsv.xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, a, axn, ayn)
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
}
private fun calculatePosAndVel(temp: DoubleArray, a: Double, axn: Double, ayn: Double) {
@@ -141,53 +141,51 @@ class NearEarthObject(data: OrbitalData) : OrbitalObject(data) {
}
internal fun calculateSGP4(tSince: Double) {
synchronized(this) {
val temp = DoubleArray(9)
val xmdf = data.xmo + xmdot * tSince
val omgadf = data.omegao + omgdot * tSince
val xnoddf = data.xnodeo + xnodot * tSince
var omega = omgadf
var xmp = xmdf
val tsq = sqr(tSince)
val xnode = xnoddf + xnodcf * tsq
val bstar = data.bstar
var tempa = 1.0 - c1 * tSince
var tempe = bstar * c4 * tSince
var templ = t2cof * tsq
if (!sgp4Simple) {
val delomg = omgcof * tSince
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
temp[0] = delomg + delm
xmp = xmdf + temp[0]
omega = omgadf - temp[0]
val tcube = tsq * tSince
val tfour = tSince * tcube
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
tempe += bstar * c5 * (sin(xmp) - sinmo)
templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
}
val a = aodp * tempa.pow(2.0)
val eo = data.eccn
val e = eo - tempe
val xl = xmp + omega + xnode + xnodp * templ
val beta = sqrt(1.0 - e * e)
val xn = XKE / a.pow(1.5)
// Long period periodics
val axn = e * cos(omega)
temp[0] = invert(a * sqr(beta))
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = e * sin(omega) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, xnode, a, xn, axn, ayn)
calculatePhase(xlt, xnode, omgadf)
val temp = DoubleArray(9)
val xmdf = data.xmo + xmdot * tSince
val omgadf = data.omegao + omgdot * tSince
val xnoddf = data.xnodeo + xnodot * tSince
var omega = omgadf
var xmp = xmdf
val tsq = tSince * tSince
val xnode = xnoddf + xnodcf * tsq
val bstar = data.bstar
var tempa = 1.0 - c1 * tSince
var tempe = bstar * c4 * tSince
var templ = t2cof * tsq
if (!sgp4Simple) {
val delomg = omgcof * tSince
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
temp[0] = delomg + delm
xmp = xmdf + temp[0]
omega = omgadf - temp[0]
val tcube = tsq * tSince
val tfour = tSince * tcube
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
tempe += bstar * c5 * (sin(xmp) - sinmo)
templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
}
val a = aodp * tempa * tempa
val eo = data.eccn
val e = eo - tempe
val xl = xmp + omega + xnode + xnodp * templ
val beta = sqrt(1.0 - e * e)
val xn = XKE / a.pow(1.5)
// Long period periodics
val axn = e * cos(omega)
temp[0] = invert(a * sqr(beta))
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = e * sin(omega) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, xnode, a, xn, axn, ayn)
calculatePhase(xlt, xnode, omgadf)
}
private fun calculatePosAndVel(
@@ -27,7 +27,8 @@ data class OrbitalData(
val argper: Double,
val meanan: Double,
val catnum: Int,
val bstar: Double
val bstar: Double,
val ndot: Double = 0.0
) {
val xincl: Double = incl * DEG2RAD
val xnodeo: Double = raan * DEG2RAD
@@ -37,4 +38,29 @@ data class OrbitalData(
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
/** Check if satellite has likely decayed by the given time. */
fun hasDecayed(currentTimeMillis: Long): Boolean {
if (ndot == 0.0) return false
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
val epochDaynum = epochToDaynum(epoch)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
}
private fun epochToDaynum(epoch: Double): Double {
var year = kotlin.math.floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
val a = kotlin.math.floor(aYear / 100).toLong()
val b = 2 - a + a / 4
val i = kotlin.math.floor(365.25 * aYear).toLong()
return i + (30.6001 * 14).toLong() + 1720994.5 + b
}
}
@@ -0,0 +1,141 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.predict
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.sin
import kotlin.math.sqrt
// ── Shared orbital math utilities ──
// Used by both CelestialComputer (sun/moon/celestial) and OrbitalObject (SGP4/SDP4).
// Package-internal — not part of the public API.
/**
* Greenwich Mean Sidereal Time from Julian Date, in radians [0, 2π).
* Identical algorithm used in PREDICT v2.2.5 for both solar and satellite calculations.
*/
internal fun thetaGJD(jd: Double): Double {
val ut = fraction(jd + 0.5)
val aJD = jd - ut
val tu = (aJD - 2451545.0) / 36525.0
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
gmst = modulus(gmst + SEC_PER_DAY * EARTH_ROT_PER_SID_DAY * ut, SEC_PER_DAY)
return TWO_PI * gmst / SEC_PER_DAY
}
/** Fractional part of [arg]. */
internal fun fraction(arg: Double): Double = arg - floor(arg)
/** Modulo: returns [arg1] mod [arg2], result always in [0, arg2). */
internal fun modulus(arg1: Double, arg2: Double): Double {
var r = arg1
val i = floor(r / arg2).toInt()
r -= i * arg2
if (r < 0.0) r += arg2
return r
}
/** Reduce [value] to [0, 2π). */
internal fun mod2PI(value: Double): Double {
var r = value
val i = (r / TWO_PI).toInt()
r -= i * TWO_PI
if (r < 0.0) r += TWO_PI
return r
}
/**
* Delta-ET: difference between Universal Time and Ephemeris Time (seconds).
* Based on least-squares fit from 1950 to 1991 (PREDICT v2.2.5).
*/
internal fun deltaET(year: Double): Double =
26.465 + 0.747622 * (year - 1950) + 1.886913 * sin(TWO_PI * (year - 1975) / 33)
/**
* Convert Unix epoch milliseconds to daynum (days since 31 Dec 1979 00:00:00 UTC).
*/
internal fun millisToDaynum(timeMillis: Long): Double =
(timeMillis - 315446400000L) / 86400000.0
/** Convert daynum back to Unix epoch milliseconds. */
internal fun daynumToMillis(daynum: Double): Long =
((daynum + 3651.0) * 86400000.0).toLong()
/**
* Compute the Sun's ECI position vector at [julUtc] (Julian UTC).
* Returns [x, y, z, magnitude] in km.
* Based on Calculate_Solar_Position() / FindSun() from PREDICT v2.2.5.
*/
internal fun solarPositionECI(julUtc: Double): DoubleArray {
val mjd = julUtc - 2415020.0
val year = 1900 + mjd / 365.25
val t = (mjd + deltaET(year) / SEC_PER_DAY) / 36525.0
val mDeg = mod360(358.47583 + mod360(35999.04975 * t) - (0.000150 + 0.0000033 * t) * t * t)
val m = mDeg * DEG2RAD
val lDeg = mod360(279.69668 + mod360(36000.76892 * t) + 0.0003025 * t * t)
val l = lDeg * DEG2RAD
val e = 0.01675104 - (0.0000418 + 0.000000126 * t) * t
val cDeg = (1.919460 - (0.004789 + 0.000014 * t) * t) * sin(m) +
(0.020094 - 0.000100 * t) * sin(2 * m) + 0.000293 * sin(3 * m)
val c = cDeg * DEG2RAD
val oDeg = mod360(259.18 - 1934.142 * t)
val o = oDeg * DEG2RAD
val lsa = mod2PI(l + c - (0.00569 - 0.00479 * sin(o)) * DEG2RAD)
val nu = mod2PI(m + c)
var r = 1.0000002 * (1.0 - e * e) / (1.0 + e * cos(nu))
val epsDeg = 23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * t) * t) * t + 0.00256 * cos(o)
val eps = epsDeg * DEG2RAD
r *= ASTRONOMICAL_UNIT
return doubleArrayOf(r * cos(lsa), r * sin(lsa) * cos(eps), r * sin(lsa) * sin(eps), r)
}
/**
* Convert ECI position [eciPos] = [x, y, z] (km) to geodetic [lat_rad, lon_rad, alt_km].
* Based on Calculate_LatLonAlt() from PREDICT v2.2.5.
*/
internal fun eciToGeodetic(julUtc: Double, eciPos: DoubleArray): DoubleArray {
val thetaPos = atan2(eciPos[1], eciPos[0])
val lon = mod2PI(thetaPos - thetaGJD(julUtc))
val r = sqrt(eciPos[0] * eciPos[0] + eciPos[1] * eciPos[1])
val e2 = FLAT_FACT * (2.0 - FLAT_FACT)
var lat = atan2(eciPos[2], r)
var phi: Double
var c: Double
var i = 0
do {
phi = lat
c = 1.0 / sqrt(1.0 - e2 * sin(phi) * sin(phi))
lat = atan2(eciPos[2] + EARTH_RADIUS * c * e2 * sin(phi), r)
} while (i++ < 10 && abs(lat - phi) >= 1E-10)
val alt = r / cos(lat) - EARTH_RADIUS * c
if (lat > PI_2) lat -= TWO_PI
return doubleArrayOf(lat, lon, alt)
}
// Private helpers
private fun mod360(x: Double): Double {
var r = x
val i = (r / 360.0).toInt()
r -= i * 360.0
if (r < 0.0) r += 360.0
return r
}
@@ -42,6 +42,27 @@ abstract class OrbitalObject(val data: OrbitalData) {
var qoms24 = 0.0
var s4 = 0.0
// Pre-allocated reusable vectors to avoid GC pressure in hot loops
private val obsPos = Vector4()
private val obsVel = Vector4()
private val rangeVector = Vector4()
private val rgvelVector = Vector4()
private val squintVector = Vector4()
// Cached observer position data to avoid recalculation when observer hasn't moved
private var cachedGsLat = Double.NaN
private var cachedGsLon = Double.NaN
private var cachedGsAlt = Double.NaN
private var cachedSinLat = 0.0
private var cachedCosLat = 0.0
private var cachedObsC = 0.0
private var cachedObsSq = 0.0
private var cachedObsAchFactor = 0.0
private var cachedObsZFactor = 0.0
// Cache for julian epoch to avoid recomputing every call
private val julEpoch: Double = juliandDateOfEpoch(data.epoch)
fun willBeSeen(pos: GeoPos): Boolean {
return if (data.meanmo < 1e-8) false
else {
@@ -57,15 +78,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
orbitalPos = OrbitalPos()
// Date/time at which the position and velocity were calculated
julUTC = calcCurrentDaynum(time) + 2444238.5
// Convert satellite's epoch time to Julian and calculate time since epoch in minutes
val julEpoch = juliandDateOfEpoch(data.epoch)
// Calculate time since epoch in minutes
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
calculateSDP4orSGP4(tsince)
// Scale position and velocity vectors to km and km/sec
convertSatState(position, velocity)
// Calculate velocity of satellite
magnitude(velocity)
val squintVector = Vector4()
// Angles in rads, dist in km, vel in km/S. Calculate sat Az, El, Range and Range-rate.
calculateObs(julUTC, position, velocity, pos, squintVector)
calculateLatLonAlt(julUTC)
@@ -75,6 +94,38 @@ abstract class OrbitalObject(val data: OrbitalData) {
return orbitalPos
}
/**
* Lightweight elevation-only check for pass finding. Avoids full lat/lon/alt,
* eclipse, and squint calculations that aren't needed when just searching for
* horizon crossings.
*/
fun getElevation(pos: GeoPos, time: Long): Double {
julUTC = calcCurrentDaynum(time) + 2444238.5
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
calculateSDP4orSGP4(tsince)
convertSatState(position, velocity)
magnitude(velocity)
calculateObs(julUTC, position, velocity, pos, squintVector)
return orbitalPos.elevation
}
/**
* Full position calculation that also populates azimuth, altitude, etc.
* Used when we need all fields (AOS/LOS refinement, track computation).
*/
fun getFullPosition(pos: GeoPos, time: Long): OrbitalPos {
orbitalPos = OrbitalPos()
julUTC = calcCurrentDaynum(time) + 2444238.5
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
calculateSDP4orSGP4(tsince)
convertSatState(position, velocity)
magnitude(velocity)
calculateObs(julUTC, position, velocity, pos, squintVector)
calculateLatLonAlt(julUTC)
orbitalPos.time = time
return orbitalPos
}
private fun calcCurrentDaynum(now: Long): Double {
val then = 315446400000 // time in millis on 31Dec79 00:00:00 UTC (daynum 0)
return (now - then) / 1000.0 / 60.0 / 60.0 / 24.0
@@ -117,38 +168,34 @@ abstract class OrbitalObject(val data: OrbitalData) {
gsPos: GeoPos,
squintVector: Vector4
) {
val obsPos = Vector4()
val obsVel = Vector4()
val range = Vector4()
val rgvel = Vector4()
calculateUserPosVel(julianUTC, gsPos, obsPos, obsVel)
range.setXYZ(
rangeVector.setXYZ(
positionVector.x - obsPos.x,
positionVector.y - obsPos.y,
positionVector.z - obsPos.z
)
// Save these values globally for calculating squint angles later
squintVector.setXYZ(range.x, range.y, range.z)
rgvel.setXYZ(
squintVector.setXYZ(rangeVector.x, rangeVector.y, rangeVector.z)
rgvelVector.setXYZ(
velocityVector.x - obsVel.x,
velocityVector.y - obsVel.y,
velocityVector.z - obsVel.z
)
magnitude(range)
val sinLat = sin(DEG2RAD * gsPos.latitude)
val cosLat = cos(DEG2RAD * gsPos.latitude)
magnitude(rangeVector)
val sinLat = cachedSinLat
val cosLat = cachedCosLat
val sinTheta = sin(gsPosTheta)
val cosTheta = cos(gsPosTheta)
val topS = sinLat * cosTheta * range.x + sinLat * sinTheta * range.y - cosLat * range.z
val topE = -sinTheta * range.x + cosTheta * range.y
val topZ = cosLat * cosTheta * range.x + cosLat * sinTheta * range.y + sinLat * range.z
val topS = sinLat * cosTheta * rangeVector.x + sinLat * sinTheta * rangeVector.y - cosLat * rangeVector.z
val topE = -sinTheta * rangeVector.x + cosTheta * rangeVector.y
val topZ = cosLat * cosTheta * rangeVector.x + cosLat * sinTheta * rangeVector.y + sinLat * rangeVector.z
var azim = atan(-topE / topS)
if (topS > 0.0) azim += PI
if (azim < 0.0) azim += TWO_PI
orbitalPos.azimuth = azim
orbitalPos.elevation = asin(topZ / range.w)
orbitalPos.distance = range.w
orbitalPos.distanceRate = dot(range, rgvel) / range.w
orbitalPos.elevation = asin(topZ / rangeVector.w)
orbitalPos.distance = rangeVector.w
orbitalPos.distanceRate = dot(rangeVector, rgvelVector) / rangeVector.w
var elevation = orbitalPos.elevation / TWO_PI * 360.0
if (elevation > 90) elevation = 180 - elevation
orbitalPos.aboveHorizon = elevation - 0 > EPSILON
@@ -163,12 +210,24 @@ abstract class OrbitalObject(val data: OrbitalData) {
) {
val mFactor = 7.292115E-5
gsPosTheta = mod2PI(thetaGJD(time) + DEG2RAD * gsPos.longitude)
val c = invert(sqrt(1.0 + FLAT_FACT * (FLAT_FACT - 2) * sqr(sin(DEG2RAD * gsPos.latitude))))
val sq = sqr(1.0 - FLAT_FACT) * c
val achcp = (EARTH_RADIUS * c + gsPos.altitude / 1000.0) * cos(DEG2RAD * gsPos.latitude)
// Cache trig and position factors when observer position changes
if (gsPos.latitude != cachedGsLat || gsPos.longitude != cachedGsLon || gsPos.altitude != cachedGsAlt) {
cachedGsLat = gsPos.latitude
cachedGsLon = gsPos.longitude
cachedGsAlt = gsPos.altitude
cachedSinLat = sin(DEG2RAD * gsPos.latitude)
cachedCosLat = cos(DEG2RAD * gsPos.latitude)
cachedObsC = invert(sqrt(1.0 + FLAT_FACT * (FLAT_FACT - 2) * sqr(cachedSinLat)))
cachedObsSq = sqr(1.0 - FLAT_FACT) * cachedObsC
cachedObsAchFactor = (EARTH_RADIUS * cachedObsC + gsPos.altitude / 1000.0) * cachedCosLat
cachedObsZFactor = (EARTH_RADIUS * cachedObsSq + gsPos.altitude / 1000.0) * cachedSinLat
}
obsPos.setXYZ(
achcp * cos(gsPosTheta), achcp * sin(gsPosTheta),
(EARTH_RADIUS * sq + gsPos.altitude / 1000.0) * sin(DEG2RAD * gsPos.latitude)
cachedObsAchFactor * cos(gsPosTheta),
cachedObsAchFactor * sin(gsPosTheta),
cachedObsZFactor
)
obsVel.setXYZ(-mFactor * obsPos.y, mFactor * obsPos.x, 0.0)
magnitude(obsPos)
@@ -255,14 +314,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
return 1.0 / value
}
// Calculates the modulus of 2 * PI
internal fun mod2PI(value: Double): Double {
var retVal = value
val i = (retVal / TWO_PI).toInt()
retVal -= i * TWO_PI
if (retVal < 0.0) retVal += TWO_PI
return retVal
}
// Delegates to package-level mod2PI in OrbitalMath.kt
internal fun mod2PI(value: Double): Double = com.rtbishop.look4sat.core.domain.predict.mod2PI(value)
// Solves Keplers' Equation
internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) {
@@ -364,19 +417,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
return acos(dot(v1, v2) / (v1.w * v2.w))
}
/**
* The function Delta_ET has been added to allow calculations on the
* position of the sun. It provides the difference between UT (approximately
* the same as UTC) and ET (now referred to as TDT) This function is based
* on a least squares fit of data from 1950 to 1991 and will need to be
* updated periodically.
*
* Values determined using data from 1950-1991 in the 1990 Astronomical
* Almanac. See DELTA_ET.WQ1 for details.
*/
private fun deltaEt(year: Double): Double {
return 26.465 + 0.747622 * (year - 1950) + (1.886913 * sin(TWO_PI * (year - 1975) / 33))
}
// Delegates to package-level deltaET in OrbitalMath.kt
private fun deltaEt(year: Double): Double = deltaET(year)
private fun radians(degrees: Double): Double {
return degrees * DEG2RAD
@@ -387,23 +429,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z
}
// Returns fractional part of double argument
private fun fraction(arg: Double): Double {
return arg - floor(arg)
}
// Calculates scalar magnitude of a vector4 argument
private fun magnitude(v: Vector4) {
v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z))
}
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double {
var returnValue = arg1
val i = floor(returnValue / arg2).toInt()
returnValue -= i * arg2
if (returnValue < 0.0) returnValue += arg2
return returnValue
}
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double =
com.rtbishop.look4sat.core.domain.predict.modulus(arg1, arg2)
// Multiplies the vector v1 by the scalar k
private fun scaleVector(k: Double, v: Vector4) {
@@ -411,13 +443,6 @@ abstract class OrbitalObject(val data: OrbitalData) {
magnitude(v)
}
private fun thetaGJD(theJD: Double): Double {
val earthRotPerSidDay = 1.00273790934
val ut = fraction(theJD + 0.5)
val aJD = theJD - ut
val tu = (aJD - 2451545.0) / 36525.0
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
gmst = modulus(gmst + SEC_PER_DAY * earthRotPerSidDay * ut)
return TWO_PI * gmst / SEC_PER_DAY
}
// Delegates to package-level thetaGJD in OrbitalMath.kt
private fun thetaGJD(theJD: Double): Double = com.rtbishop.look4sat.core.domain.predict.thetaGJD(theJD)
}
@@ -25,9 +25,10 @@ data class OrbitalPass(
val altitude: Int = 1000,
val maxElevation: Double = 75.0,
val orbitalObject: OrbitalObject,
var progress: Float = 0.0f
val progress: Float = 0.0f,
val hasDecayed: Boolean = false
) {
val catNum: Int = orbitalObject.data.catnum
val name: String = orbitalObject.data.name
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
}
@@ -21,7 +21,6 @@ import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
@@ -50,23 +49,30 @@ data class OrbitalPos(
}
fun getOrbitalVelocity(): Double {
val earthG = 6.674 * 10.0.pow(-11)
val earthM = 5.98 * 10.0.pow(24)
val radius = 6.37 * 10.0.pow(6) + altitude * 10.0.pow(3)
return sqrt(earthG * earthM / radius) / 1000
val radius = EARTH_RADIUS_M + altitude * 1000.0
return sqrt(GM_EARTH / radius) / 1000.0
}
fun getRangeCircle(): List<GeoPos> {
val rangeCirclePoints = mutableListOf<GeoPos>()
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude)) // * EARTH_RADIUS = radiusKm
val pointCount = 721
val rangeCirclePoints = ArrayList<GeoPos>(pointCount)
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude))
val sinLat = sin(latitude)
val cosLat = cos(latitude)
val cosBeta = cos(beta)
val sinBeta = sin(beta)
for (azimuth in 0..720) {
val rads = azimuth * DEG2RAD
val lat = asin(sin(latitude) * cos(beta) + (cos(latitude) * sin(beta) * cos(rads)))
val lon = (longitude + atan2(
sin(rads) * sin(beta) * cos(latitude), cos(beta) - sin(latitude) * sin(lat)
))
val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cos(rads))
val lon = longitude + atan2(sin(rads) * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
}
return rangeCirclePoints
}
companion object {
// Pre-computed constants for orbital velocity calculation
private const val GM_EARTH = 3.986004418E14 // m^3/s^2
private const val EARTH_RADIUS_M = 6.37E6 // meters
}
}
@@ -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?
}
@@ -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()
}
@@ -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,13 +30,43 @@ 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(): IReporterRepo<WithoutExtParams>
fun provideNetworkReporter(): IReporterRepo<ExtendedParams>
fun provideBluetoothReporter(): IReporter
fun provideNetworkReporter(): IReporter
fun provideSensorsRepo(): ISensorsRepo
fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage
// 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
}
@@ -0,0 +1,88 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
interface IRadioController {
val isConnected: Boolean
suspend fun connect(): Boolean
suspend fun disconnect()
suspend fun setFrequency(frequencyHz: Long): Boolean
suspend fun setMode(mode: String): Boolean
suspend fun setCtcssMode(enabled: Boolean): Boolean
suspend fun setCtcssTone(toneHz: Double): Boolean
suspend fun readFrequencyAndMode(): Pair<Long, String>?
suspend fun pttOn(): Boolean
suspend fun pttOff(): Boolean
// ── Extended operations (IC-705 / CI-V) ──────────────────────────────
/**
* Select the band matching [frequencyHz] via the band stacking register.
* Must be called before [setFrequency] and [setMode] when first tracking.
* Default: no-op (Yaesu radios auto-switch band via frequency).
*/
suspend fun setBand(frequencyHz: Long): Boolean = false
/**
* Select the active VFO.
* @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split).
*/
suspend fun setVfo(vfoA: Boolean): Boolean = false
/**
* Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO).
* Default: not supported.
*/
suspend fun setSplitMode(enabled: Boolean): Boolean = false
/**
* Set the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
* Default: delegates to [setFrequency].
*/
suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz)
/**
* Set the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
* Sent every tracking cycle alongside [setWorkingFrequency] in split mode.
* Default: delegates to [setWorkingFrequency].
*/
suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz)
/**
* Read the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
* Default: delegates to [readFrequencyAndMode].
*/
suspend fun readWorkingFrequency(): Long? = readFrequencyAndMode()?.first
/**
* Read the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
* Used for tuning detection in split mode.
* Default: delegates to [readWorkingFrequency].
*/
suspend fun readTxVfoFrequency(): Long? = readWorkingFrequency()
}
@@ -0,0 +1,55 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import kotlinx.coroutines.flow.StateFlow
data class RadioTrackingState(
val isActive: Boolean = false,
val txConnected: Boolean = false,
val rxConnected: Boolean = false,
val txFrequencyHz: Long? = null,
val rxFrequencyHz: Long? = null,
val txMode: String? = null,
val rxMode: String? = null,
val ctcssTone: Double? = null,
val txBaseFrequencyHz: Long? = null,
val selectedTransponder: SatRadio? = null,
val currentPass: OrbitalPass? = null,
val azimuth: Double = 0.0,
val elevation: Double = 0.0,
val distance: Double = 0.0,
val errorMessage: String? = null
)
interface IRadioTrackingService {
val state: StateFlow<RadioTrackingState>
suspend fun connectRadios()
suspend fun disconnectRadios()
fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?)
fun stopTracking()
fun setTransponder(transponder: SatRadio)
fun setTxBaseFrequency(frequencyHz: Long)
fun adjustTxBaseFrequency(deltaHz: Long)
fun setCtcssTone(toneHz: Double?)
fun setMode(txMode: String, rxMode: String)
}
@@ -17,18 +17,7 @@
*/
package com.rtbishop.look4sat.core.domain.repository
interface IReporterParams
interface IReporterRepo<T : IReporterParams> {
fun isConnected(service: BtService): Boolean
fun isConnecting(service: BtService): Boolean
fun connect(service: BtService, deviceId: String)
fun reportRotation(format: String, azimuth: Double, elevation: Double, params: T)
fun reportFrequency(format: String, frequency: Long, params: T)
interface IReporter {
fun reportRotation(format: String, azimuth: Double, elevation: Double)
fun reportFrequency(format: String, frequency: Long)
}
data class ExtendedParams(val server: String, val port: Int) : IReporterParams
data class WithoutExtParams(val dummy: Int) : IReporterParams
enum class BtService { ROTATOR, FREQUENCY }
@@ -25,13 +25,44 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import kotlinx.coroutines.flow.StateFlow
interface ISatelliteRepo {
val passes: StateFlow<List<OrbitalPass>>
/** All selected OrbitalObjects, updated when the selection changes. */
val satellites: StateFlow<List<OrbitalObject>>
suspend fun getRadiosWithId(id: Int): List<SatRadio>
/** Raw calculated passes (without live progress). Updated on selection/filter change. */
val passes: StateFlow<List<OrbitalPass>>
/** Whether the repo is currently calculating passes. */
val isCalculating: StateFlow<Boolean>
/** Currently selected pass (catNum + aosTime), persisted across screen navigations. */
val selectedPass: StateFlow<Pair<Int, Long>>
/** Set the currently selected pass. */
fun selectPass(catNum: Int, aosTime: Long)
/** Load satellite objects from DB based on the current selection. */
suspend fun initRepository()
/** Recalculate passes with the given filter parameters. */
suspend fun calculatePasses(
time: Long,
hoursAhead: Int,
minElevation: Double,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
modes: List<String>
)
/** Get the current position of a single satellite. */
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
/** Get the ground track for a satellite over a time range. */
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass>
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
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>
}
@@ -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()
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
@@ -44,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
@@ -57,39 +59,23 @@ interface ISettingsRepo {
//region # RC settings
val rcSettings: StateFlow<RCSettings>
fun setBluetoothRotatorAddress(value: String)
fun setBluetoothRotatorFormat(value: String)
fun setBluetoothRotatorName(value: String)
fun setBluetoothRotatorState(value: Boolean)
fun setBluetoothFrequencyAddress(value: String)
fun setBluetoothFrequencyFormat(value: String)
fun setBluetoothFrequencyState(value: Boolean)
fun setRotatorAddress(value: String)
fun setRotatorPort(value: String)
fun setRotatorState(value: Boolean)
fun setRotatorFormat(value: String)
fun setFrequencyAddress(value: String)
fun setFrequencyPort(value: String)
fun setFrequencyState(value: Boolean)
fun setFrequencyFormat(value: String)
fun updateRCSettings(settings: RCSettings)
//endregion
//region # Other settings
val otherSettings: StateFlow<OtherSettings>
fun setStateOfAutoUpdate(value: Boolean)
fun setStateOfSensors(value: Boolean)
fun setStateOfSweep(value: Boolean)
fun setStateOfUtc(value: Boolean)
fun setStateOfLightTheme(value: Boolean)
fun setWarningDismissed()
fun setWhatsNewDismissed()
fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings)
fun setWarningDismissed() = updateOtherSettings { it.copy(shouldSeeWarning = false) }
fun setWhatsNewDismissed() = updateOtherSettings { it.copy(shouldSeeWhatsNew = false) }
//endregion
//region # Transceivers settings
val dataSourcesSettings: StateFlow<DataSourcesSettings>
fun setUseCustomTle(value: Boolean)
fun setUseCustomTransceivers(value: Boolean)
fun setTleUrl(value: String)
fun setTransceiversUrl(value: String)
fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion
//region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion
}
@@ -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)
}
@@ -21,134 +21,94 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
import java.io.InputStream
import kotlin.math.pow
class DataParser(private val dispatcher: CoroutineDispatcher) {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
val parsedItems = mutableListOf<OrbitalData>()
stream.bufferedReader().useLines { lines ->
lines.forEachIndexed { index, line ->
if (index != 0) {
val values = line.split(",")
parseCSV(values)?.let { tle -> parsedItems.add(tle) }
}
}
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
}
return@withContext parsedItems
}
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
val tleStrings = mutableListOf(String(), String(), String())
val parsedItems = mutableListOf<OrbitalData>()
var lineIndex = 0
stream.bufferedReader().forEachLine { line ->
tleStrings[lineIndex] = line
if (lineIndex < 2) {
lineIndex++
} else {
val isLineOneValid = tleStrings[1].substring(0, 1) == "1"
val isLineTwoValid = tleStrings[2].substring(0, 1) == "2"
if (!isLineOneValid && !isLineTwoValid) return@forEachLine
parseTLE(tleStrings)?.let { tle -> parsedItems.add(tle) }
lineIndex = 0
}
}
return@withContext parsedItems
stream.bufferedReader().readLines()
.chunked(3)
.filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") }
.mapNotNull { parseTLE(it) }
}
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
val parsedItems = mutableListOf<SatRadio>()
try {
val jsonArray = JSONArray(stream.bufferedReader().readText())
for (index in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(index)
parseJSON(jsonObject)?.let { parsedItems.add(it) }
}
return@withContext parsedItems
} catch (_: Exception) {
return@withContext parsedItems
}
runCatching {
val root = json.parseToJsonElement(stream.bufferedReader().readText())
(root as? JsonArray)?.mapNotNull { element ->
runCatching { json.decodeFromJsonElement<SatRadio>(element) }
.onFailure { println("JSON parsing exception: $it") }
.getOrNull()
} ?: emptyList()
}.getOrDefault(emptyList())
}
fun isLeapYear(year: Int): Boolean {
return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)
}
private fun parseCSV(values: List<String>): OrbitalData? = try {
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
val name = values[0]
val year = values[2].substring(0, 4)
val month = values[2].substring(5, 7)
val dayOfMonth = values[2].substring(8, 10)
val dayInt = getDayOfYear(year.toInt(), month.toInt(), dayOfMonth.toInt())
val day = if (dayInt < 10) "00$dayInt" else if (dayInt < 100) "0$dayInt" else "$dayInt"
val hour = values[2].substring(11, 13).toInt() * 3600000 // ms in one hour
val min = values[2].substring(14, 16).toInt() * 60000 // ms in one minute
val sec = values[2].substring(17, 19).toInt() * 1000 // ms in one second
val ms = values[2].substring(20, 26).toInt() / 1000.0 // microseconds to ms
val frac = ((hour + min + sec + ms) / 86400000.0).toString()
val epoch = "${year.substring(2)}$day${frac.substring(1)}".toDouble()
val meanmo = values[3].toDouble()
val eccn = values[4].toDouble()
val incl = values[5].toDouble()
val raan = values[6].toDouble()
val argper = values[7].toDouble()
val meanan = values[8].toDouble()
val catnum = values[11].toInt()
val bstar = values[14].toDouble()
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
} catch (exception: Exception) {
println("CSV parsing exception: $exception")
null
}
val timestamp = values[2]
val year = timestamp.substring(0, 4)
val month = timestamp.substring(5, 7).toInt()
val dayOfMonth = timestamp.substring(8, 10).toInt()
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
val day = dayInt.toString().padStart(3, '0')
val hour = timestamp.substring(11, 13).toInt() * 3600000
val min = timestamp.substring(14, 16).toInt() * 60000
val sec = timestamp.substring(17, 19).toInt() * 1000
val ms = timestamp.substring(20, 26).toInt() / 1000.0
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
val epoch = "${year.substring(2)}$day$frac".toDouble()
OrbitalData(
name = name,
epoch = epoch,
meanmo = values[3].toDouble(),
eccn = values[4].toDouble(),
incl = values[5].toDouble(),
raan = values[6].toDouble(),
argper = values[7].toDouble(),
meanan = values[8].toDouble(),
catnum = values[11].toInt(),
bstar = values[14].toDouble(),
ndot = values[15].toDouble()
)
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
private fun parseTLE(tle: List<String>): OrbitalData? = try {
val name: String = tle[0].trim()
val epoch: Double = tle[1].substring(18, 32).toDouble()
val meanmo: Double = tle[2].substring(52, 63).toDouble()
val eccn: Double = tle[2].substring(26, 33).toDouble() / 10000000.0
val incl: Double = tle[2].substring(8, 16).toDouble()
val raan: Double = tle[2].substring(17, 25).toDouble()
val argper: Double = tle[2].substring(34, 42).toDouble()
val meanan: Double = tle[2].substring(43, 51).toDouble()
val catnum: Int = tle[1].substring(2, 7).trim().toInt()
val bstar: Double = 1.0e-5 * tle[1].substring(53, 59).toDouble() / 10.0.pow(tle[1].substring(60, 61).toDouble())
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
} catch (exception: Exception) {
println("TLE parsing exception: $exception")
null
}
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
val line1 = tle[1]
val line2 = tle[2]
OrbitalData(
name = tle[0].trim(),
epoch = line1.substring(18, 32).toDouble(),
meanmo = line2.substring(52, 63).toDouble(),
eccn = line2.substring(26, 33).toDouble() / 1e7,
incl = line2.substring(8, 16).toDouble(),
raan = line2.substring(17, 25).toDouble(),
argper = line2.substring(34, 42).toDouble(),
meanan = line2.substring(43, 51).toDouble(),
catnum = line1.substring(2, 7).trim().toInt(),
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
ndot = line1.substring(33, 43).trim().toDouble()
)
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
private fun parseJSON(json: JSONObject): SatRadio? = try {
val uuid = json.getString("uuid")
val info = json.getString("description")
val alive = json.getBoolean("alive")
val dlinkLow = if (json.isNull("downlink_low")) null else json.getLong("downlink_low")
val dlinkHigh = if (json.isNull("downlink_high")) null else json.getLong("downlink_high")
val dlinkMode = if (json.isNull("mode")) null else json.getString("mode")
val ulinkLow = if (json.isNull("uplink_low")) null else json.getLong("uplink_low")
val ulinkHigh = if (json.isNull("uplink_high")) null else json.getLong("uplink_high")
val ulinkMode = if (json.isNull("uplink_mode")) null else json.getString("uplink_mode")
val inverted = json.getBoolean("invert")
val catnum = if (json.isNull("norad_cat_id")) null else json.getInt("norad_cat_id")
SatRadio(uuid, info, alive, dlinkLow, dlinkHigh, dlinkMode, ulinkLow, ulinkHigh, ulinkMode, inverted, catnum)
} catch (exception: Exception) {
println("JSON parsing exception: $exception")
null
}
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
if (month == 1) return dayOfMonth
val daysArray = arrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
var dayOfYear = dayOfMonth
// If leap year increment Feb days
if (isLeapYear(year)) daysArray[1]++
for (i in 0 until month - 1) {
dayOfYear += daysArray[i]
}
return dayOfYear
fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
return daysInMonth.take(month - 1).sum() + dayOfMonth
}
}
@@ -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)
}
}
@@ -21,10 +21,11 @@ import java.util.Locale
import java.util.concurrent.TimeUnit
fun Long.toTimerString(): String {
val millis = coerceAtLeast(0L)
val format = "%02d:%02d:%02d"
val hours = TimeUnit.MILLISECONDS.toHours(this)
val minutes = TimeUnit.MILLISECONDS.toMinutes(this) % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(this) % 60
val hours = TimeUnit.MILLISECONDS.toHours(millis)
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
return String.format(Locale.ENGLISH, format, hours, minutes, seconds)
}
@@ -17,11 +17,18 @@
*/
package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import kotlin.math.acos
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
private const val AVG_EARTH_RADIUS_KM = 6371.009
private const val MIN_LATITUDE = -85.05112877980658
private const val MAX_LATITUDE = 85.05112877980658
private const val MIN_LONGITUDE = -180.0
@@ -48,6 +55,27 @@ fun Double.toRadians(): Double = this * DEG2RAD
// return MIN_LONGITUDE + (MAX_LONGITUDE - MIN_LONGITUDE) * this
//}
// Great-circle distance between two positions in kilometers using the spherical law of cosines.
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val lat1R = lat1.toRadians()
val lat2R = lat2.toRadians()
val lon1R = lon1.toRadians()
val lon2R = lon2.toRadians()
return acos(
sin(lat1R) * sin(lat2R) + cos(lat1R) * cos(lat2R) * cos(lon2R - lon1R)
) * AVG_EARTH_RADIUS_KM
}
// Initial bearing (azimuth) from position 1 to position 2, in degrees (0-360).
fun bearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val lat1R = lat1.toRadians()
val lat2R = lat2.toRadians()
val dLon = (lon2 - lon1).toRadians()
val y = sin(dLon) * cos(lat2R)
val x = cos(lat1R) * sin(lat2R) - sin(lat1R) * cos(lat2R) * cos(dLon)
return (atan2(y, x).toDegrees() + 360) % 360
}
fun clipLat(latitude: Double): Double {
return clip(latitude, MIN_LATITUDE, MAX_LATITUDE)
}
@@ -59,6 +87,13 @@ fun clipLon(longitude: Double): Double {
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
}
fun OrbitalPos.toMapGeoPos(): GeoPos {
return GeoPos(
latitude = clipLat(latitude.toDegrees()),
longitude = clipLon(longitude.toDegrees())
)
}
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
return min(max(currentValue, minValue), maxValue)
}
@@ -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,73 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
object TransponderMapper {
fun mapUplinkToDownlink(txFreqHz: Long, transponder: SatRadio): Long? {
val uplinkLow = transponder.uplinkLow ?: return null
val downlinkLow = transponder.downlinkLow ?: return null
// Single-frequency transponder (FM repeater) - just return the downlink freq
val uplinkHigh = transponder.uplinkHigh
val downlinkHigh = transponder.downlinkHigh
if (uplinkHigh == null || downlinkHigh == null
|| uplinkLow == uplinkHigh || downlinkLow == downlinkHigh
) {
return downlinkLow
}
// Passband transponder (linear) - map within the band
val offset = txFreqHz - uplinkLow
return if (transponder.isInverted) {
downlinkHigh - offset
} else {
downlinkLow + offset
}
}
fun mapDownlinkToUplink(rxFreqHz: Long, transponder: SatRadio): Long? {
val uplinkLow = transponder.uplinkLow ?: return null
val downlinkLow = transponder.downlinkLow ?: return null
val uplinkHigh = transponder.uplinkHigh
val downlinkHigh = transponder.downlinkHigh
if (uplinkHigh == null || downlinkHigh == null
|| uplinkLow == uplinkHigh || downlinkLow == downlinkHigh
) {
return uplinkLow
}
return if (transponder.isInverted) {
uplinkLow + (downlinkHigh - rxFreqHz)
} else {
uplinkLow + (rxFreqHz - downlinkLow)
}
}
fun mapUplinkModeToDownlinkMode(txMode: String, isInverted: Boolean): String {
if (!isInverted) return txMode
return when (txMode.uppercase()) {
"USB" -> "LSB"
"LSB" -> "USB"
else -> txMode
}
}
}
@@ -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())
}
}
@@ -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)
}
}
@@ -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!!") == "----")
}
}
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff. Show more