From fafa0c22ab6cd9f6921241565dfebfffc76d85f2 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 5 Jul 2026 20:52:37 +0200 Subject: [PATCH] fix: solve issue with Antenna Genius for remote operations --- app.go | 20 ++- cmd/icomnettest/main.go | 182 ++++++++++++++++++++++ frontend/src/components/SettingsModal.tsx | 13 +- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/models.ts | 2 + internal/antgenius/antgenius.go | 45 +++++- internal/antgenius/antgenius_test.go | 8 +- internal/cat/icomserial.go | 58 +++++-- 8 files changed, 297 insertions(+), 35 deletions(-) create mode 100644 cmd/icomnettest/main.go diff --git a/app.go b/app.go index 099ae85..82b08d4 100644 --- a/app.go +++ b/app.go @@ -151,8 +151,9 @@ const ( // Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP // port is fixed at 9007, so only the IP is configurable. - keyAntGeniusEnabled = "antgenius.enabled" - keyAntGeniusHost = "antgenius.host" + keyAntGeniusEnabled = "antgenius.enabled" + keyAntGeniusHost = "antgenius.host" + keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN) // PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius. keyPGXLEnabled = "pgxl.enabled" @@ -9001,8 +9002,9 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error { // AntGeniusSettings is the JSON shape for the Hardware → Antenna Genius panel. // The TCP port is fixed at 9007 on the device, so only the IP is configurable. type AntGeniusSettings struct { - Enabled bool `json:"enabled"` - Host string `json:"host"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Password string `json:"password"` // remote-access password; leave blank on LAN (no AUTH) } // GetAntGeniusSettings returns the persisted Antenna Genius config. @@ -9011,12 +9013,13 @@ func (a *App) GetAntGeniusSettings() (AntGeniusSettings, error) { if a.settings == nil { return out, fmt.Errorf("db not initialized") } - m, err := a.settings.GetMany(a.ctx, keyAntGeniusEnabled, keyAntGeniusHost) + m, err := a.settings.GetMany(a.ctx, keyAntGeniusEnabled, keyAntGeniusHost, keyAntGeniusPassword) if err != nil { return out, err } out.Enabled = m[keyAntGeniusEnabled] == "1" out.Host = m[keyAntGeniusHost] + out.Password = m[keyAntGeniusPassword] return out, nil } @@ -9026,8 +9029,9 @@ func (a *App) SaveAntGeniusSettings(s AntGeniusSettings) error { return fmt.Errorf("db not initialized") } for k, v := range map[string]string{ - keyAntGeniusEnabled: boolStr(s.Enabled), - keyAntGeniusHost: strings.TrimSpace(s.Host), + keyAntGeniusEnabled: boolStr(s.Enabled), + keyAntGeniusHost: strings.TrimSpace(s.Host), + keyAntGeniusPassword: s.Password, } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err @@ -9049,7 +9053,7 @@ func (a *App) startAntGenius() { if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" { return } - a.antgenius = antgenius.New(s.Host, 9007) + a.antgenius = antgenius.New(s.Host, 9007, s.Password) _ = a.antgenius.Start() } diff --git a/cmd/icomnettest/main.go b/cmd/icomnettest/main.go new file mode 100644 index 0000000..c079559 --- /dev/null +++ b/cmd/icomnettest/main.go @@ -0,0 +1,182 @@ +// 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. +// +// 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. +// +// 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 +// +// SAFE: only the control stream, no CI-V commands, no TX — it just opens and +// pings, then disconnects. Share the log and we iterate. +package main + +import ( + "encoding/binary" + "encoding/hex" + "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) +) + +// 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 +} + +func parseHeader(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 +} + +func main() { + if len(os.Args) < 2 { + fmt.Println("usage: icomnettest [control-port] [run-seconds]") + fmt.Println("example: icomnettest 192.168.1.60 50001 20") + os.Exit(2) + } + ip := os.Args[1] + port := 50001 + if len(os.Args) >= 3 { + if v, err := strconv.Atoi(os.Args[2]); err == nil { + port = v + } + } + runSecs := 20 + if len(os.Args) >= 4 { + if v, err := strconv.Atoi(os.Args[3]); err == nil && v > 0 { + runSecs = v + } + } + + target := net.JoinHostPort(ip, strconv.Itoa(port)) + conn, err := net.Dial("udp4", target) + if err != nil { + fmt.Printf("dial %s: %v\n", target, err) + os.Exit(1) + } + defer conn.Close() + + // 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) + } + } + + fmt.Printf("Probing Icom control stream at %s (myID=0x%08X)\n\n", target, myID) + + // 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) + 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) + 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)) + + 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 { + fmt.Printf(">> iAmReady — control link is up. (Login is the next milestone.)\n\n") + } + 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)) + } + fmt.Println("Done. Paste the log — especially the rig's replies to areYouThere.") +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 38ea51a..cf7b88f 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -748,7 +748,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); // Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007. - const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string }>({ enabled: false, host: '' }); + const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' }); // PowerGenius XL (4O3A) amp fan-control settings. const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 }); @@ -2175,6 +2175,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan className="font-mono" /> +
+ + setAntgenius((s) => ({ ...s, password: e.target.value }))} + placeholder={t('ag2.passwordPh')} + className="font-mono" + /> +

{t('ag2.passwordHint')}

+
); diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index af02fe3..f7f3673 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -122,7 +122,7 @@ const en: Dict = { // Section hints (hardware/software panel headers) 'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.', 'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).", - 'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', + 'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.', 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.", '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', @@ -308,7 +308,7 @@ const fr: Dict = { 'bk.lastRun': 'Dernière exécution :', 'bk.never': 'jamais', 'bk.backupNow': 'Sauvegarder maintenant', 'bk.backingUp': 'Sauvegarde…', 'bk.writtenTo': 'Sauvegarde écrite dans', 'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.", 'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).", - 'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", + 'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", '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', diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1c1df78..2426f37 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1089,6 +1089,7 @@ export namespace main { export class AntGeniusSettings { enabled: boolean; host: string; + password: string; static createFrom(source: any = {}) { return new AntGeniusSettings(source); @@ -1098,6 +1099,7 @@ export namespace main { if ('string' === typeof source) source = JSON.parse(source); this.enabled = source["enabled"]; this.host = source["host"]; + this.password = source["password"]; } } export class AudioSettings { diff --git a/internal/antgenius/antgenius.go b/internal/antgenius/antgenius.go index 072386a..ac2553f 100644 --- a/internal/antgenius/antgenius.go +++ b/internal/antgenius/antgenius.go @@ -17,6 +17,8 @@ import ( "strings" "sync" "time" + + "hamlog/internal/applog" ) const ( @@ -47,8 +49,9 @@ type Status struct { } type Client struct { - host string - port int + host string + port int + password string // remote-access password; sent as "login " when the device requires AUTH mu sync.Mutex // guards conn + writes conn net.Conn @@ -61,13 +64,14 @@ type Client struct { running bool } -func New(host string, port int) *Client { +func New(host string, port int, password string) *Client { if port <= 0 || port > 65535 { port = defaultPort } return &Client{ host: host, port: port, + password: strings.TrimSpace(password), stop: make(chan struct{}), antennas: map[int]string{}, status: Status{Host: host}, @@ -146,13 +150,27 @@ func (c *Client) runLoop() { c.conn = conn c.mu.Unlock() c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host }) + applog.Printf("antgenius: TCP connected %s → %s", conn.LocalAddr(), conn.RemoteAddr()) + + // When reached remotely the device announces "V… AG AUTH" and refuses every + // command (R1|FF) until the client logs in. Sent FIRST so it's authenticated + // before the subscribe/get commands (the device processes the stream in + // order). On the LAN there's no AUTH and the password is blank → skipped. + if c.password != "" { + if err := c.send("login " + c.password); err != nil { + applog.Printf("antgenius: login send failed: %v", err) + } else { + applog.Printf("antgenius: sent login (password %d chars)", len(c.password)) + } + } // Subscribe to live updates and pull the initial state. Command set and // order mirror a known-working Node-RED v4 client (WA9WUD). - _ = c.send("antenna list") - _ = c.send("sub port all") - _ = c.send("port get 1") - _ = c.send("port get 2") + for _, cmd := range []string{"antenna list", "sub port all", "port get 1", "port get 2"} { + if err := c.send(cmd); err != nil { + applog.Printf("antgenius: init send %q failed: %v", cmd, err) + } + } done := make(chan struct{}) go c.keepalive(conn, done) @@ -198,14 +216,27 @@ func (c *Client) keepalive(conn net.Conn, done chan struct{}) { func (c *Client) readLoop(conn net.Conn) error { r := bufio.NewReader(conn) var sb strings.Builder + start := time.Now() + lines, bytesRx := 0, 0 for { _ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout)) b, err := r.ReadByte() if err != nil { + // Log where the link died: 0 lines after connect points at the device + // refusing/closing (e.g. a client-slot limit or source-IP rejection — + // common when reaching it remotely); an EOF after the banner/replies + // points at something later in the exchange. + applog.Printf("antgenius: read ended after %s — %d line(s), %d byte(s): %v", + time.Since(start).Round(time.Millisecond), lines, bytesRx, err) return err } + bytesRx++ if b == '\r' || b == '\n' { if sb.Len() > 0 { + lines++ + if lines <= 10 { + applog.Printf("antgenius: rx[%d] %q", lines, sb.String()) + } c.handleLine(sb.String()) sb.Reset() } diff --git a/internal/antgenius/antgenius_test.go b/internal/antgenius/antgenius_test.go index 1c1a09f..0be7445 100644 --- a/internal/antgenius/antgenius_test.go +++ b/internal/antgenius/antgenius_test.go @@ -3,7 +3,7 @@ package antgenius import "testing" func TestHandleAntennaList(t *testing.T) { - c := New("x", 9007) + c := New("x", 9007, "") // Names may contain spaces — must be captured up to " tx=". c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000") c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000") @@ -20,7 +20,7 @@ func TestHandleAntennaList(t *testing.T) { } func TestAntennaUnderscoreAndPlaceholder(t *testing.T) { - c := New("x", 9007) + c := New("x", 9007, "") c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered @@ -31,7 +31,7 @@ func TestAntennaUnderscoreAndPlaceholder(t *testing.T) { } func TestHandlePortStatus(t *testing.T) { - c := New("x", 9007) + c := New("x", 9007, "") // Async push after "sub port all": active antenna is txant (fallback rxant). c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0") c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0") @@ -50,7 +50,7 @@ func TestHandlePortStatus(t *testing.T) { } func TestPortTxFallbackToRx(t *testing.T) { - c := New("x", 9007) + c := New("x", 9007, "") c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0") if st := c.GetStatus(); st.PortA != 3 { t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA) diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index 0a29fd9..ea2ac8c 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -12,18 +12,38 @@ import ( "go.bug.st/serial" ) -// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local -// control). It speaks the shared civ protocol, so when the network backend -// (icomnet) is added it will reuse the same encode/decode — only the transport -// changes. Implements Backend; all methods run on the Manager's CAT goroutine, -// so the port is accessed single-threaded (no locking needed). +// civTransport is everything IcomSerial needs from its underlying link. It's +// satisfied directly by a go.bug.st serial.Port (USB, local control) and, for +// remote control, by the icomnet UDP stream which presents the tunnelled CI-V +// byte stream as plain Read/Write (there SetDTR/SetRTS/SetReadTimeout are +// no-ops). Abstracting it here means the whole IcomController surface — DSP, +// scope, RIT, CW — is reused unchanged over the network; only `open` differs. +type civTransport interface { + Read(p []byte) (int, error) + Write(p []byte) (int, error) + Close() error + SetReadTimeout(time.Duration) error + SetDTR(bool) error + SetRTS(bool) error +} + +// IcomSerial controls an Icom transceiver over the shared civ protocol. The +// transport is pluggable via `open`: NewIcomSerial opens a USB/serial port; +// NewIcomNet (later) returns one configured with a network transport. Implements +// Backend; all methods run on the Manager's CAT goroutine, so the port is +// accessed single-threaded (no locking needed). type IcomSerial struct { portName string baud int rigAddr byte // rig's CI-V address (IC-7610 default 0x98) digital string // mode to command for DATA (FT8/RTTY/…) - port serial.Port + // open dials the underlying link (serial port or network stream). Set by the + // constructor; called by Connect. Blocks until connected (the network opener + // performs the full UDP handshake + login before returning). + open func() (civTransport, error) + + port civTransport model string // I/O routing. A single reader goroutine owns port.Read and dispatches every @@ -82,7 +102,7 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I if digitalDefault == "" { digitalDefault = "FT8" } - return &IcomSerial{ + b := &IcomSerial{ portName: portName, baud: baud, rigAddr: byte(civAddr), @@ -90,20 +110,32 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I model: "Icom", scopeFixed: true, // rigs default to a fixed-span scope } + // USB/serial transport opener. + b.open = func() (civTransport, error) { + if b.portName == "" { + return nil, fmt.Errorf("no serial port configured") + } + p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud}) + if err != nil { + return nil, fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err) + } + return p, nil + } + return b } func (b *IcomSerial) Name() string { return "icom" } func (b *IcomSerial) Connect() error { - if b.portName == "" { - return fmt.Errorf("no serial port configured") + if b.open == nil { + return fmt.Errorf("no transport configured") } - port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud}) + port, err := b.open() if err != nil { - return fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err) + return err } // Short read timeout so recv() polls in a tight loop without blocking the - // CAT goroutine when the rig is silent. + // CAT goroutine when the rig is silent. (No-op on the network transport.) _ = port.SetReadTimeout(60 * time.Millisecond) // Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and // "USB Keying (CW)" be mapped to the RTS or DTR line: if the port opens with @@ -312,7 +344,7 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) ( // frames and routes each: our own echoes are dropped, spectrum-scope frames // (0x27) go to specCh, everything else (control replies, acks, unsolicited // transceive updates) goes to respCh. It exits when the port is closed. -func (b *IcomSerial) reader(port serial.Port, done chan struct{}) { +func (b *IcomSerial) reader(port civTransport, done chan struct{}) { defer close(done) tmp := make([]byte, 512) var rx []byte