Files
OpsLog/internal/powergenius/powergenius.go
T
rouggy ed099a660e fix(pgxl): read the amplifier's real state instead of assuming standby
Reported: OpsLog shows STANDBY on a PowerGenius XL that is operating, and
pressing the button 'puts it in Operate' — because it was already there.

The amplifier's status frame has no operate= field. The direct GSCP
client only looked for one, so Operate stayed at its zero value until the
operator pressed the button: at startup OpsLog was not reading the state
wrongly, it was not reading it at all.

The live state is in the frame under 'state', and the FlexRadio side of
this same amplifier has been reading it that way all along — anything but
STANDBY/OFF means the amp is in line, with IDLE meaning in line but not
keyed. The GSCP client now does the same when no operate= is present.

An unknown state leaves the flag alone rather than guessing: claiming
STANDBY on an amp that is in line is precisely the error being fixed, and
it invites the operator to switch on what is already on.
2026-08-25 14:59:50 +02:00

454 lines
14 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])
}
// operateFromState maps the amplifier's live state to "in line or not".
//
// Unknown states leave the flag alone rather than guessing: a state nobody has
// seen is not evidence that the amp is standing by, and claiming STANDBY on an
// amplifier that is in line is the error that matters here — it invites the
// operator to "switch it on" and command the state it is already in.
func operateFromState(state string) (operate, known bool) {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "STANDBY", "OFF", "POWERED_OFF", "DISCONNECTED":
return false, true
case "OPERATE", "OPERATING", "IDLE", "RECEIVE", "RX", "TRANSMIT", "TRANSMIT_A", "TRANSMIT_B", "TX", "KEYED":
return true, true
}
return false, false
}
// 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
}
line, err := c.readReplyLocked(fmt.Sprintf("R%d|", id))
if err != nil {
return err
}
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)
}
// readReplyLocked reads until the reply carrying `want` as its prefix arrives,
// feeding every unsolicited frame it passes to parse() on the way.
//
// The amplifier PUSHES status frames ("S0|state=…") on the same socket, and it
// pushes them constantly once it is in OPERATE — power, SWR and temperature all
// move while transmitting. The old code read exactly one line per command and
// took whatever came first as its answer, so a single pushed frame put the
// stream permanently one reply behind: every later command read the PREVIOUS
// command's answer, and the last one waited out the 3 s deadline, failed, and
// dropped the connection. That is the reconnect-every-few-seconds seen in the
// field, and the stall in transmit — command() holds the mutex across that whole
// dead wait, so anything else touching the amplifier queued behind it.
//
// It only showed up remotely and in OPERATE: on a LAN with an idle amplifier
// there is almost nothing to push and the race hardly ever opens.
func (c *Client) readReplyLocked(want string) (string, error) {
deadline := time.Now().Add(ioTimeout)
for {
_ = c.conn.SetReadDeadline(deadline)
line, err := c.reader.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
// Every frame is worth having, ours or not — a pushed status is fresher
// than the one we were about to ask for.
c.parse(line)
if strings.HasPrefix(line, want) {
return line, 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
}
return c.readReplyLocked(fmt.Sprintf("R%d|", id))
}
// 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)
}
sawOperate := false
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":
sawOperate = true
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)
}
}
// THE AMP DOES NOT SEND "operate=".
//
// Operate was therefore never read at all on this link: it stayed at the
// zero value until the operator pressed the button, so OpsLog opened
// claiming STANDBY on an amplifier that was in line — and the first press
// then commanded the state it was already in. Reported from a real PGXL.
//
// The live state IS in the frame, under "state", and the FlexRadio side of
// this same amplifier has been reading it that way all along (see
// flexAmp): anything but STANDBY/OFF means the amp is IN LINE. IDLE is
// operate — in line, not keyed.
if !sawOperate && c.status.State != "" {
if op, known := operateFromState(c.status.State); known {
c.status.Operate = op
}
}
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
}