fix: solve issue with Antenna Genius for remote operations
This commit is contained in:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 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',
|
||||||
@@ -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 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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user