chore: release v0.26.3
This commit is contained in:
+18
-7
@@ -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
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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