Reported from a Flex in Kenwood CAT mode: frequency read perfectly, split never appeared. Not every rig speaking this dialect fills IF's split bit. Rather than guess which column that firmware populates, ask the question that DEFINES split — is the transmit VFO a different VFO from the receive one — which is exactly what FR and FT answer, and the same rule the Yaesu backend settled on after several wrong turns. FR is also trusted over IF for which VFO is in use: they are asked in the same breath, and a rig vague about the split bit may be just as vague about the VFO field. Cost is bounded: a rig that rejects FR/FT answers "?;" once and is never asked again, so this is two short commands per poll only where it works. Both paths are tested against the emulator — split found through FR/FT with IF silent, and IF-reported split still working on a rig that refuses FR/FT without re-asking. Also extends the CAT wire trace to the Kenwood backend, ASCII quoted so an empty reply is visible as such: "" and ";" look identical unquoted, and telling them apart is the whole question when a rig half-supports a command. If this fix is not the whole story on real hardware, the trace is what will say so.
485 lines
15 KiB
Go
485 lines
15 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 (
|
|
"fmt"
|
|
"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
|
|
digital string // mode name logged for data (FT8 by default)
|
|
|
|
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"
|
|
// Commands this rig answered "?;" to — asked once, then never again.
|
|
unsupported map[string]bool
|
|
}
|
|
|
|
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 == "" {
|
|
return fmt.Errorf("kenwood: no serial port 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 {
|
|
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;")
|
|
|
|
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 = ""
|
|
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)
|
|
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")
|
|
}
|
|
raw, err := k.ask("IF;")
|
|
if err != nil {
|
|
return RigState{}, err
|
|
}
|
|
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
|
|
s.Mode = kenwoodModeToADIF(f.Mode, k.digital)
|
|
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 {
|
|
rxv, rxOK := k.askVFO("FR;")
|
|
txv, txOK := k.askVFO("FT;")
|
|
if rxOK && txOK && rxv != txv {
|
|
split = 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.
|
|
// f.VFO too, not just the reported state: the block below picks the
|
|
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
|
|
// disagreeing would read the transmit frequency off the wrong dial.
|
|
if rxv == "A" || rxv == "B" {
|
|
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
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")
|
|
}
|
|
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))
|
|
}
|
|
|
|
func (k *Kenwood) SetPTT(on bool) error {
|
|
k.mu.Lock()
|
|
defer k.mu.Unlock()
|
|
if k.port == nil {
|
|
return fmt.Errorf("kenwood: not connected")
|
|
}
|
|
if on {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
buf := make([]byte, 0, 64)
|
|
tmp := make([]byte, 64)
|
|
deadline := time.Now().Add(600 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
n, err := k.port.Read(tmp)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if n == 0 {
|
|
continue // read timeout — the rig may still be composing its answer
|
|
}
|
|
buf = append(buf, tmp[:n]...)
|
|
for {
|
|
i := strings.IndexByte(string(buf), ';')
|
|
if i < 0 {
|
|
break
|
|
}
|
|
frame := string(buf[:i+1])
|
|
buf = buf[i+1:]
|
|
traceText("kenwood", "RX", frame)
|
|
if frame == "?;" {
|
|
// The rig rejected the command. Remember it so the poll loop stops
|
|
// paying a 600 ms timeout for it on every cycle.
|
|
k.unsupported[want] = true
|
|
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
|
|
return "", fmt.Errorf("kenwood: %s rejected", want)
|
|
}
|
|
if strings.HasPrefix(frame, want) {
|
|
return frame, nil
|
|
}
|
|
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
|
}
|
|
}
|
|
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
|
}
|
|
|
|
// ── 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 rides on the data sideband, which on Kenwood is
|
|
// plain USB/LSB with the rig's DATA input selected.
|
|
if hz > 0 && hz < 10_000_000 {
|
|
return '1'
|
|
}
|
|
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.
|
|
func kenwoodModeToADIF(d byte, digital string) 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':
|
|
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",
|
|
"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()
|
|
}
|
|
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
|
}
|
|
|
|
// 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
|
|
}
|