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
+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 {