added SkyCAT support

This commit is contained in:
2026-03-25 00:18:12 +01:00
parent 313d22be2e
commit 5b31a9a9d8
8 changed files with 432 additions and 22 deletions

227
backend/skycat/client.go Normal file
View File

@@ -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
}