feat(cat): share the rig over TCI as well as Hamlib — one or the other

internal/rigctld exists because Windows gives a COM port to ONE process: the
moment OpsLog talks to the radio natively, nothing else can. It answers the
programs that speak Hamlib NET rigctl. This answers the ones built around Expert
Electronics' TCI instead — and it answers them whatever radio is connected,
because it sits on the same backend-agnostic Rig interface. An operator with an
Icom or a Yaesu can now hand a TCI-only program a working rig.

One server or the other, never both. They answer the same questions about the
same radio, nothing speaks both, and a second listener is only a second thing to
go wrong.

Written against the official TCI Protocol document (ExpertSDR3/TCI, 12 January
2024, MIT — downloaded and read, not recalled): the initialisation set of §4.1
in its documented order, and the argument order of every command from §4.2. A
client will not proceed past connect without that block, which is why it is
written out in full rather than stubbed.

The one dangerous detail is the VFO mapping. TCI's channel A is where you
LISTEN and channel B where you transmit — the opposite way round from OpsLog's
RigState, which follows ADIF. Getting that backwards would put a station on the
DX's own frequency, so it is pinned in both directions by a test, and RxFreq was
added to the adapter rather than inferred.

Writing channel B while the rig is simplex is ignored: the client asked to
prepare a split transmit frequency, not to QSY, and a logger doing that on every
spot click would drag the operator off the station they were listening to. A
backend that cannot split still refuses out loud.

Only changes are pushed. TCI clients redraw on each command, so re-sending an
unchanged frequency four times a second makes a VFO readout flicker and fights
the operator's own tuning.

Nine tests, no socket needed — the protocol is the decision, not the transport.
This commit is contained in:
2026-08-17 02:35:21 +02:00
parent a8bca8c316
commit 72ec3cbb97
7 changed files with 882 additions and 19 deletions
+242
View File
@@ -0,0 +1,242 @@
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)
}
}
}