feat: follow the SUB VFO over OmniRig; distance column in the QSO grids
OmniRig reports the VFO pair (AA/AB/BA/BB) on the whole Yaesu range and never the single-letter form. Only the latter was honoured, so every rig in that family stayed pinned to VFO A: pressing SUB moved the radio but not OpsLog, and a QSO worked on SUB was logged on the main VFO's frequency. The first letter of the pair is the VFO being listened on — Log4OM reads it and gets the right frequency on the same rigs, which is what showed the data was there. The enum now wins over the Yaesu Freq==FreqB inference, which is only a fallback for a rig file that names no VFO at all (the stock FTDX10 one answers neither VS; nor FR; usably — verified on the air). Split: the ON flag is still latched to survive a rig file that flips it on its own, but the latch is now ARMED only after 8 flips in 30 s. A first cut at 3-in-15s was armed by the operator toggling split while testing, imposing the 6 s clearing delay on a radio that did not need it; a misreading file flips a dozen times in that window untouched, so the two cases separate cleanly. Distance (km) column added to Recent QSOs and Worked before (shared catalog). Computed from the QSO's OWN my_grid/my_lat/lon first, falling back to the current profile's locator: a log spans years and portable outings, so the station a QSO was made from is not necessarily today's. Locator: a precise QRZ/HamQTH grid is no longer overwritten by the cty.dat entity centroid. The lookup runs several times per QSO and the provider gets 2 s; a slow second answer fell back to cty.dat and downgraded JN05JG to JN16 while name and QTH survived (they are only written when non-empty). The OmniRig diagnostic line now logs what OpsLog concluded, not just what OmniRig reported. icomnet.go: gofmt alignment only.
This commit is contained in:
@@ -98,8 +98,8 @@ type icomNet struct {
|
||||
vTracked uint16
|
||||
vCivSeq uint16
|
||||
|
||||
rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
|
||||
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
|
||||
rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
|
||||
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
|
||||
// stream can't crowd control replies out (→ ScopeChan)
|
||||
leftover []byte // partial chunk not yet returned by Read (Read-only)
|
||||
readTO time.Duration // Read timeout (SetReadTimeout)
|
||||
@@ -114,11 +114,11 @@ type icomNet struct {
|
||||
// login token every ~45 s. The rig invalidates the session ~2 min after login
|
||||
// without renewal (this was the "loses control after 2 min" drop — RS-BA1/the
|
||||
// Remote Utility renew too). Owned solely by ctrlPump after dial → no lock.
|
||||
cTracked uint16 // control-stream tracked seq (continues after dial)
|
||||
cAuthSeq uint16 // token-packet innerseq
|
||||
cToken uint32 // login token (opaque, echoed back verbatim)
|
||||
cTokReq uint16 // token-request id (echoed)
|
||||
cSentBuf map[uint16][]byte // control-stream retransmit buffer (token renewals)
|
||||
cTracked uint16 // control-stream tracked seq (continues after dial)
|
||||
cAuthSeq uint16 // token-packet innerseq
|
||||
cToken uint32 // login token (opaque, echoed back verbatim)
|
||||
cTokReq uint16 // token-request id (echoed)
|
||||
cSentBuf map[uint16][]byte // control-stream retransmit buffer (token renewals)
|
||||
|
||||
// Receive-side retransmit (CI-V stream): track the rig's data-packet send seq
|
||||
// and ask it to resend any gap. Under the scope stream, UDP drops are common;
|
||||
@@ -844,8 +844,8 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
|
||||
copy(b[0x60:0x70], icnPasscode(user))
|
||||
b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
|
||||
b[0x71] = 0x00 // txenable (Phase 5)
|
||||
b[0x72] = 0x10 // rxcodec
|
||||
b[0x73] = 0x04 // txcodec
|
||||
b[0x72] = 0x10 // rxcodec
|
||||
b[0x73] = 0x04 // txcodec
|
||||
icnBE.PutUint32(b[0x74:], 16000)
|
||||
icnBE.PutUint32(b[0x78:], 8000)
|
||||
icnBE.PutUint32(b[0x7c:], uint32(civPort))
|
||||
|
||||
+100
-14
@@ -46,6 +46,19 @@ type OmniRig struct {
|
||||
// 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.
|
||||
@@ -251,21 +264,61 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
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.
|
||||
// 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: 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
|
||||
}())
|
||||
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)
|
||||
}
|
||||
|
||||
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(freqMain, freqA, freqB, s.Vfo, splitRaw)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -276,14 +329,14 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
||||
// 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) {
|
||||
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
|
||||
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
|
||||
@@ -316,6 +369,32 @@ func resolveOmniRigVFOs(freqMain, freqA, freqB int64, vfo string, splitRaw int64
|
||||
// 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
|
||||
@@ -601,6 +680,13 @@ func omniRigMode(m int64) string {
|
||||
|
||||
// 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
|
||||
|
||||
@@ -14,40 +14,61 @@ func TestResolveOmniRigVFOs(t *testing.T) {
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rig string
|
||||
main, fa, fb int64
|
||||
vfo string
|
||||
split int64
|
||||
sticky bool
|
||||
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},
|
||||
{"FTDX101D on SUB VFO", "FTDX101D", b14205, a14200, b14205, "B", pmSplitOff, false, b14205, 0, false},
|
||||
{"FTDX101D on MAIN VFO", "FTDX101D", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
|
||||
|
||||
// Yaesu fallback for a rig file that names NO VFO at all (the stock FTDX10
|
||||
// one, confirmed 2026-07-26): the generic Freq matching FreqB is then the
|
||||
// only sign that the operator is on SUB. It applies ONLY when the enum is
|
||||
// silent — when the enum names a VFO, the enum wins (pair cases below).
|
||||
{"FTDX10, no enum, generic Freq is B", "FTDX10", b14205, a14200, b14205, "", pmSplitOff, false, b14205, 0, false},
|
||||
// Same alternating ini: one poll says OFF while the rig IS in split. The
|
||||
// latched ON flag has to survive the contradicting sample.
|
||||
{"FTDX101D split, contradicting OFF sample", "FTDX101D", a14200, a14200, b14205, "AB", pmSplitOff, true, b14205, a14200, true},
|
||||
// The latch must not manufacture a split out of a stale cross-band VFO B.
|
||||
{"FTDX101D latch, VFOs on different bands", "FTDX101D", a14200, a14200, b21000, "AB", pmSplitOff, true, a14200, 0, false},
|
||||
|
||||
// Pair enums: first letter is the VFO being listened on. Reported by the
|
||||
// whole Yaesu family; ignoring them pinned the display to VFO A (F4NBZ,
|
||||
// 2026-07-26 — Log4OM follows SUB on the same rig through OmniRig).
|
||||
{"pair enum BA, simplex → listening on B", "", b14205, a14200, b14205, "BA", pmSplitOff, false, b14205, 0, false},
|
||||
{"pair enum BB, simplex → listening on B", "", b14205, a14200, b14205, "BB", pmSplitOff, false, b14205, 0, false},
|
||||
{"pair enum AB, simplex → listening on A", "", a14200, a14200, b14205, "AB", pmSplitOff, false, a14200, 0, false},
|
||||
{"pair enum AA, simplex → listening on A", "", a14200, a14200, b14205, "AA", pmSplitOff, false, 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},
|
||||
{"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false},
|
||||
{"no VFO enum, only generic Freq (IC-9100)", "IC-9100", a14200, 0, 0, "", pmSplitOff, false, a14200, 0, false},
|
||||
{"IC-7610: generic Freq reports B, enum says A", "IC-7610", b14205, a14200, b14205, "A", pmSplitOff, false, 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, ON flag alone", "", a14200, a14200, b14205, "AB", pmSplitOn, false, b14205, a14200, true},
|
||||
{"split, ON flag with extra bits set", "", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, false, b14205, a14200, true},
|
||||
{"listening on B → TX on A", "", b14205, a14200, b14205, "BA", pmSplitOn, false, 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},
|
||||
{"OFF flag, two distinct VFOs", "", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
|
||||
{"ON flag but VFOs on different bands", "", a14200, a14200, b21000, "AB", pmSplitOn, false, a14200, 0, false},
|
||||
{"ON flag but both VFOs identical", "", a14200, a14200, a14200, "AB", pmSplitOn, false, a14200, 0, false},
|
||||
{"ON and OFF both set — ambiguous, treat as no split", "", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, false, a14200, 0, false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
tx, rx, split := resolveOmniRigVFOs(c.main, c.fa, c.fb, c.vfo, c.split)
|
||||
tx, rx, split := resolveOmniRigVFOs(c.rig, c.main, c.fa, c.fb, c.vfo, c.split, c.sticky)
|
||||
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