621 lines
24 KiB
Go
621 lines
24 KiB
Go
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}())
|
|
}
|
|
|
|
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(freqMain, freqA, freqB, s.Vfo, splitRaw)
|
|
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(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:
|
|
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.
|
|
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.
|
|
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 ""
|
|
}
|