Files
OpsLog/internal/powergenius/powergenius.go
T

227 lines
5.1 KiB
Go

// 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<id>|<cmd>\n"; replies are
// "R<id>|0|<k=v …>" and asynchronous "S0|<k=v …>".
package powergenius
import (
"bufio"
"fmt"
"net"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
defaultPort = 9006
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
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.setStatus(func(s *Status) { s.FanMode = m }) // optimistic
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<id>|<cmd>\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<id>|0|<k=v …>" and "S0|<k=v …>" 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":
c.status.FanMode = strings.ToUpper(kv[1])
case "temp":
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
}
}
c.statusMu.Unlock()
}