380 lines
11 KiB
Go
380 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"time"
|
|
|
|
"SatMaster/backend/doppler"
|
|
"SatMaster/backend/flexradio"
|
|
"SatMaster/backend/propagator"
|
|
"SatMaster/backend/rotor"
|
|
"SatMaster/backend/tle"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// App holds all subsystems and is the Wails binding target.
|
|
type App struct {
|
|
dopplerEnabled bool
|
|
rotorEnabled bool
|
|
rotorAzOnly bool
|
|
trackFreqMode bool // Track freq+mode for current sat
|
|
trackAzimuth bool // Track azimuth for current sat
|
|
savedRxSlice int
|
|
savedTxSlice int
|
|
ctx context.Context
|
|
|
|
tleManager *tle.Manager
|
|
propagator *propagator.Engine
|
|
flexRadio *flexradio.Client
|
|
rotorClient *rotor.PstRotatorClient
|
|
dopplerCalc *doppler.Calculator
|
|
|
|
// Tracking state
|
|
trackedSat string
|
|
trackingOn bool
|
|
cancelTrack context.CancelFunc
|
|
watchlist []string
|
|
}
|
|
|
|
func NewApp() *App {
|
|
return &App{
|
|
dopplerEnabled: true,
|
|
rotorEnabled: true,
|
|
rotorAzOnly: true,
|
|
trackFreqMode: false,
|
|
trackAzimuth: false,
|
|
savedRxSlice: 0, // defaults — overridden by SetSliceConfig from Settings on connect
|
|
savedTxSlice: 1,
|
|
tleManager: tle.NewManager(),
|
|
propagator: propagator.NewEngine(),
|
|
flexRadio: flexradio.NewClient(),
|
|
rotorClient: rotor.NewPstRotatorClient(),
|
|
dopplerCalc: doppler.NewCalculator(),
|
|
watchlist: []string{"ISS (ZARYA)", "AO-7", "AO-27", "SO-50", "RS-44", "AO-91", "FO-29"},
|
|
}
|
|
}
|
|
|
|
func (a *App) startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
log.Println("[SatMaster] Startup")
|
|
|
|
// Load TLEs on startup (try remote, fallback local)
|
|
go func() {
|
|
if err := a.tleManager.FetchAndCache(); err != nil {
|
|
log.Printf("[TLE] Remote fetch failed: %v — loading local cache", err)
|
|
a.tleManager.LoadLocal()
|
|
}
|
|
runtime.EventsEmit(ctx, "tle:loaded", a.tleManager.SatelliteNames())
|
|
// Immediately emit positions after TLE load
|
|
allSats := a.tleManager.All()
|
|
log.Printf("[TLE] Loaded %d satellites, watchlist=%v", len(allSats), a.watchlist)
|
|
var initSats []*tle.Satellite
|
|
for _, name := range a.watchlist {
|
|
if s := a.tleManager.Get(name); s != nil {
|
|
initSats = append(initSats, s)
|
|
}
|
|
}
|
|
if len(initSats) == 0 {
|
|
log.Printf("[TLE] Watchlist resolved 0 sats, using all %d", len(allSats))
|
|
initSats = allSats
|
|
} else {
|
|
log.Printf("[TLE] Watchlist resolved %d sats", len(initSats))
|
|
}
|
|
if positions := a.propagator.AllPositions(initSats, time.Now()); len(positions) > 0 {
|
|
log.Printf("[TLE] Emitting %d initial positions", len(positions))
|
|
runtime.EventsEmit(ctx, "sat:positions", positions)
|
|
}
|
|
}()
|
|
|
|
// Emit position updates every second (filtered by watchlist)
|
|
go a.positionLoop(ctx)
|
|
}
|
|
|
|
func (a *App) shutdown(ctx context.Context) {
|
|
a.StopTracking()
|
|
a.flexRadio.Disconnect()
|
|
}
|
|
|
|
// positionLoop emits sat positions to frontend every second.
|
|
func (a *App) positionLoop(ctx context.Context) {
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
// Build sat list from watchlist; fall back to all if empty/unresolved
|
|
allSats := a.tleManager.All()
|
|
var sats []*tle.Satellite
|
|
for _, name := range a.watchlist {
|
|
if s := a.tleManager.Get(name); s != nil {
|
|
sats = append(sats, s)
|
|
}
|
|
}
|
|
if len(sats) == 0 {
|
|
sats = allSats
|
|
}
|
|
positions := a.propagator.AllPositions(sats, time.Now())
|
|
log.Printf("[Loop] watchlist=%d resolved=%d positions=%d", len(a.watchlist), len(sats), len(positions))
|
|
runtime.EventsEmit(ctx, "sat:positions", positions)
|
|
|
|
// If tracking active, update doppler + rotor
|
|
if a.trackingOn && a.trackedSat != "" {
|
|
a.updateTracking()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) updateTracking() {
|
|
sat := a.tleManager.Get(a.trackedSat)
|
|
if sat == nil {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
pos := a.propagator.Position(sat, now)
|
|
if pos == nil {
|
|
return
|
|
}
|
|
obs := a.propagator.ObserverPosition()
|
|
|
|
// Doppler: only when globally enabled AND tracking enabled for this sat AND el >= 0
|
|
if a.flexRadio.IsConnected() && pos.Elevation >= 0 && a.dopplerEnabled && a.trackFreqMode {
|
|
upFreq, downFreq := a.dopplerCalc.Correct(pos, obs, now)
|
|
if err := a.flexRadio.SetFrequency(downFreq, upFreq); err != nil {
|
|
log.Printf("[Doppler] SetFrequency error: %v", err)
|
|
}
|
|
}
|
|
|
|
// Rotor: only when globally enabled AND tracking enabled for this sat AND el >= 0
|
|
if a.rotorClient.IsConnected() && a.rotorEnabled && a.trackAzimuth && pos.Elevation >= 0 {
|
|
var rotErr error
|
|
if a.rotorAzOnly {
|
|
rotErr = a.rotorClient.SetAzOnly(pos.Azimuth)
|
|
} else {
|
|
rotErr = a.rotorClient.SetAzEl(pos.Azimuth, pos.Elevation)
|
|
}
|
|
if rotErr != nil {
|
|
log.Printf("[Rotor] error: %v", rotErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Wails-exposed methods ──────────────────────────────────────────────────
|
|
|
|
// GetSatelliteList returns all satellite names from loaded TLEs.
|
|
func (a *App) GetSatelliteList() []string {
|
|
return a.tleManager.SatelliteNames()
|
|
}
|
|
|
|
// GetPasses returns upcoming passes for a satellite over the next 24h.
|
|
func (a *App) GetPasses(satName string, hours float64) []propagator.Pass {
|
|
sat := a.tleManager.Get(satName)
|
|
if sat == nil {
|
|
return nil
|
|
}
|
|
return a.propagator.ComputePasses(sat, time.Now(), hours)
|
|
}
|
|
|
|
// GetCurrentPosition returns az/el/lat/lon for a satellite right now.
|
|
func (a *App) GetCurrentPosition(satName string) *propagator.SatPosition {
|
|
sat := a.tleManager.Get(satName)
|
|
if sat == nil {
|
|
return nil
|
|
}
|
|
return a.propagator.Position(sat, time.Now())
|
|
}
|
|
|
|
// SetObserverLocation sets the QTH for pass prediction and Doppler.
|
|
func (a *App) SetObserverLocation(lat, lon, altM float64) {
|
|
a.propagator.SetObserver(lat, lon, altM)
|
|
a.dopplerCalc.SetObserver(lat, lon, altM)
|
|
}
|
|
|
|
// SetSatelliteFrequencies configures nominal uplink/downlink for Doppler.
|
|
func (a *App) SetSatelliteFrequencies(downHz, upHz float64) {
|
|
a.dopplerCalc.SetNominal(downHz, upHz)
|
|
}
|
|
|
|
// SetSatelliteMode sets the FlexRadio slice mode for satellite operation.
|
|
// Called automatically when a frequency is selected in the frontend.
|
|
func (a *App) SetSatelliteMode(mode string) {
|
|
if !a.flexRadio.IsConnected() {
|
|
return
|
|
}
|
|
flexMode := flexradio.SatModeToFlex(mode)
|
|
if err := a.flexRadio.SetMode(flexMode); err != nil {
|
|
log.Printf("[FlexRadio] SetMode error: %v", err)
|
|
}
|
|
}
|
|
|
|
// StartTracking begins active tracking of a satellite.
|
|
func (a *App) StartTracking(satName string) string {
|
|
sat := a.tleManager.Get(satName)
|
|
if sat == nil {
|
|
return "Satellite not found: " + satName
|
|
}
|
|
// Reset per-satellite tracking toggles on satellite change
|
|
if a.trackedSat != satName {
|
|
a.trackFreqMode = false
|
|
a.trackAzimuth = false
|
|
}
|
|
a.trackedSat = satName
|
|
a.trackingOn = true
|
|
log.Printf("[Tracking] Started: %s", satName)
|
|
return "OK"
|
|
}
|
|
|
|
// StopTracking stops active tracking.
|
|
func (a *App) StopTracking() {
|
|
a.trackingOn = false
|
|
a.trackedSat = ""
|
|
if a.cancelTrack != nil {
|
|
a.cancelTrack()
|
|
}
|
|
}
|
|
|
|
// ConnectFlexRadio connects to the FlexRadio 8600 SmartSDR TCP API.
|
|
func (a *App) ConnectFlexRadio(host string, port int) string {
|
|
if err := a.flexRadio.Connect(host, port); err != nil {
|
|
return "Error: " + err.Error()
|
|
}
|
|
// Restore saved slice config and query slices
|
|
go func() {
|
|
time.Sleep(800 * time.Millisecond)
|
|
a.flexRadio.SetSlices(a.savedRxSlice, a.savedTxSlice)
|
|
a.flexRadio.QuerySlices()
|
|
}()
|
|
return "OK"
|
|
}
|
|
|
|
// DisconnectFlexRadio closes the FlexRadio connection.
|
|
func (a *App) DisconnectFlexRadio() {
|
|
a.flexRadio.Disconnect()
|
|
}
|
|
|
|
// ConnectRotor connects to PstRotator UDP.
|
|
func (a *App) ConnectRotor(host string, port int) string {
|
|
if err := a.rotorClient.Connect(host, port); err != nil {
|
|
return "Error: " + err.Error()
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
// DisconnectRotor closes the PstRotator connection.
|
|
func (a *App) DisconnectRotor() {
|
|
a.rotorClient.Disconnect()
|
|
}
|
|
|
|
// RefreshTLE forces a re-fetch of TLE data.
|
|
func (a *App) RefreshTLE() string {
|
|
if err := a.tleManager.FetchAndCache(); err != nil {
|
|
return "Error: " + err.Error()
|
|
}
|
|
runtime.EventsEmit(a.ctx, "tle:loaded", a.tleManager.SatelliteNames())
|
|
return "OK"
|
|
}
|
|
|
|
// SetRotorAzOnly sets azimuth-only mode (true) or az+el mode (false).
|
|
func (a *App) SetRotorAzOnly(azOnly bool) {
|
|
a.rotorAzOnly = azOnly
|
|
mode := "Az+El"
|
|
if azOnly {
|
|
mode = "Az only"
|
|
}
|
|
log.Printf("[Rotor] mode: %s", mode)
|
|
}
|
|
|
|
// SetRotorEnabled enables or disables rotator tracking.
|
|
func (a *App) SetRotorEnabled(enabled bool) {
|
|
a.rotorEnabled = enabled
|
|
log.Printf("[Rotor] tracking %s", map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
|
}
|
|
|
|
// GetRotorEnabled returns current rotator tracking state.
|
|
func (a *App) GetRotorEnabled() bool {
|
|
return a.rotorEnabled
|
|
}
|
|
|
|
// SetTrackFreqMode enables/disables frequency+mode tracking for current satellite.
|
|
func (a *App) SetTrackFreqMode(enabled bool) {
|
|
a.trackFreqMode = enabled
|
|
// Reset dead-band so freq is sent immediately when tracking starts
|
|
if enabled {
|
|
a.flexRadio.ResetDeadband()
|
|
}
|
|
log.Printf("[Doppler] Freq/Mode tracking: %v", enabled)
|
|
}
|
|
|
|
// SetTrackAzimuth enables/disables azimuth tracking for current satellite.
|
|
func (a *App) SetTrackAzimuth(enabled bool) {
|
|
a.trackAzimuth = enabled
|
|
// Reset rotor dead-band so it moves immediately
|
|
if enabled {
|
|
a.rotorClient.ResetDeadband()
|
|
}
|
|
log.Printf("[Rotor] Azimuth tracking: %v", enabled)
|
|
}
|
|
|
|
// SetDopplerEnabled enables or disables Doppler correction.
|
|
func (a *App) SetDopplerEnabled(enabled bool) {
|
|
a.dopplerEnabled = enabled
|
|
log.Printf("[Doppler] %s", map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
|
}
|
|
|
|
// GetDopplerEnabled returns current Doppler state.
|
|
func (a *App) GetDopplerEnabled() bool {
|
|
return a.dopplerEnabled
|
|
}
|
|
|
|
// GetFlexRadioStatus returns connection status.
|
|
func (a *App) GetFlexRadioStatus() bool {
|
|
return a.flexRadio.IsConnected()
|
|
}
|
|
|
|
// GetSliceConfig returns current RX/TX slice indices.
|
|
func (a *App) GetSliceConfig() map[string]int {
|
|
rx, tx := a.flexRadio.GetSlices()
|
|
return map[string]int{"rx": rx, "tx": tx}
|
|
}
|
|
|
|
// SetSliceConfig sets RX and TX slice indices (0=A, 1=B, ...).
|
|
func (a *App) SetSliceConfig(rxIdx, txIdx int) {
|
|
a.savedRxSlice = rxIdx
|
|
a.savedTxSlice = txIdx
|
|
a.flexRadio.SetSlices(rxIdx, txIdx)
|
|
log.Printf("[FlexRadio] Slice config saved: RX=%d TX=%d", rxIdx, txIdx)
|
|
}
|
|
|
|
// GetRotorStatus returns connection status.
|
|
func (a *App) GetRotorStatus() bool {
|
|
return a.rotorClient.IsConnected()
|
|
}
|
|
|
|
// GetTLEAge returns the age of the TLE cache in hours.
|
|
func (a *App) GetTLEAge() float64 {
|
|
return a.tleManager.AgeHours()
|
|
}
|
|
|
|
// GetGroundtrack returns lat/lon points for the next N minutes of orbit.
|
|
func (a *App) GetGroundtrack(satName string, minutes float64) []propagator.GroundtrackPoint {
|
|
sat := a.tleManager.Get(satName)
|
|
if sat == nil {
|
|
return nil
|
|
}
|
|
return a.propagator.ComputeGroundtrack(sat, time.Now(), minutes)
|
|
}
|
|
|
|
// GetWatchlist returns the current list of satellites to display on map.
|
|
func (a *App) GetWatchlist() []string {
|
|
return a.watchlist
|
|
}
|
|
|
|
// SetWatchlist sets which satellites to display on the map.
|
|
func (a *App) SetWatchlist(names []string) {
|
|
a.watchlist = names
|
|
}
|