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:
+82
-4
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user