455 lines
13 KiB
Go
455 lines
13 KiB
Go
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
|
|
}
|