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
+31 -19
View File
@@ -116,6 +116,7 @@ const (
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
keyCATKenwoodHost = "cat.kenwood.host" // Kenwood CAT over a network serial bridge (ser2net), "host:port"
keyCATKenwoodPort = "cat.kenwood.port" // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
keyCATKenwoodBaud = "cat.kenwood.baud" // Kenwood CAT baud (TS-590 default 9600, TS-890 115200)
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
@@ -359,23 +360,25 @@ type CATSettings struct {
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
SharePort int `json:"share_port"` // TCP port for it (default 4532)
KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial
// adapter). NOT the radios own RJ45, which speaks Kenwoods KNS/ARCP.
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
SharePort int `json:"share_port"` // TCP port for it (default 4532)
}
// ModePreset is a mode entry with default RST values to auto-populate
@@ -6653,7 +6656,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
if err != nil {
return CATSettings{}, err
}
@@ -6672,6 +6675,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
YaesuPort: m[keyCATYaesuPort],
YaesuBaud: 38400,
KenwoodPort: m[keyCATKenwoodPort],
KenwoodHost: m[keyCATKenwoodHost],
KenwoodBaud: 9600,
IcomPort: m[keyCATIcomPort],
IcomBaud: 115200,
@@ -6827,6 +6831,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
keyCATKenwoodHost: strings.TrimSpace(s.KenwoodHost),
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
@@ -12287,7 +12292,14 @@ func (a *App) reloadCAT() {
// same dialect (Elecraft K3/K4, and the "Kenwood" setting on other rigs).
// One IF; frame carries frequency, mode, VFO and split, so the poll costs a
// single round trip where OmniRig needed a rig file to describe each one.
a.cat.Start(cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault))
// A network address wins over the COM port when both are filled: it is the
// more deliberate setting, and silently preferring the wire would leave an
// operator staring at a host they typed and a radio that never answers.
if h := strings.TrimSpace(s.KenwoodHost); h != "" {
a.cat.Start(cat.NewKenwoodTCP(h, s.DigitalDefault))
} else {
a.cat.Start(cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault))
}
case "icom":
// Native Icom CI-V over the radio's USB serial port (local control).
// Same civ protocol the network backend reuses for remote.