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 // 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 } // 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" } func (o *OmniRig) Connect() error { debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath()) 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) } } 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) } 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() if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil { debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString()) } 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 } // Diagnostic logged ONLY when Split or VFO changes (not on a timer), so // normal operation stays quiet but toggling split on the radio is captured — // needed to pin down this rig's PM_SPLITON value. if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig { o.lastSig = sig debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d", o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 { if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil { return v.Val } return -1 }()) } // 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) if genuineSplit { // OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B). // ADIF: FreqHz = TX, RxFreqHz = RX. s.Split = true switch s.Vfo { case "BA": s.FreqHz, s.RxFreqHz = freqA, freqB // TX A, RX B default: // "AB" and the usual "TX on the other VFO" case s.FreqHz, s.RxFreqHz = freqB, freqA // TX B, RX A } } else { // Simplex: the operating frequency is OmniRig's generic Freq (the active // VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0. s.Split = false s.RxFreqHz = 0 s.FreqHz = freqMain if s.FreqHz == 0 { if s.Vfo == "B" || s.Vfo == "BB" { s.FreqHz = freqB } else { s.FreqHz = freqA } } } return s, nil } 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. if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil { debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32) } else { debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v) — using property writes", err) // Fallback: write the active VFO's property AND the generic Freq // (always — some .ini honour only one, and split here is often misread). 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 !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) } 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. 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 "" }