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:
2026-08-04 15:55:10 +02:00
parent b3fdd0b7fa
commit 18f9b915c5
12 changed files with 277 additions and 81 deletions
+21 -6
View File
@@ -61,10 +61,12 @@ type Flex struct {
spotsEnabled bool // push cluster spots + manage the panadapter overlay
spotIdx map[int]bool // panadapter spot indices currently known to the radio
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
// OnSpotClick is called (off the reader goroutine's hot path) when the user
@@ -185,7 +187,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{
host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
pinnedSlice: -1,
@@ -338,11 +340,17 @@ func (f *Flex) reader(conn net.Conn) {
if idx, err := strconv.Atoi(mm[1]); err == nil {
f.mu.Lock()
call := f.spotCall[idx]
mode := f.spotMode[idx]
handler := f.OnSpotClick
f.mu.Unlock()
if call != "" && handler != nil {
debugLog.Printf("Flex: spot %d triggered → %s", idx, call)
debugLog.Printf("Flex: spot %d triggered → %s (mode %s)", idx, call, mode)
go handler(call, 0)
// SmartSDR tunes the spot's frequency on click but does NOT apply
// its mode=, so set the slice mode ourselves from what we spotted.
if mode != "" {
go func(m string) { _ = f.SetMode(m) }(mode)
}
}
}
}
@@ -379,12 +387,15 @@ func (f *Flex) reader(conn net.Conn) {
// pair it with the callsign we stashed under this seq.
f.mu.Lock()
call, pending := f.pendingSpot[seq]
spotMode := f.pendingSpotMode[seq]
if pending {
delete(f.pendingSpot, seq)
delete(f.pendingSpotMode, seq)
}
if pending && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.spotCall[idx] = call
f.spotMode[idx] = spotMode
f.spotIdx[idx] = true
f.spotByCall[strings.ToUpper(call)] = idx
}
@@ -801,6 +812,7 @@ func (f *Flex) handleStatus(payload string) {
if removed {
delete(f.spotIdx, idx)
delete(f.spotCall, idx)
delete(f.spotMode, idx)
} else {
f.spotIdx[idx] = true
}
@@ -1263,6 +1275,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if hadOld {
delete(f.spotByCall, upperCall)
delete(f.spotCall, old)
delete(f.spotMode, old)
delete(f.spotIdx, old)
}
f.mu.Unlock()
@@ -1288,6 +1301,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
// (trigger) can be resolved back to the callsign.
f.mu.Lock()
f.pendingSpot[seq] = s.Callsign
f.pendingSpotMode[seq] = s.Mode
f.mu.Unlock()
}
return nil
@@ -1320,6 +1334,7 @@ func (f *Flex) ClearSpots() error {
f.mu.Lock()
f.spotIdx = map[int]bool{}
f.spotCall = map[int]string{}
f.spotMode = map[int]string{}
f.spotByCall = map[string]int{}
connected := f.conn != nil
f.mu.Unlock()
+47 -14
View File
@@ -116,15 +116,32 @@ func (t *TCI) SendSpot(s SpotInfo) error {
if call == "" || s.FreqHz <= 0 {
return nil
}
color := strings.TrimSpace(s.Color)
if color == "" {
color = "#FFFFA500" // opaque orange default
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
// can't parse as a number, which is why spots never showed on the panorama
// while tuning (a separate command) still worked.
hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
if hex == "" {
hex = "FFFFA500" // opaque orange default
}
if len(hex) == 6 {
hex = "FF" + hex // add full-opacity alpha when only RGB was supplied
}
argb, err := strconv.ParseUint(hex, 16, 32)
if err != nil {
argb = 0xFFFFA500
}
// Use a valid TCI modulation (usb/lsb/cw/digl…) so ExpertSDR accepts the spot;
// fall back to the raw label if we can't map it. The click-to-tune path already
// maps the mode separately, so this only affects the spot's displayed mode.
mode := adifToTCIMode(s.Mode, s.FreqHz)
if mode == "" {
mode = strings.ToLower(strings.TrimSpace(s.Mode))
}
color = "0x" + strings.TrimPrefix(color, "#")
mode := strings.ToLower(strings.TrimSpace(s.Mode))
// Commas/semicolons would break TCI's comma-separated argument parsing.
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
return t.send(fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, int32(argb), text))
}
// Disconnect closes the WebSocket; the reader goroutine then exits.
@@ -302,14 +319,30 @@ func (t *TCI) handle(msg string) {
}
default:
lname := strings.ToLower(name)
// A click on one of our panorama spots comes back as a "spot…" message.
// The exact shape isn't well documented, so read a callsign (+ freq) from
// any spot-related message and fire the callback. The log line lets us pin
// the real format against a radio if it differs.
if strings.HasPrefix(lname, "spot") {
debugLog.Printf("TCI: spot event: %s", msg)
call := get(0)
hz, _ := strconv.ParseInt(get(2), 10, 64)
// A click on one of our panorama spots comes back as
// CLICKED_ON_SPOT:<call>,<hz> (legacy)
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
// Neither name starts with "spot", which is why the click was silently
// ignored before. Read the callsign (the one non-numeric field) and the
// frequency (the large numeric field) positionally-independently, so both
// shapes work without depending on the exact arg order.
if strings.Contains(lname, "spot") {
var call string
var hz int64
for _, raw := range f {
v := strings.TrimSpace(raw)
if v == "" {
continue
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
if n >= 10000 { // a real frequency, not an rx/channel index
hz = n
}
} else if call == "" {
call = strings.ToUpper(v) // callsigns always carry letters
}
}
debugLog.Printf("TCI: spot click %q → call=%s freq=%d", msg, call, hz)
if call != "" && t.OnSpotClick != nil {
cb := t.OnSpotClick
go cb(call, hz)
+75 -11
View File
@@ -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)
}