feat: Support for Power Genius XL
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user