The RT-21 speaks the Hy-Gain DCU-1 command set in its default protocol setting, and internal/rotator/dcu1 has driven that since it landed — AP1nnn/AM1 to point, AI1 to read back, over a COM port or over TCP. Nothing was missing except a way to find it: "Green Heron" sat in a parenthesis after "Hy-Gain DCU-1", which an operator scanning the list for RT-21 does not see. So the dropdown leads with the controller people actually own, and the hint says the two things that decide whether it works: the RT-21's protocol must be set to DCU-1 / Rotor-EZ (on GS-232 it belongs to the GS-232 entry instead), and with the Ethernet option TCP reaches it directly rather than through a serial-over-IP bridge, which is what the hint used to imply was necessary. The package doc now also records what is NOT here: the native Green Heron protocol is a third command set, with 0.1° readback and a real stop, and writing it from memory is exactly the mistake this repo warns about for wire protocols.
166 lines
5.0 KiB
Go
166 lines
5.0 KiB
Go
// Package dcu1 drives rotator controllers that speak the Hy-Gain DCU-1 protocol,
|
|
// over a serial COM port (or a raw TCP socket, e.g. a serial-over-IP bridge).
|
|
//
|
|
// DCU-1 is used by the Hy-Gain DCU-1, the Green Heron RT-21, the Idiom Press
|
|
// Rotor-EZ, and the RotorCard DXA (hamsupply) for Yaesu DXA rotors. It is a
|
|
// DIFFERENT command set from Yaesu GS-232 (see internal/rotator/gs232):
|
|
// semicolon-terminated, azimuth only.
|
|
//
|
|
// The RT-21 selects its protocol on the controller, and only its DCU-1 /
|
|
// Rotor-EZ setting is this one — an RT-21 left on GS-232 belongs to the gs232
|
|
// package instead. With the Ethernet option it is a TCP endpoint in its own
|
|
// right, so the TCP transport below reaches it without a serial-over-IP
|
|
// bridge. Its NATIVE Green Heron protocol is a third command set, with 0.1°
|
|
// readback and a real stop, and is not implemented here.
|
|
//
|
|
// Commands (';' terminated — roundTrip appends the ';'):
|
|
//
|
|
// AP1nnn set the target bearing nnn (000-359)
|
|
// AM1 rotate to the target (some controllers move on AP1 alone; AM1 is
|
|
// harmless and makes the Rotor-EZ/DCU-1 variants that need it work)
|
|
// AI1 query the current bearing → the reply carries the 3-digit azimuth
|
|
//
|
|
// The base DCU-1 set has no dedicated stop; Stop re-commands the current bearing,
|
|
// which halts rotation.
|
|
package dcu1
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.bug.st/serial"
|
|
)
|
|
|
|
const (
|
|
dialTimeout = 3 * time.Second
|
|
ioTimeout = 2 * time.Second
|
|
)
|
|
|
|
// Client is a stateless per-call sender, mirroring the gs232/pst/rotgenius idiom.
|
|
// Exactly one of (Host, Port) or ComPort is used.
|
|
type Client struct {
|
|
Host string
|
|
Port int
|
|
ComPort string // serial transport: "COM5" etc.
|
|
// Baud varies by controller (a Hy-Gain DCU-1 is 4800; Green Heron / RotorCard
|
|
// can differ). Zero keeps 4800.
|
|
Baud int
|
|
}
|
|
|
|
// New returns a TCP Client (a serial-over-IP bridge in front of the controller).
|
|
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 over the controller's COM port.
|
|
func NewSerial(comPort string, baud int) *Client {
|
|
return &Client{ComPort: comPort, Baud: baud}
|
|
}
|
|
|
|
// roundTrip opens a connection, sends one ';'-terminated command and (when
|
|
// wantReply) reads until a 3-digit bearing is present. cmd must NOT carry the
|
|
// ';'.
|
|
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
|
var conn io.ReadWriteCloser
|
|
if c.ComPort != "" {
|
|
baud := c.Baud
|
|
if baud <= 0 {
|
|
baud = 4800
|
|
}
|
|
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
|
if err != nil {
|
|
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
|
}
|
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
|
conn = sp
|
|
} else {
|
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
|
if err != nil {
|
|
return "", fmt.Errorf("connect DCU-1 %s:%d: %w", c.Host, c.Port, err)
|
|
}
|
|
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
|
conn = nc
|
|
}
|
|
defer conn.Close()
|
|
if _, err := conn.Write([]byte(cmd + ";")); err != nil {
|
|
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])
|
|
// The DCU-1 reply carries the bearing as three digits (framing varies —
|
|
// ";nnn", "nnn;", "+0nnn"). Stop once we have them rather than on a
|
|
// specific terminator, so any flavour reads cleanly.
|
|
if azRe.MatchString(sb.String()) {
|
|
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 == "" {
|
|
return "", fmt.Errorf("no reply to %q", cmd)
|
|
}
|
|
return line, nil
|
|
}
|
|
|
|
// GoTo points the antenna at az (0-359): set the target, then rotate.
|
|
func (c *Client) GoTo(az int) error {
|
|
az = ((az % 360) + 360) % 360
|
|
if _, err := c.roundTrip(fmt.Sprintf("AP1%03d", az), false); err != nil {
|
|
return err
|
|
}
|
|
_, err := c.roundTrip("AM1", false)
|
|
return err
|
|
}
|
|
|
|
// Stop halts rotation. The base DCU-1 set has no stop command, so re-command the
|
|
// current bearing — the controller stops when the target equals where it is.
|
|
func (c *Client) Stop() error {
|
|
az, _, err := c.Heading()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = c.roundTrip(fmt.Sprintf("AP1%03d", az), false)
|
|
return err
|
|
}
|
|
|
|
// azRe matches the 3-digit bearing in any of the DCU-1 reply framings.
|
|
var azRe = regexp.MustCompile(`(\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("AI1", 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
|
|
}
|