chore: release v0.21.3
This commit is contained in:
@@ -649,15 +649,29 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
||||
const reconnectEvery = 5 * time.Second
|
||||
connected := false
|
||||
var lastAttempt time.Time
|
||||
var lastConnErr string // last connect failure logged, so the retry loop says it once
|
||||
tryConnect := func() {
|
||||
if connected || time.Since(lastAttempt) < reconnectEvery {
|
||||
return
|
||||
}
|
||||
lastAttempt = time.Now()
|
||||
if err := b.Connect(); err != nil {
|
||||
// Log it — the message used to live only in RigState.Error, i.e. in a
|
||||
// tooltip. The status pill condenses everything to "OmniRig not found",
|
||||
// so a user reporting that had no way to tell us WHY: the COM HRESULT,
|
||||
// the serial error, the refused TCP connect, all invisible. Logged once
|
||||
// per distinct message so the retry loop doesn't flood the file.
|
||||
if msg := err.Error(); msg != lastConnErr {
|
||||
lastConnErr = msg
|
||||
debugLog.Printf("%s connect failed: %s", b.Name(), msg)
|
||||
}
|
||||
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
|
||||
return
|
||||
}
|
||||
if lastConnErr != "" {
|
||||
debugLog.Printf("%s connected (after: %s)", b.Name(), lastConnErr)
|
||||
lastConnErr = ""
|
||||
}
|
||||
connected = true
|
||||
}
|
||||
tryConnect()
|
||||
|
||||
+44
-23
@@ -39,10 +39,10 @@ const (
|
||||
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
|
||||
CmdPower = 0x18 // power on/off (sub 0x01 = on, 0x00 = off; on needs an FE wake preamble)
|
||||
|
||||
CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub)
|
||||
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
|
||||
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
|
||||
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
|
||||
CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub)
|
||||
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
|
||||
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
|
||||
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
|
||||
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
||||
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
|
||||
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
||||
@@ -91,26 +91,26 @@ const (
|
||||
SubATU = 0x01 // antenna tuner (data 0x02 = start tune)
|
||||
|
||||
// CmdScope sub-commands.
|
||||
SubScopeData = 0x00 // waveform data frame (divided across several frames)
|
||||
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
|
||||
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
|
||||
SubScopeData = 0x00 // waveform data frame (divided across several frames)
|
||||
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
|
||||
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
|
||||
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed)
|
||||
SubScopeSpan = 0x15 // span in center mode (±span/2 as 5 LE-BCD)
|
||||
SubScopeEdge = 0x16 // fixed-mode ACTIVE edge set 1-4 (vfo + set#)
|
||||
SubScopeFixEdge = 0x1e // fixed-mode edge FREQUENCIES: [range][set#][low 5-BCD][high 5-BCD]
|
||||
|
||||
// CmdSwitch sub-commands.
|
||||
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
|
||||
SubSwNB = 0x22 // noise blanker on/off
|
||||
SubSwNR = 0x40 // noise reduction on/off
|
||||
SubSwANF = 0x41 // auto-notch on/off
|
||||
SubSwComp = 0x44 // speech compressor on/off
|
||||
SubSwMon = 0x45 // monitor on/off
|
||||
SubSwVOX = 0x46 // VOX on/off
|
||||
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
|
||||
SubSwNB = 0x22 // noise blanker on/off
|
||||
SubSwNR = 0x40 // noise reduction on/off
|
||||
SubSwANF = 0x41 // auto-notch on/off
|
||||
SubSwComp = 0x44 // speech compressor on/off
|
||||
SubSwMon = 0x45 // monitor on/off
|
||||
SubSwVOX = 0x46 // VOX on/off
|
||||
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
||||
SubSwMN = 0x48 // manual notch on/off
|
||||
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
||||
SubSwMN = 0x48 // manual notch on/off
|
||||
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
||||
)
|
||||
|
||||
// CW break-in modes (CmdSwitch 0x47).
|
||||
@@ -307,22 +307,43 @@ func ModeToADIF(m byte, data bool) string {
|
||||
|
||||
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
|
||||
// model. Unknown addresses fall back to a hex label.
|
||||
//
|
||||
// The name is not cosmetic: the UI derives model-dependent behaviour from it —
|
||||
// notably the attenuator steps, which are 6/12/18 dB on the big rigs and a single
|
||||
// 20 dB on the small ones. An address missing here therefore shows the WRONG
|
||||
// attenuator buttons, and the rig NAKs them.
|
||||
//
|
||||
// Two entries here used to be wrong in a way that pointed at each other: 0x80 was
|
||||
// labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the IC-7100), while
|
||||
// the real IC-7800 (0x6A) and IC-7700 (0x74) were absent — so a 7800 came up as
|
||||
// "Icom (0x6A)" with a 20 dB attenuator it does not have.
|
||||
//
|
||||
// Addresses cross-checked against the TR4W CI-V table and an independent
|
||||
// published list; both agree on every value below.
|
||||
func ModelName(addr byte) string {
|
||||
switch addr {
|
||||
case 0x6A:
|
||||
return "IC-7800"
|
||||
case 0x74:
|
||||
return "IC-7700"
|
||||
case 0x7A:
|
||||
return "IC-7600"
|
||||
case 0x7C:
|
||||
return "IC-9100"
|
||||
case 0x80:
|
||||
return "IC-7410"
|
||||
case 0x88:
|
||||
return "IC-7100"
|
||||
case 0x8E:
|
||||
return "IC-7851" // shared with the IC-7850
|
||||
case 0x94:
|
||||
return "IC-7300"
|
||||
case 0x98:
|
||||
return "IC-7610"
|
||||
case 0x7C:
|
||||
return "IC-9100"
|
||||
case 0xA2:
|
||||
return "IC-9700"
|
||||
case 0xA4:
|
||||
return "IC-705"
|
||||
case 0x88:
|
||||
return "IC-7700"
|
||||
case 0x80:
|
||||
return "IC-7800"
|
||||
}
|
||||
return fmt.Sprintf("Icom (0x%02X)", addr)
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ func TestScanSingleFreqResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanSkipsEchoAndKeepsPartial(t *testing.T) {
|
||||
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
|
||||
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
|
||||
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
|
||||
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
|
||||
buf := append(append([]byte{}, echo...), resp...)
|
||||
buf = append(buf, 0xFE, 0xFE, 0x98) // a partial third frame (no FD yet)
|
||||
|
||||
@@ -130,3 +130,35 @@ func TestModelName(t *testing.T) {
|
||||
t.Errorf("ModelName(0x12) = %q, want fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
// CI-V addresses are hardware constants: a wrong one means the console shows the
|
||||
// wrong model, and with it the wrong attenuator steps (6/12/18 dB on the big
|
||||
// rigs, a single 20 dB on the small ones) — buttons the rig then NAKs.
|
||||
//
|
||||
// Two entries here were previously wrong in a way that pointed at each other:
|
||||
// 0x80 was labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the
|
||||
// IC-7100), while the real IC-7800 (0x6A) and IC-7700 (0x74) were missing — so an
|
||||
// IC-7800 came up as "Icom (0x6A)" with a 20 dB attenuator it does not have.
|
||||
func TestModelNameAddresses(t *testing.T) {
|
||||
for addr, want := range map[byte]string{
|
||||
0x6A: "IC-7800",
|
||||
0x74: "IC-7700",
|
||||
0x7A: "IC-7600",
|
||||
0x7C: "IC-9100",
|
||||
0x80: "IC-7410",
|
||||
0x88: "IC-7100",
|
||||
0x8E: "IC-7851",
|
||||
0x94: "IC-7300",
|
||||
0x98: "IC-7610",
|
||||
0xA2: "IC-9700",
|
||||
0xA4: "IC-705",
|
||||
} {
|
||||
if got := ModelName(addr); got != want {
|
||||
t.Errorf("ModelName(0x%02X) = %q, want %q", addr, got, want)
|
||||
}
|
||||
}
|
||||
// An unknown address must stay identifiable rather than masquerade as a model.
|
||||
if got := ModelName(0x42); got != "Icom (0x42)" {
|
||||
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
+62
-18
@@ -79,23 +79,23 @@ type IcomSerial struct {
|
||||
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
|
||||
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
|
||||
// via ScopeData from the binding goroutine).
|
||||
dualScope bool
|
||||
scopeMu sync.Mutex
|
||||
scopeAmp []byte
|
||||
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
|
||||
scopeHigh int64 // spectrum right-edge frequency
|
||||
scopeSeq int
|
||||
scopeOn bool
|
||||
dualScope bool
|
||||
scopeMu sync.Mutex
|
||||
scopeAmp []byte
|
||||
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
|
||||
scopeHigh int64 // spectrum right-edge frequency
|
||||
scopeSeq int
|
||||
scopeOn bool
|
||||
scopeFixed bool // true = fixed-span mode (tracked optimistically)
|
||||
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
|
||||
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
|
||||
|
||||
curFreq int64 // last frequency read (for sideband choice)
|
||||
curModeByte byte // last raw Icom mode byte (for filter re-send)
|
||||
pollN int // ReadState cycle counter (staggers slow reads)
|
||||
splitOn bool // last read split state (refreshed every few cycles)
|
||||
splitTXFreq int64 // last read unselected/TX VFO freq while in split
|
||||
readFails int // consecutive ReadState freq-read failures (transient tolerance)
|
||||
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||
curFreq int64 // last frequency read (for sideband choice)
|
||||
curModeByte byte // last raw Icom mode byte (for filter re-send)
|
||||
pollN int // ReadState cycle counter (staggers slow reads)
|
||||
splitOn bool // last read split state (refreshed every few cycles)
|
||||
splitTXFreq int64 // last read unselected/TX VFO freq while in split
|
||||
readFails int // consecutive ReadState freq-read failures (transient tolerance)
|
||||
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||
// the panel's set-once controls once the rig actually answers)
|
||||
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||
lastSetFreqAt time.Time
|
||||
@@ -341,7 +341,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
}
|
||||
if b.splitOn && b.splitTXFreq > 0 && b.splitTXFreq != s.FreqHz {
|
||||
s.Split = true
|
||||
s.RxFreqHz = s.FreqHz // selected VFO = RX
|
||||
s.RxFreqHz = s.FreqHz // selected VFO = RX
|
||||
s.FreqHz = b.splitTXFreq // unselected VFO = TX
|
||||
}
|
||||
|
||||
@@ -374,10 +374,54 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
if !b.dspLoaded {
|
||||
b.readDSP()
|
||||
b.dspLoaded = true
|
||||
} else {
|
||||
b.refreshFrontPanel()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// refreshFrontPanel re-reads the few controls an operator actually reaches for on
|
||||
// the rig itself, so the console follows the radio instead of only driving it.
|
||||
//
|
||||
// readDSP loads everything but runs ONCE per connection (dspLoaded), which left
|
||||
// the panel showing whatever was set at connect: switch AGC from FAST to MID on
|
||||
// the front panel and OpsLog still said FAST, indefinitely. Commands worked, so
|
||||
// the link was plainly fine — only this direction was missing.
|
||||
//
|
||||
// ONE read per poll cycle, in rotation. The full snapshot is ~30 CI-V round trips
|
||||
// and refreshing it wholesale would hog the CAT thread for seconds at a time —
|
||||
// including the operator's own Set* commands, which is far worse than a stale
|
||||
// label. The rotation completes in 8 cycles; anything not covered here is still a
|
||||
// connect-time or ↻ Refresh read.
|
||||
func (b *IcomSerial) refreshFrontPanel() {
|
||||
switch b.pollN % 8 {
|
||||
case 1:
|
||||
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
|
||||
b.dspMu.Lock()
|
||||
b.dsp.AGC = agcName(v)
|
||||
b.dspMu.Unlock()
|
||||
}
|
||||
case 3:
|
||||
if v, ok := b.readAtt(); ok {
|
||||
b.dspMu.Lock()
|
||||
b.dsp.Att = v
|
||||
b.dspMu.Unlock()
|
||||
}
|
||||
case 5:
|
||||
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
|
||||
b.dspMu.Lock()
|
||||
b.dsp.Preamp = int(v)
|
||||
b.dspMu.Unlock()
|
||||
}
|
||||
case 7:
|
||||
if _, f, ok := b.readModeFilter(); ok {
|
||||
b.dspMu.Lock()
|
||||
b.dsp.Filter = int(f)
|
||||
b.dspMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetFrequency(hz int64) error {
|
||||
if hz <= 0 {
|
||||
return fmt.Errorf("invalid frequency")
|
||||
@@ -583,8 +627,8 @@ func (b *IcomSerial) drainResp() {
|
||||
func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
||||
regions := make(map[byte][]byte)
|
||||
var total byte
|
||||
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
|
||||
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
|
||||
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
|
||||
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
|
||||
+19
-4
@@ -4,6 +4,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// LogSink, when set by the host app at startup, receives every CAT debug
|
||||
@@ -15,13 +16,21 @@ var LogSink func(format string, args ...any)
|
||||
// catLogger forwards Printf either to the host LogSink (preferred) or to a
|
||||
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
|
||||
// unchanged.
|
||||
type catLogger struct{ fallback *log.Logger }
|
||||
type catLogger struct {
|
||||
once sync.Once
|
||||
fallback *log.Logger
|
||||
}
|
||||
|
||||
func (c *catLogger) Printf(format string, args ...any) {
|
||||
if LogSink != nil {
|
||||
LogSink("cat: "+format, args...)
|
||||
return
|
||||
}
|
||||
// Only now, on a line that genuinely has nowhere else to go, is the fallback
|
||||
// file opened. It used to be created at package init, so every installation
|
||||
// grew an %APPDATA%\OpsLog\cat.log that nothing ever wrote to once the app
|
||||
// wired LogSink — a decoy for anyone told to "check the CAT log".
|
||||
c.once.Do(func() { c.fallback = openFallbackLog() })
|
||||
if c.fallback != nil {
|
||||
c.fallback.Printf(format, args...)
|
||||
}
|
||||
@@ -30,7 +39,7 @@ func (c *catLogger) Printf(format string, args ...any) {
|
||||
// debugLog writes CAT debug events so users can diagnose mode/freq mismatches
|
||||
// without rebuilding with a console. Once LogSink is set, lines flow into the
|
||||
// main opslog.log.
|
||||
var debugLog = &catLogger{fallback: openFallbackLog()}
|
||||
var debugLog = &catLogger{}
|
||||
|
||||
func openFallbackLog() *log.Logger {
|
||||
base, err := os.UserConfigDir()
|
||||
@@ -49,9 +58,15 @@ func openFallbackLog() *log.Logger {
|
||||
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
|
||||
}
|
||||
|
||||
// DebugLogPath returns where the fallback cat.log lives, for surfacing in the
|
||||
// UI / docs. When LogSink is wired, CAT lines are in the main app log instead.
|
||||
// DebugLogPath returns where the fallback cat.log lives, or "" when CAT lines are
|
||||
// going to the app log instead — which is the normal case, and the answer callers
|
||||
// actually need. It previously returned the path unconditionally, so the one place
|
||||
// that displayed it sent operators to an empty file in %APPDATA% while their CAT
|
||||
// diagnostics sat in data\opslog.log.
|
||||
func DebugLogPath() string {
|
||||
if LogSink != nil {
|
||||
return "" // lines go to the unified app log; there is no separate cat.log
|
||||
}
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
|
||||
+162
-47
@@ -34,6 +34,11 @@ type OmniRig struct {
|
||||
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
|
||||
@@ -53,8 +58,55 @@ func NewOmniRig(rigNum int) *OmniRig {
|
||||
|
||||
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 {
|
||||
debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath())
|
||||
// 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 {
|
||||
@@ -62,15 +114,43 @@ func (o *OmniRig) Connect() error {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
@@ -80,10 +160,26 @@ func (o *OmniRig) Connect() error {
|
||||
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 to Rig%d type=%q", o.RigNum, o.rigType)
|
||||
}
|
||||
debugLog.Printf("OmniRig connected: Rig%d type=%q (OmniRig interface=%d software=%d)",
|
||||
o.RigNum, o.rigType, iv, sv)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -169,50 +265,69 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
}())
|
||||
}
|
||||
|
||||
// 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)
|
||||
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(freqMain, freqA, freqB, s.Vfo, splitRaw)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
if genuineSplit {
|
||||
// ADIF: FreqHz = TX, RxFreqHz = RX. Determine which VFO is RX from the
|
||||
// ACTIVE frequency (OmniRig's generic Freq — the VFO you're listening on):
|
||||
// RX = the active VFO, TX = the other one. This is far more reliable than
|
||||
// trusting OmniRig's Vfo AB/BA enum, which several rigs (e.g. Yaesu FTDX10)
|
||||
// report inverted — the split then showed TX/RX swapped.
|
||||
s.Split = true
|
||||
// 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:
|
||||
s.RxFreqHz, s.FreqHz = freqA, freqB // listening on A → TX on B
|
||||
return freqB, freqA, true // listening on A → TX on B
|
||||
case freqMain != 0 && freqMain == freqB:
|
||||
s.RxFreqHz, s.FreqHz = freqB, freqA // listening on B → TX on A
|
||||
case s.Vfo == "BA":
|
||||
s.FreqHz, s.RxFreqHz = freqA, freqB // fall back to the Vfo enum
|
||||
return freqA, freqB, true // listening on B → TX on A
|
||||
case vfo == "BA":
|
||||
return freqA, freqB, true // fall back to the Vfo enum
|
||||
default:
|
||||
s.FreqHz, s.RxFreqHz = freqB, freqA
|
||||
}
|
||||
} else {
|
||||
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
|
||||
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
|
||||
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
|
||||
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
|
||||
// stock ini), so keying off FreqA gives the operator the VFO they expect.
|
||||
s.Split = false
|
||||
s.RxFreqHz = 0
|
||||
switch {
|
||||
case freqA != 0:
|
||||
s.FreqHz = freqA
|
||||
case freqMain != 0:
|
||||
s.FreqHz = freqMain
|
||||
default:
|
||||
s.FreqHz = freqB
|
||||
return freqB, freqA, true
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
ole "github.com/go-ole/go-ole"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// Last-resort activation path for OmniRig, used only when the normal
|
||||
// CreateObject fails.
|
||||
//
|
||||
// OmniRig is a 32-bit program and its installer splits its COM identity across
|
||||
// registry views — on a working machine the ProgID sits in the 64-bit view while
|
||||
// the CLSID and its LocalServer32 exist only under WOW6432Node. That LOOKS like
|
||||
// it should break a 64-bit client, and it was my first theory for "OmniRig not
|
||||
// found"; measuring it on a machine with exactly that layout disproved it. COM
|
||||
// resolves an out-of-process server across views by itself, and the plain
|
||||
// CreateObject succeeds.
|
||||
//
|
||||
// What this still covers is the case where the ProgID is not visible to us at all
|
||||
// (a partial or 32-bit-only registration), where CreateObject has nothing to
|
||||
// resolve. Here we read the CLSID from BOTH views ourselves and activate the
|
||||
// 32-bit local server explicitly. CLSCTX_ACTIVATE_32_BIT_SERVER is the documented
|
||||
// flag for that; go-ole hard-codes CLSCTX_SERVER and keeps CoCreateInstance
|
||||
// unexported, hence the direct call.
|
||||
//
|
||||
// It costs nothing when the normal path works, and the reason it ran at all is
|
||||
// logged — so if it ever rescues a real installation we will see it.
|
||||
const clsctxActivate32BitServer = 0x40000
|
||||
|
||||
var (
|
||||
modole32 = syscall.NewLazyDLL("ole32.dll")
|
||||
procCoCreateInst32 = modole32.NewProc("CoCreateInstance")
|
||||
)
|
||||
|
||||
// omnirigCLSIDFromRegistry reads OmniRig's CLSID from the ProgID key, looking in
|
||||
// the 64-bit view first and then the 32-bit one. Returned as a GUID ready for
|
||||
// CoCreateInstance.
|
||||
func omnirigCLSIDFromRegistry(progID string) (*ole.GUID, error) {
|
||||
for _, access := range []uint32{registry.QUERY_VALUE, registry.QUERY_VALUE | registry.WOW64_32KEY} {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Classes\`+progID+`\CLSID`, access)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s, _, err := k.GetStringValue("")
|
||||
k.Close()
|
||||
if err != nil || s == "" {
|
||||
continue
|
||||
}
|
||||
if g := ole.NewGUID(s); g != nil {
|
||||
return g, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no CLSID registered for %s in either registry view", progID)
|
||||
}
|
||||
|
||||
// createOmniRig32 activates OmniRig's 32-bit out-of-process server explicitly,
|
||||
// bypassing the 64-bit registry lookup that CoCreateInstance would otherwise do.
|
||||
func createOmniRig32(progID string) (*ole.IDispatch, error) {
|
||||
clsid, err := omnirigCLSIDFromRegistry(progID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var unk *ole.IUnknown
|
||||
hr, _, _ := procCoCreateInst32.Call(
|
||||
uintptr(unsafe.Pointer(clsid)),
|
||||
0, // no aggregation
|
||||
uintptr(ole.CLSCTX_LOCAL_SERVER|clsctxActivate32BitServer),
|
||||
uintptr(unsafe.Pointer(ole.IID_IUnknown)),
|
||||
uintptr(unsafe.Pointer(&unk)))
|
||||
if hr != 0 {
|
||||
return nil, fmt.Errorf("CoCreateInstance(32-bit local server) failed: %w", ole.NewError(hr))
|
||||
}
|
||||
disp, err := unk.QueryInterface(ole.IID_IDispatch)
|
||||
unk.Release()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query IDispatch: %w", err)
|
||||
}
|
||||
return disp, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The COM refusal when OmniRig runs elevated and OpsLog does not (or the reverse)
|
||||
// must be recognised and named. It reached a user as "OmniRig not found" while
|
||||
// OmniRig sat visibly on screen with its settings window open — so the report was
|
||||
// a driver hunt that could never succeed.
|
||||
//
|
||||
// Windows returns this text localised, so the match cannot rely on English alone;
|
||||
// the HRESULT (0x800702E4 = ERROR_ELEVATION_REQUIRED) is accepted too.
|
||||
func TestElevationHint(t *testing.T) {
|
||||
recognised := []string{
|
||||
"L’opération demandée nécessite une élévation.", // as logged, fr-FR
|
||||
"The requested operation requires elevation.",
|
||||
"Access denied",
|
||||
"Accès refusé",
|
||||
"CoCreateInstance failed: 0x800702E4",
|
||||
}
|
||||
for _, msg := range recognised {
|
||||
if elevationHint(errors.New(msg)) == "" {
|
||||
t.Errorf("not recognised as a privilege problem: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Unrelated failures must NOT be blamed on elevation, or the advice sends the
|
||||
// operator to the wrong place just as surely.
|
||||
for _, msg := range []string{
|
||||
"Classe non enregistrée",
|
||||
"REGDB_E_CLASSNOTREG",
|
||||
"no CLSID registered for Omnirig.OmnirigX in either registry view",
|
||||
"open COM3: Access is den", // truncated word must not match "access denied"
|
||||
} {
|
||||
if h := elevationHint(errors.New(msg)); h != "" {
|
||||
t.Errorf("%q wrongly reported as a privilege problem: %s", msg, h)
|
||||
}
|
||||
}
|
||||
|
||||
if elevationHint(nil) != "" {
|
||||
t.Error("nil error must yield no hint")
|
||||
}
|
||||
}
|
||||
|
||||
// The reconnect loop runs every 5 s forever; a persistent failure must be logged
|
||||
// once, not 1500 times an hour.
|
||||
func TestLogConnFailureOnlyOncePerCause(t *testing.T) {
|
||||
var lines []string
|
||||
prev := LogSink
|
||||
LogSink = func(format string, args ...any) { lines = append(lines, format) }
|
||||
defer func() { LogSink = prev }()
|
||||
|
||||
o := &OmniRig{RigNum: 1}
|
||||
for i := 0; i < 20; i++ {
|
||||
o.logConnFailure("requires elevation")
|
||||
}
|
||||
if len(lines) != 1 {
|
||||
t.Errorf("logged %d times, want 1", len(lines))
|
||||
}
|
||||
// A DIFFERENT cause must still be reported.
|
||||
o.logConnFailure("class not registered")
|
||||
if len(lines) != 2 {
|
||||
t.Errorf("a new cause was not logged: %d lines", len(lines))
|
||||
}
|
||||
// And after reconnecting, the same cause may be reported again.
|
||||
o.connLogged = ""
|
||||
o.logConnFailure("requires elevation")
|
||||
if len(lines) != 3 {
|
||||
t.Errorf("after a reconnect the cause should log again: %d lines", len(lines))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// Every known rig's OmniRig behaviour, pinned. These rules genuinely contradict
|
||||
// each other between models, so a change that fixes one rig must be shown not to
|
||||
// break another — that is what this table is for.
|
||||
func TestResolveOmniRigVFOs(t *testing.T) {
|
||||
const (
|
||||
a14200 = 14200000
|
||||
b14205 = 14205000
|
||||
b21000 = 21000000
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
main, fa, fb int64
|
||||
vfo string
|
||||
split int64
|
||||
wantTX, wantRX int64
|
||||
wantSplit bool
|
||||
}{
|
||||
// The reported failure: FTDX101D, SUB VFO pressed. OmniRig names VFO B and
|
||||
// reports it as the generic Freq; freqA still holds the main VFO. Preferring
|
||||
// freqA meant the display never followed the operator to B.
|
||||
{"FTDX101D on SUB VFO", b14205, a14200, b14205, "B", pmSplitOff, b14205, 0, false},
|
||||
{"FTDX101D on MAIN VFO", a14200, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
|
||||
|
||||
// Non-regression: a rig that does not report the VFO enum keeps the old
|
||||
// order — freqA, then the generic Freq, then freqB.
|
||||
{"no VFO enum, freqA populated (Yaesu/Kenwood)", a14200, a14200, 0, "", pmSplitOff, a14200, 0, false},
|
||||
{"no VFO enum, only generic Freq (IC-9100)", a14200, 0, 0, "", pmSplitOff, a14200, 0, false},
|
||||
{"IC-7610: generic Freq reports B, enum says A", b14205, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
|
||||
|
||||
// Split: PM_SPLITON must be read as a BIT. An exact == 0x8000 reported "no
|
||||
// split" for any rig that sets the flag alongside another bit.
|
||||
{"split, ON flag alone", a14200, a14200, b14205, "AB", pmSplitOn, b14205, a14200, true},
|
||||
{"split, ON flag with extra bits set", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, b14205, a14200, true},
|
||||
{"listening on B → TX on A", b14205, a14200, b14205, "BA", pmSplitOn, a14200, b14205, true},
|
||||
|
||||
// Split must NOT be inferred when the rig says OFF, nor from a stale VFO B
|
||||
// left on another band (the FT-710 / TS-570 false positive).
|
||||
{"OFF flag, two distinct VFOs", a14200, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
|
||||
{"ON flag but VFOs on different bands", a14200, a14200, b21000, "AB", pmSplitOn, a14200, 0, false},
|
||||
{"ON flag but both VFOs identical", a14200, a14200, a14200, "AB", pmSplitOn, a14200, 0, false},
|
||||
{"ON and OFF both set — ambiguous, treat as no split", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, a14200, 0, false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
tx, rx, split := resolveOmniRigVFOs(c.main, c.fa, c.fb, c.vfo, c.split)
|
||||
if tx != c.wantTX || rx != c.wantRX || split != c.wantSplit {
|
||||
t.Errorf("%s:\n got TX=%d RX=%d split=%v\n want TX=%d RX=%d split=%v",
|
||||
c.name, tx, rx, split, c.wantTX, c.wantRX, c.wantSplit)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user