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.
480 lines
14 KiB
Go
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
|
|
}
|