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
+130 -10
View File
@@ -716,6 +716,7 @@ type App struct {
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
motorStartMu sync.Mutex // serialises startUltrabeam: two restarts at once left two poll loops on one COM port
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
@@ -14827,7 +14828,11 @@ func (a *App) restartAsync(name string, f func()) {
go func() {
t0 := time.Now()
f()
if d := time.Since(t0); d > 2*time.Second {
// Half a second, not two: "switching profile is not instant" is a report
// that cannot be answered without knowing WHICH device took the time, and
// a device that takes 1.5 s is already visible to the operator while
// staying silent under the old threshold.
if d := time.Since(t0); d > 500*time.Millisecond {
applog.Printf("%s: restart took %s (device slow to release/connect)", name, d.Round(time.Millisecond))
}
}()
@@ -15170,7 +15175,18 @@ func (a *App) SaveStationSettings(s StationSettings) error {
// "eu-005" would otherwise put a reference no award matcher recognises on
// every QSO of an activation.
p.MyIOTA = strings.ToUpper(strings.TrimSpace(s.MyIOTA))
return a.profiles.Save(a.ctx, &p)
if err := a.profiles.Save(a.ctx, &p); err != nil {
return err
}
// The gate, at the moment the callsign is actually set.
//
// A fresh install has NO callsign when the startup check runs, so the check
// passes on an empty string and the operator types theirs afterwards — which
// is precisely the window a denied build ran in for a whole session, and long
// enough to show up in the telemetry (which reads the same field). Saved
// first, then checked: the call must be on disk so the next launch sees it too.
enforceCallGate(p.Callsign)
return nil
}
// --- Profile bindings (multi-profile CRUD) ---
@@ -15203,6 +15219,11 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
if err := a.profiles.Save(a.ctx, &p); err != nil {
return profile.Profile{}, err
}
// Only for the profile in use: editing some other profile's callsign is not
// running under it, and "--profile <other>" has to stay a way back in.
if act, aerr := a.profiles.Active(a.ctx); aerr == nil && act.ID == p.ID {
enforceCallGate(p.Callsign)
}
a.refreshOperatorGrid()
return p, nil
}
@@ -15228,6 +15249,16 @@ func (a *App) ActivateProfile(id int64) error {
// EVERY setting is per-profile: re-scope the settings store first, so all
// the reloads below read this profile's values.
a.settings.SetProfile(id)
// The gate again, on the profile just activated.
//
// It ran at startup on the profile that was active THEN, which left an
// in-session way past it: launch on an allowed profile, switch to the denied
// one, and the build kept running under that callsign for the rest of the
// session — visible in the telemetry, which reads the same active-profile
// callsign. Startup is not the only moment the answer can change.
if p, err := a.profiles.Get(a.ctx, id); err == nil {
enforceCallGate(p.Callsign)
}
a.refreshOperatorGrid()
// The logbook follows the active profile: reconnect to this profile's DB
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
@@ -15264,6 +15295,13 @@ func (a *App) reloadAfterProfileSwitch() {
// arriving every few minutes still means something is asking for it, and
// nothing in the log said so.
applog.Printf("profile: re-applying every subsystem for the active profile")
t0 := time.Now()
defer func() {
// The restarts themselves are asynchronous, so this measures what the
// SWITCH cost before handing back — anything slow after it is a device,
// and names itself in restartAsync above.
applog.Printf("profile: re-apply issued in %s", time.Since(t0).Round(time.Millisecond))
}()
a.reloadLookupProviders()
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
@@ -16449,6 +16487,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
out.StepKHz = st
}
out.Baud = normMotorBaud(out.Type, out.Transport, out.Baud)
out.TrackMode = normMotorTrackMode(m[keyMotorTrackMode])
out.BandFreqs = decodeMotorBandFreqs(m[keyMotorBandFreqs])
out.FreqMinMHz, _ = strconv.Atoi(m[keyMotorFreqMin])
@@ -16521,7 +16560,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
keyMotorType: s.Type,
keyMotorTransport: s.Transport,
keyMotorCOM: strings.TrimSpace(s.COM),
keyMotorBaud: strconv.Itoa(s.Baud),
keyMotorBaud: strconv.Itoa(normMotorBaud(s.Type, s.Transport, s.Baud)),
keyMotorTXInhibit: boolStr(s.TXInhibit),
keyMotorFreqMin: strconv.Itoa(s.FreqMinMHz),
keyMotorFreqMax: strconv.Itoa(s.FreqMaxMHz),
@@ -16535,6 +16574,28 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
return nil
}
// ubSerialBaud is the ONLY line speed an Ultrabeam controller answers on over
// its USB cable. Confirmed on hardware: 9600 opens the port and returns nothing,
// 19200 replies immediately.
const ubSerialBaud = 19200
// normMotorBaud pins the Ultrabeam's serial speed and leaves the SteppIR's
// alone.
//
// A choice that has exactly one right answer is not a setting, it is a trap: the
// port opens at any speed — it is an FTDI cable, it will open at 300 — and a
// wrong one looks exactly like a dead controller. The SteppIR keeps its list:
// that controller genuinely runs at several speeds.
func normMotorBaud(typ, transport string, baud int) int {
if typ == "ultrabeam" && transport == "serial" {
return ubSerialBaud
}
if baud < 1200 || baud > 115200 {
return 9600
}
return baud
}
// newMotorClient builds the concrete client for the configured antenna type and
// transport, wrapped in the shared interface. Returns nil if nothing usable is
// configured (no host for TCP, no COM for serial).
@@ -16566,6 +16627,15 @@ func newMotorClient(s UltrabeamSettings) motorAntenna {
// antenna is enabled and configured. Safe to call repeatedly (on startup and
// after a settings save).
func (a *App) startUltrabeam() {
// ONE restart at a time.
//
// Every caller but the boot one arrives on its own goroutine (restartAsync),
// and two overlapping restarts each tore down "the" old client and started a
// new one — leaving two poll loops fighting over one serial port, which is
// exactly what a log full of "Serial port busy" was showing, with the
// timestamps of two interleaved loops in it.
a.motorStartMu.Lock()
defer a.motorStartMu.Unlock()
// Stop any running follow loop first.
if a.ubFollowStop != nil {
close(a.ubFollowStop)
@@ -16576,12 +16646,8 @@ func (a *App) startUltrabeam() {
close(a.motorInhibStop)
a.motorInhibStop = nil
}
if a.motorAnt != nil {
// Background teardown so saving Settings doesn't block on an in-progress
// connect (Stop waits for the dial timeout).
go a.motorAnt.Stop()
a.motorAnt = nil
}
old := a.motorAnt
a.motorAnt = nil
// Every way out of here used to be silent, which is how an antenna that had
// been working for three minutes went off the air with NOTHING in the log
// after the disconnection — the operator could not tell a deliberate stop
@@ -16609,8 +16675,19 @@ func (a *App) startUltrabeam() {
} else {
applog.Printf("antenna: %s starting on %s:%d", s.Type, s.Host, s.Port)
}
// Release the previous client BEFORE opening the new one.
//
// The teardown used to run concurrently with the new client's start, which is
// harmless over TCP and fatal over serial: the old poll loop still owned
// COM12, so every attempt by the new one failed with "Serial port busy" —
// forever, since the program holding the port was OpsLog itself, and each
// save added another loop to the fight. Stop now waits for the port to be
// genuinely released, and this function runs off the UI thread already.
if old != nil {
old.Stop()
}
a.motorAnt = c
_ = a.motorAnt.Start()
_ = c.Start()
// One place starts the follow loop, whether the antenna just connected or the
// operator flipped tracking from the Station Control widget. Two copies of
// this drifting apart is how a step change quietly stops taking effect.
@@ -17212,6 +17289,27 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error {
if s.Port <= 0 || s.Port > 65535 {
s.Port = 23
}
// TESTING A SERIAL PORT WE ALREADY OWN.
//
// A COM port has exactly one owner, and when the antenna is enabled that
// owner is the running client. Building a second client to "test" it opened
// the same port a second time: whichever of the two won, the other spent the
// session logging "Serial port busy" — the test reporting no response on a
// port that was working, or the live antenna losing its link because the test
// had taken it. Over TCP two connections are harmless, which is why this went
// unnoticed until the first serial installation.
//
// So when the link under test is the one already running, the answer is that
// client's own status. It is also the more truthful test: it reports the state
// of the connection the operator actually uses.
if s.Transport == "serial" {
if live, ok := a.liveMotorFor(s); ok {
if live.Status().Connected {
return nil
}
return fmt.Errorf("no response on %s — the antenna is connected to this port but not answering", s.COM)
}
}
c := newMotorClient(s)
if c == nil {
return fmt.Errorf("antenna not configured")
@@ -17235,6 +17333,28 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error {
return fmt.Errorf("no response from %s:%d", s.Host, s.Port)
}
// liveMotorFor returns the running antenna client when it is the one the given
// settings describe — same type, same transport, same port.
//
// Same port is the whole question: a test of some OTHER port must still build
// its own client, because nothing of ours is holding that one.
func (a *App) liveMotorFor(s UltrabeamSettings) (motorAntenna, bool) {
if a.motorAnt == nil {
return nil, false
}
cur, err := a.GetUltrabeamSettings()
if err != nil || !cur.Enabled {
return nil, false
}
if cur.Type != s.Type || cur.Transport != s.Transport {
return nil, false
}
if !strings.EqualFold(strings.TrimSpace(cur.COM), strings.TrimSpace(s.COM)) {
return nil, false
}
return a.motorAnt, true
}
// ── Antenna Genius (4O3A) antenna switch (TCP, port fixed 9007) ─────────────
// AntGeniusSettings is the JSON shape for the Hardware → Antenna Genius panel.