fix: solve issue with Antenna Genius for remote operations
This commit is contained in:
@@ -17,6 +17,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -47,8 +49,9 @@ type Status struct {
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
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
|
||||
@@ -61,13 +64,14 @@ type Client struct {
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
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},
|
||||
@@ -146,13 +150,27 @@ func (c *Client) runLoop() {
|
||||
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())
|
||||
|
||||
// When reached remotely the device announces "V… AG AUTH" and refuses every
|
||||
// command (R1|FF) until the client logs in. Sent FIRST so it's authenticated
|
||||
// before the subscribe/get commands (the device processes the stream in
|
||||
// order). On the LAN there's no AUTH and the password is blank → skipped.
|
||||
if c.password != "" {
|
||||
if err := c.send("login " + c.password); err != nil {
|
||||
applog.Printf("antgenius: login send failed: %v", err)
|
||||
} else {
|
||||
applog.Printf("antgenius: sent login (password %d chars)", len(c.password))
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to live updates and pull the initial state. Command set and
|
||||
// order mirror a known-working Node-RED v4 client (WA9WUD).
|
||||
_ = c.send("antenna list")
|
||||
_ = c.send("sub port all")
|
||||
_ = c.send("port get 1")
|
||||
_ = c.send("port get 2")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go c.keepalive(conn, done)
|
||||
@@ -198,14 +216,27 @@ func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package antgenius
|
||||
import "testing"
|
||||
|
||||
func TestHandleAntennaList(t *testing.T) {
|
||||
c := New("x", 9007)
|
||||
c := New("x", 9007, "")
|
||||
// Names may contain spaces — must be captured up to " tx=".
|
||||
c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000")
|
||||
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
|
||||
@@ -20,7 +20,7 @@ func TestHandleAntennaList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
||||
c := New("x", 9007)
|
||||
c := New("x", 9007, "")
|
||||
c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space
|
||||
c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||
@@ -31,7 +31,7 @@ func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandlePortStatus(t *testing.T) {
|
||||
c := New("x", 9007)
|
||||
c := New("x", 9007, "")
|
||||
// Async push after "sub port all": active antenna is txant (fallback rxant).
|
||||
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0")
|
||||
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
|
||||
@@ -50,7 +50,7 @@ func TestHandlePortStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPortTxFallbackToRx(t *testing.T) {
|
||||
c := New("x", 9007)
|
||||
c := New("x", 9007, "")
|
||||
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
|
||||
if st := c.GetStatus(); st.PortA != 3 {
|
||||
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
|
||||
|
||||
+45
-13
@@ -12,18 +12,38 @@ import (
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local
|
||||
// control). It speaks the shared civ protocol, so when the network backend
|
||||
// (icomnet) is added it will reuse the same encode/decode — only the transport
|
||||
// changes. Implements Backend; all methods run on the Manager's CAT goroutine,
|
||||
// so the port is accessed single-threaded (no locking needed).
|
||||
// civTransport is everything IcomSerial needs from its underlying link. It's
|
||||
// satisfied directly by a go.bug.st serial.Port (USB, local control) and, for
|
||||
// remote control, by the icomnet UDP stream which presents the tunnelled CI-V
|
||||
// byte stream as plain Read/Write (there SetDTR/SetRTS/SetReadTimeout are
|
||||
// no-ops). Abstracting it here means the whole IcomController surface — DSP,
|
||||
// scope, RIT, CW — is reused unchanged over the network; only `open` differs.
|
||||
type civTransport interface {
|
||||
Read(p []byte) (int, error)
|
||||
Write(p []byte) (int, error)
|
||||
Close() error
|
||||
SetReadTimeout(time.Duration) error
|
||||
SetDTR(bool) error
|
||||
SetRTS(bool) error
|
||||
}
|
||||
|
||||
// IcomSerial controls an Icom transceiver over the shared civ protocol. The
|
||||
// transport is pluggable via `open`: NewIcomSerial opens a USB/serial port;
|
||||
// NewIcomNet (later) returns one configured with a network transport. Implements
|
||||
// Backend; all methods run on the Manager's CAT goroutine, so the port is
|
||||
// accessed single-threaded (no locking needed).
|
||||
type IcomSerial struct {
|
||||
portName string
|
||||
baud int
|
||||
rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
|
||||
digital string // mode to command for DATA (FT8/RTTY/…)
|
||||
|
||||
port serial.Port
|
||||
// open dials the underlying link (serial port or network stream). Set by the
|
||||
// constructor; called by Connect. Blocks until connected (the network opener
|
||||
// performs the full UDP handshake + login before returning).
|
||||
open func() (civTransport, error)
|
||||
|
||||
port civTransport
|
||||
model string
|
||||
|
||||
// I/O routing. A single reader goroutine owns port.Read and dispatches every
|
||||
@@ -82,7 +102,7 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
|
||||
if digitalDefault == "" {
|
||||
digitalDefault = "FT8"
|
||||
}
|
||||
return &IcomSerial{
|
||||
b := &IcomSerial{
|
||||
portName: portName,
|
||||
baud: baud,
|
||||
rigAddr: byte(civAddr),
|
||||
@@ -90,20 +110,32 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
|
||||
model: "Icom",
|
||||
scopeFixed: true, // rigs default to a fixed-span scope
|
||||
}
|
||||
// USB/serial transport opener.
|
||||
b.open = func() (civTransport, error) {
|
||||
if b.portName == "" {
|
||||
return nil, fmt.Errorf("no serial port configured")
|
||||
}
|
||||
p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *IcomSerial) Name() string { return "icom" }
|
||||
|
||||
func (b *IcomSerial) Connect() error {
|
||||
if b.portName == "" {
|
||||
return fmt.Errorf("no serial port configured")
|
||||
if b.open == nil {
|
||||
return fmt.Errorf("no transport configured")
|
||||
}
|
||||
port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
|
||||
port, err := b.open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
|
||||
return err
|
||||
}
|
||||
// Short read timeout so recv() polls in a tight loop without blocking the
|
||||
// CAT goroutine when the rig is silent.
|
||||
// CAT goroutine when the rig is silent. (No-op on the network transport.)
|
||||
_ = port.SetReadTimeout(60 * time.Millisecond)
|
||||
// Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and
|
||||
// "USB Keying (CW)" be mapped to the RTS or DTR line: if the port opens with
|
||||
@@ -312,7 +344,7 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (
|
||||
// frames and routes each: our own echoes are dropped, spectrum-scope frames
|
||||
// (0x27) go to specCh, everything else (control replies, acks, unsolicited
|
||||
// transceive updates) goes to respCh. It exits when the port is closed.
|
||||
func (b *IcomSerial) reader(port serial.Port, done chan struct{}) {
|
||||
func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
|
||||
defer close(done)
|
||||
tmp := make([]byte, 512)
|
||||
var rx []byte
|
||||
|
||||
Reference in New Issue
Block a user