chore: release v0.21.3
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
"L’opé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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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> </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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user