442 lines
12 KiB
Go
442 lines
12 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).
|
|
type Antenna struct {
|
|
Index int `json:"index"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// 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)
|
|
|
|
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{},
|
|
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, "_", " "))
|
|
}
|
|
c.statusMu.Lock()
|
|
if name != "" && !isPlaceholderName(name) {
|
|
c.antennas[id] = name
|
|
} else {
|
|
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
|
}
|
|
c.status.Antennas = sortedAntennas(c.antennas)
|
|
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")
|
|
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=<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) []Antenna {
|
|
out := make([]Antenna, 0, len(m))
|
|
for idx, name := range m {
|
|
out = append(out, Antenna{Index: idx, Name: name})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
|
|
return out
|
|
}
|