HAMLOG.online is a seventh external service: one API key, one ADIF record per QSO, immediate / delayed / on-close like the rest. They publish no API documentation, so the protocol is read from THEIR OWN client — the HAMLOG Agent (github.com/hamlogonline/Agent), which is the authoritative source short of asking them: POST https://hamlog.online/api/agent/ {"ADIFADD": {"APIKEY": k, "ADIFDATA": record}} → {"STATUS":"OK"} {"KEYSTATUS": {"APIKEY": k}} → {"STATUS":"OK","CALLSIGN":…} Success is STATUS == OK, not "no ERROR field": their failure carries ERROR and no STATUS, and reading an unknown reply — a proxy page, a maintenance notice — as an acceptance is how a contact goes missing without anyone noticing. KEYSTATUS buys something no other service here offers: the key can be checked BEFORE the first QSO, and the answer names the account. A key pasted from another callsign is caught in the settings panel rather than through a week of silent refusals. Their confirmations are also an award source now, ticked like LoTW rather than expressed through "custom". It reads the ADIF extras, not a column: the standard names a field for hamlog.EU and none for hamlog.ONLINE, and borrowing the other site's field would write a falsehood into every exported log. Three plausible key names from their own export are accepted too, so nobody has to rename a column by hand after an export. Yaesu gains antenna selection (AN), remembered per band — the antenna picked on a band comes back with it, with no table to fill in anywhere. Rigs with one socket never answer AN and never show the row; the log says which case it is. And a serial port that is refused now names its likely holder. OmniRig stays resident once activated and keeps the port of the rig configured in it, so a native backend never gets it — "Serial port busy" alone accused nobody, and an FTDX10 spent a morning being blamed for it.
409 lines
14 KiB
Go
409 lines
14 KiB
Go
package cat
|
|
|
|
import (
|
|
"fmt"
|
|
"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")
|
|
}
|
|
}
|
|
|
|
// The mode commands address the receiver the operator is ON.
|
|
//
|
|
// Reported on an FTDX101 (F4NBZ, 2026-07-29): once a spot click tuned the right
|
|
// VFO, it still changed the mode of MAIN while the operator worked from SUB — so
|
|
// the VFO in use kept its old mode and the idle one was altered. The read had the
|
|
// same fault, so the panel showed main's mode as well.
|
|
func TestYaesuModeVFOSuffix(t *testing.T) {
|
|
cases := []struct{ vfo, want string }{
|
|
{"A", "0"}, // main
|
|
{"", "0"}, // not yet read — main is the safe assumption
|
|
{"B", "1"}, // sub
|
|
{"AB", "0"}, // pair enums start with the receive VFO
|
|
}
|
|
for _, c := range cases {
|
|
y := &Yaesu{}
|
|
y.curVFO = c.vfo
|
|
if got := y.modeVFOSuffix(); got != c.want {
|
|
t.Errorf("curVFO=%q → MD%s, want MD%s", c.vfo, got, c.want)
|
|
}
|
|
}
|
|
|
|
// Spelled out as the commands actually sent, which is what the rig sees.
|
|
y := &Yaesu{}
|
|
y.curVFO = "B"
|
|
if cmd := "MD" + y.modeVFOSuffix() + "3;"; cmd != "MD13;" {
|
|
t.Errorf("setting CW on the sub receiver sends %q, want MD13;", cmd)
|
|
}
|
|
y.curVFO = "A"
|
|
if cmd := "MD" + y.modeVFOSuffix() + "3;"; cmd != "MD03;" {
|
|
t.Errorf("setting CW on main sends %q, want MD03;", cmd)
|
|
}
|
|
}
|
|
|
|
// The antenna command, as the FTDX10 reference gives it: AN + receiver + jack.
|
|
// The jack is clamped rather than trusted — a panel bug that sent AN09 would be
|
|
// answered by the rig with silence, and the operator would be left wondering
|
|
// which antenna they were on.
|
|
func TestYaesuAntennaCommand(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
in int
|
|
want string
|
|
}{
|
|
{1, "AN01;"},
|
|
{2, "AN02;"},
|
|
{3, "AN03;"},
|
|
{0, "AN01;"}, // below range → the first jack
|
|
{9, "AN03;"}, // above range → the last
|
|
{-1, "AN01;"},
|
|
} {
|
|
got := fmt.Sprintf("AN0%d;", clampInt(tc.in, 1, 3))
|
|
if got != tc.want {
|
|
t.Errorf("antenna %d → %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|