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.
132 lines
4.6 KiB
Go
132 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|