diff --git a/app.go b/app.go index c195868..a6fdc65 100644 --- a/app.go +++ b/app.go @@ -93,6 +93,9 @@ const ( keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200) keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98) + keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname + keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID + keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2) keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001) keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama @@ -265,7 +268,7 @@ type QSLDefaults struct { // individual key/value pairs to keep the settings table flat. type CATSettings struct { Enabled bool `json:"enabled"` - Backend string `json:"backend"` // "omnirig" | "flex" | "icom" + Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci" OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot) FlexHost string `json:"flex_host"` // FlexRadio IP (native backend) FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992) @@ -273,6 +276,9 @@ type CATSettings struct { IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) + IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server) + IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID + IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR) TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001) TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama @@ -4276,7 +4282,7 @@ func (a *App) GetCATSettings() (CATSettings, error) { if a.settings == nil { return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") } - m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) + m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) if err != nil { return CATSettings{}, err } @@ -4290,6 +4296,9 @@ func (a *App) GetCATSettings() (CATSettings, error) { IcomPort: m[keyCATIcomPort], IcomBaud: 115200, IcomAddr: 0x98, // IC-7610 default + IcomNetHost: m[keyCATIcomNetHost], + IcomNetUser: m[keyCATIcomNetUser], + IcomNetPass: m[keyCATIcomNetPass], TCIHost: m[keyCATTCIHost], TCIPort: 40001, TCISpots: m[keyCATTCISpots] == "1", @@ -4378,6 +4387,9 @@ func (a *App) SaveCATSettings(s CATSettings) error { keyCATIcomPort: strings.TrimSpace(s.IcomPort), keyCATIcomBaud: strconv.Itoa(s.IcomBaud), keyCATIcomAddr: strconv.Itoa(s.IcomAddr), + keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost), + keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser), + keyCATIcomNetPass: s.IcomNetPass, keyCATTCIHost: strings.TrimSpace(s.TCIHost), keyCATTCIPort: strconv.Itoa(s.TCIPort), keyCATTCISpots: tciSpots, @@ -8349,8 +8361,12 @@ func (a *App) reloadCAT() { a.cat.Start(fb) case "icom": // Native Icom CI-V over the radio's USB serial port (local control). - // Same civ protocol a future network backend will reuse for remote. + // Same civ protocol the network backend reuses for remote. a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault)) + case "icom-net": + // Icom CI-V over the rig's built-in LAN server (remote, no RS-BA1 / Remote + // Utility). Reuses the whole IcomController over the network transport. + a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault)) case "tci": // Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any // TCI-compatible server. diff --git a/cmd/icomnettest/main.go b/cmd/icomnettest/main.go index 436d426..e30919f 100644 --- a/cmd/icomnettest/main.go +++ b/cmd/icomnettest/main.go @@ -1,23 +1,21 @@ -// Command icomnettest is an iteration probe for the Icom IP remote protocol -// (the LAN server built into the IC-7610 — the one RS-BA1 and wfview talk to). -// We're reimplementing it from the public protocol description, so this tool -// drives the CONTROL stream (default UDP 50001) and hex-dumps every packet both -// ways, letting us confirm the framing / type codes against the real rig before -// folding it into internal/cat/icomnet. Nothing here is copied from wfview -// (GPLv3) — it's a clean-room implementation from the protocol structure. +// 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 first milestone is the CONNECTION HANDSHAKE only (no login yet): -// areYouThere → iAmHere → areYouReady → iAmReady → periodic idle pings. -// Watch the log: if the rig answers our areYouThere we've got the framing right; -// its reply reveals the remote station ID we echo back. Login (token + user/ -// password) is the next step once the handshake is confirmed. +// 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 192.168.1.60 # control port 50001 -// go run ./cmd/icomnettest 192.168.1.60 50001 20 # port + run seconds +// go run ./cmd/icomnettest [compname] // -// SAFE: only the control stream, no CI-V commands, no TX — it just opens and -// pings, then disconnects. Share the log and we iterate. +// SAFE: read-only CI-V (operating frequency). No TX, no writes. package main import ( @@ -26,83 +24,59 @@ import ( "fmt" "net" "os" - "strconv" "time" ) -// Control-stream packet types (best-known values from the public protocol -// description — the very thing we're verifying with this probe). -const ( - typeAreYouThere = 0x03 - typeIAmHere = 0x04 - typeDisconnect = 0x05 - typeAreYouReady = 0x06 // same type both directions (areYouReady / iAmReady) - typeIdle = 0x00 // 16-byte keepalive (retransmit/ack carrier) - typePing = 0x07 // 21-byte ping (offset 16 = 0x00 request / 0x01 reply, +4-byte payload) -) +var le = binary.LittleEndian +var be = binary.BigEndian -// ctrlPacket is the 16-byte common control packet, all fields little-endian: -// -// uint32 len (=0x10) · uint16 type · uint16 seq · uint32 sentid · uint32 rcvdid -func ctrlPacket(typ uint16, seq uint16, sentid, rcvdid uint32) []byte { - b := make([]byte, 16) - binary.LittleEndian.PutUint32(b[0:], 0x10) - binary.LittleEndian.PutUint16(b[4:], typ) - binary.LittleEndian.PutUint16(b[6:], seq) - binary.LittleEndian.PutUint32(b[8:], sentid) - binary.LittleEndian.PutUint32(b[12:], rcvdid) - return b -} - -// passcodeSeq is Icom's fixed obfuscation table for the login username/password -// (used by RS-BA1). BEST-EFFORT public reconstruction — the values that matter -// for a given credential are sequence[char+index]; if the radio rejects auth, -// compare the "scrambled" bytes this tool prints against a real login capture to -// correct the needed entries. +// 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{ - 0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41, - 0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e, 0x20, 0x77, 0x5f, 0x51, 0x3e, 0x70, 0x4d, 0x1f, 0x74, 0x38, - 0x2c, 0x4b, 0x1e, 0x54, 0x30, 0x71, 0x2b, 0x2a, 0x66, 0x27, 0x2e, 0x58, 0x24, 0x21, 0x2f, 0x50, - 0x1b, 0x73, 0x69, 0x36, 0x1d, 0x4f, 0x1c, 0x51, 0x2e, 0x1e, 0x45, 0x2e, 0x22, 0x50, 0x64, 0x66, - 0x24, 0x36, 0x0c, 0x7d, 0x50, 0x25, 0x7c, 0x3f, 0x2d, 0x35, 0x71, 0x6a, 0x0e, 0x41, 0x2a, 0x67, - 0x7c, 0x64, 0x77, 0x67, 0x6d, 0x5b, 0x3d, 0x5b, 0x2b, 0x67, 0x6c, 0x39, 0x35, 0x76, 0x3b, 0x2f, - 0x2f, 0x6d, 0x59, 0x6e, 0x59, 0x77, 0x3b, 0x24, 0x74, 0x7c, 0x6b, 0x37, 0x54, 0x5c, 0x4d, 0x1f, - 0x27, 0x69, 0x5b, 0x2e, 0x28, 0x35, 0x77, 0x74, 0x35, 0x1f, 0x6a, 0x2a, 0x28, 0x30, 0x25, 0x20, + 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, } -// passcode scrambles s (username or password) via the Icom sequence table. func passcode(s string) []byte { - out := make([]byte, len(s)) - for i := 0; i < len(s); i++ { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s) && i < 16; i++ { p := int(s[i]) + i - if p > 0x7f { - p = ((p - 0x7f) % 0x33) - 1 - if p < 0 { - p = 0 - } + if p > 126 { + p = 32 + p%127 } - out[i] = passcodeSeq[p&0xff] + out = append(out, passcodeSeq[p]) } return out } -// buildLogin builds the 0x80-byte login packet: control header + username/ -// password (scrambled) at 0x40/0x50 and the app name at 0x60. The middle fields -// (payload size, request type, inner seq, token request) are a best-effort -// reconstruction and may need adjustment against a capture. -func buildLogin(seq uint16, sentid, rcvdid uint32, innerSeq, tokRequest uint16, user, pass, name string) []byte { +// --- 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) - binary.LittleEndian.PutUint32(b[0:], 0x80) // len - // type (b[4:6]) = 0x00 - binary.LittleEndian.PutUint16(b[6:], seq) - binary.LittleEndian.PutUint32(b[8:], sentid) - binary.LittleEndian.PutUint32(b[12:], rcvdid) - binary.LittleEndian.PutUint32(b[16:], 0x70) // payload size (len - 0x10) - binary.LittleEndian.PutUint16(b[20:], 0x00) // requesttype - binary.LittleEndian.PutUint16(b[22:], 0x01) // requestreply - binary.LittleEndian.PutUint16(b[24:], innerSeq) - binary.LittleEndian.PutUint16(b[26:], tokRequest) - // token (b[0x20:0x24]) = 0 until the rig grants one + 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 @@ -113,140 +87,380 @@ func buildLogin(seq uint16, sentid, rcvdid uint32, innerSeq, tokRequest uint16, return b } -func parseHeader(b []byte) (length uint32, typ, seq uint16, sentid, rcvdid uint32, ok bool) { +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 } - length = binary.LittleEndian.Uint32(b[0:]) - typ = binary.LittleEndian.Uint16(b[4:]) - seq = binary.LittleEndian.Uint16(b[6:]) - sentid = binary.LittleEndian.Uint32(b[8:]) - rcvdid = binary.LittleEndian.Uint32(b[12:]) - return length, typ, seq, sentid, rcvdid, true + 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) < 2 { - fmt.Println("usage: icomnettest [user] [password]") - fmt.Println(" only → handshake + ping probe") - fmt.Println(" → also attempt login") - fmt.Println("example: icomnettest 192.168.1.60 f6bgc cgb6f1") + if len(os.Args) < 4 { + fmt.Println("usage: icomnettest [compname]") os.Exit(2) } - ip := os.Args[1] - port := 50001 - runSecs := 25 - user, pass := "", "" - if len(os.Args) >= 4 { - user, pass = os.Args[2], os.Args[3] + ip, user, pass := os.Args[1], os.Args[2], os.Args[3] + compName := "OpsLog" + if len(os.Args) >= 5 { + compName = os.Args[4] } - target := net.JoinHostPort(ip, strconv.Itoa(port)) - conn, err := net.Dial("udp4", target) + // ===================== CONTROL STREAM (50001) ===================== + ctrl, err := net.Dial("udp4", net.JoinHostPort(ip, "50001")) if err != nil { - fmt.Printf("dial %s: %v\n", target, err) + fmt.Printf("dial control: %v\n", err) os.Exit(1) } - defer conn.Close() + 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)) - // Our local station ID. Real clients derive it from the local IP:port; a - // stable non-zero value is fine for probing. We'll refine once we see how the - // rig echoes it back. - local := conn.LocalAddr().(*net.UDPAddr) - myID := uint32(local.IP.To4()[0])<<24 | uint32(local.IP.To4()[1])<<16 | uint32(uint16(local.Port)) - var remoteID uint32 - var seq uint16 - - logTx := func(name string, p []byte) { - fmt.Printf("TX %-14s (%d bytes)\n%s\n", name, len(p), hex.Dump(p)) - if _, err := conn.Write(p); err != nil { - fmt.Printf(" write error: %v\n", err) - } + cRemote, ok := handshake(ctrl, cID, "ctrl") + if !ok { + fmt.Println("control handshake failed") + return } - fmt.Printf("Probing Icom control stream at %s (myID=0x%08X)\n\n", target, myID) - if user != "" { - fmt.Printf("Login mode: user=%q pass=%q\n", user, pass) - fmt.Printf(" scrambled user = % X\n", passcode(user)) - fmt.Printf(" scrambled pass = % X\n\n", passcode(pass)) - } - var innerSeq uint16 = 0x0001 - var tokRequest uint16 = 0x1234 // fixed for reproducibility (no RNG in this probe) - loginSent := false + // 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++ - // 1) areYouThere — ask the rig to announce itself. - seq++ - logTx("areYouThere", ctrlPacket(typeAreYouThere, seq, myID, 0)) - - // Read loop: dump everything, and advance the handshake when we recognise a - // reply. Runs for runSecs then disconnects. - deadline := time.Now().Add(time.Duration(runSecs) * time.Second) + var token uint32 buf := make([]byte, 2048) - lastIdle := time.Now() - readyStarted := false - for time.Now().Before(deadline) { - _ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) - n, err := conn.Read(buf) - if err != nil { - if ne, ok := err.(net.Error); ok && ne.Timeout() { - // Periodic idle keepalive once connected. - if remoteID != 0 && time.Since(lastIdle) > 100*time.Millisecond { - seq++ - logTx("idle", ctrlPacket(typeIdle, seq, myID, remoteID)) - lastIdle = time.Now() - } - continue - } - fmt.Printf("read error: %v\n", err) - break - } - pkt := append([]byte(nil), buf[:n]...) - length, typ, rseq, sentid, rcvdid, ok := parseHeader(pkt) + deadline := time.Now().Add(4 * time.Second) + for token == 0 && time.Now().Before(deadline) { + p, ok := recv(ctrl, 200, buf) if !ok { - fmt.Printf("RX (%d bytes, too short to parse)\n%s\n", n, hex.Dump(pkt)) continue } - fmt.Printf("RX len=%d type=0x%02X seq=%d sentid=0x%08X rcvdid=0x%08X (%d bytes)\n%s\n", - length, typ, rseq, sentid, rcvdid, n, hex.Dump(pkt)) + 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 + } - switch typ { - case typeIAmHere: - remoteID = sentid // the rig's ID — echo it back as rcvdid from now on - fmt.Printf(">> iAmHere: remoteID=0x%08X — sending areYouReady\n\n", remoteID) - seq++ - logTx("areYouReady", ctrlPacket(typeAreYouReady, seq, myID, remoteID)) - readyStarted = true - case typeAreYouReady: - if readyStarted && !loginSent { - fmt.Printf(">> iAmReady — control link is up.\n\n") - if user != "" { - seq++ - lg := buildLogin(seq, myID, remoteID, innerSeq, tokRequest, user, pass, "OpsLog") - fmt.Printf(">> sending login (user=%q)\n", user) - logTx("login", lg) - loginSent = true - } + // 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)) } - case typePing: - // Reply to the rig's ping: mirror the packet, swap sender/receiver IDs, - // set the reply flag at offset 16. Keeps the link healthy so we can - // observe the connection long enough to work on login. - reply := append([]byte(nil), pkt...) - if len(reply) >= 17 { - binary.LittleEndian.PutUint32(reply[8:], myID) - binary.LittleEndian.PutUint32(reply[12:], remoteID) - reply[16] = 0x01 // request → reply - logTx("pingReply", reply) - } - case typeDisconnect: - fmt.Printf(">> rig sent disconnect\n\n") } } - // Clean disconnect. - if remoteID != 0 { - seq++ - logTx("disconnect", ctrlPacket(typeDisconnect, seq, myID, remoteID)) + // ===================== 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 } - fmt.Println("Done. Paste the log — especially the rig's replies to areYouThere.") + 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) } diff --git a/frontend/src/components/FirstRunModal.tsx b/frontend/src/components/FirstRunModal.tsx index 4c949f8..3c91ae3 100644 --- a/frontend/src/components/FirstRunModal.tsx +++ b/frontend/src/components/FirstRunModal.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; -import { useI18n } from '@/lib/i18n'; +import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n'; import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; @@ -14,7 +14,7 @@ type Profile = Omit; // (no callsign configured yet). It writes straight into the active profile, so // OpsLog has a valid station before any QSO is logged. Not dismissable. export function FirstRunModal({ onDone }: { onDone: () => void }) { - const { t } = useI18n(); + const { t, lang, setLang } = useI18n(); const [p, setP] = useState(null); const [saving, setSaving] = useState(false); const [err, setErr] = useState(''); @@ -68,6 +68,22 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) { return (
+ {/* Language chooser — lives here (and not only in the localStorage-backed + first-launch flag gate) so a fresh setup always offers EN/FR, like the + station identity below. */} +
+
+ {([['en', FlagGB, 'English'], ['fr', FlagFR, 'Français']] as [Lang, typeof FlagGB, string][]).map(([code, Flag, label]) => ( + + ))} +
+
+

{t('frm.welcome')}

diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 4c098d1..0deb382 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -793,7 +793,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [modeDraft, setModeDraft] = useState(''); const [catCfg, setCatCfg] = useState({ enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, - icom_port: '', icom_baud: 115200, icom_addr: 0x98, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, + icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', + tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, digital_default: 'FT8', }); const [rotator, setRotator] = useState({ @@ -1967,6 +1968,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {t('cat.optOmnirig')} {t('cat.optFlex')} {t('cat.optIcom')} + {t('cat.optIcomNet')} {t('cat.optTci')} @@ -2037,6 +2039,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)} + {catCfg.backend === 'icom-net' && ( + <> +
+ + setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} /> +
+
+ + { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} /> +
+
+ + setCatCfg((s) => ({ ...s, icom_net_user: e.target.value }))} /> +
+
+ + setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} /> +
+

{t('cat.icomNetHint')}

+ + )} {catCfg.backend === 'tci' && ( <>
@@ -2058,7 +2085,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan )} - {(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && ( + {(catCfg.backend === 'omnirig' || catCfg.backend === 'icom' || catCfg.backend === 'icom-net') && ( <>
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index c41f45c..b7dafda 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -130,7 +130,9 @@ const en: Dict = { 'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', // CAT panel body - 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', + 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', + 'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password', + 'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.", 'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.', 'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)", @@ -320,7 +322,9 @@ const fr: Dict = { 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.", 'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', - 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', + 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', + 'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau', + 'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.", 'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.', 'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)", diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 2426f37..e1b78b1 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1290,6 +1290,9 @@ export namespace main { icom_port: string; icom_baud: number; icom_addr: number; + icom_net_host: string; + icom_net_user: string; + icom_net_pass: string; tci_host: string; tci_port: number; tci_spots: boolean; @@ -1312,6 +1315,9 @@ export namespace main { this.icom_port = source["icom_port"]; this.icom_baud = source["icom_baud"]; this.icom_addr = source["icom_addr"]; + this.icom_net_host = source["icom_net_host"]; + this.icom_net_user = source["icom_net_user"]; + this.icom_net_pass = source["icom_net_pass"]; this.tci_host = source["tci_host"]; this.tci_port = source["tci_port"]; this.tci_spots = source["tci_spots"]; diff --git a/internal/cat/icomnet.go b/internal/cat/icomnet.go new file mode 100644 index 0000000..3e5fdd0 --- /dev/null +++ b/internal/cat/icomnet.go @@ -0,0 +1,643 @@ +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" + "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") + } + return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr) + } + return b +} + +// 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 + 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 + + done chan struct{} + closeOnce sync.Once +} + +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 } + +// 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-256) // keep the buffer bounded (~last 256 packets) + n.sentMu.Unlock() + if _, err := n.civ.Write(pkt); err != nil { + return 0, err + } + return len(p), nil +} + +// icnTrace toggles verbose CI-V request/reply logging for diagnosing the +// network transport (temporary). +var icnTrace = true + +func (n *icomNet) Close() error { + n.closeOnce.Do(func() { + close(n.done) + // Best-effort clean teardown. + _, _ = n.civ.Write(icnOpenClose(n.vTracked, n.vID, n.vRemote, n.vCivSeq, 0x00)) // close + _, _ = n.civ.Write(icnCtrl(0x05, 0, n.vID, n.vRemote)) // disconnect + _, _ = n.ctrl.Write(icnCtrl(0x05, 0, n.cID, n.cRemote)) + _ = n.civ.Close() + _ = n.ctrl.Close() + }) + return nil +} + +// ctrlPump keeps the control stream (50001) alive: replies to the rig's pings +// and sends idle keepalives. Its own goroutine so it never throttles civPump. +func (n *icomNet) ctrlPump() { + buf := make([]byte, 4096) + lastIdle := time.Now() + 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 { + switch icnLE.Uint16(buf[4:]) { + case 0x07: // ping + _, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote)) + case 0x01: // retransmit request + if k >= 8 { + n.resend(icnLE.Uint16(buf[6:])) + } + } + } + if time.Since(lastIdle) > 150*time.Millisecond { + _, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote)) + lastIdle = time.Now() + } + } +} + +// 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() + 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 { + 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 == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data + civBytes := buf[0x15:k] + // Skip scope (0x27) frames: over the network the rig streams the + // panadapter continuously as large frames that would crowd control + // replies out of rx. (Network scope is handled separately.) + if !(len(civBytes) >= 5 && civBytes[4] == 0x27) { + if icnTrace { + debugLog.Printf("icom net RX: % X", civBytes) + } + cp := append([]byte(nil), 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() + } + } +} + +// 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) + } +} + +// ------------------------- connect ------------------------- + +func dialIcomNet(host, user, pass, compName string, rigAddr byte) (*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) + 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) { + 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) { + 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 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) + 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 — sending power-on, waiting for the rig to boot (~15s)") + + // 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), + sentBuf: make(map[uint16][]byte), + done: make(chan struct{}), + } + // openClose(open) starts the CI-V data flow. + ocPkt := icnOpenClose(n.vTracked, vID, vRemote, n.vCivSeq, 0x04) + n.sentBuf[n.vTracked] = ocPkt + _, _ = civ.Write(ocPkt) + n.vTracked++ + n.vCivSeq++ + // Power-on (the rig may be in standby — it NG's every command until on): + // an FE wake preamble then FE FE E0 18 01 FD. Harmless if already on. + po := make([]byte, 0, 32) + for i := 0; i < 25; i++ { + po = append(po, 0xFE) + } + po = append(po, 0xFE, 0xFE, rigAddr, 0xE0, 0x18, 0x01, 0xFD) + poPkt := icnCivData(n.vTracked, vID, vRemote, n.vCivSeq, po) + n.sentBuf[n.vTracked] = poPkt + _, _ = civ.Write(poPkt) + n.vTracked++ + n.vCivSeq++ + + // Wait for the rig to finish booting: a rig woken from standby NG's/ignores + // commands for ~10-15 s. Poll read-freq (keeping both streams alive) until it + // answers, so Connect only returns a READY link — otherwise the manager's + // read-timeouts would flap the connection during boot. Give up after 25 s and + // return anyway (the rig may already be on and just quiet). + if n.waitReady(rigAddr, 25*time.Second) { + debugLog.Printf("icom net: rig is READY (answered read-freq) — connection up ✓") + } else { + debugLog.Printf("icom net: boot wait timed out (25s, no freq reply) — proceeding anyway") + } + + go n.ctrlPump() + go n.civPump() + return n, nil +} + +// waitReady polls read-freq until the rig replies (booted) or timeout, replying +// to pings and sending idle keepalives so the session stays up. Runs before the +// pump goroutine starts, so it owns the socket reads. +func (n *icomNet) waitReady(rigAddr byte, timeout time.Duration) bool { + cbuf := make([]byte, 4096) + vbuf := make([]byte, 4096) + readFreq := []byte{0xFE, 0xFE, rigAddr, 0xE0, 0x03, 0xFD} + end := time.Now().Add(timeout) + var lastPoll, lastIdle time.Time + for time.Now().Before(end) { + if p, ok := icnRecv(n.ctrl, 25, cbuf); ok && icnLE.Uint16(p[4:]) == 0x07 { + _, _ = n.ctrl.Write(icnPingReply(p, n.cID, n.cRemote)) + } + if p, ok := icnRecv(n.civ, 25, vbuf); ok { + typ := icnLE.Uint16(p[4:]) + if typ == 0x07 { + _, _ = n.civ.Write(icnPingReply(p, n.vID, n.vRemote)) + } else if typ == 0x00 && len(p) > 0x15 && p[0x10] == 0xc1 { + f := p[0x15:] + // A frequency reply from the rig: FE FE E0 03 … + if len(f) >= 6 && f[0] == 0xFE && f[1] == 0xFE && f[3] == rigAddr && f[4] == 0x03 { + return true + } + } + } + if time.Since(lastIdle) > 180*time.Millisecond { + _, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote)) + _, _ = n.civ.Write(icnCtrl(0x00, 0, n.vID, n.vRemote)) + lastIdle = time.Now() + } + if time.Since(lastPoll) > 1000*time.Millisecond { + _, _ = n.civ.Write(icnCivData(n.vTracked, n.vID, n.vRemote, n.vCivSeq, readFreq)) + n.vTracked++ + n.vCivSeq++ + lastPoll = time.Now() + } + } + return false +} + +// icnHandshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady. +func icnHandshake(c *net.UDPConn, myID uint32) (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) { + 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 +} + +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 +}