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;", } { 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 := s.publish() if len(got) != 2 || !strings.Contains(strings.Join(got, ""), "14200000") { // Both channels move together on a simplex rig, and both are reported. t.Errorf("after a QSY: %v", 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) } } }