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:
2026-08-22 14:07:00 +02:00
parent abfed4afa2
commit cb430a22ee
18 changed files with 819 additions and 45 deletions
+38 -1
View File
@@ -1,6 +1,10 @@
package main
import "testing"
import (
"os"
"strings"
"testing"
)
// The tracking mode decides how often a motorized antenna's elements run, so a
// value that fails to parse must not silently become the most aggressive
@@ -46,3 +50,36 @@ func TestBandForHzDrivesBandTracking(t *testing.T) {
t.Errorf("30 m and 20 m both read %q — band mode would never re-tune between them", a)
}
}
// A "Test connection" on a serial port must not open a port OpsLog already
// holds: a COM port has one owner, and the owner is the running antenna client.
// Building a second client made the two fight — one of them logging "Serial port
// busy" for the rest of the session.
func TestTestUltrabeamReusesTheLiveSerialClient(t *testing.T) {
src, err := os.ReadFile("app.go")
if err != nil {
t.Fatal(err)
}
body := funcBody(t, string(src), "func (a *App) TestUltrabeam(s UltrabeamSettings) error {")
if !strings.Contains(body, "a.liveMotorFor(s)") {
t.Error("TestUltrabeam builds a client without first checking whether the live one already owns that port")
}
// The check has to come BEFORE the second client is created, or it is no check.
if i, j := strings.Index(body, "a.liveMotorFor(s)"), strings.Index(body, "newMotorClient(s)"); i < 0 || j < 0 || i > j {
t.Error("the live-client check must precede newMotorClient")
}
}
// funcBody returns the source of a function, up to the closing brace in column 0.
func funcBody(t *testing.T, src, signature string) string {
t.Helper()
i := strings.Index(src, signature)
if i < 0 {
t.Fatalf("function not found: %s", signature)
}
rest := src[i:]
if j := strings.Index(rest, "\n}\n"); j >= 0 {
return rest[:j]
}
return rest
}