feat: Ultrabeam over USB, Paper QSL from the awards grid, per-role schemas
Ultrabeam on a serial port never worked, and three faults were stacked so
each hid the next:
- Stop() did not wait for the poll loop, so a stopped client kept the COM
port. Every later client then failed with "Serial port busy" — the
program holding it being OpsLog itself.
- startUltrabeam tore the old client down CONCURRENTLY with starting the
new one, and "Test connection" built a second client on a port already
ours. Harmless over TCP, fatal on a port with one owner.
- A silent serial port returns (0, nil) and bufio retries that a hundred
times: a 4 s timeout became ~7 minutes of a frozen poll loop logging
nothing.
The controller then answered at once. Confirmed on hardware: the USB cable
presents TWO COM ports, only the second reaches the controller, and only at
19200 baud — so the speed is pinned in code (an FTDI cable opens at any
speed, and a wrong one is indistinguishable from a dead controller) and the
port field says which one to pick. The first exchange after each connect is
hex-dumped, which separates silence from a wrong baud from a misread frame.
Databases now carry only the tables their role needs. Every target used to
get the whole migration set, so a shared MySQL logbook grew settings and
station_profiles tables nothing ever wrote to — an operator inspecting the
server could not tell which copy was authoritative. Statements are filtered
by role, unknown tables are kept in both (fail-safe), and existing databases
are cleaned once, dropping only EMPTY tables. Settings → Database gains a
Compact button, since SQLite frees pages inside the file and never shrinks it.
Also:
- Awards: the callsigns behind a cell open the QSL Manager on Paper QSL,
searched, ready for the card dates.
- The record button no longer goes missing after an update: whether manual
recording is possible is a per-profile question that was asked once, at
startup, before the profile was known.
- Alert rules and filter presets confirm that they were saved.
- Spot clicks on the radio panadapter carry the POTA park into F3.
- The build gate is re-checked wherever the active callsign can change; it
ran at startup alone, and a fresh install has no callsign then.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package ultrabeam
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Stop must not return while the poll loop is still alive: on a serial link the
|
||||
// loop owns the port, and a client that outlives its Stop is what turned every
|
||||
// later connection into "Serial port busy".
|
||||
func TestStopWaitsForPollLoop(t *testing.T) {
|
||||
// A transport that cannot connect, so the loop spends its life in open() and
|
||||
// the reconnect path — the state the real fault happened in.
|
||||
c := New(Transport{Mode: "tcp", Host: "127.0.0.1", Port: 1})
|
||||
if err := c.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := c.done
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
c.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
t.Fatal("Stop returned while the poll loop was still running")
|
||||
}
|
||||
// Stopping twice must not panic on the closed channel.
|
||||
c.Stop()
|
||||
}
|
||||
|
||||
func TestPortBusyHint(t *testing.T) {
|
||||
// The Windows driver's own words, and the ones an operator has to act on.
|
||||
if h := portBusyHint("serial", "COM13", errors.New("Serial port busy")); !strings.Contains(h, "COM13") {
|
||||
t.Fatalf("no hint for a busy port: %q", h)
|
||||
}
|
||||
if h := portBusyHint("serial", "COM13", errors.New("Access is denied.")); h == "" {
|
||||
t.Fatal("no hint for access denied")
|
||||
}
|
||||
// A port that simply is not there is a different problem, and saying "another
|
||||
// program has it" would send the operator hunting for a program that is not
|
||||
// running.
|
||||
if h := portBusyHint("serial", "COM13", errors.New("The system cannot find the file specified.")); h != "" {
|
||||
t.Fatalf("hinted at a busy port for a missing one: %q", h)
|
||||
}
|
||||
if h := portBusyHint("tcp", "", errors.New("connection refused")); h != "" {
|
||||
t.Fatalf("serial hint on a TCP link: %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
// silentPort answers every read the way a serial port with nothing on the other
|
||||
// end does: no bytes, no error. bufio turns a run of those into ErrNoProgress.
|
||||
type silentPort struct{ writes int }
|
||||
|
||||
func (s *silentPort) Read(p []byte) (int, error) { return 0, nil }
|
||||
func (s *silentPort) Write(p []byte) (int, error) { s.writes++; return len(p), nil }
|
||||
func (s *silentPort) Close() error { return nil }
|
||||
|
||||
// A controller that never answers must fail ONCE, promptly, with a message that
|
||||
// says so — not wedge the poll loop for minutes inside bufio's retry budget.
|
||||
func TestSilentControllerFailsWithinTheReadTimeout(t *testing.T) {
|
||||
c := New(Transport{Mode: "serial", COM: "COM_TEST", Baud: 9600})
|
||||
c.conn = &silentPort{}
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
|
||||
start := time.Now()
|
||||
_, err := c.sendCommand(CMD_STATUS, nil)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("a silent controller reported success")
|
||||
}
|
||||
if elapsed > 3*ubReadTimeout {
|
||||
t.Fatalf("took %s to give up — the read deadline is not bounding the exchange", elapsed.Round(time.Millisecond))
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no reply") {
|
||||
t.Fatalf("unhelpful error for a silent port: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -91,9 +92,19 @@ type Client struct {
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
seqNum byte
|
||||
seqMu sync.Mutex
|
||||
// done is closed by pollLoop as it exits, so Stop can WAIT for it. Without
|
||||
// that wait the loop outlives the client that owns it, and on a serial link
|
||||
// the zombie keeps the port open: every later client then fails with "Serial
|
||||
// port busy" — forever, because the thing holding the port is us.
|
||||
done chan struct{}
|
||||
running bool
|
||||
seqNum byte
|
||||
seqMu sync.Mutex
|
||||
|
||||
// First-exchange diagnostics — see armDiag.
|
||||
diagMu sync.Mutex
|
||||
diag bool
|
||||
diagJunk []byte
|
||||
|
||||
// Optimistic pattern direction kept until the antenna's status poll reports
|
||||
// it (or it ages out) — the motors take a second or two, and a stale poll in
|
||||
@@ -165,7 +176,15 @@ func (c *Client) open() (io.ReadWriteCloser, error) {
|
||||
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})
|
||||
// 8N1 spelled out. The library's zero values happen to mean the same
|
||||
// thing today, but a controller that answers nothing is impossible to
|
||||
// diagnose with a line count that depends on a default.
|
||||
p, err := serial.Open(c.tr.COM, &serial.Mode{
|
||||
BaudRate: c.tr.Baud,
|
||||
DataBits: 8,
|
||||
Parity: serial.NoParity,
|
||||
StopBits: serial.OneStopBit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -181,6 +200,27 @@ func (c *Client) open() (io.ReadWriteCloser, error) {
|
||||
return dialer.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
|
||||
}
|
||||
|
||||
// diagNextExchange asks for the next command/reply to be dumped to the log.
|
||||
//
|
||||
// "It does not work" is unanswerable for a serial link, because the three
|
||||
// possible causes look identical from the outside: nothing arrives (wrong port,
|
||||
// dead cable, controller off), something arrives but is not our protocol (wrong
|
||||
// baud), or a valid frame arrives and we misread it. One hex dump of the first
|
||||
// exchange after each connect separates them, and costs two log lines per
|
||||
// connection.
|
||||
func (c *Client) armDiag() {
|
||||
c.diagMu.Lock()
|
||||
c.diag = true
|
||||
c.diagJunk = c.diagJunk[:0]
|
||||
c.diagMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) diagOn() bool {
|
||||
c.diagMu.Lock()
|
||||
defer c.diagMu.Unlock()
|
||||
return c.diag
|
||||
}
|
||||
|
||||
// target names what the client is talking to, for the log.
|
||||
func (c *Client) target() string {
|
||||
if c.tr.Mode == "serial" {
|
||||
@@ -219,10 +259,22 @@ func transientRead(err error) bool {
|
||||
|
||||
func (c *Client) Start() error {
|
||||
c.running = true
|
||||
c.done = make(chan struct{})
|
||||
go c.pollLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the link and WAITS for the poll loop to leave.
|
||||
//
|
||||
// The wait is the point. Closing the port from another goroutine does not undo
|
||||
// an open() the loop is already inside: that open returns a fresh handle, the
|
||||
// loop stores it, and the client that was told to stop goes on owning the serial
|
||||
// port. The next client — a settings save, a profile switch — then cannot open
|
||||
// it, and the operator sees "Serial port busy" with no other program running.
|
||||
//
|
||||
// Bounded, because a serial open can sit in the driver for a while and a stuck
|
||||
// device must not freeze a settings save. If the deadline passes, the loop is
|
||||
// still on its way out and the log says so.
|
||||
func (c *Client) Stop() {
|
||||
if !c.running {
|
||||
return
|
||||
@@ -236,9 +288,30 @@ func (c *Client) Stop() {
|
||||
c.conn = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
if c.done == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-c.done:
|
||||
case <-time.After(6 * time.Second):
|
||||
log.Printf("Ultrabeam: poll loop did not exit within 6s — %s may stay busy a moment longer", c.target())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) pollLoop() {
|
||||
// Signals Stop that the port is genuinely released.
|
||||
defer func() {
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
if c.done != nil {
|
||||
close(c.done)
|
||||
}
|
||||
}()
|
||||
ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -256,7 +329,7 @@ func (c *Client) pollLoop() {
|
||||
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)
|
||||
log.Printf("Ultrabeam: Connection failed: %v%s", err, portBusyHint(c.tr.Mode, c.tr.COM, err))
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected
|
||||
@@ -266,9 +339,25 @@ func (c *Client) pollLoop() {
|
||||
continue
|
||||
}
|
||||
c.conn = conn
|
||||
// Stopped while open() was running? Let go of the port at once.
|
||||
// Stop cannot interrupt an open already in flight, so without this
|
||||
// the fresh handle is stored by a client that has been told to die,
|
||||
// and it keeps the port — which is precisely the "Serial port busy"
|
||||
// the next client then reports, forever.
|
||||
select {
|
||||
case <-c.stopChan:
|
||||
conn.Close()
|
||||
c.conn = nil // already closed; keep the deferred cleanup from closing it twice
|
||||
c.connMu.Unlock()
|
||||
return
|
||||
default:
|
||||
}
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
pollFails = 0
|
||||
log.Printf("Ultrabeam: Connected to %s", c.target())
|
||||
// Dump the first exchange on this link. A connection that opens and
|
||||
// then says nothing is the whole of what a serial user can see.
|
||||
c.armDiag()
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
@@ -502,6 +591,10 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
||||
|
||||
seq := c.getNextSeq()
|
||||
packet := c.buildPacket(seq, cmd, data)
|
||||
diag := c.diagOn()
|
||||
if diag {
|
||||
log.Printf("Ultrabeam: first exchange on %s — sending %d bytes: % X", c.target(), len(packet), packet)
|
||||
}
|
||||
if _, err := c.conn.Write(packet); err != nil {
|
||||
return nil, fmt.Errorf("failed to write: %w", err)
|
||||
}
|
||||
@@ -509,6 +602,20 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
||||
// Read the reply with a timeout generous enough for a remote link.
|
||||
c.setReadTimeout(ubReadTimeout)
|
||||
buffer, err := c.readPacket()
|
||||
if diag {
|
||||
c.diagMu.Lock()
|
||||
junk := append([]byte(nil), c.diagJunk...)
|
||||
c.diag = false
|
||||
c.diagMu.Unlock()
|
||||
switch {
|
||||
case err != nil && len(junk) == 0:
|
||||
log.Printf("Ultrabeam: first exchange — NOTHING came back (%v). The controller is not answering on this port. Note that the USB cable presents TWO COM ports and only the SECOND one reaches the controller (at 19200 baud on the units seen so far); also check the cable and that the controller is on.", err)
|
||||
case err != nil:
|
||||
log.Printf("Ultrabeam: first exchange — %d bytes came back but no frame started (% X): %v. Bytes with no frame usually mean the wrong baud rate.", len(junk), junk, err)
|
||||
default:
|
||||
log.Printf("Ultrabeam: first exchange — reply %d bytes: % X (%d discarded before the frame: % X)", len(buffer), buffer, len(junk), junk)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -561,13 +668,53 @@ func (c *Client) drainStale() {
|
||||
// so a raw ETX only ever appears as the real terminator. Caller holds connMu and
|
||||
// has set a read deadline.
|
||||
func (c *Client) readPacket() ([]byte, error) {
|
||||
// A DEADLINE for the whole frame, not a timeout per read.
|
||||
//
|
||||
// A silent serial port does not error: it returns (0, nil) on every read once
|
||||
// its timeout expires, and bufio retries that a hundred times before giving up
|
||||
// with ErrNoProgress. With a 4-second port timeout that is over six minutes of
|
||||
// a poll loop frozen mid-exchange, logging nothing — which is exactly how a
|
||||
// controller that never answered looked like a program that had hung. Short
|
||||
// port timeouts, checked against a deadline here, turn it into one clear
|
||||
// "nothing came back" after four seconds.
|
||||
deadline := time.Now().Add(ubReadTimeout)
|
||||
c.setReadTimeout(250 * time.Millisecond)
|
||||
defer c.setReadTimeout(ubReadTimeout)
|
||||
var buffer []byte
|
||||
for {
|
||||
// A stop must not have to wait out the deadline. Without this, tearing the
|
||||
// client down mid-exchange took up to four seconds — long enough for the
|
||||
// replacement client to find the port still held, and for Stop to give up
|
||||
// waiting and say so.
|
||||
select {
|
||||
case <-c.stopChan:
|
||||
return nil, fmt.Errorf("stopped")
|
||||
default:
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
if len(buffer) == 0 {
|
||||
return nil, fmt.Errorf("no reply within %s", ubReadTimeout)
|
||||
}
|
||||
return nil, fmt.Errorf("incomplete frame within %s (% X)", ubReadTimeout, buffer)
|
||||
}
|
||||
b, err := c.reader.ReadByte()
|
||||
if err != nil {
|
||||
// A quiet port between bytes is normal — go.bug.st returns (0, nil) on
|
||||
// its timeout, which bufio eventually reports as ErrNoProgress. Only the
|
||||
// deadline above decides that the exchange has failed.
|
||||
if transientRead(err) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read: %w", err)
|
||||
}
|
||||
if len(buffer) == 0 && b != STX {
|
||||
// Kept, briefly, for the first exchange after a connect: what gets
|
||||
// discarded here IS the diagnosis when the link is misconfigured.
|
||||
c.diagMu.Lock()
|
||||
if c.diag && len(c.diagJunk) < 32 {
|
||||
c.diagJunk = append(c.diagJunk, b)
|
||||
}
|
||||
c.diagMu.Unlock()
|
||||
continue // resync to the start of a frame
|
||||
}
|
||||
buffer = append(buffer, b)
|
||||
@@ -725,3 +872,21 @@ func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
|
||||
_, err := c.sendCommand(CMD_MODIFY_ELEM, data)
|
||||
return err
|
||||
}
|
||||
|
||||
// portBusyHint turns "Serial port busy" into something actionable.
|
||||
//
|
||||
// A COM port has exactly one owner. The message the driver gives back says the
|
||||
// port is busy and stops there, which reads like a fault in OpsLog — and the
|
||||
// program actually holding it is usually the antenna manufacturer's own control
|
||||
// window, sitting open on the same desktop. Naming that is the difference
|
||||
// between a bug report and a five-second fix.
|
||||
func portBusyHint(mode, com string, err error) string {
|
||||
if mode != "serial" || err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
if !strings.Contains(msg, "busy") && !strings.Contains(msg, "access is denied") && !strings.Contains(msg, "denied") {
|
||||
return ""
|
||||
}
|
||||
return " — another program already has " + com + " open (the UltraBeam Controller window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user