diff --git a/changelog.json b/changelog.json index e61c039..605fe65 100644 --- a/changelog.json +++ b/changelog.json @@ -3,6 +3,7 @@ "version": "0.21.9", "date": "2026-07-28", "en": [ + "Antenna Genius: port A no longer jumps onto port B antenna a few seconds after a change. Only one port can hold the transmit antenna, so the switch reports the same one on both — the display now follows each port own receive antenna.", "Yaesu control panel: with the native Yaesu backend, a console tab and Main-view pane give S/PO/SWR meters, band and mode buttons, AF/RF/squelch, AGC, IPO-AMP1-AMP2, a 6/12/18 dB attenuator, NB, DNR, narrow filter, power, mic gain, VOX, split and ATU tune. CW, RTTY, DIGI and PSK show their sideband — click an active button again to switch it. Pressing SPLIT places the transmit VFO relative to where you are listening — up 1 kHz on CW and data, up 5 kHz on phone — and shows the transmit frequency; +1k and +5k buttons cover the rest.", "CW through the Yaesu keyer: a fifth CW engine sends your macros with the radio own keyer (CAT \"KY\") — no WinKeyer, no second cable. Pick \"Yaesu (rig keyer)\" in Settings → CW keyer, with the Yaesu CAT backend active.", "The Yaesu console follows the mode: in CW the microphone gain and VOX disappear and a CW card takes their place with keyer speed, break-in and ZIN (zero-in).", @@ -18,6 +19,7 @@ "CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes." ], "fr": [ + "Antenna Genius : le port A ne bascule plus sur l'antenne du port B quelques secondes après un changement. Un seul port peut porter l'antenne d'émission, le switch renvoie donc la même sur les deux — l'affichage suit désormais l'antenne de réception propre à chaque port.", "Panneau de contrôle Yaesu : avec le backend Yaesu natif, un onglet console et un volet de la vue principale offrent les mesures S/PO/ROS, les boutons de bande et de mode, AF/RF/squelch, AGC, IPO-AMP1-AMP2, un atténuateur 6/12/18 dB, NB, DNR, filtre étroit, puissance, gain micro, VOX, split et accord d'antenne. CW, RTTY, DIGI et PSK affichent leur bande latérale — recliquez sur le bouton actif pour en changer. Appuyer sur SPLIT place le VFO d'émission par rapport à votre écoute — +1 kHz en CW et numérique, +5 kHz en phonie — et affiche la fréquence d'émission ; les boutons +1k et +5k couvrent le reste.", "CW par le keyer Yaesu : un cinquième moteur CW envoie vos macros avec le keyer de la radio (CAT « KY ») — sans WinKeyer ni second câble. À choisir dans Réglages → Keyer CW sous « Yaesu (keyer de la radio) », avec le backend CAT Yaesu actif.", "La console Yaesu suit le mode : en CW, le gain micro et le VOX disparaissent au profit d'une carte CW avec la vitesse du manipulateur, le break-in et ZIN (zéro-in).", diff --git a/internal/antgenius/antgenius.go b/internal/antgenius/antgenius.go index 06aa175..23e9d8b 100644 --- a/internal/antgenius/antgenius.go +++ b/internal/antgenius/antgenius.go @@ -66,11 +66,12 @@ type Client struct { authTries int ready atomic.Bool // init commands sent → keepalive may run - statusMu sync.RWMutex - status Status - antennas map[int]string // index → name (rebuilt into status.Antennas) - antBands map[int]int // index → band bitmask (which bands the antenna covers) - antRawN int // one-shot: how many raw antenna lines we've logged + statusMu sync.RWMutex + status Status + antennas map[int]string // index → name (rebuilt into status.Antennas) + antBands map[int]int // index → band bitmask (which bands the antenna covers) + antRawN int // one-shot: how many raw antenna lines we've logged + lastShown map[int]int // port id → antenna last reported, so only CHANGES are logged stop chan struct{} running bool @@ -81,13 +82,14 @@ func New(host string, port int, password string) *Client { port = defaultPort } return &Client{ - host: host, - port: port, - password: strings.TrimSpace(password), - stop: make(chan struct{}), - antennas: map[int]string{}, - antBands: map[int]int{}, - status: Status{Host: host}, + host: host, + port: port, + password: strings.TrimSpace(password), + stop: make(chan struct{}), + antennas: map[int]string{}, + antBands: map[int]int{}, + lastShown: map[int]int{}, + status: Status{Host: host}, } } @@ -398,9 +400,27 @@ func (c *Client) parsePort(msg string) { } tx := kvInt(msg, "txant") rx := kvInt(msg, "rxant") - active := tx + // The RX antenna is what identifies a port's selection, and it is the only + // thing Activate sets. Preferring the TX antenna made both ports converge on + // the same row a few seconds after a change: on an 8x2 only ONE port can hold + // the transmit antenna, so the device reports the same txant on both, and a + // port A genuinely on the 80 m vertical was redrawn as the beam that port B + // transmits through. + // + // TX remains the fallback for a port that reports no RX antenna at all. + active := rx if active == 0 { - active = rx + active = tx + } + // Logged only when the shown antenna CHANGES: the device pushes port state + // every few seconds, and logging each one would bury everything else — but + // without any trace, "port A jumped to the wrong antenna" is unfalsifiable. + c.statusMu.Lock() + prev, seen := c.lastShown[id] + c.lastShown[id] = active + c.statusMu.Unlock() + if !seen || prev != active { + applog.Printf("antgenius: port %d → antenna %d (rxant=%d txant=%d) raw=%q", id, active, rx, tx, msg) } txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag c.setStatus(func(s *Status) { diff --git a/internal/antgenius/port_test.go b/internal/antgenius/port_test.go new file mode 100644 index 0000000..7df188f --- /dev/null +++ b/internal/antgenius/port_test.go @@ -0,0 +1,73 @@ +package antgenius + +import "testing" + +// Which antenna a port SHOWS. +// +// Reported on an 8x2: port A was correctly on the 80 m vertical, then a few +// seconds later both A and B were drawn on the same beam. The cause is that only +// ONE port can hold the transmit antenna, so the device reports the same txant +// on both — and the display preferred txant. The RX antenna is the per-port +// selection, and the only thing Activate sets, so it has to win. +func TestPortShowsRXAntenna(t *testing.T) { + cases := []struct { + name string + msg string + wantPort int + wantAnt int + }{ + { + // The reported case: port A listening on antenna 2 while the shared TX + // antenna is 1. Before the fix this drew port A on antenna 1. + name: "rx and tx disagree — rx wins", + msg: "port 1 rxant=2 txant=1 tx=0", + wantPort: 1, wantAnt: 2, + }, + { + name: "they agree", + msg: "port 2 rxant=3 txant=3 tx=0", + wantPort: 2, wantAnt: 3, + }, + { + // A port with no RX antenna still shows something meaningful. + name: "no rx antenna — fall back to tx", + msg: "port 1 rxant=0 txant=4 tx=1", + wantPort: 1, wantAnt: 4, + }, + { + name: "nothing selected", + msg: "port 2 rxant=0 txant=0 tx=0", + wantPort: 2, wantAnt: 0, + }, + } + for _, c := range cases { + cl := New("host", 0, "") + cl.parsePort(c.msg) + st := cl.GetStatus() + got := st.PortA + if c.wantPort == 2 { + got = st.PortB + } + if got != c.wantAnt { + t.Errorf("%s: port %d shows antenna %d, want %d (%q)", c.name, c.wantPort, got, c.wantAnt, c.msg) + } + } +} + +// The two ports must stay independent: a message about one must never move the +// other, which is the shape the operator actually saw on screen. +func TestPortsAreIndependent(t *testing.T) { + c := New("host", 0, "") + c.parsePort("port 1 rxant=2 txant=0 tx=0") + c.parsePort("port 2 rxant=1 txant=1 tx=1") + st := c.GetStatus() + if st.PortA != 2 { + t.Errorf("port A = %d, want 2 — port B's message moved it", st.PortA) + } + if st.PortB != 1 { + t.Errorf("port B = %d, want 1", st.PortB) + } + if !st.TxB || st.TxA { + t.Errorf("transmit flags crossed: TxA=%v TxB=%v", st.TxA, st.TxB) + } +}