diff --git a/changelog.json b/changelog.json index 05946de..c820f3d 100644 --- a/changelog.json +++ b/changelog.json @@ -4,11 +4,13 @@ "date": "", "en": [ "QSOs logged from WSJT-X now carry the space weather and the distance, like hand-logged ones. The UDP path stamped the station profile, the DXCC and the QSL defaults but not SFI, A, K or distance — so an operator running digital had those fields empty across the whole log. Space weather is only stamped on a contact less than a day old: a logger re-broadcasting its backlog would otherwise be handed this morning readings for last month contacts.", - "Band openings: EME contacts are no longer mistaken for an opening. A 2 m opening was announced at 9650 km towards Japan on stations working moonbounce — real contacts, but the moon says nothing about the band, and an antenna pointed that way finds nothing. Each band now has the longest path the atmosphere can actually carry: 3500 km on 2 m, 4000 on 4 m, and no limit at all on 6 and 10 m where multi-hop really does go round the world." + "Band openings: EME contacts are no longer mistaken for an opening. A 2 m opening was announced at 9650 km towards Japan on stations working moonbounce — real contacts, but the moon says nothing about the band, and an antenna pointed that way finds nothing. Each band now has the longest path the atmosphere can actually carry: 3500 km on 2 m, 4000 on 4 m, and no limit at all on 6 and 10 m where multi-hop really does go round the world.", + "Kenwood: WSJT-X \"Fake It\" no longer leaves the dial on the transmit frequency. A frequency set while transmitting was not recorded, so WSJT-X was told the radio was already back on the receive frequency and never restored it." ], "fr": [ "Les QSO enregistrés depuis WSJT-X portent désormais la météo spatiale et la distance, comme ceux saisis à la main. Le chemin UDP posait le profil station, le DXCC et les défauts QSL mais ni SFI, ni A, ni K, ni distance — un opérateur en numérique avait donc ces champs vides sur tout son log. La météo spatiale n est posée que sur un contact de moins d un jour : sinon un logiciel qui rediffuse son historique se verrait attribuer les relevés de ce matin sur des contacts du mois dernier.", - "Ouvertures de bande : les contacts EME ne sont plus pris pour une ouverture. Une ouverture 2 m était annoncée à 9650 km vers le Japon sur des stations en rebond lunaire — de vrais contacts, mais la Lune ne dit rien de la bande, et une antenne pointée par là ne trouve rien. Chaque bande a désormais la distance maximale que l atmosphère peut réellement porter : 3500 km en 2 m, 4000 en 4 m, et aucune limite en 6 et 10 m où les sauts multiples font vraiment le tour du monde." + "Ouvertures de bande : les contacts EME ne sont plus pris pour une ouverture. Une ouverture 2 m était annoncée à 9650 km vers le Japon sur des stations en rebond lunaire — de vrais contacts, mais la Lune ne dit rien de la bande, et une antenne pointée par là ne trouve rien. Chaque bande a désormais la distance maximale que l atmosphère peut réellement porter : 3500 km en 2 m, 4000 en 4 m, et aucune limite en 6 et 10 m où les sauts multiples font vraiment le tour du monde.", + "Kenwood : le « Fake It » de WSJT-X ne laisse plus le VFO sur la fréquence d émission. Un changement de fréquence pendant l émission n était pas enregistré, WSJT-X croyait donc la radio déjà revenue sur la fréquence de réception et ne la remettait jamais en place." ] }, { diff --git a/internal/cat/kenwood.go b/internal/cat/kenwood.go index ca6da1e..6d9642b 100644 --- a/internal/cat/kenwood.go +++ b/internal/cat/kenwood.go @@ -440,7 +440,31 @@ func (k *Kenwood) SetFrequency(hz int64) error { if k.curVFO == "B" { cmd = "FB" } - return k.write(fmt.Sprintf("%s%011d;", cmd, hz)) + if err := k.write(fmt.Sprintf("%s%011d;", cmd, hz)); err != nil { + return err + } + // Remember what we just commanded. + // + // While PTT is held the poll is skipped and State() hands back lastState — the + // rig answers "?;" to IF; mid-transmission, and reading that as a fault used + // to drop the whole link. But a frequency SET during that window then went + // unrecorded, so the cache kept describing the dial as it was before. + // + // WSJT-X's "Fake It" is exactly that sequence: move the dial, key, transmit, + // and afterwards put it back. Polling during the over, it was told the rig was + // still on the receive frequency — so there was nothing to put back, and the + // dial stayed on the transmit frequency for good. Every following over + // started from there, which is the drift that was reported. + // + // Only simplex is updated here. Under split, FreqHz means the transmit + // frequency while this write lands on whichever VFO the operator is on, and + // guessing which side moved would be worse than a stale value the next poll + // corrects on its own. + if !k.lastState.Split { + k.curFreq = hz + k.lastState.FreqHz = hz + } + return nil } func (k *Kenwood) SetMode(mode string) error { diff --git a/internal/cat/kenwood_fakeit_test.go b/internal/cat/kenwood_fakeit_test.go new file mode 100644 index 0000000..6758b70 --- /dev/null +++ b/internal/cat/kenwood_fakeit_test.go @@ -0,0 +1,100 @@ +package cat + +import "testing" + +// WSJT-X "Fake It" against the transmit-window cache. +// +// Fake It keeps the radio on one dial frequency and shifts it only for the +// duration of each over: set the transmit frequency, key, transmit, unkey, set +// it back. The restore is not unconditional — WSJT-X reads the frequency back +// and puts the dial where it believes it should be. +// +// That read lands inside the window where this backend deliberately stops +// polling, because a Kenwood answers "?;" to IF; while it is transmitting and +// treating that as a fault used to drop the whole shared link. The cache +// answers instead. So the cache has to account for frequency SETS made during +// the window, or it describes the dial as it was before the over — and WSJT-X, +// told the radio is already on the receive frequency, has nothing to restore. +// +// This reproduces the sequence from a reported session: the dial stayed on the +// transmit frequency after the first over and every later one started there. +func TestKenwoodFakeItRestoresAfterTransmit(t *testing.T) { + const ( + rxHz = 7074000 // where the operator is listening + txHz = 7075500 // where Fake It moves the dial to transmit + ) + + rig := &ts2000{vfoA: rxHz, mode: '2'} + k := NewKenwood("COM-TEST", 9600, "FT8") + k.dialPort = dialTo(rig) + if err := k.Connect(); err != nil { + t.Fatalf("connect: %v", err) + } + defer k.Disconnect() + + if s, err := k.ReadState(); err != nil || s.FreqHz != rxHz { + t.Fatalf("before the over: %d (err %v) — want %d", s.FreqHz, err, rxHz) + } + + // The over: shift the dial, then key. + if err := k.SetFrequency(txHz); err != nil { + t.Fatalf("set transmit frequency: %v", err) + } + if err := k.SetPTT(true); err != nil { + t.Fatalf("ptt on: %v", err) + } + + // WSJT-X reads back mid-over. The wire is not polled here — this is the + // cache talking, and it must not still be saying rxHz. + s, err := k.ReadState() + if err != nil { + t.Fatalf("read during the over: %v", err) + } + if s.FreqHz != txHz { + t.Errorf("during the over the backend reported %d, want %d — "+ + "reporting the pre-over frequency is what stops Fake It restoring the dial", s.FreqHz, txHz) + } + + if err := k.SetPTT(false); err != nil { + t.Fatalf("ptt off: %v", err) + } + + // The restore, once the over is done. + if err := k.SetFrequency(rxHz); err != nil { + t.Fatalf("restore: %v", err) + } + if rig.vfoA != rxHz { + t.Errorf("dial left on %d after the over, want %d", rig.vfoA, rxHz) + } + if s, err := k.ReadState(); err != nil || s.FreqHz != rxHz { + t.Errorf("after the over: %d (err %v) — want %d", s.FreqHz, err, rxHz) + } +} + +// Under split the same write must NOT touch the cache: FreqHz means the +// transmit frequency while the write lands on whichever VFO the operator is on, +// so guessing which side moved would put a wrong number in front of the +// operator. A stale one survives only until the next poll. +func TestKenwoodSplitCacheLeftToThePoll(t *testing.T) { + rig := &ts2000{vfoA: 14025000, vfoB: 14030000, mode: '3', split: true} + k := NewKenwood("COM-TEST", 9600, "CW") + k.dialPort = dialTo(rig) + if err := k.Connect(); err != nil { + t.Fatalf("connect: %v", err) + } + defer k.Disconnect() + + s, err := k.ReadState() + if err != nil || !s.Split { + t.Fatalf("split not seen: %+v (err %v)", s, err) + } + before := s.FreqHz + + if err := k.SetFrequency(14026000); err != nil { + t.Fatalf("set: %v", err) + } + if k.lastState.FreqHz != before { + t.Errorf("split cache moved to %d on a VFO write, want it left at %d for the poll", + k.lastState.FreqHz, before) + } +}