diff --git a/changelog.json b/changelog.json index 7d71afd..e384b47 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,15 @@ "An entity that is a single island group now fills the IOTA reference on its own — no callbook subscription needed.", "CAT sharing can now speak TCI instead of Hamlib, so a TCI-only program reaches whatever radio OpsLog is on.", "Lookup cache: a TTL of 0 switches it off, so a callbook record you are correcting is re-read every time.", - "TCI: when the radio forbids transmitting, PTT now says so instead of doing nothing silently." + "TCI: when the radio forbids transmitting, PTT now says so instead of doing nothing silently.", + "TCI sharing: the server now announces transmit permission, without which a client such as MSHV never keys at all." ], "fr": [ "Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook.", "Le partage CAT peut désormais parler TCI au lieu de Hamlib : un logiciel TCI atteint la radio, quelle qu’elle soit.", "Cache des recherches : un TTL à 0 le désactive, pour relire à chaque fois une fiche callbook en cours de correction.", - "TCI : quand la radio interdit l’émission, le PTT le dit désormais au lieu de ne rien faire en silence." + "TCI : quand la radio interdit l’émission, le PTT le dit désormais au lieu de ne rien faire en silence.", + "Partage TCI : le serveur annonce désormais l’autorisation d’émettre, sans laquelle un client comme MSHV ne passe jamais en émission." ] }, { diff --git a/internal/tciserver/tciserver.go b/internal/tciserver/tciserver.go index e220a12..22bb6d0 100644 --- a/internal/tciserver/tciserver.go +++ b/internal/tciserver/tciserver.go @@ -91,10 +91,16 @@ type state struct { valid bool } +// clientLogCap bounds how many of one client's commands reach the log. +const clientLogCap = 200 + // client is one connected program. type client struct { conn *websocket.Conn mu sync.Mutex // one writer at a time: gorilla panics on concurrent writes + // logged counts what has been written to the log for this connection. Only + // the reader goroutine touches it, so it needs no lock of its own. + logged int } func (c *client) send(s string) error { @@ -211,9 +217,27 @@ func (s *Server) serve(c *client, remote string) { } // One frame may carry several ";"-terminated commands. for _, cmd := range strings.Split(string(data), ";") { - if cmd = strings.TrimSpace(cmd); cmd != "" { - s.handle(c, cmd) + if cmd = strings.TrimSpace(cmd); cmd == "" { + continue } + // Every command the client sends, in the log. + // + // This is the only evidence there will ever be about a program on + // someone else's machine: "MSHV's PTT test does nothing" is + // unanswerable without knowing whether MSHV sent trx at all, and if + // so in what form. Cheap, because TCI is event-driven — a client + // speaks when the operator does something, not on a timer. + // + // Capped so a client that DOES poll cannot quietly fill the + // operator's log; the cap says so once and then stays quiet. + if c.logged < clientLogCap { + c.logged++ + s.log("tci server: ← %s;", cmd) + } else if c.logged == clientLogCap { + c.logged++ + s.log("tci server: (further commands from this client are not logged)") + } + s.handle(c, cmd) } } s.mu.Lock() @@ -250,6 +274,20 @@ func (s *Server) initBlock() []string { fmt.Sprintf("modulation:0,%s;", mode), fmt.Sprintf("split_enable:0,%t;", split), "trx:0,false;", + // TRANSMIT PERMISSION, and it is not optional in practice. + // + // The document files TX_ENABLE under unidirectional control rather than + // initialisation, but its own note says it is "sent to the client when + // connected". A client that models permission — and one written for + // ExpertSDR users has every reason to — starts out assuming it may NOT + // transmit, and without this it never even tries: PTT does nothing and + // the server never sees a trx command to refuse. + // + // Always true. OpsLog is not the thing that decides: the radio behind + // whichever backend is connected does, and its refusal comes back through + // SetPTT and into the log. + "tx_enable:0,true;", + fmt.Sprintf("tx_frequency:%d;", tx), } } @@ -307,6 +345,9 @@ func (s *Server) publish() []string { } if !prev.valid || prev.txHz != cur.txHz { lines = append(lines, fmt.Sprintf("vfo:0,1,%d;", cur.txHz)) + // The transmit frequency has its own command, which is what a client + // showing "TX 14.080" reads. Channel B alone leaves that stale. + lines = append(lines, fmt.Sprintf("tx_frequency:%d;", cur.txHz)) } if (!prev.valid || prev.mode != cur.mode) && cur.mode != "" { lines = append(lines, fmt.Sprintf("modulation:0,%s;", cur.mode)) diff --git a/internal/tciserver/tciserver_test.go b/internal/tciserver/tciserver_test.go index 17e657b..5e009b5 100644 --- a/internal/tciserver/tciserver_test.go +++ b/internal/tciserver/tciserver_test.go @@ -68,6 +68,10 @@ func TestInitBlockCarriesTheDocumentedInitialisationSet(t *testing.T) { "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) @@ -183,10 +187,14 @@ func TestOnlyChangesAreSent(t *testing.T) { } 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) + 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)