Files
OpsLog/internal/cat/omnirig.go
T
rouggy aefb984974 feat: follow the SUB VFO over OmniRig; distance column in the QSO grids
OmniRig reports the VFO pair (AA/AB/BA/BB) on the whole Yaesu range and never
the single-letter form. Only the latter was honoured, so every rig in that
family stayed pinned to VFO A: pressing SUB moved the radio but not OpsLog,
and a QSO worked on SUB was logged on the main VFO's frequency. The first
letter of the pair is the VFO being listened on — Log4OM reads it and gets the
right frequency on the same rigs, which is what showed the data was there.
The enum now wins over the Yaesu Freq==FreqB inference, which is only a
fallback for a rig file that names no VFO at all (the stock FTDX10 one answers
neither VS; nor FR; usably — verified on the air).

Split: the ON flag is still latched to survive a rig file that flips it on its
own, but the latch is now ARMED only after 8 flips in 30 s. A first cut at
3-in-15s was armed by the operator toggling split while testing, imposing the
6 s clearing delay on a radio that did not need it; a misreading file flips a
dozen times in that window untouched, so the two cases separate cleanly.

Distance (km) column added to Recent QSOs and Worked before (shared catalog).
Computed from the QSO's OWN my_grid/my_lat/lon first, falling back to the
current profile's locator: a log spans years and portable outings, so the
station a QSO was made from is not necessarily today's.

Locator: a precise QRZ/HamQTH grid is no longer overwritten by the cty.dat
entity centroid. The lookup runs several times per QSO and the provider gets
2 s; a slow second answer fell back to cty.dat and downgraded JN05JG to JN16
while name and QTH survived (they are only written when non-empty).

The OmniRig diagnostic line now logs what OpsLog concluded, not just what
OmniRig reported. icomnet.go: gofmt alignment only.
2026-07-26 23:36:56 +02:00

707 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cat
import (
"fmt"
"strings"
"time"
"github.com/go-ole/go-ole"
"github.com/go-ole/go-ole/oleutil"
)
// OmniRig Split is an enum, not a boolean: PM_SPLITON vs PM_SPLITOFF — both
// non-zero, so it must be compared to PM_SPLITON (testing "!= 0" reads OFF as
// split). Values confirmed empirically from real rigs (FT-710, SmartSDR):
// split ON = 0x8000, split OFF = 0x10000.
const (
pmSplitOn = 0x8000 // PM_SPLITON
pmSplitOff = 0x10000 // PM_SPLITOFF
)
// OmniRig talks to the user's installed OmniRig server over COM.
//
// All methods MUST be called from the same OS thread (the one Manager.run
// locks). COM is thread-affine on Windows — calling these from random
// goroutines will return E_FAIL or crash.
//
// The user must install OmniRig separately and configure their rig (COM port,
// baud rate) in OmniRig's own GUI. HamLog just reads/writes through it.
type OmniRig struct {
RigNum int // 1 (Rig1) or 2 (Rig2)
omnirig *ole.IDispatch
rig *ole.IDispatch
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
// after a QSY — that lag is why a clicked spot needed a second click to fix
// the sideband (freq moved, but mode read the old band → wrong sideband).
lastSetFreq int64
lastSetFreqAt time.Time
// lastSplitOnAt is when OmniRig last reported PM_SPLITON cleanly. See the
// FTDX101D note in ReadState — some .ini files alternate between ON and OFF
// on consecutive polls, so the flag has to be latched to be usable.
lastSplitOnAt time.Time
// splitFlaky records that THIS rig's .ini flips the split flag on its own,
// which is what arms the latch. lastSplitFlag / splitFlips / splitFlipWindow
// count the flips inside a rolling window to detect it.
lastSplitFlag bool
splitFlaky bool
splitFlips int
splitFlipWindow time.Time
}
// NewOmniRig creates a non-connected backend. Call Connect before use.
func NewOmniRig(rigNum int) *OmniRig {
if rigNum < 1 || rigNum > 2 {
rigNum = 1
}
return &OmniRig{RigNum: rigNum}
}
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 {
// 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 {
return fmt.Errorf("CoInitializeEx: %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 {
omnirig.Release()
return fmt.Errorf("get Rig%d: %w", o.RigNum, err)
}
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: Rig%d type=%q (OmniRig interface=%d software=%d)",
o.RigNum, o.rigType, iv, sv)
return nil
}
func (o *OmniRig) Disconnect() {
if o.rig != nil {
o.rig.Release()
o.rig = nil
}
if o.omnirig != nil {
o.omnirig.Release()
o.omnirig = nil
}
ole.CoUninitialize()
}
func (o *OmniRig) ReadState() (RigState, error) {
if o.rig == nil {
return RigState{}, fmt.Errorf("not connected")
}
var s RigState
s.Backend = o.Name()
s.RigNum = o.RigNum
// Status: 0 = NOTCONFIGURED, 1 = DISABLED, 2 = PORTBUSY,
// 3 = NOTRESPONDING, 4 = ONLINE.
if statusVar, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
s.Connected = statusVar.Val == 4
}
if rigTypeVar, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
s.Rig = rigTypeVar.ToString()
}
if !s.Connected {
// Status string from OmniRig is informative for the user.
if statusStrVar, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
s.Error = statusStrVar.ToString()
}
return s, nil
}
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
s.Mode = omniRigMode(modeVar.Val)
}
rawVfo := int64(0)
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
rawVfo = vfoVar.Val
s.Vfo = omniRigVfo(vfoVar.Val)
}
// Read the active/displayed frequency (generic Freq) AND both VFOs. The
// generic Freq is what the rig is operating on — the reliable source for the
// main/TX frequency. FreqA/FreqB are only needed to expose a genuine split.
freqMain, freqA, freqB := int64(0), int64(0), int64(0)
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freqMain = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
freqA = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
freqB = v.Val
}
// Split is an enum (PM_SPLITON / PM_SPLITOFF) — both non-zero, so it must be
// compared to PM_SPLITON, not "!= 0".
splitRaw := int64(0)
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
splitRaw = v.Val
}
// FTDX101D field capture: OmniRig alternates between two contradictory
// readings on consecutive polls — "Vfo=AB Split=0x10000(OFF)" then
// "Vfo=BA Split=0x8000(ON)", ~1.5 s apart, with the rig untouched. The stock
// .ini evidently has two status commands that each write these params. A
// sample-by-sample test therefore reports split for half the polls and no
// split for the other half, which the UI shows as no split at all. Latch the
// ON flag briefly so one truthful sample survives the contradicting one; the
// latch expires on its own once the rig stops reporting ON, so cancelling
// split on the radio still clears within a few seconds.
//
// The latch is ARMED ONLY for a rig that actually oscillates, because it costs
// ~6 s before split clears on screen. A correct .ini (FTDX10 with VS; and FT;
// read as separate frames, confirmed on the air 2026-07-26) never flips
// unprompted, and there the latch would be a pure delay on a reading that was
// already right.
//
// 8 flips in 30 s: a first cut at 3-in-15s was armed by the OPERATOR toggling
// split three times while testing, which then imposed the 6 s delay on a rig
// that did not need it. A misreading .ini flips every 1.53 s without being
// touched — a dozen in the same window — so the gap is wide. The arming also
// expires after 30 s without a flip, so a rig that behaves is never stuck with
// the delay because of one burst.
const flipWindow, flipsToArm = 30 * time.Second, 8
now := time.Now()
flagOn := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
if flagOn {
o.lastSplitOnAt = now
}
if flagOn != o.lastSplitFlag {
o.lastSplitFlag = flagOn
if now.Sub(o.splitFlipWindow) > flipWindow {
o.splitFlipWindow, o.splitFlips, o.splitFlaky = now, 0, false
}
o.splitFlips++
if o.splitFlips >= flipsToArm {
o.splitFlaky = true
}
} else if o.splitFlaky && now.Sub(o.splitFlipWindow) > flipWindow {
o.splitFlaky, o.splitFlips = false, 0 // stopped oscillating — drop the delay
}
splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, s.Vfo, splitRaw, splitRecentOn)
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal
// operation stays quiet but toggling split or SUB VFO on the radio is
// captured. It logs the RESOLVED tx/rx/split too: with the raw values alone a
// user's log showed what OmniRig said but not what OpsLog concluded, which is
// the half that was wrong.
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
o.lastSig = sig
debugLog.Printf("OmniRig Rig%d raw: rig=%q Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X sticky=%v → tx=%d rx=%d split=%v",
o.RigNum, o.rigType, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, splitRecentOn,
s.FreqHz, s.RxFreqHz, s.Split)
}
return s, nil
}
// 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(rigType string, freqMain, freqA, freqB int64, vfo string, splitRaw int64, splitRecentOn bool) (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) || splitRecentOn
// 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:
return freqB, freqA, true // listening on A → TX on B
case freqMain != 0 && freqMain == freqB:
return freqA, freqB, true // listening on B → TX on A
case vfo == "BA":
return freqA, freqB, true // fall back to the Vfo enum
default:
return freqB, freqA, true
}
}
// 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.
// The PAIR enums name BOTH VFOs at once — first letter = the one being
// listened on, second = the one that transmits. Only the single-letter forms
// were honoured here, so a rig that reports nothing but pairs (the whole Yaesu
// family) always fell through to freqA and never followed the operator to SUB.
// Log4OM reads that first letter and logs the right frequency on the same
// rigs, which is what showed this was readable data and not a dead end.
//
// Checked BEFORE the Yaesu fallback below: an enum that names a VFO is the
// rig speaking, the fallback is only an inference.
switch {
case (vfo == "BA" || vfo == "BB") && freqB != 0:
return freqB, 0, false
case (vfo == "AA" || vfo == "AB") && freqA != 0:
return freqA, 0, false
}
// Yaesu fallback, for a rig file that names no VFO at all (the stock FTDX10
// one: it answers neither VS; nor FR; usably, verified on the air 2026-07-26).
// There the generic Freq is the last clue — matching FreqB and not FreqA means
// the operator is on SUB. Limited to Yaesu: the IC-7610's stock ini reports
// the generic Freq as VFO B permanently, where this would name the wrong VFO —
// that case is pinned in the test table.
if isYaesuRig(rigType) && freqMain != 0 && freqMain == freqB && freqB != freqA {
return freqB, 0, false
}
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 {
if o.rig == nil {
debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz)
return fmt.Errorf("not connected")
}
// OmniRig Freq is a Long (int32). Validate to avoid silent truncation.
if hz < 0 || hz > 0x7fffffff {
debugLog.Printf("OmniRig.SetFrequency(%d): out of int32 range", hz)
return fmt.Errorf("frequency out of OmniRig int32 range")
}
hz32 := int32(hz)
// Remember the commanded frequency so a mode change moments later (a clicked
// spot sets freq then mode) picks the sideband from the TARGET band, not the
// not-yet-updated OmniRig Freq property.
o.lastSetFreq, o.lastSetFreqAt = hz, time.Now()
// Log the rig's writable-params, status and VFO state up front so a
// friend's session shows exactly what OmniRig reports for their rig.
status, statusStr, rigType := int64(-1), "", ""
if v, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
status = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
statusStr = v.ToString()
}
if v, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
rigType = v.ToString()
}
rawVfo, vfo := int64(-1), ""
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
rawVfo = vfoVar.Val
vfo = omniRigVfo(vfoVar.Val)
} else {
debugLog.Printf("OmniRig.SetFrequency: Vfo read error: %v", err)
}
split := int64(0)
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
split = v.Val
}
// What can this rig's .ini actually write? OmniRig exposes a WriteableParams
// bitmask — if FreqA/FreqB/Freq bits are missing, the write is a silent no-op.
writeable := int64(-1)
if v, err := oleutil.GetProperty(o.rig, "WriteableParams"); err == nil {
writeable = v.Val
}
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz): rig=%q status=%d(%s) vfo=%q(raw=%d) split=%d writeableParams=0x%X",
hz, float64(hz)/1e6, rigType, status, statusStr, vfo, rawVfo, split, writeable)
// Primary path: OmniRig's SetSimplexMode is the rig-agnostic "QSY here"
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
// where direct FreqA/FreqB writes are accepted but never move the radio.
// Clearing split is the right thing when tuning to a spot anyway.
simplexOK := false
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
simplexOK = true
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
} else {
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
}
// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
// OK but is a SILENT NO-OP — the rig never moves. Confirmed on an FT-891 over
// OmniRig: every spot click logged "SetSimplexMode OK" yet FreqA stayed put,
// while SetMode on the same .ini worked fine (so the CAT link is healthy).
// Writing the VFO frequency PROPERTY directly DOES move them, so also do that
// here. It is skipped on Icom, where the direct write is the unreliable one and
// could nudge the wrong Main/Sub VFO — there SetSimplexMode is authoritative.
isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
if !isIcom || !simplexOK {
prop := "FreqA"
switch vfo {
case "B", "BB", "BA":
prop = "FreqB"
}
okAny := false
for _, p := range []string{prop, "Freq"} {
if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
} else {
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s, %d) OK", p, hz32)
okAny = true
}
}
if !simplexOK && !okAny {
return fmt.Errorf("OmniRig: no writable frequency property for this rig")
}
}
// Read back all three immediately. OmniRig is async (the CAT command is
// queued + sent over serial), so these may still show the OLD value for
// one poll cycle — but if they NEVER change in the next poll, the rig
// isn't honouring the write (wrong .ini WRITE command for this model).
fa, fb, fg := int64(-1), int64(-1), int64(-1)
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
fa = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
fb = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
fg = v.Val
}
debugLog.Printf("OmniRig.SetFrequency: readback FreqA=%d FreqB=%d Freq=%d (target %d)", fa, fb, fg, hz)
return nil
}
// SetMode maps an ADIF mode to the OmniRig PM_* bit and pushes it to the rig.
// For SSB, the USB/LSB side is chosen from the rig's current frequency
// following worldwide convention (LSB below 14 MHz, USB above).
//
// IMPORTANT: OmniRig's Mode property is typed as Long (VT_I4). go-ole would
// otherwise wrap a Go int64 into a VT_I8 variant which COM marshalling can
// reject silently or misinterpret — passing the wrong bit. Always cast to
// int32 explicitly.
//
// Logs each call to stdout so the user can cross-check what HamLog sent
// against OmniRig's Monitor window (right-click systray → Monitor) to find
// rig-specific mismatches (e.g. a Kenwood without FM on HF, an .ini with the
// wrong CAT command for a mode, etc.).
func (o *OmniRig) SetMode(mode string) error {
if o.rig == nil {
return fmt.Errorf("not connected")
}
var (
bit int64
bitName string
)
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
bit, bitName = pmCWU, "PM_CW_U"
case "SSB":
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
// property still reports the OLD band for a poll or two after a QSY, so
// reading it here picked the wrong sideband and the user had to click a
// second time. Fall back to the live read for a standalone mode change.
var freq int64
if o.lastSetFreq > 0 && time.Since(o.lastSetFreqAt) < 5*time.Second {
freq = o.lastSetFreq
} else if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freq = freqVar.Val
}
if freq > 0 && freq < 10_000_000 {
bit, bitName = pmSSBL, "PM_SSB_L"
} else {
bit, bitName = pmSSBU, "PM_SSB_U"
}
case "AM":
bit, bitName = pmAM, "PM_AM"
case "FM":
bit, bitName = pmFM, "PM_FM"
case "RTTY", "FSK":
// OmniRig has no specific RTTY/FSK mode — falls back to generic
// digital USB. Many rigs need RTTY selected manually on the panel.
bit, bitName = pmDIGU, "PM_DIG_U"
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DIGITALVOICE", "DATA":
bit, bitName = pmDIGU, "PM_DIG_U"
default:
return fmt.Errorf("OmniRig: unsupported mode %q", mode)
}
debugLog.Printf("OmniRig.SetMode(%q) → %s = 0x%08X (%d)", mode, bitName, bit, bit)
_, err := oleutil.PutProperty(o.rig, "Mode", int32(bit))
if err != nil {
debugLog.Printf("OmniRig.SetMode error: %v", err)
return fmt.Errorf("SetMode(%s) → %s: %w", mode, bitName, err)
}
// Read back what OmniRig now thinks the rig is on (best-effort —
// OmniRig is async so this may still be the old value for one poll).
if mv, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
debugLog.Printf("OmniRig.Mode immediately after Put = 0x%08X (%d) → %s",
mv.Val, mv.Val, omniRigMode(mv.Val))
}
return nil
}
// SetPTT keys or unkeys the rig via OmniRig's SetTx(PM_RX|PM_TX). Used by the
// Digital Voice Keyer to put the rig into TX while a voice message plays.
func (o *OmniRig) SetPTT(on bool) error {
if o.rig == nil {
debugLog.Printf("OmniRig.SetPTT(%v): NOT CONNECTED", on)
return fmt.Errorf("not connected")
}
status, statusStr, writeable := int64(-1), "", int64(-1)
if v, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
status = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
statusStr = v.ToString()
}
if v, err := oleutil.GetProperty(o.rig, "WriteableParams"); err == nil {
writeable = v.Val
}
txWriteable := writeable != -1 && writeable&pmTX != 0
param, name := pmRX, "PM_RX"
if on {
param, name = pmTX, "PM_TX"
}
debugLog.Printf("OmniRig.SetPTT(%v): status=%d(%s) writeableParams=0x%X PM_TX-writeable=%v → Tx=%s",
on, status, statusStr, writeable, txWriteable, name)
// When OmniRig DID report its writeable params (writeable != -1) and PM_TX
// is NOT among them, writing Tx is a silent no-op: the rig never keys and
// SetPTT would otherwise return success, leaving the user puzzled ("Test PTT
// does nothing"). Surface a clear, actionable error instead. If we couldn't
// read the writeable params (-1), fall through and try anyway (best effort).
if on && writeable != -1 && writeable&pmTX == 0 {
debugLog.Printf("OmniRig.SetPTT: ⚠ PM_TX not writeable for this rig profile (writeableParams=0x%X)", writeable)
return fmt.Errorf("this rig's OmniRig profile doesn't expose CAT TX keying (PM_TX not writeable) — use RTS/DTR or VOX for PTT")
}
// OmniRig has NO SetTx method (that returns "unknown name"); the Tx
// parameter is set via the writeable Tx PROPERTY (PM_TX / PM_RX).
if _, err := oleutil.PutProperty(o.rig, "Tx", int32(param)); err != nil {
debugLog.Printf("OmniRig.SetPTT error: %v", err)
return fmt.Errorf("set Tx=%s: %w", name, err)
}
// Read the Tx param straight back. OmniRig is async — this may still show the
// previous value for a poll cycle — but if a key/unkey NEVER changes it, the
// write was coalesced or the rig isn't honouring PM_TX/PM_RX (wrong .ini).
if v, err := oleutil.GetProperty(o.rig, "Tx"); err == nil {
txState := "PM_RX"
if v.Val&pmTX != 0 {
txState = "PM_TX"
}
debugLog.Printf("OmniRig.SetPTT: Tx readback = 0x%X (%s)", v.Val, txState)
}
return nil
}
// ===== OmniRig enum decoders =====
// Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas).
//
// Cross-checked against https://github.com/VE3NEA/OmniRig — be careful when
// referencing other people's writeups online, several have these one bit
// too low which causes every mode to map to the slot below it (AM → DIG_L,
// FT8 → SSB_L, etc.).
const (
pmRX int64 = 1 << 20 // 0x00100000 — PM_RX (receive)
pmTX int64 = 1 << 21 // 0x00200000 — PM_TX (transmit / PTT on)
pmCWU int64 = 1 << 23 // 0x00800000
pmCWL int64 = 1 << 24 // 0x01000000
pmSSBU int64 = 1 << 25 // 0x02000000
pmSSBL int64 = 1 << 26 // 0x04000000
pmDIGU int64 = 1 << 27 // 0x08000000
pmDIGL int64 = 1 << 28 // 0x10000000
pmAM int64 = 1 << 29 // 0x20000000
pmFM int64 = 1 << 30 // 0x40000000 — still fits in int32 (max 2^31-1)
)
// omniRigMode maps the OmniRig Mode bit-flag to an ADIF mode string.
// OmniRig only reports rough categories; specific digital modes
// (FT8, RTTY, PSK31…) can't be inferred — DATA is returned and the user
// can keep / override the mode they already had in the entry form.
func omniRigMode(m int64) string {
switch {
case m&(pmCWU|pmCWL) != 0:
return "CW"
case m&(pmSSBU|pmSSBL) != 0:
return "SSB"
case m&(pmDIGU|pmDIGL) != 0:
return "DATA"
case m&pmAM != 0:
return "AM"
case m&pmFM != 0:
return "FM"
}
return ""
}
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
// documented PM_VFO* constants.
// isYaesuRig recognises a Yaesu from OmniRig's RigType (the .ini title, e.g.
// "FTDX101D", "FT-891"). Only used to gate rules that are true for Yaesu and
// false for Icom, so a mis-titled ini simply keeps the generic behaviour.
func isYaesuRig(rigType string) bool {
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "FT")
}
func omniRigVfo(v int64) string {
switch {
case v&0x40 != 0: // PM_VFOAA
return "AA"
case v&0x80 != 0: // PM_VFOAB
return "AB"
case v&0x100 != 0: // PM_VFOBA
return "BA"
case v&0x200 != 0: // PM_VFOBB
return "BB"
case v&0x400 != 0: // PM_VFOA
return "A"
case v&0x800 != 0: // PM_VFOB
return "B"
}
return ""
}