Reported on an FTDX101: with RX and TX moved together to the sub VFO, OpsLog showed split — and took the MAIN frequency as the transmit one, which would log the wrong frequency. ST is a bare flag. It says "split" without saying which VFO transmits, and this rig raises it whenever the transmit VFO is the sub one, whether or not the operator is also listening there. FT names the transmit VFO, so where the receive VFO is known as well (FR), split is derived from the pair: they differ or they do not. That is a fact about the rig's state rather than a flag whose meaning varies by model. The fix is therefore in the probe ORDER, not in the reading: FT is asked first when FR answered, and ST stays the fallback for rigs that have neither. A test pins the order and both halves of the trap — that FT gets this case right, and that ST alone gets it wrong.
350 lines
12 KiB
Go
350 lines
12 KiB
Go
package cat
|
|
|
|
import "testing"
|
|
|
|
func TestParseYaesuFreq(t *testing.T) {
|
|
cases := []struct {
|
|
reply, prefix string
|
|
want int64
|
|
ok bool
|
|
}{
|
|
{"FA014074000;", "FA", 14074000, true},
|
|
{"FB007100000;", "FB", 7100000, true},
|
|
{"FA000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
|
|
{"FA010368000000;", "FA", 10368000000, true},
|
|
{"FB;", "FB", 0, false}, // query echoed back with no value
|
|
{"FA014074000;", "FB", 0, false}, // wrong VFO — never silently accepted
|
|
{"", "FA", 0, false},
|
|
{"FAxxxxxxxxx;", "FA", 0, false},
|
|
}
|
|
for _, c := range cases {
|
|
got, ok := parseYaesuFreq(c.reply, c.prefix)
|
|
if got != c.want || ok != c.ok {
|
|
t.Errorf("parseYaesuFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The split rules. Getting these wrong writes a WRONG TX frequency into the log,
|
|
// which is why an ambiguous state resolves to "not split" rather than to a
|
|
// guess — the same principle the OmniRig backend arrived at the hard way.
|
|
func TestResolveYaesuVFOs(t *testing.T) {
|
|
const a, b = 14074000, 14100000
|
|
cases := []struct {
|
|
name string
|
|
fa, fb int64
|
|
vfo string
|
|
split bool
|
|
wantTX, wantRX int64
|
|
wantSplit bool
|
|
}{
|
|
{"simplex on A", a, b, "A", false, a, 0, false},
|
|
{"simplex on B", a, b, "B", false, b, 0, false},
|
|
{"split, listening on A → TX on B", a, b, "A", true, b, a, true},
|
|
{"split, listening on B → TX on A", a, b, "B", true, a, b, true},
|
|
{"split flag but the other VFO is unread", a, 0, "A", true, a, 0, false},
|
|
{"split flag but both VFOs identical", a, a, "A", true, a, 0, false},
|
|
}
|
|
for _, c := range cases {
|
|
tx, rx, sp := resolveYaesuVFOs(c.fa, c.fb, c.vfo, c.split)
|
|
if tx != c.wantTX || rx != c.wantRX || sp != c.wantSplit {
|
|
t.Errorf("%s: got tx=%d rx=%d split=%v — want tx=%d rx=%d split=%v",
|
|
c.name, tx, rx, sp, c.wantTX, c.wantRX, c.wantSplit)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ST and FT say different things, and reading them alike inverted the split
|
|
// display on an FTDX101 (F4NBZ, 2026-07-29): the panel showed split ON with the
|
|
// radio OFF and the reverse.
|
|
//
|
|
// ST is a split FLAG — ST1 means split, whatever VFO is in use.
|
|
// FT names the TX VFO — FT0 = transmit on A, FT1 = transmit on B.
|
|
//
|
|
// Split is on when the rig transmits on a DIFFERENT VFO from the one it listens
|
|
// to, so FT has to be compared with the current VFO. That is why the same model
|
|
// behaved correctly for one operator and backwards for another: one was on MAIN,
|
|
// the other on SUB.
|
|
func TestYaesuSplitReply(t *testing.T) {
|
|
cases := []struct {
|
|
reply, cmd, vfo string
|
|
want bool
|
|
}{
|
|
// The flag is absolute.
|
|
{"ST1;", "ST", "A", true},
|
|
{"ST0;", "ST", "A", false},
|
|
{"ST1;", "ST", "B", true},
|
|
{"ST0;", "ST", "B", false},
|
|
|
|
// Listening on A: transmit on B is split, transmit on A is not.
|
|
{"FT1;", "FT", "A", true},
|
|
{"FT0;", "FT", "A", false},
|
|
// Listening on B: exactly the opposite — the reported inversion.
|
|
{"FT0;", "FT", "B", true},
|
|
{"FT1;", "FT", "B", false},
|
|
// The pair enums the Yaesus also report start with the listening VFO.
|
|
{"FT0;", "FT", "BA", true},
|
|
{"FT1;", "FT", "AB", true},
|
|
|
|
{"ST1;", "FT", "A", false}, // reply for the other command — not accepted
|
|
{"ST", "ST", "A", false}, // truncated
|
|
{"", "ST", "A", false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := yaesuSplitFromReply(c.reply, c.cmd, c.vfo); got != c.want {
|
|
t.Errorf("yaesuSplitFromReply(%q, %q, vfo=%q) = %v, want %v",
|
|
c.reply, c.cmd, c.vfo, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The sideband follows the frequency, worldwide convention — a CAT backend that
|
|
// puts USB on 40 m makes every SSB QSO wrong.
|
|
func TestYaesuModeDigit(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'}, // explicit wins over the convention
|
|
{"USB", 7150000, '2'},
|
|
{"CW", 7030000, '3'},
|
|
{"RTTY", 14080000, '6'},
|
|
{"AM", 7150000, '5'},
|
|
{"FM", 145000000, '4'},
|
|
{"FT8", 7074000, '8'}, // DATA-LSB
|
|
{"FT8", 14074000, 'C'}, // DATA-USB
|
|
{"JS8", 14078000, 'C'}, // any unknown digital rides on DATA
|
|
{"", 14074000, 0}, // nothing to set
|
|
}
|
|
for _, c := range cases {
|
|
if got := yaesuModeDigit(c.mode, c.hz); got != c.want {
|
|
t.Errorf("yaesuModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A reply belongs to the command that asked for it.
|
|
//
|
|
// Without this, a CW macro knocked the CAT link over: KY produces no reply, so
|
|
// the poll loop's next FA; collected a leftover frame, failed to parse it as a
|
|
// frequency, and the Manager treated that as "lost the rig" and reconnected —
|
|
// the CAT dropping for a few seconds on every macro click.
|
|
func TestYaesuCmdPrefix(t *testing.T) {
|
|
cases := []struct{ cmd, want string }{
|
|
{"FA;", "FA"},
|
|
{"FB;", "FB"},
|
|
{"MD0;", "MD"},
|
|
{"KY;", "KY"},
|
|
{"KY CQ TEST;", "KY"},
|
|
{"SM0;", "SM"},
|
|
{"RM4;", "RM"},
|
|
{"AG0;", "AG"},
|
|
{"TX;", "TX"},
|
|
{"", ""},
|
|
{";", ""},
|
|
}
|
|
for _, c := range cases {
|
|
if got := cmdPrefix(c.cmd); got != c.want {
|
|
t.Errorf("cmdPrefix(%q) = %q, want %q", c.cmd, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Writing split is as asymmetric as reading it.
|
|
//
|
|
// ST takes the state directly. FT sets which VFO TRANSMITS, so "split on" means
|
|
// transmit on the OTHER VFO from the one being listened to. Sending FT1 for "on"
|
|
// regardless is right only on VFO A — on SUB it would CLEAR the split it was
|
|
// asked to set, which is the same inversion that showed on the FTDX101 panel.
|
|
func TestYaesuSplitCommand(t *testing.T) {
|
|
cases := []struct {
|
|
cmd, vfo string
|
|
on bool
|
|
want string
|
|
}{
|
|
{"ST", "A", true, "ST1;"},
|
|
{"ST", "B", true, "ST1;"}, // the flag does not care which VFO
|
|
{"ST", "B", false, "ST0;"},
|
|
|
|
// Listening on A: split means transmit on B.
|
|
{"FT", "A", true, "FT1;"},
|
|
{"FT", "A", false, "FT0;"},
|
|
// Listening on B: split means transmit on A — the reverse.
|
|
{"FT", "B", true, "FT0;"},
|
|
{"FT", "B", false, "FT1;"},
|
|
// Pair enums start with the listening VFO.
|
|
{"FT", "BA", true, "FT0;"},
|
|
{"FT", "AB", true, "FT1;"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := yaesuSplitCommand(c.cmd, c.vfo, c.on); got != c.want {
|
|
t.Errorf("yaesuSplitCommand(%q, vfo=%q, on=%v) = %q, want %q",
|
|
c.cmd, c.vfo, c.on, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Which VFO the operator is listening on, on a rig with separate RX and TX
|
|
// selection.
|
|
//
|
|
// Reported on an FTDX101 (2026-07-29): moving RX alone to SUB displayed VFO B
|
|
// correctly, but moving BOTH RX and TX to SUB displayed VFO A — the operator was
|
|
// entirely on B and OpsLog showed the other one. FR reports the receive VFO; VS
|
|
// does not answer that question on this rig.
|
|
//
|
|
// With FR read correctly the split follows too, since split is "transmit VFO
|
|
// differs from receive VFO".
|
|
func TestYaesuReceiveVFOAndSplit(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fr, ft string // replies
|
|
wantVFO string
|
|
wantSplit bool
|
|
}{
|
|
{"everything on main", "FR0;", "FT0;", "A", false},
|
|
{"RX on sub, TX still on main — split", "FR1;", "FT0;", "B", true},
|
|
{"RX and TX both on sub — NOT split", "FR1;", "FT1;", "B", false},
|
|
{"RX on main, TX on sub — split", "FR0;", "FT1;", "A", true},
|
|
}
|
|
for _, c := range cases {
|
|
vfo := "A"
|
|
if len(c.fr) >= 3 && c.fr[2] == '1' {
|
|
vfo = "B"
|
|
}
|
|
if vfo != c.wantVFO {
|
|
t.Errorf("%s: receive VFO = %s, want %s", c.name, vfo, c.wantVFO)
|
|
}
|
|
if got := yaesuSplitFromReply(c.ft, "FT", vfo); got != c.wantSplit {
|
|
t.Errorf("%s: split = %v, want %v", c.name, got, c.wantSplit)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The state digit of a reply, when the parameter is not one character.
|
|
//
|
|
// An FTDX101 answers "FR01;" where an FTDX10 answers "FR0;". The state is the
|
|
// FIRST digit on both — the second is a separate parameter.
|
|
//
|
|
// This test asserted the opposite for one evening. Reading the LAST digit turned
|
|
// "FR01" into SUB, so an operator with RX and TX on MAIN saw the main frequency
|
|
// freeze and a spot click tune VFO B (F4NBZ, 2026-07-29). The fault it was meant
|
|
// to fix — "SUB shows MAIN" — came from reading VS, not from this digit, and the
|
|
// FR probe alone had already fixed it.
|
|
func TestYaesuStateDigit(t *testing.T) {
|
|
cases := []struct {
|
|
reply, cmd string
|
|
want byte
|
|
}{
|
|
{"FR0;", "FR", '0'}, // FTDX10 form
|
|
{"FR1;", "FR", '1'},
|
|
{"FR01;", "FR", '0'}, // FTDX101 form: MAIN — the reported bug read this as SUB
|
|
{"FR11;", "FR", '1'}, // …and this is SUB
|
|
{"ST1;", "ST", '1'},
|
|
{"FT0;", "FT", '0'},
|
|
{"VS1;", "VS", '1'},
|
|
{"FR1", "FR", '1'}, // terminator already stripped
|
|
|
|
{"ST1;", "FR", 0}, // another command's reply is never accepted
|
|
{"FR;", "FR", 0}, // query echoed with no value
|
|
{"FRx;", "FR", 0}, // not a digit
|
|
{"", "FR", 0},
|
|
}
|
|
for _, c := range cases {
|
|
if got := yaesuStateDigit(c.reply, c.cmd); got != c.want {
|
|
t.Errorf("yaesuStateDigit(%q, %q) = %q, want %q", c.reply, c.cmd, got, c.want)
|
|
}
|
|
}
|
|
|
|
// End to end, both directions of the reported fault:
|
|
if d := yaesuStateDigit("FR01;", "FR"); d != '0' {
|
|
t.Fatalf("FR01 read as %q — the operator is on MAIN and must be seen there", d)
|
|
}
|
|
if d := yaesuStateDigit("FR11;", "FR"); d != '1' {
|
|
t.Fatalf("FR11 read as %q — the operator is on SUB", d)
|
|
}
|
|
}
|
|
|
|
// A spot click tunes the VFO the operator is ON, and the display reads that same
|
|
// VFO. Both follow from the receive-VFO digit, which is why it is pinned here in
|
|
// the operator's terms rather than only as a byte.
|
|
//
|
|
// Reported both ways round on an FTDX101 (F4NBZ, 2026-07-29): with RX and TX on
|
|
// SUB everything worked, and with them on MAIN the frequency froze and a spot
|
|
// clicked tuned the sub VFO.
|
|
func TestYaesuActiveVFOFollowsReceiveVFO(t *testing.T) {
|
|
// Mirrors ReadState's choice of VFO and SetFrequency's choice of command.
|
|
activeVFO := func(frReply string) string {
|
|
if yaesuStateDigit(frReply, "FR") == '1' {
|
|
return "B"
|
|
}
|
|
return "A"
|
|
}
|
|
tuneCmd := func(vfo string) string {
|
|
if vfo == "B" {
|
|
return "FB" // sub
|
|
}
|
|
return "FA" // main
|
|
}
|
|
|
|
cases := []struct {
|
|
name, fr, wantVFO, wantCmd string
|
|
}{
|
|
{"RX and TX on MAIN (FTDX101 two-digit reply)", "FR01;", "A", "FA"},
|
|
{"RX and TX on SUB (FTDX101)", "FR11;", "B", "FB"},
|
|
{"MAIN on a one-digit rig", "FR0;", "A", "FA"},
|
|
{"SUB on a one-digit rig", "FR1;", "B", "FB"},
|
|
}
|
|
for _, c := range cases {
|
|
vfo := activeVFO(c.fr)
|
|
if vfo != c.wantVFO {
|
|
t.Errorf("%s: active VFO = %s, want %s", c.name, vfo, c.wantVFO)
|
|
}
|
|
if cmd := tuneCmd(vfo); cmd != c.wantCmd {
|
|
t.Errorf("%s: a spot click would write %s, want %s", c.name, cmd, c.wantCmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Which split command to ASK, given what else the rig answers.
|
|
//
|
|
// ST is a bare flag and its meaning varies: an FTDX101 with RX and TX both on
|
|
// SUB reports ST1, which is plain simplex on the sub VFO, and OpsLog showed
|
|
// split with the main frequency as the transmit one (F4NBZ, 2026-07-29).
|
|
//
|
|
// FT names the transmit VFO. Where the receive VFO is known too (FR), split is
|
|
// derived from the pair — they differ or they do not — which is a fact about the
|
|
// rig rather than a flag to be interpreted. So FT is preferred when FR answered.
|
|
func TestYaesuSplitProbeOrder(t *testing.T) {
|
|
order := func(rxVFOCmd string) []string {
|
|
if rxVFOCmd != "" {
|
|
return []string{"FT", "ST"}
|
|
}
|
|
return []string{"ST", "FT"}
|
|
}
|
|
|
|
if got := order("FR")[0]; got != "FT" {
|
|
t.Errorf("with FR available the first split probe is %q, want FT", got)
|
|
}
|
|
if got := order("")[0]; got != "ST" {
|
|
t.Errorf("without FR the first split probe is %q, want ST", got)
|
|
}
|
|
// Both remain available: a rig answering only one must still be handled.
|
|
for _, rx := range []string{"FR", ""} {
|
|
if len(order(rx)) != 2 {
|
|
t.Errorf("rxVFOCmd=%q: both probes must remain, got %v", rx, order(rx))
|
|
}
|
|
}
|
|
|
|
// The case that was reported, end to end: RX and TX both on sub is NOT split.
|
|
if yaesuSplitFromReply("FT1;", "FT", "B") {
|
|
t.Error("RX and TX both on SUB reported as split")
|
|
}
|
|
// And the flag alone would have got it wrong, which is why the order changed.
|
|
if !yaesuSplitFromReply("ST1;", "ST", "B") {
|
|
t.Error("ST1 is a flag and reads as split whatever the VFO — that is the trap")
|
|
}
|
|
}
|