Files
OpsLog/internal/cat/kenwood.go
T
rouggy cfdd24d52e fix(kenwood): a busy rig is not a lost rig
A Kenwood answers "?;" while it is busy, and the TS-590SG does it for a moment
after RX; — which is exactly when the poll resumes. One rejected IF; then tore
down the whole CAT link.

The code already knew this. Its own comment says a "?;" to IF; is "a transient
busy, NOT unsupported" and that latching it off "would read as lost the rig".
That reasoning was applied to the unsupported-command map and stopped there: the
call still returned an error, the poll loop still turned it into connected=false,
and the link still dropped.

What it cost was not cosmetic. WSJT-X keys through shared CAT, so it lost the
rig mid-sequence; Hamlib then sent "F 9223372036854775808.000000" - an
uninitialised 2^63 - which OpsLog refused and logged. The operator sees a
frightening frequency error whose actual cause is three lines earlier.

A "?;" is now sentinel-wrapped so the poll can tell "the rig declined" from "the
serial link is gone", and three consecutive ones are ridden out on the last
known state before the link is called dead. Under a second of tolerance: enough
for the rig to finish, far too short to hide an unplugged cable. A serial fault
is untouched and still drops immediately.

Tested against the emulated rig already in this package, with a hook that
answers "?;" on demand: the link survives inside the grace and still reports the
last good frequency, the count resets on recovery so a later busy spell gets the
full allowance, and a rig refusing forever is still reported as broken.
2026-08-11 08:37:24 +02:00

842 lines
31 KiB
Go

package cat
// Native Kenwood CAT — TS-590, TS-890, TS-2000 and the many rigs that speak the
// same dialect (Elecraft K3/K4, and the "Kenwood/Elecraft" setting on Flex and
// SunSDR). Plain ASCII, every command terminated by ';', same shape as Yaesu but
// a different vocabulary.
//
// The dialect is already proven in this repository from the other side:
// internal/catemu ANSWERS these commands, pretending to be a TS-2000 so an ACOM
// amplifier follows OpsLog. The frame layouts here and there are the same ones.
//
// Commands used:
//
// FA; → FA00014025000; VFO A frequency, ELEVEN digits, Hz
// FB; → FB00014030000; VFO B frequency
// IF; → 38-char status frame: frequency, RX/TX, mode, VFO, split —
// the whole operating state in ONE round trip, which is why it
// is the poll rather than asking four separate questions.
// MD; → MD3; mode (1=LSB 2=USB 3=CW 4=FM 5=AM 6=FSK
// 7=CW-R 9=FSK-R)
// FR0;/FR1; receive VFO — 0 = A, 1 = B
// FT0;/FT1; transmit VFO (split = the two differ)
// TX;/RX; key / unkey
// ID; → ID020; model number
// AI0; silence unsolicited status reports
//
// Why not OmniRig: the same reason the Yaesu backend exists. Every Kenwood
// fault reported through OmniRig came from its interpretation layer rather than
// from the radio, and the rig file decides what a "Freq" property means.
import (
"bytes"
"errors"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
)
// Kenwood is the native backend. One serial port, one mutex: a command and its
// reply are never interleaved with another exchange.
type Kenwood struct {
portName string
baud int
// 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)
// dataMode chooses what CAT mode a DATA/digital mode (FT8, PSK…) sets, because
// no single Kenwood mode digit is right for every rig: "usb" → USB (the SSB-data
// base, default), "data" → the DATA mode digit MD6 (Elecraft K3/K4), "keep" →
// leave the rig's current mode untouched (safest for a TS-590SG/TS-990S whose
// data mode is a USB modifier the operator sets on the rig). See SetMode.
dataMode string
// elecraft marks a K3/K4 (the "Elecraft" backend). It reuses this Kenwood-dialect
// client but, being an Elecraft, always drives digital modes to DATA A (MD6+DT0)
// — the sub-mode FT8 audio needs — instead of going through the dataMode option.
elecraft bool
mu sync.Mutex
port serial.Port
// dialPort, when set, replaces serial.Open. It exists so the backend can be
// driven against internal/catemu — which already SPEAKS this dialect to
// satisfy an ACOM amplifier — without a radio, a COM port or a null-modem
// pair. Written for a Kenwood backend nobody here owns a rig to test.
dialPort func() (serial.Port, error)
model string
curFreq int64
curRXFreq int64
curVFO string // "A" or "B"
// Split is confirmed with FR;/FT; because IF's split bit is empty on some
// Kenwood-dialect rigs — but that check is THROTTLED so it doesn't run every
// poll: the two extra commands tripled the poll time on a slow K3 and made the
// frequency (read from IF at the top of the poll) lag by seconds. Between
// checks the last result stands.
splitCheckAt time.Time
splitCached bool
splitVFOCached string
keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go)
// Commands this rig answered "?;" to — asked once, then never again.
unsupported map[string]bool
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
//
// It has to survive: a rig answers faster than we ask, so one Read often
// returns a whole reply plus the start of the next frame. When this buffer
// was local to ask, everything after the matched frame was dropped — half a
// frame included — and the link desynchronised permanently: every ask then
// found the PREVIOUS command's answer and timed out waiting for its own.
// 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
// lowerLines deasserts DTR and RTS after opening the port.
//
// Neither default is safe for everyone, which is why this is a setting and
// not a decision. Windows raises both lines on open: an interface that reads
// them as PTT then keys the rig for as long as OpsLog is running — reported
// as FT8 output power wandering, and gone the moment OpsLog was closed.
// Lowering them instead silences the radios whose USB-serial interface needs
// RTS asserted to transmit at all — a TS-990 that opened cleanly and answered
// nothing. Off by default: that is how this backend behaved for its whole
// life before the question came up.
lowerLines bool
// tx tracks whether we currently hold PTT (SetPTT true). A Kenwood/Elecraft rig
// answers "?;" to a status poll (IF;) WHILE TRANSMITTING; OpsLog used to read
// that as "the rig doesn't support IF;", latch it off and drop the whole CAT
// link — which tore down the shared-CAT PTT that WSJT-X / JTDX key through, so a
// K3 keyed but the logger's transmission fell apart. While PTT is held we skip
// the wire poll and hand back the last good state (lastState) instead. txAt caps
// the skip so a missed SetPTT(false) can't freeze the state for ever.
tx bool
txAt time.Time
lastState RigState
// ifRejects counts consecutive "?;" answers to IF;. See State().
ifRejects int
}
// errRigRejected marks a "?;" — the rig understood the frame and declined it.
// Distinct from a serial fault on purpose: one is "ask again in a moment", the
// other is "the link is gone", and collapsing them is what dropped CAT sharing.
var errRigRejected = errors.New("rejected by rig")
// ifRejectGrace is how many consecutive "?;" answers to IF; are ridden out
// before the link is called dead.
//
// A Kenwood answers "?;" while it is busy — the tail of a transmission, a menu
// open on the front panel. The TS-590SG does it for a moment after RX;, which is
// exactly when the poll resumes: WSJT-X keyed through shared CAT, dropped PTT,
// and the very next IF; came back "?;". One rejected poll then tore down the
// whole link, WSJT-X lost the rig, and Hamlib went on to send an uninitialised
// frequency (2^63) that OpsLog rightly refused — an alarming error message whose
// real cause was three lines earlier in the log.
//
// Three at 250 ms is under a second of tolerance: long enough for the rig to
// finish whatever it was doing, far too short to hide an unplugged cable.
const ifRejectGrace = 3
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
// before Connect.
func (k *Kenwood) SetLowerLines(v bool) {
k.mu.Lock()
k.lowerLines = v
k.mu.Unlock()
}
// 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
// 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
}
if strings.TrimSpace(digital) == "" {
digital = "FT8"
}
return &Kenwood{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
}
func (k *Kenwood) Name() string { return "kenwood" }
func (k *Kenwood) Connect() error {
k.mu.Lock()
defer k.mu.Unlock()
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
// handle makes the next open fail with "port busy" (the fault found in the
// Yaesu backend — same shape here).
if k.port != nil {
_ = k.port.Close()
k.port = nil
}
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)
k.port = p
k.unsupported = map[string]bool{}
// Silence unsolicited status reports: they interleave with our
// request/response pairs and make a reply impossible to attribute. We poll.
_ = k.write("AI0;")
// Start from silence. A reconnect inherits whatever the rig said last —
// unsolicited frames sent before AI0 landed, the tail of an answer nobody
// read — and one stale frame is enough to leave every ask one reply behind
// 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") {
answered = true
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
if name, ok := kenwoodModels[code]; ok {
k.model = name
} else {
k.model = "Kenwood (" + code + ")"
debugLog.Printf("kenwood: unknown model id %q — add it to kenwoodModels", code)
}
}
// IF is the command everything else depends on, so it is also the honest
// test of whether a radio is really there.
if r, err := k.ask("IF;"); err == nil && strings.HasPrefix(r, "IF") {
answered = true
}
if !answered {
k.model = ""
if k.heard == "" && len(k.rx) > 0 {
k.heard = string(k.rx) // an unterminated fragment is evidence too
}
// 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
// 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
}
func (k *Kenwood) Disconnect() {
k.mu.Lock()
defer k.mu.Unlock()
if k.port != nil {
_ = k.port.Close()
k.port = nil
}
}
// ReadState polls the rig. IF carries frequency, mode, VFO, split and TX state
// in one frame; the other VFO is only asked for when split is actually on, so
// the common simplex case costs a single round trip.
func (k *Kenwood) ReadState() (RigState, error) {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return RigState{}, fmt.Errorf("kenwood: not connected")
}
// While transmitting, don't poll: the rig returns "?;" to IF; during TX, and
// treating that as a fault dropped the shared CAT link the digital-mode
// software keys through. Hand back the last known state. The 30 s cap resyncs
// if a SetPTT(false) was somehow missed, so a stuck TX can't freeze state.
if k.tx && !k.txAt.IsZero() && time.Since(k.txAt) < 30*time.Second {
s := k.lastState
s.Connected = true
return s, nil
}
raw, err := k.ask("IF;")
if err != nil {
// A "?;" is the rig saying "busy", not "gone". Ride out a few and keep
// serving the last known state, so the CAT link the digital software keys
// through survives the moment after a transmission. A serial fault is NOT
// covered: that returns a different error and drops through at once.
if errors.Is(err, errRigRejected) && k.lastState.Connected && k.ifRejects < ifRejectGrace {
k.ifRejects++
debugLog.Printf("kenwood: IF; rejected (%d/%d) — rig busy, keeping the link", k.ifRejects, ifRejectGrace)
s := k.lastState
s.Connected = true
return s, nil
}
k.ifRejects = 0
return RigState{}, err
}
k.ifRejects = 0
f, ok := parseKenwoodIF(raw)
if !ok {
return RigState{}, fmt.Errorf("kenwood: unparsable IF frame %q", raw)
}
s := RigState{Connected: true, Backend: "kenwood"}
k.curVFO = f.VFO
s.Vfo = f.VFO
// Both branches of SetMode that emit MD6 identify a K3/K4: the Elecraft
// backend, and the "MD6" data-mode option that exists for it. Decode the
// read-back the same way, or OpsLog contradicts the mode it just set.
s.Mode = kenwoodModeToADIF(f.Mode, k.digital, k.elecraft || k.dataMode == "data")
s.Rig = k.model
// IF reports the frequency of the VFO in USE (what the operator hears).
rx := f.FreqHz
tx := rx
// IF's split bit is not filled in by every rig that speaks this dialect —
// reported on a Flex through its Kenwood CAT emulation, where the frequency
// reads perfectly and split never appears. So ask the question directly as
// well: split IS "the transmit VFO differs from the receive VFO", which is
// what FR/FT answer, and it is the same rule the Yaesu backend settled on.
//
// A rig that rejects FR/FT answers "?;" once and is never asked again, so
// this costs two short commands per poll only where it actually works.
split := f.Split
if !split {
// Confirm with FR;/FT; only once a second, not every poll. Running the two
// extra commands each cycle tripled the poll time on a slow K3, so the
// frequency — already read from IF above — only surfaced every couple of
// seconds. Between checks the last FR/FT result stands.
if time.Since(k.splitCheckAt) >= time.Second {
k.splitCheckAt = time.Now()
k.splitCached, k.splitVFOCached = false, ""
rxv, rxOK := k.askVFO("FR;")
txv, txOK := k.askVFO("FT;")
if rxOK && txOK && rxv != txv {
k.splitCached = true
// Trust FR over IF for which VFO is in use: they were asked in the
// same breath, and a rig that leaves the split bit empty may be just
// as vague about the VFO field. The block below picks the TRANSMIT
// VFO as "the other one" from f.VFO, so leaving them disagreeing
// would read the transmit frequency off the wrong dial.
if rxv == "A" || rxv == "B" {
k.splitVFOCached = rxv
}
}
}
if k.splitCached {
split = true
if k.splitVFOCached != "" {
k.curVFO, s.Vfo, f.VFO = k.splitVFOCached, k.splitVFOCached, k.splitVFOCached
}
}
}
f.Split = split
if f.Split {
// The transmit VFO is the other one. Read it rather than assume, and fall
// back to simplex if it cannot be read: a wrong TX frequency is written
// into the log, which is worse than showing no split at all.
other := "FB;"
if f.VFO == "B" {
other = "FA;"
}
if r, err := k.ask(other); err == nil {
if hz, ok := parseKenwoodFreq(r, strings.TrimSuffix(other, ";")); ok && hz > 0 && hz != rx {
tx = hz
}
}
}
if tx != rx {
s.FreqHz = tx // ADIF: FREQ is the TRANSMIT frequency
s.RxFreqHz = rx
s.Split = true
} else {
s.FreqHz = rx
}
k.curFreq = s.FreqHz
if s.Split {
k.curRXFreq = s.RxFreqHz
}
k.lastState = s // cache for the transmit window, where we can't poll
return s, nil
}
// SetFrequency tunes the VFO the operator is actually on — writing FA blind is
// what makes a display disagree with the radio when they are on B.
func (k *Kenwood) SetFrequency(hz int64) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
if hz <= 0 || hz > 99_999_999_999 {
return fmt.Errorf("kenwood: frequency %d out of the 11-digit CAT range", hz)
}
cmd := "FA"
if k.curVFO == "B" {
cmd = "FB"
}
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
}
func (k *Kenwood) SetMode(mode string) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
// Honour the operator's DATA-mode preference for a digital mode. No single
// mode digit fits every rig, so the operator picks: keep the rig's mode, use
// MD6 (K3/K4 DATA), or fall through to the default USB from kenwoodModeDigit.
if isKenwoodDataMode(mode) {
// An Elecraft always uses DATA A for a soundcard digital mode — that's what
// the "Elecraft" backend means, so the dataMode option doesn't apply.
if k.elecraft {
if err := k.write("MD6;"); err != nil {
return err
}
return k.write("DT0;")
}
switch k.dataMode {
case "keep":
return nil // leave whatever data mode the operator set on the rig
case "data":
// Elecraft K3/K4 DATA mode (MD6) PLUS the DATA-A submode (DT0). MD6 alone
// can leave the rig in an FSK/PSK data submode (from a prior RTTY/PSK
// session), where FT8 keys the transmitter but the rear sound-card audio
// never modulates — "transmits but nothing comes out". DT0 forces DATA A,
// the audio submode FT8 needs. This is the Elecraft path (the option is
// labelled K3/K4); a real K3/K4 answers DT with nothing. A plain Kenwood
// has no DT command and would "?;" it — but that's a misconfiguration
// (pick USB there), and IF is never latched so the link self-recovers.
if err := k.write("MD6;"); err != nil {
return err
}
return k.write("DT0;")
}
}
d := kenwoodModeDigit(mode, k.curFreq)
if d == 0 {
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
}
return k.write(fmt.Sprintf("MD%c;", d))
}
// SetDataMode configures how a DATA/digital mode is set: "usb" (default), "data"
// (MD6, Elecraft K3/K4) or "keep" (don't change the rig's mode).
func (k *Kenwood) SetDataMode(m string) {
k.mu.Lock()
k.dataMode = strings.ToLower(strings.TrimSpace(m))
k.mu.Unlock()
}
// SetElecraft marks this client as driving a K3/K4 (the "Elecraft" backend), so
// digital modes always land in DATA A (MD6+DT0). Set before Start.
func (k *Kenwood) SetElecraft(v bool) {
k.mu.Lock()
k.elecraft = v
k.mu.Unlock()
}
// isKenwoodDataMode reports whether a mode name is a soundcard/data mode (FT8,
// PSK, JT…) rather than a voice/CW/RTTY mode the rig sets natively.
func isKenwoodDataMode(mode string) bool {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "", "LSB", "USB", "SSB", "CW", "CW-R", "CWR", "FM", "NFM", "AM",
"RTTY", "FSK", "RTTY-R", "FSK-R":
return false
}
return true
}
func (k *Kenwood) SetPTT(on bool) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
k.tx = on
if on {
k.txAt = time.Now()
return k.write("TX;")
}
return k.write("RX;")
}
func (k *Kenwood) write(cmd string) error {
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
traceText("kenwood", "TX", cmd)
_, err := k.port.Write([]byte(cmd))
return err
}
// drain reads and throws away whatever the rig has already sent, until it stays
// quiet for one read timeout or the budget runs out.
func (k *Kenwood) drain(budget time.Duration) {
if k.port == nil {
return
}
tmp := make([]byte, 256)
deadline := time.Now().Add(budget)
for time.Now().Before(deadline) {
n, err := k.port.Read(tmp)
if err != nil || n == 0 {
return // an error here is not interesting: we are throwing this away
}
traceText("kenwood", "RX-drop", string(tmp[:n]))
}
}
// ask sends a query and returns the reply belonging to THAT command. Anything
// else on the wire is discarded: a stray frame parsed as a frequency reads as
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
// for no reason (found the hard way on the Yaesu backend).
func (k *Kenwood) ask(cmd string) (string, error) {
want := cmdPrefix(cmd)
if k.unsupported[want] {
return "", fmt.Errorf("kenwood: %s is not supported by this rig", want)
}
if err := k.write(cmd); err != nil {
return "", err
}
tmp := make([]byte, 64)
deadline := time.Now().Add(600 * time.Millisecond)
for {
// Consume whatever is already buffered BEFORE reading more: the answer
// may have arrived attached to the previous one.
for {
i := bytes.IndexByte(k.rx, ';')
if i < 0 {
break
}
frame := string(k.rx[:i+1])
k.rx = k.rx[i+1:]
traceText("kenwood", "RX", frame)
if frame == "?;" {
// Sentinel-wrapped so the poll loop can tell "the rig said no" apart
// from a serial fault. The two look identical as plain errors and must
// not be handled the same way: one means try again in a moment, the
// other means the link is gone.
// IF; and ID; are universal on Kenwood/Elecraft — a "?;" to them is a
// transient "busy" (typically mid-transmit, or a menu open on the rig),
// NOT "unsupported". Latching them off would blind the poll loop for
// good and read as "lost the rig". Only remember the OPTIONAL commands
// (FR/FT/…) so the poll loop stops paying a 600 ms timeout for those.
if want != "IF" && want != "ID" {
k.unsupported[want] = true
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
}
return "", fmt.Errorf("kenwood: %s rejected: %w", want, errRigRejected)
}
if strings.HasPrefix(frame, want) {
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)
}
n, err := k.port.Read(tmp)
if err != nil {
return "", err
}
if n > 0 {
k.rx = append(k.rx, tmp[:n]...)
}
// n == 0 is a read timeout, not silence for good: the rig may still be
// composing its answer.
}
}
// ── Frame parsing ──────────────────────────────────────────────────────────
// kenwoodIF is what the 38-character IF status frame carries.
type kenwoodIF struct {
FreqHz int64
Mode byte
VFO string // "A" or "B"
Split bool
// TX is parsed even though RigState has no PTT field: it is one character of
// the same frame, and having it here means a future "transmitting" indicator
// costs no extra round trip.
TX bool
}
// parseKenwoodIF reads the TS-2000/TS-590 status frame. Its layout — the same
// one internal/catemu emits — is:
//
// IF | freq(11) | step(4) | RIT(±5) | RIT/XIT/bank(3) | mem(2) | rx-tx(1) |
// mode(1) | VFO(1) | scan(1) | split(1) | tone(1) | tone#(2) | shift(1) | ;
//
// Fields are read by POSITION, so the length is checked first: a short frame
// means a truncated read, and indexing into it would panic or, worse, silently
// yield a wrong frequency.
func parseKenwoodIF(reply string) (kenwoodIF, bool) {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(r, "IF") || len(r) < 38 {
return kenwoodIF{}, false
}
hz, err := strconv.ParseInt(strings.TrimSpace(r[2:13]), 10, 64)
if err != nil || hz <= 0 {
return kenwoodIF{}, false
}
out := kenwoodIF{FreqHz: hz, Mode: r[29], VFO: "A", TX: r[28] == '1', Split: r[32] == '1'}
if r[30] == '1' {
out.VFO = "B"
}
return out, true
}
// parseKenwoodFreq reads an FA/FB reply — ELEVEN digits on Kenwood, where Yaesu
// uses nine. The prefix is checked so an FB reply is never accepted as FA.
func parseKenwoodFreq(reply, prefix string) (int64, bool) {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(r, prefix) {
return 0, false
}
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
if digits == "" {
return 0, false
}
hz, err := strconv.ParseInt(digits, 10, 64)
if err != nil || hz <= 0 {
return 0, false
}
return hz, true
}
// ── Modes ──────────────────────────────────────────────────────────────────
// kenwoodModeDigit maps an ADIF mode to the Kenwood digit. The sideband follows
// the frequency by worldwide convention — a backend that puts USB on 40 m makes
// every SSB QSO in the log wrong.
func kenwoodModeDigit(mode string, hz int64) byte {
m := strings.ToUpper(strings.TrimSpace(mode))
switch m {
case "":
return 0
case "LSB":
return '1'
case "USB":
return '2'
case "CW":
return '3'
case "CW-R", "CWR":
return '7'
case "FM":
return '4'
case "AM":
return '5'
case "RTTY", "FSK":
return '6'
case "RTTY-R", "FSK-R":
return '9'
case "SSB":
if hz > 0 && hz < 10_000_000 {
return '1'
}
return '2'
}
// Any other digital mode (FT8, PSK, JT…) → USB on every band. There is NO single
// mode digit that is right for both families: a K3 wants DATA (MD6), a TS-590SG
// wants USB-DATA (MD2 + its DATA modifier), and MD6 on the TS-590SG is FSK/RTTY
// — wrong for FT8. USB is the safe common base (the reversed-sideband "DATA REV"
// only came from the old LSB-below-10-MHz rule). A rig-specific DATA mode is a
// follow-up that needs the model, not a blind default.
return '2'
}
// kenwoodModeToADIF turns the rig's mode digit into what the log records.
// Digital modes are indistinguishable from SSB over CAT — the rig only knows
// it is on USB — so the operator's configured digital mode is used, exactly as
// the other backends do.
//
// md6IsData says whether MD6 means DATA on THIS rig — true for an Elecraft
// (the K3/K4 backend, and the "MD6" data-mode option that targets it), false
// for a plain Kenwood. See below.
func kenwoodModeToADIF(d byte, digital string, md6IsData bool) string {
switch d {
case '1':
return "LSB"
case '2':
return "USB"
case '3', '7':
return "CW"
case '4':
return "FM"
case '5':
return "AM"
case '6', '9':
// One digit, two meanings. On a Kenwood MD6/MD9 is FSK/FSK-R; on an
// Elecraft it is DATA / DATA-REV — and DATA is precisely what SetMode
// puts a K3/K4 into for FT8. Reading it straight back as RTTY meant a K3
// running FT8 reported RTTY: the QSO was logged on the wrong mode, and
// the shared CAT link told WSJT-X/JTDX the rig sat in RTTY while they had
// asked for a data mode.
if md6IsData {
return digital
}
return "RTTY"
}
return ""
}
// kenwoodModels maps the ID reply to a name for the status bar. An unknown code
// is shown as-is rather than refused: the model name is cosmetic, and a rig that
// answers everything else must not be rejected over it.
var kenwoodModels = map[string]string{
"017": "TS-570",
"019": "TS-2000",
"020": "TS-480",
"021": "TS-590S",
// 022 reported by a real TS-990S in the field. Kenwood's documentation gives
// 024 for that radio, so both are kept: the observed value wins where they
// disagree, and neither maps to anything else.
"022": "TS-990S",
"023": "TS-590SG",
"024": "TS-990S",
"025": "TS-890S",
}
// openPort opens the serial link, or whatever dialPort provides in a test.
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
}
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
if err != nil {
return nil, err
}
// The modem lines are only touched when the operator asks for it. See
// lowerLines: both defaults break somebody's station.
if k.lowerLines {
_ = p.SetDTR(false)
_ = p.SetRTS(false)
}
return p, nil
}
// 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.
// Anything else (a rig that answers with more fields, or not at all) returns
// false, and the caller keeps whatever IF said rather than inventing a split.
func (k *Kenwood) askVFO(cmd string) (string, bool) {
r, err := k.ask(cmd)
if err != nil {
return "", false
}
want := cmdPrefix(cmd)
body := strings.TrimSuffix(strings.TrimPrefix(r, want), ";")
if body == "" {
return "", false
}
switch body[0] {
case '0':
return "A", true
case '1':
return "B", true
}
// 2 is "sub receiver" on a TS-2000 — real, but not a VFO we track. Saying
// nothing is better than mapping it onto A or B and reporting a split that
// does not exist.
return "", false
}