package tciserver import ( "fmt" "strings" "testing" ) // fakeRig is a radio that remembers what it was told. Everything here is about // what OpsLog does with a client's command, so the rig only has to answer and // record. type fakeRig struct { freq, rxFreq int64 mode string split bool txHz int64 ptt bool splitErr error calls []string } func (r *fakeRig) Freq() int64 { return r.freq } func (r *fakeRig) RxFreq() int64 { return r.rxFreq } func (r *fakeRig) Mode() string { return r.mode } func (r *fakeRig) Split() (bool, int64) { return r.split, r.txHz } func (r *fakeRig) SetFreq(hz int64) error { r.calls = append(r.calls, fmt.Sprintf("freq=%d", hz)) r.freq, r.rxFreq = hz, hz return nil } func (r *fakeRig) SetMode(m string) error { r.calls = append(r.calls, "mode="+m) r.mode = m return nil } func (r *fakeRig) SetPTT(on bool) error { r.calls = append(r.calls, fmt.Sprintf("ptt=%v", on)) r.ptt = on return nil } func (r *fakeRig) SetSplit(on bool, txHz int64) error { if r.splitErr != nil { return r.splitErr } r.calls = append(r.calls, fmt.Sprintf("split=%v,%d", on, txHz)) r.split, r.txHz = on, txHz return nil } // srv builds a server with no listener — handle() and publish() are the whole // protocol, and neither needs a socket. func srv(r *fakeRig) *Server { return New(0, r, nil) } // A client with no connection: send() would need one, so reads are checked // through the returned line instead. This is why handle returns what it sent. func ask(t *testing.T, s *Server, cmd string) string { t.Helper() return s.handle(&client{}, cmd) } // The initialisation block is what a client needs before it will believe there // is a radio at all. Its contents come from §4.1 of the protocol document, and // a client that does not see ready; simply waits for ever. func TestInitBlockCarriesTheDocumentedInitialisationSet(t *testing.T) { s := srv(&fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}) block := strings.Join(s.initBlock(), "") for _, want := range []string{ "protocol:ExpertSDR3,", "device:", "receive_only:false;", "trx_count:1;", "channel_count:2;", "vfo_limits:", "if_limits:", "modulations_list:", "ready;", "start;", // Transmit permission. A client that models it starts out assuming it // may NOT transmit, and without this never even tries — PTT does // nothing and the server never sees a trx command at all. "tx_enable:0,true;", } { if !strings.Contains(block, want) { t.Errorf("the initialisation block is missing %q — a client would not proceed past connect", want) } } // And the current state, so a client that connects mid-session shows the // right frequency instead of waiting for the operator to touch something. if !strings.Contains(block, "vfo:0,0,14074000;") { t.Errorf("no current frequency in the block:\n%s", block) } if !strings.Contains(block, "modulation:0,usb;") { t.Errorf("no current mode in the block:\n%s", block) } } // Channel A is where we LISTEN, channel B where we transmit. Handing these to a // client the wrong way round is the one mistake here that puts a station on the // DX's own frequency, so it is pinned in both directions. func TestSplitPutsTheListeningFrequencyOnChannelA(t *testing.T) { // OpsLog's RigState is ADIF: Freq is the TRANSMIT frequency, RxFreq where we // listen. A DX transmitting on 14025 and listening up 2. r := &fakeRig{freq: 14027000, rxFreq: 14025000, mode: "CW", split: true, txHz: 14027000} s := srv(r) block := strings.Join(s.initBlock(), "") if !strings.Contains(block, "vfo:0,0,14025000;") { t.Errorf("channel A is not the receive frequency:\n%s", block) } if !strings.Contains(block, "vfo:0,1,14027000;") { t.Errorf("channel B is not the transmit frequency:\n%s", block) } if !strings.Contains(block, "split_enable:0,true;") { t.Errorf("split was not announced:\n%s", block) } } // Simplex: both channels report the one frequency, so a client reading either // gets the right answer. func TestSimplexReportsTheSameFrequencyOnBothChannels(t *testing.T) { s := srv(&fakeRig{freq: 7100000, rxFreq: 7100000, mode: "SSB"}) if got := ask(t, s, "vfo:0,0"); got != "vfo:0,0,7100000;" { t.Errorf("read of channel A = %q", got) } if got := ask(t, s, "vfo:0,1"); got != "vfo:0,1,7100000;" { t.Errorf("read of channel B = %q", got) } // 7 MHz is below 10, so SSB is lower sideband — a client told "ssb" would // not recognise it at all, the modulation list is the vocabulary. if got := ask(t, s, "modulation:0"); got != "modulation:0,lsb;" { t.Errorf("read of the mode = %q, want lsb below 10 MHz", got) } } // The client tunes the radio. func TestAClientCanTuneAndSetModeAndKey(t *testing.T) { r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"} s := srv(r) ask(t, s, "vfo:0,0,14200000") ask(t, s, "modulation:0,cw") ask(t, s, "trx:0,true") ask(t, s, "trx:0,false") want := []string{"freq=14200000", "mode=CW", "ptt=true", "ptt=false"} if strings.Join(r.calls, " ") != strings.Join(want, " ") { t.Errorf("the radio was told %v, want %v", r.calls, want) } } // Channel B is the SPLIT transmit frequency. Writing it while the rig is // simplex must not move the only VFO there is: the client asked to prepare a // transmit frequency, not to QSY — and a logger that did this on every spot // click would drag the operator off the station they were listening to. func TestWritingChannelBWhileSimplexLeavesTheRigAlone(t *testing.T) { r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"} s := srv(r) ask(t, s, "vfo:0,1,14080000") if len(r.calls) != 0 { t.Errorf("the radio was told %v — a split TX frequency moved a simplex rig", r.calls) } // With split armed it means what it says. r.split, r.txHz = true, 14074000 ask(t, s, "vfo:0,1,14080000") if len(r.calls) != 1 || r.calls[0] != "split=true,14080000" { t.Errorf("with split on the radio was told %v, want the new transmit frequency", r.calls) } } // A backend that cannot split says so, and the refusal must not be dressed up // as success: the client can then tell the operator to use Fake It, where // before it would transmit on the receive frequency believing all was well. func TestARefusedSplitIsNotAnnouncedAsDone(t *testing.T) { r := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW", splitErr: fmt.Errorf("this backend cannot split")} s := srv(r) if got := ask(t, s, "split_enable:0,true"); got != "" { t.Errorf("a refused split answered %q", got) } if r.split { t.Error("the rig was recorded as split after the backend refused") } } // Only what moved is sent. TCI clients redraw on every command they receive, so // re-sending an unchanged frequency four times a second makes a VFO readout // flicker and, in some clients, fights the operator's own tuning. func TestOnlyChangesAreSent(t *testing.T) { r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"} s := srv(r) first := s.publish() if len(first) == 0 { t.Fatal("the first pass sent nothing — a client would never learn the state") } if got := s.publish(); len(got) != 0 { t.Errorf("an unchanged radio produced %v", got) } r.freq, r.rxFreq = 14200000, 14200000 got := strings.Join(s.publish(), "") // Both channels move together on a simplex rig, and the transmit frequency // has its own command besides — a client showing "TX 14.200" reads that one, // and channel B alone leaves it stale. for _, want := range []string{"vfo:0,0,14200000;", "vfo:0,1,14200000;", "tx_frequency:14200000;"} { if !strings.Contains(got, want) { t.Errorf("after a QSY the clients were not told %q — got %q", want, got) } } if got := s.publish(); len(got) != 0 { t.Errorf("the QSY was re-sent: %v", got) } } // Modes travel both ways, and the data family is the interesting half: a client // switching to "digital" must not overwrite the mode the operator chose in // OpsLog with a guess at which data mode it was. func TestModeMapping(t *testing.T) { up := []struct { adif string hz int64 want string }{ {"CW", 14025000, "cw"}, {"SSB", 14200000, "usb"}, {"SSB", 7100000, "lsb"}, {"USB", 7100000, "usb"}, // an explicit sideband is never second-guessed {"FT8", 14074000, "digu"}, {"RTTY", 14080000, "digl"}, {"AM", 3700000, "am"}, {"FM", 145500000, "nfm"}, {"", 14074000, ""}, } for _, c := range up { if got := adifToTCIMode(c.adif, c.hz); got != c.want { t.Errorf("adifToTCIMode(%q, %d) = %q, want %q", c.adif, c.hz, got, c.want) } } down := map[string]string{ "cw": "CW", "usb": "USB", "lsb": "LSB", "am": "AM", "sam": "AM", "nfm": "FM", "digu": "DATA", "digl": "DATA", "": "", } for in, want := range down { if got := tciModeToADIF(in); got != want { t.Errorf("tciModeToADIF(%q) = %q, want %q", in, got, want) } } } // A command for something OpsLog is not — audio streams, CW macros, the // panorama's settings — is met with silence rather than an error. A client // sends these hopefully at connect, and a refusal it did not ask for reads as a // fault with the rig. func TestUnknownCommandsAreQuiet(t *testing.T) { s := srv(&fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}) for _, cmd := range []string{"audio_start:0", "cw_macros_speed:25", "rx_filter_band:0,-2700,-100", "iq_start:0"} { if got := ask(t, s, cmd); got != "" { t.Errorf("%q answered %q", cmd, got) } } } // Split, with the client sending the two commands in the order it prefers. // // A client working split has to say two things: where to transmit, and that // split is on. Nothing obliges it to say them in that order, and the frequency // arriving first is the dangerous case: discarding it and then arming split // leaves the transmit VFO on whatever it held — the RECEIVE frequency — so the // operator transmits straight onto the DX while their software shows exactly // what they asked for. func TestSplitIsArmedOnTheFrequencyTheClientGaveWhicheverOrderItCame(t *testing.T) { // Frequency first, then split — the order that used to lose the frequency. r := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW"} s := srv(r) ask(t, s, "vfo:0,1,14027000") ask(t, s, "split_enable:0,true") if len(r.calls) != 1 || r.calls[0] != "split=true,14027000" { t.Errorf("frequency first: the radio was told %v, want split armed on 14027000", r.calls) } // Split first, then the frequency — the order that always worked. r2 := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW"} s2 := srv(r2) ask(t, s2, "split_enable:0,true") ask(t, s2, "vfo:0,1,14027000") if len(r2.calls) == 0 || r2.calls[len(r2.calls)-1] != "split=true,14027000" { t.Errorf("split first: the radio was told %v, want it to end on 14027000", r2.calls) } } // "Fake It" uses no split at all: the client shifts the DIAL at the start of // transmit and shifts it back at the end. All it needs is channel A, and it // must reach the radio both ways. func TestFakeItIsJustTheDialMoving(t *testing.T) { r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"} s := srv(r) ask(t, s, "vfo:0,0,14075300") // up for the over ask(t, s, "vfo:0,0,14074000") // and back want := []string{"freq=14075300", "freq=14074000"} if strings.Join(r.calls, " ") != strings.Join(want, " ") { t.Errorf("the radio was told %v, want %v", r.calls, want) } } // A client in Fake It still says "split off" to be sure. The rig is already // simplex, so there is nothing to do — and saying so beats asking a backend // that may not be able to set split at all. // // This is what broke JTDX through the rigctl server: an error answered to a // request that was already true, read as rig control failing, and the // transmission abandoned a second into the frame. func TestSayingSplitOffWhenAlreadySimplexTouchesNothing(t *testing.T) { r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB", splitErr: fmt.Errorf("this backend cannot split")} s := srv(r) ask(t, s, "split_enable:0,false") if len(r.calls) != 0 { t.Errorf("the radio was told %v for a state it was already in", r.calls) } } // A client restating PTT must not re-command the radio. Through the rigctl // server, one sent set_ptt 0 sixteen times a second and the Flex's own transmit // request was overwritten between two of them inside a millisecond. func TestRepeatedPTTIsNotResentToTheRadio(t *testing.T) { r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"} s := srv(r) for i := 0; i < 5; i++ { ask(t, s, "trx:0,false") } if len(r.calls) != 1 || r.calls[0] != "ptt=false" { // The FIRST one always goes through: there is no knowing how the radio // was left. t.Errorf("the radio was told %v, want one unkey and no repeats", r.calls) } ask(t, s, "trx:0,true") ask(t, s, "trx:0,true") if len(r.calls) != 2 || r.calls[1] != "ptt=true" { t.Errorf("the radio was told %v, want the change through and the repeat dropped", r.calls) } }