package cat import ( "fmt" "strings" "github.com/go-ole/go-ole" "github.com/go-ole/go-ole/oleutil" ) // 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 } // 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) } if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil { s.Vfo = omniRigVfo(vfoVar.Val) } // Read both VFO frequencies separately so we can expose split TX/RX. // Fall back to generic Freq if the rig only exposes the merged property. freqA, freqB := int64(0), int64(0) 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 detection: trust the explicit Split property when it's set, // BUT only call it a real split if both VFO frequencies are non-zero // and distinct. Bridges like SmartSDR-OmniRig report Split=ON by // default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's // slice model doesn't map to VFO A/B — that would yield a useless // permanent SPLIT badge. if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 { s.Split = true } if s.Split && (freqB == 0 || freqA == freqB) { s.Split = false s.RxFreqHz = 0 } // OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B). // We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split). switch s.Vfo { case "AB": s.FreqHz = freqB // TX s.RxFreqHz = freqA // RX case "BA": s.FreqHz = freqA // TX s.RxFreqHz = freqB // RX case "B", "BB": s.FreqHz = freqB default: // "A", "AA", "" — single VFO on A or unknown s.FreqHz = freqA } if s.FreqHz == 0 { // Last resort — some rigs only update generic Freq. if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil { s.FreqHz = v.Val } } return s, nil } func (o *OmniRig) SetFrequency(hz int64) error { if o.rig == nil { return fmt.Errorf("not connected") } // OmniRig Freq is a Long (int32). Validate to avoid silent truncation. if hz < 0 || hz > 0x7fffffff { return fmt.Errorf("frequency out of OmniRig int32 range") } hz32 := int32(hz) // Pick the right OmniRig property. Many rig .ini files only define a // WRITE command for FreqA/FreqB but not the generic Freq — in which case // PutProperty(Freq) silently succeeds but the rig never moves. Write to // the active VFO's specific property when we know it; fall back to Freq. prop := "FreqA" if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil { switch omniRigVfo(vfoVar.Val) { case "B", "BB", "BA": prop = "FreqB" case "A", "AA", "AB": prop = "FreqA" } } debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz) → %s", hz, float64(hz)/1e6, prop) if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil { debugLog.Printf("OmniRig.SetFrequency(%s) error: %v — falling back to Freq", prop, err) if _, err2 := oleutil.PutProperty(o.rig, "Freq", hz32); err2 != nil { debugLog.Printf("OmniRig.SetFrequency(Freq) also failed: %v", err2) return err2 } } // Read back the active VFO freq after a short delay so the log shows // whether the rig actually moved. Useful when the .ini accepts the write // silently but the rig doesn't honour it (wrong WRITE command etc.). if fv, err := oleutil.GetProperty(o.rig, "Freq"); err == nil { debugLog.Printf("OmniRig.Freq immediately after Put = %d Hz", fv.Val) } 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": // Read current freq to decide USB vs LSB. var freq int64 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 } // ===== 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 ( 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 "" } func omniRigVfo(v int64) string { switch { case v&1024 != 0: return "A" case v&2048 != 0: return "B" case v&64 != 0: return "AA" case v&128 != 0: return "AB" case v&256 != 0: return "BA" case v&512 != 0: return "BB" } return "" }