MSHV's PTT test does nothing against the TCI sharing server. The initialisation block never carried TX_ENABLE. The document files it under unidirectional control rather than initialisation, so it was missed when the block was written from §4.1 — but its own note says it is "sent to the client when connected", and that is the point: a client that models transmit permission starts out assuming it may NOT transmit. Without it MSHV never even tries, so nothing arrives to relay and there is nothing to see at either end. Sent as true always. OpsLog is not what decides — the radio behind whichever backend is connected does, and its refusal already travels back through SetPTT into the log. TX_FREQUENCY goes with it, at connect and whenever the transmit frequency moves. It is the command a client showing "TX 14.200" reads; channel B alone left that stale. And every command a client sends is now logged. This is the only evidence there will ever be about a program on someone else's machine: "the PTT test does nothing" cannot be answered without knowing whether MSHV sent trx at all, and in what form. Cheap — TCI is event-driven, a client speaks when the operator does something — and capped at 200 lines per connection so one that does poll cannot quietly fill the log. If this was not the cause, the next report answers it in one line rather than another round of guessing.
563 lines
17 KiB
Go
563 lines
17 KiB
Go
// Package tciserver shares OpsLog's CAT link with programs that speak TCI.
|
|
//
|
|
// It is the second half of internal/rigctld, and exists for the same reason:
|
|
// Windows gives a COM port to ONE process, so the moment OpsLog talks to the
|
|
// radio directly nothing else can. rigctld answers the programs that speak
|
|
// Hamlib NET rigctl (WSJT-X, JTDX, MSHV, Log4OM); this answers the ones built
|
|
// around Expert Electronics' TCI instead — and it answers them whatever radio
|
|
// is actually connected, because it sits on the same backend-agnostic
|
|
// interface. An operator with an Icom or a Yaesu can hand a TCI-only program a
|
|
// working rig.
|
|
//
|
|
// ── The protocol ──────────────────────────────────────────────────────────
|
|
// Text commands over a WebSocket, "name:arg,arg;", the same syntax in both
|
|
// directions. On connection the server sends a block of initialisation
|
|
// commands describing the device, ending with ready; and start;. Thereafter
|
|
// either side may send a control command, and the server echoes every change
|
|
// to all connected clients so they stay in step with each other.
|
|
//
|
|
// vfo:0,0,14074000; receiver 0, channel A (RX), Hz
|
|
// vfo:0,1,14080000; channel B — the TX frequency when split is on
|
|
// modulation:0,usb; mode
|
|
// trx:0,true; PTT
|
|
// split_enable:0,true; split
|
|
// vfo:0,0; a READ: the reply is the three-argument form
|
|
//
|
|
// Written against the official TCI Protocol document (ExpertSDR3/TCI, 12
|
|
// January 2024, MIT) — the initialisation set and the argument order of every
|
|
// command below are from §4.1 and §4.2, not from guesswork about what a client
|
|
// might accept.
|
|
package tciserver
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
|
|
// package stays testable without a radio and without importing internal/cat —
|
|
// which also keeps it building on every platform.
|
|
type Rig interface {
|
|
Freq() int64 // TX frequency in Hz (ADIF sense), 0 if unknown
|
|
RxFreq() int64 // RX frequency in Hz; equals Freq when not split
|
|
Mode() string // ADIF mode (SSB, CW, FT8…)
|
|
Split() (bool, int64) // split on?, and the TX frequency
|
|
SetFreq(hz int64) error
|
|
SetMode(mode string) error
|
|
SetPTT(on bool) error
|
|
SetSplit(on bool, txHz int64) error
|
|
}
|
|
|
|
// DefaultPort is TCI's own default, which is what a client offers first.
|
|
const DefaultPort = 40001
|
|
|
|
// pollInterval is how often the rig is compared with what the clients were last
|
|
// told. TCI is an event protocol — a client is entitled to sit silent and be
|
|
// told when something moves — so this is the rate at which a knob turned on the
|
|
// radio reaches it.
|
|
const pollInterval = 250 * time.Millisecond
|
|
|
|
type Server struct {
|
|
port int
|
|
rig Rig
|
|
log func(string, ...any)
|
|
|
|
mu sync.Mutex
|
|
ln net.Listener
|
|
http *http.Server
|
|
conns map[*client]struct{}
|
|
closed bool
|
|
|
|
// last is what the clients have been told, so only changes are sent. TCI
|
|
// clients redraw on every command they receive; re-sending an unchanged
|
|
// frequency four times a second makes a VFO readout flicker and, in some
|
|
// clients, fights the operator's own tuning.
|
|
last state
|
|
}
|
|
|
|
// state is the part of the rig the clients are kept in step with.
|
|
type state struct {
|
|
rxHz int64
|
|
txHz int64
|
|
mode string
|
|
split bool
|
|
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 {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.conn == nil {
|
|
return nil // a client with no socket: the tests exercise the protocol, not the transport
|
|
}
|
|
_ = c.conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
|
|
return c.conn.WriteMessage(websocket.TextMessage, []byte(s))
|
|
}
|
|
|
|
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
|
if port <= 0 || port > 65535 {
|
|
port = DefaultPort
|
|
}
|
|
if logf == nil {
|
|
logf = func(string, ...any) {}
|
|
}
|
|
return &Server{port: port, rig: rig, log: logf, conns: map[*client]struct{}{}}
|
|
}
|
|
|
|
// Start binds the port and serves until Stop.
|
|
func (s *Server) Start() error {
|
|
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
|
if err != nil {
|
|
return fmt.Errorf("tci server: port %d: %w", s.port, err)
|
|
}
|
|
up := websocket.Upgrader{
|
|
// Any origin: the clients are desktop programs on the same machine or
|
|
// LAN, and they send whatever Origin their toolkit happens to set. This
|
|
// is the same trust boundary as the rigctl server on 4532 — a plain TCP
|
|
// port with no authentication, which is what every logger expects.
|
|
CheckOrigin: func(*http.Request) bool { return true },
|
|
}
|
|
mux := http.NewServeMux()
|
|
// Any path: clients connect to ws://host:port/ but some append a name.
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := up.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
s.log("tci server: upgrade from %s failed: %v", r.RemoteAddr, err)
|
|
return
|
|
}
|
|
s.serve(&client{conn: conn}, r.RemoteAddr)
|
|
})
|
|
srv := &http.Server{Handler: mux}
|
|
s.mu.Lock()
|
|
s.ln, s.http, s.closed = ln, srv, false
|
|
s.mu.Unlock()
|
|
go func() { _ = srv.Serve(ln) }()
|
|
go s.pushLoop()
|
|
s.log("tci server: listening on :%d", s.port)
|
|
return nil
|
|
}
|
|
|
|
// Stop closes the listener and every client.
|
|
func (s *Server) Stop() {
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.closed = true
|
|
ln, srv := s.ln, s.http
|
|
conns := make([]*client, 0, len(s.conns))
|
|
for c := range s.conns {
|
|
conns = append(conns, c)
|
|
}
|
|
s.conns = map[*client]struct{}{}
|
|
s.last = state{}
|
|
s.mu.Unlock()
|
|
for _, c := range conns {
|
|
_ = c.conn.Close()
|
|
}
|
|
if srv != nil {
|
|
_ = srv.Close()
|
|
}
|
|
if ln != nil {
|
|
_ = ln.Close()
|
|
}
|
|
s.log("tci server: stopped")
|
|
}
|
|
|
|
// Clients reports how many programs are connected — the one thing an operator
|
|
// wants to know when a client says it cannot find the rig.
|
|
func (s *Server) Clients() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.conns)
|
|
}
|
|
|
|
// serve runs one connection: the initialisation block, then commands until it
|
|
// closes.
|
|
func (s *Server) serve(c *client, remote string) {
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
_ = c.conn.Close()
|
|
return
|
|
}
|
|
s.conns[c] = struct{}{}
|
|
s.mu.Unlock()
|
|
s.log("tci server: %s connected", remote)
|
|
|
|
for _, line := range s.initBlock() {
|
|
if err := c.send(line); err != nil {
|
|
break
|
|
}
|
|
}
|
|
for {
|
|
_, data, err := c.conn.ReadMessage()
|
|
if err != nil {
|
|
break
|
|
}
|
|
// One frame may carry several ";"-terminated commands.
|
|
for _, cmd := range strings.Split(string(data), ";") {
|
|
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()
|
|
delete(s.conns, c)
|
|
s.mu.Unlock()
|
|
_ = c.conn.Close()
|
|
s.log("tci server: %s disconnected", remote)
|
|
}
|
|
|
|
// initBlock is the initialisation set from §4.1 of the protocol document, in
|
|
// the documented order, followed by the current state so a client that has just
|
|
// connected shows the right frequency instead of waiting for the first change.
|
|
//
|
|
// A client will not proceed without these: they are how it learns the device
|
|
// exists, what it can do, and that the server has finished setting up.
|
|
func (s *Server) initBlock() []string {
|
|
rx, tx, mode, split := s.read()
|
|
return []string{
|
|
"protocol:ExpertSDR3,1.9;",
|
|
"device:OpsLog;",
|
|
"receive_only:false;",
|
|
"trx_count:1;",
|
|
"channel_count:2;",
|
|
// The whole HF/VHF/UHF span OpsLog itself works over. A client uses this
|
|
// to bound its own tuning; too narrow a range and it refuses to follow the
|
|
// rig onto 2 m.
|
|
"vfo_limits:10000,470000000;",
|
|
"if_limits:-48000,48000;",
|
|
"modulations_list:am,sam,dsb,lsb,usb,cw,nfm,digl,digu;",
|
|
"ready;",
|
|
"start;",
|
|
fmt.Sprintf("vfo:0,0,%d;", rx),
|
|
fmt.Sprintf("vfo:0,1,%d;", tx),
|
|
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),
|
|
}
|
|
}
|
|
|
|
// read takes one consistent snapshot of the rig in TCI's terms: channel A is
|
|
// where we LISTEN and channel B where we transmit, which is the opposite way
|
|
// round from ADIF's RigState and the one mistake here that would make a client
|
|
// transmit on the DX's frequency.
|
|
func (s *Server) read() (rxHz, txHz int64, mode string, split bool) {
|
|
split, txHz = s.rig.Split()
|
|
rxHz = s.rig.RxFreq()
|
|
if !split {
|
|
txHz = s.rig.Freq()
|
|
if rxHz == 0 {
|
|
rxHz = txHz
|
|
}
|
|
}
|
|
if rxHz == 0 {
|
|
rxHz = s.rig.Freq()
|
|
}
|
|
if txHz == 0 {
|
|
txHz = rxHz
|
|
}
|
|
mode = adifToTCIMode(s.rig.Mode(), rxHz)
|
|
return rxHz, txHz, mode, split
|
|
}
|
|
|
|
// pushLoop tells the clients what has changed on the radio.
|
|
func (s *Server) pushLoop() {
|
|
t := time.NewTicker(pollInterval)
|
|
defer t.Stop()
|
|
for range t.C {
|
|
s.mu.Lock()
|
|
done := s.closed
|
|
s.mu.Unlock()
|
|
if done {
|
|
return
|
|
}
|
|
s.publish()
|
|
}
|
|
}
|
|
|
|
// publish sends only what moved. Returns the lines sent, for the tests.
|
|
func (s *Server) publish() []string {
|
|
rx, tx, mode, split := s.read()
|
|
cur := state{rxHz: rx, txHz: tx, mode: mode, split: split, valid: true}
|
|
|
|
s.mu.Lock()
|
|
prev := s.last
|
|
s.last = cur
|
|
s.mu.Unlock()
|
|
|
|
var lines []string
|
|
if !prev.valid || prev.rxHz != cur.rxHz {
|
|
lines = append(lines, fmt.Sprintf("vfo:0,0,%d;", cur.rxHz))
|
|
}
|
|
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))
|
|
}
|
|
if !prev.valid || prev.split != cur.split {
|
|
lines = append(lines, fmt.Sprintf("split_enable:0,%t;", cur.split))
|
|
}
|
|
for _, l := range lines {
|
|
s.broadcast(l)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (s *Server) broadcast(line string) {
|
|
s.mu.Lock()
|
|
conns := make([]*client, 0, len(s.conns))
|
|
for c := range s.conns {
|
|
conns = append(conns, c)
|
|
}
|
|
s.mu.Unlock()
|
|
for _, c := range conns {
|
|
_ = c.send(line)
|
|
}
|
|
}
|
|
|
|
// handle answers one command from a client. Returns what was sent back, which
|
|
// is "" for a command that only acts on the radio.
|
|
//
|
|
// A command that SETS something is echoed to every client, not just answered to
|
|
// the one that sent it: the protocol document is explicit that the server
|
|
// synchronises all connected clients, and two loggers that disagree about the
|
|
// frequency are worse than one that is merely slow.
|
|
func (s *Server) handle(c *client, cmd string) string {
|
|
name, args := cmd, ""
|
|
if i := strings.IndexByte(cmd, ':'); i >= 0 {
|
|
name, args = cmd[:i], cmd[i+1:]
|
|
}
|
|
f := strings.Split(args, ",")
|
|
arg := func(i int) string {
|
|
if i < len(f) {
|
|
return strings.TrimSpace(f[i])
|
|
}
|
|
return ""
|
|
}
|
|
num := func(i int) int64 {
|
|
v, _ := strconv.ParseInt(arg(i), 10, 64)
|
|
return v
|
|
}
|
|
reply := func(line string) string {
|
|
_ = c.send(line)
|
|
return line
|
|
}
|
|
rx, tx, mode, split := s.read()
|
|
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
case "vfo":
|
|
// Read form: two arguments. Set form: three.
|
|
if len(f) < 3 || arg(2) == "" {
|
|
if arg(1) == "1" {
|
|
return reply(fmt.Sprintf("vfo:0,1,%d;", tx))
|
|
}
|
|
return reply(fmt.Sprintf("vfo:0,0,%d;", rx))
|
|
}
|
|
hz := num(2)
|
|
if hz <= 0 {
|
|
return ""
|
|
}
|
|
if arg(1) == "1" {
|
|
// Channel B is the transmit frequency, and it only means anything
|
|
// with split armed. Setting it while simplex would silently move the
|
|
// rig's only VFO — the client asked to prepare a split TX frequency,
|
|
// not to QSY.
|
|
if !split {
|
|
return ""
|
|
}
|
|
if err := s.rig.SetSplit(true, hz); err != nil {
|
|
s.log("tci server: split TX %d Hz refused: %v", hz, err)
|
|
return ""
|
|
}
|
|
} else if err := s.rig.SetFreq(hz); err != nil {
|
|
s.log("tci server: tune to %d Hz refused: %v", hz, err)
|
|
return ""
|
|
}
|
|
s.broadcast(fmt.Sprintf("vfo:0,%s,%d;", orZero(arg(1)), hz))
|
|
return ""
|
|
|
|
case "modulation":
|
|
if len(f) < 2 || arg(1) == "" {
|
|
return reply(fmt.Sprintf("modulation:0,%s;", mode))
|
|
}
|
|
m := tciModeToADIF(arg(1))
|
|
if m == "" {
|
|
return ""
|
|
}
|
|
if err := s.rig.SetMode(m); err != nil {
|
|
s.log("tci server: mode %s refused: %v", m, err)
|
|
return ""
|
|
}
|
|
s.broadcast(fmt.Sprintf("modulation:0,%s;", strings.ToLower(arg(1))))
|
|
return ""
|
|
|
|
case "trx":
|
|
if len(f) < 2 || arg(1) == "" {
|
|
return reply("trx:0,false;")
|
|
}
|
|
on := strings.EqualFold(arg(1), "true")
|
|
if err := s.rig.SetPTT(on); err != nil {
|
|
s.log("tci server: PTT %v refused: %v", on, err)
|
|
return ""
|
|
}
|
|
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
|
|
return ""
|
|
|
|
case "split_enable":
|
|
if len(f) < 2 || arg(1) == "" {
|
|
return reply(fmt.Sprintf("split_enable:0,%t;", split))
|
|
}
|
|
on := strings.EqualFold(arg(1), "true")
|
|
if err := s.rig.SetSplit(on, tx); err != nil {
|
|
// The refusal is the useful part: a backend that cannot split says
|
|
// so, and the client can tell the operator instead of transmitting
|
|
// on the wrong frequency believing all is well.
|
|
s.log("tci server: split %v refused: %v", on, err)
|
|
return ""
|
|
}
|
|
s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
|
|
return ""
|
|
|
|
case "dds":
|
|
// The panorama's centre frequency. OpsLog has no panorama, so it answers
|
|
// with the receive frequency — which is where a client draws its own.
|
|
return reply(fmt.Sprintf("dds:0,%d;", rx))
|
|
|
|
case "if":
|
|
// Offset of the tuning filter inside the panorama: zero, since our "dds"
|
|
// is the receive frequency itself.
|
|
return reply("if:0,0,0;")
|
|
|
|
case "start", "stop", "ready":
|
|
return ""
|
|
|
|
default:
|
|
// Everything else — audio streams, CW macros, the E-Coder, the
|
|
// panorama's own settings — belongs to a radio, not to a CAT link.
|
|
// Silence rather than an error: a client sends these hopefully at
|
|
// connect, and a refusal it did not ask for reads as a fault.
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func orZero(s string) string {
|
|
if s == "" {
|
|
return "0"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// adifToTCIMode maps an ADIF mode to a TCI modulation.
|
|
//
|
|
// SSB carries no sideband, so it is resolved from the frequency the way every
|
|
// operator does: below 10 MHz lower, above it upper. A client told "ssb" would
|
|
// not recognise it — the modulation list is the vocabulary.
|
|
func adifToTCIMode(mode string, hz int64) string {
|
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
|
case "":
|
|
return ""
|
|
case "CW", "CWR":
|
|
return "cw"
|
|
case "USB":
|
|
return "usb"
|
|
case "LSB":
|
|
return "lsb"
|
|
case "SSB":
|
|
if hz > 0 && hz < 10_000_000 {
|
|
return "lsb"
|
|
}
|
|
return "usb"
|
|
case "AM":
|
|
return "am"
|
|
case "FM", "NFM":
|
|
return "nfm"
|
|
case "RTTY":
|
|
return "digl"
|
|
}
|
|
// Everything else is a data mode: FT8, FT4, JT65, PSK31, MSK144, VARA…
|
|
// TCI has one pair for the whole family, and the sideband follows the same
|
|
// rule the data modes themselves use — upper, but for the few HF corners
|
|
// where LSB is conventional the radio is already there.
|
|
return "digu"
|
|
}
|
|
|
|
// tciModeToADIF maps a TCI modulation back to an ADIF mode.
|
|
func tciModeToADIF(m string) string {
|
|
switch strings.ToLower(strings.TrimSpace(m)) {
|
|
case "cw":
|
|
return "CW"
|
|
case "usb":
|
|
return "USB"
|
|
case "lsb":
|
|
return "LSB"
|
|
case "am", "sam":
|
|
return "AM"
|
|
case "nfm", "fm", "wfm":
|
|
return "FM"
|
|
case "digl", "digu", "dsb", "drm":
|
|
// The data family: the mode the operator is actually running (FT8, RTTY)
|
|
// is chosen in OpsLog, and a client switching to "digital" must not
|
|
// overwrite it with a guess. DATA is the honest ADIF answer.
|
|
return "DATA"
|
|
}
|
|
return ""
|
|
}
|