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 interlockID string interlockName string interlockMu sync.RWMutex lastStatus *Status statusMu sync.RWMutex cmdSeq int cmdSeqMu sync.Mutex running bool stopChan chan struct{} // Callbacks checkTransmitAllowed func() bool onFrequencyChange func(freqMHz float64) } 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, }, } } // SetTransmitCheckCallback sets the callback function to check if transmit is allowed func (c *Client) SetTransmitCheckCallback(callback func() bool) { c.checkTransmitAllowed = callback } // SetFrequencyChangeCallback sets the callback function called when frequency changes func (c *Client) SetFrequencyChangeCallback(callback func(freqMHz float64)) { c.onFrequencyChange = 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 (no sleep needed, connection is synchronous) if err := c.createInterlock(); err != nil { log.Printf("FlexRadio: Failed to create interlock: %v", err) return 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) { c.connMu.Lock() defer c.connMu.Unlock() if c.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 := c.conn.Write([]byte(fullCmd)) if err != nil { c.conn = nil c.reader = nil 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|| parts := strings.SplitN(msg, "|", 3) if len(parts) < 2 { return } status := parts[1] if status != "0" { log.Printf("FlexRadio: Command error: status=%s", status) return } // Check if this is interlock create response if len(parts) >= 3 && parts[2] != "" { // This is likely the interlock ID interlockID := parts[2] c.interlockMu.Lock() c.interlockID = interlockID c.interlockMu.Unlock() log.Printf("FlexRadio: Interlock created with ID: %s", interlockID) c.statusMu.Lock() c.lastStatus.InterlockID = interlockID c.lastStatus.InterlockState = InterlockStateReady c.statusMu.Unlock() } } 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 interlock state changes if state, ok := statusMap["state"]; ok && strings.Contains(msg, "interlock") { c.handleInterlockState(state, statusMap) } // 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) } } } } } } func (c *Client) handleInterlockState(state string, _ map[string]string) { log.Printf("FlexRadio: Interlock state changed to: %s", state) c.statusMu.Lock() c.lastStatus.InterlockState = state c.statusMu.Unlock() // Handle PTT_REQUESTED - this is where we decide to allow or block transmit if state == "PTT_REQUESTED" { c.handlePTTRequest() } } func (c *Client) handlePTTRequest() { log.Println("FlexRadio: PTT requested, checking if transmit is allowed...") 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 { log.Println("FlexRadio: Transmit ALLOWED - sending ready") c.sendCommand(fmt.Sprintf("interlock ready %s", interlockID)) // Update state immediately for UI c.statusMu.Lock() c.lastStatus.InterlockState = InterlockStateReady c.statusMu.Unlock() } else { log.Println("FlexRadio: Transmit BLOCKED - sending not_ready") c.sendCommand(fmt.Sprintf("interlock not_ready %s", interlockID)) // Update state immediately for UI c.statusMu.Lock() c.lastStatus.InterlockState = InterlockStateNotReady c.statusMu.Unlock() } } func (c *Client) createInterlock() error { log.Printf("FlexRadio: Creating interlock with name: %s", c.interlockName) // Format: interlock create type=ant name= serial= cmd := fmt.Sprintf("interlock create type=ant name=%s serial=ShackMaster", c.interlockName) _, err := c.sendCommand(cmd) if err != nil { return fmt.Errorf("failed to create interlock: %w", 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) 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 }