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:
@@ -153,6 +153,16 @@ func pruneForeignTables(conn *sql.DB, role Role, label string) {
|
||||
if role != RoleLogbook || conn == nil {
|
||||
return
|
||||
}
|
||||
// Once per database, recorded like a migration.
|
||||
//
|
||||
// The check itself is a COUNT(*) per settings table — twelve round trips,
|
||||
// which is nothing locally and is paid on EVERY open of a remote MySQL
|
||||
// logbook, including every profile switch. There is nothing to find after the
|
||||
// first pass: the role filter means no settings table is ever created in a
|
||||
// logbook again.
|
||||
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, prunedMarker); err != nil {
|
||||
return // already recorded (primary key), or the table is not writable
|
||||
}
|
||||
dropped := 0
|
||||
for _, t := range settingsTables {
|
||||
var n int
|
||||
@@ -175,6 +185,12 @@ func pruneForeignTables(conn *sql.DB, role Role, label string) {
|
||||
}
|
||||
}
|
||||
|
||||
// prunedMarker records, in schema_migrations, that a logbook has had its unused
|
||||
// settings tables removed. Named like a migration and stored beside them because
|
||||
// that is exactly what it is: a one-off schema step, and the applied set is
|
||||
// already read in a single query at every open.
|
||||
const prunedMarker = "_opslog_pruned_settings_tables"
|
||||
|
||||
// quoteIdent quotes one of OUR OWN table names for either dialect. Backticks
|
||||
// work in MySQL and in SQLite alike, which is why the migrations use them.
|
||||
func quoteIdent(s string) string { return "`" + s + "`" }
|
||||
|
||||
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -213,3 +214,70 @@ func TestDropAndRecreateQSOTable(t *testing.T) {
|
||||
t.Fatalf("EnsureQSOTable disturbed an existing table (err=%v n=%d)", err, n)
|
||||
}
|
||||
}
|
||||
|
||||
// A brand-new logbook — the case of a fresh install, or the "New database"
|
||||
// button — is right from the first open: the contacts and the migration ledger,
|
||||
// nothing else. Pinned as an exact list so an accidentally unfiltered future
|
||||
// migration shows up here rather than in an operator's phpMyAdmin.
|
||||
func TestFreshLogbookHasOnlyContactTables(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "fresh.db")
|
||||
conn, err := OpenLogbook(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := conn.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, n)
|
||||
}
|
||||
rows.Close()
|
||||
conn.Close()
|
||||
sort.Strings(got)
|
||||
want := []string{"qso", "schema_migrations"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("fresh logbook holds %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The cleanup is a one-off, recorded like a migration: it must not be repeated
|
||||
// at every connection. Twelve COUNT(*) round trips on a remote MySQL is a cost
|
||||
// paid at every profile switch for something that can no longer be found.
|
||||
func TestPruneRunsOnlyOnce(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "once.db")
|
||||
conn, err := Open(path) // full legacy schema
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
conn, err = OpenLogbook(path) // first open: the cleanup happens
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM settings`).Scan(&n); err == nil {
|
||||
t.Fatal("first open did not clean up")
|
||||
}
|
||||
// Put one back by hand. A second pass would remove it again; a cleanup that
|
||||
// knows it is done leaves it alone.
|
||||
if _, err := conn.Exec("CREATE TABLE `settings` (`key` TEXT PRIMARY KEY, value TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
conn, err = OpenLogbook(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := conn.QueryRow(`SELECT COUNT(*) FROM settings`).Scan(&n); err != nil {
|
||||
t.Fatal("the cleanup ran a second time — it is not recorded as done")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -126,7 +127,10 @@ type Client struct {
|
||||
pendingDirSet bool
|
||||
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
// done is closed by the poll loop on its way out, so Stop can wait for the
|
||||
// serial port to be genuinely released — see Stop.
|
||||
done chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(tr Transport) *Client {
|
||||
@@ -138,10 +142,20 @@ func New(tr Transport) *Client {
|
||||
|
||||
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.
|
||||
//
|
||||
// Closing the port from another goroutine does not undo an open() the loop is
|
||||
// already inside: that open returns a fresh handle which the loop then stores,
|
||||
// and the stopped client goes on owning the serial port. The next client — a
|
||||
// settings save, a profile switch — cannot open it, and the operator sees
|
||||
// "Serial port busy" with no other program running.
|
||||
//
|
||||
// Bounded, so a device stuck in the driver cannot freeze a settings save.
|
||||
func (c *Client) Stop() {
|
||||
if !c.running {
|
||||
return
|
||||
@@ -154,6 +168,14 @@ 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("steppir: poll loop did not exit within 6s — %s may stay busy a moment longer", c.target())
|
||||
}
|
||||
}
|
||||
|
||||
// LastSetKHz returns the frequency last commanded, or 0.
|
||||
@@ -179,7 +201,14 @@ func (c *Client) open() (io.ReadWriteCloser, error) {
|
||||
if c.tr.COM == "" {
|
||||
return nil, fmt.Errorf("steppir: no serial port configured")
|
||||
}
|
||||
p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud})
|
||||
// 8N1 spelled out rather than left to the library defaults: a controller
|
||||
// that answers nothing must not have a line format that depends on them.
|
||||
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
|
||||
}
|
||||
@@ -210,7 +239,7 @@ func (c *Client) noteOpenFailure(err error) {
|
||||
c.connMu.Unlock()
|
||||
switch {
|
||||
case n <= openFailQuiet:
|
||||
log.Printf("steppir: cannot open %s: %v (attempt %d)", c.target(), err, n)
|
||||
log.Printf("steppir: cannot open %s: %v%s (attempt %d)", c.target(), err, portBusyHint(c.tr.Mode, c.tr.COM, err), n)
|
||||
case n == openFailQuiet+1:
|
||||
log.Printf("steppir: still cannot open %s — retrying every 2 s, further attempts will not be logged until it comes back", c.target())
|
||||
}
|
||||
@@ -225,6 +254,18 @@ func (c *Client) target() string {
|
||||
}
|
||||
|
||||
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)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
@@ -632,3 +673,16 @@ func (c *Client) Retract() error {
|
||||
}
|
||||
return c.writeCmd(buildSet(khz*1000, DirNormal, 'S'))
|
||||
}
|
||||
|
||||
// portBusyHint turns "Serial port busy" into something actionable — see the
|
||||
// identical note in internal/ultrabeam.
|
||||
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 SteppIR control window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect."
|
||||
}
|
||||
|
||||
@@ -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