// Package powergenius drives a 4O3A PowerGenius XL amplifier over its TCP text // API (same "Genius Series" line protocol as the Antenna Genius). OpsLog reads // the amp's operate state via the FlexRadio amplifier object, but the fan mode // is a PGXL-only setting only reachable on the amp's own control port — hence // this small direct client. Commands are "C|\n"; replies are // "R|0|" and asynchronous "S0|". package powergenius import ( "bufio" "fmt" "math" "net" "strconv" "strings" "sync" "sync/atomic" "time" "hamlog/internal/applog" ) const ( defaultPort = 9008 dialTimeout = 5 * time.Second ioTimeout = 3 * time.Second pollEvery = 1500 * time.Millisecond reconnectDelay = 2 * time.Second ) // Status is the snapshot the UI renders (only the bits OpsLog needs). type Status struct { Connected bool `json:"connected"` Host string `json:"host,omitempty"` LastError string `json:"last_error,omitempty"` State string `json:"state,omitempty"` // IDLE / TRANSMIT_A … FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST Temperature float64 `json:"temperature"` Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it) // Live power, read straight from the amplifier's own status frame rather // than sampled off the FlexRadio meter stream. PeakW is the amp's own peak // detector: a poll catches one instant of the envelope, so the plain forward // figure lands between syllables as often as on a peak. FwdW float64 `json:"fwd_w"` // forward power [W] (the frame reports dBm) PeakW float64 `json:"peak_w"` // peak forward power [W] Vswr float64 `json:"vswr"` // VSWR, derived from the frame's return loss in dB Id float64 `json:"id"` // drain current [A] PeakId float64 `json:"peak_id"` // peak drain current [A] } type Client struct { host string port int mu sync.Mutex // serialises command send/recv on the connection conn net.Conn reader *bufio.Reader statusMu sync.RWMutex status Status lastRaw string // last raw status payload — logged on change so unknown fields (operate?) can be mapped from a real amp // Optimistic fan mode kept until the amp's status poll confirms it (or it // ages out) — otherwise a stale poll right after a change reverts the UI. fanPending string fanPendingAt time.Time cmdID atomic.Int64 stop chan struct{} running bool } func New(host string, port int) *Client { if port <= 0 || port > 65535 { port = defaultPort } return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}} } func (c *Client) Start() error { c.running = true go c.pollLoop() return nil } func (c *Client) Stop() { if !c.running { return } c.running = false close(c.stop) c.mu.Lock() if c.conn != nil { c.conn.Close() c.conn = nil c.reader = nil } c.mu.Unlock() } func (c *Client) GetStatus() Status { c.statusMu.RLock() defer c.statusMu.RUnlock() return c.status } func (c *Client) setStatus(fn func(*Status)) { c.statusMu.Lock() fn(&c.status) c.statusMu.Unlock() } // SetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST). func (c *Client) SetFanMode(mode string) error { m := strings.ToUpper(strings.TrimSpace(mode)) switch m { case "STANDARD", "CONTEST", "BROADCAST": default: return fmt.Errorf("powergenius: invalid fan mode %q", mode) } if _, err := c.command("setup fanmode=" + m); err != nil { return err } c.statusMu.Lock() c.status.FanMode = m // optimistic c.fanPending, c.fanPendingAt = m, time.Now() c.statusMu.Unlock() return nil } // SetOperate puts the amp in OPERATE (1) or STANDBY (0). func (c *Client) SetOperate(on bool) error { v := "0" if on { v = "1" } if _, err := c.command("operate=" + v); err != nil { return err } // Optimistic: the status poll's "operate" field (when the firmware reports // one) confirms or corrects this. c.statusMu.Lock() c.status.Operate = on c.statusMu.Unlock() return nil } func (c *Client) pollLoop() { t := time.NewTicker(pollEvery) defer t.Stop() for { select { case <-c.stop: return case <-t.C: if err := c.ensureConnected(); err != nil { c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() }) continue } if _, err := c.command("status"); err != nil { c.dropConn() c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() }) } } } } func (c *Client) ensureConnected() error { c.mu.Lock() defer c.mu.Unlock() if c.conn != nil { return nil } conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout) if err != nil { return err } c.conn = conn c.reader = bufio.NewReader(conn) // Discard the version banner the device sends on connect. _ = conn.SetReadDeadline(time.Now().Add(ioTimeout)) _, _ = c.reader.ReadString('\n') return nil } func (c *Client) dropConn() { c.mu.Lock() if c.conn != nil { c.conn.Close() c.conn = nil c.reader = nil } c.mu.Unlock() } // command sends "C|\n" and parses the single-line reply into status. func (c *Client) command(cmd string) (string, error) { c.mu.Lock() defer c.mu.Unlock() if c.conn == nil || c.reader == nil { return "", fmt.Errorf("powergenius: not connected") } id := c.cmdID.Add(1) _ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout)) if _, err := fmt.Fprintf(c.conn, "C%d|%s\n", id, cmd); err != nil { return "", err } _ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout)) line, err := c.reader.ReadString('\n') if err != nil { return "", err } line = strings.TrimSpace(line) c.parse(line) return line, nil } // parse handles "R|0|" and "S0|" status lines. func (c *Client) parse(resp string) { var data string switch { case strings.HasPrefix(resp, "R"): p := strings.SplitN(resp, "|", 3) if len(p) < 3 { return } data = p[2] case strings.HasPrefix(resp, "S"): p := strings.SplitN(resp, "|", 2) if len(p) < 2 { return } data = p[1] default: return } c.statusMu.Lock() c.status.Connected = true c.status.LastError = "" // One raw frame per session is enough to learn the field set — the frames // carry live meter values, so "log on change" logged every frame. if c.lastRaw == "" { c.lastRaw = data applog.Printf("pgxl: status raw=%q", data) } for _, pair := range strings.Fields(data) { kv := strings.SplitN(pair, "=", 2) if len(kv) != 2 { continue } switch kv[0] { case "state": c.status.State = kv[1] case "operate": c.status.Operate = kv[1] == "1" case "fanmode": dev := strings.ToUpper(kv[1]) // Honour a recent optimistic change until the amp confirms it. if c.fanPending != "" && time.Since(c.fanPendingAt) < 3*time.Second && dev != c.fanPending { break } c.fanPending = "" c.status.FanMode = dev case "temp": c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64) case "fwd": if v, err := strconv.ParseFloat(kv[1], 64); err == nil { c.status.FwdW = dbmToWatts(v) } case "peakfwd": if v, err := strconv.ParseFloat(kv[1], 64); err == nil { c.status.PeakW = dbmToWatts(v) } case "swr": if v, err := strconv.ParseFloat(kv[1], 64); err == nil { c.status.Vswr = returnLossToVswr(v) } case "id": c.status.Id, _ = strconv.ParseFloat(kv[1], 64) case "peakid": c.status.PeakId, _ = strconv.ParseFloat(kv[1], 64) } } c.statusMu.Unlock() } // dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW). The amp // reports power that way — "fwd=60.5" is 1122 W, not 60 W. func dbmToWatts(dbm float64) float64 { if dbm <= 0 { return 0 } return math.Pow(10, (dbm-30)/10) } // returnLossToVswr converts the amp's "swr" field — a return loss in dB, sent // negative for a good match — into the VSWR ratio an operator reads. func returnLossToVswr(swrDb float64) float64 { rl := math.Abs(swrDb) if rl <= 0 { return 0 } rho := math.Pow(10, -rl/20) if rho >= 1 { return 0 } vswr := (1 + rho) / (1 - rho) if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) { return 0 } return vswr }