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 }