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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user