// Package antgenius drives a 4O3A Antenna Genius switch over its v4 TCP/IP // text API (default port 9007). On connect the device sends a banner line // (e.g. "V4.1.16 AG"); commands are "C|\r" and the device replies // with "R||" (hex "0" = success) plus asynchronous // "S<0>|" status pushes once you subscribe with "sub port/antenna". // // (The older "GSCP" binary-ish framing documented at gscp.arula.rs is only used // by pre-v4 firmware and is NOT what v4 speaks.) package antgenius import ( "bufio" "fmt" "net" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "hamlog/internal/applog" ) const ( defaultPort = 9007 dialTimeout = 5 * time.Second writeTimeout = 3 * time.Second readIdleTimeout = 12 * time.Second // no data for this long → assume the link is dead keepaliveEvery = 3 * time.Second // periodic "port get" refreshes state + keeps the link alive reconnectDelay = 2 * time.Second ) // Antenna is one configured antenna (index + name as stored on the device). // Bands is the device's band bitmask for the antenna (which bands it covers); // 0 = unknown/all. The UI uses it to show only band-appropriate antennas, like // the native 4O3A app. type Antenna struct { Index int `json:"index"` Name string `json:"name"` Bands int `json:"bands"` } // Status is the snapshot the UI renders. type Status struct { Connected bool `json:"connected"` Host string `json:"host,omitempty"` LastError string `json:"last_error,omitempty"` PortA int `json:"port_a"` // active antenna index on port A (0 = none) PortB int `json:"port_b"` // active antenna index on port B TxA bool `json:"tx_a"` // port A is transmitting TxB bool `json:"tx_b"` // port B is transmitting Antennas []Antenna `json:"antennas"` } type Client struct { host string port int password string // remote-access password; sent as "login " when the device requires AUTH mu sync.Mutex // guards conn + writes conn net.Conn // Auth handshake state (touched only on the runLoop/readLoop goroutine). awaitingAuth bool authTries int ready atomic.Bool // init commands sent → keepalive may run statusMu sync.RWMutex status Status antennas map[int]string // index → name (rebuilt into status.Antennas) antBands map[int]int // index → band bitmask (which bands the antenna covers) antRawN int // one-shot: how many raw antenna lines we've logged 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{}), antennas: map[int]string{}, antBands: map[int]int{}, status: Status{Host: host}, } } func (c *Client) Start() error { c.running = true go c.runLoop() 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.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() } // Activate selects antenna on a port (1 = A, 2 = B). antenna 0 deselects (sets // the port to "None"). We set both RX and TX antennas and force manual mode so // the choice sticks regardless of the device's auto band-following. func (c *Client) Activate(port, antenna int) error { if port != 1 && port != 2 { return fmt.Errorf("antgenius: invalid port %d (1=A, 2=B)", port) } if antenna < 0 { return fmt.Errorf("antgenius: invalid antenna %d", antenna) } // Set only rxant (like the reference ShackMaster client): the AG mirrors it // to the TX antenna automatically. Forcing txant too can be rejected on the // 8x2 (an antenna can't be TX on both ports at once), which broke port-B // selection and deselection. if err := c.send(fmt.Sprintf("port set %d rxant=%d", port, antenna)); err != nil { return err } // Ask for the new port state so the snapshot reflects it promptly (the // subscription also pushes it, but this makes the change deterministic). _ = c.send(fmt.Sprintf("port get %d", port)) return nil } func (c *Client) runLoop() { for { if !c.running { return } conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout) if err != nil { c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() }) if c.sleep(reconnectDelay) { return } continue } c.mu.Lock() c.conn = conn c.mu.Unlock() c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host }) applog.Printf("antgenius: TCP connected %s → %s", conn.LocalAddr(), conn.RemoteAddr()) // Auth + init are driven by the banner (handleLine): on "V… AG AUTH" with a // password we send "auth code=" and only send the subscribe/get commands // once it's accepted (retrying on FF); without AUTH they go out immediately. c.awaitingAuth, c.authTries = false, 0 c.ready.Store(false) done := make(chan struct{}) go c.keepalive(conn, done) err = c.readLoop(conn) // blocks until the link errors close(done) c.mu.Lock() if c.conn == conn { c.conn = nil } c.mu.Unlock() conn.Close() c.setStatus(func(s *Status) { s.Connected = false if err != nil { s.LastError = "read: " + err.Error() } }) if c.sleep(reconnectDelay) { return } } } // keepalive periodically re-reads a port so an idle-but-dead link is detected // (the read loop's idle timeout fires if these stop producing replies). func (c *Client) keepalive(conn net.Conn, done chan struct{}) { t := time.NewTicker(keepaliveEvery) defer t.Stop() for { select { case <-done: return case <-c.stop: return case <-t.C: if !c.ready.Load() { continue // not authenticated / subscribed yet } _ = c.send("port get 1") _ = c.send("port get 2") } } } // sendInit subscribes to live updates and pulls the initial state. Called once // the link is ready (authenticated, or immediately when no AUTH is required). // Command set/order mirror a known-working Node-RED v4 client (WA9WUD). func (c *Client) sendInit() { c.ready.Store(true) for _, cmd := range []string{"antenna list", "sub port all", "port get 1", "port get 2"} { if err := c.send(cmd); err != nil { applog.Printf("antgenius: init send %q failed: %v", cmd, err) } } } func (c *Client) readLoop(conn net.Conn) error { r := bufio.NewReader(conn) var sb strings.Builder start := time.Now() lines, bytesRx := 0, 0 for { _ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout)) b, err := r.ReadByte() if err != nil { // Log where the link died: 0 lines after connect points at the device // refusing/closing (e.g. a client-slot limit or source-IP rejection — // common when reaching it remotely); an EOF after the banner/replies // points at something later in the exchange. applog.Printf("antgenius: read ended after %s — %d line(s), %d byte(s): %v", time.Since(start).Round(time.Millisecond), lines, bytesRx, err) return err } bytesRx++ if b == '\r' || b == '\n' { if sb.Len() > 0 { lines++ if lines <= 10 { applog.Printf("antgenius: rx[%d] %q", lines, sb.String()) } c.handleLine(sb.String()) sb.Reset() } continue } sb.WriteByte(b) } } // send writes a "C|\r" line to the device. func (c *Client) send(command string) error { c.mu.Lock() defer c.mu.Unlock() if c.conn == nil { return fmt.Errorf("antgenius: not connected") } _ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)) // The device only accepts the constant "C1|" sequence prefix for every // command (using incrementing sequence numbers makes it drop the link); // commands are LF-terminated. _, err := fmt.Fprintf(c.conn, "C1|%s\n", command) return err } // handleLine parses one response/status/banner line and updates the snapshot. func (c *Client) handleLine(line string) { line = strings.TrimSpace(line) if line == "" { return } // Banner: "V4.1.16 AG" (LAN) or "V4.1.16 AG AUTH" (remote). It drives what we // send next — authenticate first when AUTH is announced, else go straight to // the subscribe/get commands. if line[0] == 'V' && strings.Contains(line, "AG") { c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" }) if strings.Contains(line, "AUTH") && c.password != "" { c.awaitingAuth, c.authTries = true, 1 applog.Printf("antgenius: AUTH required — authenticating") _ = c.send("auth code=" + c.password) } else { if strings.Contains(line, "AUTH") { applog.Printf("antgenius: device requires AUTH but no remote password set (Settings → Antenna Genius)") } c.sendInit() } return } // R|| or S| var hexCode, msg string switch { case strings.HasPrefix(line, "R"): p := strings.SplitN(line, "|", 3) if len(p) >= 2 { hexCode = p[1] } if len(p) == 3 { msg = p[2] } case strings.HasPrefix(line, "S"): p := strings.SplitN(line, "|", 2) if len(p) == 2 { msg = p[1] } } msg = strings.TrimSpace(msg) // Auth result: the reply to "auth code=" carries an empty message; hex "0" = // accepted. The device rejects the FIRST attempt (R|FF) and accepts a retry, // so resend a few times before giving up. if c.awaitingAuth && strings.HasPrefix(line, "R") && msg == "" { switch { case hexCode == "0": c.awaitingAuth = false applog.Printf("antgenius: authenticated") c.sendInit() case c.authTries < 4: c.authTries++ applog.Printf("antgenius: auth rejected (R|%s|) — retry %d", hexCode, c.authTries) _ = c.send("auth code=" + c.password) default: c.awaitingAuth = false applog.Printf("antgenius: auth failed after %d tries (R|%s|) — check the remote password", c.authTries, hexCode) } return } switch { case strings.HasPrefix(msg, "antenna "): c.parseAntenna(msg) case strings.HasPrefix(msg, "port "): c.parsePort(msg) } } // parseAntenna handles "antenna name= tx=.. rx=.. inband=..". // The name may contain spaces, so it's extracted up to the " tx=" field. func (c *Client) parseAntenna(msg string) { fields := strings.Fields(msg) if len(fields) < 2 { return } id, err := strconv.Atoi(fields[1]) if err != nil { return } name := "" if i := strings.Index(msg, "name="); i >= 0 { name = msg[i+len("name="):] if j := strings.Index(name, " tx="); j >= 0 { name = name[:j] } // The device stores spaces as underscores in names. name = strings.TrimSpace(strings.ReplaceAll(name, "_", " ")) } // Band bitmask: which bands this antenna is configured for. The device sends // it as "band=" (a bitmask). Kept so the UI can show only band-relevant // antennas (like the native app). Log the first few raw antenna lines so the // exact field name + bit values can be confirmed on real hardware. bands := kvInt(msg, "band") c.statusMu.Lock() if c.antRawN < 8 { c.antRawN++ applog.Printf("antgenius: antenna raw #%d: %q (parsed band mask=%d/0x%X)", c.antRawN, msg, bands, bands) } if name != "" && !isPlaceholderName(name) { c.antennas[id] = name c.antBands[id] = bands } else { delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown delete(c.antBands, id) } c.status.Antennas = sortedAntennas(c.antennas, c.antBands) c.status.Connected = true c.statusMu.Unlock() } // parsePort handles "port ... rxant= txant= ...". The active antenna // shown is the TX antenna, falling back to the RX antenna when TX is none. func (c *Client) parsePort(msg string) { fields := strings.Fields(msg) if len(fields) < 2 { return } id, err := strconv.Atoi(fields[1]) if err != nil || (id != 1 && id != 2) { return } tx := kvInt(msg, "txant") rx := kvInt(msg, "rxant") active := tx if active == 0 { active = rx } txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag c.setStatus(func(s *Status) { s.Connected = true if id == 1 { s.PortA, s.TxA = active, txOn } else { s.PortB, s.TxB = active, txOn } }) } func (c *Client) sleep(d time.Duration) (stopped bool) { select { case <-c.stop: return true case <-time.After(d): return false } } // kvInt extracts the integer value of a "key=" token from a space- // separated string (returns 0 if absent). func kvInt(s, key string) int { i := strings.Index(s, key+"=") if i < 0 { return 0 } v := s[i+len(key)+1:] if sp := strings.IndexByte(v, ' '); sp >= 0 { v = v[:sp] } n, _ := strconv.Atoi(strings.TrimSpace(v)) return n } // isPlaceholderName reports whether name is an unconfigured-slot default like // "Antenna 4" / "antenna_5" (after underscores become spaces): the word // "antenna" followed by a number, which the UI shouldn't list. func isPlaceholderName(name string) bool { f := strings.Fields(strings.ToLower(name)) if len(f) != 2 || f[0] != "antenna" { return false } _, err := strconv.Atoi(f[1]) return err == nil } func sortedAntennas(m map[int]string, bands map[int]int) []Antenna { out := make([]Antenna, 0, len(m)) for idx, name := range m { out = append(out, Antenna{Index: idx, Name: name, Bands: bands[idx]}) } sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index }) return out }