package powergenius import ( "bufio" "fmt" "log" "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"` PowerReflected float64 `json:"power_reflected"` SWR float64 `json:"swr"` Voltage float64 `json:"voltage"` VDD float64 `json:"vdd"` Current float64 `json:"current"` PeakCurrent float64 `json:"peak_current"` Temperature float64 `json:"temperature"` HarmonicLoadTemp float64 `json:"harmonic_load_temp"` FanSpeed int `json:"fan_speed"` FanMode string `json:"fan_mode"` State string `json:"state"` BandA string `json:"band_a"` BandB string `json:"band_b"` FaultPresent bool `json:"fault_present"` Connected bool `json:"connected"` Meffa string `json:"meffa"` } 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 err := c.Connect(); err != nil { return err } if c.running { return nil } c.running = true go c.pollLoop() return nil } // pollLoop continuously polls the device for status func (c *Client) pollLoop() { ticker := time.NewTicker(150 * time.Millisecond) defer ticker.Stop() for { select { case <-ticker.C: status, err := c.queryStatus() if err != nil { log.Printf("PowerGenius query error: %v", err) // Try to reconnect c.connMu.Lock() if c.conn != nil { c.conn.Close() c.conn = nil } c.connMu.Unlock() if err := c.Connect(); err != nil { log.Printf("PowerGenius reconnect failed: %v", err) } continue } // Merge with existing status (spontaneous messages may only update some fields) c.statusMu.Lock() if c.lastStatus != nil { // Keep existing values for fields not in the new status if status.PowerForward == 0 && c.lastStatus.PowerForward != 0 { status.PowerForward = c.lastStatus.PowerForward } if status.Temperature == 0 && c.lastStatus.Temperature != 0 { status.Temperature = c.lastStatus.Temperature } if status.HarmonicLoadTemp == 0 && c.lastStatus.HarmonicLoadTemp != 0 { status.HarmonicLoadTemp = c.lastStatus.HarmonicLoadTemp } if status.Voltage == 0 && c.lastStatus.Voltage != 0 { status.Voltage = c.lastStatus.Voltage } if status.FanMode == "" && c.lastStatus.FanMode != "" { status.FanMode = c.lastStatus.FanMode } if status.BandA == "" && c.lastStatus.BandA != "" { status.BandA = c.lastStatus.BandA } if status.BandB == "" && c.lastStatus.BandB != "" { status.BandB = c.lastStatus.BandB } } 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 fullCmd := fmt.Sprintf("C%d|status\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, } var data string // Handle two message formats: // 1. Response to command: R|0|state=IDLE bandA=40 ... // 2. Spontaneous status: S0|state=TRANSMIT_A if strings.HasPrefix(resp, "R") { // Response format: R|0|state=IDLE bandA=40 ... parts := strings.SplitN(resp, "|", 3) if len(parts) < 3 { return nil, fmt.Errorf("invalid response format: %s", resp) } data = parts[2] // Get everything after "R|0|" } else if strings.HasPrefix(resp, "S") { // Spontaneous message format: S0|state=TRANSMIT_A parts := strings.SplitN(resp, "|", 2) if len(parts) < 2 { return nil, fmt.Errorf("invalid spontaneous message format: %s", resp) } data = parts[1] // Get everything after "S0|" } else { return nil, fmt.Errorf("unknown message format: %s", resp) } // 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., 56.5 dBm = 446W) // 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 "peakfwd": // Peak forward power 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 "vac": status.Voltage, _ = strconv.ParseFloat(value, 64) case "meffa": status.Meffa = value case "vdd": status.VDD, _ = strconv.ParseFloat(value, 64) case "id": status.Current, _ = strconv.ParseFloat(value, 64) case "peakid": status.PeakCurrent, _ = strconv.ParseFloat(value, 64) case "temp": status.Temperature, _ = strconv.ParseFloat(value, 64) case "hltemp": status.HarmonicLoadTemp, _ = strconv.ParseFloat(value, 64) case "bandA": if band, err := strconv.Atoi(value); err == nil { status.BandA = fmt.Sprintf("%dM", band) } case "bandB": if band, err := strconv.Atoi(value); err == nil && band > 0 { status.BandB = fmt.Sprintf("%dM", band) } case "fanmode": status.FanMode = value case "state": status.State = value } } return status, nil } // Subscribe starts receiving periodic status updates func (c *Client) Subscribe() error { _, err := c.sendCommand("sub status") return err } // Unsubscribe stops receiving periodic status updates func (c *Client) Unsubscribe() error { _, err := c.sendCommand("unsub status") return err } // ReadUpdate reads a status update (when subscribed) func (c *Client) ReadUpdate() (*Status, error) { if c.conn == nil { return nil, fmt.Errorf("not connected") } reader := bufio.NewReader(c.conn) response, err := reader.ReadString('\n') if err != nil { return nil, fmt.Errorf("failed to read update: %w", err) } return c.parseStatus(strings.TrimSpace(response)) } // SetFanMode sets the fan mode // mode can be: STANDARD, CONTEST, or BROADCAST func (c *Client) SetFanMode(mode string) error { validModes := map[string]bool{ "STANDARD": true, "CONTEST": true, "BROADCAST": true, } if !validModes[mode] { return fmt.Errorf("invalid fan mode: %s (must be STANDARD, CONTEST, or BROADCAST)", mode) } cmd := fmt.Sprintf("setup fanmode=%s", mode) _, err := c.sendCommand(cmd) return err }