// 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|\n" and replies are "R||" (code 0 = OK). // The status reply is pushed back as "S|status ", and unsolicited // "M|" 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 (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 " 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 " and checks the reply. Must be called with c.mu // held (during ensureConnected). Note the device replies R|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|\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||", "S|status …" // and "M|". func (c *Client) parse(resp string) { // Async info/warning: "M|" (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 }