Confirmed live: "setup fanmode=CONTEST" is accepted (reply code 0) while the bare "fanmode=CONTEST" we switched to earlier is rejected (0x50000015), so the fan mode snapped back and never changed on the amp. Restored the "setup " verb and now check the reply code, returning an error if the amp rejects the set.
395 lines
12 KiB
Go
395 lines
12 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"
|
|
"math"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"hamlog/internal/applog"
|
|
)
|
|
|
|
const (
|
|
defaultPort = 9008
|
|
dialTimeout = 5 * time.Second
|
|
ioTimeout = 3 * time.Second
|
|
// Poll fast enough that the amp's OWN forward/current figures make a usable
|
|
// live meter on their own — the UI prefers them over the FlexRadio VITA stream
|
|
// (which never traverses a public-IP/NAT link), so this direct reading is what
|
|
// an operator watches when running the amp over the internet. At 250 ms the
|
|
// SSB envelope is sampled often enough that peak-hold keeps a steady reading
|
|
// instead of collapsing to ~1 W in the gaps between syllables.
|
|
pollEvery = 250 * 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
|
|
password string // remote-access code; sent as "auth <code>" when the banner announces AUTH
|
|
|
|
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, password string) *Client {
|
|
if port <= 0 || port > 65535 {
|
|
port = defaultPort
|
|
}
|
|
return &Client{host: host, port: port, password: strings.TrimSpace(password), 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)
|
|
}
|
|
// The verb the amp wants is "setup fanmode=VALUE" — confirmed LIVE: with the
|
|
// "setup " prefix the amp replies code 0 (accepted), while the bare
|
|
// "fanmode=VALUE" we switched to earlier is rejected (reply code 0x50000015).
|
|
// That regression is what stopped the fan from changing; restoring "setup "
|
|
// fixes it.
|
|
reply, err := c.command("setup fanmode=" + m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if code := replyCode(reply); code != "" && code != "0" {
|
|
applog.Printf("pgxl: set fanmode=%s REJECTED, reply=%q", m, reply)
|
|
return fmt.Errorf("powergenius: amp rejected fanmode=%s (code %s)", m, code)
|
|
}
|
|
applog.Printf("pgxl: set fanmode=%s reply=%q", m, reply)
|
|
c.statusMu.Lock()
|
|
c.status.FanMode = m // optimistic
|
|
c.fanPending, c.fanPendingAt = m, time.Now()
|
|
c.statusMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// replyCode returns the result code from an "R<id>|<code>|…" reply — "0" means
|
|
// the amp accepted the command. Returns "" when the line isn't an R reply.
|
|
func replyCode(reply string) string {
|
|
if !strings.HasPrefix(reply, "R") {
|
|
return ""
|
|
}
|
|
p := strings.SplitN(reply, "|", 3)
|
|
if len(p) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(p[1])
|
|
}
|
|
|
|
// 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)
|
|
// Banner: "V…" (LAN) or "V… AUTH" (remote → authentication required, exactly
|
|
// like the Tuner Genius / Antenna Genius). Send the remote code when the amp
|
|
// demands it, otherwise every command comes back "Unauthorized".
|
|
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
|
banner, _ := c.reader.ReadString('\n')
|
|
banner = strings.TrimSpace(banner)
|
|
applog.Printf("pgxl: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
|
|
if strings.Contains(banner, "AUTH") {
|
|
if c.password == "" {
|
|
applog.Printf("pgxl: device requires AUTH but no remote code set (Settings → Amplifier)")
|
|
} else if err := c.authLocked(); err != nil {
|
|
c.conn.Close()
|
|
c.conn, c.reader = nil, nil
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// authLocked authenticates the remote link. Must be called with c.mu held
|
|
// (during ensureConnected). 4O3A boxes want "auth code=<pw>" and reply
|
|
// "R<seq>|<hex>|" with an EMPTY message — hex "0" means accepted, so the response
|
|
// CODE decides, not the text. The device also rejects the FIRST attempt (R|FF)
|
|
// and accepts a retry, so resend a few times before giving up.
|
|
func (c *Client) authLocked() error {
|
|
var lastHex string
|
|
for try := 1; try <= 4; try++ {
|
|
id := c.cmdID.Add(1)
|
|
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
|
if _, err := fmt.Fprintf(c.conn, "C%d|auth code=%s\n", id, c.password); 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)
|
|
applog.Printf("pgxl: auth reply=%q (try %d)", line, try)
|
|
hex, msg := "", ""
|
|
if p := strings.SplitN(line, "|", 3); len(p) >= 2 {
|
|
hex = strings.TrimSpace(p[1])
|
|
if len(p) == 3 {
|
|
msg = strings.TrimSpace(p[2])
|
|
}
|
|
}
|
|
// hex "0" is the standard accept; also treat an explicit OK message as success.
|
|
if hex == "0" || (msg != "" && strings.Contains(strings.ToLower(msg), "ok")) {
|
|
applog.Printf("pgxl: authenticated")
|
|
return nil
|
|
}
|
|
lastHex = hex
|
|
}
|
|
return fmt.Errorf("powergenius: authentication failed after 4 tries (R|%s|) — check the remote code", lastHex)
|
|
}
|
|
|
|
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 = ""
|
|
// Log the first REAL status frame (one that carries "key=value" fields) so the
|
|
// field set is visible — even if an earlier reply was junk like "Unauthorized"
|
|
// (which would otherwise latch lastRaw and hide the real frame).
|
|
if strings.Contains(data, "=") && !strings.Contains(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
|
|
}
|