Files
OpsLog/internal/antgenius/antgenius.go
T
rouggy 0cc806722e fix: Antenna Genius drew both ports on the same antenna
Reported on an 8x2: port A correctly on the 80 m vertical, then a few seconds
later both A and B shown on the same beam.

The display preferred the TX antenna, falling back to RX. On an 8x2 only ONE port
can hold the transmit antenna, so the switch reports the same txant on both — and
every keepalive poll redrew port A as whatever port B transmits through. The RX
antenna is the per-port selection, and the only thing Activate sets, so it is
what a port shows; TX stays the fallback for a port reporting no RX antenna.

A port change is now logged with the raw line — but only on CHANGE, since the
device pushes state every few seconds and logging each one would bury the rest.
Without any trace, "port A jumped to the wrong antenna" cannot be checked.

Two tests pin it: rx wins over a disagreeing tx, and a message about one port
never moves the other — which is the shape that was actually on screen.
2026-07-29 12:46:48 +02:00

480 lines
14 KiB
Go

// 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<seq>|<command>\r" and the device replies
// with "R<seq>|<hex>|<message>" (hex "0" = success) plus asynchronous
// "S<0>|<message>" 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 <pw>" 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
lastShown map[int]int // port id → antenna last reported, so only CHANGES are 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{},
lastShown: 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=<pw>" 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<seq>|<command>\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<seq>|<hex>|<message> or S<seq>|<message>
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 <id> name=<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=<mask>" (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 <id> ... rxant=<n> txant=<n> ...". 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")
// The RX antenna is what identifies a port's selection, and it is the only
// thing Activate sets. Preferring the TX antenna made both ports converge on
// the same row a few seconds after a change: on an 8x2 only ONE port can hold
// the transmit antenna, so the device reports the same txant on both, and a
// port A genuinely on the 80 m vertical was redrawn as the beam that port B
// transmits through.
//
// TX remains the fallback for a port that reports no RX antenna at all.
active := rx
if active == 0 {
active = tx
}
// Logged only when the shown antenna CHANGES: the device pushes port state
// every few seconds, and logging each one would bury everything else — but
// without any trace, "port A jumped to the wrong antenna" is unfalsifiable.
c.statusMu.Lock()
prev, seen := c.lastShown[id]
c.lastShown[id] = active
c.statusMu.Unlock()
if !seen || prev != active {
applog.Printf("antgenius: port %d → antenna %d (rxant=%d txant=%d) raw=%q", id, active, rx, tx, msg)
}
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=<int>" 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
}