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.5–3 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 "" }