// 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" "net" "strconv" "strings" "sync" "sync/atomic" "time" ) 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"` } 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 // 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" } _, err := c.command("operate=" + v) return err } 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 = "" 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 "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) } } c.statusMu.Unlock() }