diff --git a/app.go b/app.go index e7620f1..91fdde4 100644 --- a/app.go +++ b/app.go @@ -9,11 +9,21 @@ import ( "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 @@ -23,11 +33,13 @@ type App struct { 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 @@ -47,9 +59,11 @@ func NewApp() *App { 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"}, @@ -92,9 +106,18 @@ func (a *App) startup(ctx context.Context) { 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. @@ -142,9 +165,9 @@ func (a *App) updateTracking() { 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 { + if a.activeRadio().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 { + if err := a.activeRadio().SetFrequency(downFreq, upFreq); err != nil { log.Printf("[Doppler] SetFrequency error: %v", err) } } @@ -199,15 +222,21 @@ func (a *App) SetSatelliteFrequencies(downHz, upHz float64) { a.dopplerCalc.SetNominal(downHz, upHz) } -// SetSatelliteMode sets the FlexRadio slice mode for satellite operation. +// SetSatelliteMode sets the radio mode for satellite operation. // Called automatically when a frequency is selected in the frontend. func (a *App) SetSatelliteMode(mode string) { - if !a.flexRadio.IsConnected() { + radio := a.activeRadio() + if !radio.IsConnected() { return } - flexMode := flexradio.SatModeToFlex(mode) - if err := a.flexRadio.SetMode(flexMode); err != nil { - log.Printf("[FlexRadio] SetMode error: %v", err) + 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) } } @@ -304,7 +333,7 @@ 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() + a.activeRadio().ResetDeadband() } log.Printf("[Doppler] Freq/Mode tracking: %v", enabled) } @@ -330,11 +359,57 @@ func (a *App) GetDopplerEnabled() bool { return a.dopplerEnabled } -// GetFlexRadioStatus returns connection status. +// 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() diff --git a/backend/skycat/client.go b/backend/skycat/client.go new file mode 100644 index 0000000..b1fe0d1 --- /dev/null +++ b/backend/skycat/client.go @@ -0,0 +1,227 @@ +package skycat + +import ( + "bufio" + "fmt" + "log" + "net" + "strings" + "sync" + "sync/atomic" + "time" +) + +// SatMode defines the satellite operating mode for SkyCAT. +type SatMode string + +const ( + ModeDuplex SatMode = "Duplex" + ModeSplit SatMode = "Split" + ModeSimplex SatMode = "Simplex" +) + +// Client manages a TCP connection to skycatd / rigctld. +// Protocol: simple newline-terminated text commands (HamLib-compatible). +// +// Key commands: +// +// U Duplex — satellite duplex mode (RX≠TX) +// U Split — split VFO mode +// U Simplex — simplex mode +// F {hz} — set RX frequency (Hz, integer) +// I {hz} — set TX frequency (Hz, integer) +// M {mode} 0 — set RX mode (FM, USB, LSB, CW…) +// X {mode} 0 — set TX mode +type Client struct { + mu sync.Mutex + conn net.Conn + reader *bufio.Reader + connected atomic.Bool + satMode SatMode + lastDownHz float64 + lastUpHz float64 +} + +func NewClient() *Client { + return &Client{satMode: ModeDuplex} +} + +// Connect establishes a TCP connection to skycatd (default port 4532). +func (c *Client) Connect(host string, port int) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.connected.Load() { + c.disconnectLocked() + } + + addr := fmt.Sprintf("%s:%d", host, port) + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + return fmt.Errorf("SkyCAT connect %s: %w", addr, err) + } + + c.conn = conn + c.reader = bufio.NewReader(conn) + c.connected.Store(true) + + // Start read loop to drain responses (skycatd sends RPRT lines) + go c.readLoop() + + // Set satellite duplex mode immediately after connect + log.Printf("[SkyCAT] Connected to %s", addr) + c.sendLocked(fmt.Sprintf("U %s", c.satMode)) + return nil +} + +// Disconnect closes the connection. +func (c *Client) Disconnect() { + c.mu.Lock() + defer c.mu.Unlock() + c.disconnectLocked() +} + +func (c *Client) disconnectLocked() { + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connected.Store(false) + log.Println("[SkyCAT] Disconnected") +} + +// IsConnected returns true if currently connected. +func (c *Client) IsConnected() bool { + return c.connected.Load() +} + +// SetSatMode configures the operating mode (Duplex/Split/Simplex). +// Sends the U command to skycatd. +func (c *Client) SetSatMode(mode SatMode) error { + c.satMode = mode + return c.sendCommand(fmt.Sprintf("U %s", mode)) +} + +// SetFrequency sends corrected RX and TX frequencies to skycatd. +// downHz = RX downlink (command F), upHz = TX uplink (command I). +// Dead-band: 1 Hz. +func (c *Client) SetFrequency(downHz, upHz float64) error { + if !c.connected.Load() { + return fmt.Errorf("not connected") + } + + c.mu.Lock() + defer c.mu.Unlock() + + var errs []error + + if downHz > 0 && absf(downHz-c.lastDownHz) >= 1.0 { + if err := c.sendLocked(fmt.Sprintf("F %.0f", downHz)); err != nil { + errs = append(errs, fmt.Errorf("RX freq: %w", err)) + } else { + c.lastDownHz = downHz + } + } + + if upHz > 0 && absf(upHz-c.lastUpHz) >= 1.0 { + if err := c.sendLocked(fmt.Sprintf("I %.0f", upHz)); err != nil { + errs = append(errs, fmt.Errorf("TX freq: %w", err)) + } else { + c.lastUpHz = upHz + } + } + + if len(errs) > 0 { + return fmt.Errorf("SkyCAT SetFrequency: %v", errs) + } + return nil +} + +// SetMode sets RX and TX modes (FM, USB, LSB, CW…). +func (c *Client) SetMode(mode string) error { + mode = strings.ToUpper(strings.TrimSpace(mode)) + var errs []error + if err := c.sendCommand(fmt.Sprintf("M %s 0", mode)); err != nil { + errs = append(errs, fmt.Errorf("RX mode: %w", err)) + } + if err := c.sendCommand(fmt.Sprintf("X %s 0", mode)); err != nil { + errs = append(errs, fmt.Errorf("TX mode: %w", err)) + } + if len(errs) > 0 { + return fmt.Errorf("SkyCAT SetMode: %v", errs) + } + log.Printf("[SkyCAT] Mode → %s", mode) + return nil +} + +// ResetDeadband forces next frequency command to be sent immediately. +func (c *Client) ResetDeadband() { + c.mu.Lock() + defer c.mu.Unlock() + c.lastDownHz = 0 + c.lastUpHz = 0 +} + +// SatModeToSkyCAT converts a satellite DB mode string to SkyCAT mode string. +func SatModeToSkyCAT(satMode string) string { + switch strings.ToUpper(strings.TrimSpace(satMode)) { + case "FM", "NFM": + return "FM" + case "LSB": + return "LSB" + case "USB": + return "USB" + case "CW", "CW/DATA": + return "CW" + case "APRS", "DATA", "DIGI": + return "PKTLSB" + case "AM": + return "AM" + default: + return "USB" + } +} + +func (c *Client) sendCommand(cmd string) error { + c.mu.Lock() + defer c.mu.Unlock() + return c.sendLocked(cmd) +} + +// sendLocked sends a command — caller must hold c.mu. +func (c *Client) sendLocked(cmd string) error { + if c.conn == nil { + return fmt.Errorf("not connected") + } + line := cmd + "\n" + c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) + _, err := fmt.Fprint(c.conn, line) + if err != nil { + c.connected.Store(false) + return fmt.Errorf("send: %w", err) + } + log.Printf("[SkyCAT] → %s", strings.TrimSpace(line)) + return nil +} + +// readLoop drains responses from skycatd (RPRT 0 = OK, RPRT -1 = error). +func (c *Client) readLoop() { + for { + line, err := c.reader.ReadString('\n') + if err != nil { + if c.connected.Load() { + log.Printf("[SkyCAT] Read error: %v", err) + c.connected.Store(false) + } + return + } + log.Printf("[SkyCAT] ← %s", strings.TrimSpace(line)) + } +} + +func absf(x float64) float64 { + if x < 0 { + return -x + } + return x +} diff --git a/build/bin/SatMaster.exe b/build/bin/SatMaster.exe index 4e5ec16..e13f30f 100644 Binary files a/build/bin/SatMaster.exe and b/build/bin/SatMaster.exe differ diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 61c7f3d..0b9fc91 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -8,7 +8,7 @@ import PolarPlot from './components/PolarPlot.svelte' import { Go, onWailsEvent } from './lib/wails.js' import { get } from 'svelte/store' - import { satPositions, satList, trackedSat, trackedPosition, passes, tleAge, tleLoaded, flexConnected, rotorConnected, settings, watchlist, soundEnabled, passesAllCache } from './stores/satstore.js' + import { satPositions, satList, trackedSat, trackedPosition, passes, tleAge, tleLoaded, flexConnected, rotorConnected, skycatConnected, settings, watchlist, soundEnabled, passesAllCache } from './stores/satstore.js' let activeTab = 'map' let cleanups = [] @@ -43,6 +43,15 @@ await Go.SetRotorAzOnly(s.rotorAzOnly ?? true) } } + if (s.autoConnectSkycat && s.radioType === 'skycat') { + const res = await Go.ConnectSkyCAT(s.skycatHost, s.skycatPort) + if (res === 'OK') { + skycatConnected.set(true) + await Go.SetSkyCATMode(s.skycatSatMode || 'Duplex') + } + } + // Sync radio type to backend on startup + if (s.radioType) await Go.SetRadioType(s.radioType) const names = await Go.GetSatelliteList() if (names) satList.set(names) diff --git a/frontend/src/components/SettingsPanel.svelte b/frontend/src/components/SettingsPanel.svelte index ca6f1c4..c7d76ef 100644 --- a/frontend/src/components/SettingsPanel.svelte +++ b/frontend/src/components/SettingsPanel.svelte @@ -1,5 +1,5 @@