228 lines
5.2 KiB
Go
228 lines
5.2 KiB
Go
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
|
|
}
|