fix(kenwood): record a frequency set made while transmitting

WSJT-X "Fake It" shifts the dial for the duration of each over and puts it
back afterwards, but the restore is conditional: it reads the frequency back
and only moves the dial if it disagrees with where it believes the radio
should be.

That read lands inside the window where this backend stops polling on
purpose. A Kenwood answers "?;" to IF; while it is transmitting, and treating
that as a fault used to drop the shared CAT link entirely, so the cached
state answers instead. SetFrequency wrote the command to the rig without
touching that cache — so mid-over the cache still described the pre-over
dial, WSJT-X read its own receive frequency back, concluded there was nothing
to restore, and the radio stayed on the transmit frequency. Every later over
started from there.

Reported from a session where the dial stuck at 7075500 after a full FT8
over while a bare TUNE, which never sets a frequency, worked fine.

Only simplex updates the cache. Under split, FreqHz is the transmit frequency
while the write lands on whichever VFO the operator is on, and guessing which
side moved would put a wrong number in front of the operator — a stale one
survives until the next poll.

The test reproduces the reported sequence and fails without the fix with the
same frequency the log shows.
This commit is contained in:
2026-08-12 07:34:21 +02:00
parent 025472820b
commit e4014e11d2
3 changed files with 129 additions and 3 deletions
+4 -2
View File
@@ -4,11 +4,13 @@
"date": "", "date": "",
"en": [ "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.", "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": [ "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.", "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."
] ]
}, },
{ {
+25 -1
View File
@@ -440,7 +440,31 @@ func (k *Kenwood) SetFrequency(hz int64) error {
if k.curVFO == "B" { if k.curVFO == "B" {
cmd = "FB" 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 { func (k *Kenwood) SetMode(mode string) error {
+100
View File
@@ -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)
}
}