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 // 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 { 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 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 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 }