feat: control the 4O3A Tuner Genius XL directly over TCP (port 9010)
New internal/tunergenius client speaking the same "Genius Series" text API as the Antenna Genius / PowerGenius XL: banner on connect (with optional "AUTH" for remote access), "C<seq>|<cmd>\n" commands, "R<seq>|<code>|<msg>" replies and the "S<seq>|status k=v …" snapshot; async "M|<msg>" info lines are consumed inline. Polls status ~1.5s and exposes SWR (return-loss dB converted to a VSWR ratio), forward power (dBm→W), and the operate/bypass/ tuning/active-channel state. Actions: autotune, global bypass, operate/standby. Controlled directly (not via the radio) so OpsLog uses only one of the box's four connection slots, per the device's protocol doc. Wiring: - app.go: tunergenius.* settings keys, Get/Save/start, GetTunerGeniusStatus, TunerGeniusAutotune/SetBypass/SetOperate; started in the background at launch. - Settings → Tuner Genius panel (enable + IP + optional remote code). - Docked TunerGeniusPanel widget (SWR/power readouts + Tune/Bypass/Operate), top-bar toggle, shown when enabled — mirrors the Antenna Genius widget. - i18n EN/FR (sec.tunergenius, tg2.*, tgp.*). Command verbs (operate/bypass/autotune/status/auth) come straight from the 4O3A "Tuner Genius XL — Protocol Description"; UNTESTED on hardware.
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
// 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
|
||||
pollEvery = 1500 * time.Millisecond
|
||||
reconnectDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// 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)
|
||||
FreqMHz float64 `json:"freq_mhz"` // active channel frequency
|
||||
|
||||
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)
|
||||
Antenna int `json:"antenna"` // antenna in use (3WAY / SO2R-with-AG)
|
||||
ThreeWay bool `json:"three_way"` // 3-way (vs SO2R) hardware variant
|
||||
|
||||
Message string `json:"message,omitempty"` // last M| warning/info (empty = cleared)
|
||||
}
|
||||
|
||||
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:
|
||||
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: "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
|
||||
}
|
||||
// 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. Per-channel
|
||||
// fields (A/B) are folded down to the active channel for the single-radio UI.
|
||||
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"
|
||||
|
||||
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)
|
||||
}
|
||||
// Active-channel frequency + antenna (suffix A for ch1, B for ch2).
|
||||
suffix := "A"
|
||||
if active == 2 {
|
||||
suffix = "B"
|
||||
}
|
||||
if v, ok := parseFloat(kv["freq"+suffix]); ok {
|
||||
c.status.FreqMHz = v
|
||||
}
|
||||
c.status.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
|
||||
}
|
||||
Reference in New Issue
Block a user