chore: release v0.26.3

This commit is contained in:
2026-08-21 23:28:55 +02:00
parent 0cd243fe00
commit d48420485f
31 changed files with 1908 additions and 164 deletions
+18 -7
View File
@@ -290,12 +290,20 @@ func (m *Manager) SetSplit(on bool, txHz int64) error {
// the backend picks a default when it's empty. (Status-based colouring can be
// driven later by setting Color per spot.)
type SpotInfo struct {
FreqHz int64
Callsign string
Mode string
Color string
Comment string
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
FreqHz int64
Callsign string
Mode string
// Color is the TEXT colour of the callsign on the panadapter, BackgroundColor
// the plate behind it. Both are #AARRGGBB — the alpha channel first, which is
// SmartSDR's order and not the web's.
//
// Two colours rather than one because that is what makes a spot readable at a
// glance on a busy waterfall: the fill carries the status and the text stays
// legible against it. An empty background leaves the radio's own default.
Color string
BackgroundColor string
Comment string
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
}
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
@@ -475,7 +483,10 @@ type FlexController interface {
SetRXAntenna(string) error
SetTXAntenna(string) error
SetActiveSlice(int) error // focus slice idx so commands target it
SetTXSlice(int) error // make slice idx the transmitter (tx=1)
// ZoomPan sets the visible width (MHz) of the active slice's panadapter and
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error
SetTXSlice(int) error // make slice idx the transmitter (tx=1)
SetSplit(bool) error
SetNB(bool) error
SetNBLevel(int) error
+136 -7
View File
@@ -66,18 +66,36 @@ type Flex struct {
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
pendingSpotFreq map[int]int64 // seq → Hz, paired with pendingSpot
// panWindow is what each panadapter is currently showing, from its own status
// (centre and width, both MHz). Tracked because a zoom that only changes the
// WIDTH keeps the old centre, and the frequency the operator just clicked can
// end up outside the new window — see ZoomPan.
panWindow map[string]panView
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
// OnSpotClick is called (off the reader goroutine's hot path) when the user
// clicks one of our spots on the panadapter, with the spot's callsign and
// frequency. The host wires this to fill the entry form. Set before Connect.
OnSpotClick func(callsign string, freqHz int64)
// clicks one of our spots on the panadapter, with the spot's callsign, its
// frequency and the mode it was spotted in — everything the host knows about
// the spot, since the radio's own notification carries only an index. The host
// wires this to fill the entry form and to size the panadapter. Set before Connect.
OnSpotClick func(callsign string, freqHz int64, mode string)
}
// panView is one panadapter's visible window, in MHz.
type panView struct{ centre, width float64 }
type flexSlice struct {
freqHz int64
mode string // raw Flex mode (USB/LSB/CW/DIGU/…)
// pan is the panadapter this slice is displayed on ("0x40000000"), taken
// from the slice status. Kept PER SLICE rather than as one radio-wide id:
// with two slices on two bands there are two panadapters, and zooming the
// wrong one moves a display the operator is not looking at.
pan string
active bool
tx bool
inUse bool
@@ -187,7 +205,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{
host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
pinnedSlice: -1,
@@ -248,6 +266,7 @@ func (f *Flex) Connect() error {
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
f.send("sub profile all") // mic/global/tx profiles (for the mic-profile dropdown)
f.send("profile mic info") // request the current mic profile list + selection
f.send("sub pan all") // panadapter centre/bandwidth, so a zoom knows where the display already is
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
if f.spotsEnabled {
@@ -341,11 +360,12 @@ func (f *Flex) reader(conn net.Conn) {
f.mu.Lock()
call := f.spotCall[idx]
mode := f.spotMode[idx]
hz := f.spotFreq[idx]
handler := f.OnSpotClick
f.mu.Unlock()
if call != "" && handler != nil {
debugLog.Printf("Flex: spot %d triggered → %s (mode %s)", idx, call, mode)
go handler(call, 0)
go handler(call, hz, mode)
// SmartSDR tunes the spot's frequency on click but does NOT apply
// its mode=, so set the slice mode ourselves from what we spotted.
if mode != "" {
@@ -388,14 +408,17 @@ func (f *Flex) reader(conn net.Conn) {
f.mu.Lock()
call, pending := f.pendingSpot[seq]
spotMode := f.pendingSpotMode[seq]
spotFreq := f.pendingSpotFreq[seq]
if pending {
delete(f.pendingSpot, seq)
delete(f.pendingSpotMode, seq)
delete(f.pendingSpotFreq, seq)
}
if pending && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.spotCall[idx] = call
f.spotMode[idx] = spotMode
f.spotFreq[idx] = spotFreq
f.spotIdx[idx] = true
f.spotByCall[strings.ToUpper(call)] = idx
}
@@ -692,6 +715,32 @@ func (f *Flex) handleStatus(payload string) {
}
f.mu.Unlock()
}
// Panadapter — "display pan 0x40000000 center=14.075 bandwidth=0.2 …".
// Only the window matters here; everything else about the display is
// SmartSDR's business.
if len(fields) >= 3 && fields[0] == "display" && fields[1] == "pan" {
id := fields[2]
f.mu.Lock()
w := f.panWindow[id]
for _, kv := range fields[3:] {
key, val, ok := splitKV(kv)
if !ok {
continue
}
switch key {
case "center":
if v, err := strconv.ParseFloat(val, 64); err == nil && v > 0 {
w.centre = v
}
case "bandwidth":
if v, err := strconv.ParseFloat(val, 64); err == nil && v > 0 {
w.width = v
}
}
}
f.panWindow[id] = w
f.mu.Unlock()
}
// Meter definitions — "meter <num>.src=… <num>.nam=… <num>.unit=… …".
// The unit scales the UDP values, the name labels them; subscribe to each
// new id so the radio streams it.
@@ -813,6 +862,7 @@ func (f *Flex) handleStatus(payload string) {
delete(f.spotIdx, idx)
delete(f.spotCall, idx)
delete(f.spotMode, idx)
delete(f.spotFreq, idx)
} else {
f.spotIdx[idx] = true
}
@@ -847,6 +897,8 @@ func (f *Flex) handleStatus(payload string) {
}
case "mode":
s.mode = val
case "pan":
s.pan = val
case "active":
s.active = val == "1"
case "tx":
@@ -1241,6 +1293,74 @@ func (f *Flex) SetMode(mode string) error {
return nil
}
// ZoomPan sets the visible width of the ACTIVE slice's panadapter, and puts the
// frequency the operator clicked inside it.
//
// bandwidthMHz is the whole visible span, which is how SmartSDR expresses it —
// not a zoom factor.
//
// Re-centring is the subtle part. SmartSDR keeps the panadapter's CENTRE when
// only the width changes, so narrowing from 200 kHz to 25 for a CW spot — or
// jumping to a spot clicked in the band map — regularly left the very signal the
// operator asked for outside the new window: the radio had tuned to it, but the
// display was showing somewhere else. So the centre moves when it must:
//
// centre == true always re-centre (the operator asked for it)
// centre == false re-centre ONLY when freqMHz would fall outside the new
// window, leaving a settled display alone the rest of the time
//
// The test uses 40% of the width either side rather than the full half: a spot
// pinned to the last pixel of the panadapter is visible in the arithmetic and
// useless in practice.
//
// A no-op when the slice reports no panadapter, which is the case on a slice
// that exists but is not displayed. Silent, because that is not a fault.
// needRecentre decides whether the panadapter has to move for freqMHz to be
// comfortably visible in a window of bandwidthMHz. forced short-circuits it.
//
// An unknown current centre (no pan status seen yet) counts as "would fall
// outside": the display is more likely wrong than right, and re-centring on the
// spot the operator just clicked is never the surprising answer.
func needRecentre(cur panView, freqMHz, bandwidthMHz float64, forced bool) bool {
if freqMHz <= 0 {
return false // nothing to centre on
}
if forced {
return true
}
return cur.centre <= 0 || math.Abs(freqMHz-cur.centre) > bandwidthMHz*0.4
}
func (f *Flex) ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error {
f.mu.Lock()
pan := ""
// The operator's slice, by the same rule as everything else — see
// mainSliceLocked. Zooming any other panadapter would move a display the
// operator is not looking at.
if _, sl := f.mainSliceLocked(); sl != nil {
pan = sl.pan
}
cur := f.panWindow[pan]
connected := f.conn != nil && f.gotHandle
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if pan == "" || bandwidthMHz <= 0 {
return nil
}
recentre := needRecentre(cur, freqMHz, bandwidthMHz, centre)
// Centre first, then width: SmartSDR clamps a window that would run past the
// end of the spectrum, and moving to the right place before widening leaves
// nothing to clamp.
if recentre {
f.send(fmt.Sprintf("display pan s %s center=%.6f", pan, freqMHz))
}
f.send(fmt.Sprintf("display pan s %s bandwidth=%.6f", pan, bandwidthMHz))
debugLog.Printf("Flex: zoom pan %s → %.4f MHz (centre=%v on %.6f, was %.6f)", pan, bandwidthMHz, recentre, freqMHz, cur.centre)
return nil
}
// SendSpot renders a cluster spot on the panadapter via "spot add". Spots carry
// a lifetime so the radio expires them on its own (the API has no "spot clear").
// Per the SmartSDR API, spaces inside a field value are encoded as 0x7F.
@@ -1276,6 +1396,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
delete(f.spotByCall, upperCall)
delete(f.spotCall, old)
delete(f.spotMode, old)
delete(f.spotFreq, old)
delete(f.spotIdx, old)
}
f.mu.Unlock()
@@ -1291,6 +1412,12 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if m := flexEncode(adifModeToFlex(s.Mode, s.FreqHz)); m != "" {
cmd += " mode=" + m
}
// background_color is optional in the API and empty means "the radio's own".
// Sent only when set, so a spot with no configured background does not blank
// the default to nothing.
if bg := flexEncode(s.BackgroundColor); bg != "" {
cmd += " background_color=" + bg
}
if c := flexEncode(s.Comment); c != "" {
cmd += " comment=" + c
}
@@ -1302,6 +1429,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
f.mu.Lock()
f.pendingSpot[seq] = s.Callsign
f.pendingSpotMode[seq] = s.Mode
f.pendingSpotFreq[seq] = s.FreqHz
f.mu.Unlock()
}
return nil
@@ -1335,6 +1463,7 @@ func (f *Flex) ClearSpots() error {
f.spotIdx = map[int]bool{}
f.spotCall = map[int]string{}
f.spotMode = map[int]string{}
f.spotFreq = map[int]int64{}
f.spotByCall = map[string]int{}
connected := f.conn != nil
f.mu.Unlock()
+34
View File
@@ -0,0 +1,34 @@
package cat
import "testing"
func TestNeedRecentre(t *testing.T) {
pan := panView{centre: 14.100, width: 0.200}
// A spot the display is already showing comfortably: leave the window alone,
// which is the whole point of the option being off.
if needRecentre(pan, 14.110, 0.200, false) {
t.Fatal("moved a display that was already on the spot")
}
// Narrowing for a CW spot 40 kHz away: 25 kHz around the old centre would not
// reach it, so the centre must follow.
if !needRecentre(pan, 14.060, 0.025, false) {
t.Fatal("spot would have been left outside the new window")
}
// Near the very edge of the new window — visible in the arithmetic, useless
// in practice.
if !needRecentre(pan, 14.199, 0.200, false) {
t.Fatal("spot pinned to the edge counts as outside")
}
// The operator asked for it.
if !needRecentre(pan, 14.110, 0.200, true) {
t.Fatal("forced centring ignored")
}
// No pan status yet: assume the display is elsewhere.
if !needRecentre(panView{}, 14.110, 0.200, false) {
t.Fatal("unknown centre must re-centre")
}
// Nothing to centre on.
if needRecentre(pan, 0, 0.200, true) {
t.Fatal("centred on nothing")
}
}
+54
View File
@@ -0,0 +1,54 @@
package qso
import (
"testing"
"time"
)
// "When did I last add an entity" is judged against the whole log. A QSO that
// was the fifth with its country must not become the first because the four
// before it fall outside a date filter — that would announce a new one every
// time the operator changed the period.
func TestLastNewDXCCIgnoresThePeriodFilter(t *testing.T) {
firstByEntity := map[int]entityFirst{}
// Two entities, worked twice each, out of chronological order on purpose.
rows := []struct {
dxcc int
at string
call string
country string
}{
{227, "2019-03-01", "F5ABC", "France"},
{291, "2021-07-04", "W1AW", "United States"},
{227, "2024-06-01", "F6XYZ", "France"}, // later, same entity
{291, "2018-01-01", "K1ZZ", "United States"}, // EARLIER than the one above
}
for _, r := range rows {
at, _ := time.Parse("2006-01-02", r.at)
if cur, seen := firstByEntity[r.dxcc]; !seen || at.Before(cur.at) {
firstByEntity[r.dxcc] = entityFirst{at: at, call: r.call, country: r.country}
}
}
if got := firstByEntity[291].call; got != "K1ZZ" {
t.Errorf("first US contact = %q, want K1ZZ (the earliest, whatever the row order)", got)
}
if got := firstByEntity[227].call; got != "F5ABC" {
t.Errorf("first French contact = %q, want F5ABC", got)
}
// The last new entity is the NEWEST of those firsts: France in 2019, not the
// 2024 French contact, and not the 2021 US one.
var s Stats
for num, f := range firstByEntity {
if f.at.After(parseTimeLoose(s.LastNewDXCCDate)) {
s.LastNewDXCC, s.LastNewDXCCNum, s.LastNewDXCCCall = f.country, num, f.call
s.LastNewDXCCDate = f.at.Format(time.RFC3339)
}
}
if s.LastNewDXCC != "France" || s.LastNewDXCCNum != 227 || s.LastNewDXCCCall != "F5ABC" {
t.Errorf("last new entity = %q/%d/%q, want France/227/F5ABC", s.LastNewDXCC, s.LastNewDXCCNum, s.LastNewDXCCCall)
}
if s.LastNewDXCCDate[:10] != "2019-03-01" {
t.Errorf("date = %q, want 2019-03-01", s.LastNewDXCCDate)
}
}
+48
View File
@@ -137,6 +137,13 @@ const gapThreshold = 30 * time.Minute
const rateMaxHours = 7 * 24
// Stats is the whole dashboard payload.
// entityFirst is the first contact ever made with one DXCC entity.
type entityFirst struct {
at time.Time
call string
country string
}
type Stats struct {
// Headline figures.
Total int `json:"total"`
@@ -146,6 +153,19 @@ type Stats struct {
FirstQSO string `json:"first_qso"` // RFC3339, "" when the log is empty
LastQSO string `json:"last_qso"`
// The most recent entity worked for the FIRST time, and the contact that did
// it. "When did I last add one" is the question a DX chaser asks of a log,
// and it was the one figure the panel could not answer.
//
// Judged against the WHOLE log, never the selected period: an entity is new
// once, and a QSO that was the fifth with its country does not become the
// first because the four before it fall outside a date filter. Only the
// period filter decides whether that contact is SHOWN.
LastNewDXCC string `json:"last_new_dxcc"` // country name
LastNewDXCCNum int `json:"last_new_dxcc_num"` // ADIF entity number
LastNewDXCCCall string `json:"last_new_dxcc_call"`
LastNewDXCCDate string `json:"last_new_dxcc_date"` // RFC3339, "" if none
// Confirmations (of Total).
ConfirmedLoTW int `json:"confirmed_lotw"`
ConfirmedEQSL int `json:"confirmed_eqsl"`
@@ -331,6 +351,8 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
monthC = map[string]int{}
times []entry // every dated QSO (+ its operator), for the rate / gap maths
first, last time.Time
// The earliest contact with each entity, for "last new DXCC".
firstByEntity = map[int]entityFirst{}
)
for rows.Next() {
@@ -346,6 +368,22 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
return s, err
}
// The first contact with each entity, over the WHOLE log — recorded before
// every filter below, because being new is a property of the log and not
// of the period on screen.
if dxcc.Valid && dxcc.Int64 > 0 {
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
n := int(dxcc.Int64)
if cur, seen := firstByEntity[n]; !seen || t.Before(cur.at) {
firstByEntity[n] = entityFirst{
at: t,
call: strings.ToUpper(strings.TrimSpace(call.String)),
country: strings.TrimSpace(country.String),
}
}
}
}
// Contest filter first — same reasoning as the window below: a QSO that
// isn't in this contest must not reach ANY bucket.
if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID {
@@ -459,6 +497,16 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
return s, err
}
// The newest of the first-contacts: the last entity added to the log.
for num, f := range firstByEntity {
if f.at.After(parseTimeLoose(s.LastNewDXCCDate)) {
s.LastNewDXCC = f.country
s.LastNewDXCCNum = num
s.LastNewDXCCCall = f.call
s.LastNewDXCCDate = f.at.Format(time.RFC3339)
}
}
s.UniqueCalls = len(calls)
s.Entities = len(entities)
s.Continents = len(contC)
+57
View File
@@ -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
+95 -17
View File
@@ -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)