From 51e279887d3b9c7fd47e5bd98834c30cfb4aee28 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 25 Jul 2026 20:05:33 +0200 Subject: [PATCH] feat: Super Check Partial + N+1 helper; fix Flex binding to SmartSDR CAT; telemetry callsign wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app.go | 93 ++++++++ changelog.json | 4 + frontend/src/App.tsx | 61 ++++- frontend/src/components/ScpPanel.tsx | 63 +++++ frontend/src/components/SettingsModal.tsx | 38 +++ frontend/src/lib/i18n.tsx | 2 + frontend/wailsjs/go/main/App.d.ts | 9 + frontend/wailsjs/go/main/App.js | 16 ++ frontend/wailsjs/go/models.ts | 35 +++ internal/cat/flex.go | 9 +- internal/scp/scp.go | 270 ++++++++++++++++++++++ telemetry.go | 26 ++- 12 files changed, 619 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/ScpPanel.tsx create mode 100644 internal/scp/scp.go diff --git a/app.go b/app.go index 8913161..d58fbe3 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/changelog.json b/changelog.json index da1af08..c6bd4aa 100644 --- a/changelog.json +++ b/changelog.json @@ -3,9 +3,13 @@ "version": "0.21.2", "date": "2026-07-25", "en": [ + "Fixed OpsLog disrupting another Flex client: it could bind to 'SmartSDR CAT' (or DAX) instead of the real GUI client, which made SmartSDR CAT keep disconnecting/reconnecting from the radio while OpsLog was open. OpsLog now only binds to the actual SmartSDR/Maestro GUI client (e.g. a FlexRadio 'M' integrated screen).", + "New: Super Check Partial + N+1 callsign helper (like N1MM/DXLog). Enable it in Settings → General to download the community MASTER.SCP list; a docked two-column widget then shows, as you type a call, the known calls that contain it (Partial) and the calls one character away (N+1) — click one to fix a busted call.", "When a callsign isn't found on QRZ/HamQTH (or you don't use a lookup service), the name, QTH, locator and address are recovered from the last time you worked that station — and the precise locator from that QSO is used instead of the coarse cty.dat country centroid. A real QRZ/HamQTH hit still wins." ], "fr": [ + "Correction : OpsLog pouvait perturber un autre client Flex — il pouvait se « binder » sur « SmartSDR CAT » (ou DAX) au lieu du vrai client GUI, ce qui faisait décrocher/reconnecter SmartSDR CAT de la radio en boucle tant qu'OpsLog était ouvert. OpsLog ne se binde désormais qu'au vrai client GUI SmartSDR/Maestro (ex. l'écran intégré d'un FlexRadio « M »).", + "Nouveau : assistant indicatifs Super Check Partial + N+1 (façon N1MM/DXLog). Active-le dans Réglages → Général pour télécharger la liste communautaire MASTER.SCP ; un widget ancré en 2 colonnes affiche alors, pendant que tu tapes, les indicatifs connus qui contiennent ta saisie (Partiel) et ceux à une lettre près (N+1) — clique pour corriger un call busté.", "Quand un indicatif est introuvable sur QRZ/HamQTH (ou si tu n'utilises pas de service de lookup), le nom, le QTH, le locator et l'adresse sont récupérés du dernier QSO avec cette station — et c'est le locator précis de ce QSO qui est utilisé, pas le centroïde du pays de cty.dat. Un vrai résultat QRZ/HamQTH reste prioritaire." ] }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3348e09..6f3b11e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock, - Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap, + Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap, } from 'lucide-react'; import { @@ -25,6 +25,7 @@ import { GetUltrabeamStatus, SetUltrabeamDirection, GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate, GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate, + GetScpStatus, ScpLookup, OpenExternalURL, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ListClusterServers, ClusterSpotStatuses, SendClusterSpot, @@ -71,6 +72,7 @@ import { FlexPanel } from '@/components/FlexPanel'; import { IcomPanel } from '@/components/IcomPanel'; import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel'; +import { ScpPanel, type ScpResult } from '@/components/ScpPanel'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { AwardsPanel } from '@/components/AwardsPanel'; import { StatsPanel } from '@/components/StatsPanel'; @@ -473,6 +475,10 @@ export default function App() { const [agEnabled, setAgEnabled] = useState(false); const [tgStatus, setTgStatus] = useState({ connected: false }); const [tgEnabled, setTgEnabled] = useState(false); + // Super Check Partial / N+1 + const [scpEnabled, setScpEnabled] = useState(false); + const [scpCount, setScpCount] = useState(0); + const [scpResult, setScpResult] = useState({}); // Per-port optimistic selection that the status poll must not revert until the // device confirms it (or it expires) — otherwise a stale poll right after a // click reverts the UI and the click looks like it did nothing. @@ -1555,6 +1561,7 @@ export default function App() { const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0'); const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0'); const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0'); + const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0'); const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); // Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route @@ -1786,6 +1793,31 @@ export default function App() { TunerGeniusActivate(ch).catch((e) => setError(String(e?.message ?? e))); }; + // Super Check Partial: poll the enabled flag + list size so the widget shows up + // once the operator turns SCP on and the master list has downloaded. + useEffect(() => { + let alive = true; + const load = async () => { + try { const s: any = await GetScpStatus(); if (alive && s) { setScpEnabled(!!s.enabled); setScpCount(s.count || 0); } } catch {} + }; + load(); + const id = window.setInterval(load, 3000); + const off = EventsOn('scp:updated', load); + return () => { alive = false; window.clearInterval(id); off(); }; + }, []); + // Query SCP/N+1 as the callsign changes (debounced). Skipped when disabled or + // the widget is hidden, so we don't hit the backend for nothing. + useEffect(() => { + if (!scpEnabled || !showScp) { setScpResult({}); return; } + const c = callsign.trim(); + if (c.length < 2) { setScpResult({}); return; } + let alive = true; + const id = window.setTimeout(async () => { + try { const r: any = await ScpLookup(c); if (alive && r) setScpResult(r as ScpResult); } catch {} + }, 120); + return () => { alive = false; window.clearTimeout(id); }; + }, [callsign, scpEnabled, showScp]); + // RX band auto-follows the TX band (only differs for cross-band work). useEffect(() => { setBandRx(band); }, [band]); @@ -4330,6 +4362,20 @@ export default function App() { {showTuner && tgStatus.connected && } )} + {scpEnabled && ( + + )} {chatAvailable && ( + ))} + + + ); + + return ( +
+
+ 0 ? 'text-primary' : 'text-muted-foreground')} /> + {t('scp.title')} + + +
+ {count === 0 ? ( +
+ {t('scp.noList')} +
+ ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 62d017b..81938d2 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -39,6 +39,7 @@ import { GetPOTAToken, SavePOTAToken, TestLoTWUpload, ListTQSLStationLocations, DownloadLoTWUsers, GetLoTWUsersStatus, + GetScpStatus, SetScpEnabled, DownloadScp, DownloadULSCounties, ULSStatus, BackfillUSCounties, ComputeStationInfo, GetUIPref, SetUIPref, @@ -1253,6 +1254,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); } finally { setLotwUsersBusy(false); } }; + // Super Check Partial / N+1 callsign helper: enabled flag + master-list status. + const [scp, setScp] = useState<{ enabled: boolean; count: number; updated?: string }>({ enabled: false, count: 0 }); + const [scpBusy, setScpBusy] = useState(false); + useEffect(() => { GetScpStatus().then((s) => setScp(s as any)).catch(() => {}); }, []); + const toggleScp = async (on: boolean) => { + setScp((s) => ({ ...s, enabled: on })); + setScpBusy(true); + try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {} + finally { setScpBusy(false); } + }; + const downloadScp = async () => { + setScpBusy(true); + try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {} + finally { setScpBusy(false); } + }; // US Counties (offline FCC ULS) — download progress arrives via events. const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 }); const [ulsBusy, setUlsBusy] = useState(false); @@ -4761,6 +4777,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }} /> {t('gen.showBeam')} + + {/* Super Check Partial / N+1 — downloads the community MASTER.SCP list and + shows a two-column callsign helper (partial matches + one-edit calls). */} +
+ +

{t('scp.settingsHint')}

+ {scp.enabled && ( +
+ + + {scp.count > 0 + ? t('scp.loaded', { n: scp.count.toLocaleString(), date: scp.updated ? new Date(scp.updated).toLocaleDateString() : '?' }) + : t('scp.notLoaded')} + +
+ )} +