OmniRig: on the IC-7610 the generic Freq property reports the wrong VFO (its Main/Sub model confuses the stock ini), so OpsLog showed VFO B. Detect the rig by RigType and, in simplex, read VFO A explicitly — matching Log4OM. Only the 7610 is affected; other rigs keep using the generic Freq. Icom network: when the rig tears the session down (control/CI-V 0x05) OpsLog only logged it and kept the half-dead link until the 6 s liveness timeout expired. Mark the link dead on 0x05 so Alive() fails on the next poll and the manager reconnects cleanly right away.
906 lines
31 KiB
Go
906 lines
31 KiB
Go
package cat
|
|
|
|
// icomnet.go — the NETWORK transport for the Icom backend. It talks the Icom IP
|
|
// remote protocol (the LAN server built into the IC-7610, the one the Icom
|
|
// Remote Utility speaks) directly, and presents the tunnelled CI-V byte stream
|
|
// as a plain civTransport (Read/Write). So the entire IcomController surface —
|
|
// freq/mode, receive-DSP, TX, scope, RIT, CW — runs unchanged over the network;
|
|
// only the transport differs. OpsLog thus replaces BOTH the Remote Utility and
|
|
// RS-BA1.
|
|
//
|
|
// The protocol (framing, passcode table, packet offsets, power-on) was
|
|
// reimplemented from the public wfview protocol and verified byte-for-byte
|
|
// against real Remote-Utility captures. No GPLv3 code is copied.
|
|
//
|
|
// Three UDP streams exist on the rig (control 50001 / CI-V 50002 / audio 50003);
|
|
// this transport uses control + CI-V (audio is not needed for CAT). Connect:
|
|
// control: areYouThere→iAmHere→areYouReady→iAmReady → login → token → conninfo
|
|
// civ: areYouThere→…→iAmReady → openClose(open) → power-on
|
|
// then CI-V flows in data packets. A pump goroutine keeps both streams alive
|
|
// (ping replies + idle keepalives) and feeds received CI-V bytes to Read.
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
var icnLE = binary.LittleEndian
|
|
var icnBE = binary.BigEndian
|
|
|
|
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
|
|
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
|
|
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
|
|
//
|
|
// audioSink (optional) enables the network RX audio stream (UDP 50003): when
|
|
// non-nil the conninfo asks the rig to stream audio and each received payload is
|
|
// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil
|
|
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
|
|
// so enabling it can't affect freq/mode/DSP control.
|
|
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
|
|
if civAddr <= 0 || civAddr > 0xFF {
|
|
civAddr = 0x98 // IC-7610
|
|
}
|
|
if digitalDefault == "" {
|
|
digitalDefault = "FT8"
|
|
}
|
|
b := &IcomSerial{
|
|
portName: host,
|
|
rigAddr: byte(civAddr),
|
|
digital: strings.ToUpper(digitalDefault),
|
|
model: "Icom",
|
|
scopeFixed: true,
|
|
}
|
|
b.open = func() (civTransport, error) {
|
|
if strings.TrimSpace(host) == "" {
|
|
return nil, fmt.Errorf("no rig host configured")
|
|
}
|
|
b.dialMu.Lock()
|
|
cancel := b.dialCancel
|
|
b.dialMu.Unlock()
|
|
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// errDialCanceled is returned by dialIcomNet when Interrupt() aborts the dial
|
|
// (Stop/Start). The Manager treats it like any connect error and simply stops.
|
|
var errDialCanceled = fmt.Errorf("dial canceled")
|
|
|
|
// icnCanceled reports whether the dial has been asked to abort.
|
|
func icnCanceled(cancel <-chan struct{}) bool {
|
|
if cancel == nil {
|
|
return false
|
|
}
|
|
select {
|
|
case <-cancel:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// icomNet is the connected network transport. It satisfies civTransport.
|
|
type icomNet struct {
|
|
ctrl *net.UDPConn // control stream (50001)
|
|
civ *net.UDPConn // CI-V stream (50002)
|
|
|
|
cID, cRemote uint32 // control stream ids
|
|
vID, vRemote uint32 // civ stream ids
|
|
|
|
// Tracked-packet sequence + CI-V data sequence. Written only by Write (on the
|
|
// CAT goroutine) and during dial — never by the pump — so no lock is needed.
|
|
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
|
|
// 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)
|
|
|
|
// sentBuf keeps recently-sent tracked civ packets (by outer seq) so we can
|
|
// answer the rig's UDP retransmit requests. Written by Write (CAT goroutine),
|
|
// read by the pumps → guarded by sentMu.
|
|
sentMu sync.Mutex
|
|
sentBuf map[uint16][]byte
|
|
|
|
// Control-stream auth state, carried out of dial so ctrlPump can RENEW the
|
|
// 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)
|
|
|
|
// 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;
|
|
// without recovering them the gaps accumulate and the rig drops the WHOLE
|
|
// session after ~20 s (RS-BA1/wfview request retransmits, which is why they
|
|
// stay up with the panadapter on). Owned solely by civPump → no lock.
|
|
rxHaveSeq bool
|
|
rxLastSeq uint16
|
|
rxMissing map[uint16]int
|
|
|
|
// lastRx is the UnixNano of the last packet received from the rig (any type),
|
|
// updated by both pumps. The rig's network server answers pings/idles even
|
|
// when the RADIO is in standby, so this tracks the CONTROL-LINK liveness
|
|
// independently of whether CI-V is replying — letting ReadState tell "rig off
|
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
|
lastRx atomic.Int64
|
|
|
|
// dead is set when the rig explicitly tears the session down (control 0x05):
|
|
// Alive() then returns false immediately so ReadState fails on the next poll and
|
|
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
|
|
dead atomic.Bool
|
|
|
|
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
|
// Torn down alongside the CI-V/control streams in Close.
|
|
audio *icomAudio
|
|
|
|
done chan struct{}
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
// ScopeChan exposes the raw scope (0x27) CI-V frames for the scope feeder.
|
|
// Satisfies scopeTransport in icomserial.go.
|
|
func (n *icomNet) ScopeChan() <-chan []byte { return n.scopeRx }
|
|
|
|
// icnEnqueueDrop pushes onto a bounded channel, discarding the oldest entry when
|
|
// full — a lagging consumer never blocks the producer (used for the scope stream,
|
|
// where only the latest sweep matters).
|
|
func icnEnqueueDrop(ch chan []byte, v []byte) {
|
|
select {
|
|
case ch <- v:
|
|
default:
|
|
select {
|
|
case <-ch:
|
|
default:
|
|
}
|
|
select {
|
|
case ch <- v:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (n *icomNet) SetReadTimeout(d time.Duration) error { n.readTO = d; return nil }
|
|
func (n *icomNet) SetDTR(bool) error { return nil } // n/a on the network
|
|
func (n *icomNet) SetRTS(bool) error { return nil }
|
|
|
|
// markRx records that a packet just arrived from the rig (control-link liveness).
|
|
func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
|
|
|
// Alive reports whether the rig's network server is still talking to us. The rig
|
|
// pings/idles continuously (even in standby), so a gap means the link — not just
|
|
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
|
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
|
func (n *icomNet) Alive() bool {
|
|
if n.dead.Load() {
|
|
return false // rig sent an explicit disconnect — reconnect now, don't wait
|
|
}
|
|
last := n.lastRx.Load()
|
|
if last == 0 {
|
|
return true // just connected, nothing received yet — give it a chance
|
|
}
|
|
return time.Since(time.Unix(0, last)) < 6*time.Second
|
|
}
|
|
|
|
// Read returns tunnelled CI-V bytes, mimicking a serial port: (0,nil) on
|
|
// timeout, (n,nil) with data, (0,err) when the link is closed.
|
|
func (n *icomNet) Read(p []byte) (int, error) {
|
|
if len(n.leftover) > 0 {
|
|
k := copy(p, n.leftover)
|
|
n.leftover = n.leftover[k:]
|
|
return k, nil
|
|
}
|
|
to := n.readTO
|
|
if to <= 0 {
|
|
to = 60 * time.Millisecond
|
|
}
|
|
select {
|
|
case f, ok := <-n.rx:
|
|
if !ok {
|
|
return 0, io.EOF
|
|
}
|
|
k := copy(p, f)
|
|
if k < len(f) {
|
|
n.leftover = append(n.leftover[:0], f[k:]...)
|
|
}
|
|
return k, nil
|
|
case <-time.After(to):
|
|
return 0, nil // timeout, no data
|
|
case <-n.done:
|
|
return 0, io.EOF
|
|
}
|
|
}
|
|
|
|
// Write wraps raw CI-V bytes (FE FE … FD) in a data packet and sends them.
|
|
func (n *icomNet) Write(p []byte) (int, error) {
|
|
if icnTrace {
|
|
debugLog.Printf("icom net TX: % X", p)
|
|
}
|
|
seq := n.vTracked
|
|
pkt := icnCivData(seq, n.vID, n.vRemote, n.vCivSeq, p)
|
|
n.vTracked++
|
|
n.vCivSeq++
|
|
n.sentMu.Lock()
|
|
n.sentBuf[seq] = pkt
|
|
delete(n.sentBuf, seq-1024) // keep the buffer bounded (~last 1024 packets) so
|
|
// the rig's retransmit requests still hit even under sustained CW + poll load
|
|
n.sentMu.Unlock()
|
|
if _, err := n.civ.Write(pkt); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// icnTrace toggles verbose per-frame CI-V request/reply logging for diagnosing
|
|
// the network transport. Off by default (the connect-step logs stay); flip to
|
|
// true to trace every TX/RX again.
|
|
var icnTrace = false
|
|
|
|
func (n *icomNet) Close() error {
|
|
n.closeOnce.Do(func() {
|
|
close(n.done)
|
|
if n.audio != nil {
|
|
n.audio.Close()
|
|
}
|
|
// Tell the rig we're leaving so it frees its SINGLE control session at
|
|
// once. If it never gets a disconnect it holds the session for minutes and
|
|
// refuses every new login — which is why a lost link (or a hard app exit)
|
|
// left the rig un-reconnectable, even from the Icom Remote Utility. UDP is
|
|
// lossy, so send openClose(close) + disconnect on both streams a few times.
|
|
// The whole teardown is bounded to ~90 ms so it never stalls the caller
|
|
// (Settings "Save & Close" / a reconnect's Disconnect).
|
|
for i := 0; i < 3; i++ {
|
|
_, _ = n.civ.Write(icnOpenClose(n.vTracked, n.vID, n.vRemote, n.vCivSeq, 0x00)) // close CI-V
|
|
_, _ = n.civ.Write(icnCtrl(0x05, 0, n.vID, n.vRemote)) // disconnect civ
|
|
_, _ = n.ctrl.Write(icnCtrl(0x05, 0, n.cID, n.cRemote)) // disconnect ctrl
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
debugLog.Printf("icom net: sent disconnect to rig (session released)")
|
|
_ = n.civ.Close()
|
|
_ = n.ctrl.Close()
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// ctrlPump keeps the control stream (50001) alive: replies to the rig's pings,
|
|
// sends idle keepalives, RENEWS the login token every ~45 s (without this the rig
|
|
// invalidates the session after ~2 min → total loss of control), and answers the
|
|
// rig's retransmit requests for those tracked control packets. Its own goroutine
|
|
// so it never throttles civPump.
|
|
func (n *icomNet) ctrlPump() {
|
|
buf := make([]byte, 4096)
|
|
lastIdle := time.Now()
|
|
lastToken := time.Now() // token was just granted during dial
|
|
for {
|
|
select {
|
|
case <-n.done:
|
|
return
|
|
default:
|
|
}
|
|
_ = n.ctrl.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
|
if k, err := n.ctrl.Read(buf); err == nil && k >= 16 {
|
|
n.markRx()
|
|
switch icnLE.Uint16(buf[4:]) {
|
|
case 0x07: // ping
|
|
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
|
case 0x00: // idle keepalive from the rig — nothing to do
|
|
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
|
if k >= 8 {
|
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
|
}
|
|
case 0x05: // rig-initiated disconnect — it dropped US
|
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
|
default:
|
|
// Anything else on the control stream is (almost always) the rig's
|
|
// reply to our token renewal. Log it: a 0x40-length token packet
|
|
// carries a result code, and if the rig is REJECTING renewals this is
|
|
// where the ~2-3 min disconnect originates. The hex makes the cause
|
|
// visible in the friend's log without a protocol analyzer.
|
|
if k >= 0x18 {
|
|
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:0x18])
|
|
} else {
|
|
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:k])
|
|
}
|
|
}
|
|
}
|
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
|
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
|
lastIdle = time.Now()
|
|
}
|
|
// 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(lastToken) > 30*time.Second {
|
|
n.renewToken()
|
|
lastToken = time.Now()
|
|
}
|
|
}
|
|
}
|
|
|
|
// renewToken re-authorizes the session (control 0x40 token packet, requesttype
|
|
// 0x05). Tracked so a lost renewal can be retransmitted. Runs only on ctrlPump,
|
|
// the sole owner of the control-stream auth state, so no locking is needed.
|
|
func (n *icomNet) renewToken() {
|
|
seq := n.cTracked
|
|
pkt := icnTokenRenew(seq, n.cAuthSeq, n.cTokReq, n.cID, n.cRemote, n.cToken)
|
|
n.cTracked++
|
|
n.cAuthSeq++
|
|
n.cSentBuf[seq] = pkt
|
|
delete(n.cSentBuf, seq-256)
|
|
_, _ = n.ctrl.Write(pkt)
|
|
debugLog.Printf("icom net: token renewed (seq %d)", seq)
|
|
}
|
|
|
|
// ctrlResend answers a control-stream retransmit request from the control
|
|
// sent-buffer (token renewals). Separate from resend(), which owns the CI-V
|
|
// buffer — the two streams have independent sequence spaces.
|
|
func (n *icomNet) ctrlResend(seq uint16) {
|
|
if pkt := n.cSentBuf[seq]; pkt != nil {
|
|
_, _ = n.ctrl.Write(pkt)
|
|
}
|
|
}
|
|
|
|
// civPump owns the CI-V stream (50002): drains it as fast as packets arrive
|
|
// (its own goroutine — not throttled by the control reads), replies to pings,
|
|
// answers retransmit requests, skips scope frames, and feeds control CI-V bytes
|
|
// to Read via n.rx.
|
|
func (n *icomNet) civPump() {
|
|
buf := make([]byte, 8192)
|
|
lastIdle := time.Now()
|
|
lastReq := time.Now()
|
|
for {
|
|
select {
|
|
case <-n.done:
|
|
return
|
|
default:
|
|
}
|
|
_ = n.civ.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
|
if k, err := n.civ.Read(buf); err == nil && k >= 16 {
|
|
n.markRx()
|
|
switch typ := icnLE.Uint16(buf[4:]); {
|
|
case typ == 0x07: // ping
|
|
_, _ = n.civ.Write(icnPingReply(buf[:k], n.vID, n.vRemote))
|
|
case typ == 0x01: // retransmit request — resend that seq
|
|
if k >= 8 {
|
|
n.resend(icnLE.Uint16(buf[6:]))
|
|
}
|
|
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
|
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
|
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
|
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
|
civBytes := buf[0x15:k]
|
|
cp := append([]byte(nil), civBytes...)
|
|
// Scope (0x27) frames go to their OWN channel: the panadapter streams
|
|
// continuously as large frames and would otherwise crowd control
|
|
// replies out of rx (every command would then time out). The scope
|
|
// feeder in IcomSerial picks them up. Everything else is a control
|
|
// reply → rx → Read.
|
|
if len(civBytes) >= 5 && civBytes[4] == 0x27 {
|
|
icnEnqueueDrop(n.scopeRx, cp)
|
|
break
|
|
}
|
|
if icnTrace {
|
|
debugLog.Printf("icom net RX: % X", civBytes)
|
|
}
|
|
select {
|
|
case n.rx <- cp:
|
|
case <-n.done:
|
|
return
|
|
default: // buffer full — drop oldest, enqueue newest
|
|
select {
|
|
case <-n.rx:
|
|
default:
|
|
}
|
|
select {
|
|
case n.rx <- cp:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if time.Since(lastIdle) > 150*time.Millisecond {
|
|
_, _ = n.civ.Write(icnCtrl(0x00, 0, n.vID, n.vRemote))
|
|
lastIdle = time.Now()
|
|
}
|
|
if time.Since(lastReq) > 100*time.Millisecond {
|
|
n.sendRetransmitReq()
|
|
lastReq = time.Now()
|
|
}
|
|
}
|
|
}
|
|
|
|
// icnMaxMissing caps the outstanding retransmit backlog; a bigger jump is treated
|
|
// as a wrap/desync and the tracker resets rather than requesting a storm.
|
|
const icnMaxMissing = 50
|
|
|
|
// trackRxSeq records the rig's data-packet send seq (outer seq @0x06) and flags
|
|
// any forward gap as missing so sendRetransmitReq can ask for it. Handles uint16
|
|
// wrap via the signed distance; ignores duplicates and already-seen packets.
|
|
func (n *icomNet) trackRxSeq(seq uint16) {
|
|
if !n.rxHaveSeq {
|
|
n.rxHaveSeq = true
|
|
n.rxLastSeq = seq
|
|
return
|
|
}
|
|
switch d := int16(seq - n.rxLastSeq); {
|
|
case d == 0: // duplicate
|
|
case d < 0: // an older seq arrived — a retransmit we were missing
|
|
delete(n.rxMissing, seq)
|
|
case d == 1: // in order
|
|
n.rxLastSeq = seq
|
|
case int(d) <= icnMaxMissing: // forward gap — mark the in-between seqs missing
|
|
for f := n.rxLastSeq + 1; f != seq; f++ {
|
|
n.rxMissing[f] = 0
|
|
}
|
|
n.rxLastSeq = seq
|
|
default: // huge jump (wrap/desync) — reset to avoid a false retransmit storm
|
|
n.rxMissing = make(map[uint16]int)
|
|
n.rxLastSeq = seq
|
|
}
|
|
}
|
|
|
|
// sendRetransmitReq asks the rig to resend any CI-V data packets we detected as
|
|
// missing. Each seq is requested up to 4 times then dropped. Mirrors the Remote
|
|
// Utility/wfview format: a single miss = a 16-byte control (type 0x01, seq set);
|
|
// several = a control header + a list of [lo hi lo hi] per seq.
|
|
func (n *icomNet) sendRetransmitReq() {
|
|
if len(n.rxMissing) == 0 {
|
|
return
|
|
}
|
|
if len(n.rxMissing) > icnMaxMissing {
|
|
n.rxMissing = make(map[uint16]int) // hopelessly behind — flush and move on
|
|
return
|
|
}
|
|
var seqs []uint16
|
|
for s, cnt := range n.rxMissing {
|
|
if cnt >= 4 {
|
|
delete(n.rxMissing, s)
|
|
continue
|
|
}
|
|
n.rxMissing[s] = cnt + 1
|
|
seqs = append(seqs, s)
|
|
}
|
|
switch {
|
|
case len(seqs) == 0:
|
|
return
|
|
case len(seqs) == 1:
|
|
_, _ = n.civ.Write(icnCtrl(0x01, seqs[0], n.vID, n.vRemote))
|
|
default:
|
|
b := make([]byte, 16+4*len(seqs))
|
|
icnLE.PutUint32(b[0:], uint32(len(b)))
|
|
icnLE.PutUint16(b[4:], 0x01) // type = retransmit request
|
|
icnLE.PutUint32(b[8:], n.vID)
|
|
icnLE.PutUint32(b[12:], n.vRemote)
|
|
off := 16
|
|
for _, s := range seqs {
|
|
icnLE.PutUint16(b[off:], s)
|
|
icnLE.PutUint16(b[off+2:], s)
|
|
off += 4
|
|
}
|
|
_, _ = n.civ.Write(b)
|
|
}
|
|
}
|
|
|
|
// resend re-transmits a previously-sent tracked CI-V packet the rig asks for
|
|
// (its UDP retransmit mechanism). Without this the rig drops the whole session
|
|
// after a few seconds when a packet is lost under load.
|
|
func (n *icomNet) resend(seq uint16) {
|
|
n.sentMu.Lock()
|
|
pkt := n.sentBuf[seq]
|
|
n.sentMu.Unlock()
|
|
if pkt != nil {
|
|
_, _ = n.civ.Write(pkt)
|
|
} else {
|
|
// The rig asked for a packet we've already evicted (>256 sent since). It
|
|
// can't fill its gap → it eventually drops the session. If this shows up in
|
|
// the log around a disconnect, the send buffer is too small for the load.
|
|
debugLog.Printf("icom net: retransmit MISS for seq %d (already evicted)", seq)
|
|
}
|
|
}
|
|
|
|
// ------------------------- connect -------------------------
|
|
|
|
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) {
|
|
wantAudio := audioSink != nil
|
|
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio)
|
|
// ---- control stream (50001): handshake → login → token → conninfo ----
|
|
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctrl, err := net.DialUDP("udp4", nil, craddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dial control: %w", err)
|
|
}
|
|
cID := icnLocalID(ctrl)
|
|
cRemote, err := icnHandshake(ctrl, cID, cancel)
|
|
if err != nil {
|
|
_ = ctrl.Close()
|
|
debugLog.Printf("icom net: control handshake FAILED (rig unreachable at %s:50001?): %v", host, err)
|
|
return nil, fmt.Errorf("control handshake: %w", err)
|
|
}
|
|
debugLog.Printf("icom net: control link up (rig id 0x%08X) — logging in", cRemote)
|
|
|
|
var cTracked, cInner uint16 = 1, 1
|
|
tokReq := uint16(0x0c77)
|
|
_, _ = ctrl.Write(icnLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
|
cTracked++
|
|
cInner++
|
|
|
|
var token uint32
|
|
buf := make([]byte, 2048)
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for token == 0 && time.Now().Before(deadline) {
|
|
if icnCanceled(cancel) {
|
|
_ = ctrl.Close()
|
|
return nil, errDialCanceled
|
|
}
|
|
p, ok := icnRecv(ctrl, 200, buf)
|
|
if !ok {
|
|
continue
|
|
}
|
|
length := icnLE.Uint32(p[0:])
|
|
typ := icnLE.Uint16(p[4:])
|
|
if typ == 0x00 && length == 0x60 && len(p) >= 0x34 { // login response
|
|
token = icnLE.Uint32(p[0x1c:])
|
|
if e := icnLE.Uint32(p[0x30:]); e != 0 || token == 0 {
|
|
_ = ctrl.Close()
|
|
debugLog.Printf("icom net: LOGIN REJECTED (err=0x%08X) — wrong Network User1 ID/Password", e)
|
|
return nil, fmt.Errorf("login rejected — check the rig's Network User1 ID/Password")
|
|
}
|
|
_, _ = ctrl.Write(icnToken(cTracked, cInner, tokReq, cID, cRemote, token))
|
|
cTracked++
|
|
cInner++
|
|
debugLog.Printf("icom net: LOGIN OK, token 0x%08X", token)
|
|
} else if typ == 0x07 {
|
|
_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
|
|
}
|
|
}
|
|
if token == 0 {
|
|
_ = ctrl.Close()
|
|
debugLog.Printf("icom net: login TIMED OUT (no token in 5s) — check host/credentials")
|
|
return nil, fmt.Errorf("login timed out (no token) — check host/credentials")
|
|
}
|
|
|
|
// Learn the rig's MAC from its conninfo push (144B) to echo in our conninfo.
|
|
var rigMAC []byte
|
|
macEnd := time.Now().Add(1200 * time.Millisecond)
|
|
for time.Now().Before(macEnd) {
|
|
if icnCanceled(cancel) {
|
|
_ = ctrl.Close()
|
|
return nil, errDialCanceled
|
|
}
|
|
p, ok := icnRecv(ctrl, 150, buf)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if len(p) >= 0x30 && icnLE.Uint32(p[0:]) == 0x90 { // 144-byte conninfo push
|
|
rigMAC = append([]byte(nil), p[0x2a:0x30]...)
|
|
}
|
|
if icnLE.Uint16(p[4:]) == 0x07 {
|
|
_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
|
|
}
|
|
if rigMAC != nil {
|
|
break
|
|
}
|
|
}
|
|
if rigMAC == nil {
|
|
rigMAC = make([]byte, 6)
|
|
}
|
|
|
|
var rxEnable byte
|
|
if wantAudio {
|
|
rxEnable = 0x01 // ask the rig to stream RX audio on 50003
|
|
}
|
|
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable))
|
|
cTracked++
|
|
cInner++
|
|
drainEnd := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(drainEnd) {
|
|
if icnCanceled(cancel) {
|
|
_ = ctrl.Close()
|
|
return nil, errDialCanceled
|
|
}
|
|
if p, ok := icnRecv(ctrl, 100, buf); ok && icnLE.Uint16(p[4:]) == 0x07 {
|
|
_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
|
|
}
|
|
}
|
|
|
|
// ---- CI-V stream (50002): bind LOCAL :50002 (the announced civport) ----
|
|
vraddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50002"))
|
|
if err != nil {
|
|
_ = ctrl.Close()
|
|
return nil, err
|
|
}
|
|
debugLog.Printf("icom net: conninfo sent (rig mac % X) — opening CI-V stream", rigMAC)
|
|
civ, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50002}, vraddr)
|
|
if err != nil {
|
|
_ = ctrl.Close()
|
|
debugLog.Printf("icom net: cannot bind local :50002 — the Icom Remote Utility is probably still running: %v", err)
|
|
return nil, fmt.Errorf("dial CI-V (local :50002 — is the Icom Remote Utility still running?): %w", err)
|
|
}
|
|
vID := icnLocalID(civ)
|
|
vRemote, err := icnHandshake(civ, vID, cancel)
|
|
if err != nil {
|
|
_ = civ.Close()
|
|
_ = ctrl.Close()
|
|
debugLog.Printf("icom net: CI-V handshake FAILED: %v", err)
|
|
return nil, fmt.Errorf("CI-V handshake: %w", err)
|
|
}
|
|
debugLog.Printf("icom net: CI-V link up — opening the CI-V data flow (rig power left to the ON button)")
|
|
|
|
// Bigger receive buffers so a burst of scope/CI-V packets doesn't overflow
|
|
// (dropped packets → the rig's retransmit requests → session drop).
|
|
_ = ctrl.SetReadBuffer(1 << 20)
|
|
_ = civ.SetReadBuffer(1 << 20)
|
|
|
|
n := &icomNet{
|
|
ctrl: ctrl, civ: civ,
|
|
cID: cID, cRemote: cRemote, vID: vID, vRemote: vRemote,
|
|
vTracked: 1, vCivSeq: 1,
|
|
rx: make(chan []byte, 256),
|
|
scopeRx: make(chan []byte, 8),
|
|
sentBuf: make(map[uint16][]byte),
|
|
rxMissing: make(map[uint16]int),
|
|
done: make(chan struct{}),
|
|
// Auth state for periodic token renewal (see ctrlPump). cTracked/cAuthSeq
|
|
// continue the control-stream sequences from where the dial's login/token/
|
|
// conninfo left off.
|
|
cTracked: cTracked, cAuthSeq: cInner,
|
|
cToken: token, cTokReq: tokReq,
|
|
cSentBuf: make(map[uint16][]byte),
|
|
}
|
|
n.markRx() // the successful handshake counts as initial rig activity
|
|
// openClose(open) starts the CI-V data flow. We intentionally DO NOT power the
|
|
// rig on here — that's a manual ON button now (the user asked not to wake the
|
|
// rig at launch). If the rig is in standby the control/CI-V streams still stay
|
|
// up and Alive() stays true (the rig's server answers pings even when the radio
|
|
// is off), so the session doesn't flap; CI-V just stays silent until ON.
|
|
ocPkt := icnOpenClose(n.vTracked, vID, vRemote, n.vCivSeq, 0x04)
|
|
n.sentBuf[n.vTracked] = ocPkt
|
|
_, _ = civ.Write(ocPkt)
|
|
n.vTracked++
|
|
n.vCivSeq++
|
|
|
|
go n.ctrlPump()
|
|
go n.civPump()
|
|
|
|
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
|
|
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
|
|
// CAT works without audio, so we log and continue rather than tear down a
|
|
// perfectly good control/CI-V session.
|
|
if wantAudio {
|
|
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
|
|
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
|
|
} else {
|
|
n.audio = a
|
|
}
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// icnHandshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady.
|
|
func icnHandshake(c *net.UDPConn, myID uint32, cancel <-chan struct{}) (uint32, error) {
|
|
buf := make([]byte, 2048)
|
|
_, _ = c.Write(icnCtrl(0x03, 0, myID, 0))
|
|
var remoteID uint32
|
|
deadline := time.Now().Add(4 * time.Second)
|
|
lastTry := time.Now()
|
|
for time.Now().Before(deadline) {
|
|
if icnCanceled(cancel) {
|
|
return 0, errDialCanceled
|
|
}
|
|
p, ok := icnRecv(c, 200, buf)
|
|
if !ok {
|
|
if remoteID == 0 && time.Since(lastTry) > 500*time.Millisecond {
|
|
_, _ = c.Write(icnCtrl(0x03, 0, myID, 0))
|
|
lastTry = time.Now()
|
|
}
|
|
continue
|
|
}
|
|
typ := icnLE.Uint16(p[4:])
|
|
sentid := icnLE.Uint32(p[8:])
|
|
switch typ {
|
|
case 0x04: // iAmHere
|
|
remoteID = sentid
|
|
_, _ = c.Write(icnCtrl(0x06, 1, myID, remoteID))
|
|
case 0x06: // iAmReady
|
|
if remoteID != 0 {
|
|
return remoteID, nil
|
|
}
|
|
case 0x07: // ping
|
|
_, _ = c.Write(icnPingReply(p, myID, remoteID))
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("handshake timeout")
|
|
}
|
|
|
|
func icnRecv(c *net.UDPConn, ms int, buf []byte) ([]byte, bool) {
|
|
_ = c.SetReadDeadline(time.Now().Add(time.Duration(ms) * time.Millisecond))
|
|
k, err := c.Read(buf)
|
|
if err != nil || k < 16 {
|
|
return nil, false
|
|
}
|
|
return buf[:k], true
|
|
}
|
|
|
|
func icnLocalID(c *net.UDPConn) uint32 {
|
|
a := c.LocalAddr().(*net.UDPAddr)
|
|
ip := a.IP.To4()
|
|
if ip == nil {
|
|
ip = []byte{192, 168, 0, 1}
|
|
}
|
|
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(uint16(a.Port))
|
|
}
|
|
|
|
// ------------------------- packet builders -------------------------
|
|
// (offsets verified vs wfview structs + real captures)
|
|
|
|
func icnCtrl(typ, seq uint16, sentid, rcvdid uint32) []byte {
|
|
b := make([]byte, 16)
|
|
icnLE.PutUint32(b[0:], 0x10)
|
|
icnLE.PutUint16(b[4:], typ)
|
|
icnLE.PutUint16(b[6:], seq)
|
|
icnLE.PutUint32(b[8:], sentid)
|
|
icnLE.PutUint32(b[12:], rcvdid)
|
|
return b
|
|
}
|
|
|
|
func icnPingReply(pkt []byte, myID, remoteID uint32) []byte {
|
|
r := append([]byte(nil), pkt...)
|
|
if len(r) >= 17 {
|
|
icnLE.PutUint32(r[8:], myID)
|
|
icnLE.PutUint32(r[12:], remoteID)
|
|
r[16] = 0x01
|
|
}
|
|
return r
|
|
}
|
|
|
|
func icnLogin(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user, pass, name string) []byte {
|
|
b := make([]byte, 0x80)
|
|
icnLE.PutUint32(b[0:], 0x80)
|
|
icnLE.PutUint16(b[6:], seq)
|
|
icnLE.PutUint32(b[8:], sentid)
|
|
icnLE.PutUint32(b[12:], rcvdid)
|
|
icnBE.PutUint32(b[0x10:], 0x80-0x10)
|
|
b[0x14] = 0x01
|
|
b[0x15] = 0x00
|
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
|
icnLE.PutUint32(b[0x1c:], token)
|
|
copy(b[0x40:0x50], icnPasscode(user))
|
|
copy(b[0x50:0x60], icnPasscode(pass))
|
|
nm := name
|
|
if len(nm) > 16 {
|
|
nm = nm[:16]
|
|
}
|
|
copy(b[0x60:0x70], []byte(nm))
|
|
return b
|
|
}
|
|
|
|
func icnToken(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
|
|
b := make([]byte, 0x40)
|
|
icnLE.PutUint32(b[0:], 0x40)
|
|
icnLE.PutUint16(b[6:], seq)
|
|
icnLE.PutUint32(b[8:], sentid)
|
|
icnLE.PutUint32(b[12:], rcvdid)
|
|
icnBE.PutUint32(b[0x10:], 0x40-0x10)
|
|
b[0x14] = 0x01
|
|
b[0x15] = 0x02
|
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
|
icnLE.PutUint32(b[0x1c:], token)
|
|
return b
|
|
}
|
|
|
|
// icnTokenRenew builds the periodic token-renewal packet (control 0x40). Same as
|
|
// the login-time token confirm but requesttype 0x05 (renew) with the resetcap
|
|
// field (0x0798 BE @0x24) the Remote Utility sends on renewals. Keeps the rig
|
|
// from invalidating the session (~2-min timeout without renewal). Offsets per the
|
|
// wfview token_packet struct (verified) — protocol facts, not copied code.
|
|
func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
|
|
b := make([]byte, 0x40)
|
|
icnLE.PutUint32(b[0:], 0x40)
|
|
icnLE.PutUint16(b[6:], seq)
|
|
icnLE.PutUint32(b[8:], sentid)
|
|
icnLE.PutUint32(b[12:], rcvdid)
|
|
icnBE.PutUint32(b[0x10:], 0x40-0x10)
|
|
b[0x14] = 0x01 // requestreply = request
|
|
b[0x15] = 0x05 // requesttype = token renewal
|
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
|
icnLE.PutUint32(b[0x1c:], token)
|
|
icnBE.PutUint16(b[0x24:], 0x0798) // resetcap
|
|
return b
|
|
}
|
|
|
|
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte {
|
|
b := make([]byte, 0x90)
|
|
icnLE.PutUint32(b[0:], 0x90)
|
|
icnLE.PutUint16(b[6:], seq)
|
|
icnLE.PutUint32(b[8:], sentid)
|
|
icnLE.PutUint32(b[12:], rcvdid)
|
|
icnBE.PutUint32(b[0x10:], 0x90-0x10)
|
|
b[0x14] = 0x01
|
|
b[0x15] = 0x03 // requesttype = conninfo / open streams
|
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
|
icnLE.PutUint32(b[0x1c:], token)
|
|
icnLE.PutUint16(b[0x27:], 0x8010) // commoncap
|
|
copy(b[0x2a:0x30], rigMAC)
|
|
copy(b[0x40:0x60], []byte("IC-7610"))
|
|
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
|
|
icnBE.PutUint32(b[0x74:], 16000)
|
|
icnBE.PutUint32(b[0x78:], 8000)
|
|
icnBE.PutUint32(b[0x7c:], uint32(civPort))
|
|
icnBE.PutUint32(b[0x80:], uint32(audioPort))
|
|
icnBE.PutUint32(b[0x84:], 100)
|
|
b[0x88] = 0x00
|
|
return b
|
|
}
|
|
|
|
func icnOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
|
|
b := make([]byte, 0x16)
|
|
icnLE.PutUint32(b[0:], 0x16)
|
|
icnLE.PutUint16(b[6:], seq)
|
|
icnLE.PutUint32(b[8:], sentid)
|
|
icnLE.PutUint32(b[12:], rcvdid)
|
|
icnLE.PutUint16(b[0x10:], 0x01c0)
|
|
icnBE.PutUint16(b[0x13:], civSeq)
|
|
b[0x15] = magic
|
|
return b
|
|
}
|
|
|
|
func icnCivData(seq uint16, sentid, rcvdid uint32, civSeq uint16, civ []byte) []byte {
|
|
nn := 0x15 + len(civ)
|
|
b := make([]byte, nn)
|
|
icnLE.PutUint32(b[0:], uint32(nn))
|
|
icnLE.PutUint16(b[6:], seq)
|
|
icnLE.PutUint32(b[8:], sentid)
|
|
icnLE.PutUint32(b[12:], rcvdid)
|
|
b[0x10] = 0xc1
|
|
icnLE.PutUint16(b[0x11:], uint16(len(civ)))
|
|
icnBE.PutUint16(b[0x13:], civSeq)
|
|
copy(b[0x15:], civ)
|
|
return b
|
|
}
|
|
|
|
// icnPasscodeSeq — Icom's obfuscation table (values at index 0x20..0x7e).
|
|
// VERIFIED: user "f6bgc" → 3F 65 50 25 55 (matches the capture).
|
|
var icnPasscodeSeq = [256]byte{
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41, 0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e,
|
|
0x7c, 0x65, 0x7d, 0x49, 0x29, 0x72, 0x73, 0x78, 0x21, 0x6e, 0x5a, 0x5e, 0x4a, 0x3e, 0x71, 0x2c, 0x2a, 0x54, 0x3c, 0x3a, 0x63, 0x4f,
|
|
0x43, 0x75, 0x27, 0x79, 0x5b, 0x35, 0x70, 0x48, 0x6b, 0x56, 0x6f, 0x34, 0x32, 0x6c, 0x30, 0x61, 0x6d, 0x7b, 0x2f, 0x4b, 0x64, 0x38,
|
|
0x2b, 0x2e, 0x50, 0x40, 0x3f, 0x55, 0x33, 0x37, 0x25, 0x77, 0x24, 0x26, 0x74, 0x6a, 0x28, 0x53, 0x4d, 0x69, 0x22, 0x5c, 0x44, 0x31,
|
|
0x36, 0x58, 0x3b, 0x7a, 0x51, 0x5f, 0x52,
|
|
}
|
|
|
|
func icnPasscode(s string) []byte {
|
|
out := make([]byte, 0, len(s))
|
|
for i := 0; i < len(s) && i < 16; i++ {
|
|
p := int(s[i]) + i
|
|
if p > 126 {
|
|
p = 32 + p%127
|
|
}
|
|
out = append(out, icnPasscodeSeq[p])
|
|
}
|
|
return out
|
|
}
|