fix(icom): the client pings too — the dropouts' likely root cause

wfview sends a ping (0x07, 0x15 bytes, its own seq and a monotonic
timestamp) every 500 ms on every stream, and so does RS-BA1. OpsLog only
ever REPLIED to the rig's pings — and the loaner IC-7760 stopped serving
CI-V data about a minute into nearly every session while the transport
stayed alive: the rig had concluded nobody was listening. Scope, TX audio
and idle numbering were each eliminated in turn before wfview's source
settled it. Pings now go out on control, CI-V and audio alike.
This commit is contained in:
2026-08-30 11:42:48 +02:00
parent 7b15b534cd
commit 88c9756edf
3 changed files with 51 additions and 3 deletions
+10 -1
View File
@@ -57,7 +57,9 @@ type icomAudio struct {
rxLastSeq uint16
rxMissing map[uint16]int
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
pingSeq uint16 // client-ping counter — the rig wants OUR pings too (see icnPing)
started time.Time
// Live-TX state — see SendTXChunk.
txMu sync.Mutex
@@ -109,6 +111,7 @@ func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*ico
_ = conn.SetReadBuffer(1 << 20)
a := &icomAudio{
conn: conn, aID: aID, aRemote: aRemote,
started: time.Now(),
sink: sink,
rxMissing: make(map[uint16]int),
done: make(chan struct{}),
@@ -124,6 +127,7 @@ func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*ico
func (a *icomAudio) audioPump() {
buf := make([]byte, 8192)
lastIdle := time.Now()
lastPing := time.Now()
lastReq := time.Now()
for {
select {
@@ -163,6 +167,11 @@ func (a *icomAudio) audioPump() {
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
lastIdle = time.Now()
}
if time.Since(lastPing) > 500*time.Millisecond {
a.pingSeq++
_, _ = a.conn.Write(icnPing(a.pingSeq, a.aID, a.aRemote, uint32(time.Since(a.started).Milliseconds())))
lastPing = time.Now()
}
if time.Since(lastReq) > 100*time.Millisecond {
a.sendRetransmitReq()
lastReq = time.Now()
+37
View File
@@ -138,6 +138,11 @@ type icomNet struct {
vTracked uint16
vCivSeq uint16
seqMu sync.Mutex // guards vTracked/vCivSeq: the command loop AND the pump's quiet-recovery both send
// Client-ping sequence counters, one per stream, and the connection's epoch
// for the ping timestamps. Owned by their pump goroutines — no locking.
civPingSeq uint16
ctrlPingSeq uint16
started time.Time
// txCiv counts CI-V command packets sent, and txAtData snapshots it at the
// last received CI-V data. Their difference during a silence answers the
// question the reconnects cannot: were we still ASKING when the answers
@@ -332,6 +337,7 @@ func (n *icomNet) Close() error {
func (n *icomNet) ctrlPump() {
buf := make([]byte, 4096)
lastIdle := time.Now()
lastCtrlPing := time.Now()
lastToken := time.Now() // token was just granted during dial
for {
select {
@@ -372,6 +378,11 @@ func (n *icomNet) ctrlPump() {
}
// Renew well inside the rig's ~2-min token timeout. 30 s (was 45) leaves room
// for one lost renewal + its retransmit before the token would lapse.
if time.Since(lastCtrlPing) > 500*time.Millisecond {
n.ctrlPingSeq++
_, _ = n.ctrl.Write(icnPing(n.ctrlPingSeq, n.cID, n.cRemote, uint32(time.Since(n.started).Milliseconds())))
lastCtrlPing = time.Now()
}
if time.Since(lastToken) > 30*time.Second {
n.renewToken()
lastToken = time.Now()
@@ -416,6 +427,7 @@ func (n *icomNet) civPump() {
// and a rig that stopped talking are different repairs, and the 30 s
// watchdog that follows cannot tell them apart from where it sits.
lastPkt := time.Now()
lastPing := time.Now()
lastData := time.Now() // CI-V payload packets (replies + transceive)
lastScope := time.Time{} // scope frames within those
var lastErr error
@@ -551,6 +563,11 @@ func (n *icomNet) civPump() {
n.sendRetransmitReq()
lastReq = time.Now()
}
if time.Since(lastPing) > 500*time.Millisecond {
n.civPingSeq++
_, _ = n.civ.Write(icnPing(n.civPingSeq, n.vID, n.vRemote, uint32(time.Since(n.started).Milliseconds())))
lastPing = time.Now()
}
}
}
@@ -799,6 +816,7 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
cTracked: cTracked, cAuthSeq: cInner,
cToken: token, cTokReq: tokReq,
cSentBuf: make(map[uint16][]byte),
started: time.Now(),
}
n.markRx() // the successful handshake counts as initial rig activity
// openClose(open) starts the CI-V data flow. We intentionally DO NOT power the
@@ -1068,6 +1086,25 @@ func icnWakeOnLAN(mac string) error {
return err
}
// icnPing builds a CLIENT ping request (wfview's ping_packet: 0x15 bytes,
// type 0x07, reply=0, a monotonic time at 0x11). The rig answers each one —
// and, decisively, treats them as the client's sign of life: wfview sends one
// every 500 ms on every stream, and OpsLog, which only ever REPLIED to the
// rig's pings, watched the IC-7760 stop serving CI-V data about a minute into
// every session. Same socket answered, same transport alive: the rig had
// simply concluded nobody was listening.
func icnPing(seq uint16, sentid, rcvdid uint32, ms uint32) []byte {
b := make([]byte, 0x15)
icnLE.PutUint32(b[0:], 0x15)
icnLE.PutUint16(b[4:], 0x07)
icnLE.PutUint16(b[6:], seq)
icnLE.PutUint32(b[8:], sentid)
icnLE.PutUint32(b[12:], rcvdid)
b[0x10] = 0x00
icnLE.PutUint32(b[0x11:], ms)
return b
}
func icnOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
b := make([]byte, 0x16)
icnLE.PutUint32(b[0:], 0x16)