Files
OpsLog/internal/cat/icom_silence_test.go
T
rouggy bd5f9c0746 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.
2026-07-28 22:24:32 +02:00

63 lines
2.2 KiB
Go

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)
}
}