fix: PGXL meters/auth/fan, TCI spots+click, PTT-over-CAT, CW <LOGQSO>
- PGXL: remote AUTH password; direct-link meter fallback (VITA-first) with 250 ms poll + peak-hold; fan mode uses bare "fanmode=" (was ignored). - TCI: spot colour sent as decimal ARGB (ExpertSDR dropped the hex string); spot click handled via CLICKED_ON_SPOT / RX_CLICKED_ON_SPOT to fill the entry. - FlexRadio: clicking an OpsLog spot on the panadapter now applies the mode too. - Filter presets: the trash icon deletes again (Radix pointer-down intercept). - PTT hotkey: keys over CAT when a backend is active (was hijacked by a stale Audio-tab RTS/DTR serial setting); AltGr keys allowed; presses logged. - CW macro <LOGQSO>: logs after the preceding CW is sent, not on the first letter.
This commit is contained in:
@@ -24,7 +24,13 @@ const (
|
||||
defaultPort = 9008
|
||||
dialTimeout = 5 * time.Second
|
||||
ioTimeout = 3 * time.Second
|
||||
pollEvery = 1500 * time.Millisecond
|
||||
// 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
|
||||
)
|
||||
|
||||
@@ -50,8 +56,9 @@ type Status struct {
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
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
|
||||
@@ -70,11 +77,11 @@ type Client struct {
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
func New(host string, port int, password string) *Client {
|
||||
if port <= 0 || port > 65535 {
|
||||
port = defaultPort
|
||||
}
|
||||
return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}}
|
||||
return &Client{host: host, port: port, password: strings.TrimSpace(password), stop: make(chan struct{}), status: Status{Host: host}}
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
@@ -118,9 +125,15 @@ func (c *Client) SetFanMode(mode string) error {
|
||||
default:
|
||||
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
||||
}
|
||||
if _, err := c.command("setup fanmode=" + m); err != nil {
|
||||
// Set with the bare "key=value" verb the amp uses for its own status fields
|
||||
// (same convention as "operate=1"). The earlier "setup fanmode=…" carried a
|
||||
// bogus prefix the amp silently ignored, so the fan never changed and the next
|
||||
// status kept reporting the old mode — the revert-to-Contest the operator saw.
|
||||
reply, err := c.command("fanmode=" + m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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()
|
||||
@@ -177,12 +190,62 @@ func (c *Client) ensureConnected() error {
|
||||
}
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(conn)
|
||||
// Discard the version banner the device sends on connect.
|
||||
// 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))
|
||||
_, _ = c.reader.ReadString('\n')
|
||||
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 {
|
||||
@@ -237,9 +300,10 @@ func (c *Client) parse(resp string) {
|
||||
c.statusMu.Lock()
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
// One raw frame per session is enough to learn the field set — the frames
|
||||
// carry live meter values, so "log on change" logged every frame.
|
||||
if c.lastRaw == "" {
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user