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:
+21
-6
@@ -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
@@ -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)
|
||||
|
||||
@@ -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