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
+16
View File
@@ -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 + "`" }
+68
View File
@@ -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")
}
}