Files
2026-01-11 16:50:38 +01:00

522 lines
13 KiB
Go

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
// Connection health tracking
lastAliveTime time.Time
// 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: false, // Auto fan DISABLED - manual control only
lastFanMode: "Contest",
}
}
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
}
// Initialize connection tracking
c.lastAliveTime = time.Now()
// 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
// Check if device is actually alive (not just TCP connected)
// If voltage is 0 and temperature is 0, device might be temporarily idle
// Use a 3-second timeout before marking as disconnected (helps with morse code pauses)
if status.Voltage == 0 && status.Temperature == 0 {
// Check if we've seen valid data recently (within 3 seconds)
if time.Since(c.lastAliveTime) > 3*time.Second {
status.Connected = false
}
// else: Keep Connected = true (device is probably just idle between morse letters)
} else {
// Valid data received, update lastAliveTime
c.lastAliveTime = time.Now()
}
// 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<id>|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<id>|<command>
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<id>|0|state=IDLE bandA=40 ...
// 2. Spontaneous status: S0|state=TRANSMIT_A
if strings.HasPrefix(resp, "R") {
// Response format: R<id>|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<id>|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
}