feat: Kenwood CAT over a network serial bridge

The same ASCII stream over a socket instead of a wire: ser2net, an
Ethernet-serial adapter, a Raspberry Pi at the radio. One TCP transport
presented as a serial port, so the backend keeps a single code path.

Explicitly NOT the radio's own RJ45. A TS-890 or TS-990 speaks Kenwood's
KNS/ARCP there — session, authentication, a different protocol — and that needs
one of those radios in hand to write honestly. The setting's help text says so,
because an operator who plugs in their TS-890's Ethernet port and types its
address deserves to learn that from the interface rather than from silence.

Two details that decide whether this is usable or maddening:

  - A read deadline expiring is reported as "no data yet", not as an error. It
    is exactly what a serial read timeout means to the caller; as an error it
    would make ask() abandon a rig that is merely thinking.
  - The log line names what was connected to. "connected on  @ 0 baud" after a
    network connect sends someone hunting a serial fault that does not exist.

The host wins over the COM port when both are filled: it is the more deliberate
setting, and silently preferring the wire leaves someone staring at an address
they typed and a radio that never answers.
This commit is contained in:
2026-07-30 21:13:19 +02:00
parent e6b8a17772
commit 3a87b58ba3
6 changed files with 131 additions and 28 deletions
+82 -4
View File
@@ -29,7 +29,9 @@ package cat
// from the radio, and the rig file decides what a "Freq" property means.
import (
"errors"
"fmt"
"net"
"strconv"
"strings"
"sync"
@@ -43,7 +45,16 @@ import (
type Kenwood struct {
portName string
baud int
digital string // mode name logged for data (FT8 by default)
// host is "address:port" for a serial link reached over the network — a
// ser2net daemon, an Ethernet-serial adapter, a Raspberry Pi in the shack.
//
// This is NOT Kenwood's own network protocol. A TS-890 or TS-990 speaks
// KNS/ARCP over its Ethernet socket, with a session and authentication, and
// that is a different piece of work needing one of those radios to confirm
// it. What this covers is the same CAT byte stream over a socket instead of
// a wire, which is how most operators actually put a rig on the network.
host string
digital string // mode name logged for data (FT8 by default)
mu sync.Mutex
port serial.Port
@@ -62,6 +73,14 @@ type Kenwood struct {
unsupported map[string]bool
}
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
// a COM port (ser2net and friends).
func NewKenwoodTCP(hostPort, digital string) *Kenwood {
k := NewKenwood("", 0, digital)
k.host = strings.TrimSpace(hostPort)
return k
}
func NewKenwood(portName string, baud int, digital string) *Kenwood {
if baud <= 0 {
baud = 9600 // TS-590 factory default; TS-890 ships at 115200
@@ -77,8 +96,8 @@ func (k *Kenwood) Name() string { return "kenwood" }
func (k *Kenwood) Connect() error {
k.mu.Lock()
defer k.mu.Unlock()
if k.portName == "" {
return fmt.Errorf("kenwood: no serial port configured")
if k.portName == "" && k.host == "" {
return fmt.Errorf("kenwood: no serial port or network address configured")
}
// Close any handle still held before opening another: Connect runs again on
// every reconnect, and Windows opens a serial port exclusively, so a leaked
@@ -90,6 +109,9 @@ func (k *Kenwood) Connect() error {
}
p, err := k.openPort()
if err != nil {
if k.host != "" {
return fmt.Errorf("kenwood: connect %s: %w", k.host, err)
}
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
}
p.SetReadTimeout(300 * time.Millisecond)
@@ -118,9 +140,19 @@ 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)
}
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)
}
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
// 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
// serial fault that does not exist.
if k.host != "" {
debugLog.Printf("kenwood: connected to %s (network serial bridge), model=%q", k.host, k.model)
} else {
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
}
return nil
}
@@ -453,9 +485,55 @@ func (k *Kenwood) openPort() (serial.Port, error) {
if k.dialPort != nil {
return k.dialPort()
}
if k.host != "" {
c, err := net.DialTimeout("tcp", k.host, 5*time.Second)
if err != nil {
return nil, err
}
return &tcpSerial{conn: c}, nil
}
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
}
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
// code path whether the rig is on a wire or on the network.
//
// The modem-control methods are no-ops rather than errors: a bridge has no DTR
// to raise, and failing them would break a caller that sets them defensively.
type tcpSerial struct{ conn net.Conn }
func (t *tcpSerial) Read(p []byte) (int, error) {
n, err := t.conn.Read(p)
// A read deadline expiring is this transport's "no data yet", exactly what a
// serial read timeout means to the caller — not a dead link. Reporting it as
// an error would make ask() abandon a rig that is merely thinking.
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return n, nil
}
}
return n, err
}
func (t *tcpSerial) Write(p []byte) (int, error) { return t.conn.Write(p) }
func (t *tcpSerial) Close() error { return t.conn.Close() }
func (t *tcpSerial) SetReadTimeout(d time.Duration) error {
if d <= 0 {
return t.conn.SetReadDeadline(time.Time{})
}
return t.conn.SetReadDeadline(time.Now().Add(d))
}
func (t *tcpSerial) SetMode(*serial.Mode) error { return nil }
func (t *tcpSerial) Drain() error { return nil }
func (t *tcpSerial) ResetInputBuffer() error { return nil }
func (t *tcpSerial) ResetOutputBuffer() error { return nil }
func (t *tcpSerial) SetDTR(bool) error { return nil }
func (t *tcpSerial) SetRTS(bool) error { return nil }
func (t *tcpSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
return &serial.ModemStatusBits{}, nil
}
func (t *tcpSerial) Break(time.Duration) error { return nil }
// askVFO asks FR; or FT; and returns "A" or "B".
//
// The reply is FR0; / FR1; — the digit right after the two-letter command.