package tunergenius import ( "bufio" "fmt" "math" "net" "strconv" "strings" "sync" "time" . "git.rouggy.com/rouggy/ShackMaster/internal/devices" ) type Client struct { host string port int conn net.Conn connMu sync.Mutex lastStatus *Status statusMu sync.RWMutex stopChan chan struct{} running bool } type Status struct { PowerForward float64 `json:"power_forward"` PowerPeak float64 `json:"power_peak"` PowerMax float64 `json:"power_max"` SWR float64 `json:"swr"` PTTA int `json:"ptt_a"` BandA int `json:"band_a"` FreqA float64 `json:"frequency_a"` BypassA bool `json:"bypass_a"` AntA int `json:"antenna_a"` PTTB int `json:"ptt_b"` BandB int `json:"band_b"` FreqB float64 `json:"frequency_b"` BypassB bool `json:"bypass_b"` AntB int `json:"antenna_b"` State int `json:"state"` Active int `json:"active"` Tuning int `json:"tuning"` Bypass bool `json:"bypass"` RelayC1 int `json:"c1"` RelayL int `json:"l"` RelayC2 int `json:"c2"` TuningStatus string `json:"tuning_status"` Connected bool `json:"connected"` // Peak hold for display (internal) displayPower float64 peakTime time.Time } func New(host string, port int) *Client { return &Client{ host: host, port: port, stopChan: make(chan struct{}), } } func (c *Client) Connect() error { c.connMu.Lock() defer c.connMu.Unlock() if c.conn != nil { return nil // Already connected } conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second) if err != nil { return fmt.Errorf("failed to connect: %w", err) } c.conn = conn // Read and discard version banner reader := bufio.NewReader(c.conn) _, _ = reader.ReadString('\n') return nil } func (c *Client) Close() error { c.connMu.Lock() defer c.connMu.Unlock() if c.stopChan != nil { close(c.stopChan) } if c.conn != nil { return c.conn.Close() } return nil } // Start begins continuous polling of the device func (c *Client) Start() error { if c.running { return nil } // Try to connect, but don't fail if it doesn't work // The poll loop will keep trying _ = c.Connect() c.running = true go c.pollLoop() return nil } // pollLoop continuously polls the device for status func (c *Client) pollLoop() { ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-ticker.C: // Try to reconnect if not connected c.connMu.Lock() if c.conn == nil { c.connMu.Unlock() // Mark as disconnected and reset all values c.statusMu.Lock() c.lastStatus = &Status{ Connected: false, } c.statusMu.Unlock() if err := c.Connect(); err != nil { // Silent fail, will retry on next tick continue } c.connMu.Lock() } c.connMu.Unlock() status, err := c.queryStatus() if err != nil { // Connection lost, close and retry next tick c.connMu.Lock() if c.conn != nil { c.conn.Close() c.conn = nil } c.connMu.Unlock() // Mark as disconnected and reset all values c.statusMu.Lock() c.lastStatus = &Status{ Connected: false, } c.statusMu.Unlock() continue } // Mark as connected status.Connected = true // Peak hold logic - keep highest power for 1 second now := time.Now() if c.lastStatus != nil { // If new power is higher, update peak if status.PowerForward > c.lastStatus.displayPower { status.displayPower = status.PowerForward status.peakTime = now } else { // Check if peak has expired (1 second) if now.Sub(c.lastStatus.peakTime) < 1*time.Second { // Keep old peak status.displayPower = c.lastStatus.displayPower status.peakTime = c.lastStatus.peakTime } else { // Peak expired, use current value status.displayPower = status.PowerForward status.peakTime = now } } } else { status.displayPower = status.PowerForward status.peakTime = now } // Override PowerForward with display power for frontend status.PowerForward = status.displayPower c.statusMu.Lock() c.lastStatus = status c.statusMu.Unlock() case <-c.stopChan: return } } } func (c *Client) queryStatus() (*Status, error) { c.connMu.Lock() defer c.connMu.Unlock() if c.conn == nil { return nil, fmt.Errorf("not connected") } // Get next command ID from global counter cmdID := GetGlobalCommandID().GetNextID() // Format command with ID: C|status get fullCmd := fmt.Sprintf("C%d|status get\n", cmdID) // Send command _, err := c.conn.Write([]byte(fullCmd)) if err != nil { c.conn = nil return nil, fmt.Errorf("failed to send command: %w", err) } // Read response reader := bufio.NewReader(c.conn) response, err := reader.ReadString('\n') if err != nil { c.conn = nil return nil, fmt.Errorf("failed to read response: %w", err) } return c.parseStatus(strings.TrimSpace(response)) } func (c *Client) sendCommand(cmd string) (string, error) { c.connMu.Lock() defer c.connMu.Unlock() if c.conn == nil { return "", fmt.Errorf("not connected") } // Get next command ID from global counter cmdID := GetGlobalCommandID().GetNextID() // Format command with ID: C| fullCmd := fmt.Sprintf("C%d|%s\n", cmdID, cmd) // Send command _, err := c.conn.Write([]byte(fullCmd)) if err != nil { c.conn = nil return "", fmt.Errorf("failed to send command: %w", err) } // Read response reader := bufio.NewReader(c.conn) response, err := reader.ReadString('\n') if err != nil { c.conn = nil return "", fmt.Errorf("failed to read response: %w", err) } return strings.TrimSpace(response), nil } func (c *Client) GetStatus() (*Status, error) { c.statusMu.RLock() defer c.statusMu.RUnlock() if c.lastStatus == nil { return &Status{Connected: false}, nil } return c.lastStatus, nil } func (c *Client) parseStatus(resp string) (*Status, error) { status := &Status{ Connected: true, } // Response format: S|status fwd=21.19 peak=21.55 ... // Extract the data part after "S|status " idx := strings.Index(resp, "|status ") if idx == -1 { return nil, fmt.Errorf("invalid response format: %s", resp) } data := resp[idx+8:] // Skip "|status " // Parse key=value pairs separated by spaces pairs := strings.Fields(data) for _, pair := range pairs { kv := strings.SplitN(pair, "=", 2) if len(kv) != 2 { continue } key := kv[0] value := kv[1] switch key { case "fwd": // fwd is in dBm (e.g., 42.62 dBm) // Formula: watts = 10^(dBm/10) / 1000 if dBm, err := strconv.ParseFloat(value, 64); err == nil { milliwatts := math.Pow(10, dBm/10.0) status.PowerForward = milliwatts / 1000.0 } case "peak": // peak power in dBm if dBm, err := strconv.ParseFloat(value, 64); err == nil { milliwatts := math.Pow(10, dBm/10.0) status.PowerPeak = milliwatts / 1000.0 } case "max": if dBm, err := strconv.ParseFloat(value, 64); err == nil { milliwatts := math.Pow(10, dBm/10.0) status.PowerMax = milliwatts / 1000.0 } case "swr": // SWR from return loss // Formula: returnLoss = abs(swr) / 20 // swr = (10^returnLoss + 1) / (10^returnLoss - 1) if swrRaw, err := strconv.ParseFloat(value, 64); err == nil { returnLoss := math.Abs(swrRaw) / 20.0 tenPowRL := math.Pow(10, returnLoss) calculatedSWR := (tenPowRL + 1) / (tenPowRL - 1) status.SWR = calculatedSWR } case "pttA": status.PTTA, _ = strconv.Atoi(value) case "bandA": status.BandA, _ = strconv.Atoi(value) case "freqA": status.FreqA, _ = strconv.ParseFloat(value, 64) case "bypassA": status.BypassA = value == "1" case "antA": status.AntA, _ = strconv.Atoi(value) case "pttB": status.PTTB, _ = strconv.Atoi(value) case "bandB": status.BandB, _ = strconv.Atoi(value) case "freqB": status.FreqB, _ = strconv.ParseFloat(value, 64) case "bypassB": status.BypassB = value == "1" case "antB": status.AntB, _ = strconv.Atoi(value) case "state": status.State, _ = strconv.Atoi(value) case "active": status.Active, _ = strconv.Atoi(value) case "tuning": status.Tuning, _ = strconv.Atoi(value) if status.Tuning == 1 { status.TuningStatus = "TUNING" } else { status.TuningStatus = "READY" } case "bypass": status.Bypass = value == "1" case "relayC1": status.RelayC1, _ = strconv.Atoi(value) case "relayL": status.RelayL, _ = strconv.Atoi(value) case "relayC2": status.RelayC2, _ = strconv.Atoi(value) } } return status, nil } // SetOperate switches between STANDBY (0) and OPERATE (1) func (c *Client) SetOperate(value int) error { if value != 0 && value != 1 { return fmt.Errorf("invalid operate value: %d (must be 0 or 1)", value) } cmd := fmt.Sprintf("operate set=%d", value) _, err := c.sendCommand(cmd) return err } // SetBypass sets BYPASS mode func (c *Client) SetBypass(value int) error { if value != 0 && value != 1 { return fmt.Errorf("invalid bypass value: %d (must be 0 or 1)", value) } cmd := fmt.Sprintf("bypass set=%d", value) _, err := c.sendCommand(cmd) return err } // AutoTune starts a tuning cycle func (c *Client) AutoTune() error { _, err := c.sendCommand("autotune") return err } // TuneRelay adjusts one tuning parameter by one step // relay: 0=C1, 1=L, 2=C2 // move: -1 (decrease) or 1 (increase) func (c *Client) TuneRelay(relay, move int) error { if relay < 0 || relay > 2 { return fmt.Errorf("invalid relay: %d (must be 0, 1, or 2)", relay) } if move != -1 && move != 1 { return fmt.Errorf("invalid move: %d (must be -1 or 1)", move) } cmd := fmt.Sprintf("tune relay=%d move=%d", relay, move) _, err := c.sendCommand(cmd) return err }