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") } }