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.
159 lines
4.6 KiB
Go
159 lines
4.6 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 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
|
|
}
|