fix: solve issue with Antenna Genius for remote operations

This commit is contained in:
2026-07-05 20:52:37 +02:00
parent 4f32012930
commit fafa0c22ab
8 changed files with 297 additions and 35 deletions
+6 -2
View File
@@ -153,6 +153,7 @@ const (
// port is fixed at 9007, so only the IP is configurable. // port is fixed at 9007, so only the IP is configurable.
keyAntGeniusEnabled = "antgenius.enabled" keyAntGeniusEnabled = "antgenius.enabled"
keyAntGeniusHost = "antgenius.host" keyAntGeniusHost = "antgenius.host"
keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN)
// PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius. // PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius.
keyPGXLEnabled = "pgxl.enabled" keyPGXLEnabled = "pgxl.enabled"
@@ -9003,6 +9004,7 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error {
type AntGeniusSettings struct { type AntGeniusSettings struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Host string `json:"host"` Host string `json:"host"`
Password string `json:"password"` // remote-access password; leave blank on LAN (no AUTH)
} }
// GetAntGeniusSettings returns the persisted Antenna Genius config. // GetAntGeniusSettings returns the persisted Antenna Genius config.
@@ -9011,12 +9013,13 @@ func (a *App) GetAntGeniusSettings() (AntGeniusSettings, error) {
if a.settings == nil { if a.settings == nil {
return out, fmt.Errorf("db not initialized") 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 { if err != nil {
return out, err return out, err
} }
out.Enabled = m[keyAntGeniusEnabled] == "1" out.Enabled = m[keyAntGeniusEnabled] == "1"
out.Host = m[keyAntGeniusHost] out.Host = m[keyAntGeniusHost]
out.Password = m[keyAntGeniusPassword]
return out, nil return out, nil
} }
@@ -9028,6 +9031,7 @@ func (a *App) SaveAntGeniusSettings(s AntGeniusSettings) error {
for k, v := range map[string]string{ for k, v := range map[string]string{
keyAntGeniusEnabled: boolStr(s.Enabled), keyAntGeniusEnabled: boolStr(s.Enabled),
keyAntGeniusHost: strings.TrimSpace(s.Host), keyAntGeniusHost: strings.TrimSpace(s.Host),
keyAntGeniusPassword: s.Password,
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -9049,7 +9053,7 @@ func (a *App) startAntGenius() {
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" { if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
return return
} }
a.antgenius = antgenius.New(s.Host, 9007) a.antgenius = antgenius.New(s.Host, 9007, s.Password)
_ = a.antgenius.Start() _ = a.antgenius.Start()
} }
+182
View File
@@ -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 <rig-ip> [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.")
}
+12 -1
View File
@@ -748,7 +748,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007. // 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. // PowerGenius XL (4O3A) amp fan-control settings.
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 }); 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" className="font-mono"
/> />
</div> </div>
<div className="space-y-1">
<Label>{t('ag2.password')}</Label>
<Input
type="password"
value={antgenius.password ?? ''}
onChange={(e) => setAntgenius((s) => ({ ...s, password: e.target.value }))}
placeholder={t('ag2.passwordPh')}
className="font-mono"
/>
<p className="text-xs text-muted-foreground">{t('ag2.passwordHint')}</p>
</div>
</div> </div>
</> </>
); );
+2 -2
View File
@@ -122,7 +122,7 @@ const en: Dict = {
// Section hints (hardware/software panel headers) // 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.', '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).", '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.", '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 12 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 12 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',
@@ -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', '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.", '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).", '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.", '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 12 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 12 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',
+2
View File
@@ -1089,6 +1089,7 @@ export namespace main {
export class AntGeniusSettings { export class AntGeniusSettings {
enabled: boolean; enabled: boolean;
host: string; host: string;
password: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AntGeniusSettings(source); return new AntGeniusSettings(source);
@@ -1098,6 +1099,7 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.host = source["host"]; this.host = source["host"];
this.password = source["password"];
} }
} }
export class AudioSettings { export class AudioSettings {
+36 -5
View File
@@ -17,6 +17,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"hamlog/internal/applog"
) )
const ( const (
@@ -49,6 +51,7 @@ type Status struct {
type Client struct { type Client struct {
host string host string
port int port int
password string // remote-access password; sent as "login <pw>" when the device requires AUTH
mu sync.Mutex // guards conn + writes mu sync.Mutex // guards conn + writes
conn net.Conn conn net.Conn
@@ -61,13 +64,14 @@ type Client struct {
running bool running bool
} }
func New(host string, port int) *Client { func New(host string, port int, password string) *Client {
if port <= 0 || port > 65535 { if port <= 0 || port > 65535 {
port = defaultPort port = defaultPort
} }
return &Client{ return &Client{
host: host, host: host,
port: port, port: port,
password: strings.TrimSpace(password),
stop: make(chan struct{}), stop: make(chan struct{}),
antennas: map[int]string{}, antennas: map[int]string{},
status: Status{Host: host}, status: Status{Host: host},
@@ -146,13 +150,27 @@ func (c *Client) runLoop() {
c.conn = conn c.conn = conn
c.mu.Unlock() c.mu.Unlock()
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host }) 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 // Subscribe to live updates and pull the initial state. Command set and
// order mirror a known-working Node-RED v4 client (WA9WUD). // order mirror a known-working Node-RED v4 client (WA9WUD).
_ = c.send("antenna list") for _, cmd := range []string{"antenna list", "sub port all", "port get 1", "port get 2"} {
_ = c.send("sub port all") if err := c.send(cmd); err != nil {
_ = c.send("port get 1") applog.Printf("antgenius: init send %q failed: %v", cmd, err)
_ = c.send("port get 2") }
}
done := make(chan struct{}) done := make(chan struct{})
go c.keepalive(conn, done) 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 { func (c *Client) readLoop(conn net.Conn) error {
r := bufio.NewReader(conn) r := bufio.NewReader(conn)
var sb strings.Builder var sb strings.Builder
start := time.Now()
lines, bytesRx := 0, 0
for { for {
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout)) _ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
b, err := r.ReadByte() b, err := r.ReadByte()
if err != nil { 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 return err
} }
bytesRx++
if b == '\r' || b == '\n' { if b == '\r' || b == '\n' {
if sb.Len() > 0 { if sb.Len() > 0 {
lines++
if lines <= 10 {
applog.Printf("antgenius: rx[%d] %q", lines, sb.String())
}
c.handleLine(sb.String()) c.handleLine(sb.String())
sb.Reset() sb.Reset()
} }
+4 -4
View File
@@ -3,7 +3,7 @@ package antgenius
import "testing" import "testing"
func TestHandleAntennaList(t *testing.T) { func TestHandleAntennaList(t *testing.T) {
c := New("x", 9007) c := New("x", 9007, "")
// Names may contain spaces — must be captured up to " tx=". // 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 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") 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) { 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 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 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 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) { 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). // 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 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") 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) { 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") c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
if st := c.GetStatus(); st.PortA != 3 { if st := c.GetStatus(); st.PortA != 3 {
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA) t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
+45 -13
View File
@@ -12,18 +12,38 @@ import (
"go.bug.st/serial" "go.bug.st/serial"
) )
// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local // civTransport is everything IcomSerial needs from its underlying link. It's
// control). It speaks the shared civ protocol, so when the network backend // satisfied directly by a go.bug.st serial.Port (USB, local control) and, for
// (icomnet) is added it will reuse the same encode/decode — only the transport // remote control, by the icomnet UDP stream which presents the tunnelled CI-V
// changes. Implements Backend; all methods run on the Manager's CAT goroutine, // byte stream as plain Read/Write (there SetDTR/SetRTS/SetReadTimeout are
// so the port is accessed single-threaded (no locking needed). // 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 { type IcomSerial struct {
portName string portName string
baud int baud int
rigAddr byte // rig's CI-V address (IC-7610 default 0x98) rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
digital string // mode to command for DATA (FT8/RTTY/…) 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 model string
// I/O routing. A single reader goroutine owns port.Read and dispatches every // 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 == "" { if digitalDefault == "" {
digitalDefault = "FT8" digitalDefault = "FT8"
} }
return &IcomSerial{ b := &IcomSerial{
portName: portName, portName: portName,
baud: baud, baud: baud,
rigAddr: byte(civAddr), rigAddr: byte(civAddr),
@@ -90,20 +110,32 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
model: "Icom", model: "Icom",
scopeFixed: true, // rigs default to a fixed-span scope 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) Name() string { return "icom" }
func (b *IcomSerial) Connect() error { func (b *IcomSerial) Connect() error {
if b.portName == "" { if b.open == nil {
return fmt.Errorf("no serial port configured") return fmt.Errorf("no transport configured")
} }
port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud}) port, err := b.open()
if err != nil { 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 // 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) _ = port.SetReadTimeout(60 * time.Millisecond)
// Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and // 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 // "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 // frames and routes each: our own echoes are dropped, spectrum-scope frames
// (0x27) go to specCh, everything else (control replies, acks, unsolicited // (0x27) go to specCh, everything else (control replies, acks, unsolicited
// transceive updates) goes to respCh. It exits when the port is closed. // 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) defer close(done)
tmp := make([]byte, 512) tmp := make([]byte, 512)
var rx []byte var rx []byte