Files
OpsLog/internal/cat/icomnet.go
T

852 lines
29 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.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *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)
}
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
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 {
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)
// 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 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
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
}
}
if time.Since(lastIdle) > 100*time.Millisecond {
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
lastIdle = time.Now()
}
if time.Since(lastToken) > 45*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
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{}) (*icomNet, error) {
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr)
// ---- 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)
}
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
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()
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) []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] = 0x00 // rxenable (audio off — CI-V only)
b[0x71] = 0x00 // txenable
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
}