Files
OpsLog/internal/tunergenius/tunergenius.go
T
rouggy 3a9dda13c4 feat: collapsible Flex/amp/tuner cards, matched meter sizes, faster + distinct-icon TGXL
Addresses three points of feedback on the Tuner Genius work:

- Meter sizes: the amplifier meters were rendered `compact` (smaller than the
  FlexRadio meters). Dropped compact so the amp, tuner and Flex meters are all
  the same size.
- Collapsible cards: the FlexRadio panel Card, AmpCard and TunerCard now fold
  from a chevron in the header, state persisted per card (opslog.cardOpen.*).
  The amplifier cards share the "amplifier" collapse key across their SPE/ACOM/
  PGXL variants so folding sticks regardless of the shown model.
- TGXL responsiveness: the tuner's device poll dropped 1500ms→400ms and the
  three UI pollers 1500ms→500ms, so the SWR/power meters track TX without the
  2–3s lag behind the amplifier the user saw.
- Icon: the Tuner Genius top-bar toggle used Zap, same as the CW keyer — changed
  the tuner's icon (top bar + widget + card) to Gauge so the two are distinct.
2026-07-25 11:22:56 +02:00

497 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package tunergenius drives a 4O3A Tuner Genius XL over its TCP text API
// (fixed port 9010 — the same port the device also uses for UDP discovery
// broadcasts). It's the same "Genius Series" line protocol as the PowerGenius
// XL / Antenna Genius: on connect the device sends a banner ("V1.1.8" or
// "V1.1.8 AUTH" when reached from outside the LAN); commands are
// "C<seq>|<command>\n" and replies are "R<seq>|<code>|<message>" (code 0 = OK).
// The status reply is pushed back as "S<seq>|status <k=v …>", and unsolicited
// "M|<message>" info/warning lines can arrive at any time.
//
// Protocol reference: 4O3A "TUNER GENIUS XL — PROTOCOL DESCRIPTION".
package tunergenius
import (
"bufio"
"fmt"
"math"
"net"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"hamlog/internal/applog"
)
const (
// DefaultPort is fixed on the device (both the TCP control channel and the
// UDP discovery broadcast use 9010).
DefaultPort = 9010
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
// Poll fast so the meters track TX like the amplifier does (the amp's numbers
// ride the real-time Flex UDP stream; the tuner is a synchronous TCP poll, so
// a slow interval made its SWR/power lag noticeably behind).
pollEvery = 400 * time.Millisecond
reconnectDelay = 2 * time.Second
)
// Channel is the live state of one of the tuner's two RF channels (A / B). The
// Tuner Genius XL is a dual (SO2R) coupler, so each channel tracks its own
// source, band, frequency and antenna — mirroring the two rows the native 4O3A
// app shows.
type Channel struct {
PTT bool `json:"ptt"` // this channel is keyed
Band int `json:"band"` // band as reported by the device (0 = unknown)
Mode int `json:"mode"` // 0=RF Sense 1=FLEX 2=CAT 3=P2B 4=BCD
ModeStr string `json:"mode_str"` // human-readable mode/source
Flex string `json:"flex"` // bound Flex radio nickname (FLEX mode)
FreqMHz float64 `json:"freq_mhz"` // current frequency
Bypass bool `json:"bypass"` // this channel bypassed
Antenna int `json:"antenna"` // antenna in use (3WAY / SO2R-with-AG; 0 = n/a)
}
// Status is the snapshot the UI renders. Power/SWR come from the device's
// "status" reply; the booleans mirror the tuner's operating state.
type Status struct {
Connected bool `json:"connected"`
Host string `json:"host,omitempty"`
LastError string `json:"last_error,omitempty"`
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
Operate bool `json:"operate"` // state == OPERATE (1) vs STANDBY (0)
Bypass bool `json:"bypass"` // device global bypass engaged
Tuning bool `json:"tuning"` // autotune in progress
Active int `json:"active"` // active channel (1 = A, 2 = B)
ThreeWay bool `json:"three_way"` // 3-way (vs SO2R) hardware variant
A Channel `json:"a"` // channel A
B Channel `json:"b"` // channel B
RelayC1 int `json:"relay_c1"` // tuner network position (0255)
RelayL int `json:"relay_l"`
RelayC2 int `json:"relay_c2"`
// FreqMHz / Antenna mirror the ACTIVE channel, kept for the compact docked
// widget that shows a single readout.
FreqMHz float64 `json:"freq_mhz"`
Antenna int `json:"antenna"`
Message string `json:"message,omitempty"` // last M| warning/info (empty = cleared)
}
// modeName maps the device's numeric mode/source to a label.
func modeName(m int) string {
switch m {
case 0:
return "RF Sense"
case 1:
return "Flex"
case 2:
return "CAT"
case 3:
return "P2B"
case 4:
return "BCD"
default:
return ""
}
}
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 to map fields against real hardware
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()
}
// SetOperate puts the tuner in OPERATE (1) or STANDBY (0).
func (c *Client) SetOperate(on bool) error {
if _, err := c.command("operate set=" + boolNum(on)); err != nil {
return err
}
c.setStatus(func(s *Status) { s.Operate = on }) // optimistic; the poll confirms
return nil
}
// SetBypass engages (1) or clears (0) the device's global bypass (antenna
// connected straight through, tuner out of line).
func (c *Client) SetBypass(on bool) error {
if _, err := c.command("bypass set=" + boolNum(on)); err != nil {
return err
}
c.setStatus(func(s *Status) { s.Bypass = on })
return nil
}
// Autotune starts an automatic tuning cycle on the active channel. The rig must
// be keyed into a carrier for the tuner to measure SWR — the device asserts its
// own PTT OUT if "tune PTT" is enabled in its setup.
func (c *Client) Autotune() error {
if _, err := c.command("autotune"); err != nil {
return err
}
c.setStatus(func(s *Status) { s.Tuning = true }) // optimistic until the poll clears it
return nil
}
// Activate selects the active channel. On SO2R hardware ch is 1 (A) or 2 (B);
// on the 3-way variant it selects the antenna (1/2/3).
func (c *Client) Activate(ch int) error {
if ch < 1 {
return fmt.Errorf("tunergenius: invalid channel %d", ch)
}
key := "ch"
if c.GetStatus().ThreeWay {
key = "ant"
}
_, err := c.command(fmt.Sprintf("activate %s=%d", key, ch))
return err
}
func (c *Client) pollLoop() {
t := time.NewTicker(pollEvery)
defer t.Stop()
for {
select {
case <-c.stop:
return
case <-t.C:
fresh := false
if c.needConnect() {
if err := c.ensureConnected(); err != nil {
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
continue
}
fresh = true
}
// One-shot on a fresh link: learn the hardware variant (3-way vs SO2R).
if fresh {
_, _ = c.command("info")
}
if _, err := c.command("status"); err != nil {
c.dropConn()
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
}
}
}
}
// needConnect reports whether the TCP link is currently down (so the poll loop
// knows a fresh connect + one-shot info query is needed).
func (c *Client) needConnect() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.conn == nil
}
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: "V1.1.8" (LAN) or "V1.1.8 AUTH" (remote → authentication required).
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
banner, _ := c.reader.ReadString('\n')
banner = strings.TrimSpace(banner)
applog.Printf("tunergenius: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
if strings.Contains(banner, "AUTH") {
if c.password == "" {
applog.Printf("tunergenius: device requires AUTH but no remote code set (Settings → Tuner Genius)")
} else if err := c.authLocked(); err != nil {
c.conn.Close()
c.conn, c.reader = nil, nil
return err
}
}
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
return nil
}
// authLocked sends "auth <code>" and checks the reply. Must be called with c.mu
// held (during ensureConnected). Note the device replies R<seq>|0|... for BOTH
// success ("auth OK") and failure ("Unauthorized"), so the message text — not
// the response code — decides.
func (c *Client) authLocked() error {
id := c.cmdID.Add(1)
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
if _, err := fmt.Fprintf(c.conn, "C%d|auth %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)
if strings.Contains(strings.ToLower(line), "unauthorized") {
return fmt.Errorf("tunergenius: authentication failed — check the remote code")
}
applog.Printf("tunergenius: authenticated")
return nil
}
// command sends "C<id>|<cmd>\n" and returns the matching reply line, updating
// the status snapshot from whatever status/message lines arrive. Unsolicited
// "M|" info lines that precede the reply are consumed (they update Message).
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("tunergenius: 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
}
// Read until the command's reply (R…/S…); consume async M| lines along the way.
for i := 0; i < 8; i++ {
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
line, err := c.reader.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
c.parse(line)
if line[0] == 'R' || line[0] == 'S' {
return line, nil
}
}
return "", fmt.Errorf("tunergenius: no reply to %q", cmd)
}
func (c *Client) dropConn() {
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.mu.Unlock()
}
// parse handles the three line shapes: "R<id>|<code>|<msg>", "S<id>|status …"
// and "M|<message>".
func (c *Client) parse(resp string) {
// Async info/warning: "M|<message>" (empty message = cleared).
if strings.HasPrefix(resp, "M|") {
msg := strings.TrimSpace(strings.TrimPrefix(resp, "M|"))
c.setStatus(func(s *Status) { s.Message = msg })
return
}
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
}
// "info …" carries the hardware variant (3way=1 on the 3-way model, absent on
// SO2R) — parsed once so the UI knows whether channels are A/B or antennas.
if strings.HasPrefix(data, "info") {
tw := strings.Contains(data, "3way=1")
c.statusMu.Lock()
c.status.ThreeWay = tw
c.statusMu.Unlock()
return
}
// Only the "status …" payload carries the live state we render.
if !strings.HasPrefix(data, "status") {
return
}
if data != c.lastRaw {
c.lastRaw = data
applog.Printf("tunergenius: status raw=%q", data)
}
c.applyStatus(data)
}
// applyStatus maps the "status k=v …" fields onto the snapshot, filling both
// channels (A/B) plus the global power/SWR and operating state.
func (c *Client) applyStatus(data string) {
kv := map[string]string{}
for _, tok := range strings.Fields(data) {
if p := strings.SplitN(tok, "=", 2); len(p) == 2 {
kv[p[0]] = p[1]
}
}
active := atoiDefault(kv["active"], 1)
c.statusMu.Lock()
defer c.statusMu.Unlock()
c.status.Connected = true
c.status.LastError = ""
c.status.Active = active
c.status.Operate = kv["state"] == "1"
c.status.Bypass = kv["bypass"] == "1"
c.status.Tuning = kv["tuning"] == "1"
c.status.RelayC1 = atoiDefault(kv["relayC1"], 0)
c.status.RelayL = atoiDefault(kv["relayL"], 0)
c.status.RelayC2 = atoiDefault(kv["relayC2"], 0)
if v, ok := parseFloat(kv["fwd"]); ok {
c.status.FwdDbm = v
c.status.FwdW = dbmToWatts(v)
}
if v, ok := parseFloat(kv["swr"]); ok {
c.status.SwrDb = v
c.status.Vswr = returnLossToVswr(v)
}
c.status.A = channelFrom(kv, "A")
c.status.B = channelFrom(kv, "B")
// Mirror the active channel into the flat fields the compact widget uses.
act := c.status.A
if active == 2 {
act = c.status.B
}
c.status.FreqMHz = act.FreqMHz
c.status.Antenna = act.Antenna
}
// channelFrom extracts one channel's fields (suffix "A" or "B") from the parsed
// status map.
func channelFrom(kv map[string]string, suffix string) Channel {
mode := atoiDefault(kv["mode"+suffix], 0)
freq, _ := parseFloat(kv["freq"+suffix])
return Channel{
PTT: kv["ptt"+suffix] == "1",
Band: atoiDefault(kv["band"+suffix], 0),
Mode: mode,
ModeStr: modeName(mode),
Flex: strings.TrimSpace(kv["flex"+suffix]),
FreqMHz: freq,
Bypass: kv["bypass"+suffix] == "1",
Antenna: atoiDefault(kv["ant"+suffix], 0),
}
}
func boolNum(on bool) string {
if on {
return "1"
}
return "0"
}
func atoiDefault(s string, def int) int {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
return n
}
return def
}
func parseFloat(s string) (float64, bool) {
if s == "" {
return 0, false
}
v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v, err == nil
}
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW).
func dbmToWatts(dbm float64) float64 {
return math.Pow(10, (dbm-30)/10)
}
// returnLossToVswr converts the device's "swr" field — a return loss in dB,
// reported as a negative number (e.g. -60 = an excellent 60 dB match) — into a
// conventional VSWR ratio. A near-zero return loss (bad match) yields a large
// VSWR; a large negative one yields ~1.0.
func returnLossToVswr(swrDb float64) float64 {
rl := math.Abs(swrDb)
rho := math.Pow(10, -rl/20) // reflection coefficient magnitude
if rho >= 1 {
return 99.9
}
vswr := (1 + rho) / (1 - rho)
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
return 99.9
}
return vswr
}