package main import ( "context" "log" "time" "SatMaster/backend/doppler" "SatMaster/backend/flexradio" "SatMaster/backend/propagator" "SatMaster/backend/rotor" "SatMaster/backend/skycat" "SatMaster/backend/tle" "github.com/wailsapp/wails/v2/pkg/runtime" ) // RadioClient is the common interface for FlexRadio and SkyCAT backends. type RadioClient interface { IsConnected() bool SetFrequency(downHz, upHz float64) error SetMode(mode string) error ResetDeadband() Disconnect() } // 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 radioType string // "flex" or "skycat" ctx context.Context tleManager *tle.Manager propagator *propagator.Engine flexRadio *flexradio.Client skyCat *skycat.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, radioType: "flex", tleManager: tle.NewManager(), propagator: propagator.NewEngine(), flexRadio: flexradio.NewClient(), skyCat: skycat.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) } // activeRadio returns the currently selected radio client. func (a *App) activeRadio() RadioClient { if a.radioType == "skycat" { return a.skyCat } return a.flexRadio } func (a *App) shutdown(ctx context.Context) { a.StopTracking() a.flexRadio.Disconnect() a.skyCat.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.activeRadio().IsConnected() && pos.Elevation >= 0 && a.dopplerEnabled && a.trackFreqMode { upFreq, downFreq := a.dopplerCalc.Correct(pos, obs, now) if err := a.activeRadio().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 radio mode for satellite operation. // Called automatically when a frequency is selected in the frontend. func (a *App) SetSatelliteMode(mode string) { radio := a.activeRadio() if !radio.IsConnected() { return } var converted string if a.radioType == "skycat" { converted = skycat.SatModeToSkyCAT(mode) } else { converted = flexradio.SatModeToFlex(mode) } if err := radio.SetMode(converted); err != nil { log.Printf("[Radio] 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.activeRadio().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 FlexRadio connection status. func (a *App) GetFlexRadioStatus() bool { return a.flexRadio.IsConnected() } // SetRadioType switches between "flex" and "skycat" backends. func (a *App) SetRadioType(radioType string) { a.radioType = radioType log.Printf("[Radio] Backend switched to: %s", radioType) } // GetRadioType returns the current radio backend type. func (a *App) GetRadioType() string { return a.radioType } // ConnectSkyCAT connects to skycatd TCP server. func (a *App) ConnectSkyCAT(host string, port int) string { if err := a.skyCat.Connect(host, port); err != nil { return "Error: " + err.Error() } return "OK" } // DisconnectSkyCAT closes the SkyCAT connection. func (a *App) DisconnectSkyCAT() { a.skyCat.Disconnect() } // GetSkyCATStatus returns SkyCAT connection status. func (a *App) GetSkyCATStatus() bool { return a.skyCat.IsConnected() } // SetSkyCATMode sets the SkyCAT satellite operating mode (Duplex/Split/Simplex). func (a *App) SetSkyCATMode(mode string) string { var m skycat.SatMode switch mode { case "Split": m = skycat.ModeSplit case "Simplex": m = skycat.ModeSimplex default: m = skycat.ModeDuplex } if err := a.skyCat.SetSatMode(m); err != nil { return "Error: " + err.Error() } return "OK" } // 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 }