Nobody here owns a Kenwood, and a backend that has never completed a single exchange is a guess however carefully it was written. But this repository already contains the other half of the conversation: internal/catemu ANSWERS this dialect, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog. The test's responder replies exactly as catemu does — same FA/FB widths, same 38-character IF layout, same ID019 — so the two halves are checked against each other instead of against my reading of a manual. Connect adds a dialPort seam for this; nothing else changed in the backend. Covered: model identification, simplex on A, everything following the VFO in use when on B, tuning writing to that VFO rather than blindly to FA, split with TX and RX the right way round (ADIF FREQ is the TRANSMIT frequency — swapping them writes the wrong frequency into every logged QSO), the mode digit, and a silent port reported as not answering rather than as a connected radio. The last three are each a fault that actually reached a user through the Yaesu backend. What this does NOT prove: that a real TS-590 answers on the same timings, or fills every IF field the way its documentation says. That still needs a radio.
428 lines
13 KiB
Go
428 lines
13 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 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")
|
|
}
|
|
_, 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:]
|
|
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})
|
|
}
|