Push the tuner control further so it mirrors the native 4O3A app and the way the PowerGenius XL is surfaced. Backend (internal/tunergenius): - Status now carries both RF channels A and B (source/mode, band, frequency, bound Flex nickname, per-channel bypass, antenna, PTT), the active channel, the C1/L/C2 relay-network positions, and the 3-way-vs-SO2R hardware variant (learned once from the `info` reply). Flat freq/antenna still mirror the active channel for the compact widget. - New TunerGeniusActivate(ch) binding → `activate ch=N` (or `ant=N` on 3-way). Frontend: - New shared TunerCard, styled exactly like AmpCard (PWR/SWR meter bars, A/B channel selector with source+freq+antenna, Tune/Bypass/Operate). Used in BOTH the FlexRadio panel (its own card, like the PGXL) and Station Control. - Docked TunerGeniusPanel widget now shows the two channels A/B with their source/frequency/antenna and lets you click to make one active — the missing A/B state the user flagged. - i18n EN/FR for the new labels (channels, antenna, in-line/bypassed, title). Still UNTESTED on hardware — verify the per-channel field names/units and the activate behaviour on the real box.
494 lines
14 KiB
Go
494 lines
14 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
|
||
)
|
||
|
||
// 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 (0–255)
|
||
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
|
||
}
|