fix: Icom over LAN froze on the last frequency after WSJT-X released the rig
The network backend treated "control link alive but no CI-V reply" as the rig being in standby, and tolerated it WITHOUT BOUND. When another program takes the CI-V session — WSJT-X through OmniRig, or the Remote Utility — the rig goes on answering pings on the control stream while sending us nothing at all. Alive() stayed true, so ReadState returned the cached frequency with err == nil, the Manager never saw a failure, never reconnected, and re-published a frozen number with a fresh timestamp on every poll. Only restarting OpsLog cleared it. Bounded now, on the last SUCCESSFUL read. Past the grace the error is reported so the Manager tears the session down and reconnects, which re-takes the CI-V stream. Also fatal immediately: the CI-V reader goroutine having exited — no read can ever succeed after that, however healthy the control link looks. The grace backs off to minutes when the silence persists, because the two cases pull opposite ways: a stolen session recovers on the first attempt, while a rig switched OFF is silent for hours and re-tearing its session every 30 s would blink the panel — and its ON button — away continuously. A good read resets it. The decision table is pinned by a test; the standby case (never answered since connect) keeps the old tolerate-for-ever behaviour.
This commit is contained in:
@@ -3,10 +3,12 @@
|
||||
"version": "0.21.9",
|
||||
"date": "2026-07-28",
|
||||
"en": [
|
||||
"Icom over the LAN: the frequency no longer stays frozen after another program (WSJT-X through OmniRig, the Remote Utility) takes the CI-V session. The rig kept answering pings while sending nothing, so OpsLog showed the last known frequency until it was restarted; it now reconnects on its own.",
|
||||
"The offline FCC (ULS) database now answers while you type a US callsign, filling state, county and a 6-character grid. It was only applied when the QSO was saved, so without a QRZ.com or HamQTH account you saw nothing but the country and a coarse grid. Online lookups still win — ULS only fills what is blank.",
|
||||
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
|
||||
],
|
||||
"fr": [
|
||||
"Icom via le réseau : la fréquence ne reste plus figée après qu'un autre programme (WSJT-X via OmniRig, la Remote Utility) a pris la session CI-V. La radio continuait de répondre aux pings sans rien émettre, si bien qu'OpsLog affichait la dernière fréquence connue jusqu'à son redémarrage ; il se reconnecte désormais tout seul.",
|
||||
"La base FCC (ULS) hors ligne répond désormais pendant la saisie d'un indicatif américain, en remplissant l'état, le comté et un locator 6 caractères. Elle n'était appliquée qu'à l'enregistrement du QSO : sans compte QRZ.com ou HamQTH, vous ne voyiez que le pays et un locator approximatif. Les recherches en ligne restent prioritaires — l'ULS ne comble que ce qui est vide.",
|
||||
"Les zones CQ et ITU corrigées à la main dans votre profil restent corrigées. Elles proviennent de cty.dat, qui donne les zones de l'entité entière — dans un pays qui en couvre plusieurs, la valeur automatique est fausse et revenait à chaque redémarrage. Elles ne se remplissent désormais que si le champ est vide, et se recalculent quand l'indicatif ou le locator change."
|
||||
]
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The rule that decides whether a silent-but-alive Icom network session is
|
||||
// tolerated or torn down. It was "always tolerate", and that is what froze the
|
||||
// frequency display for ever when another program took the CI-V session.
|
||||
//
|
||||
// The decision is pinned here rather than left implicit, because the two cases
|
||||
// it separates pull in opposite directions: a rig in standby must NOT be
|
||||
// reconnected every few seconds (the panel and its ON button would blink away),
|
||||
// while a hijacked session must recover without restarting OpsLog.
|
||||
func TestIcomSilenceTolerance(t *testing.T) {
|
||||
// tolerate mirrors the condition in ReadState.
|
||||
tolerate := func(alive, readerGone bool, lastGood time.Time, silentFor, grace time.Duration) bool {
|
||||
return alive && !readerGone && (lastGood.IsZero() || silentFor < grace)
|
||||
}
|
||||
never := time.Time{}
|
||||
some := time.Now()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
alive bool
|
||||
readerGone bool
|
||||
lastGood time.Time
|
||||
silentFor time.Duration
|
||||
want bool
|
||||
}{
|
||||
{"never answered since connect — rig is off, keep the panel", true, false, never, time.Hour, true},
|
||||
{"brief silence during a band change", true, false, some, 2 * time.Second, true},
|
||||
{"silence past the grace — reconnect", true, false, some, icomSilentGrace + time.Second, false},
|
||||
{"reader goroutine gone — dead however alive the link looks", true, true, some, time.Second, false},
|
||||
{"control link itself dead", false, false, some, time.Second, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := tolerate(c.alive, c.readerGone, c.lastGood, c.silentFor, icomSilentGrace)
|
||||
if got != c.want {
|
||||
t.Errorf("%s: tolerate = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The backoff must actually converge on the ceiling: a rig left switched off is
|
||||
// silent for hours, and a window that stayed at 30 s would re-tear its session
|
||||
// about a hundred times an hour.
|
||||
func TestIcomSilenceBackoff(t *testing.T) {
|
||||
g := icomSilentGrace
|
||||
for i := 0; i < 20; i++ {
|
||||
if g < icomSilentGraceMax {
|
||||
g *= 2
|
||||
}
|
||||
}
|
||||
if g < icomSilentGraceMax {
|
||||
t.Fatalf("backoff never reached the ceiling: %s < %s", g, icomSilentGraceMax)
|
||||
}
|
||||
if g > 2*icomSilentGraceMax {
|
||||
t.Errorf("backoff overshot the ceiling: %s", g)
|
||||
}
|
||||
}
|
||||
+73
-10
@@ -37,6 +37,19 @@ type aliveTransport interface {
|
||||
Alive() bool
|
||||
}
|
||||
|
||||
const (
|
||||
// How long the network backend accepts "the control link answers but the rig
|
||||
// sends no CI-V" before declaring the session lost. Long enough to cover a
|
||||
// band change or a rig booting from standby; short enough that an operator
|
||||
// whose CI-V session was stolen (WSJT-X via OmniRig, the Remote Utility) gets
|
||||
// a live frequency back on his own rather than restarting OpsLog.
|
||||
icomSilentGrace = 30 * time.Second
|
||||
// Ceiling for the backoff applied when the silence persists — a rig switched
|
||||
// OFF is silent for hours, and re-tearing its session every 30 s would blink
|
||||
// the panel (and its ON button) away continuously.
|
||||
icomSilentGraceMax = 4 * time.Minute
|
||||
)
|
||||
|
||||
// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
|
||||
// (0x27) frames on a SEPARATE channel from control replies. The network transport
|
||||
// implements it so the continuous panadapter stream can't crowd control replies
|
||||
@@ -89,13 +102,17 @@ type IcomSerial struct {
|
||||
scopeFixed bool // true = fixed-span mode (tracked optimistically)
|
||||
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)
|
||||
lastGoodAt time.Time // last SUCCESSFUL frequency read — bounds the network
|
||||
// "alive but silent" tolerance below, which used to be
|
||||
// unbounded and left the display frozen for ever
|
||||
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
|
||||
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
|
||||
@@ -267,7 +284,34 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
// rig is switched on) rather than tearing the whole UDP session down and
|
||||
// flapping every few seconds. The panel stays up so the ON button works.
|
||||
if at, ok := b.port.(aliveTransport); ok {
|
||||
if at.Alive() {
|
||||
// The reader goroutine is what feeds every CI-V reply. If it has
|
||||
// exited, no read can ever succeed again, however healthy the control
|
||||
// link looks — that is not a silent rig, it is a dead connection.
|
||||
readerGone := false
|
||||
if b.readerDone != nil {
|
||||
select {
|
||||
case <-b.readerDone:
|
||||
readerGone = true
|
||||
default:
|
||||
}
|
||||
}
|
||||
// "Alive but silent" is the rig in standby, or mid band-change. It was
|
||||
// tolerated for ever, and that is the freeze: when ANOTHER program takes
|
||||
// the CI-V session (WSJT-X through OmniRig, say) the rig keeps answering
|
||||
// pings on the control stream while sending us no CI-V at all. Alive()
|
||||
// stayed true, the cached frequency was re-published with a fresh
|
||||
// timestamp on every poll, and the operator saw a confident, frozen
|
||||
// number until OpsLog was restarted. Bounded now: past the grace period
|
||||
// the error is reported so the Manager tears the session down and
|
||||
// reconnects, which re-takes the CI-V stream.
|
||||
silentFor := time.Duration(0)
|
||||
if !b.lastGoodAt.IsZero() {
|
||||
silentFor = time.Since(b.lastGoodAt)
|
||||
}
|
||||
if b.silentGrace <= 0 {
|
||||
b.silentGrace = icomSilentGrace
|
||||
}
|
||||
if at.Alive() && !readerGone && (b.lastGoodAt.IsZero() || silentFor < b.silentGrace) {
|
||||
b.readFails = 0
|
||||
s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
|
||||
if b.curModeByte != 0 {
|
||||
@@ -286,8 +330,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
b.dspMu.Unlock()
|
||||
return s, nil
|
||||
}
|
||||
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
||||
return RigState{}, err // control link dead → let the Manager reconnect
|
||||
switch {
|
||||
case readerGone:
|
||||
debugLog.Printf("icom net: the CI-V reader has exited — the connection is dead however alive the control link looks → reconnecting")
|
||||
case at.Alive():
|
||||
debugLog.Printf("icom net: control link answers but no CI-V reply for %s → reconnecting. Another program (WSJT-X/OmniRig, the Remote Utility) has most likely taken the CI-V session.", silentFor.Round(time.Second))
|
||||
default:
|
||||
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
||||
}
|
||||
// Restart the clock, and widen the window each time it fires without a
|
||||
// good read in between. A hijacked session recovers on the first shot;
|
||||
// a rig simply switched OFF never will, and re-tearing its session every
|
||||
// 30 s would make the panel — and its ON button — blink away
|
||||
// continuously. Backing off to minutes keeps the standby case quiet
|
||||
// while still recovering on its own.
|
||||
b.lastGoodAt = time.Now()
|
||||
if b.silentGrace < icomSilentGraceMax {
|
||||
b.silentGrace *= 2
|
||||
}
|
||||
return RigState{}, err // let the Manager reconnect
|
||||
}
|
||||
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
||||
// switches band/VFO. Tolerate a few consecutive misses as transient — keep
|
||||
@@ -308,6 +369,8 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
return RigState{}, err
|
||||
}
|
||||
b.readFails = 0
|
||||
b.lastGoodAt = time.Now()
|
||||
b.silentGrace = icomSilentGrace // the rig answers: back to the short window
|
||||
s.FreqHz = hz
|
||||
b.curFreq = hz
|
||||
|
||||
|
||||
Reference in New Issue
Block a user