package flexradio import ( "bufio" "fmt" "log" "net" "strconv" "strings" "sync" "time" ) type Client struct { host string port int conn net.Conn reader *bufio.Reader connMu sync.Mutex // For connection management writeMu sync.Mutex // For writing to connection (separate from reads) lastStatus *Status statusMu sync.RWMutex cmdSeq int cmdSeqMu sync.Mutex running bool stopChan chan struct{} // Interlock interlockID string // ID retourné par la radio (ex: "000000F4") interlockName string // Nom de notre interlock interlockMu sync.RWMutex // Protection pour l'ID // Callbacks onFrequencyChange func(freqMHz float64) checkTransmitAllowed func() bool // Returns true if transmit allowed (motors not moving) } func New(host string, port int, interlockName string) *Client { return &Client{ host: host, port: port, interlockName: interlockName, stopChan: make(chan struct{}), lastStatus: &Status{ Connected: false, }, } } // SetFrequencyChangeCallback sets the callback function called when frequency changes func (c *Client) SetFrequencyChangeCallback(callback func(freqMHz float64)) { c.onFrequencyChange = callback } // SetTransmitCheckCallback sets the callback to check if transmit is allowed func (c *Client) SetTransmitCheckCallback(callback func() bool) { c.checkTransmitAllowed = callback } func (c *Client) Connect() error { c.connMu.Lock() defer c.connMu.Unlock() if c.conn != nil { return nil } addr := fmt.Sprintf("%s:%d", c.host, c.port) log.Printf("FlexRadio: Connecting to %s...", addr) conn, err := net.DialTimeout("tcp", addr, 5*time.Second) if err != nil { return fmt.Errorf("failed to connect: %w", err) } c.conn = conn c.reader = bufio.NewReader(conn) log.Println("FlexRadio: Connected successfully") return nil } func (c *Client) Start() error { if c.running { return nil } if err := c.Connect(); err != nil { return err } // Update connected status c.statusMu.Lock() if c.lastStatus != nil { c.lastStatus.Connected = true } c.statusMu.Unlock() c.running = true // Start message listener go c.messageLoop() // Create interlock log.Printf("FlexRadio: Creating interlock '%s'...", c.interlockName) if err := c.createInterlock(); err != nil { log.Printf("FlexRadio: Warning - failed to create interlock: %v", err) } // Subscribe to slice updates for frequency tracking log.Println("FlexRadio: Subscribing to slice updates...") _, err := c.sendCommand("sub slice all") if err != nil { log.Printf("FlexRadio: Warning - failed to subscribe to slices: %v", err) } return nil } func (c *Client) Stop() { if !c.running { return } c.running = false close(c.stopChan) c.connMu.Lock() if c.conn != nil { c.conn.Close() c.conn = nil c.reader = nil } c.connMu.Unlock() // Update connected status c.statusMu.Lock() if c.lastStatus != nil { c.lastStatus.Connected = false } c.statusMu.Unlock() } func (c *Client) getNextSeq() int { c.cmdSeqMu.Lock() defer c.cmdSeqMu.Unlock() c.cmdSeq++ return c.cmdSeq } func (c *Client) sendCommand(cmd string) (string, error) { // Use writeMu instead of connMu to avoid blocking on messageLoop reads c.writeMu.Lock() defer c.writeMu.Unlock() c.connMu.Lock() conn := c.conn c.connMu.Unlock() if conn == nil { return "", fmt.Errorf("not connected") } seq := c.getNextSeq() fullCmd := fmt.Sprintf("C%d|%s\n", seq, cmd) log.Printf("FlexRadio TX: %s", strings.TrimSpace(fullCmd)) _, err := conn.Write([]byte(fullCmd)) if err != nil { c.connMu.Lock() c.conn = nil c.reader = nil c.connMu.Unlock() return "", fmt.Errorf("failed to send command: %w", err) } return "", nil } func (c *Client) messageLoop() { log.Println("FlexRadio: Message loop started") for c.running { c.connMu.Lock() if c.conn == nil || c.reader == nil { c.connMu.Unlock() time.Sleep(1 * time.Second) if err := c.Connect(); err != nil { log.Printf("FlexRadio: Reconnect failed: %v", err) continue } continue } // Set read deadline to allow periodic checks c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) line, err := c.reader.ReadString('\n') c.connMu.Unlock() if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { // Timeout is expected, continue continue } log.Printf("FlexRadio: Read error: %v", err) c.connMu.Lock() if c.conn != nil { c.conn.Close() c.conn = nil c.reader = nil } c.connMu.Unlock() // Update connected status c.statusMu.Lock() if c.lastStatus != nil { c.lastStatus.Connected = false } c.statusMu.Unlock() continue } line = strings.TrimSpace(line) if line == "" { continue } c.handleMessage(line) } log.Println("FlexRadio: Message loop stopped") } func (c *Client) handleMessage(msg string) { // Response format: R|| if strings.HasPrefix(msg, "R") { c.handleResponse(msg) return } // Status format: S|= ... if strings.HasPrefix(msg, "S") { c.handleStatus(msg) return } // Version/handle format: V|H if strings.HasPrefix(msg, "V") { log.Printf("FlexRadio: Version/Handle received: %s", msg) return } // Message format: M| if strings.HasPrefix(msg, "M") { log.Printf("FlexRadio: Message: %s", msg) return } } func (c *Client) handleResponse(msg string) { // Format: R|| // Example: R21|0|000000F4 parts := strings.SplitN(msg, "|", 3) if len(parts) < 2 { return } status := parts[1] if status != "0" { log.Printf("FlexRadio: Command error: status=%s, message=%s", status, msg) return } // Check if this is interlock create response (has data in 3rd part) if len(parts) >= 3 && parts[2] != "" { interlockID := parts[2] c.interlockMu.Lock() c.interlockID = interlockID c.interlockMu.Unlock() log.Printf("FlexRadio: ✅ Interlock created successfully with ID: %s", interlockID) } } func (c *Client) handleStatus(msg string) { // Format: S|= ... parts := strings.SplitN(msg, "|", 2) if len(parts) < 2 { return } data := parts[1] // Parse key=value pairs pairs := strings.Fields(data) statusMap := make(map[string]string) for _, pair := range pairs { kv := strings.SplitN(pair, "=", 2) if len(kv) == 2 { statusMap[kv[0]] = kv[1] } } // Check for slice updates (frequency changes) if strings.Contains(msg, "slice") { if rfFreq, ok := statusMap["RF_frequency"]; ok { freq, err := strconv.ParseFloat(rfFreq, 64) if err == nil { c.statusMu.Lock() oldFreq := c.lastStatus.Frequency c.lastStatus.Frequency = freq c.statusMu.Unlock() // Only log significant frequency changes (> 1 kHz) if oldFreq == 0 || (freq-oldFreq)*(freq-oldFreq) > 0.001 { log.Printf("FlexRadio: Frequency updated to %.6f MHz", freq) // Trigger callback for immediate auto-track if c.onFrequencyChange != nil { go c.onFrequencyChange(freq) } } } } } // Check for interlock PTT_REQUESTED // Format: S0|interlock ... state=PTT_REQUESTED ... if strings.Contains(msg, "interlock") { if state, ok := statusMap["state"]; ok { // Update status for UI c.statusMu.Lock() c.lastStatus.InterlockState = state c.statusMu.Unlock() if state == "PTT_REQUESTED" { // PTT requested - we MUST respond within 500ms! c.handlePTTRequest() } } } } func (c *Client) GetStatus() (*Status, error) { c.statusMu.RLock() defer c.statusMu.RUnlock() if c.lastStatus == nil { return &Status{Connected: false}, nil } // Create a copy status := *c.lastStatus // DON'T lock connMu here - it causes 4-second blocking! // The messageLoop updates Connected status, and we trust the cached value return &status, nil } // createInterlock creates an interlock on the radio func (c *Client) createInterlock() error { // Format: interlock create type=ANT name= serial=ShackMaster // Type ANT = External antenna controller (state always controlled by us) cmd := fmt.Sprintf("interlock create type=ANT name=%s serial=ShackMaster", c.interlockName) log.Printf("FlexRadio: Sending interlock create command: %s", cmd) _, err := c.sendCommand(cmd) if err != nil { return fmt.Errorf("failed to send interlock create: %w", err) } // The response will be parsed in handleResponse() // Format: R|0| (ex: R21|0|000000F4) return nil } // handlePTTRequest is called when FlexRadio sends PTT_REQUESTED // We MUST respond within 500ms with ready or not_ready func (c *Client) handlePTTRequest() { c.interlockMu.RLock() interlockID := c.interlockID c.interlockMu.RUnlock() if interlockID == "" { log.Println("FlexRadio: No interlock ID, cannot respond to PTT request!") return } // Check if transmit is allowed via callback allowed := true if c.checkTransmitAllowed != nil { allowed = c.checkTransmitAllowed() } if allowed { cmd := fmt.Sprintf("interlock ready %s", interlockID) c.sendCommand(cmd) // Update status immediately for UI c.statusMu.Lock() c.lastStatus.InterlockState = InterlockStateReady c.statusMu.Unlock() } else { log.Println("FlexRadio: Transmit BLOCKED - sending 'not_ready'") cmd := fmt.Sprintf("interlock not_ready %s", interlockID) c.sendCommand(cmd) // Update status immediately for UI c.statusMu.Lock() c.lastStatus.InterlockState = InterlockStateNotReady c.statusMu.Unlock() } }