Files
OpsLog/internal/tciserver/tciserver_test.go
T
rouggy bbe1b3ce80 fix(tci): a refused un-key no longer leaves the rig keyed for good
The trx handler stamped its PTT cache BEFORE commanding the radio and left
it in place when the command failed. An operator running JTDX over TCI with
an Icom on CI-V lost an un-key to a lost acknowledgement: the cache recorded
"off" regardless, and from then on every trx:0,false was dismissed as a
repeat of a state the radio had never reached. The cache is per-server, not
per-connection, so reconnecting JTDX changed nothing either -- the
transmitter stayed keyed into the amplifier, with no drive, until the radio
was switched off by hand.

The cache is now written only on success, and a failure clears "known"
outright so the next command reaches the radio whatever it is.

Second guard: releasePTT drops a PTT this server asserted when the client
disconnects, and when the server stops -- before the CAT backend goes down,
while the rig is still reachable. rigctld has had that since a K3 sat in
transmit for 29 s; the TCI server was written without it, so an operator
moving from Hamlib to TCI silently lost the protection. A later log shows
the rig keyed for 40 s across a JTDX reconnect for exactly that reason.
2026-08-17 16:23:53 +02:00

387 lines
15 KiB
Go

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
pttErr 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 {
// Refused BEFORE the state moves, like a radio that never got the frame.
if r.pttErr != nil {
return r.pttErr
}
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)
}
}
// A refused un-key must never be remembered as done.
//
// The failure an operator hit running JTDX over TCI with an Icom on CI-V: the
// rig went to transmit, the un-key was refused on a lost acknowledgement, and
// from then on NOTHING could take it out of transmit. The cache had stamped
// "off" before the radio was even commanded and kept it after the refusal, so
// every later trx:0,false was dismissed as a repeat of a state the radio had
// never reached. It is per-server, not per-connection, so reconnecting the
// client changed nothing either — the transmitter stayed keyed into the
// amplifier, with no drive, until the radio was switched off by hand.
func TestARefusedUnkeyIsNotRememberedAsDone(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "trx:0,true")
if !r.ptt {
t.Fatal("the rig was never keyed — the test would prove nothing")
}
r.pttErr = fmt.Errorf("icom: timeout waiting for response")
ask(t, s, "trx:0,false")
if !r.ptt {
t.Fatal("the fake rig un-keyed on a refusal — the test would prove nothing")
}
// The client asks again, and this time the radio answers. It MUST be told.
r.pttErr = nil
ask(t, s, "trx:0,false")
if r.ptt {
t.Error("still keyed: the refused un-key was cached as done and the retry was dropped as a repeat")
}
}
// A client that walks away mid-over must not leave the rig transmitting, and
// the release must be once-only — a second call has nothing to un-key and must
// not re-command a radio that is already receiving.
func TestReleasePTTUnkeysOnceWhenTheClientLeaves(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "trx:0,true")
s.releasePTT("client left")
if r.ptt {
t.Error("the rig is still keyed after the client left")
}
n := len(r.calls)
s.releasePTT("client left")
if len(r.calls) != n {
t.Errorf("released twice — the radio was told %v", r.calls)
}
}