package flexradio import ( "bufio" "fmt" "log" "net" "strings" "sync" "sync/atomic" "time" ) // Client manages a TCP connection to FlexRadio SmartSDR API. // For satellite operation: // - Slice A (index 0) = RX downlink (436 MHz for SO-50) // - Slice B (index 1) = TX uplink (145 MHz for SO-50) type Client struct { mu sync.Mutex conn net.Conn scanner *bufio.Scanner connected atomic.Bool seqNum uint32 rxSlice int // Slice index for RX (downlink) — default 0 = Slice A txSlice int // Slice index for TX (uplink) — default 1 = Slice B lastDownHz float64 // Last sent RX frequency (dead-band) lastUpHz float64 // Last sent TX frequency (dead-band) } func NewClient() *Client { return &Client{rxSlice: 0, txSlice: 1} // defaults — always overridden by SetSliceConfig on connect } // Connect establishes TCP connection to FlexRadio SmartSDR API (port 4992). 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("connexion FlexRadio %s : %w", addr, err) } c.conn = conn c.scanner = bufio.NewScanner(conn) c.connected.Store(true) go c.readLoop() log.Printf("[FlexRadio] Connecté à %s (RX=Slice %s, TX=Slice %s)", addr, sliceLetter(c.rxSlice), sliceLetter(c.txSlice)) 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("[FlexRadio] Déconnecté") } func (c *Client) IsConnected() bool { return c.connected.Load() } // SetFrequency applies Doppler-corrected frequencies to both slices. // downHz = corrected RX frequency → rxSlice (default Slice A) // upHz = corrected TX frequency → txSlice (default Slice B) // Only sends command if frequency changed by more than 1 Hz (dead-band) func (c *Client) SetFrequency(downHz, upHz float64) error { if !c.connected.Load() { return fmt.Errorf("non connecté") } var errs []error // RX downlink — use "slice t" (tune) command if downHz > 0 { downMHz := downHz / 1e6 if abs(downHz-c.lastDownHz) >= 1.0 { // 1 Hz dead-band cmd := fmt.Sprintf("slice t %d %.6f", c.rxSlice, downMHz) if err := c.sendCommand(cmd); err != nil { errs = append(errs, err) } else { c.lastDownHz = downHz } } } // TX uplink — use "slice t" (tune) command if upHz > 0 { upMHz := upHz / 1e6 if abs(upHz-c.lastUpHz) >= 1.0 { cmd := fmt.Sprintf("slice t %d %.6f", c.txSlice, upMHz) if err := c.sendCommand(cmd); err != nil { errs = append(errs, err) } else { c.lastUpHz = upHz } } } if len(errs) > 0 { return fmt.Errorf("FlexRadio SetFrequency: %v", errs) } return nil } // ResetDeadband forces next frequency command to be sent regardless of change. func (c *Client) ResetDeadband() { c.lastDownHz = 0 c.lastUpHz = 0 } func abs(x float64) float64 { if x < 0 { return -x } return x } // SetSlices configures which slice indices to use for RX and TX. // Default: rx=0 (Slice A), tx=1 (Slice B) func (c *Client) SetSlices(rxIdx, txIdx int) { c.mu.Lock() defer c.mu.Unlock() c.rxSlice = rxIdx c.txSlice = txIdx log.Printf("[FlexRadio] Slices configurées: RX=Slice %s, TX=Slice %s", sliceLetter(rxIdx), sliceLetter(txIdx)) } // GetSlices returns current RX and TX slice indices. func (c *Client) GetSlices() (rx, tx int) { return c.rxSlice, c.txSlice } // QuerySlices sends a slice list request to discover available slices. func (c *Client) QuerySlices() error { return c.sendCommand("slice list") } // SetMode sets the demodulation mode on RX and/or TX slices. // mode: "usb", "lsb", "cw", "am", "sam", "fm", "nfm", "dfm", "digl", "digu", "rtty" // For satellite: RX and TX usually have the same mode (FM for FM sats, LSB/USB for linear) func (c *Client) SetMode(mode string) error { if !c.connected.Load() { return fmt.Errorf("not connected") } mode = strings.ToLower(strings.TrimSpace(mode)) var errs []error // Set mode on RX slice if err := c.sendCommand(fmt.Sprintf("slice s %d mode=%s", c.rxSlice, mode)); err != nil { errs = append(errs, fmt.Errorf("RX mode: %w", err)) } // Set mode on TX slice (same mode for satellite split operation) if err := c.sendCommand(fmt.Sprintf("slice s %d mode=%s", c.txSlice, mode)); err != nil { errs = append(errs, fmt.Errorf("TX mode: %w", err)) } if len(errs) > 0 { return fmt.Errorf("SetMode: %v", errs) } log.Printf("[FlexRadio] Mode set to %s on slices %s/%s", strings.ToUpper(mode), sliceLetter(c.rxSlice), sliceLetter(c.txSlice)) return nil } // SatModeToFlex converts a satellite DB mode string to FlexRadio mode. // FM satellites use "fm", linear transponders use "lsb" or "usb". func SatModeToFlex(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 "digl" case "AM": return "am" default: return "usb" // safe default } } func (c *Client) sendCommand(cmd string) error { c.mu.Lock() defer c.mu.Unlock() if c.conn == nil { return fmt.Errorf("non connecté") } seq := atomic.AddUint32(&c.seqNum, 1) line := fmt.Sprintf("C%d|%s\n", seq, cmd) 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("envoi commande: %w", err) } log.Printf("[FlexRadio] → %s", strings.TrimSpace(line)) return nil } func (c *Client) readLoop() { for c.scanner.Scan() { line := c.scanner.Text() // Log ALL responses for debugging log.Printf("[FlexRadio] ← %s", line) } c.connected.Store(false) log.Println("[FlexRadio] Disconnected") } func sliceLetter(idx int) string { letters := []string{"A", "B", "C", "D", "E", "F", "G", "H"} if idx < len(letters) { return letters[idx] } return fmt.Sprintf("%d", idx) }