feat: Super Check Partial + N+1 helper; fix Flex binding to SmartSDR CAT; telemetry callsign wait

- SCP/N+1: new internal/scp downloads the community MASTER.SCP master list and a
  docked two-column widget shows, as you type a call, the known calls containing it
  (Partial) and the calls one edit away (N+1) — click to fix a busted call. Opt-in
  in Settings → General; top-bar toggle; queried debounced on callsign input.
- Flex: OpsLog's GUI-client detection was too loose and could bind to "SmartSDR CAT"
  (or DAX) — both carry "smartsdr" in the program name — instead of the real GUI
  client, making SmartSDR CAT drop and reconnect in a loop while OpsLog was open.
  Now it binds only to a real SmartSDR/Maestro GUI client (e.g. a FLEX-8600M's
  integrated screen) and excludes cat/dax; dropped the risky empty-program fallback.
- Telemetry: on a fresh install the callsign isn't set at launch, so the once-a-day
  heartbeat recorded the machine UUID. Now it waits (~10 min) for the operator to
  enter their callsign before sending, falling back to the UUID only if none appears.
This commit is contained in:
2026-07-25 20:05:33 +02:00
parent 7e08553e6e
commit 51e279887d
12 changed files with 619 additions and 7 deletions
+93
View File
@@ -53,6 +53,7 @@ import (
"hamlog/internal/rotator/gs232"
"hamlog/internal/rotator/pst"
"hamlog/internal/rotgenius"
"hamlog/internal/scp"
"hamlog/internal/settings"
"hamlog/internal/solar"
"hamlog/internal/spe"
@@ -234,6 +235,8 @@ const (
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
keyBackupEnabled = "backup.enabled"
keyBackupFolder = "backup.folder"
keyBackupRotation = "backup.rotation"
@@ -493,6 +496,7 @@ type App struct {
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
scp *scp.Manager // Super Check Partial / N+1 callsign master list
// NET Control: persistent net definitions/rosters (global JSON) + the live
// session (in-memory only — active stations currently in QSO).
@@ -985,6 +989,25 @@ func (a *App) startup(ctx context.Context) {
}
}
}()
// Super Check Partial / N+1: load the cached MASTER.SCP; when the feature is
// enabled, auto-(re)download it if missing or older than a week.
a.scp = scp.NewManager(dataDir)
go func() {
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
return
}
if a.scp.Count() == 0 || time.Since(a.scp.Updated()) > 7*24*time.Hour {
if n, err := a.scp.Download(context.Background()); err == nil {
applog.Printf("scp: auto-downloaded %d callsigns", n)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "scp:updated")
}
} else {
applog.Printf("scp: auto-download failed: %v", err)
}
}
}()
go func() {
_ = a.clublog.EnsureLoaded()
// Auto-refresh a missing/stale country file (ClubLog adds date-ranged
@@ -2311,6 +2334,76 @@ func (a *App) DownloadLoTWUsers() (int, error) {
return a.lotwUsers.Download(a.ctx)
}
// ── Super Check Partial / N+1 ────────────────────────────────────────────────
// ScpStatus is the loaded-list summary for Settings + the widget gate.
type ScpStatus struct {
Enabled bool `json:"enabled"`
Count int `json:"count"`
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
}
// GetScpStatus returns whether SCP is enabled and how many calls are loaded.
func (a *App) GetScpStatus() ScpStatus {
st := ScpStatus{}
if a.settings != nil {
v, _ := a.settings.Get(a.ctx, keyScpEnabled)
st.Enabled = v == "1"
}
if a.scp != nil {
st.Count = a.scp.Count()
if u := a.scp.Updated(); !u.IsZero() {
st.Updated = u.UTC().Format(time.RFC3339)
}
}
return st
}
// SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
// download when the list is missing or stale.
func (a *App) SetScpEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if err := a.settings.Set(a.ctx, keyScpEnabled, boolStr(on)); err != nil {
return err
}
if on && a.scp != nil && (a.scp.Count() == 0 || time.Since(a.scp.Updated()) > 7*24*time.Hour) {
go func() {
if n, err := a.scp.Download(context.Background()); err == nil {
applog.Printf("scp: downloaded %d callsigns", n)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "scp:updated")
}
} else {
applog.Printf("scp: download failed: %v", err)
}
}()
}
return nil
}
// DownloadScp fetches the MASTER.SCP master file and caches it. Returns the
// number of callsigns loaded.
func (a *App) DownloadScp() (int, error) {
if a.scp == nil {
return 0, fmt.Errorf("not initialized")
}
return a.scp.Download(a.ctx)
}
// ScpLookup returns the Super Check Partial (substring) and N+1 (one-edit)
// suggestions for a typed fragment. Empty when SCP is disabled.
func (a *App) ScpLookup(fragment string) scp.Result {
if a.scp == nil || a.settings == nil {
return scp.Result{}
}
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
return scp.Result{}
}
return a.scp.Lookup(fragment, 60)
}
// StationInfoComputed bundles the data we resolve live from the
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
// lat/lon. Used by the Settings UI to show the "what will be stamped on