fix(cat): stop deasserting DTR/RTS on Kenwood and Yaesu

d4bfd30 fixed a Xiegu G90 behind a DE-19 that keyed on connect, and I
applied the same line-lowering to Kenwood and Yaesu with no evidence that
either needed it. It silenced them: many USB-serial interfaces will not
transmit with RTS low — hardware flow control, or an output stage the
line enables. A TS-990 on COM3 opened cleanly and answered nothing, which
reached us as "CAT stopped working after the update".

Xiegu keeps the behaviour: that is where the fault was reported, and that
backend now has an explicit setting for which line keys the rig. The CI-V
backend keeps its own, which predates all of this.

Kenwood also distinguishes a silent port from one sending data that never
answers, and quotes what arrived — "the rig is not answering" sent an
operator checking the power switch on a radio whose frames were visibly
on the wire.
This commit is contained in:
2026-08-01 12:09:11 +02:00
parent c62d992ad6
commit 3dd428d748
4 changed files with 81 additions and 32 deletions
+45 -15
View File
@@ -83,6 +83,20 @@ type Kenwood struct {
// That is the "discarding \" 000000000010000000;\" while waiting for IF"
// a TS-480 reported, followed by connected=false for ever.
rx []byte
// heard is whatever arrived during Connect that was not a reply we wanted.
// Kept only to put it in the error message: "not answering" and "answering
// something unreadable" are different faults with different fixes.
heard string
}
// where names the link for a message, so an error does not read "COM @ 0 baud"
// after a network connect.
func (k *Kenwood) where() string {
if k.host != "" {
return k.host
}
return k.portName
}
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
@@ -140,6 +154,7 @@ func (k *Kenwood) Connect() error {
// its question for the rest of the session.
k.rx = nil
k.drain(250 * time.Millisecond)
k.heard = ""
answered := false
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
@@ -159,10 +174,22 @@ func (k *Kenwood) Connect() error {
}
if !answered {
k.model = ""
if k.host != "" {
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — check that the serial bridge points at the radio and that the radio is switched on", k.host)
if k.heard == "" && len(k.rx) > 0 {
k.heard = string(k.rx) // an unterminated fragment is evidence too
}
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud)
// Distinguish silence from noise. "The rig is not answering" sent an
// operator checking the power switch and the baud rate on a radio that was
// visibly talking — its frames were arriving, they just did not match what
// was asked (wrong baud garbles them; an interface echoing our own
// commands back produces the same). Say which of the two it is, and quote
// what came back, because that is the fact that decides where to look.
if seen := k.heard; seen != "" {
return fmt.Errorf("kenwood: %s is sending data but no reply to ID; or IF; — got %q. Check the baud rate (set to %d here) and that nothing else is echoing the port", k.where(), seen, k.baud)
}
if k.host != "" {
return fmt.Errorf("kenwood: %s accepted the connection but the rig sent nothing — check that the serial bridge points at the radio and that the radio is switched on", k.host)
}
return fmt.Errorf("kenwood: %s opened but the rig sent nothing — check that it is switched on and set to %d baud", k.portName, k.baud)
}
// Name what was actually connected to. A log reading "connected on @ 0 baud"
// after a network connect is the kind of line that sends someone hunting a
@@ -372,6 +399,11 @@ func (k *Kenwood) ask(cmd string) (string, error) {
return frame, nil
}
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
// Remember the first unexpected frame: if the whole handshake fails, this
// is what tells the operator the radio was talking after all.
if k.heard == "" {
k.heard = frame
}
}
if !time.Now().Before(deadline) {
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
@@ -536,20 +568,18 @@ func (k *Kenwood) openPort() (serial.Port, error) {
if err != nil {
return nil, err
}
// Deassert DTR and RTS.
// The modem lines are LEFT ALONE.
//
// Windows raises both when a serial port is opened, and a great many
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
// documentation asks for RTS and DTR low. The same applies to an Icom with
// USB SEND mapped to a line (which is why the CI-V backend has done this
// from the start) and to any rig keyed by a home-made cable.
// They were briefly deasserted here, to stop an interface that reads them as
// PTT from keying the rig on connect. That silenced radios instead: a TS-990
// on COM3 opened fine and answered nothing, because a great many USB-serial
// interfaces will not transmit with RTS low — hardware flow control, or an
// output stage the line enables. "Opened but the rig sent nothing" was this,
// and it arrived as "CAT stopped working after the update".
//
// PTT on this backend is a CAT command, so neither line should ever be
// asserted here. A station keying by RTS/DTR configures that separately,
// on its own port.
_ = p.SetDTR(false)
_ = p.SetRTS(false)
// The fault that change was written for was a Xiegu G90 behind a DE-19, and
// that backend now has an explicit setting for which line keys it. A rig
// whose PTT is a CAT command has no business touching DTR or RTS at all.
return p, nil
}
+25 -2
View File
@@ -265,8 +265,8 @@ func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
if err == nil {
t.Fatal("a silent port was reported as a connected rig")
}
if !strings.Contains(err.Error(), "not answering") {
t.Errorf("error was %q — it should say the rig is not answering", err)
if !strings.Contains(err.Error(), "sent nothing") {
t.Errorf("error was %q — a silent port should be reported as silence", err)
}
if k.model != "" {
t.Errorf("a stale model survived a failed connect: %q", k.model)
@@ -355,3 +355,26 @@ func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
}
}
// A rig that talks but never answers what was asked must not be reported as a
// silent one.
//
// The two faults need opposite responses: silence means the radio is off, the
// wrong port, or a dead cable; noise means the baud rate is wrong or something
// is echoing the line. "The rig is not answering" sent an operator checking the
// power switch on a radio whose frames were visibly arriving.
func TestKenwoodNoisyRigIsReportedAsNoiseNotSilence(t *testing.T) {
k := NewKenwood("COM-TEST", 9600, "FT8")
k.dialPort = func() (serial.Port, error) {
return &fakeSerial{toRig: &strings.Builder{}, answer: func(cmd string) string {
return "XX9999;" // something, but never the reply to ID; or IF;
}}, nil
}
err := k.Connect()
if err == nil {
t.Fatal("a rig answering gibberish was reported as connected")
}
if !strings.Contains(err.Error(), "sending data") || !strings.Contains(err.Error(), "XX9999;") {
t.Errorf("error was %q — it should say data arrived, and quote it", err)
}
}
+5 -13
View File
@@ -150,20 +150,12 @@ func (y *Yaesu) Connect() error {
if err != nil {
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
}
// Deassert DTR and RTS.
// The modem lines are LEFT ALONE — see the same note in kenwood.go.
//
// Windows raises both when a serial port is opened, and a great many
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
// documentation asks for RTS and DTR low. The same applies to an Icom with
// USB SEND mapped to a line (which is why the CI-V backend has done this
// from the start) and to any rig keyed by a home-made cable.
//
// PTT on this backend is a CAT command, so neither line should ever be
// asserted here. A station keying by RTS/DTR configures that separately,
// on its own port.
_ = p.SetDTR(false)
_ = p.SetRTS(false)
// Deasserting them to keep an interface from keying the rig silenced radios
// instead: many USB-serial interfaces will not transmit with RTS low. The
// Xiegu fault that change was written for is handled in that backend, which
// now has an explicit setting for which line keys the rig.
p.SetReadTimeout(300 * time.Millisecond)
y.port = p