chore: release v0.21.3

This commit is contained in:
2026-07-26 16:57:19 +02:00
parent 4fd70f6a9d
commit 91b5af1c7b
46 changed files with 2491 additions and 355 deletions
+14
View File
@@ -649,15 +649,29 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
const reconnectEvery = 5 * time.Second
connected := false
var lastAttempt time.Time
var lastConnErr string // last connect failure logged, so the retry loop says it once
tryConnect := func() {
if connected || time.Since(lastAttempt) < reconnectEvery {
return
}
lastAttempt = time.Now()
if err := b.Connect(); err != nil {
// Log it — the message used to live only in RigState.Error, i.e. in a
// tooltip. The status pill condenses everything to "OmniRig not found",
// so a user reporting that had no way to tell us WHY: the COM HRESULT,
// the serial error, the refused TCP connect, all invisible. Logged once
// per distinct message so the retry loop doesn't flood the file.
if msg := err.Error(); msg != lastConnErr {
lastConnErr = msg
debugLog.Printf("%s connect failed: %s", b.Name(), msg)
}
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
return
}
if lastConnErr != "" {
debugLog.Printf("%s connected (after: %s)", b.Name(), lastConnErr)
lastConnErr = ""
}
connected = true
}
tryConnect()
+44 -23
View File
@@ -39,10 +39,10 @@ const (
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
CmdPower = 0x18 // power on/off (sub 0x01 = on, 0x00 = off; on needs an FE wake preamble)
CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub)
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub)
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
@@ -91,26 +91,26 @@ const (
SubATU = 0x01 // antenna tuner (data 0x02 = start tune)
// CmdScope sub-commands.
SubScopeData = 0x00 // waveform data frame (divided across several frames)
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
SubScopeData = 0x00 // waveform data frame (divided across several frames)
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed)
SubScopeSpan = 0x15 // span in center mode (±span/2 as 5 LE-BCD)
SubScopeEdge = 0x16 // fixed-mode ACTIVE edge set 1-4 (vfo + set#)
SubScopeFixEdge = 0x1e // fixed-mode edge FREQUENCIES: [range][set#][low 5-BCD][high 5-BCD]
// CmdSwitch sub-commands.
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
SubSwNB = 0x22 // noise blanker on/off
SubSwNR = 0x40 // noise reduction on/off
SubSwANF = 0x41 // auto-notch on/off
SubSwComp = 0x44 // speech compressor on/off
SubSwMon = 0x45 // monitor on/off
SubSwVOX = 0x46 // VOX on/off
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
SubSwNB = 0x22 // noise blanker on/off
SubSwNR = 0x40 // noise reduction on/off
SubSwANF = 0x41 // auto-notch on/off
SubSwComp = 0x44 // speech compressor on/off
SubSwMon = 0x45 // monitor on/off
SubSwVOX = 0x46 // VOX on/off
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
SubSwMN = 0x48 // manual notch on/off
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
SubSwMN = 0x48 // manual notch on/off
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
)
// CW break-in modes (CmdSwitch 0x47).
@@ -307,22 +307,43 @@ func ModeToADIF(m byte, data bool) string {
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
// model. Unknown addresses fall back to a hex label.
//
// The name is not cosmetic: the UI derives model-dependent behaviour from it —
// notably the attenuator steps, which are 6/12/18 dB on the big rigs and a single
// 20 dB on the small ones. An address missing here therefore shows the WRONG
// attenuator buttons, and the rig NAKs them.
//
// Two entries here used to be wrong in a way that pointed at each other: 0x80 was
// labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the IC-7100), while
// the real IC-7800 (0x6A) and IC-7700 (0x74) were absent — so a 7800 came up as
// "Icom (0x6A)" with a 20 dB attenuator it does not have.
//
// Addresses cross-checked against the TR4W CI-V table and an independent
// published list; both agree on every value below.
func ModelName(addr byte) string {
switch addr {
case 0x6A:
return "IC-7800"
case 0x74:
return "IC-7700"
case 0x7A:
return "IC-7600"
case 0x7C:
return "IC-9100"
case 0x80:
return "IC-7410"
case 0x88:
return "IC-7100"
case 0x8E:
return "IC-7851" // shared with the IC-7850
case 0x94:
return "IC-7300"
case 0x98:
return "IC-7610"
case 0x7C:
return "IC-9100"
case 0xA2:
return "IC-9700"
case 0xA4:
return "IC-705"
case 0x88:
return "IC-7700"
case 0x80:
return "IC-7800"
}
return fmt.Sprintf("Icom (0x%02X)", addr)
}
+34 -2
View File
@@ -55,8 +55,8 @@ func TestScanSingleFreqResponse(t *testing.T) {
}
func TestScanSkipsEchoAndKeepsPartial(t *testing.T) {
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
buf := append(append([]byte{}, echo...), resp...)
buf = append(buf, 0xFE, 0xFE, 0x98) // a partial third frame (no FD yet)
@@ -130,3 +130,35 @@ func TestModelName(t *testing.T) {
t.Errorf("ModelName(0x12) = %q, want fallback", got)
}
}
// CI-V addresses are hardware constants: a wrong one means the console shows the
// wrong model, and with it the wrong attenuator steps (6/12/18 dB on the big
// rigs, a single 20 dB on the small ones) — buttons the rig then NAKs.
//
// Two entries here were previously wrong in a way that pointed at each other:
// 0x80 was labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the
// IC-7100), while the real IC-7800 (0x6A) and IC-7700 (0x74) were missing — so an
// IC-7800 came up as "Icom (0x6A)" with a 20 dB attenuator it does not have.
func TestModelNameAddresses(t *testing.T) {
for addr, want := range map[byte]string{
0x6A: "IC-7800",
0x74: "IC-7700",
0x7A: "IC-7600",
0x7C: "IC-9100",
0x80: "IC-7410",
0x88: "IC-7100",
0x8E: "IC-7851",
0x94: "IC-7300",
0x98: "IC-7610",
0xA2: "IC-9700",
0xA4: "IC-705",
} {
if got := ModelName(addr); got != want {
t.Errorf("ModelName(0x%02X) = %q, want %q", addr, got, want)
}
}
// An unknown address must stay identifiable rather than masquerade as a model.
if got := ModelName(0x42); got != "Icom (0x42)" {
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
}
}
+62 -18
View File
@@ -79,23 +79,23 @@ type IcomSerial struct {
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
dualScope bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeHigh int64 // spectrum right-edge frequency
scopeSeq int
scopeOn bool
dualScope bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeHigh int64 // spectrum right-edge frequency
scopeSeq int
scopeOn bool
scopeFixed bool // true = fixed-span mode (tracked optimistically)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance)
dspLoaded bool // readDSP has run since the rig became responsive (loads all
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance)
dspLoaded bool // readDSP has run since the rig became responsive (loads all
// the panel's set-once controls once the rig actually answers)
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time
@@ -341,7 +341,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
}
if b.splitOn && b.splitTXFreq > 0 && b.splitTXFreq != s.FreqHz {
s.Split = true
s.RxFreqHz = s.FreqHz // selected VFO = RX
s.RxFreqHz = s.FreqHz // selected VFO = RX
s.FreqHz = b.splitTXFreq // unselected VFO = TX
}
@@ -374,10 +374,54 @@ func (b *IcomSerial) ReadState() (RigState, error) {
if !b.dspLoaded {
b.readDSP()
b.dspLoaded = true
} else {
b.refreshFrontPanel()
}
return s, nil
}
// refreshFrontPanel re-reads the few controls an operator actually reaches for on
// the rig itself, so the console follows the radio instead of only driving it.
//
// readDSP loads everything but runs ONCE per connection (dspLoaded), which left
// the panel showing whatever was set at connect: switch AGC from FAST to MID on
// the front panel and OpsLog still said FAST, indefinitely. Commands worked, so
// the link was plainly fine — only this direction was missing.
//
// ONE read per poll cycle, in rotation. The full snapshot is ~30 CI-V round trips
// and refreshing it wholesale would hog the CAT thread for seconds at a time —
// including the operator's own Set* commands, which is far worse than a stale
// label. The rotation completes in 8 cycles; anything not covered here is still a
// connect-time or ↻ Refresh read.
func (b *IcomSerial) refreshFrontPanel() {
switch b.pollN % 8 {
case 1:
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
b.dspMu.Lock()
b.dsp.AGC = agcName(v)
b.dspMu.Unlock()
}
case 3:
if v, ok := b.readAtt(); ok {
b.dspMu.Lock()
b.dsp.Att = v
b.dspMu.Unlock()
}
case 5:
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
b.dspMu.Lock()
b.dsp.Preamp = int(v)
b.dspMu.Unlock()
}
case 7:
if _, f, ok := b.readModeFilter(); ok {
b.dspMu.Lock()
b.dsp.Filter = int(f)
b.dspMu.Unlock()
}
}
}
func (b *IcomSerial) SetFrequency(hz int64) error {
if hz <= 0 {
return fmt.Errorf("invalid frequency")
@@ -583,8 +627,8 @@ func (b *IcomSerial) drainResp() {
func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
regions := make(map[byte][]byte)
var total byte
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
for {
select {
case <-done:
+19 -4
View File
@@ -4,6 +4,7 @@ import (
"log"
"os"
"path/filepath"
"sync"
)
// LogSink, when set by the host app at startup, receives every CAT debug
@@ -15,13 +16,21 @@ var LogSink func(format string, args ...any)
// catLogger forwards Printf either to the host LogSink (preferred) or to a
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
// unchanged.
type catLogger struct{ fallback *log.Logger }
type catLogger struct {
once sync.Once
fallback *log.Logger
}
func (c *catLogger) Printf(format string, args ...any) {
if LogSink != nil {
LogSink("cat: "+format, args...)
return
}
// Only now, on a line that genuinely has nowhere else to go, is the fallback
// file opened. It used to be created at package init, so every installation
// grew an %APPDATA%\OpsLog\cat.log that nothing ever wrote to once the app
// wired LogSink — a decoy for anyone told to "check the CAT log".
c.once.Do(func() { c.fallback = openFallbackLog() })
if c.fallback != nil {
c.fallback.Printf(format, args...)
}
@@ -30,7 +39,7 @@ func (c *catLogger) Printf(format string, args ...any) {
// debugLog writes CAT debug events so users can diagnose mode/freq mismatches
// without rebuilding with a console. Once LogSink is set, lines flow into the
// main opslog.log.
var debugLog = &catLogger{fallback: openFallbackLog()}
var debugLog = &catLogger{}
func openFallbackLog() *log.Logger {
base, err := os.UserConfigDir()
@@ -49,9 +58,15 @@ func openFallbackLog() *log.Logger {
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
}
// DebugLogPath returns where the fallback cat.log lives, for surfacing in the
// UI / docs. When LogSink is wired, CAT lines are in the main app log instead.
// DebugLogPath returns where the fallback cat.log lives, or "" when CAT lines are
// going to the app log instead — which is the normal case, and the answer callers
// actually need. It previously returned the path unconditionally, so the one place
// that displayed it sent operators to an empty file in %APPDATA% while their CAT
// diagnostics sat in data\opslog.log.
func DebugLogPath() string {
if LogSink != nil {
return "" // lines go to the unified app log; there is no separate cat.log
}
base, err := os.UserConfigDir()
if err != nil {
return ""
+162 -47
View File
@@ -34,6 +34,11 @@ type OmniRig struct {
lastSig string // last logged Split/VFO signature — only log on change
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
// connLogged holds the connect failure already written to the log, so the
// 5-second reconnect loop reports a persistent problem once instead of
// forever. Cleared on success.
connLogged string
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
// async Freq property, which still reports the OLD band for a poll or two
@@ -53,8 +58,55 @@ func NewOmniRig(rigNum int) *OmniRig {
func (o *OmniRig) Name() string { return "omnirig" }
// elevationHint recognises the COM refusal that happens when OmniRig runs
// elevated (as administrator) and OpsLog does not — or the reverse. Windows keeps
// the two integrity levels apart, so the client cannot bind to the running
// server's object and COM falls back to launching a fresh one, which then needs
// elevation the client cannot grant.
//
// It is worth naming explicitly: the operator SEES OmniRig running, with its
// settings window open, so "OmniRig not found" reads as nonsense and sends them
// hunting for a driver or COM-port problem that does not exist. The fix is thirty
// seconds of work once you know what to look for.
func elevationHint(err error) string {
if err == nil {
return ""
}
msg := strings.ToLower(err.Error())
// Matched on the HRESULT text in whatever language Windows is running in, so
// the code is checked too: 0x800702E4 = ERROR_ELEVATION_REQUIRED.
if strings.Contains(msg, "elevation") || strings.Contains(msg, "élévation") ||
strings.Contains(msg, "0x800702e4") || strings.Contains(msg, "access denied") ||
strings.Contains(msg, "accès refusé") {
return "OmniRig and OpsLog are running at different privilege levels — Windows keeps them apart, " +
"so OpsLog cannot reach OmniRig even though it is running. Start BOTH the same way: either " +
"un-tick \"Run as administrator\" on the OmniRig shortcut (and its Compatibility tab), or run " +
"OpsLog as administrator too"
}
return ""
}
// logConnFailure writes a connect failure once per distinct cause. The reconnect
// loop retries every 5 seconds forever, and a station whose OmniRig was simply
// elevated had this filling its log at roughly 1500 lines an hour — which buries
// the very diagnostics someone would go looking for.
func (o *OmniRig) logConnFailure(msg string) {
if o.connLogged == msg {
return
}
o.connLogged = msg
debugLog.Printf("OmniRig Rig%d: %s", o.RigNum, msg)
}
func (o *OmniRig) Connect() error {
debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath())
// This used to announce DebugLogPath() on every attempt — the path of the
// FALLBACK cat.log, which nothing writes to once the app has wired LogSink and
// everything goes to data\opslog.log. It pointed operators at an empty file in
// %APPDATA% while the lines they wanted were somewhere else entirely. Dropped;
// and logged once per failure run rather than every 5-second retry.
if o.connLogged == "" {
debugLog.Printf("OmniRig.Connect Rig%d", o.RigNum)
}
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
// 0x1 = S_FALSE → COM already initialised on this thread, fine.
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
@@ -62,15 +114,43 @@ func (o *OmniRig) Connect() error {
}
}
unk, err := oleutil.CreateObject("Omnirig.OmnirigX")
if err != nil {
return fmt.Errorf("Omnirig.OmnirigX not available — is OmniRig installed and running?: %w", err)
}
omnirig, err := unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return fmt.Errorf("query interface: %w", err)
const progID = "Omnirig.OmnirigX"
var omnirig *ole.IDispatch
unk, err := oleutil.CreateObject(progID)
if err == nil {
omnirig, err = unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return fmt.Errorf("query interface: %w", err)
}
} else {
// A privilege mismatch is final — retrying, or trying the 32-bit server,
// cannot cross an integrity boundary. Say what to do instead of dressing it
// up as "not installed", which is what sends operators looking in the wrong
// place entirely.
if hint := elevationHint(err); hint != "" {
o.logConnFailure(hint)
return fmt.Errorf("%s (Windows said: %v)", hint, err)
}
// Otherwise it may be a partial registration; try activating the 32-bit
// server explicitly before giving up (see omnirig_activate32.go).
disp, err32 := createOmniRig32(progID)
if err32 != nil {
o.logConnFailure(fmt.Sprintf("CreateObject(%s) failed: %v; 32-bit activation also failed: %v", progID, err, err32))
// Name the version requirement. HB9RYZ's OmniRig v2.1 is a different
// product that its own author states is not compatible with v1, and it
// does not provide v1's IOmniRigX interface — so an operator who has
// only v2 installed sees OmniRig running and OpsLog failing, with
// nothing to connect the two facts.
return fmt.Errorf("OmniRig (v1) not reachable: %w — OpsLog needs OmniRig v1.19/v1.20 "+
"(VE3NEA/Alex), the interface every logger uses. HB9RYZ's OmniRig v2.1 is a separate, "+
"incompatible product and cannot serve OpsLog; the two may be installed side by side, "+
"but v1 must be present and running", err)
}
debugLog.Printf("OmniRig: reached via explicit 32-bit activation after CreateObject failed (%v)", err)
omnirig = disp
}
o.connLogged = "" // connected: re-arm the one-shot failure logging
rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
if err != nil {
@@ -80,10 +160,26 @@ func (o *OmniRig) Connect() error {
o.omnirig = omnirig
o.rig = rigVar.ToIDispatch()
// Log WHICH OmniRig answered. There are two incompatible products called
// OmniRig: v1.19/1.20 (Alex, VE3NEA), whose IOmniRigX interface every logger
// including OpsLog uses, and HB9RYZ's v2.1, which its own author states is "not
// compatible" with v1 and works only with programs written for it. They can
// coexist, and OmniRig's own window looks much the same either way — so an
// operator running only v2 sees OmniRig on screen, sees OpsLog fail, and has no
// way to know the two were never going to talk. These two version numbers
// settle it in one line of a bug report.
var iv, sv int64 = -1, -1
if v, err := oleutil.GetProperty(o.omnirig, "InterfaceVersion"); err == nil {
iv = v.Val
}
if v, err := oleutil.GetProperty(o.omnirig, "SoftwareVersion"); err == nil {
sv = v.Val
}
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
o.rigType = rt.ToString()
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
}
debugLog.Printf("OmniRig connected: Rig%d type=%q (OmniRig interface=%d software=%d)",
o.RigNum, o.rigType, iv, sv)
return nil
}
@@ -169,50 +265,69 @@ func (o *OmniRig) ReadState() (RigState, error) {
}())
}
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
// distinct and non-zero, AND they're in the same band. The same-band test
// kills the common false positive where VFO B just holds a leftover from
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
genuineSplit := splitRaw == pmSplitOn &&
freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB)
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(freqMain, freqA, freqB, s.Vfo, splitRaw)
return s, nil
}
if genuineSplit {
// ADIF: FreqHz = TX, RxFreqHz = RX. Determine which VFO is RX from the
// ACTIVE frequency (OmniRig's generic Freq — the VFO you're listening on):
// RX = the active VFO, TX = the other one. This is far more reliable than
// trusting OmniRig's Vfo AB/BA enum, which several rigs (e.g. Yaesu FTDX10)
// report inverted — the split then showed TX/RX swapped.
s.Split = true
// resolveOmniRigVFOs turns OmniRig's four readings into the ADIF pair
// (FreqHz = TX, RxFreqHz = RX) plus a split flag.
//
// Pure and separate from ReadState because it encodes rig-specific rules that
// contradict each other — what fixes a Yaesu can break an Icom — and the only way
// to change it safely is with every known rig's behaviour pinned in a test. COM
// cannot be exercised from a test; this can.
func resolveOmniRigVFOs(freqMain, freqA, freqB int64, vfo string, splitRaw int64) (txHz, rxHz int64, split bool) {
// PM_SPLITON is tested as a BIT, not by equality. OmniRig's Split is a flag
// word: an exact `== 0x8000` holds only for a rig whose ini sets that bit and
// nothing else, and silently reports "no split" for any rig reporting the bit
// alongside another. Requiring ON set and OFF clear keeps the two states apart
// (both flags are non-zero, so a bare `!= 0` would read OFF as split) while
// tolerating extra bits.
splitFlagged := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
// A genuine split also needs two distinct, non-zero VFOs in the SAME band. The
// band test kills the common false positive where VFO B merely holds a
// leftover from another band (a "28 MHz / 7 MHz split" is nonsensical), which
// on the FT-710 / TS-570 otherwise froze the TX freq on the wrong VFO.
if splitFlagged && freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB) {
// RX is the VFO being listened on — identified from the generic Freq rather
// than from the Vfo AB/BA enum, which several rigs (Yaesu FTDX10) report
// inverted, showing TX and RX swapped.
switch {
case freqMain != 0 && freqMain == freqA:
s.RxFreqHz, s.FreqHz = freqA, freqB // listening on A → TX on B
return freqB, freqA, true // listening on A → TX on B
case freqMain != 0 && freqMain == freqB:
s.RxFreqHz, s.FreqHz = freqB, freqA // listening on B → TX on A
case s.Vfo == "BA":
s.FreqHz, s.RxFreqHz = freqA, freqB // fall back to the Vfo enum
return freqA, freqB, true // listening on B → TX on A
case vfo == "BA":
return freqA, freqB, true // fall back to the Vfo enum
default:
s.FreqHz, s.RxFreqHz = freqB, freqA
}
} else {
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
// stock ini), so keying off FreqA gives the operator the VFO they expect.
s.Split = false
s.RxFreqHz = 0
switch {
case freqA != 0:
s.FreqHz = freqA
case freqMain != 0:
s.FreqHz = freqMain
default:
s.FreqHz = freqB
return freqB, freqA, true
}
}
return s, nil
// Simplex. The VFO the rig says is ACTIVE comes first: preferring freqA
// unconditionally (as this did) meant the displayed frequency never left VFO
// A — press SUB VFO on an FTDX101D and the radio receives on B while OpsLog
// went on showing A, taking the band and the logged frequency with it.
//
// Only a VFO OmniRig explicitly names is honoured, so a rig that does not
// report the enum keeps exactly the previous fallback order. That matters for
// the IC-7610, whose stock ini reports the generic Freq as VFO B; and for the
// PM_FREQA rigs (Yaesu, Kenwood) versus the Icoms (IC-9100) that populate only
// the generic Freq.
switch {
case (vfo == "B" || vfo == "BB") && freqB != 0:
return freqB, 0, false
case (vfo == "A" || vfo == "AA") && freqA != 0:
return freqA, 0, false
case freqA != 0:
return freqA, 0, false
case freqMain != 0:
return freqMain, 0, false
default:
return freqB, 0, false
}
}
func (o *OmniRig) SetFrequency(hz int64) error {
+83
View File
@@ -0,0 +1,83 @@
package cat
import (
"fmt"
"syscall"
"unsafe"
ole "github.com/go-ole/go-ole"
"golang.org/x/sys/windows/registry"
)
// Last-resort activation path for OmniRig, used only when the normal
// CreateObject fails.
//
// OmniRig is a 32-bit program and its installer splits its COM identity across
// registry views — on a working machine the ProgID sits in the 64-bit view while
// the CLSID and its LocalServer32 exist only under WOW6432Node. That LOOKS like
// it should break a 64-bit client, and it was my first theory for "OmniRig not
// found"; measuring it on a machine with exactly that layout disproved it. COM
// resolves an out-of-process server across views by itself, and the plain
// CreateObject succeeds.
//
// What this still covers is the case where the ProgID is not visible to us at all
// (a partial or 32-bit-only registration), where CreateObject has nothing to
// resolve. Here we read the CLSID from BOTH views ourselves and activate the
// 32-bit local server explicitly. CLSCTX_ACTIVATE_32_BIT_SERVER is the documented
// flag for that; go-ole hard-codes CLSCTX_SERVER and keeps CoCreateInstance
// unexported, hence the direct call.
//
// It costs nothing when the normal path works, and the reason it ran at all is
// logged — so if it ever rescues a real installation we will see it.
const clsctxActivate32BitServer = 0x40000
var (
modole32 = syscall.NewLazyDLL("ole32.dll")
procCoCreateInst32 = modole32.NewProc("CoCreateInstance")
)
// omnirigCLSIDFromRegistry reads OmniRig's CLSID from the ProgID key, looking in
// the 64-bit view first and then the 32-bit one. Returned as a GUID ready for
// CoCreateInstance.
func omnirigCLSIDFromRegistry(progID string) (*ole.GUID, error) {
for _, access := range []uint32{registry.QUERY_VALUE, registry.QUERY_VALUE | registry.WOW64_32KEY} {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Classes\`+progID+`\CLSID`, access)
if err != nil {
continue
}
s, _, err := k.GetStringValue("")
k.Close()
if err != nil || s == "" {
continue
}
if g := ole.NewGUID(s); g != nil {
return g, nil
}
}
return nil, fmt.Errorf("no CLSID registered for %s in either registry view", progID)
}
// createOmniRig32 activates OmniRig's 32-bit out-of-process server explicitly,
// bypassing the 64-bit registry lookup that CoCreateInstance would otherwise do.
func createOmniRig32(progID string) (*ole.IDispatch, error) {
clsid, err := omnirigCLSIDFromRegistry(progID)
if err != nil {
return nil, err
}
var unk *ole.IUnknown
hr, _, _ := procCoCreateInst32.Call(
uintptr(unsafe.Pointer(clsid)),
0, // no aggregation
uintptr(ole.CLSCTX_LOCAL_SERVER|clsctxActivate32BitServer),
uintptr(unsafe.Pointer(ole.IID_IUnknown)),
uintptr(unsafe.Pointer(&unk)))
if hr != 0 {
return nil, fmt.Errorf("CoCreateInstance(32-bit local server) failed: %w", ole.NewError(hr))
}
disp, err := unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return nil, fmt.Errorf("query IDispatch: %w", err)
}
return disp, nil
}
+73
View File
@@ -0,0 +1,73 @@
package cat
import (
"errors"
"testing"
)
// The COM refusal when OmniRig runs elevated and OpsLog does not (or the reverse)
// must be recognised and named. It reached a user as "OmniRig not found" while
// OmniRig sat visibly on screen with its settings window open — so the report was
// a driver hunt that could never succeed.
//
// Windows returns this text localised, so the match cannot rely on English alone;
// the HRESULT (0x800702E4 = ERROR_ELEVATION_REQUIRED) is accepted too.
func TestElevationHint(t *testing.T) {
recognised := []string{
"Lopération demandée nécessite une élévation.", // as logged, fr-FR
"The requested operation requires elevation.",
"Access denied",
"Accès refusé",
"CoCreateInstance failed: 0x800702E4",
}
for _, msg := range recognised {
if elevationHint(errors.New(msg)) == "" {
t.Errorf("not recognised as a privilege problem: %q", msg)
}
}
// Unrelated failures must NOT be blamed on elevation, or the advice sends the
// operator to the wrong place just as surely.
for _, msg := range []string{
"Classe non enregistrée",
"REGDB_E_CLASSNOTREG",
"no CLSID registered for Omnirig.OmnirigX in either registry view",
"open COM3: Access is den", // truncated word must not match "access denied"
} {
if h := elevationHint(errors.New(msg)); h != "" {
t.Errorf("%q wrongly reported as a privilege problem: %s", msg, h)
}
}
if elevationHint(nil) != "" {
t.Error("nil error must yield no hint")
}
}
// The reconnect loop runs every 5 s forever; a persistent failure must be logged
// once, not 1500 times an hour.
func TestLogConnFailureOnlyOncePerCause(t *testing.T) {
var lines []string
prev := LogSink
LogSink = func(format string, args ...any) { lines = append(lines, format) }
defer func() { LogSink = prev }()
o := &OmniRig{RigNum: 1}
for i := 0; i < 20; i++ {
o.logConnFailure("requires elevation")
}
if len(lines) != 1 {
t.Errorf("logged %d times, want 1", len(lines))
}
// A DIFFERENT cause must still be reported.
o.logConnFailure("class not registered")
if len(lines) != 2 {
t.Errorf("a new cause was not logged: %d lines", len(lines))
}
// And after reconnecting, the same cause may be reported again.
o.connLogged = ""
o.logConnFailure("requires elevation")
if len(lines) != 3 {
t.Errorf("after a reconnect the cause should log again: %d lines", len(lines))
}
}
+56
View File
@@ -0,0 +1,56 @@
package cat
import "testing"
// Every known rig's OmniRig behaviour, pinned. These rules genuinely contradict
// each other between models, so a change that fixes one rig must be shown not to
// break another — that is what this table is for.
func TestResolveOmniRigVFOs(t *testing.T) {
const (
a14200 = 14200000
b14205 = 14205000
b21000 = 21000000
)
cases := []struct {
name string
main, fa, fb int64
vfo string
split int64
wantTX, wantRX int64
wantSplit bool
}{
// The reported failure: FTDX101D, SUB VFO pressed. OmniRig names VFO B and
// reports it as the generic Freq; freqA still holds the main VFO. Preferring
// freqA meant the display never followed the operator to B.
{"FTDX101D on SUB VFO", b14205, a14200, b14205, "B", pmSplitOff, b14205, 0, false},
{"FTDX101D on MAIN VFO", a14200, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
// Non-regression: a rig that does not report the VFO enum keeps the old
// order — freqA, then the generic Freq, then freqB.
{"no VFO enum, freqA populated (Yaesu/Kenwood)", a14200, a14200, 0, "", pmSplitOff, a14200, 0, false},
{"no VFO enum, only generic Freq (IC-9100)", a14200, 0, 0, "", pmSplitOff, a14200, 0, false},
{"IC-7610: generic Freq reports B, enum says A", b14205, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
// Split: PM_SPLITON must be read as a BIT. An exact == 0x8000 reported "no
// split" for any rig that sets the flag alongside another bit.
{"split, ON flag alone", a14200, a14200, b14205, "AB", pmSplitOn, b14205, a14200, true},
{"split, ON flag with extra bits set", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, b14205, a14200, true},
{"listening on B → TX on A", b14205, a14200, b14205, "BA", pmSplitOn, a14200, b14205, true},
// Split must NOT be inferred when the rig says OFF, nor from a stale VFO B
// left on another band (the FT-710 / TS-570 false positive).
{"OFF flag, two distinct VFOs", a14200, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
{"ON flag but VFOs on different bands", a14200, a14200, b21000, "AB", pmSplitOn, a14200, 0, false},
{"ON flag but both VFOs identical", a14200, a14200, a14200, "AB", pmSplitOn, a14200, 0, false},
{"ON and OFF both set — ambiguous, treat as no split", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, a14200, 0, false},
}
for _, c := range cases {
tx, rx, split := resolveOmniRigVFOs(c.main, c.fa, c.fb, c.vfo, c.split)
if tx != c.wantTX || rx != c.wantRX || split != c.wantSplit {
t.Errorf("%s:\n got TX=%d RX=%d split=%v\n want TX=%d RX=%d split=%v",
c.name, tx, rx, split, c.wantTX, c.wantRX, c.wantSplit)
}
}
}