// Command icomnettest is an iteration probe for the Icom IP remote protocol — // the LAN server built into the IC-7610 that the Icom "Remote Utility" (and // wfview) talk to. OpsLog reimplements this directly so it can BE both the // Remote Utility (Ethernet ↔ radio) and the logger/CAT client, dropping the // virtual-COM + RS-BA1 chain entirely. // // This probe drives TWO streams and hex-dumps everything: // Control (UDP 50001): handshake → login → token [VERIFIED on the real rig] // CI-V (UDP 50002): handshake → openClose(open) → send CI-V read-freq // (FE FE 98 E0 03 FD) → print the rig's reply. // Framing (passcode table, packet offsets, CI-V data_packet, openclose) is // reimplemented from the public wfview protocol and verified byte-for-byte // against real Remote-Utility captures (build/bin/civ*.pcapng). No GPLv3 code. // // Usage: // go run ./cmd/icomnettest [compname] // // SAFE: read-only CI-V (operating frequency). No TX, no writes. package main import ( "encoding/binary" "encoding/hex" "fmt" "net" "os" "time" ) var le = binary.LittleEndian var be = binary.BigEndian // passcodeSeq — Icom's obfuscation table (values live at index 0x20..0x7e). // VERIFIED: user "f6bgc" → 3F 65 50 25 55 (matches the capture). var passcodeSeq = [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 passcode(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, passcodeSeq[p]) } return out } // --- packet builders (offsets verified vs wfview structs + real captures) --- func ctrlPacket(typ, seq uint16, sentid, rcvdid uint32) []byte { b := make([]byte, 16) le.PutUint32(b[0:], 0x10) le.PutUint16(b[4:], typ) le.PutUint16(b[6:], seq) le.PutUint32(b[8:], sentid) le.PutUint32(b[12:], rcvdid) return b } func buildLogin(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user, pass, name string) []byte { b := make([]byte, 0x80) le.PutUint32(b[0:], 0x80) le.PutUint16(b[6:], seq) le.PutUint32(b[8:], sentid) le.PutUint32(b[12:], rcvdid) be.PutUint32(b[0x10:], 0x80-0x10) b[0x14] = 0x01 // requestreply b[0x15] = 0x00 // requesttype = login be.PutUint16(b[0x16:], innerSeq) le.PutUint16(b[0x1a:], tokReq) le.PutUint32(b[0x1c:], token) copy(b[0x40:0x50], passcode(user)) copy(b[0x50:0x60], passcode(pass)) nm := name if len(nm) > 16 { nm = nm[:16] } copy(b[0x60:0x70], []byte(nm)) return b } func buildToken(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte { b := make([]byte, 0x40) le.PutUint32(b[0:], 0x40) le.PutUint16(b[6:], seq) le.PutUint32(b[8:], sentid) le.PutUint32(b[12:], rcvdid) be.PutUint32(b[0x10:], 0x40-0x10) b[0x14] = 0x01 // requestreply b[0x15] = 0x02 // requesttype = token be.PutUint16(b[0x16:], innerSeq) le.PutUint16(b[0x1a:], tokReq) le.PutUint32(b[0x1c:], token) return b } // buildConnInfo — 144-byte sendRequestStream on the CONTROL stream. Tells the // rig to route the CI-V/audio streams to the authenticated session and which // local ports we use. Values verified byte-for-byte vs a real Remote-Utility // capture (civ4): requesttype=0x03, commoncap=0x8010, the rig's MAC echoed, // name "IC-7610", scrambled username, rxenable=0 (audio off — CI-V only), // rxcodec 0x10 / txcodec 0x04, rxsample 16000 / txsample 8000 (BE), civport / // audioport (BE), txbuffer 100. func buildConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte { b := make([]byte, 0x90) le.PutUint32(b[0:], 0x90) le.PutUint16(b[6:], seq) le.PutUint32(b[8:], sentid) le.PutUint32(b[12:], rcvdid) be.PutUint32(b[0x10:], 0x90-0x10) b[0x14] = 0x01 // requestreply b[0x15] = 0x03 // requesttype = conninfo / open streams be.PutUint16(b[0x16:], innerSeq) le.PutUint16(b[0x1a:], tokReq) le.PutUint32(b[0x1c:], token) le.PutUint16(b[0x27:], 0x8010) // commoncap copy(b[0x2a:0x30], rigMAC) // macaddress (the rig's, echoed back) copy(b[0x40:0x60], []byte("IC-7610")) copy(b[0x60:0x70], passcode(user)) b[0x70] = 0x00 // rxenable (0 = audio off) b[0x71] = 0x00 // txenable b[0x72] = 0x10 // rxcodec b[0x73] = 0x04 // txcodec be.PutUint32(b[0x74:], 16000) // rxsample be.PutUint32(b[0x78:], 8000) // txsample be.PutUint32(b[0x7c:], uint32(civPort)) be.PutUint32(b[0x80:], uint32(audioPort)) be.PutUint32(b[0x84:], 100) // txbuffer b[0x88] = 0x00 // convert return b } // buildOpenClose — 22-byte start/stop for the CI-V stream. magic 0x04=open, // 0x00=close. data=0x01c0 (@0x10), civSeq (BE @0x13), magic (@0x15). func buildOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte { b := make([]byte, 0x16) le.PutUint32(b[0:], 0x16) le.PutUint16(b[6:], seq) le.PutUint32(b[8:], sentid) le.PutUint32(b[12:], rcvdid) le.PutUint16(b[0x10:], 0x01c0) be.PutUint16(b[0x13:], civSeq) b[0x15] = magic return b } // buildCivData — wraps raw CI-V bytes: 21-byte header (reply 0xc1 @0x10, // datalen LE @0x11, civSeq BE @0x13) + CI-V frame @0x15. func buildCivData(seq uint16, sentid, rcvdid uint32, civSeq uint16, civ []byte) []byte { n := 0x15 + len(civ) b := make([]byte, n) le.PutUint32(b[0:], uint32(n)) le.PutUint16(b[6:], seq) le.PutUint32(b[8:], sentid) le.PutUint32(b[12:], rcvdid) b[0x10] = 0xc1 le.PutUint16(b[0x11:], uint16(len(civ))) be.PutUint16(b[0x13:], civSeq) copy(b[0x15:], civ) return b } func header(b []byte) (length uint32, typ, seq uint16, sentid, rcvdid uint32, ok bool) { if len(b) < 16 { return 0, 0, 0, 0, 0, false } return le.Uint32(b[0:]), le.Uint16(b[4:]), le.Uint16(b[6:]), le.Uint32(b[8:]), le.Uint32(b[12:]), true } func localID(conn net.Conn) uint32 { a := conn.LocalAddr().(*net.UDPAddr) return uint32(a.IP.To4()[0])<<24 | uint32(a.IP.To4()[1])<<16 | uint32(uint16(a.Port)) } func recv(conn net.Conn, ms int, buf []byte) ([]byte, bool) { _ = conn.SetReadDeadline(time.Now().Add(time.Duration(ms) * time.Millisecond)) n, err := conn.Read(buf) if err != nil { return nil, false } return append([]byte(nil), buf[:n]...), true } func dump(tag string, p []byte) { fmt.Printf("%s (%d)\n%s\n", tag, len(p), hex.Dump(p)) } // pingReply mirrors a ping, swaps ids, sets the reply flag at offset 16. func pingReply(pkt []byte, myID, remoteID uint32) []byte { r := append([]byte(nil), pkt...) if len(r) >= 17 { le.PutUint32(r[8:], myID) le.PutUint32(r[12:], remoteID) r[16] = 0x01 } return r } // handshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady. // Returns the rig's remote id. Replies to any pings meanwhile. func handshake(conn net.Conn, myID uint32, label string) (uint32, bool) { buf := make([]byte, 2048) conn.Write(ctrlPacket(0x03, 0, myID, 0)) // areYouThere fmt.Printf("[%s] TX areYouThere\n", label) var remoteID uint32 deadline := time.Now().Add(4 * time.Second) lastTry := time.Now() for time.Now().Before(deadline) { p, ok := recv(conn, 200, buf) if !ok { if remoteID == 0 && time.Since(lastTry) > 500*time.Millisecond { conn.Write(ctrlPacket(0x03, 0, myID, 0)) lastTry = time.Now() } continue } _, typ, _, sentid, _, ok := header(p) if !ok { continue } switch typ { case 0x04: // iAmHere remoteID = sentid fmt.Printf("[%s] iAmHere remoteID=0x%08X → TX areYouReady\n", label, remoteID) conn.Write(ctrlPacket(0x06, 1, myID, remoteID)) case 0x06: // iAmReady if remoteID != 0 { fmt.Printf("[%s] iAmReady — link up ✓\n", label) return remoteID, true } case 0x07: // ping conn.Write(pingReply(p, myID, remoteID)) } } return remoteID, false } func main() { if len(os.Args) < 4 { fmt.Println("usage: icomnettest [compname]") os.Exit(2) } ip, user, pass := os.Args[1], os.Args[2], os.Args[3] compName := "OpsLog" if len(os.Args) >= 5 { compName = os.Args[4] } // ===================== CONTROL STREAM (50001) ===================== ctrl, err := net.Dial("udp4", net.JoinHostPort(ip, "50001")) if err != nil { fmt.Printf("dial control: %v\n", err) os.Exit(1) } defer ctrl.Close() cID := localID(ctrl) fmt.Printf("=== CONTROL 50001 (myID=0x%08X) ===\n", cID) fmt.Printf("scrambled user=% X pass=% X\n\n", passcode(user), passcode(pass)) cRemote, ok := handshake(ctrl, cID, "ctrl") if !ok { fmt.Println("control handshake failed") return } // login → token var cTracked uint16 = 1 var cInner uint16 = 1 tokReq := uint16(0x0c77) dump("[ctrl] TX login", buildLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName)) ctrl.Write(buildLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName)) cTracked++ cInner++ var token uint32 buf := make([]byte, 2048) deadline := time.Now().Add(4 * time.Second) for token == 0 && time.Now().Before(deadline) { p, ok := recv(ctrl, 200, buf) if !ok { continue } length, typ, _, _, _, _ := header(p) if typ == 0x00 && length == 0x60 && len(p) >= 0x34 { // login response token = le.Uint32(p[0x1c:]) errCode := le.Uint32(p[0x30:]) if errCode != 0 || token == 0 { fmt.Printf(">> LOGIN REJECTED err=0x%08X token=0x%08X\n", errCode, token) return } fmt.Printf(">> LOGIN OK ✓ token=0x%08X\n", token) ctrl.Write(buildToken(cTracked, cInner, tokReq, cID, cRemote, token)) cTracked++ cInner++ } else if typ == 0x07 { ctrl.Write(pingReply(p, cID, cRemote)) } } if token == 0 { fmt.Println("no token — login not accepted") return } // Send conninfo on the control stream — routes the CI-V stream to this // authenticated session and announces our civ/audio local ports (50002/3). rigMAC := []byte{0x00, 0x90, 0xc7, 0x09, 0xba, 0x3f} // F6BGC's IC-7610 (from the caps packet) dump("[ctrl] TX conninfo", buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003)) ctrl.Write(buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003)) cTracked++ cInner++ // Let the rig's caps/conninfo replies flow for ~600ms (reply to pings). drainEnd := time.Now().Add(600 * time.Millisecond) for time.Now().Before(drainEnd) { if p, ok := recv(ctrl, 100, buf); ok { if _, typ, _, _, _, _ := header(p); typ == 0x07 { ctrl.Write(pingReply(p, cID, cRemote)) } } } // ===================== CI-V STREAM (50002) ===================== // Bind our civ socket to LOCAL port 50002 (= the civport announced above), // as the Remote Utility does. Requires the Remote Utility to be CLOSED. civ, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50002}, &net.UDPAddr{IP: net.ParseIP(ip), Port: 50002}) if err != nil { fmt.Printf("dial civ (local :50002 — is the Remote Utility still running?): %v\n", err) return } defer civ.Close() vID := localID(civ) fmt.Printf("\n=== CI-V 50002 (myID=0x%08X) ===\n", vID) vRemote, ok := handshake(civ, vID, "civ") if !ok { fmt.Println("CI-V handshake failed (may need the conninfo packet on control first)") return } var vTracked uint16 = 1 // outer tracked seq @0x06 var vCivSeq uint16 = 1 // inner CI-V seq @0x13 (BE) // openClose(open) starts CI-V data flow. dump("[civ] TX openClose(open)", buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x04)) civ.Write(buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x04)) vTracked++ vCivSeq++ // Try several read commands, spaced out. Some rigs NG the basic 0x03 read // over the network tunnel; 0x25 / 0x04 and unsolicited transceive frames // (sent when you turn the VFO) still work. The tunnel itself is proven, so // this figures out which read the rig actually answers. sendCiv := func(name string, f []byte) { fmt.Printf("[civ] TX %s\n", name) civ.Write(buildCivData(vTracked, vID, vRemote, vCivSeq, f)) vTracked++ vCivSeq++ } // The rig is in STANDBY (network up, radio off) — it NG's every command // until powered on via CI-V. Send power-on (0x18 0x01, with an FE wake // preamble, as the Remote Utility does), then poll read-freq while it boots. powerOn := make([]byte, 0, 32) for i := 0; i < 25; i++ { powerOn = append(powerOn, 0xFE) } powerOn = append(powerOn, 0xFE, 0xFE, 0x98, 0xE0, 0x18, 0x01, 0xFD) time.Sleep(300 * time.Millisecond) sendCiv("POWER ON (0x18 01)", powerOn) fmt.Print("\n>>> rig booting (~10-15 s) — polling read-freq until it answers <<<\n\n") readFreq := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD} cbuf := make([]byte, 4096) vbuf := make([]byte, 4096) end := time.Now().Add(30 * time.Second) lastIdleC, lastIdleV, lastCmd := time.Now(), time.Now(), time.Now() for time.Now().Before(end) { if p, ok := recv(ctrl, 40, cbuf); ok { if _, typ, _, _, _, _ := header(p); typ == 0x07 { ctrl.Write(pingReply(p, cID, cRemote)) } } else if time.Since(lastIdleC) > 200*time.Millisecond { ctrl.Write(ctrlPacket(0x00, 0, cID, cRemote)) lastIdleC = time.Now() } if p, ok := recv(civ, 40, vbuf); ok { _, typ, _, _, _, _ := header(p) if typ == 0x07 { civ.Write(pingReply(p, vID, vRemote)) } else if typ == 0x00 && len(p) > 0x15 && p[0x10] == 0xc1 { f := p[0x15:] if d := decodeCiv(f); d != "" { fmt.Printf(">> CI-V RX: % X %s\n", f, d) } } } else if time.Since(lastIdleV) > 200*time.Millisecond { civ.Write(ctrlPacket(0x00, 0, vID, vRemote)) lastIdleV = time.Now() } if time.Since(lastCmd) > 1000*time.Millisecond { sendCiv("read-freq 0x03", readFreq) lastCmd = time.Now() } } // Clean close. civ.Write(buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x00)) // openClose(close) civ.Write(ctrlPacket(0x05, 0, vID, vRemote)) // disconnect ctrl.Write(ctrlPacket(0x05, 0, cID, cRemote)) fmt.Println("\nDone. Look for '>> CI-V RX:' and 'FREQUENCY reply'.") } // decodeCiv describes a received CI-V frame (FE FE … FD). // Only frames FROM the rig (from=0x98) are interesting; our own echoed commands // (from=0xE0) return "" so they're not printed. func decodeCiv(f []byte) string { if len(f) < 6 || f[0] != 0xFE || f[1] != 0xFE { return "" } if f[3] != 0x98 { // not from the rig (our echoed command) — skip return "" } cmd := f[4] body := f[5 : len(f)-1] // between cmd and the trailing FD switch cmd { case 0xFA: return "NG (command rejected)" case 0xFB: return "OK (ack)" case 0x00, 0x03, 0x05: // (transceive) freq / read-freq if len(body) >= 5 { return "FREQ " + decodeFreq(body[:5]) } case 0x25: // read/set VFO freq (body = subcmd + 5 BCD) if len(body) >= 6 { return fmt.Sprintf("VFO%d FREQ %s", body[0], decodeFreq(body[1:6])) } case 0x01, 0x04: // (transceive) mode / read-mode if len(body) >= 1 { return fmt.Sprintf("MODE 0x%02X filt 0x%02X", body[0], lastOr(body, 1)) } } return fmt.Sprintf("cmd 0x%02X", cmd) } func lastOr(b []byte, i int) byte { if i < len(b) { return b[i] } return 0 } // decodeFreq turns Icom little-endian BCD (5 bytes) into a MHz string. func decodeFreq(bcd []byte) string { var hz uint64 mul := uint64(1) for _, b := range bcd { hz += uint64(b&0x0f) * mul mul *= 10 hz += uint64(b>>4) * mul mul *= 10 } return fmt.Sprintf("%.6f MHz", float64(hz)/1e6) }