chore: release v0.26.3
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package ultrabeam
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The two transports report a slow reply differently, and neither means the
|
||||
// link is gone: a remote TCP hop and a controller busy moving its motors both
|
||||
// look like silence. Getting this wrong tears the connection down on every poll.
|
||||
func TestTransientRead(t *testing.T) {
|
||||
if !transientRead(timeoutErr{}) {
|
||||
t.Errorf("a TCP read timeout must be transient")
|
||||
}
|
||||
// A serial port that stays silent returns (0, nil) forever; bufio gives up
|
||||
// after a hundred empty reads with ErrNoProgress.
|
||||
if !transientRead(io.ErrNoProgress) {
|
||||
t.Errorf("a silent serial port must be transient")
|
||||
}
|
||||
if transientRead(io.EOF) {
|
||||
t.Errorf("EOF is the link closing, not a slow reply")
|
||||
}
|
||||
if transientRead(errors.New("access denied")) {
|
||||
t.Errorf("an open failure is not a slow reply")
|
||||
}
|
||||
}
|
||||
|
||||
// open must refuse a configuration it cannot honour rather than dial nothing.
|
||||
func TestOpenRejectsEmptyConfig(t *testing.T) {
|
||||
if _, err := New(Transport{Mode: "serial"}).open(); err == nil {
|
||||
t.Errorf("serial with no COM port must fail")
|
||||
}
|
||||
if _, err := New(Transport{Mode: "tcp"}).open(); err == nil {
|
||||
t.Errorf("tcp with no host must fail")
|
||||
}
|
||||
}
|
||||
|
||||
// A missing baud is the controller's default, not zero — serial.Open would
|
||||
// reject 0 and the operator would see "invalid speed" for a field they never
|
||||
// knew existed.
|
||||
func TestDefaultBaud(t *testing.T) {
|
||||
if got := New(Transport{Mode: "serial", COM: "COM3"}).tr.Baud; got != 9600 {
|
||||
t.Errorf("default baud = %d, want 9600", got)
|
||||
}
|
||||
}
|
||||
|
||||
type timeoutErr struct{}
|
||||
|
||||
func (timeoutErr) Error() string { return "i/o timeout" }
|
||||
func (timeoutErr) Timeout() bool { return true }
|
||||
func (timeoutErr) Temporary() bool { return true }
|
||||
|
||||
var _ net.Error = timeoutErr{}
|
||||
var _ = time.Second
|
||||
@@ -1,19 +1,39 @@
|
||||
// Package ultrabeam drives an Ultrabeam remote-controlled antenna over TCP
|
||||
// (typically via an RS232↔Ethernet adapter). The wire protocol (STX/ETX
|
||||
// framing, DLE escaping, XOR checksum) and command codes are the manufacturer's.
|
||||
// Package ultrabeam drives an Ultrabeam remote-controlled antenna over SERIAL
|
||||
// or TCP. The wire protocol (STX/ETX framing, DLE escaping, XOR checksum) and
|
||||
// command codes are the manufacturer's, and identical on both: the Ethernet
|
||||
// route is an RS232↔Ethernet adapter passing the same bytes.
|
||||
//
|
||||
// Serial came second, which is the wrong way round for most stations: the
|
||||
// controller has an RS232 port and the PC is usually right next to it, so a
|
||||
// plain FTDI cable does the job and the adapter is only needed to put the
|
||||
// antenna at the other end of a link.
|
||||
package ultrabeam
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// Transport says how to reach the controller. Mirrors internal/steppir, which
|
||||
// has had both routes from the start — one shape for the two antennas rather
|
||||
// than two shapes to remember.
|
||||
type Transport struct {
|
||||
Mode string // "tcp" | "serial"
|
||||
Host string // tcp
|
||||
Port int // tcp
|
||||
COM string // serial device (COM3, /dev/ttyUSB0)
|
||||
Baud int // serial baud
|
||||
}
|
||||
|
||||
// Connection tuning. Remote operation (the antenna controller reached over the
|
||||
// internet, not the LAN) sees real latency and jitter, so the read timeout is
|
||||
// generous and a few transient timeouts are tolerated before the link is torn
|
||||
@@ -64,9 +84,8 @@ const (
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
tr Transport
|
||||
conn io.ReadWriteCloser
|
||||
connMu sync.Mutex
|
||||
reader *bufio.Reader
|
||||
lastStatus *Status
|
||||
@@ -129,15 +148,75 @@ type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
func New(tr Transport) *Client {
|
||||
if tr.Baud <= 0 {
|
||||
tr.Baud = 9600
|
||||
}
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
tr: tr,
|
||||
stopChan: make(chan struct{}),
|
||||
seqNum: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// open dials the transport. Callers hold connMu.
|
||||
func (c *Client) open() (io.ReadWriteCloser, error) {
|
||||
if c.tr.Mode == "serial" {
|
||||
if c.tr.COM == "" {
|
||||
return nil, fmt.Errorf("ultrabeam: no serial port configured")
|
||||
}
|
||||
p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A finite read timeout so a silent controller cannot wedge the poll
|
||||
// loop. Replaced per exchange by setReadTimeout below.
|
||||
_ = p.SetReadTimeout(ubReadTimeout)
|
||||
return p, nil
|
||||
}
|
||||
if c.tr.Host == "" {
|
||||
return nil, fmt.Errorf("ultrabeam: no host configured")
|
||||
}
|
||||
dialer := net.Dialer{Timeout: 5 * time.Second, KeepAlive: ubKeepAlive}
|
||||
return dialer.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
|
||||
}
|
||||
|
||||
// target names what the client is talking to, for the log.
|
||||
func (c *Client) target() string {
|
||||
if c.tr.Mode == "serial" {
|
||||
return fmt.Sprintf("%s @ %d baud", c.tr.COM, c.tr.Baud)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", c.tr.Host, c.tr.Port)
|
||||
}
|
||||
|
||||
// setReadTimeout bounds the next read, whichever transport is open.
|
||||
//
|
||||
// TCP takes a deadline (an instant) and serial a timeout (a duration) — the two
|
||||
// libraries disagree, and the caller should not have to care.
|
||||
func (c *Client) setReadTimeout(d time.Duration) {
|
||||
switch t := c.conn.(type) {
|
||||
case net.Conn:
|
||||
_ = t.SetReadDeadline(time.Now().Add(d))
|
||||
case serial.Port:
|
||||
_ = t.SetReadTimeout(d)
|
||||
}
|
||||
}
|
||||
|
||||
// transientRead reports a read that timed out rather than failed.
|
||||
//
|
||||
// The two transports say it differently. TCP returns a net.Error with
|
||||
// Timeout(); a serial port that stays silent returns (0, nil) on every read,
|
||||
// which bufio turns into io.ErrNoProgress after a hundred empty attempts.
|
||||
// Neither means the link is gone — over a remote link, or with a controller
|
||||
// busy moving its motors, a slow reply is ordinary.
|
||||
func transientRead(err error) bool {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, io.ErrNoProgress)
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
@@ -174,9 +253,8 @@ func (c *Client) pollLoop() {
|
||||
// Try to connect if not connected
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
log.Printf("Ultrabeam: Not connected, attempting connection...")
|
||||
dialer := net.Dialer{Timeout: 5 * time.Second, KeepAlive: ubKeepAlive}
|
||||
conn, err := dialer.Dial("tcp", net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port)))
|
||||
log.Printf("Ultrabeam: Not connected, attempting connection to %s...", c.target())
|
||||
conn, err := c.open()
|
||||
if err != nil {
|
||||
log.Printf("Ultrabeam: Connection failed: %v", err)
|
||||
c.connMu.Unlock()
|
||||
@@ -190,7 +268,7 @@ func (c *Client) pollLoop() {
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
pollFails = 0
|
||||
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
|
||||
log.Printf("Ultrabeam: Connected to %s", c.target())
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
@@ -200,8 +278,7 @@ func (c *Client) pollLoop() {
|
||||
// A single slow/lost reply over a remote link is normal — keep
|
||||
// the connection (and the last status) for a few tries before
|
||||
// tearing it down, so we don't churn reconnect/disconnect.
|
||||
var ne net.Error
|
||||
transient := errors.As(err, &ne) && ne.Timeout()
|
||||
transient := transientRead(err)
|
||||
pollFails++
|
||||
if transient && pollFails < ubMaxPollTimeout {
|
||||
log.Printf("Ultrabeam: status timeout (%d/%d), keeping link: %v", pollFails, ubMaxPollTimeout, err)
|
||||
@@ -430,7 +507,7 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
||||
}
|
||||
|
||||
// Read the reply with a timeout generous enough for a remote link.
|
||||
c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
|
||||
c.setReadTimeout(ubReadTimeout)
|
||||
buffer, err := c.readPacket()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -467,7 +544,8 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
||||
// behind by a command that timed out. A short read deadline lets it consume what
|
||||
// is there and stop quickly when the stream is clean. Caller holds connMu.
|
||||
func (c *Client) drainStale() {
|
||||
c.conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond))
|
||||
c.setReadTimeout(5 * time.Millisecond)
|
||||
defer c.setReadTimeout(ubReadTimeout) // leave the link on its normal budget
|
||||
buf := make([]byte, 256)
|
||||
for {
|
||||
n, err := c.reader.Read(buf)
|
||||
|
||||
Reference in New Issue
Block a user