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.
404 lines
11 KiB
Go
404 lines
11 KiB
Go
// 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
|
|
}
|