chore: release v0.20.8
This commit is contained in:
@@ -33,6 +33,7 @@ type Status struct {
|
||||
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
|
||||
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
||||
Temperature float64 `json:"temperature"`
|
||||
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
@@ -119,8 +120,15 @@ func (c *Client) SetOperate(on bool) error {
|
||||
if on {
|
||||
v = "1"
|
||||
}
|
||||
_, err := c.command("operate=" + v)
|
||||
return err
|
||||
if _, err := c.command("operate=" + v); err != nil {
|
||||
return err
|
||||
}
|
||||
// Optimistic: the status poll's "operate" field (when the firmware reports
|
||||
// one) confirms or corrects this.
|
||||
c.statusMu.Lock()
|
||||
c.status.Operate = on
|
||||
c.statusMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) pollLoop() {
|
||||
@@ -223,6 +231,8 @@ func (c *Client) parse(resp string) {
|
||||
switch kv[0] {
|
||||
case "state":
|
||||
c.status.State = kv[1]
|
||||
case "operate":
|
||||
c.status.Operate = kv[1] == "1"
|
||||
case "fanmode":
|
||||
dev := strings.ToUpper(kv[1])
|
||||
// Honour a recent optimistic change until the amp confirms it.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
|
||||
// protocol, over a raw TCP socket or a serial COM port. The target device is
|
||||
// the microHAM 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"
|
||||
"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. (ARCO USB virtual COM — any baud)
|
||||
}
|
||||
|
||||
// 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) *Client {
|
||||
return &Client{ComPort: comPort}
|
||||
}
|
||||
|
||||
// roundTrip opens a connection (TCP or serial per the client's config), sends
|
||||
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
|
||||
// reply line.
|
||||
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||
var conn io.ReadWriteCloser
|
||||
if c.ComPort != "" {
|
||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, 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 ARCO %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 + "\r")); 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])
|
||||
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 == "" {
|
||||
return "", fmt.Errorf("no reply to %q", cmd)
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user