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 // Auto fan management autoFanEnabled bool lastFanMode string // Remember last manual mode autoFanActive bool // Track if auto-fan is currently active (in Broadcast mode) } 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"` // 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{}), autoFanEnabled: true, // Auto fan management enabled by default lastFanMode: "Contest", // Default to Contest mode } } 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(150 * 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 // Auto fan management based on temperature // Do this BEFORE merging to use the fresh temperature value if c.autoFanEnabled { // Use the temperature from the current status message // If it's 0, use the last known temperature temp := status.Temperature if temp == 0 && c.lastStatus != nil { temp = c.lastStatus.Temperature } currentMode := strings.ToUpper(status.FanMode) if currentMode == "" && c.lastStatus != nil { currentMode = strings.ToUpper(c.lastStatus.FanMode) } // Only act on valid temperature readings if temp > 5.0 { // Ignore invalid/startup readings below 5°C // If temp >= 60°C, switch to Broadcast if temp >= 60.0 && currentMode != "BROADCAST" { if !c.autoFanActive { log.Printf("PowerGenius: Temperature %.1f°C >= 60°C, switching fan to Broadcast mode", temp) c.autoFanActive = true } if err := c.setFanModeInternal("BROADCAST"); err != nil { log.Printf("PowerGenius: Failed to set fan mode: %v", err) } } // If temp <= 55°C, switch back to Contest if temp <= 55.0 && currentMode == "BROADCAST" { if c.autoFanActive { log.Printf("PowerGenius: Temperature %.1f°C <= 55°C, switching fan back to Contest mode", temp) c.autoFanActive = false } if err := c.setFanModeInternal("CONTEST"); err != nil { log.Printf("PowerGenius: Failed to set fan mode: %v", err) } } } } // 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.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 "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, } // Normalize mode to title case for comparison modeUpper := strings.ToUpper(mode) if !validModes[modeUpper] { return fmt.Errorf("invalid fan mode: %s (must be STANDARD, CONTEST, or BROADCAST)", mode) } // Remember last manual mode (if not triggered by auto-fan) // We store it in title case: "Standard", "Contest", "Broadcast" c.lastFanMode = strings.Title(strings.ToLower(mode)) return c.setFanModeInternal(modeUpper) } // setFanModeInternal sets fan mode without updating lastFanMode (for auto-fan) func (c *Client) setFanModeInternal(mode string) error { cmd := fmt.Sprintf("setup fanmode=%s", mode) _, err := c.sendCommand(cmd) return err } // SetOperate sets the operate mode // value can be: 0 (STANDBY) or 1 (OPERATE) 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=%d", value) _, err := c.sendCommand(cmd) return err }