A K3NG controller answered PuTTY perfectly and told OpsLog 'no reply to C'. The reason is not the protocol: the client opened and CLOSED the serial port for every single command, and an Arduino-based controller resets when its port is opened — DTR pulses the reset pin. OpsLog was rebooting it several times a second, and every command it sent landed in a bootloader. The port is opened once and held, per COM port, at package level: the callers build a fresh Client per poll, so the port has to outlive them, and a serial port is a single-owner resource in any case. A newly opened port is given two seconds to boot before the first command, bytes left from a previous exchange are drained rather than read as this command's answer, and a failed exchange drops the port so the next starts from a clean open instead of repeating the same silence. Reply parsing was already right for both flavours and now has the real strings to prove it, that controller's '+0140' among them.
245 lines
7.7 KiB
Go
245 lines
7.7 KiB
Go
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
|
|
// protocol, over a raw TCP socket or a serial COM port.
|
|
//
|
|
// Any controller set to GS-232A works, which is most of them: the microHAM ARCO
|
|
// natively, and the ERC family (Easy Rotor Control — ERC Mini, ERC interface),
|
|
// which EMULATES GS-232A/B over its USB port. An ERC can also be configured for
|
|
// Hy-Gain DCU-1, a different command set entirely, so it must be set to GS-232.
|
|
//
|
|
// On the ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
|
|
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
|
|
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
|
|
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
|
|
// connections, and commands are single CR-terminated lines, so short
|
|
// per-call connections (same idiom as the other rotator backends) work fine.
|
|
//
|
|
// GS-232A subset used:
|
|
//
|
|
// Maaa<CR> move to azimuth aaa (000-450)
|
|
// S<CR> stop rotation
|
|
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
|
|
// flavour); both are parsed.
|
|
package gs232
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.bug.st/serial"
|
|
)
|
|
|
|
const (
|
|
dialTimeout = 3 * time.Second
|
|
ioTimeout = 2 * time.Second
|
|
)
|
|
|
|
// Client is a stateless per-call sender, mirroring the pst/rotgenius idiom.
|
|
// Exactly one of (Host, Port) or ComPort is used, per Transport.
|
|
type Client struct {
|
|
Host string
|
|
Port int
|
|
ComPort string // serial transport: "COM5" etc.
|
|
// Baud matters on some controllers. An ARCO's USB virtual COM ignores it, but
|
|
// an ERC (Easy Rotor Control) runs at whatever rate its own configuration
|
|
// sets — commonly 9600 or 19200 — and a mismatch reads as a dead rotator.
|
|
// Zero keeps the historical 9600.
|
|
Baud int
|
|
}
|
|
|
|
// New returns a TCP Client with sane defaults applied for empty fields. There
|
|
// is no standard port: the number is whatever the user typed into the ARCO's
|
|
// LAN CONTROL PROTOCOL setting — 4001 is only a placeholder.
|
|
func New(host string, port int) *Client {
|
|
if host == "" {
|
|
host = "127.0.0.1"
|
|
}
|
|
if port <= 0 || port > 65535 {
|
|
port = 4001
|
|
}
|
|
return &Client{Host: host, Port: port}
|
|
}
|
|
|
|
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
|
|
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
|
|
// open at 9600 which also suits a real RS-232 hookup left at its default.
|
|
func NewSerial(comPort string, baud int) *Client {
|
|
return &Client{ComPort: comPort, Baud: baud}
|
|
}
|
|
|
|
// bootSettle is how long a freshly opened serial port is left alone before the
|
|
// first command.
|
|
//
|
|
// An Arduino-based controller — K3NG's firmware, the ERC family — RESETS when
|
|
// the serial port is opened: the DTR line pulses its reset pin, and the
|
|
// bootloader then holds the processor for a second or more. A command sent into
|
|
// that window is simply lost, which is exactly how a controller that answers
|
|
// PuTTY perfectly reports "no reply" here.
|
|
const bootSettle = 2 * time.Second
|
|
|
|
// heldPort is an open serial port, kept between calls.
|
|
//
|
|
// The package holds it rather than the Client because the callers build a FRESH
|
|
// Client for every poll (one per heading request), and the port has to outlive
|
|
// them. Reopening per command is what made an Arduino controller reboot several
|
|
// times a second and never answer anything. A serial port is a single-owner
|
|
// resource in any case: two clients for COM5 would be two handles on one cable.
|
|
type heldPort struct {
|
|
p serial.Port
|
|
openedAt time.Time
|
|
}
|
|
|
|
var (
|
|
portsMu sync.Mutex
|
|
openPorts = map[string]*heldPort{}
|
|
)
|
|
|
|
// acquire returns the open port for com, opening it if needed.
|
|
func acquire(com string, baud int) (*heldPort, error) {
|
|
portsMu.Lock()
|
|
defer portsMu.Unlock()
|
|
if h, ok := openPorts[com]; ok && h.p != nil {
|
|
return h, nil
|
|
}
|
|
if baud <= 0 {
|
|
baud = 9600
|
|
}
|
|
sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err)
|
|
}
|
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
|
h := &heldPort{p: sp, openedAt: time.Now()}
|
|
openPorts[com] = h
|
|
return h, nil
|
|
}
|
|
|
|
// drop closes and forgets a port, so the next call opens a fresh one. Called
|
|
// when an exchange fails: a half-spoken conversation is worse than a new one.
|
|
func drop(com string) {
|
|
portsMu.Lock()
|
|
defer portsMu.Unlock()
|
|
if h, ok := openPorts[com]; ok {
|
|
if h.p != nil {
|
|
_ = h.p.Close()
|
|
}
|
|
delete(openPorts, com)
|
|
}
|
|
}
|
|
|
|
// roundTrip sends one CR-terminated command and (when wantReply) reads one
|
|
// CR/LF-terminated reply line. Serial keeps its port open between calls; TCP
|
|
// dials per call, which is what the ARCO's LAN side expects.
|
|
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
|
var conn io.ReadWriteCloser
|
|
if c.ComPort != "" {
|
|
h, err := acquire(c.ComPort, c.Baud)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Let a just-reset controller finish booting before speaking to it.
|
|
if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
|
|
time.Sleep(wait)
|
|
}
|
|
conn = h.p
|
|
// Whatever is already in the buffer belongs to the last exchange — the
|
|
// trailing LF of the previous reply, or a line the controller volunteered
|
|
// while nobody was reading. Read as the answer to THIS command it would
|
|
// be an answer to the wrong question.
|
|
drain(h.p)
|
|
} else {
|
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
|
if err != nil {
|
|
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
|
|
}
|
|
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
|
defer nc.Close()
|
|
conn = nc
|
|
}
|
|
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
|
|
if c.ComPort != "" {
|
|
drop(c.ComPort)
|
|
}
|
|
return "", fmt.Errorf("send %q: %w", cmd, err)
|
|
}
|
|
if !wantReply {
|
|
return "", nil
|
|
}
|
|
buf := make([]byte, 64)
|
|
var sb strings.Builder
|
|
deadline := time.Now().Add(ioTimeout)
|
|
for time.Now().Before(deadline) {
|
|
n, err := conn.Read(buf)
|
|
if n > 0 {
|
|
sb.Write(buf[:n])
|
|
if strings.ContainsAny(sb.String(), "\r\n") {
|
|
break
|
|
}
|
|
}
|
|
// A serial read that times out returns (0, nil) — keep polling until the
|
|
// overall deadline; a real error ends the read.
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
line := strings.TrimSpace(sb.String())
|
|
if line == "" {
|
|
// Silence may mean the port is fine and the controller is not, or that
|
|
// the handle is stale (a USB adapter unplugged and replugged). Let go of
|
|
// it so the next attempt starts from a clean open rather than repeating
|
|
// the same silence for ever.
|
|
if c.ComPort != "" {
|
|
drop(c.ComPort)
|
|
}
|
|
return "", fmt.Errorf("no reply to %q", cmd)
|
|
}
|
|
return line, nil
|
|
}
|
|
|
|
// drain empties whatever is waiting, without blocking for long.
|
|
func drain(sp serial.Port) {
|
|
buf := make([]byte, 128)
|
|
for i := 0; i < 4; i++ {
|
|
n, err := sp.Read(buf)
|
|
if n == 0 || err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
|
|
// (overlap rotators accept >360); we normalise to [0,360).
|
|
func (c *Client) GoTo(az int) error {
|
|
az = ((az % 360) + 360) % 360
|
|
_, err := c.roundTrip(fmt.Sprintf("M%03d", az), false)
|
|
return err
|
|
}
|
|
|
|
// Stop interrupts any in-progress rotation.
|
|
func (c *Client) Stop() error {
|
|
_, err := c.roundTrip("S", false)
|
|
return err
|
|
}
|
|
|
|
// azRe matches both reply flavours: "+0aaa" (GS-232A) and "AZ=aaa" (GS-232B).
|
|
var azRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})`)
|
|
|
|
// Heading queries the current azimuth. Returns the raw reply for diagnostics.
|
|
func (c *Client) Heading() (az int, raw string, err error) {
|
|
raw, err = c.roundTrip("C", true)
|
|
if err != nil {
|
|
return 0, raw, err
|
|
}
|
|
m := azRe.FindStringSubmatch(raw)
|
|
if m == nil {
|
|
return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
|
|
}
|
|
az, _ = strconv.Atoi(m[1])
|
|
return az % 360, raw, nil
|
|
}
|