feat: Implemented Icom Ethernet CAT control
This commit is contained in:
@@ -93,6 +93,9 @@ const (
|
|||||||
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
||||||
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
|
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
|
||||||
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
|
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)
|
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
||||||
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
||||||
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
|
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.
|
// individual key/value pairs to keep the settings table flat.
|
||||||
type CATSettings struct {
|
type CATSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
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)
|
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
|
||||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
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)
|
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)
|
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
|
||||||
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
|
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)
|
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
|
||||||
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
|
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
|
||||||
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
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 {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
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 {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -4290,6 +4296,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
IcomPort: m[keyCATIcomPort],
|
IcomPort: m[keyCATIcomPort],
|
||||||
IcomBaud: 115200,
|
IcomBaud: 115200,
|
||||||
IcomAddr: 0x98, // IC-7610 default
|
IcomAddr: 0x98, // IC-7610 default
|
||||||
|
IcomNetHost: m[keyCATIcomNetHost],
|
||||||
|
IcomNetUser: m[keyCATIcomNetUser],
|
||||||
|
IcomNetPass: m[keyCATIcomNetPass],
|
||||||
TCIHost: m[keyCATTCIHost],
|
TCIHost: m[keyCATTCIHost],
|
||||||
TCIPort: 40001,
|
TCIPort: 40001,
|
||||||
TCISpots: m[keyCATTCISpots] == "1",
|
TCISpots: m[keyCATTCISpots] == "1",
|
||||||
@@ -4378,6 +4387,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||||
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
||||||
|
keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost),
|
||||||
|
keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser),
|
||||||
|
keyCATIcomNetPass: s.IcomNetPass,
|
||||||
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
||||||
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
||||||
keyCATTCISpots: tciSpots,
|
keyCATTCISpots: tciSpots,
|
||||||
@@ -8349,8 +8361,12 @@ func (a *App) reloadCAT() {
|
|||||||
a.cat.Start(fb)
|
a.cat.Start(fb)
|
||||||
case "icom":
|
case "icom":
|
||||||
// Native Icom CI-V over the radio's USB serial port (local control).
|
// 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))
|
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":
|
case "tci":
|
||||||
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
||||||
// TCI-compatible server.
|
// TCI-compatible server.
|
||||||
|
|||||||
+401
-187
@@ -1,23 +1,21 @@
|
|||||||
// Command icomnettest is an iteration probe for the Icom IP remote protocol
|
// 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).
|
// the LAN server built into the IC-7610 that the Icom "Remote Utility" (and
|
||||||
// We're reimplementing it from the public protocol description, so this tool
|
// wfview) talk to. OpsLog reimplements this directly so it can BE both the
|
||||||
// drives the CONTROL stream (default UDP 50001) and hex-dumps every packet both
|
// Remote Utility (Ethernet ↔ radio) and the logger/CAT client, dropping the
|
||||||
// ways, letting us confirm the framing / type codes against the real rig before
|
// virtual-COM + RS-BA1 chain entirely.
|
||||||
// folding it into internal/cat/icomnet. Nothing here is copied from wfview
|
|
||||||
// (GPLv3) — it's a clean-room implementation from the protocol structure.
|
|
||||||
//
|
//
|
||||||
// This first milestone is the CONNECTION HANDSHAKE only (no login yet):
|
// This probe drives TWO streams and hex-dumps everything:
|
||||||
// areYouThere → iAmHere → areYouReady → iAmReady → periodic idle pings.
|
// Control (UDP 50001): handshake → login → token [VERIFIED on the real rig]
|
||||||
// Watch the log: if the rig answers our areYouThere we've got the framing right;
|
// CI-V (UDP 50002): handshake → openClose(open) → send CI-V read-freq
|
||||||
// its reply reveals the remote station ID we echo back. Login (token + user/
|
// (FE FE 98 E0 03 FD) → print the rig's reply.
|
||||||
// password) is the next step once the handshake is confirmed.
|
// 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:
|
// Usage:
|
||||||
// go run ./cmd/icomnettest 192.168.1.60 # control port 50001
|
// go run ./cmd/icomnettest <rig-ip> <user> <pass> [compname]
|
||||||
// go run ./cmd/icomnettest 192.168.1.60 50001 20 # port + run seconds
|
|
||||||
//
|
//
|
||||||
// SAFE: only the control stream, no CI-V commands, no TX — it just opens and
|
// SAFE: read-only CI-V (operating frequency). No TX, no writes.
|
||||||
// pings, then disconnects. Share the log and we iterate.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -26,83 +24,59 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Control-stream packet types (best-known values from the public protocol
|
var le = binary.LittleEndian
|
||||||
// description — the very thing we're verifying with this probe).
|
var be = binary.BigEndian
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ctrlPacket is the 16-byte common control packet, all fields little-endian:
|
// passcodeSeq — Icom's obfuscation table (values live at index 0x20..0x7e).
|
||||||
//
|
// VERIFIED: user "f6bgc" → 3F 65 50 25 55 (matches the capture).
|
||||||
// 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.
|
|
||||||
var passcodeSeq = [256]byte{
|
var passcodeSeq = [256]byte{
|
||||||
0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41,
|
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,
|
||||||
0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e, 0x20, 0x77, 0x5f, 0x51, 0x3e, 0x70, 0x4d, 0x1f, 0x74, 0x38,
|
0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41, 0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e,
|
||||||
0x2c, 0x4b, 0x1e, 0x54, 0x30, 0x71, 0x2b, 0x2a, 0x66, 0x27, 0x2e, 0x58, 0x24, 0x21, 0x2f, 0x50,
|
0x7c, 0x65, 0x7d, 0x49, 0x29, 0x72, 0x73, 0x78, 0x21, 0x6e, 0x5a, 0x5e, 0x4a, 0x3e, 0x71, 0x2c, 0x2a, 0x54, 0x3c, 0x3a, 0x63, 0x4f,
|
||||||
0x1b, 0x73, 0x69, 0x36, 0x1d, 0x4f, 0x1c, 0x51, 0x2e, 0x1e, 0x45, 0x2e, 0x22, 0x50, 0x64, 0x66,
|
0x43, 0x75, 0x27, 0x79, 0x5b, 0x35, 0x70, 0x48, 0x6b, 0x56, 0x6f, 0x34, 0x32, 0x6c, 0x30, 0x61, 0x6d, 0x7b, 0x2f, 0x4b, 0x64, 0x38,
|
||||||
0x24, 0x36, 0x0c, 0x7d, 0x50, 0x25, 0x7c, 0x3f, 0x2d, 0x35, 0x71, 0x6a, 0x0e, 0x41, 0x2a, 0x67,
|
0x2b, 0x2e, 0x50, 0x40, 0x3f, 0x55, 0x33, 0x37, 0x25, 0x77, 0x24, 0x26, 0x74, 0x6a, 0x28, 0x53, 0x4d, 0x69, 0x22, 0x5c, 0x44, 0x31,
|
||||||
0x7c, 0x64, 0x77, 0x67, 0x6d, 0x5b, 0x3d, 0x5b, 0x2b, 0x67, 0x6c, 0x39, 0x35, 0x76, 0x3b, 0x2f,
|
0x36, 0x58, 0x3b, 0x7a, 0x51, 0x5f, 0x52,
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// passcode scrambles s (username or password) via the Icom sequence table.
|
|
||||||
func passcode(s string) []byte {
|
func passcode(s string) []byte {
|
||||||
out := make([]byte, len(s))
|
out := make([]byte, 0, len(s))
|
||||||
for i := 0; i < len(s); i++ {
|
for i := 0; i < len(s) && i < 16; i++ {
|
||||||
p := int(s[i]) + i
|
p := int(s[i]) + i
|
||||||
if p > 0x7f {
|
if p > 126 {
|
||||||
p = ((p - 0x7f) % 0x33) - 1
|
p = 32 + p%127
|
||||||
if p < 0 {
|
|
||||||
p = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out[i] = passcodeSeq[p&0xff]
|
out = append(out, passcodeSeq[p])
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildLogin builds the 0x80-byte login packet: control header + username/
|
// --- packet builders (offsets verified vs wfview structs + real captures) ---
|
||||||
// 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
|
func ctrlPacket(typ, seq uint16, sentid, rcvdid uint32) []byte {
|
||||||
// reconstruction and may need adjustment against a capture.
|
b := make([]byte, 16)
|
||||||
func buildLogin(seq uint16, sentid, rcvdid uint32, innerSeq, tokRequest uint16, user, pass, name string) []byte {
|
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)
|
b := make([]byte, 0x80)
|
||||||
binary.LittleEndian.PutUint32(b[0:], 0x80) // len
|
le.PutUint32(b[0:], 0x80)
|
||||||
// type (b[4:6]) = 0x00
|
le.PutUint16(b[6:], seq)
|
||||||
binary.LittleEndian.PutUint16(b[6:], seq)
|
le.PutUint32(b[8:], sentid)
|
||||||
binary.LittleEndian.PutUint32(b[8:], sentid)
|
le.PutUint32(b[12:], rcvdid)
|
||||||
binary.LittleEndian.PutUint32(b[12:], rcvdid)
|
be.PutUint32(b[0x10:], 0x80-0x10)
|
||||||
binary.LittleEndian.PutUint32(b[16:], 0x70) // payload size (len - 0x10)
|
b[0x14] = 0x01 // requestreply
|
||||||
binary.LittleEndian.PutUint16(b[20:], 0x00) // requesttype
|
b[0x15] = 0x00 // requesttype = login
|
||||||
binary.LittleEndian.PutUint16(b[22:], 0x01) // requestreply
|
be.PutUint16(b[0x16:], innerSeq)
|
||||||
binary.LittleEndian.PutUint16(b[24:], innerSeq)
|
le.PutUint16(b[0x1a:], tokReq)
|
||||||
binary.LittleEndian.PutUint16(b[26:], tokRequest)
|
le.PutUint32(b[0x1c:], token)
|
||||||
// token (b[0x20:0x24]) = 0 until the rig grants one
|
|
||||||
copy(b[0x40:0x50], passcode(user))
|
copy(b[0x40:0x50], passcode(user))
|
||||||
copy(b[0x50:0x60], passcode(pass))
|
copy(b[0x50:0x60], passcode(pass))
|
||||||
nm := name
|
nm := name
|
||||||
@@ -113,140 +87,380 @@ func buildLogin(seq uint16, sentid, rcvdid uint32, innerSeq, tokRequest uint16,
|
|||||||
return b
|
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 {
|
if len(b) < 16 {
|
||||||
return 0, 0, 0, 0, 0, false
|
return 0, 0, 0, 0, 0, false
|
||||||
}
|
}
|
||||||
length = binary.LittleEndian.Uint32(b[0:])
|
return le.Uint32(b[0:]), le.Uint16(b[4:]), le.Uint16(b[6:]), le.Uint32(b[8:]), le.Uint32(b[12:]), true
|
||||||
typ = binary.LittleEndian.Uint16(b[4:])
|
}
|
||||||
seq = binary.LittleEndian.Uint16(b[6:])
|
|
||||||
sentid = binary.LittleEndian.Uint32(b[8:])
|
func localID(conn net.Conn) uint32 {
|
||||||
rcvdid = binary.LittleEndian.Uint32(b[12:])
|
a := conn.LocalAddr().(*net.UDPAddr)
|
||||||
return length, typ, seq, sentid, rcvdid, true
|
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() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 4 {
|
||||||
fmt.Println("usage: icomnettest <rig-ip> [user] [password]")
|
fmt.Println("usage: icomnettest <rig-ip> <user> <pass> [compname]")
|
||||||
fmt.Println(" <rig-ip> only → handshake + ping probe")
|
|
||||||
fmt.Println(" <rig-ip> <user> <pass> → also attempt login")
|
|
||||||
fmt.Println("example: icomnettest 192.168.1.60 f6bgc cgb6f1")
|
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
ip := os.Args[1]
|
ip, user, pass := os.Args[1], os.Args[2], os.Args[3]
|
||||||
port := 50001
|
compName := "OpsLog"
|
||||||
runSecs := 25
|
if len(os.Args) >= 5 {
|
||||||
user, pass := "", ""
|
compName = os.Args[4]
|
||||||
if len(os.Args) >= 4 {
|
|
||||||
user, pass = os.Args[2], os.Args[3]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
target := net.JoinHostPort(ip, strconv.Itoa(port))
|
// ===================== CONTROL STREAM (50001) =====================
|
||||||
conn, err := net.Dial("udp4", target)
|
ctrl, err := net.Dial("udp4", net.JoinHostPort(ip, "50001"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("dial %s: %v\n", target, err)
|
fmt.Printf("dial control: %v\n", err)
|
||||||
os.Exit(1)
|
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
|
cRemote, ok := handshake(ctrl, cID, "ctrl")
|
||||||
// stable non-zero value is fine for probing. We'll refine once we see how the
|
if !ok {
|
||||||
// rig echoes it back.
|
fmt.Println("control handshake failed")
|
||||||
local := conn.LocalAddr().(*net.UDPAddr)
|
return
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Probing Icom control stream at %s (myID=0x%08X)\n\n", target, myID)
|
// login → token
|
||||||
if user != "" {
|
var cTracked uint16 = 1
|
||||||
fmt.Printf("Login mode: user=%q pass=%q\n", user, pass)
|
var cInner uint16 = 1
|
||||||
fmt.Printf(" scrambled user = % X\n", passcode(user))
|
tokReq := uint16(0x0c77)
|
||||||
fmt.Printf(" scrambled pass = % X\n\n", passcode(pass))
|
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))
|
||||||
var innerSeq uint16 = 0x0001
|
cTracked++
|
||||||
var tokRequest uint16 = 0x1234 // fixed for reproducibility (no RNG in this probe)
|
cInner++
|
||||||
loginSent := false
|
|
||||||
|
|
||||||
// 1) areYouThere — ask the rig to announce itself.
|
var token uint32
|
||||||
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)
|
|
||||||
buf := make([]byte, 2048)
|
buf := make([]byte, 2048)
|
||||||
lastIdle := time.Now()
|
deadline := time.Now().Add(4 * time.Second)
|
||||||
readyStarted := false
|
for token == 0 && time.Now().Before(deadline) {
|
||||||
for time.Now().Before(deadline) {
|
p, ok := recv(ctrl, 200, buf)
|
||||||
_ = 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)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
fmt.Printf("RX (%d bytes, too short to parse)\n%s\n", n, hex.Dump(pkt))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf("RX len=%d type=0x%02X seq=%d sentid=0x%08X rcvdid=0x%08X (%d bytes)\n%s\n",
|
length, typ, _, _, _, _ := header(p)
|
||||||
length, typ, rseq, sentid, rcvdid, n, hex.Dump(pkt))
|
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 {
|
// Send conninfo on the control stream — routes the CI-V stream to this
|
||||||
case typeIAmHere:
|
// authenticated session and announces our civ/audio local ports (50002/3).
|
||||||
remoteID = sentid // the rig's ID — echo it back as rcvdid from now on
|
rigMAC := []byte{0x00, 0x90, 0xc7, 0x09, 0xba, 0x3f} // F6BGC's IC-7610 (from the caps packet)
|
||||||
fmt.Printf(">> iAmHere: remoteID=0x%08X — sending areYouReady\n\n", remoteID)
|
dump("[ctrl] TX conninfo", buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||||
seq++
|
ctrl.Write(buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||||
logTx("areYouReady", ctrlPacket(typeAreYouReady, seq, myID, remoteID))
|
cTracked++
|
||||||
readyStarted = true
|
cInner++
|
||||||
case typeAreYouReady:
|
// Let the rig's caps/conninfo replies flow for ~600ms (reply to pings).
|
||||||
if readyStarted && !loginSent {
|
drainEnd := time.Now().Add(600 * time.Millisecond)
|
||||||
fmt.Printf(">> iAmReady — control link is up.\n\n")
|
for time.Now().Before(drainEnd) {
|
||||||
if user != "" {
|
if p, ok := recv(ctrl, 100, buf); ok {
|
||||||
seq++
|
if _, typ, _, _, _, _ := header(p); typ == 0x07 {
|
||||||
lg := buildLogin(seq, myID, remoteID, innerSeq, tokRequest, user, pass, "OpsLog")
|
ctrl.Write(pingReply(p, cID, cRemote))
|
||||||
fmt.Printf(">> sending login (user=%q)\n", user)
|
|
||||||
logTx("login", lg)
|
|
||||||
loginSent = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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.
|
// ===================== CI-V STREAM (50002) =====================
|
||||||
if remoteID != 0 {
|
// Bind our civ socket to LOCAL port 50002 (= the civport announced above),
|
||||||
seq++
|
// as the Remote Utility does. Requires the Remote Utility to be CLOSED.
|
||||||
logTx("disconnect", ctrlPacket(typeDisconnect, seq, myID, remoteID))
|
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 <to> <from> <cmd> … 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
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 { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
|||||||
// (no callsign configured yet). It writes straight into the active profile, so
|
// (no callsign configured yet). It writes straight into the active profile, so
|
||||||
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
||||||
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t, lang, setLang } = useI18n();
|
||||||
const [p, setP] = useState<Profile | null>(null);
|
const [p, setP] = useState<Profile | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
@@ -68,6 +68,22 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{([['en', FlagGB, 'English'], ['fr', FlagFR, 'Français']] as [Lang, typeof FlagGB, string][]).map(([code, Flag, label]) => (
|
||||||
|
<button key={code} type="button" onClick={() => setLang(code)}
|
||||||
|
className={cn('flex items-center gap-2 px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0 transition-colors',
|
||||||
|
lang === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
<Flag className="w-5 rounded-[2px] border border-border/30" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Radio className="size-5 text-primary" />
|
<Radio className="size-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
|
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
|
||||||
|
|||||||
@@ -793,7 +793,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
|
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',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||||
@@ -1967,6 +1968,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
|
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
|
||||||
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
|
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
|
||||||
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
|
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
|
||||||
|
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
|
||||||
<SelectItem value="tci">{t('cat.optTci')}</SelectItem>
|
<SelectItem value="tci">{t('cat.optTci')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -2037,6 +2039,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{catCfg.backend === 'icom-net' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.icomNetHost')}</Label>
|
||||||
|
<Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.civAddr')}</Label>
|
||||||
|
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
||||||
|
onChange={(e) => { 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 })); }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.icomNetUser')}</Label>
|
||||||
|
<Input value={catCfg.icom_net_user ?? ''}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_user: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.icomNetPass')}</Label>
|
||||||
|
<Input type="password" value={catCfg.icom_net_pass ?? ''}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{catCfg.backend === 'tci' && (
|
{catCfg.backend === 'tci' && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -2058,7 +2085,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
|
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom' || catCfg.backend === 'icom-net') && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('cat.pollMs')}</Label>
|
<Label>{t('cat.pollMs')}</Label>
|
||||||
|
|||||||
@@ -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).',
|
'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',
|
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||||
// CAT panel body
|
// 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.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.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)",
|
'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.",
|
'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).",
|
'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',
|
'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.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.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)",
|
'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)",
|
||||||
|
|||||||
@@ -1290,6 +1290,9 @@ export namespace main {
|
|||||||
icom_port: string;
|
icom_port: string;
|
||||||
icom_baud: number;
|
icom_baud: number;
|
||||||
icom_addr: number;
|
icom_addr: number;
|
||||||
|
icom_net_host: string;
|
||||||
|
icom_net_user: string;
|
||||||
|
icom_net_pass: string;
|
||||||
tci_host: string;
|
tci_host: string;
|
||||||
tci_port: number;
|
tci_port: number;
|
||||||
tci_spots: boolean;
|
tci_spots: boolean;
|
||||||
@@ -1312,6 +1315,9 @@ export namespace main {
|
|||||||
this.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
this.icom_baud = source["icom_baud"];
|
this.icom_baud = source["icom_baud"];
|
||||||
this.icom_addr = source["icom_addr"];
|
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_host = source["tci_host"];
|
||||||
this.tci_port = source["tci_port"];
|
this.tci_port = source["tci_port"];
|
||||||
this.tci_spots = source["tci_spots"];
|
this.tci_spots = source["tci_spots"];
|
||||||
|
|||||||
@@ -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 <rig> 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 <rig> 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user