feat: native Kenwood CAT backend
A sixth CAT backend, talking to a TS-590/890/990/2000 over its serial port with
no OmniRig in between — frequency, mode, VFO, split and PTT. Elecraft K3/K4 and
the "Kenwood" setting on other radios speak the same dialect.
The protocol was not researched from scratch: internal/catemu already ANSWERS
these commands, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
The IF frame layout here is read from the same format string catemu emits, so
the two halves of the repository agree by construction — and the test caught my
own fixture being one character short, which is exactly the error that layout
invites.
Design notes worth keeping:
- IF; is the poll. One frame carries frequency, TX state, mode, VFO and split,
so simplex operation costs a single round trip; the other VFO is only asked
for when split is actually on.
- FA/FB take ELEVEN digits here where Yaesu uses nine. That is the likeliest
place to copy the Yaesu backend and be wrong by a factor of a hundred, so it
has its own test.
- Every lesson the Yaesu backend learned the hard way is built in from the
start: replies matched to the command that asked, "?;" remembered so a poll
stops paying a timeout for an unsupported command, the serial handle closed
before reopening, and a rig that answers nothing reported as absent rather
than "connected".
Untested on hardware — I have no Kenwood here. The frame parsing is covered by
tests against catemu's own format.
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
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
|
||||
|
||||
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 := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||
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",
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// The IF status frame, read by POSITION.
|
||||
//
|
||||
// One frame carries frequency, TX state, mode, VFO and split, which is why it is
|
||||
// the poll. Every field is a fixed offset, so a length check comes first: a
|
||||
// truncated read would otherwise index into the wrong characters and yield a
|
||||
// plausible-looking wrong frequency — the worst kind, because it reaches the log.
|
||||
//
|
||||
// The layout is the one internal/catemu already EMITS to satisfy an ACOM
|
||||
// amplifier, so these two halves of the repository agree by construction.
|
||||
func TestParseKenwoodIF(t *testing.T) {
|
||||
// IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) | rx/tx | mode | VFO | scan |
|
||||
// split | tone | tone#(2) | shift | ; → 38 characters
|
||||
const rx20mUSB = "IF00014250000" + "0000" + "+00000" + "000" + "00" + "0" + "2" + "0" + "0" + "0" + "0" + "00" + "0" + ";"
|
||||
if len(rx20mUSB) != 38 {
|
||||
t.Fatalf("the test's own frame is %d chars, not 38 — fix the fixture first", len(rx20mUSB))
|
||||
}
|
||||
|
||||
f, ok := parseKenwoodIF(rx20mUSB)
|
||||
if !ok {
|
||||
t.Fatalf("a valid frame was rejected: %q", rx20mUSB)
|
||||
}
|
||||
if f.FreqHz != 14250000 {
|
||||
t.Errorf("frequency = %d, want 14250000", f.FreqHz)
|
||||
}
|
||||
if f.Mode != '2' {
|
||||
t.Errorf("mode = %q, want '2' (USB)", f.Mode)
|
||||
}
|
||||
if f.VFO != "A" || f.Split || f.TX {
|
||||
t.Errorf("got VFO=%s split=%v tx=%v — want A, simplex, receiving", f.VFO, f.Split, f.TX)
|
||||
}
|
||||
|
||||
// Same frame transmitting, on VFO B, split, in CW.
|
||||
const txSplitB = "IF00007030000" + "0000" + "+00000" + "000" + "00" + "1" + "3" + "1" + "0" + "1" + "0" + "00" + "0" + ";"
|
||||
f2, ok := parseKenwoodIF(txSplitB)
|
||||
if !ok {
|
||||
t.Fatalf("valid frame rejected: %q", txSplitB)
|
||||
}
|
||||
if !f2.TX || f2.Mode != '3' || f2.VFO != "B" || !f2.Split {
|
||||
t.Errorf("got tx=%v mode=%q vfo=%s split=%v — want transmitting, CW, B, split",
|
||||
f2.TX, f2.Mode, f2.VFO, f2.Split)
|
||||
}
|
||||
|
||||
// Rejections: anything that would make position-reading unsafe.
|
||||
for _, bad := range []string{
|
||||
"",
|
||||
"IF;", // query echoed with no payload
|
||||
"IF0001425000;", // short — the trap this guards against
|
||||
"FA00014250000;", // another command's reply
|
||||
"IF0001425000x0000+00000000000203000000;", // non-numeric frequency
|
||||
} {
|
||||
if _, ok := parseKenwoodIF(bad); ok {
|
||||
t.Errorf("accepted a frame it should have refused: %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FA/FB carry ELEVEN digits on Kenwood where Yaesu uses nine — the single most
|
||||
// likely place to copy the Yaesu backend and be wrong by a factor of a hundred.
|
||||
func TestParseKenwoodFreq(t *testing.T) {
|
||||
cases := []struct {
|
||||
reply, prefix string
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{"FA00014250000;", "FA", 14250000, true},
|
||||
{"FB00007030000;", "FB", 7030000, true},
|
||||
{"FA00000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
|
||||
{"FA10368000000;", "FA", 10368000000, true}, // 3 cm
|
||||
{"FA00014250000;", "FB", 0, false}, // wrong VFO is never silently accepted
|
||||
{"FA;", "FA", 0, false},
|
||||
{"FAxxxxxxxxxxx;", "FA", 0, false},
|
||||
{"", "FA", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseKenwoodFreq(c.reply, c.prefix)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("parseKenwoodFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sideband follows the frequency by worldwide convention. A backend that
|
||||
// puts USB on 40 m makes every SSB QSO in the log wrong, which is why this is
|
||||
// pinned rather than left to the rig.
|
||||
func TestKenwoodModeDigit(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
hz int64
|
||||
want byte
|
||||
}{
|
||||
{"SSB", 7150000, '1'}, // LSB below 10 MHz
|
||||
{"SSB", 14250000, '2'}, // USB above
|
||||
{"LSB", 14250000, '1'}, // an explicit choice wins over the convention
|
||||
{"USB", 7150000, '2'},
|
||||
{"CW", 7030000, '3'},
|
||||
{"RTTY", 14080000, '6'},
|
||||
{"AM", 7150000, '5'},
|
||||
{"FM", 145000000, '4'},
|
||||
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
|
||||
{"FT8", 14074000, '2'},
|
||||
{"", 14074000, 0}, // nothing to set
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := kenwoodModeDigit(c.mode, c.hz); got != c.want {
|
||||
t.Errorf("kenwoodModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the log records for each mode digit the rig reports.
|
||||
func TestKenwoodModeToADIF(t *testing.T) {
|
||||
cases := []struct {
|
||||
d byte
|
||||
want string
|
||||
}{
|
||||
{'1', "LSB"}, {'2', "USB"},
|
||||
{'3', "CW"}, {'7', "CW"}, // CW-R is still CW in the log
|
||||
{'4', "FM"}, {'5', "AM"},
|
||||
{'6', "RTTY"}, {'9', "RTTY"}, // FSK-R likewise
|
||||
{'0', ""}, // unknown → say nothing rather than guess
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := kenwoodModeToADIF(c.d, "FT8"); got != c.want {
|
||||
t.Errorf("kenwoodModeToADIF(%q) = %q, want %q", c.d, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user