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