feat(rotator): add Hy-Gain DCU-1 backend (RotorCard DXA, Rotor-EZ, Green Heron)
New internal/rotator/dcu1 client speaking the DCU-1 command set (AP1nnn / AM1 to go-to, AI1 to read bearing), over a serial COM port (4800 baud default) or a raw TCP serial-over-IP bridge. Azimuth-only; Stop re-commands the current bearing since the base set has no stop opcode. Wired through app.go (normRotorType, default port 4001, GoTo/Heading/Stop/test switches, park returns the same "PstRotator only" note as ARCO) and exposed in the rotor settings as "Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)" with the ARCO's serial/TCP transport chooser and a bilingual hint. Protocol is implemented from the standard DCU-1 spec and still needs field confirmation against a real RotorCard DXA.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// 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 Idiom Press Rotor-EZ, Green Heron
|
||||
// controllers, 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.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user