WSJT-X "Fake It" shifts the dial for the duration of each over and puts it back afterwards, but the restore is conditional: it reads the frequency back and only moves the dial if it disagrees with where it believes the radio should be. That read lands inside the window where this backend stops polling on purpose. A Kenwood answers "?;" to IF; while it is transmitting, and treating that as a fault used to drop the shared CAT link entirely, so the cached state answers instead. SetFrequency wrote the command to the rig without touching that cache — so mid-over the cache still described the pre-over dial, WSJT-X read its own receive frequency back, concluded there was nothing to restore, and the radio stayed on the transmit frequency. Every later over started from there. Reported from a session where the dial stuck at 7075500 after a full FT8 over while a bare TUNE, which never sets a frequency, worked fine. Only simplex updates the cache. Under split, FreqHz is the transmit frequency while the write lands on whichever VFO the operator is on, and guessing which side moved would put a wrong number in front of the operator — a stale one survives until the next poll. The test reproduces the reported sequence and fails without the fix with the same frequency the log shows.
923 lines
34 KiB
Go
923 lines
34 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 OTHER VFO is the one IF did not report. 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;"
|
|
}
|
|
var otherHz int64
|
|
if r, err := k.ask(other); err == nil {
|
|
if hz, ok := parseKenwoodFreq(r, strings.TrimSuffix(other, ";")); ok && hz > 0 && hz != rx {
|
|
otherHz = hz
|
|
}
|
|
}
|
|
if otherHz > 0 {
|
|
// WHICH of the two is the transmit frequency depends on whether the rig
|
|
// is transmitting RIGHT NOW.
|
|
//
|
|
// IF reports the VFO "in use", and in split that is the RECEIVE VFO on
|
|
// receive and the TRANSMIT VFO on transmit. The code took it as the
|
|
// receive VFO always, so the moment the operator keyed up the two
|
|
// frequencies swapped: correct on receive, reversed on transmit, which
|
|
// is exactly how it was reported from a TS-590 on USB.
|
|
//
|
|
// It matters beyond the display. FreqHz is what a QSO is logged on, and
|
|
// a contact made in split would have gone into the log on the DX's
|
|
// frequency instead of the operator's.
|
|
if f.TX {
|
|
tx, rx = rx, otherHz
|
|
} else {
|
|
tx = otherHz
|
|
}
|
|
}
|
|
}
|
|
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"
|
|
}
|
|
if err := k.write(fmt.Sprintf("%s%011d;", cmd, hz)); err != nil {
|
|
return err
|
|
}
|
|
// Remember what we just commanded.
|
|
//
|
|
// While PTT is held the poll is skipped and State() hands back lastState — the
|
|
// rig answers "?;" to IF; mid-transmission, and reading that as a fault used
|
|
// to drop the whole link. But a frequency SET during that window then went
|
|
// unrecorded, so the cache kept describing the dial as it was before.
|
|
//
|
|
// WSJT-X's "Fake It" is exactly that sequence: move the dial, key, transmit,
|
|
// and afterwards put it back. Polling during the over, it was told the rig was
|
|
// still on the receive frequency — so there was nothing to put back, and the
|
|
// dial stayed on the transmit frequency for good. Every following over
|
|
// started from there, which is the drift that was reported.
|
|
//
|
|
// Only simplex is updated here. Under split, FreqHz means the transmit
|
|
// frequency while this write lands on whichever VFO the operator is on, and
|
|
// guessing which side moved would be worse than a stale value the next poll
|
|
// corrects on its own.
|
|
if !k.lastState.Split {
|
|
k.curFreq = hz
|
|
k.lastState.FreqHz = hz
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// SetSplit arms or clears split, and when arming puts txHz on the transmit VFO.
|
|
//
|
|
// Both halves in one call on purpose. WSJT-X sends "split on, VFO B" and "VFO B
|
|
// to 14075300" as two commands, and honouring only the first is worse than
|
|
// honouring neither: split would arm on whatever VFO B happened to hold, so the
|
|
// operator transmits somewhere they never chose while the software reports
|
|
// exactly what they asked for. Nothing is armed here until the frequency is on
|
|
// the dial.
|
|
//
|
|
// FR selects the receive VFO, FT the transmit one — the same pair the poll loop
|
|
// already reads to detect split, so this writes what State() knows how to read.
|
|
func (k *Kenwood) SetSplit(on bool, txHz int64) error {
|
|
k.mu.Lock()
|
|
defer k.mu.Unlock()
|
|
if k.port == nil {
|
|
return fmt.Errorf("kenwood: not connected")
|
|
}
|
|
if !on {
|
|
// Transmit follows receive again. FR is left alone: which VFO the operator
|
|
// listens on is theirs to choose, and clearing split should not move them.
|
|
return k.write("FT0;")
|
|
}
|
|
if txHz <= 0 || txHz > 99_999_999_999 {
|
|
return fmt.Errorf("kenwood: split TX frequency %d out of the 11-digit CAT range", txHz)
|
|
}
|
|
// The transmit dial FIRST, then arm. Arming first would transmit on the old
|
|
// contents of VFO B for however long the next command takes to arrive — brief,
|
|
// but on the wrong frequency, and this runs the instant before a transmission.
|
|
if err := k.write(fmt.Sprintf("FB%011d;", txHz)); err != nil {
|
|
return err
|
|
}
|
|
if err := k.write("FR0;"); err != nil { // receive on A
|
|
return err
|
|
}
|
|
return k.write("FT1;") // transmit on B
|
|
}
|
|
|
|
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
|
|
}
|