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:
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user