chore: release v0.21.3

This commit is contained in:
2026-07-26 16:57:19 +02:00
parent 4fd70f6a9d
commit 91b5af1c7b
46 changed files with 2491 additions and 355 deletions
+14
View File
@@ -649,15 +649,29 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
const reconnectEvery = 5 * time.Second
connected := false
var lastAttempt time.Time
var lastConnErr string // last connect failure logged, so the retry loop says it once
tryConnect := func() {
if connected || time.Since(lastAttempt) < reconnectEvery {
return
}
lastAttempt = time.Now()
if err := b.Connect(); err != nil {
// Log it — the message used to live only in RigState.Error, i.e. in a
// tooltip. The status pill condenses everything to "OmniRig not found",
// so a user reporting that had no way to tell us WHY: the COM HRESULT,
// the serial error, the refused TCP connect, all invisible. Logged once
// per distinct message so the retry loop doesn't flood the file.
if msg := err.Error(); msg != lastConnErr {
lastConnErr = msg
debugLog.Printf("%s connect failed: %s", b.Name(), msg)
}
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
return
}
if lastConnErr != "" {
debugLog.Printf("%s connected (after: %s)", b.Name(), lastConnErr)
lastConnErr = ""
}
connected = true
}
tryConnect()
+44 -23
View File
@@ -39,10 +39,10 @@ const (
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
CmdPower = 0x18 // power on/off (sub 0x01 = on, 0x00 = off; on needs an FE wake preamble)
CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub)
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub)
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
@@ -91,26 +91,26 @@ const (
SubATU = 0x01 // antenna tuner (data 0x02 = start tune)
// CmdScope sub-commands.
SubScopeData = 0x00 // waveform data frame (divided across several frames)
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
SubScopeData = 0x00 // waveform data frame (divided across several frames)
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed)
SubScopeSpan = 0x15 // span in center mode (±span/2 as 5 LE-BCD)
SubScopeEdge = 0x16 // fixed-mode ACTIVE edge set 1-4 (vfo + set#)
SubScopeFixEdge = 0x1e // fixed-mode edge FREQUENCIES: [range][set#][low 5-BCD][high 5-BCD]
// CmdSwitch sub-commands.
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
SubSwNB = 0x22 // noise blanker on/off
SubSwNR = 0x40 // noise reduction on/off
SubSwANF = 0x41 // auto-notch on/off
SubSwComp = 0x44 // speech compressor on/off
SubSwMon = 0x45 // monitor on/off
SubSwVOX = 0x46 // VOX on/off
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
SubSwNB = 0x22 // noise blanker on/off
SubSwNR = 0x40 // noise reduction on/off
SubSwANF = 0x41 // auto-notch on/off
SubSwComp = 0x44 // speech compressor on/off
SubSwMon = 0x45 // monitor on/off
SubSwVOX = 0x46 // VOX on/off
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
SubSwMN = 0x48 // manual notch on/off
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
SubSwMN = 0x48 // manual notch on/off
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
)
// CW break-in modes (CmdSwitch 0x47).
@@ -307,22 +307,43 @@ func ModeToADIF(m byte, data bool) string {
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
// model. Unknown addresses fall back to a hex label.
//
// The name is not cosmetic: the UI derives model-dependent behaviour from it —
// notably the attenuator steps, which are 6/12/18 dB on the big rigs and a single
// 20 dB on the small ones. An address missing here therefore shows the WRONG
// attenuator buttons, and the rig NAKs them.
//
// Two entries here used to be wrong in a way that pointed at each other: 0x80 was
// labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the IC-7100), while
// the real IC-7800 (0x6A) and IC-7700 (0x74) were absent — so a 7800 came up as
// "Icom (0x6A)" with a 20 dB attenuator it does not have.
//
// Addresses cross-checked against the TR4W CI-V table and an independent
// published list; both agree on every value below.
func ModelName(addr byte) string {
switch addr {
case 0x6A:
return "IC-7800"
case 0x74:
return "IC-7700"
case 0x7A:
return "IC-7600"
case 0x7C:
return "IC-9100"
case 0x80:
return "IC-7410"
case 0x88:
return "IC-7100"
case 0x8E:
return "IC-7851" // shared with the IC-7850
case 0x94:
return "IC-7300"
case 0x98:
return "IC-7610"
case 0x7C:
return "IC-9100"
case 0xA2:
return "IC-9700"
case 0xA4:
return "IC-705"
case 0x88:
return "IC-7700"
case 0x80:
return "IC-7800"
}
return fmt.Sprintf("Icom (0x%02X)", addr)
}
+34 -2
View File
@@ -55,8 +55,8 @@ func TestScanSingleFreqResponse(t *testing.T) {
}
func TestScanSkipsEchoAndKeepsPartial(t *testing.T) {
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
buf := append(append([]byte{}, echo...), resp...)
buf = append(buf, 0xFE, 0xFE, 0x98) // a partial third frame (no FD yet)
@@ -130,3 +130,35 @@ func TestModelName(t *testing.T) {
t.Errorf("ModelName(0x12) = %q, want fallback", got)
}
}
// CI-V addresses are hardware constants: a wrong one means the console shows the
// wrong model, and with it the wrong attenuator steps (6/12/18 dB on the big
// rigs, a single 20 dB on the small ones) — buttons the rig then NAKs.
//
// Two entries here were previously wrong in a way that pointed at each other:
// 0x80 was labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the
// IC-7100), while the real IC-7800 (0x6A) and IC-7700 (0x74) were missing — so an
// IC-7800 came up as "Icom (0x6A)" with a 20 dB attenuator it does not have.
func TestModelNameAddresses(t *testing.T) {
for addr, want := range map[byte]string{
0x6A: "IC-7800",
0x74: "IC-7700",
0x7A: "IC-7600",
0x7C: "IC-9100",
0x80: "IC-7410",
0x88: "IC-7100",
0x8E: "IC-7851",
0x94: "IC-7300",
0x98: "IC-7610",
0xA2: "IC-9700",
0xA4: "IC-705",
} {
if got := ModelName(addr); got != want {
t.Errorf("ModelName(0x%02X) = %q, want %q", addr, got, want)
}
}
// An unknown address must stay identifiable rather than masquerade as a model.
if got := ModelName(0x42); got != "Icom (0x42)" {
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
}
}
+62 -18
View File
@@ -79,23 +79,23 @@ type IcomSerial struct {
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
dualScope bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeHigh int64 // spectrum right-edge frequency
scopeSeq int
scopeOn bool
dualScope bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeHigh int64 // spectrum right-edge frequency
scopeSeq int
scopeOn bool
scopeFixed bool // true = fixed-span mode (tracked optimistically)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance)
dspLoaded bool // readDSP has run since the rig became responsive (loads all
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance)
dspLoaded bool // readDSP has run since the rig became responsive (loads all
// the panel's set-once controls once the rig actually answers)
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time
@@ -341,7 +341,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
}
if b.splitOn && b.splitTXFreq > 0 && b.splitTXFreq != s.FreqHz {
s.Split = true
s.RxFreqHz = s.FreqHz // selected VFO = RX
s.RxFreqHz = s.FreqHz // selected VFO = RX
s.FreqHz = b.splitTXFreq // unselected VFO = TX
}
@@ -374,10 +374,54 @@ func (b *IcomSerial) ReadState() (RigState, error) {
if !b.dspLoaded {
b.readDSP()
b.dspLoaded = true
} else {
b.refreshFrontPanel()
}
return s, nil
}
// refreshFrontPanel re-reads the few controls an operator actually reaches for on
// the rig itself, so the console follows the radio instead of only driving it.
//
// readDSP loads everything but runs ONCE per connection (dspLoaded), which left
// the panel showing whatever was set at connect: switch AGC from FAST to MID on
// the front panel and OpsLog still said FAST, indefinitely. Commands worked, so
// the link was plainly fine — only this direction was missing.
//
// ONE read per poll cycle, in rotation. The full snapshot is ~30 CI-V round trips
// and refreshing it wholesale would hog the CAT thread for seconds at a time —
// including the operator's own Set* commands, which is far worse than a stale
// label. The rotation completes in 8 cycles; anything not covered here is still a
// connect-time or ↻ Refresh read.
func (b *IcomSerial) refreshFrontPanel() {
switch b.pollN % 8 {
case 1:
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
b.dspMu.Lock()
b.dsp.AGC = agcName(v)
b.dspMu.Unlock()
}
case 3:
if v, ok := b.readAtt(); ok {
b.dspMu.Lock()
b.dsp.Att = v
b.dspMu.Unlock()
}
case 5:
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
b.dspMu.Lock()
b.dsp.Preamp = int(v)
b.dspMu.Unlock()
}
case 7:
if _, f, ok := b.readModeFilter(); ok {
b.dspMu.Lock()
b.dsp.Filter = int(f)
b.dspMu.Unlock()
}
}
}
func (b *IcomSerial) SetFrequency(hz int64) error {
if hz <= 0 {
return fmt.Errorf("invalid frequency")
@@ -583,8 +627,8 @@ func (b *IcomSerial) drainResp() {
func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
regions := make(map[byte][]byte)
var total byte
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
for {
select {
case <-done:
+19 -4
View File
@@ -4,6 +4,7 @@ import (
"log"
"os"
"path/filepath"
"sync"
)
// LogSink, when set by the host app at startup, receives every CAT debug
@@ -15,13 +16,21 @@ var LogSink func(format string, args ...any)
// catLogger forwards Printf either to the host LogSink (preferred) or to a
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
// unchanged.
type catLogger struct{ fallback *log.Logger }
type catLogger struct {
once sync.Once
fallback *log.Logger
}
func (c *catLogger) Printf(format string, args ...any) {
if LogSink != nil {
LogSink("cat: "+format, args...)
return
}
// Only now, on a line that genuinely has nowhere else to go, is the fallback
// file opened. It used to be created at package init, so every installation
// grew an %APPDATA%\OpsLog\cat.log that nothing ever wrote to once the app
// wired LogSink — a decoy for anyone told to "check the CAT log".
c.once.Do(func() { c.fallback = openFallbackLog() })
if c.fallback != nil {
c.fallback.Printf(format, args...)
}
@@ -30,7 +39,7 @@ func (c *catLogger) Printf(format string, args ...any) {
// debugLog writes CAT debug events so users can diagnose mode/freq mismatches
// without rebuilding with a console. Once LogSink is set, lines flow into the
// main opslog.log.
var debugLog = &catLogger{fallback: openFallbackLog()}
var debugLog = &catLogger{}
func openFallbackLog() *log.Logger {
base, err := os.UserConfigDir()
@@ -49,9 +58,15 @@ func openFallbackLog() *log.Logger {
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
}
// DebugLogPath returns where the fallback cat.log lives, for surfacing in the
// UI / docs. When LogSink is wired, CAT lines are in the main app log instead.
// DebugLogPath returns where the fallback cat.log lives, or "" when CAT lines are
// going to the app log instead — which is the normal case, and the answer callers
// actually need. It previously returned the path unconditionally, so the one place
// that displayed it sent operators to an empty file in %APPDATA% while their CAT
// diagnostics sat in data\opslog.log.
func DebugLogPath() string {
if LogSink != nil {
return "" // lines go to the unified app log; there is no separate cat.log
}
base, err := os.UserConfigDir()
if err != nil {
return ""
+162 -47
View File
@@ -34,6 +34,11 @@ type OmniRig struct {
lastSig string // last logged Split/VFO signature — only log on change
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
// connLogged holds the connect failure already written to the log, so the
// 5-second reconnect loop reports a persistent problem once instead of
// forever. Cleared on success.
connLogged string
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
// async Freq property, which still reports the OLD band for a poll or two
@@ -53,8 +58,55 @@ func NewOmniRig(rigNum int) *OmniRig {
func (o *OmniRig) Name() string { return "omnirig" }
// elevationHint recognises the COM refusal that happens when OmniRig runs
// elevated (as administrator) and OpsLog does not — or the reverse. Windows keeps
// the two integrity levels apart, so the client cannot bind to the running
// server's object and COM falls back to launching a fresh one, which then needs
// elevation the client cannot grant.
//
// It is worth naming explicitly: the operator SEES OmniRig running, with its
// settings window open, so "OmniRig not found" reads as nonsense and sends them
// hunting for a driver or COM-port problem that does not exist. The fix is thirty
// seconds of work once you know what to look for.
func elevationHint(err error) string {
if err == nil {
return ""
}
msg := strings.ToLower(err.Error())
// Matched on the HRESULT text in whatever language Windows is running in, so
// the code is checked too: 0x800702E4 = ERROR_ELEVATION_REQUIRED.
if strings.Contains(msg, "elevation") || strings.Contains(msg, "élévation") ||
strings.Contains(msg, "0x800702e4") || strings.Contains(msg, "access denied") ||
strings.Contains(msg, "accès refusé") {
return "OmniRig and OpsLog are running at different privilege levels — Windows keeps them apart, " +
"so OpsLog cannot reach OmniRig even though it is running. Start BOTH the same way: either " +
"un-tick \"Run as administrator\" on the OmniRig shortcut (and its Compatibility tab), or run " +
"OpsLog as administrator too"
}
return ""
}
// logConnFailure writes a connect failure once per distinct cause. The reconnect
// loop retries every 5 seconds forever, and a station whose OmniRig was simply
// elevated had this filling its log at roughly 1500 lines an hour — which buries
// the very diagnostics someone would go looking for.
func (o *OmniRig) logConnFailure(msg string) {
if o.connLogged == msg {
return
}
o.connLogged = msg
debugLog.Printf("OmniRig Rig%d: %s", o.RigNum, msg)
}
func (o *OmniRig) Connect() error {
debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath())
// This used to announce DebugLogPath() on every attempt — the path of the
// FALLBACK cat.log, which nothing writes to once the app has wired LogSink and
// everything goes to data\opslog.log. It pointed operators at an empty file in
// %APPDATA% while the lines they wanted were somewhere else entirely. Dropped;
// and logged once per failure run rather than every 5-second retry.
if o.connLogged == "" {
debugLog.Printf("OmniRig.Connect Rig%d", o.RigNum)
}
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
// 0x1 = S_FALSE → COM already initialised on this thread, fine.
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
@@ -62,15 +114,43 @@ func (o *OmniRig) Connect() error {
}
}
unk, err := oleutil.CreateObject("Omnirig.OmnirigX")
if err != nil {
return fmt.Errorf("Omnirig.OmnirigX not available — is OmniRig installed and running?: %w", err)
}
omnirig, err := unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return fmt.Errorf("query interface: %w", err)
const progID = "Omnirig.OmnirigX"
var omnirig *ole.IDispatch
unk, err := oleutil.CreateObject(progID)
if err == nil {
omnirig, err = unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return fmt.Errorf("query interface: %w", err)
}
} else {
// A privilege mismatch is final — retrying, or trying the 32-bit server,
// cannot cross an integrity boundary. Say what to do instead of dressing it
// up as "not installed", which is what sends operators looking in the wrong
// place entirely.
if hint := elevationHint(err); hint != "" {
o.logConnFailure(hint)
return fmt.Errorf("%s (Windows said: %v)", hint, err)
}
// Otherwise it may be a partial registration; try activating the 32-bit
// server explicitly before giving up (see omnirig_activate32.go).
disp, err32 := createOmniRig32(progID)
if err32 != nil {
o.logConnFailure(fmt.Sprintf("CreateObject(%s) failed: %v; 32-bit activation also failed: %v", progID, err, err32))
// Name the version requirement. HB9RYZ's OmniRig v2.1 is a different
// product that its own author states is not compatible with v1, and it
// does not provide v1's IOmniRigX interface — so an operator who has
// only v2 installed sees OmniRig running and OpsLog failing, with
// nothing to connect the two facts.
return fmt.Errorf("OmniRig (v1) not reachable: %w — OpsLog needs OmniRig v1.19/v1.20 "+
"(VE3NEA/Alex), the interface every logger uses. HB9RYZ's OmniRig v2.1 is a separate, "+
"incompatible product and cannot serve OpsLog; the two may be installed side by side, "+
"but v1 must be present and running", err)
}
debugLog.Printf("OmniRig: reached via explicit 32-bit activation after CreateObject failed (%v)", err)
omnirig = disp
}
o.connLogged = "" // connected: re-arm the one-shot failure logging
rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
if err != nil {
@@ -80,10 +160,26 @@ func (o *OmniRig) Connect() error {
o.omnirig = omnirig
o.rig = rigVar.ToIDispatch()
// Log WHICH OmniRig answered. There are two incompatible products called
// OmniRig: v1.19/1.20 (Alex, VE3NEA), whose IOmniRigX interface every logger
// including OpsLog uses, and HB9RYZ's v2.1, which its own author states is "not
// compatible" with v1 and works only with programs written for it. They can
// coexist, and OmniRig's own window looks much the same either way — so an
// operator running only v2 sees OmniRig on screen, sees OpsLog fail, and has no
// way to know the two were never going to talk. These two version numbers
// settle it in one line of a bug report.
var iv, sv int64 = -1, -1
if v, err := oleutil.GetProperty(o.omnirig, "InterfaceVersion"); err == nil {
iv = v.Val
}
if v, err := oleutil.GetProperty(o.omnirig, "SoftwareVersion"); err == nil {
sv = v.Val
}
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
o.rigType = rt.ToString()
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
}
debugLog.Printf("OmniRig connected: Rig%d type=%q (OmniRig interface=%d software=%d)",
o.RigNum, o.rigType, iv, sv)
return nil
}
@@ -169,50 +265,69 @@ func (o *OmniRig) ReadState() (RigState, error) {
}())
}
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
// distinct and non-zero, AND they're in the same band. The same-band test
// kills the common false positive where VFO B just holds a leftover from
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
genuineSplit := splitRaw == pmSplitOn &&
freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB)
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(freqMain, freqA, freqB, s.Vfo, splitRaw)
return s, nil
}
if genuineSplit {
// ADIF: FreqHz = TX, RxFreqHz = RX. Determine which VFO is RX from the
// ACTIVE frequency (OmniRig's generic Freq — the VFO you're listening on):
// RX = the active VFO, TX = the other one. This is far more reliable than
// trusting OmniRig's Vfo AB/BA enum, which several rigs (e.g. Yaesu FTDX10)
// report inverted — the split then showed TX/RX swapped.
s.Split = true
// resolveOmniRigVFOs turns OmniRig's four readings into the ADIF pair
// (FreqHz = TX, RxFreqHz = RX) plus a split flag.
//
// Pure and separate from ReadState because it encodes rig-specific rules that
// contradict each other — what fixes a Yaesu can break an Icom — and the only way
// to change it safely is with every known rig's behaviour pinned in a test. COM
// cannot be exercised from a test; this can.
func resolveOmniRigVFOs(freqMain, freqA, freqB int64, vfo string, splitRaw int64) (txHz, rxHz int64, split bool) {
// PM_SPLITON is tested as a BIT, not by equality. OmniRig's Split is a flag
// word: an exact `== 0x8000` holds only for a rig whose ini sets that bit and
// nothing else, and silently reports "no split" for any rig reporting the bit
// alongside another. Requiring ON set and OFF clear keeps the two states apart
// (both flags are non-zero, so a bare `!= 0` would read OFF as split) while
// tolerating extra bits.
splitFlagged := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
// A genuine split also needs two distinct, non-zero VFOs in the SAME band. The
// band test kills the common false positive where VFO B merely holds a
// leftover from another band (a "28 MHz / 7 MHz split" is nonsensical), which
// on the FT-710 / TS-570 otherwise froze the TX freq on the wrong VFO.
if splitFlagged && freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB) {
// RX is the VFO being listened on — identified from the generic Freq rather
// than from the Vfo AB/BA enum, which several rigs (Yaesu FTDX10) report
// inverted, showing TX and RX swapped.
switch {
case freqMain != 0 && freqMain == freqA:
s.RxFreqHz, s.FreqHz = freqA, freqB // listening on A → TX on B
return freqB, freqA, true // listening on A → TX on B
case freqMain != 0 && freqMain == freqB:
s.RxFreqHz, s.FreqHz = freqB, freqA // listening on B → TX on A
case s.Vfo == "BA":
s.FreqHz, s.RxFreqHz = freqA, freqB // fall back to the Vfo enum
return freqA, freqB, true // listening on B → TX on A
case vfo == "BA":
return freqA, freqB, true // fall back to the Vfo enum
default:
s.FreqHz, s.RxFreqHz = freqB, freqA
}
} else {
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
// stock ini), so keying off FreqA gives the operator the VFO they expect.
s.Split = false
s.RxFreqHz = 0
switch {
case freqA != 0:
s.FreqHz = freqA
case freqMain != 0:
s.FreqHz = freqMain
default:
s.FreqHz = freqB
return freqB, freqA, true
}
}
return s, nil
// Simplex. The VFO the rig says is ACTIVE comes first: preferring freqA
// unconditionally (as this did) meant the displayed frequency never left VFO
// A — press SUB VFO on an FTDX101D and the radio receives on B while OpsLog
// went on showing A, taking the band and the logged frequency with it.
//
// Only a VFO OmniRig explicitly names is honoured, so a rig that does not
// report the enum keeps exactly the previous fallback order. That matters for
// the IC-7610, whose stock ini reports the generic Freq as VFO B; and for the
// PM_FREQA rigs (Yaesu, Kenwood) versus the Icoms (IC-9100) that populate only
// the generic Freq.
switch {
case (vfo == "B" || vfo == "BB") && freqB != 0:
return freqB, 0, false
case (vfo == "A" || vfo == "AA") && freqA != 0:
return freqA, 0, false
case freqA != 0:
return freqA, 0, false
case freqMain != 0:
return freqMain, 0, false
default:
return freqB, 0, false
}
}
func (o *OmniRig) SetFrequency(hz int64) error {
+83
View File
@@ -0,0 +1,83 @@
package cat
import (
"fmt"
"syscall"
"unsafe"
ole "github.com/go-ole/go-ole"
"golang.org/x/sys/windows/registry"
)
// Last-resort activation path for OmniRig, used only when the normal
// CreateObject fails.
//
// OmniRig is a 32-bit program and its installer splits its COM identity across
// registry views — on a working machine the ProgID sits in the 64-bit view while
// the CLSID and its LocalServer32 exist only under WOW6432Node. That LOOKS like
// it should break a 64-bit client, and it was my first theory for "OmniRig not
// found"; measuring it on a machine with exactly that layout disproved it. COM
// resolves an out-of-process server across views by itself, and the plain
// CreateObject succeeds.
//
// What this still covers is the case where the ProgID is not visible to us at all
// (a partial or 32-bit-only registration), where CreateObject has nothing to
// resolve. Here we read the CLSID from BOTH views ourselves and activate the
// 32-bit local server explicitly. CLSCTX_ACTIVATE_32_BIT_SERVER is the documented
// flag for that; go-ole hard-codes CLSCTX_SERVER and keeps CoCreateInstance
// unexported, hence the direct call.
//
// It costs nothing when the normal path works, and the reason it ran at all is
// logged — so if it ever rescues a real installation we will see it.
const clsctxActivate32BitServer = 0x40000
var (
modole32 = syscall.NewLazyDLL("ole32.dll")
procCoCreateInst32 = modole32.NewProc("CoCreateInstance")
)
// omnirigCLSIDFromRegistry reads OmniRig's CLSID from the ProgID key, looking in
// the 64-bit view first and then the 32-bit one. Returned as a GUID ready for
// CoCreateInstance.
func omnirigCLSIDFromRegistry(progID string) (*ole.GUID, error) {
for _, access := range []uint32{registry.QUERY_VALUE, registry.QUERY_VALUE | registry.WOW64_32KEY} {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Classes\`+progID+`\CLSID`, access)
if err != nil {
continue
}
s, _, err := k.GetStringValue("")
k.Close()
if err != nil || s == "" {
continue
}
if g := ole.NewGUID(s); g != nil {
return g, nil
}
}
return nil, fmt.Errorf("no CLSID registered for %s in either registry view", progID)
}
// createOmniRig32 activates OmniRig's 32-bit out-of-process server explicitly,
// bypassing the 64-bit registry lookup that CoCreateInstance would otherwise do.
func createOmniRig32(progID string) (*ole.IDispatch, error) {
clsid, err := omnirigCLSIDFromRegistry(progID)
if err != nil {
return nil, err
}
var unk *ole.IUnknown
hr, _, _ := procCoCreateInst32.Call(
uintptr(unsafe.Pointer(clsid)),
0, // no aggregation
uintptr(ole.CLSCTX_LOCAL_SERVER|clsctxActivate32BitServer),
uintptr(unsafe.Pointer(ole.IID_IUnknown)),
uintptr(unsafe.Pointer(&unk)))
if hr != 0 {
return nil, fmt.Errorf("CoCreateInstance(32-bit local server) failed: %w", ole.NewError(hr))
}
disp, err := unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return nil, fmt.Errorf("query IDispatch: %w", err)
}
return disp, nil
}
+73
View File
@@ -0,0 +1,73 @@
package cat
import (
"errors"
"testing"
)
// The COM refusal when OmniRig runs elevated and OpsLog does not (or the reverse)
// must be recognised and named. It reached a user as "OmniRig not found" while
// OmniRig sat visibly on screen with its settings window open — so the report was
// a driver hunt that could never succeed.
//
// Windows returns this text localised, so the match cannot rely on English alone;
// the HRESULT (0x800702E4 = ERROR_ELEVATION_REQUIRED) is accepted too.
func TestElevationHint(t *testing.T) {
recognised := []string{
"Lopération demandée nécessite une élévation.", // as logged, fr-FR
"The requested operation requires elevation.",
"Access denied",
"Accès refusé",
"CoCreateInstance failed: 0x800702E4",
}
for _, msg := range recognised {
if elevationHint(errors.New(msg)) == "" {
t.Errorf("not recognised as a privilege problem: %q", msg)
}
}
// Unrelated failures must NOT be blamed on elevation, or the advice sends the
// operator to the wrong place just as surely.
for _, msg := range []string{
"Classe non enregistrée",
"REGDB_E_CLASSNOTREG",
"no CLSID registered for Omnirig.OmnirigX in either registry view",
"open COM3: Access is den", // truncated word must not match "access denied"
} {
if h := elevationHint(errors.New(msg)); h != "" {
t.Errorf("%q wrongly reported as a privilege problem: %s", msg, h)
}
}
if elevationHint(nil) != "" {
t.Error("nil error must yield no hint")
}
}
// The reconnect loop runs every 5 s forever; a persistent failure must be logged
// once, not 1500 times an hour.
func TestLogConnFailureOnlyOncePerCause(t *testing.T) {
var lines []string
prev := LogSink
LogSink = func(format string, args ...any) { lines = append(lines, format) }
defer func() { LogSink = prev }()
o := &OmniRig{RigNum: 1}
for i := 0; i < 20; i++ {
o.logConnFailure("requires elevation")
}
if len(lines) != 1 {
t.Errorf("logged %d times, want 1", len(lines))
}
// A DIFFERENT cause must still be reported.
o.logConnFailure("class not registered")
if len(lines) != 2 {
t.Errorf("a new cause was not logged: %d lines", len(lines))
}
// And after reconnecting, the same cause may be reported again.
o.connLogged = ""
o.logConnFailure("requires elevation")
if len(lines) != 3 {
t.Errorf("after a reconnect the cause should log again: %d lines", len(lines))
}
}
+56
View File
@@ -0,0 +1,56 @@
package cat
import "testing"
// Every known rig's OmniRig behaviour, pinned. These rules genuinely contradict
// each other between models, so a change that fixes one rig must be shown not to
// break another — that is what this table is for.
func TestResolveOmniRigVFOs(t *testing.T) {
const (
a14200 = 14200000
b14205 = 14205000
b21000 = 21000000
)
cases := []struct {
name string
main, fa, fb int64
vfo string
split int64
wantTX, wantRX int64
wantSplit bool
}{
// The reported failure: FTDX101D, SUB VFO pressed. OmniRig names VFO B and
// reports it as the generic Freq; freqA still holds the main VFO. Preferring
// freqA meant the display never followed the operator to B.
{"FTDX101D on SUB VFO", b14205, a14200, b14205, "B", pmSplitOff, b14205, 0, false},
{"FTDX101D on MAIN VFO", a14200, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
// Non-regression: a rig that does not report the VFO enum keeps the old
// order — freqA, then the generic Freq, then freqB.
{"no VFO enum, freqA populated (Yaesu/Kenwood)", a14200, a14200, 0, "", pmSplitOff, a14200, 0, false},
{"no VFO enum, only generic Freq (IC-9100)", a14200, 0, 0, "", pmSplitOff, a14200, 0, false},
{"IC-7610: generic Freq reports B, enum says A", b14205, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
// Split: PM_SPLITON must be read as a BIT. An exact == 0x8000 reported "no
// split" for any rig that sets the flag alongside another bit.
{"split, ON flag alone", a14200, a14200, b14205, "AB", pmSplitOn, b14205, a14200, true},
{"split, ON flag with extra bits set", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, b14205, a14200, true},
{"listening on B → TX on A", b14205, a14200, b14205, "BA", pmSplitOn, a14200, b14205, true},
// Split must NOT be inferred when the rig says OFF, nor from a stale VFO B
// left on another band (the FT-710 / TS-570 false positive).
{"OFF flag, two distinct VFOs", a14200, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
{"ON flag but VFOs on different bands", a14200, a14200, b21000, "AB", pmSplitOn, a14200, 0, false},
{"ON flag but both VFOs identical", a14200, a14200, a14200, "AB", pmSplitOn, a14200, 0, false},
{"ON and OFF both set — ambiguous, treat as no split", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, a14200, 0, false},
}
for _, c := range cases {
tx, rx, split := resolveOmniRigVFOs(c.main, c.fa, c.fb, c.vfo, c.split)
if tx != c.wantTX || rx != c.wantRX || split != c.wantSplit {
t.Errorf("%s:\n got TX=%d RX=%d split=%v\n want TX=%d RX=%d split=%v",
c.name, tx, rx, split, c.wantTX, c.wantRX, c.wantSplit)
}
}
}
+74 -3
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"embed"
"fmt"
"os"
"sort"
"strings"
"time"
@@ -143,7 +144,6 @@ func SetDialect(d string) {
// same INSERT/UPDATE works on both backends.
func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
// Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) {
@@ -161,18 +161,77 @@ func Open(path string) (*sql.DB, error) {
return nil, fmt.Errorf("ping sqlite: %w", err)
}
Dialect = "sqlite"
if err := migrate(conn, nil); err != nil {
if err := migrate(conn, nil, path); err != nil {
_ = conn.Close()
return nil, err
}
return conn, nil
}
// LogSink receives this package's diagnostic lines. The app points it at
// applog.Printf at startup (same pattern as cat / audio / extsvc); left nil in
// tests and in the CLI tools under cmd/, where it is simply discarded.
var LogSink func(format string, args ...any)
// logMigration records a migration that has just been applied, and how long it
// took — the only trace an operator has that a data-rewriting migration ran.
func logMigration(name string, start time.Time) {
logf("db: migration %s applied in %s", name, time.Since(start).Round(time.Millisecond))
}
func logf(format string, args ...any) {
if LogSink != nil {
LogSink(format, args...)
}
}
// dataRewriteMarker flags a migration that rewrites existing user rows rather
// than only altering the schema. Put it on its own line in the .sql file, and a
// safety copy of the logbook is taken before it runs.
const dataRewriteMarker = "-- opslog:rewrites-data"
// backupBeforeRewrite takes a one-off copy of the logbook before a migration
// that rewrites user rows.
//
// It exists because the auto-updater gives the operator no say: the new build
// relaunches and migrates before the changelog explaining it is ever shown, so
// "back up first" is advice nobody can act on. The app takes the copy instead.
//
// Skipped when there is nothing to protect — a shared MySQL (no file to copy;
// that server is the admin's to back up), the settings database, and a
// freshly-created empty logbook all have no QSOs at stake.
func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
if dbPath == "" {
return
}
var n int
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n == 0 {
return
}
dest := dbPath + ".pre-" + strings.TrimSuffix(migration, ".sql") + ".bak"
if _, err := os.Stat(dest); err == nil {
return // a copy from an earlier attempt is already there — never overwrite it
}
// VACUUM INTO rather than copying the file: it writes a consistent,
// self-contained snapshot even with WAL pages still outstanding, which a
// plain file copy would silently miss. It cannot run inside a transaction,
// so it happens here, before the migration opens one. The destination is
// spliced (VACUUM INTO takes no bound parameter), with quotes doubled.
start := time.Now()
if _, err := conn.Exec(`VACUUM INTO '` + strings.ReplaceAll(dest, "'", "''") + `'`); err != nil {
// Not fatal: the migration itself is a single atomic transaction, so
// failing to take a belt-and-braces copy is no reason to block the update.
logf("db: could not back up before %s: %v — continuing (the migration is atomic)", migration, err)
return
}
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
}
// migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system
// (no external dependency). translate, when non-nil, rewrites each statement
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
func migrate(conn *sql.DB, translate func(string) string) error {
func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
// A non-nil translator means this is the MySQL connection (use the
// per-statement, FK-aware path); nil means a SQLite connection. This is
// determined by the caller's argument, NOT the global Dialect, so the
@@ -219,12 +278,22 @@ func migrate(conn *sql.DB, translate func(string) string) error {
if applied[name] {
continue // already applied
}
// Timed, and logged only once it has actually succeeded (below). Most
// migrations are instant DDL, but some rewrite user rows — 0024 upper-cases
// every callsign — and on a large logbook that is exactly what an operator
// wants confirmed afterwards: that it ran, once, and what it cost.
start := time.Now()
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
sqlText := translate(string(content))
// A migration that rewrites user rows gets a safety copy taken first.
if strings.Contains(string(content), dataRewriteMarker) {
backupBeforeRewrite(conn, dbPath, name)
}
// MySQL implicitly commits each DDL statement, so a wrapping transaction
// gives no atomicity — a mid-file failure would leave columns/tables
// behind, unrecorded, and every restart would re-run and choke on
@@ -238,6 +307,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
logMigration(name, start)
continue
}
@@ -257,6 +327,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
logMigration(name, start)
}
return nil
}
+161
View File
@@ -0,0 +1,161 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
const mig0024 = "0024_normalise_callsign.sql"
// openWithUnappliedRewrite builds a logbook that already holds QSOs and has not
// yet had the callsign-normalising migration applied — i.e. exactly what an
// existing installation looks like the moment the auto-updater relaunches it.
func openWithUnappliedRewrite(t *testing.T, rows [][2]string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "logbook.db")
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if _, err := conn.Exec(
`INSERT INTO qso (callsign, qso_date, band, mode) VALUES (?, ?, '20m', 'SSB')`, r[0], r[1]); err != nil {
t.Fatal(err)
}
}
// Rewind so the migration runs against real data on the next Open.
if _, err := conn.Exec(`DELETE FROM schema_migrations WHERE name = ?`, mig0024); err != nil {
t.Fatal(err)
}
if err := conn.Close(); err != nil {
t.Fatal(err)
}
return p
}
func callsigns(t *testing.T, path string) []string {
t.Helper()
conn, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
rows, err := conn.Query(`SELECT callsign FROM qso ORDER BY id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
t.Fatal(err)
}
out = append(out, c)
}
return out
}
// The auto-updater migrates before the operator ever sees the changelog, so the
// app has to take the safety copy itself. The copy must hold the data as it was
// BEFORE the rewrite — a copy of the already-migrated rows would be worthless.
func TestRewriteMigrationBacksUpOriginalData(t *testing.T) {
p := openWithUnappliedRewrite(t, [][2]string{
{"f5lit", "2026-07-01"},
{" Pa3Eyf ", "2026-07-02"},
{"F4BPO", "2026-07-03"},
})
var logged []string
LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
defer func() { LogSink = nil }()
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
conn.Close()
// The live logbook is normalised.
if got, want := callsigns(t, p), []string{"F5LIT", "PA3EYF", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("logbook callsigns = %v, want %v", got, want)
}
// The backup exists, next to the logbook, named after the migration.
backup := p + ".pre-0024_normalise_callsign.bak"
if _, err := os.Stat(backup); err != nil {
t.Fatalf("no safety copy at %s: %v\nlogged:\n%s", backup, err, strings.Join(logged, "\n"))
}
// …and it holds the ORIGINAL rows, untouched.
if got, want := callsigns(t, backup), []string{"f5lit", " Pa3Eyf ", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("backup callsigns = %v, want the pre-migration values %v", got, want)
}
var sawBackup bool
for _, l := range logged {
if strings.Contains(l, "backed up 3 QSO(s)") {
sawBackup = true
}
}
if !sawBackup {
t.Errorf("the backup was not logged; got:\n%s", strings.Join(logged, "\n"))
}
}
// No QSOs, nothing to protect: the settings database and a freshly created
// logbook must not litter the folder with pointless copies.
func TestRewriteMigrationSkipsBackupWhenNoQSOs(t *testing.T) {
p := openWithUnappliedRewrite(t, nil)
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
conn.Close()
if _, err := os.Stat(p + ".pre-0024_normalise_callsign.bak"); err == nil {
t.Error("an empty database should not be backed up")
}
}
// schema_migrations is what makes "runs once" a guarantee rather than a promise:
// a second launch must neither re-run the rewrite nor take a second copy.
func TestRewriteMigrationRunsOnce(t *testing.T) {
p := openWithUnappliedRewrite(t, [][2]string{{"f5lit", "2026-07-01"}})
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
conn.Close()
backup := p + ".pre-0024_normalise_callsign.bak"
first, err := os.Stat(backup)
if err != nil {
t.Fatal(err)
}
var logged []string
LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
defer func() { LogSink = nil }()
conn2, err := Open(p) // second launch
if err != nil {
t.Fatal(err)
}
conn2.Close()
for _, l := range logged {
if strings.Contains(l, mig0024) {
t.Errorf("migration ran again on the second launch: %s", l)
}
}
second, err := os.Stat(backup)
if err != nil {
t.Fatal(err)
}
if !first.ModTime().Equal(second.ModTime()) {
t.Error("the existing safety copy was overwritten on the second launch")
}
}
@@ -0,0 +1,22 @@
-- opslog:rewrites-data
-- Normalise stored callsigns so lookups can use idx_qso_callsign.
--
-- WorkedBefore matched rows with `upper(trim(callsign)) = ?`. Wrapping the
-- column in functions makes the predicate non-sargable: SQLite cannot use the
-- index and falls back to scanning. Measured on a 190 000-row logbook, the
-- COUNT went from 0.3 ms (SEARCH ... USING INDEX) to 20.8 ms (SCAN), and the
-- entries query — which needs every column, so not even a covering index helps
-- — scans the whole table. That runs on every keystroke of a callsign, and it
-- made the entry strip's history arrive too late to auto-fill the name and
-- locator from the previous QSO. Small logbooks never showed it.
--
-- Add, bulk insert and Update have always upper-cased and trimmed the callsign,
-- so this only rewrites rows left by older versions or foreign imports, and the
-- queries can then compare the column directly.
--
-- SQLite compares case-sensitively, so the WHERE finds exactly the rows that
-- need it. MySQL's default collation is case- and trailing-space-insensitive:
-- there the UPDATE is largely a no-op and equally unnecessary, because `=`
-- already matches those rows through the index.
UPDATE qso SET callsign = upper(trim(callsign))
WHERE callsign <> upper(trim(callsign));
+2 -2
View File
@@ -200,7 +200,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL)
err = migrate(conn, mysqlDDL, "")
}
if err != nil {
_ = conn.Close()
@@ -287,7 +287,7 @@ func applyMySQLBaseline(conn *sql.DB) error {
return fmt.Errorf("open baseline sqlite: %w", err)
}
defer mem.Close()
if err := migrate(mem, nil); err != nil {
if err := migrate(mem, nil, ""); err != nil {
return fmt.Errorf("build baseline schema: %w", err)
}
+28 -7
View File
@@ -36,8 +36,8 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
q.Set("login", user)
q.Set("password", cfg.Password)
q.Set("qso_query", "1")
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
if c := strings.TrimSpace(ownCall); c != "" {
q.Set("qso_owncall", c) // restrict to this station callsign
}
@@ -65,11 +65,32 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("lotw: http %d", resp.StatusCode)
}
// LoTW returns a plain-text error (not ADIF) on bad login.
// Not ADIF. Two very different failures land here, and telling them apart is
// the difference between a fixable message and a wall of markup.
if !strings.Contains(strings.ToUpper(text), "<EOH>") && !strings.Contains(strings.ToLower(text), "<eor>") {
msg := strings.TrimSpace(text)
trimmed := strings.TrimSpace(text)
// Keep the whole thing in the log — that is where a real diagnosis happens,
// and a 200-character excerpt of an HTML page tells nobody anything.
snippet := trimmed
if len(snippet) > 2000 {
snippet = snippet[:2000]
}
LogSink("lotw: expected ADIF, got %d bytes of non-ADIF; first 2000: %s", len(text), snippet)
// LoTW answers a REJECTED LOGIN with its ordinary web page rather than an
// error string, so an HTML body here means the credentials were not
// accepted — not that the download is broken.
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") || strings.Contains(lower, "logbook of the world</title>") {
return "", fmt.Errorf("LoTW returned its web page instead of a log, which is how it answers a login it did not accept. " +
"Check the username and password in Settings → External services: LoTW wants your lotw.arrl.org WEBSITE login, " +
"not your callsign certificate or your ARRL member number")
}
// Anything else: a plain-text complaint from LoTW, or a maintenance notice.
msg := trimmed
if len(msg) > 200 {
msg = msg[:200]
msg = msg[:200] + "…"
}
return "", fmt.Errorf("lotw: unexpected response: %s", msg)
}
@@ -220,8 +241,8 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
// AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows
// refuses to launch it. Actionable message instead of the raw error.
return UploadResult{}, fmt.Errorf(
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". " +
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). " +
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". "+
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). "+
"Or run OpsLog itself as administrator.", tqsl)
} else {
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
+41
View File
@@ -19,3 +19,44 @@ func TestHomeCall(t *testing.T) {
}
}
}
// Mobile and maritime-mobile suffixes: /M is the case that surfaced in the field
// (F4LYI/M resolved only to cty.dat while F4LYI resolved on QRZ), so pin the
// whole suffix family — the home call is what the provider record is filed under.
func TestHomeCallSuffixes(t *testing.T) {
for call, want := range map[string]string{
"F4LYI/M": "F4LYI",
"F4LYI/MM": "F4LYI",
"F4LYI/AM": "F4LYI",
"F4LYI/QRP": "F4LYI",
"F4LYI/A": "F4LYI",
"F4LYI/B": "F4LYI",
} {
if got := homeCall(call); got != want {
t.Errorf("homeCall(%q) = %q, want %q", call, got, want)
}
}
}
// Operational suffixes must resolve to the bare call WITHOUT a provider query on
// the slashed form; entity- and area-changing forms must not be stripped.
func TestStripOpSuffix(t *testing.T) {
strip := map[string]string{
"F4LYI/M": "F4LYI", "F4LYI/MM": "F4LYI", "F4LYI/AM": "F4LYI",
"F4LYI/P": "F4LYI", "F4LYI/QRP": "F4LYI", "F4LYI/p": "F4LYI",
"F4BPO/M/P": "F4BPO",
}
for call, want := range strip {
got, ok := stripOpSuffix(call)
if !ok || got != want {
t.Errorf("stripOpSuffix(%q) = %q,%v — want %q,true", call, got, ok, want)
}
}
// These change the entity or the call area: they are real, separately
// registered forms and must be queried exactly as entered.
for _, call := range []string{"JW/OR1A", "VP8/F4BPO", "F4BPO/8", "DL/F4NIE", "OH2BH", "F4BPO/W6"} {
if got, ok := stripOpSuffix(call); ok {
t.Errorf("stripOpSuffix(%q) stripped to %q — it changes entity/area and must be kept", call, got)
}
}
}
+102 -29
View File
@@ -109,22 +109,30 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
}
var lastErr error
for _, p := range providers {
r, err := p.Lookup(ctx, call)
if err == nil {
r.Callsign = call
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
fillFromDXCC(&r, dxcc)
normalizeNames(&r)
_ = m.cache.Put(ctx, r)
return r, nil
// An operational suffix (/M, /P, …) is never registered as such: skip the
// futile query on the slashed form and let the home-call pass below do the one
// request that can actually answer.
_, opOnly := stripOpSuffix(call)
if opOnly {
LogSink("lookup: %s carries only an operational suffix — querying the bare call", call)
} else {
for _, p := range providers {
r, err := p.Lookup(ctx, call)
if err == nil {
r.Callsign = call
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
fillFromDXCC(&r, dxcc)
normalizeNames(&r)
_ = m.cache.Put(ctx, r)
return r, nil
}
if errors.Is(err, ErrNotFound) {
lastErr = err
continue
}
lastErr = fmt.Errorf("%s: %w", p.Name(), err)
}
if errors.Is(err, ErrNotFound) {
lastErr = err
continue
}
lastErr = fmt.Errorf("%s: %w", p.Name(), err)
}
// Portable / slashed call not found under its full form: the operator's
@@ -135,6 +143,10 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
for _, p := range providers {
r, err := p.Lookup(ctx, home)
if err != nil {
// Logged, because this is where a portable lookup silently dies: the
// error is swallowed to try the next provider, and the operator only
// ever sees the cty.dat fallback with no clue why.
LogSink("lookup: %s → home call %s failed on %s: %v", call, home, p.Name(), err)
continue
}
r.Callsign = call
@@ -177,6 +189,46 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
return Result{}, lastErr
}
// LogSink receives this package's diagnostic lines (which call was actually
// queried, and why a lookup fell back). Set to applog.Printf by the app.
var LogSink = func(string, ...any) {}
// opSuffixes are OPERATIONAL suffixes: they describe how the operator is working
// — mobile, maritime, aeronautical, portable, low power — not who they are or
// where. No provider has a record filed under "F4LYI/M", so querying that form
// is a round trip that cannot succeed.
//
// It was worse than merely wasted: it spent the lookup's time budget, so the
// home-call retry that followed ran out of time and the entry fell back to
// cty.dat for every /M and /P call, even though the operator was on QRZ. These
// go straight to the bare callsign instead.
//
// Everything else after a slash is NOT this: JW/, VP8/ change the DXCC entity,
// and /8 or /W6 change the call area. Those forms can be registered in their own
// right and must be looked up exactly as entered.
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
// stripOpSuffix returns the bare callsign when call carries nothing but
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
// part isn't callsign-shaped.
func stripOpSuffix(call string) (string, bool) {
if !strings.ContainsRune(call, '/') {
return call, false
}
parts := strings.Split(call, "/")
base := strings.TrimSpace(parts[0])
if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
return call, false // "JW/OR1A": the first part is a prefix, not the callsign
}
for _, p := range parts[1:] {
if !opSuffixes[strings.ToUpper(strings.TrimSpace(p))] {
return call, false
}
}
return base, true
}
// homeCall extracts the operator's home callsign from a slashed/portable call
// so its provider record (name/QTH/QSL) can be fetched when the full form isn't
// registered: JW/OR1A → OR1A, DL/F4NIE → F4NIE, F4BPO/P → F4BPO, VP8/F4BPO →
@@ -266,12 +318,30 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
return false
}
filled := false
if country != "" { r.Country = country; filled = true }
if cont != "" { r.Continent = cont; filled = true }
if cqz != 0 { r.CQZ = cqz; filled = true }
if ituz != 0 { r.ITUZ = ituz; filled = true }
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true }
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true }
if country != "" {
r.Country = country
filled = true
}
if cont != "" {
r.Continent = cont
filled = true
}
if cqz != 0 {
r.CQZ = cqz
filled = true
}
if ituz != 0 {
r.ITUZ = ituz
filled = true
}
if lat != 0 && r.Lat == 0 {
r.Lat = lat
filled = true
}
if lon != 0 && r.Lon == 0 {
r.Lon = lon
filled = true
}
// cty.dat is authoritative for the *operating* entity: it strips benign
// suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE).
// Use its DXCC# when known — this overrides the provider's home-call
@@ -279,7 +349,10 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
// France's 227). Only when cty.dat can't map a slashed call do we drop
// the provider's number rather than mislabel.
if dxccNum != 0 {
if r.DXCC != dxccNum { r.DXCC = dxccNum; filled = true }
if r.DXCC != dxccNum {
r.DXCC = dxccNum
filled = true
}
} else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
r.DXCC = 0
filled = true
@@ -317,13 +390,13 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
source, fetched_at
FROM callsign_cache WHERE callsign = ?`, callsign)
var (
r Result
name, qth, addr, state, cnty sql.NullString
country, grid, cont, email, qslVia, image sql.NullString
src string
dxcc, cqz, ituz sql.NullInt64
lat, lon sql.NullFloat64
fetched string
r Result
name, qth, addr, state, cnty sql.NullString
country, grid, cont, email, qslVia, image sql.NullString
src string
dxcc, cqz, ituz sql.NullInt64
lat, lon sql.NullFloat64
fetched string
)
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
&country, &grid, &lat, &lon,
+114
View File
@@ -0,0 +1,114 @@
package qso
import (
"context"
"database/sql"
"encoding/json"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// openBulkTestDB builds the minimum of the qso table this needs. It does not go
// through db.Open (that would pull the whole migration set and an import cycle);
// the columns BulkSetExtra touches are extras_json and updated_at.
func openBulkTestDB(t *testing.T) *sql.DB {
t.Helper()
conn, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
if _, err := conn.Exec(`CREATE TABLE qso (
id INTEGER PRIMARY KEY AUTOINCREMENT,
callsign TEXT NOT NULL,
extras_json TEXT,
updated_at TEXT
)`); err != nil {
t.Fatal(err)
}
return conn
}
func extras(t *testing.T, conn *sql.DB, id int64) map[string]any {
t.Helper()
var raw sql.NullString
if err := conn.QueryRow(`SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&raw); err != nil {
t.Fatal(err)
}
if !raw.Valid || raw.String == "" {
return map[string]any{}
}
var m map[string]any
if err := json.Unmarshal([]byte(raw.String), &m); err != nil {
t.Fatalf("extras_json is not valid JSON (%q): %v", raw.String, err)
}
return m
}
// OWNER_CALLSIGN has no promoted column, so bulk-editing it means merging a key
// into extras_json. The thing that must not happen is collateral damage: the
// other ADIF extras on the same QSO have to survive.
func TestBulkSetExtraPreservesOtherExtras(t *testing.T) {
conn := openBulkTestDB(t)
r := &Repo{db: conn}
ctx := context.Background()
// Two QSOs with existing extras, one with none at all (NULL column).
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F5LIT', '{"SILENT_KEY":"Y","ANT_PATH":"S"}')`)
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('PA3EYF', '{"ANT_PATH":"L"}')`)
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F4BPO', NULL)`)
n, err := r.BulkSetExtra(ctx, []int64{1, 2, 3}, "OWNER_CALLSIGN", "TM2Q")
if err != nil {
t.Fatal(err)
}
if n != 3 {
t.Errorf("updated %d rows, want 3", n)
}
e1 := extras(t, conn, 1)
if e1["OWNER_CALLSIGN"] != "TM2Q" {
t.Errorf("row 1 OWNER_CALLSIGN = %v, want TM2Q", e1["OWNER_CALLSIGN"])
}
if e1["SILENT_KEY"] != "Y" || e1["ANT_PATH"] != "S" {
t.Errorf("row 1 lost its other extras: %v", e1)
}
// A NULL extras_json must become a valid object, not stay null or hold "null".
if e3 := extras(t, conn, 3); e3["OWNER_CALLSIGN"] != "TM2Q" {
t.Errorf("row 3 (extras_json was NULL) = %v, want OWNER_CALLSIGN=TM2Q", e3)
}
}
// Clearing the field must REMOVE the key: a blank extra would otherwise be
// carried into every ADIF export from then on.
func TestBulkSetExtraEmptyRemovesKey(t *testing.T) {
conn := openBulkTestDB(t)
r := &Repo{db: conn}
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F5LIT', '{"OWNER_CALLSIGN":"TM2Q","ANT_PATH":"S"}')`)
if _, err := r.BulkSetExtra(context.Background(), []int64{1}, "OWNER_CALLSIGN", ""); err != nil {
t.Fatal(err)
}
e := extras(t, conn, 1)
if _, present := e["OWNER_CALLSIGN"]; present {
t.Errorf("OWNER_CALLSIGN should be gone, got %v", e)
}
if e["ANT_PATH"] != "S" {
t.Errorf("clearing one extra removed another: %v", e)
}
}
// The frontend field id must resolve to the ADIF key, and nothing else must slip
// through — this map is the whitelist guarding a spliced JSON path.
func TestBulkExtraKeyWhitelist(t *testing.T) {
if got := BulkExtraKey("owner_callsign"); got != "OWNER_CALLSIGN" {
t.Errorf(`BulkExtraKey("owner_callsign") = %q, want "OWNER_CALLSIGN"`, got)
}
for _, bad := range []string{"", "callsign", "notes", "OWNER_CALLSIGN", "owner_callsign'"} {
if got := BulkExtraKey(bad); got != "" {
t.Errorf("BulkExtraKey(%q) = %q, want empty", bad, got)
}
}
}
+139 -89
View File
@@ -62,29 +62,29 @@ type QSO struct {
RSTRcvd string `json:"rst_rcvd,omitempty"`
// --- Contacted station ---
Name string `json:"name,omitempty"`
QTH string `json:"qth,omitempty"`
Address string `json:"address,omitempty"`
Email string `json:"email,omitempty"`
Web string `json:"web,omitempty"`
Grid string `json:"grid,omitempty"`
GridExt string `json:"gridsquare_ext,omitempty"`
VUCCGrids string `json:"vucc_grids,omitempty"`
Country string `json:"country,omitempty"`
State string `json:"state,omitempty"`
County string `json:"cnty,omitempty"`
DXCC *int `json:"dxcc,omitempty"`
Continent string `json:"cont,omitempty"`
CQZ *int `json:"cqz,omitempty"`
ITUZ *int `json:"ituz,omitempty"`
IOTA string `json:"iota,omitempty"`
SOTARef string `json:"sota_ref,omitempty"`
POTARef string `json:"pota_ref,omitempty"`
Age *int `json:"age,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Rig string `json:"rig,omitempty"`
Ant string `json:"ant,omitempty"`
Name string `json:"name,omitempty"`
QTH string `json:"qth,omitempty"`
Address string `json:"address,omitempty"`
Email string `json:"email,omitempty"`
Web string `json:"web,omitempty"`
Grid string `json:"grid,omitempty"`
GridExt string `json:"gridsquare_ext,omitempty"`
VUCCGrids string `json:"vucc_grids,omitempty"`
Country string `json:"country,omitempty"`
State string `json:"state,omitempty"`
County string `json:"cnty,omitempty"`
DXCC *int `json:"dxcc,omitempty"`
Continent string `json:"cont,omitempty"`
CQZ *int `json:"cqz,omitempty"`
ITUZ *int `json:"ituz,omitempty"`
IOTA string `json:"iota,omitempty"`
SOTARef string `json:"sota_ref,omitempty"`
POTARef string `json:"pota_ref,omitempty"`
Age *int `json:"age,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Rig string `json:"rig,omitempty"`
Ant string `json:"ant,omitempty"`
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
QSLSent string `json:"qsl_sent,omitempty"`
@@ -105,12 +105,12 @@ type QSO struct {
EQSLSentDate string `json:"eqsl_sent_date,omitempty"`
EQSLRcvdDate string `json:"eqsl_rcvd_date,omitempty"`
ClublogUploadDate string `json:"clublog_qso_upload_date,omitempty"`
ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"`
HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"`
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
ClublogUploadDate string `json:"clublog_qso_upload_date,omitempty"`
ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"`
HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"`
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
@@ -599,7 +599,7 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
// active logbook's callsign (a mixed-call DB — F4BPO, F4BPO/P, TM2Q — must not
// all be signed under one cert).
type UploadCandidate struct {
ID int64
ID int64
StationCallsign string
}
@@ -828,6 +828,56 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
return n, nil
}
// bulkEditableExtras whitelists ADIF fields that are bulk-editable but live in
// extras_json rather than in a promoted column. Key = the frontend's field id,
// value = the uppercase ADIF key inside the JSON object.
//
// OWNER_CALLSIGN is the case that prompted this: it was already filterable (see
// filterableExtras) but could not be bulk-edited, because BulkSetField writes a
// column and there is no owner_callsign column.
var bulkEditableExtras = map[string]string{
"owner_callsign": "OWNER_CALLSIGN",
}
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
// leaving the other extras untouched. An empty value REMOVES the key rather than
// storing a blank — an empty extra would otherwise be carried into every export.
//
// json_set / json_remove exist under those names in both SQLite and MySQL and
// take the same '$.KEY' path syntax, so one statement serves both backends.
func (r *Repo) BulkSetExtra(ctx context.Context, ids []int64, adifKey, value string) (int64, error) {
if adifKey == "" {
return 0, fmt.Errorf("empty extras key")
}
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+2)
expr := `json_set(COALESCE(extras_json, '{}'), '$.` + adifKey + `', ?)`
if value == "" {
expr = `json_remove(COALESCE(extras_json, '{}'), '$.` + adifKey + `')`
} else {
args = append(args, value)
}
args = append(args, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...)
if err != nil {
return 0, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
}
n, _ := res.RowsAffected()
return n, nil
}
// BulkSetFrequency sets freq_hz AND band together on every listed QSO. Kept
// separate from BulkSetField because frequency is numeric and must keep the band
// consistent — the main use is fixing a batch that was logged on a stale/default
@@ -1388,13 +1438,13 @@ type WorkedBefore struct {
Callsign string `json:"callsign"`
// --- Per-callsign ---
Count int `json:"count"` // total prior QSOs with this call
First time.Time `json:"first,omitempty"` // oldest call QSO date
Last time.Time `json:"last,omitempty"` // most recent call QSO date
Bands []string `json:"bands"` // distinct bands for this call
Modes []string `json:"modes"` // distinct modes for this call
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
Entries []QSO `json:"entries"` // up to maxWorkedEntries most recent (full records)
Count int `json:"count"` // total prior QSOs with this call
First time.Time `json:"first,omitempty"` // oldest call QSO date
Last time.Time `json:"last,omitempty"` // most recent call QSO date
Bands []string `json:"bands"` // distinct bands for this call
Modes []string `json:"modes"` // distinct modes for this call
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
Entries []QSO `json:"entries"` // up to maxWorkedEntries most recent (full records)
// --- Per-DXCC entity (populated when DXCC is known) ---
DXCC int `json:"dxcc,omitempty"`
@@ -1478,14 +1528,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
// ---- Per-callsign stats ----
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
`SELECT COUNT(*) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
return wb, fmt.Errorf("count worked: %w", err)
}
if wb.Count > 0 {
// Pull the full QSO records (same columns as the Recent QSOs list) so
// the Worked-before grid can offer the same rich column picker.
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
FROM qso WHERE upper(trim(callsign)) = ?
FROM qso WHERE callsign = ?
ORDER BY qso_date DESC, id DESC
LIMIT ?`, wb.Callsign, maxWorkedEntries)
if err != nil {
@@ -1520,7 +1570,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
if wb.Count > maxWorkedEntries {
var firstStr sql.NullString
_ = r.db.QueryRowContext(ctx,
`SELECT MIN(qso_date) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&firstStr)
`SELECT MIN(qso_date) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&firstStr)
if firstStr.Valid {
wb.First = parseTimeLoose(firstStr.String)
}
@@ -1545,7 +1595,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
var d sql.NullInt64
_ = r.db.QueryRowContext(ctx, `
SELECT dxcc FROM qso
WHERE upper(trim(callsign)) = ? AND dxcc IS NOT NULL
WHERE callsign = ? AND dxcc IS NOT NULL
ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d)
if d.Valid {
dxcc = int(d.Int64)
@@ -1614,8 +1664,8 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
// WorkedBefore call, blanking the matrix in the UI.
statusRows, err := r.db.QueryContext(ctx, `
SELECT band, mode,
MAX(CASE WHEN upper(trim(callsign)) = ? THEN 1 ELSE 0 END),
MAX(CASE WHEN upper(trim(callsign)) = ?
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
MAX(CASE WHEN callsign = ?
AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y')
THEN 1 ELSE 0 END),
MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'
@@ -2459,58 +2509,58 @@ type scanner interface {
func scanQSO(s scanner) (QSO, error) {
var q QSO
var (
qsoDateStr string
qsoDateOffStr sql.NullString
bandRx, submode sql.NullString
freqHz, freqRX sql.NullInt64
rstS, rstR sql.NullString
name, qth, addr, email, web sql.NullString
grid, gridExt, vucc sql.NullString
country, state, cnty sql.NullString
dxcc, cqz, ituz sql.NullInt64
cont, iota, sota, pota sql.NullString
age sql.NullInt64
lat, lon sql.NullFloat64
rig, ant sql.NullString
qslSent, qslRcvd sql.NullString
qslSentDate, qslRcvdDate sql.NullString
qslVia, qslMsg, qslMsgRcvd sql.NullString
lotwSent, lotwRcvd sql.NullString
lotwSentDate, lotwRcvdDate sql.NullString
eqslSent, eqslRcvd sql.NullString
eqslSentDate, eqslRcvdDate sql.NullString
clublogDate, clublogStatus sql.NullString
hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString
qrzcomDlDate, qrzcomDlStatus sql.NullString
contestID sql.NullString
srx, stx sql.NullInt64
srxStr, stxStr sql.NullString
checkField, precedence, arrlSect sql.NullString
propMode, satName, satMode sql.NullString
antAz, antEl sql.NullFloat64
antPath sql.NullString
stCall, op, myGrid, myGridExt sql.NullString
myCountry, myState, myCnty, myIOTA sql.NullString
mySOTA, myPOTA sql.NullString
myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon sql.NullFloat64
myStreet, myCity, myPostal sql.NullString
myRig, myAntenna sql.NullString
txp sql.NullFloat64
comment, notes sql.NullString
sig, sigInfo, mySig, mySigInfo sql.NullString
qsoDateStr string
qsoDateOffStr sql.NullString
bandRx, submode sql.NullString
freqHz, freqRX sql.NullInt64
rstS, rstR sql.NullString
name, qth, addr, email, web sql.NullString
grid, gridExt, vucc sql.NullString
country, state, cnty sql.NullString
dxcc, cqz, ituz sql.NullInt64
cont, iota, sota, pota sql.NullString
age sql.NullInt64
lat, lon sql.NullFloat64
rig, ant sql.NullString
qslSent, qslRcvd sql.NullString
qslSentDate, qslRcvdDate sql.NullString
qslVia, qslMsg, qslMsgRcvd sql.NullString
lotwSent, lotwRcvd sql.NullString
lotwSentDate, lotwRcvdDate sql.NullString
eqslSent, eqslRcvd sql.NullString
eqslSentDate, eqslRcvdDate sql.NullString
clublogDate, clublogStatus sql.NullString
hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString
qrzcomDlDate, qrzcomDlStatus sql.NullString
contestID sql.NullString
srx, stx sql.NullInt64
srxStr, stxStr sql.NullString
checkField, precedence, arrlSect sql.NullString
propMode, satName, satMode sql.NullString
antAz, antEl sql.NullFloat64
antPath sql.NullString
stCall, op, myGrid, myGridExt sql.NullString
myCountry, myState, myCnty, myIOTA sql.NullString
mySOTA, myPOTA sql.NullString
myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon sql.NullFloat64
myStreet, myCity, myPostal sql.NullString
myRig, myAntenna sql.NullString
txp sql.NullFloat64
comment, notes sql.NullString
sig, sigInfo, mySig, mySigInfo sql.NullString
wwffRef, myWWFFRef sql.NullString
distance, rxPwr, aIndex, kIndex, sfi sql.NullFloat64
distance, rxPwr, aIndex, kIndex, sfi sql.NullFloat64
skcc, fists, tenTen sql.NullString
contactedOp, eqCall, pfx, myName sql.NullString
class, darcDOK, myDarcDOK, region sql.NullString
silentKey, swl, qsoComplete, qsoRandom sql.NullString
creditGranted, creditSubmitted sql.NullString
myARRLSect, myVUCCGrids sql.NullString
extrasJSON sql.NullString
awardRefs sql.NullString
createdStr, updatedStr string
myARRLSect, myVUCCGrids sql.NullString
extrasJSON sql.NullString
awardRefs sql.NullString
createdStr, updatedStr string
)
if err := s.Scan(
&q.ID, &q.Callsign, &qsoDateStr, &qsoDateOffStr, &q.Band, &bandRx, &q.Mode, &submode, &freqHz, &freqRX,
+109 -8
View File
@@ -25,6 +25,7 @@ package steppir
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"log"
@@ -35,6 +36,10 @@ import (
"go.bug.st/serial"
)
// errBadFrame marks a reply that isn't a well-formed status frame. It means
// "ignore this poll", not "the link is down".
var errBadFrame = errors.New("steppir: malformed status frame")
// Direction values, matching the app-wide convention (also used by Ultrabeam):
// 0 normal, 1 reverse (180°), 2 bidirectional.
const (
@@ -50,6 +55,15 @@ const (
wireBi = 0x80
)
// pendingDirTTL is how long a commanded direction is trusted over the
// controller's own report. The elements physically re-tune to swap director and
// reflector, and the SDA only reports the new pattern once it starts that move,
// so a few seconds is not enough — 4 s (the original value) had the UI snapping
// back to "normal" while the antenna was on its way to 180°. Long enough to
// cover a real move, short enough that a command the controller never received
// self-corrects instead of lying forever.
const pendingDirTTL = 45 * time.Second
// Transport says how to reach the controller.
type Transport struct {
Mode string // "tcp" | "serial"
@@ -94,6 +108,11 @@ type Client struct {
// A just-commanded direction is held until the controller's poll reports it —
// the motors take a second or two, and a stale poll would otherwise snap the
// UI back. Same trick as the Ultrabeam client.
//
// The hold is deliberately long (pendingDirTTL). It is not just a UI nicety:
// the follow loop re-tunes with the direction it reads back from this status,
// so a single stale poll reading "normal" would make OpsLog command the
// antenna out of 180° all by itself.
pendingDir int
pendingDirAt time.Time
pendingDirSet bool
@@ -189,6 +208,12 @@ func (c *Client) pollLoop() {
c.connMu.Unlock()
st, err := c.queryStatus()
if errors.Is(err, errBadFrame) {
// Framing glitch, not a dead link: skip this tick and keep the
// previous status. Dropping the connection here would blink the
// UI to "disconnected" over one garbled reply.
continue
}
if err != nil {
log.Printf("steppir: status query failed, reconnecting: %v", err)
c.closeConn()
@@ -197,19 +222,32 @@ func (c *Client) pollLoop() {
}
st.Connected = true
c.statusMu.Lock()
if c.pendingDirSet {
if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir {
c.pendingDirSet = false
} else {
st.Direction = c.pendingDir
}
}
c.applyPendingDir(st)
c.lastStatus = st
c.statusMu.Unlock()
}
}
}
// applyPendingDir replaces a freshly polled direction with the one the operator
// last commanded, until the controller confirms it (or the hold expires). The
// caller holds statusMu.
func (c *Client) applyPendingDir(st *Status) {
if !c.pendingDirSet {
return
}
switch {
case st.Direction == c.pendingDir:
c.pendingDirSet = false // confirmed — trust the controller's reports again
case time.Since(c.pendingDirAt) > pendingDirTTL:
c.pendingDirSet = false
log.Printf("steppir: controller never confirmed direction %d (still reports %d) — dropping the hold",
c.pendingDir, st.Direction)
default:
st.Direction = c.pendingDir
}
}
func (c *Client) setDisconnected() {
c.statusMu.Lock()
c.lastStatus = &Status{Connected: false}
@@ -232,6 +270,56 @@ func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
}
}
// setReadTimeout bounds a single read on either transport, so a drain can tell
// "nothing more queued" from "still arriving" without blocking.
func setReadTimeout(conn io.ReadWriteCloser, d time.Duration) {
switch t := conn.(type) {
case net.Conn:
_ = t.SetReadDeadline(time.Now().Add(d))
case serial.Port:
_ = t.SetReadTimeout(d)
}
}
// restoreTimeouts puts the normal exchange timeouts back after a drain shortened
// them.
func restoreTimeouts(conn io.ReadWriteCloser) {
setDeadline(conn, 3*time.Second) // TCP: read + write
setReadTimeout(conn, 2*time.Second)
}
// drain throws away everything already sitting in the input buffer and returns
// how many bytes it discarded.
//
// This is the fix for the antenna's state appearing tens of seconds out of date.
// The SDA controller does not only answer "?A" — it also pushes status frames on
// its own (front-panel changes, autotrack moves, each command it processes). We
// consume exactly one frame per poll, so every unsolicited frame adds one to a
// backlog that only ever grows: reading 11 bytes then returns a frame from
// minutes ago. The field log showed it plainly — two consecutive polls 4 s apart
// reporting 28280 kHz then 14200 kHz, a frequency last used hours earlier, and a
// 180° command not showing up in the status for ~40 s (long after the UI had
// given up waiting and snapped the button back to "normal"). Emptying the buffer
// immediately before each query means the frame we then read is the answer to
// THIS query.
func drain(conn io.ReadWriteCloser) int {
buf := make([]byte, 512)
total := 0
// Bounded so a controller that streams continuously can't hold the poll
// goroutine here forever. 32 × 512 B is ~1500 frames — far more backlog than
// any real link builds up, and it only costs one 30 ms timeout when the
// buffer is already empty (reads return immediately while data is queued).
for i := 0; i < 32; i++ {
setReadTimeout(conn, 30*time.Millisecond)
n, err := conn.Read(buf)
total += n
if err != nil || n == 0 { // timeout / nothing left
break
}
}
return total
}
func (c *Client) queryStatus() (*Status, error) {
c.connMu.Lock()
conn := c.conn
@@ -241,7 +329,12 @@ func (c *Client) queryStatus() (*Status, error) {
}
c.ioMu.Lock()
defer c.ioMu.Unlock()
setDeadline(conn, 3*time.Second)
// Discard any frame the controller pushed on its own since the last poll, so
// what we read below is this query's answer and not a stale backlog entry.
if n := drain(conn); n > 0 {
log.Printf("steppir: discarded %d stale byte(s) queued by the controller before polling", n)
}
restoreTimeouts(conn)
if _, err := conn.Write([]byte("?A\r")); err != nil {
return nil, fmt.Errorf("write status cmd: %w", err)
}
@@ -249,6 +342,14 @@ func (c *Client) queryStatus() (*Status, error) {
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("read status: %w", err)
}
// Reject anything that isn't a framed reply rather than decoding garbage into
// a frequency and a direction the app would then act on.
if buf[0] != '@' || buf[1] != 'A' || buf[10] != 0x0D {
log.Printf("steppir: ignoring malformed status frame % X", buf)
drain(conn) // resync: drop the rest of whatever we landed mid-way through
restoreTimeouts(conn)
return nil, errBadFrame
}
st, err := parseStatus(buf)
// Log the raw frame + decode whenever it changes. The motor byte (buf[6]) is
// what decides st.MotorsMoving, and that in turn drives the app's "block TX
+121
View File
@@ -1,8 +1,12 @@
package steppir
import (
"bytes"
"encoding/binary"
"errors"
"sync"
"testing"
"time"
)
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
@@ -104,3 +108,120 @@ func TestParseStatus(t *testing.T) {
t.Error("active-motors 0xFF (command received) must not read as moving")
}
}
// fakeConn stands in for the controller link. rx holds bytes the "controller"
// has already sent (what the client will read), tx collects what the client
// wrote, and a "?A" query queues `reply` into rx the way the SDA answers.
//
// Reads never block: an empty rx returns (0, nil), which is exactly how
// go.bug.st/serial reports a read timeout, so drain() sees the same
// nothing-left signal it gets from real hardware.
type fakeConn struct {
mu sync.Mutex
rx bytes.Buffer
tx bytes.Buffer
reply []byte
}
func (f *fakeConn) Read(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.rx.Len() == 0 {
return 0, nil
}
return f.rx.Read(p)
}
func (f *fakeConn) Write(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.tx.Write(p)
if bytes.Contains(p, []byte("?A")) {
f.rx.Write(f.reply)
}
return len(p), nil
}
func (f *fakeConn) Close() error { return nil }
var (
// 50.150 MHz, normal — the "stuck at 6 m" frame that kept turning up in
// F4BPO's friend's log long after the rig had left the band.
frame6mNormal = []byte{0x40, 0x41, 0x00, 0x4C, 0x85, 0xD8, 0x00, 0x07, 0x30, 0x38, 0x0D}
// 14.250 MHz, 180° — what the controller actually reports right now.
frame20m180 = []byte{0x40, 0x41, 0x00, 0x15, 0xBE, 0x68, 0x00, 0x47, 0x30, 0x38, 0x0D}
)
// The controller pushes status frames unsolicited, so they pile up between polls.
// Reading one frame per poll then returns state from minutes ago — which is how a
// 180° command could take ~40 s to show up in the UI, and how two polls 4 s apart
// reported 28 MHz then 14 MHz. queryStatus must empty the backlog first.
func TestQueryStatusDiscardsQueuedFrames(t *testing.T) {
fc := &fakeConn{reply: frame20m180}
fc.rx.Write(frame6mNormal) // two frames the controller pushed on its own
fc.rx.Write(frame6mNormal)
c := &Client{conn: fc}
st, err := c.queryStatus()
if err != nil {
t.Fatal(err)
}
if st.Frequency != 14250 {
t.Errorf("freq = %d kHz, want 14250 — a stale queued frame was read instead of this poll's reply", st.Frequency)
}
if st.Direction != Dir180 {
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
}
}
// A reply we land on mid-frame must be rejected, not decoded into a bogus
// frequency and direction the follow loop would then act on — and it must not
// look like a dead link either (errBadFrame keeps the connection).
func TestQueryStatusRejectsMalformedFrame(t *testing.T) {
shifted := append(append([]byte{}, frame20m180[3:]...), 0x40, 0x41, 0x00) // 11 bytes, wrong header
c := &Client{conn: &fakeConn{reply: shifted}}
if _, err := c.queryStatus(); !errors.Is(err, errBadFrame) {
t.Fatalf("err = %v, want errBadFrame", err)
}
}
// The direction the operator just commanded is shown until the controller
// confirms it. The hold used to be 4 s — two polls — so the button snapped back
// to Normal while the elements were still swapping over to 180°.
func TestApplyPendingDirHold(t *testing.T) {
c := &Client{pendingDir: Dir180, pendingDirAt: time.Now(), pendingDirSet: true}
// Controller still reports the old pattern: keep showing what was commanded.
st := &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != Dir180 {
t.Fatalf("direction = %d, want %d while the move is pending", st.Direction, Dir180)
}
if !c.pendingDirSet {
t.Fatal("hold released before the controller confirmed")
}
// Still holding well past the old 4 s window — a SteppIR takes longer than
// that to report a pattern change.
c.pendingDirAt = time.Now().Add(-10 * time.Second)
st = &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != Dir180 {
t.Fatalf("direction = %d after 10 s, want %d — the hold expired too early", st.Direction, Dir180)
}
// Controller confirms: release the hold and trust its reports again.
st = &Status{Direction: Dir180}
c.applyPendingDir(st)
if c.pendingDirSet {
t.Fatal("hold should be released once the controller reports the commanded direction")
}
// A command the controller never acted on must not lie forever.
c.pendingDir, c.pendingDirAt, c.pendingDirSet = DirBi, time.Now().Add(-pendingDirTTL-time.Second), true
st = &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != DirNormal || c.pendingDirSet {
t.Fatalf("expired hold should fall back to the controller: direction = %d, pending = %v", st.Direction, c.pendingDirSet)
}
}
+100
View File
@@ -0,0 +1,100 @@
package tunergenius
import (
"bufio"
"net"
"os"
"strings"
"testing"
"time"
)
// captureLog redirects the stream applog.Printf always writes to, and returns a
// stop function yielding what was logged. Reading only after stop keeps the
// collector goroutine and the test off the same slice.
func captureLog() func() []string {
orig := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
var lines []string
done := make(chan struct{})
go func() {
sc := bufio.NewScanner(r)
for sc.Scan() {
lines = append(lines, sc.Text())
}
close(done)
}()
return func() []string {
os.Stderr = orig
w.Close()
<-done
r.Close()
return lines
}
}
// A tuner that answers, then vanishes mid-session. A drop used to leave no trace
// at all, so "did the tuner disconnect, or is the meter just reading a gap
// between syllables?" was unanswerable from the log. It must now log — and log
// ONCE, not on every 400 ms retry.
func TestLinkDownLoggedOnceNotPerRetry(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := ln.Addr().(*net.TCPAddr).Port
die := make(chan struct{})
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
conn.Write([]byte("V1.2.11\n"))
buf := make([]byte, 256)
for {
n, err := conn.Read(buf)
if err != nil {
return
}
id := strings.TrimPrefix(strings.SplitN(string(buf[:n]), "|", 2)[0], "C")
conn.Write([]byte("R" + id + "|0|status state=1 fwd=60.7 swr=-25 active=1\n"))
select {
case <-die:
conn.Close()
ln.Close()
return
default:
}
}
}()
stop := captureLog()
c := New("127.0.0.1", port, "")
if err := c.Start(); err != nil {
stop()
t.Fatal(err)
}
time.Sleep(1200 * time.Millisecond)
connected := c.GetStatus().Connected
close(die)
time.Sleep(2 * time.Second) // ~5 poll cycles with the tuner gone
c.Stop()
lines := stop()
if !connected {
t.Fatal("the client never reached the fake tuner — the rest of the test is meaningless")
}
down := 0
for _, l := range lines {
if strings.Contains(l, "link DOWN") {
down++
}
}
t.Logf("logged:\n%s", strings.Join(lines, "\n"))
if down != 1 {
t.Errorf("got %d 'link DOWN' lines, want exactly 1 — the poll retries many times in a 3 s outage and must not repeat itself", down)
}
}
+24 -2
View File
@@ -34,8 +34,7 @@ const (
// Poll fast so the meters track TX like the amplifier does (the amp's numbers
// ride the real-time Flex UDP stream; the tuner is a synchronous TCP poll, so
// a slow interval made its SWR/power lag noticeably behind).
pollEvery = 400 * time.Millisecond
reconnectDelay = 2 * time.Second
pollEvery = 400 * time.Millisecond
)
// Channel is the live state of one of the tuner's two RF channels (A / B). The
@@ -215,6 +214,17 @@ func (c *Client) Activate(ch int) error {
func (c *Client) pollLoop() {
t := time.NewTicker(pollEvery)
defer t.Stop()
// Outage bookkeeping. A dropped link used to be entirely silent: the poll just
// set Connected=false and retried, so "did the tuner disconnect, or is the
// meter simply reading a gap between syllables?" could not be answered from
// the log. Now an outage logs once when it starts and once when it ends, with
// how long it lasted.
//
// Once, not every retry: the poll comes round every 400 ms, and a tuner that
// is switched off would otherwise bury the log. These are local to the one
// goroutine that polls — ensureConnected has no other caller — so they need no
// locking.
var downSince time.Time
for {
select {
case <-c.stop:
@@ -223,16 +233,28 @@ func (c *Client) pollLoop() {
fresh := false
if c.needConnect() {
if err := c.ensureConnected(); err != nil {
if downSince.IsZero() {
downSince = time.Now()
applog.Printf("tunergenius: link DOWN — cannot reach %s:%d: %v (retrying)", c.host, c.port, err)
}
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
continue
}
fresh = true
if !downSince.IsZero() {
applog.Printf("tunergenius: link RESTORED after %s down", time.Since(downSince).Round(time.Second))
downSince = time.Time{}
}
}
// One-shot on a fresh link: learn the hardware variant (3-way vs SO2R).
if fresh {
_, _ = c.command("info")
}
if _, err := c.command("status"); err != nil {
if downSince.IsZero() {
downSince = time.Now()
applog.Printf("tunergenius: link DOWN — poll failed: %v", err)
}
c.dropConn()
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
}
+150 -7
View File
@@ -27,10 +27,13 @@ import (
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
@@ -46,6 +49,14 @@ var errFCCMaintenance = errors.New("fcc uls under maintenance")
const (
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
geoNamesURL = "https://download.geonames.org/export/zip/US.zip"
// fccULSDir is the parent listing the weekly files live under. It is browsed
// to recover from the FCC moving the file — see resolveFCCAmateurURL.
fccULSDir = "https://data.fcc.gov/download/pub/uls/"
// fccAmateurFile is the weekly full-database filename, constant across the
// FCC's directory reshuffles.
fccAmateurFile = "l_amat.zip"
)
// Location is a resolved callsign's home county + grid.
@@ -170,10 +181,19 @@ func (s *Store) Import(ctx context.Context, tmpDir string, prog Progress) error
return fmt.Errorf("GeoNames crosswalk is empty")
}
// 2) FCC ULS full amateur database (large).
// 2) FCC ULS full amateur database (large). The address is resolved rather
// than assumed — the FCC moves this file between directories.
prog("Locating FCC ULS database", 0)
amatURL, err := resolveFCCAmateurURL(ctx)
if err != nil {
return err // already a user-facing explanation
}
if amatURL != fccAmateurURL {
log.Printf("uls: FCC weekly file not at its usual address, using %s", amatURL)
}
prog("Downloading FCC ULS database", 0)
amatPath := filepath.Join(tmpDir, "opslog_l_amat.zip")
if err := download(ctx, fccAmateurURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
if err := download(ctx, amatURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
return fmt.Errorf("download FCC ULS: %w", err)
}
defer os.Remove(amatPath)
@@ -323,6 +343,129 @@ func parseGeoNames(zipPath string) (map[string]zipRow, error) {
return out, sc.Err()
}
// resolveFCCAmateurURL returns a URL that actually serves the weekly amateur
// database, working around the FCC relocating it.
//
// The canonical path is .../uls/complete/l_amat.zip and it is tried first. On
// 2026-07-24 the FCC renamed that whole directory to "complete.07242026" and
// left an empty "complete" behind, so every weekly file for every radio service
// (not just amateur) started redirecting to a generic fcc.gov help page — the
// county database became un-downloadable for everyone. The file itself was
// intact the whole time, one directory across.
//
// Rather than hard-code that dated directory — it looks like a pre-migration
// snapshot, and pinning it would break again the moment the FCC restores or
// re-snapshots — we browse the parent listing and pick the most recent
// "complete*" directory that actually holds the file. That survives the
// canonical path coming back (tried first, so it wins), a differently-dated
// snapshot next time, and anything else short of the file being withdrawn.
func resolveFCCAmateurURL(ctx context.Context) (string, error) {
if ok, _ := servesFile(ctx, fccAmateurURL); ok {
return fccAmateurURL, nil
}
dirs, err := listFCCCompleteDirs(ctx)
if err != nil {
return "", fmt.Errorf("the FCC weekly download moved and the directory listing could not be read (%w) — try again later, or check %s", err, fccULSDir)
}
// Newest first: the listing is alphabetical, and the dated names sort in an
// arbitrary order (MMDDYYYY), so try them all rather than trusting the order.
for _, d := range dirs {
u := fccULSDir + d + fccAmateurFile
if ok, _ := servesFile(ctx, u); ok {
return u, nil
}
}
return "", fmt.Errorf("the FCC no longer serves %s at its usual address, and no alternate directory under %s has it either — the FCC has changed its downloads; please report this", fccAmateurFile, fccULSDir)
}
// servesFile reports whether url returns a real file rather than a redirect to
// an HTML error page. The FCC answers a missing file with 302 → a help page, so
// a plain status check on the final response is not enough: we must refuse to
// follow the bounce and insist on a non-HTML body.
func servesFile(ctx context.Context, url string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return false, err
}
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); strings.Contains(strings.ToLower(ct), "html") {
return false, fmt.Errorf("served HTML, not a file")
}
return true, nil
}
// listFCCCompleteDirs returns the "complete*/" subdirectory names in the ULS
// download area (e.g. "complete/", "complete.07242026/"), newest-looking last.
func listFCCCompleteDirs(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fccULSDir, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
return parseCompleteDirs(string(body)), nil
}
// completeDirRe matches an Apache-style listing link to a "complete…" directory.
var completeDirRe = regexp.MustCompile(`(?i)href="(complete[^"/]*/)"`)
// parseCompleteDirs pulls the candidate directory names out of a listing page.
// Split out from the fetch so it can be tested against a captured listing.
func parseCompleteDirs(html string) []string {
seen := map[string]bool{}
var out []string
for _, m := range completeDirRe.FindAllStringSubmatch(html, -1) {
d := m[1]
if d == "complete/" || seen[d] { // canonical path was already tried
continue
}
seen[d] = true
out = append(out, d)
}
// Dated snapshots (complete.MMDDYYYY) — prefer the most recent by date, so a
// stale older snapshot is never picked over a fresh one.
sort.Slice(out, func(i, j int) bool { return snapshotDate(out[i]).After(snapshotDate(out[j])) })
return out
}
var snapshotDateRe = regexp.MustCompile(`(\d{8})`)
// snapshotDate extracts the MMDDYYYY stamp from "complete.07242026/"; a name
// without one sorts as the zero time (tried last).
func snapshotDate(dir string) time.Time {
m := snapshotDateRe.FindStringSubmatch(dir)
if m == nil {
return time.Time{}
}
t, err := time.Parse("01022006", m[1])
if err != nil {
return time.Time{}
}
return t
}
// download streams url to dest, reporting percent when the content length is
// known and prog is non-nil.
func download(ctx context.Context, url, dest string, prog func(pct int)) error {
@@ -371,11 +514,11 @@ func download(ctx context.Context, url, dest string, prog func(pct int)) error {
}
type progReader struct {
r io.Reader
total int64
read int64
last int
prog func(pct int)
r io.Reader
total int64
read int64
last int
prog func(pct int)
}
func (p *progReader) Read(b []byte) (int, error) {
+57 -3
View File
@@ -10,9 +10,9 @@ func TestGrid6(t *testing.T) {
lat, lon float64
want string
}{
{38.90, -77.03, "FM18lw"}, // Washington DC
{40.71, -74.00, "FN30xr"}, // New York
{34.05, -118.24, "DM04vd"},// Los Angeles
{38.90, -77.03, "FM18lw"}, // Washington DC
{40.71, -74.00, "FN30xr"}, // New York
{34.05, -118.24, "DM04vd"}, // Los Angeles
}
for _, c := range cases {
if got := grid6(c.lat, c.lon); got[:4] != c.want[:4] {
@@ -40,3 +40,57 @@ func TestParseGeoNames(t *testing.T) {
t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok)
}
}
// The real listing captured from data.fcc.gov on 2026-07-25, the day after the
// FCC renamed complete/ to complete.07242026/ and left an empty complete/
// behind — which broke the county-database download for every user.
const fccListing2026 = `<html><head><title>Index of /download/pub/uls</title></head><body>
<h1>Index of /download/pub/uls</h1>
<table><tr><th>Name</th><th>Last modified</th><th>Size</th></tr>
<tr><td><a href="/download/pub/">Parent Directory</a></td><td>&nbsp;</td><td>-</td></tr>
<tr><td><a href="UAT/">UAT/</a></td><td>2025-04-08 19:30</td><td>-</td></tr>
<tr><td><a href="complete.07242026/">complete.07242026/</a></td><td>2026-07-24 20:15</td><td>-</td></tr>
<tr><td><a href="complete/">complete/</a></td><td>2026-07-25 21:15</td><td>-</td></tr>
<tr><td><a href="daily/">daily/</a></td><td>2026-07-25 21:15</td><td>-</td></tr>
</table></body></html>`
func TestParseCompleteDirs(t *testing.T) {
got := parseCompleteDirs(fccListing2026)
if len(got) != 1 || got[0] != "complete.07242026/" {
t.Fatalf("got %v, want [complete.07242026/]", got)
}
// "complete/" is deliberately absent: it is the canonical URL, already tried
// before the listing is consulted, and on this date it was empty.
for _, d := range got {
if d == "complete/" {
t.Error("canonical complete/ should not be offered as a fallback")
}
}
}
// Several snapshots must be tried newest-first, so a stale one is never
// preferred over a fresh one.
func TestParseCompleteDirsPrefersNewestSnapshot(t *testing.T) {
html := `<a href="complete/">x</a><a href="complete.01052026/">x</a>` +
`<a href="complete.07242026/">x</a><a href="complete.11302025/">x</a>`
got := parseCompleteDirs(html)
want := []string{"complete.07242026/", "complete.01052026/", "complete.11302025/"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v, want %v", got, want)
}
}
}
func TestSnapshotDate(t *testing.T) {
if d := snapshotDate("complete.07242026/"); d.Format("2006-01-02") != "2026-07-24" {
t.Errorf("complete.07242026/ → %s, want 2026-07-24", d.Format("2006-01-02"))
}
// An undated name must sort last rather than crash.
if !snapshotDate("complete.backup/").IsZero() {
t.Error("an undated directory should yield the zero time")
}
}